01. What Evaluation Is
To evaluate a fleet of language model graphs, you need more than a single accuracy number. Each graph is a multi-stage pipeline with independent failure points. A single score hides where things break.
Take a generation graph. It needs two metrics. One checks that every factual claim in the output is grounded in the provided context. Another verifies the output actually answers the input prompt. A high accuracy could still hide a hallucinated claim that sounds relevant but is not true.
Now consider an extraction graph. It also requires two metrics. A stricter faithfulness check ensures no hallucinated extractions appear. A contextual precision metric measures whether the top ranked retrieved nodes are actually relevant to the query. A single accuracy number would never reveal a mismatch here.
Each stage has its own failure mode. Faithfulness failures, relevance failures, precision failures. You cannot diagnose them with one number. By splitting evaluation into separate metrics, you pinpoint exactly where the pipeline goes wrong. That is the trade-off: more work setting up metrics, but much clearer signal. Without that, you are flying blind.
Generate it: A generation graph needs two metrics: one checks every claim is grounded in context, the other checks the output answers the i____ prompt. (cue: i____; answer: input)
Generate it: An extraction graph uses a stricter faithfulness check plus a contextual p________ metric for whether top ranked nodes are relevant. (cue: p________; answer: precision)
Ask yourself: Why does a single accuracy number leave you flying blind across a multi-stage pipeline?
Recall check (try before reading the answer):
What are the two metrics a generation graph needs? Answer: One checks every factual claim is grounded in the provided context; the other verifies the output answers the input prompt.
Why can a high accuracy score still be misleading? Answer: A high accuracy could still hide a hallucinated claim that sounds relevant but is not true.
What do you gain by splitting evaluation into separate metrics, and what does it cost? Answer: You pinpoint exactly where the pipeline goes wrong; the trade-off is more work setting up metrics.
Evaluation uses separate metrics for generation and extraction graphs to pinpoint failure modes.
def make_generation_metrics(judge=None, *, with_faithfulness=True, strict=False):
from deepeval.metrics import AnswerRelevancyMetric, FaithfulnessMetric
j = judge if judge is not None else make_judge()
faith_threshold = THRESHOLD_STRICT if strict else THRESHOLD_DEFAULT
metrics = []
if with_faithfulness:
metrics.append(FaithfulnessMetric(threshold=faith_threshold, model=j, include_reason=True))
metrics.append(AnswerRelevancyMetric(threshold=THRESHOLD_DEFAULT, model=j, include_reason=True))
return metrics
def make_extraction_metrics(judge=None):
from deepeval.metrics import ContextualPrecisionMetric, FaithfulnessMetric
j = judge if judge is not None else make_judge()
return [
FaithfulnessMetric(threshold=THRESHOLD_STRICT, model=j, include_reason=True),
ContextualPrecisionMetric(threshold=THRESHOLD_DEFAULT, model=j, include_reason=True),
]
Imagine you're a quality inspector at a busy kitchen, and the head chef has dozens of different recipes running at once. You can't just taste everything and give one overall "good enough" rating — a soup might be perfectly salty but still have a burned bottom, and a steak could be cooked right but missing the side dish. That's exactly the problem this subsystem solves for language‑model "recipes" (called graphs). It's a per‑graph checklist that catches where a specific mistake happens, so a single high score never hides a broken part.
For a generation graph (like a chatbot that writes an email), the inspector runs two independent checks. First, the FaithfulnessMetric verifies every factual claim in the output is backed by the source documents — no invented facts. Second, the AnswerRelevancyMetric confirms the output actually addresses what the user asked. If you only used one average score, a brilliant but hallucinated claim could still pass. For an extraction graph (which pulls key facts from a document), the inspector is even stricter: a FaithfulnessMetric at THRESHOLD_STRICT locks down that nothing extracted is made up, and a ContextualPrecisionMetric ensures the extracted pieces are the most relevant ones, not just any random correct fact.
The non‑obvious rule is that each graph type has its own required pair of metrics, and if a graph has no LLM calls at all (a pure data‑routing graph), it's exempt — you don't run a taste test on a conveyor belt. Without this subsystem, a single accuracy number would let a smooth‑talking model pass while quietly inventing false claims, and a beginner would feel the result: a cold email that references a product feature that never existed, or an extraction that lists a wrong contact name — trust broken instantly.
The evaluation subsystem operationalized in run_evals.py follows a precisely ordered pipeline. First, it discovers every *_graph.py module in the agentic_sales package. For each graph it runs static analysis—checking against compiled regex patterns that match LLM call sites such as llm.ainvoke or llm.bind—and classifies the graph into one of four buckets: covered, no-llm, delegates, or exempt. Graphs that call an LLM and are not in the explicit DELEGATES or exempt lists must be covered, meaning at least one test file imports the graph module. Next, for every covered graph, the system runs the full DeepEval suite built by make_generation_metrics, which by default includes a FaithfulnessMetric (threshold THRESHOLD_STRICT or THRESHOLD_DEFAULT) and an AnswerRelevancyMetric. If any graph that requires an eval is found in the missing bucket, or if the aggregate DeepEval accuracy falls below the ≥0.80 bar, the gate fails and the script returns exit code 1, printing the specific problem (e.g., “MISSING eval: <graph>” or the activation-pipeline failure messages from _check_activation_pipeline_coverage).
The core invariant this design preserves is the Eval-First coverage guarantee: every language-model graph that produces an output subject to human or automated judgment must have at least one associated evaluation test, and that test must clear the ≥0.80 accuracy threshold. This invariant is enforced mechanically, not through documentation or developer discipline. The _ACTIVATION_PIPELINE_GRAPHS and _UK_UNIVERSITIES_GATED_GRAPHS registries strengthen it by requiring that even graphs that delegate their LLM work (like vertical_activation_graph) are explicitly tracked—removing a graph or letting its eval coverage silently rot causes a loud failure in the gate.
The key trade-off is between automated static classification and a hand-maintained manifest. The obvious rejected alternative is a manually curated list of every graph and its eval status. That approach would be brittle: any addition, renaming, or removal of a graph would require a human to update the list, and a stale manifest would silently mask missing evals. The chosen design instead derives coverage from source-code structure—regex detection of LLM calls provides a baseline, and explicit DELEGATES and exempt dictionaries serve as escape hatches for exceptional cases. The cost avoided is the ongoing maintenance burden and the risk of silent regressions that a static list introduces. The downside is that the regex heuristic may misclassify a graph that builds prompts but never directly calls an LLM method, so the escape hatches are necessary—and each escape must carry a written reason to prevent abuse.
A concrete failure mode: a developer introduces pipeline_orchestrator_graph.py that internally calls llm.invoke to generate a final summary but forgets to write a test_pipeline_orchestrator_graph.py. When run_evals.py executes, it detects the LLM call via the regex pattern re.compile(r"\bllm\.(a?invoke|a?stream|bind\w*)\b"), classifies the graph as needing an eval, and finds no test imports it—so it lands in the missing bucket. The operator sees the following signal printed to stderr: “MISSING eval: pipeline_orchestrator_graph (calls an LLM or is unclassified — add tests/test_pipeline_orchestrator_graph.py or DELEGATES entry)”, followed by a non-zero exit code that breaks the CI pipeline. This forces the developer to either write the required DeepEval test (which must subsequently pass the ≥0.80 bar) or explicitly add an exemption with a justification in the DELEGATES or exempt dictionary.
Q — What metrics does the evaluation subsystem use for a generation graph, and why two separate ones instead of a single accuracy number?
A — make_generation_metrics returns both FaithfulnessMetric and AnswerRelevancyMetric. The faithfulness metric verifies every factual claim in the output is grounded in the provided retrieval_context; the relevancy metric checks that the output addresses the input prompt. A single score could hide a hallucinated claim that sounds relevant but is not true.
Follow-up — How does the subsystem handle generation graphs that have no retrieval context to be faithful to?
A — make_generation_metrics accepts with_faithfulness=False; for such free-generation graphs (e.g., email_compose_graph) it returns only AnswerRelevancyMetric.
Weak answer misses — The with_faithfulness parameter and its default True; the exact case of free-generation graphs is explicitly handled.
Q — Why does make_extraction_metrics use a strict faithfulness threshold while generation uses a default threshold?
A — Extraction graphs demand that every extracted item be backed by text in the source document—no hallucinated extractions are tolerable. make_extraction_metrics sets FaithfulnessMetric(threshold=THRESHOLD_STRICT), whereas make_generation_metrics uses THRESHOLD_DEFAULT unless the caller passes strict=True.
Follow-up — What is the second metric in extraction, and what failure mode does it catch?
A — ContextualPrecisionMetric, which measures that the extracted/retrieved nodes ranked highest are actually relevant to the query (precision over recall), catching cases where top-ranked items are irrelevant even if each individually is faithful.
Weak answer misses — The presence of ContextualPrecisionMetric alongside faithfulness; the distinction between precision (relevance ranking) and faithfulness (grounding).
Q — The FaithfulnessMetric in make_generation_metrics can be made strict. How does a caller activate that, and what use case justifies it?
A — By passing strict=True to make_generation_metrics, the threshold is raised to THRESHOLD_STRICT. The function docstring says this is used for outreach/hallucination-critical generation, where even a minor fabrication could damage trust.
Follow-up — Does strict mode affect the AnswerRelevancyMetric threshold?
A — No, AnswerRelevancyMetric always uses THRESHOLD_DEFAULT regardless of the strict flag; only the faithfulness threshold changes.
Weak answer misses — The asymmetry: only FaithfulnessMetric gets the strict threshold; the relevancy metric threshold remains default.
Q — Why was a design with two separate metrics (faithfulness and relevancy) chosen over a single composite score?
A — The two metrics catch independent failure modes. A high composite score could still hide a hallucinated claim if the output appears relevant. make_generation_metrics keeps them separate so that a failing faithfulness score forces investigation of grounding, regardless of relevancy score.
Follow-up — How does the subsystem ensure these metrics are correctly scored? What must be provided in the test case?
A — Both metrics require LLMTestCase(retrieval_context=[...]) populated with source document chunks. The OUTREACH_FAITHFULNESS_PROBE example shows how the retrieval_context is the only allowed source for claims.
Weak answer misses — The requirement that LLMTestCase must have retrieval_context populated; without it the metrics cannot run.
Failure: Coverage misclassification — an LLM-calling graph is incorrectly marked as no-llm due to a heuristic miss
- Trigger: A
*_graph.pyfile uses a call site that is not captured by the regex detection in the_classify_graphfunction. The heuristic looks formake_llm,.invoke,.ainvoke, orllm_*helpers. A wrapper, a custom method, or a LangChain-compatible pattern (e.g.,model.invoke(...)but not caught) would evade detection. - Guard: The coverage classification is derived solely from the source via that regex; no guard exists to catch false negatives. The source states “the coverage classification is derived from the source, not a hand‑kept list” — there is no validation that an LLM call actually occurs.
- Posture: Fail‑soft — the graph is exempt from the eval requirement and never judged, but the gate continues to run for other graphs.
- Operator signal: The coverage report (printed by
--coverage) shows the graph underno-llm. No automatic alert is raised. - Recovery: Manual — the operator must inspect the graph, notice the misclassification, and either fix the heuristic or add an explicit exemption entry.
Failure: A graph claimed as covered has no functional DeepEval test file, causing the O69 gate to skip it or fail without the operator noticing
- Trigger: A test file (e.g.,
tests/test_*_deepeval.py) exists and imports the graph module, so the coverage scan marks the graph ascovered. But the test file may be empty, broken, or raise an exception at import, so the O69 evaluation loop never produces a result for that graph. - Guard: The
run_evals.pycoverage scan only checks for the existence of an import; it does not verify that the eval actually runs. The O69 gate itself iterates over the results list — if a graph is missing fromresults, it is silently ignored (the code buildsresultsfrom a separate iteration, not shown in the excerpt, but the gate logic assumes all graphs are represented). The_check_uk_universities_coveragefunction does checkcov.gated, but only for a subset of graphs. - Posture: Fail‑soft — the graph’s quality is never measured, but the gate may still pass if no other hard failures occur.
- Operator signal: The O69 summary prints
total_graphs: N— if the count is lower than expected, an operator might suspect omission, but no explicit error is raised. - Recovery: Manual — the operator must check which graphs are missing and fix the test file.
Failure: Floating‑point imprecision causes a graph that is exactly at the 0.80 threshold to be incorrectly classified as failing
- Trigger: The computation
std_acc >= AGGREGATE_PASSwhereAGGREGATE_PASS = 0.80is a float literal. If the actual accuracy is exactly 0.80—but due to binary representation the calculatedstd_accends up as0.7999999...— the comparison returnsFalse, producing a false hard failure. - Guard: There is no tolerance or epsilon check; the code uses plain
>=. The source does not show any guard against floating‑point rounding. - Posture: Fail‑hard — the graph is added to
std_failuresand the gate may fail if enough such false positives accumulate. - Operator signal: The log line would read e.g.
[O69] FAIL std: my_graph std=80.0% < 80.0%. The value would show80.0%due to the:.1%format, masking the sub‑threshold actual. - Recovery: None automatic — the operator would need to re‑run with higher precision or adjust the threshold to include a small epsilon.
Failure: The static list _UK_UNIVERSITIES_GATED_GRAPHS omits a newly added auto‑sending graph
- Trigger: A developer adds a new
*_graph.pythat auto‑sends extractions (e.g., to a university partner), but forgets to add the module name to the list inside_check_uk_universities_coverage. The function only checks graphs in that list. - Guard: The list is defined statically in the source; there is no automatic detection of all auto‑sending graphs. The coverage‑map classification does not check for the uk‑universities requirement.
- Posture: Fail‑soft — the new graph remains ungated, meaning its extraction quality can drop below 0.80 without any alert. The gate still passes because the function only reports on the listed graphs.
- Operator signal: The coverage report (if run with
--coverage) will show the new graph’s coverage status, but no specific warning about the uk‑universities gate. The uk‑universities gate printsPASSeven when the graph is missing from the list. - Recovery: Manual — an operator must add the graph to
_UK_UNIVERSITIES_GATED_GRAPHSand write the correspondingtest_*_deepeval.py.
Failure: The DeepEval suite for a graph contains a bug that always returns a perfect score, making the O69 gate pass despite poor extraction quality
- Trigger: The test file’s evaluation logic is flawed (e.g., the metric always returns
Trueor the accuracy is computed incorrectly). The O69 gate simply reads the pre‑computed results from the suite and does not re‑evaluate the underlying judgments. - Guard:
run_evals.pyhas no guard that verifies the correctness of the evaluation results themselves. The gate trusts whatever numbers are produced. - Posture: Fail‑soft — the graph is incorrectly reported as passing both tiers, and the quality gate is effectively disabled for that graph.
- Operator signal: The O69 output shows e.g.
std=100.0% deep=100.0%— an unusually high value might raise suspicion, but no automatic alert. - Recovery: Manual — the operator must review the test logic after noticing anomalies or after a production failure. No automatic detection exists.
What do you need instead of a single accuracy number to diagnose where a multi-stage pipeline fails?
Show answer
separate metrics
From the research: Retrieval practice / testing effect — Testing (quizzing) boosts classroom learning: A systematic and meta-analytic review (2021)