Tasks: User Story 4 — Built-in Deterministic + User-Supplied Code Evaluators (P2)¶
Input: /docs/product-specs/032-multi-turn-test-cases/ — spec.md (US4, §Complexity Tracking), data-model.md §5 (EvaluationMetric extensions) + §6 (CodeMetric) + §7a (MetricResult.kind), contracts/code-grader-contract.md.
Approach: Test-Driven Development.
Story Goal: Add two built-in deterministic evaluators (equality, numeric) implemented against the existing BaseEvaluator protocol (async _evaluate_impl returning dict, with PARAM_SPEC), plus a new top-level CodeMetric union variant for user-supplied Python graders referenced by import path module:callable. Graders are resolved at config load time (never runtime), receive a frozen GraderContext, and return a GraderResult (or shortcut). Exceptions in a grader fail only that turn's metric unless fail_on_error=true, in which case the executor raises a local TestCaseFatal that breaks the turn loop for that one test case.
Independent Test:
- type: standard, metric: numeric, absolute_tolerance: 0.01 on ground_truth: "0.14136" vs agent response "14.14%" → passes.
- type: code, grader: "my_benchmarks:numeric_equal" → fixture grader passes on "25587"/"25,587"/"25587.0", fails on "25000".
Depends on: US1 foundational models — Turn, TurnResult, TestResult.turns, MetricResult.kind='code' Literal widening (already landed by US1 T016), and TurnResult.grader_details (already reserved by US1 T012). Independent of US2/US3, but naturally composes with them.
Phase 1: Setup¶
- T001 Re-read contracts/code-grader-contract.md (all sections) and data-model.md §5–§7a. Confirm US1 T012 reserved
TurnResult.grader_detailsand US1 T016 widenedMetricResult.kindto include"code"(no schema widening needed in US4). - T002 [P] Add
tests/fixtures/graders/directory with a modulemy_benchmarks.py(matching the spec Independent Test pathmy_benchmarks:numeric_equal— do NOT useconvfinqa_fixture_gradersnaming) exposing: numeric_equal(ctx) -> boolraises_value_error(ctx) -> GraderResult(always raises)returns_float(ctx) -> float(returns0.75)returns_grader_result(ctx) -> GraderResult(fully-formed result)returns_dict(ctx) -> dict(returns a non-standard shape; covers T041)
Use pytest's pythonpath in pyproject.toml (or tests/conftest.py) rather than a per-subdir conftest.py — this matches the existing repo convention (no tests/fixtures/*/conftest.py pattern exists today).
Phase 2: Built-in deterministic evaluators (TDD)¶
Tests first¶
- T003 [P] [US4] Write
tests/unit/lib/evaluators/test_deterministic.py::test_equality_strict_default—EqualityEvaluatorwith all flags off:"Yes"vs"yes"→ fail;"Yes"vs"Yes"→ pass. - T004 [P] [US4] Add
test_deterministic.py::test_equality_case_insensitive— flag on →"Yes"vs"yes."still fails (trailing punctuation); addstrip_punctuation=true→ passes. - T005 [P] [US4] Add
test_deterministic.py::test_equality_strip_whitespace—" hello world "vs"hello world"passes withstrip_whitespace=true. - T006 [P] [US4] Add
test_deterministic.py::test_numeric_default_tolerance— no tolerances set →abs_tol=1e-6,rel_tol=0.0. Test three cases: diff = 1e-7 ("1.0000001"vs"1") → pass (within tolerance); diff = 0.1 ("1.1"vs"1") → fail; boundary diff = 1e-6 ("1.000001"vs"1") → pass (FR-018 semantics areabs(actual - expected) <= absolute_tolerance; inclusive on the boundary). - T007 [P] [US4] Add
test_deterministic.py::test_numeric_absolute_tolerance—abs_tol=0.01,"0.145"vs"0.14136"→ fail;"0.142"vs"0.14136"→ pass. - T008 [P] [US4] Add
test_deterministic.py::test_numeric_relative_tolerance—rel_tol=0.01, actual within 1% of expected → pass. - T009 [P] [US4] Add
test_deterministic.py::test_numeric_accept_percent—"14.14%"parsed as0.1414; without flag → evaluator fails to parse and returns a dict withpassed=Falseand anerrorkey. - T010 [P] [US4] Add
test_deterministic.py::test_numeric_accept_thousands_separators—"206,588"parses only when flag is on. - T011 [P] [US4] Add
test_deterministic.py::test_numeric_non_numeric_inputs—"abc"vs"5"→ dict withpassed=Falseanderrornaming the parse failure; does not raise. - T012 [P] [US4] Write
tests/unit/lib/test_runner/test_executor_deterministic_kind.py::test_equality_and_numeric_produce_metric_result_with_kind_standard— run the executor over a minimal agent config with onetype: standard, metric: equalitymetric; assert the producedMetricResult.kind == "standard"(data-model.md §7a) andscore in [0.0, 1.0]. This is an executor-level test, not an evaluator-unit test (evaluators return dicts; the executor builds theMetricResultenvelope atexecutor.py:866-884). - T013 [P] [US4] Add 15-row acceptance matrix (SC-008) as a parameterized test in
test_deterministic.pycovering: exact strings, case/whitespace/punct, int vs float, percent, thousands, abs vs rel tolerance, negative tolerance boundary (rejected at model level — see T015), and at least onediff == toleranceboundary case → all produce expected pass/fail.
Implementation¶
- T014 [US4] Create
src/holodeck/lib/evaluators/deterministic.pywithEqualityEvaluator(BaseEvaluator)andNumericEvaluator(BaseEvaluator). Each class: - Declares
PARAM_SPEC: ClassVar[ParamSpec] = ParamSpec(required=frozenset({EvalParam.RESPONSE, EvalParam.GROUND_TRUTH}))(mirrorsBLEUEvaluatoratnlp_metrics.py:78-167). - Implements
async def _evaluate_impl(self, **kwargs) -> dict[str, Any]returning{"equality": 0.0 | 1.0, "passed": bool, "error": str | None}(or{"numeric": ...}) — keyed by metric name soresult.get(metric_name, ...)atexecutor.py:854reads it correctly. - Never raises from
_evaluate_impl; parse failures surface via theerrorkey andpassed=False. - Reuses
_normalize/_try_parse_numberhelpers from US3'stool_arg_matcher.pywhen US3 is landed; if US3 is not yet merged, define private helpers indeterministic.pyand leave a# TODO(US3): migrate to shared helpersmarker. TheMetricResultenvelope is built by the executor, not by the evaluator. - T015 [US4] Extend
src/holodeck/models/evaluation.pyEvaluationMetric: - Allow
metric: Literal["bleu","rouge","meteor","equality","numeric"]. Breaking-change caveat:EvaluationMetric.metricis currentlystr(free-form). Narrowing to aLiteralrejects any pre-existing non-listed name; audit existing fixtures/user configs for stragglers before merging. Add a migration note to the PR description. - Add
case_insensitive,strip_whitespace,strip_punctuation,absolute_tolerance,relative_tolerance,accept_percent,accept_thousands_separatorsas optional fields with data-model.md §5 defaults. - Add a validator that: (a) logs a warning (does not error) if
modelis set onequality/numeric(no LLM is used); (b) rejects negative tolerances. - T016 [US4] Extend
_create_evaluatorsinsrc/holodeck/lib/test_runner/executor.py(currently lines ~386–513) with two new branches:Evaluators register under their metric-name key soelif metric_name == "equality": self.evaluators["equality"] = EqualityEvaluator( case_insensitive=metric_config.case_insensitive, strip_whitespace=metric_config.strip_whitespace, strip_punctuation=metric_config.strip_punctuation, ) elif metric_name == "numeric": self.evaluators["numeric"] = NumericEvaluator( absolute_tolerance=metric_config.absolute_tolerance, relative_tolerance=metric_config.relative_tolerance, accept_percent=metric_config.accept_percent, accept_thousands_separators=metric_config.accept_thousands_separators, )result.get(metric_name, result.get("score", 0.0))atexecutor.py:854finds the score. No new dispatch helper needed.
Phase 3: (removed — MetricResult.kind widening moved to US1 T016)¶
Retained as a placeholder for task-ID continuity with previous revisions.
- T017 [US4] Verify-only — confirm US1 T016 landed the
MetricResult.kindLiteral extension and the_metric_kind()signature update (data-model.md §7a). No code change here; readsrc/holodeck/models/test_result.py:46andexecutor.py:92-100to confirm.
Phase 4: CodeMetric + grader contract (TDD)¶
Tests first¶
- T021 [P] [US4] Write
tests/unit/models/test_code_metric.py::test_grader_path_format—grader: "my_pkg.mod:fn"parses;"bad-path"(missing:) raisesConfigError; double-colon raises. - T022 [P] [US4] Add
test_code_metric.py::test_load_time_import_failure_is_config_error—grader: "nonexistent.module:fn"raisesConfigErroratCodeMetricconstruction with the test-case / turn-index / underlyingModuleNotFoundErrormessage. - T023 [P] [US4] Add
test_code_metric.py::test_load_time_attribute_error— module exists but the callable doesn't →ConfigError. - T024 [P] [US4] Add
test_code_metric.py::test_non_callable_rejected—graderpoints to a module-level constant →ConfigError. - T025 [P] [US4] Add
test_code_metric.py::test_resolved_callable_cached_on_instance— usemock.patch.object(importlib, "import_module", wraps=importlib.import_module)to count invocations; assertimport_moduleis called exactly once across 10 accesses ofmetric.resolved_callable. Avoids thesys.modulescache being the only thing exercised. - T026 [P] [US4] Add
test_code_metric.py::test_defaults—enabled=True,fail_on_error=False,threshold=None,namefalls back to callable name. - T027 [P] [US4] Add
test_code_metric.py::test_metric_type_union_accepts_code_variant—MetricTypediscriminated union parses{type: "code", grader: "mod:fn"}asCodeMetric.
Implementation¶
- T028 [US4] Add
CodeMetrictosrc/holodeck/models/evaluation.pyper data-model.md §6: discriminatortype: Literal["code"],grader: strvalidated against^[\w.]+:[\w_]+$, optionalthreshold,enabled,fail_on_error,name. - T029 [US4] In
CodeMetric.model_validator(mode="after"), callimportlib.import_module(module),getattr(module, callable_name), assert callable, and cache the callable on a private attr. RaiseConfigErrorwith the full context (test-case name, turn index if available, grader path, underlying exception class+msg) on any failure. - T030 [US4] Update the
MetricTypeunion insrc/holodeck/models/evaluation.pytoAnnotated[Union[EvaluationMetric, GEvalMetric, RAGMetric, CodeMetric], Field(discriminator="type")]. - T031 [US4] Propagate the wider
MetricTypethroughTestCaseModel.evaluations,Turn.evaluations, agent-levelEvaluationConfig.metrics, and_metric_kind()so every reader admitsCodeMetric(no isinstance narrowing that drops it). - T031a [US4] In
src/holodeck/lib/test_runner/executor.py::_create_evaluators(~line 386-513), add an explicitelif isinstance(metric_config, CodeMetric): continuebranch. Rationale: code graders are NOT part ofself.evaluators(they run throughinvoke_graderat turn time, not through the evaluator pipeline). Inline-comment the branch pointing to T043 so future readers find the grader dispatch point. Without this branch, aCodeMetricflowing into_create_evaluatorssilently does nothing and the grader never runs — T047 would still surface it but closer-to-failure coverage is better.
Phase 5: GraderContext / GraderResult + invocation harness (TDD)¶
Tests first¶
- T032 [P] [US4] Write
tests/unit/lib/test_runner/test_code_grader.py::test_grader_context_is_frozen—GraderContextcannot be mutated (FrozenInstanceErroron assignment). - T033 [P] [US4] Add
test_code_grader.py::test_grader_context_tuples_immutable—tool_invocationsis a tuple,retrieval_contextis a tuple orNone, not lists. - T034 [P] [US4] Add
test_code_grader.py::test_invoke_with_grader_result—returns_grader_resultfixture →MetricResultcarries score/passed/reason from theGraderResult. - T035 [P] [US4] Add
test_code_grader.py::test_return_true_false_shortcuts—True→score=1.0, passed=True;False→score=0.0, passed=False(contracts §3.2). - T036 [P] [US4] Add
test_code_grader.py::test_return_float_threshold_derivation—returns_float=0.75withthreshold=0.7→passed=True; withthreshold=0.8→passed=False; withthreshold=None→ default gate>= 0.5(contracts §5). - T037 [P] [US4] Add
test_code_grader.py::test_exception_captured_only_that_turn—raises_value_errorgrader runs mid-turn, that turn'sMetricResult.errorcaptures"ValueError: ..."andpassed=False; other turns still execute. - T038 [P] [US4] Add
test_code_grader.py::test_fail_on_error_halts_test_case—fail_on_error=trueon the raising grader → remaining turns not executed for this test case (viaTestCaseFatal); other test cases continue. - T039 [P] [US4] Add
test_code_grader.py::test_grader_receives_expected_context_fields— assertsctx.turn_input,agent_response,ground_truth,tool_invocations,retrieval_context,turn_index,test_case_name,turn_configare all populated. - T040 [P] [US4] Add
test_code_grader.py::test_details_preserved_on_turn_result— grader returnsGraderResult(details={"foo":"bar"});TurnResult.grader_details["<metric_name>"] == {"foo":"bar"}(contracts §7). - T040a [P] [US4] Add
test_code_grader.py::test_details_must_be_json_safe— grader returnsGraderResult(details={"bad": object()}); the resultingMetricResult.errornames"details not JSON-serializable"and the grader is recorded as failing before the report is written. Fails fast at grader-return time rather than at report-emission time. - T041 [P] [US4] Add
test_code_grader.py::test_non_standard_return_treated_as_error—returns_dictfixture grader → recorded as grader error, not as success (contracts §3.2 last row).
Implementation¶
- T042 [US4] Create
src/holodeck/lib/test_runner/code_grader.pywith: @dataclass(frozen=True) class GraderContextper contracts §3.1 (tuples for ordered collections).@dataclass(frozen=True) class GraderResultper §3.2.invoke_grader(fn, ctx, threshold) -> MetricResultthat normalizes shortcut returns, wraps the call intry/except Exception, times withtime.perf_counter(matches DeepEval evaluators atdeepeval/base.py:243,320; emits int milliseconds viaint((perf_counter() - start) * 1000)), validatesdetailsis JSON-serializable viajson.dumps(..., default=None)catchingTypeError, and returnsMetricResult(kind="code", metric_name=..., score, threshold, passed, error, evaluation_time_ms, model_used=None, reasoning=result.reason).- An exported
build_grader_context(turn_config, turn_state) -> GraderContexthelper the executor calls per turn. - T042a [US4] Define
class TestCaseFatal(Exception)insidesrc/holodeck/lib/test_runner/executor.py(executor-local, not exported). Carry attributestest_case_name,turn_index,grader_name. Docstring: "Raised when a CodeMetric grader withfail_on_error=Trueraises; breaks the turn loop for this test case only. Other test cases continue. Caught at the per-test-case level in_execute_single_test." One-line unit test asserting it inherits fromException. - T043 [US4] In
src/holodeck/lib/test_runner/executor.py, during per-turn metric evaluation, detectCodeMetricentries and route toinvoke_grader. Respectfail_on_error=Trueby raisingTestCaseFatal(test_case_name, turn_index, grader_name)to break the turn loop for that case only; other test cases still run. Catch it at the per-test-case boundary, mark the test case failed with an explanatory error, and move on. - T044 [US4] Confirm
TurnResult.grader_detailsis already reserved from US1 T012 (per data-model.md §8). Insrc/holodeck/lib/test_runner/executor.py, populate it fromGraderResult.detailskeyed bymetric_nameafter each grader invocation. Add a regression test thatgrader_details is Nonewhen no code graders ran.
Phase 6: Three-level configuration + end-to-end¶
Tests first¶
- T045 [P] [US4] Write
tests/unit/config/test_evaluator_three_level.py::test_code_metric_usable_at_all_three_levels— sameCodeMetricattached at agentevaluations.metrics, at test-caseevaluations, or at per-turnevaluations— all produce identical behavior (FR-023). - T046 [P] [US4] Add
test_evaluator_three_level.py::test_equality_numeric_usable_at_all_three_levels— same for the two new deterministic metrics. - T047 [P] [US4] Add
tests/integration/test_multi_turn_us4_e2e.py::test_convfinqa_numeric_plus_code_grader— 1-turn multi-turn case withnumericbuilt-in + fixturecodegrader (my_benchmarks:numeric_equal); stubbed agent returns"25587"; assert bothMetricResults land inTurnResult.metric_resultswith correct kinds ("standard"and"code"), and roll-up applies.
Implementation¶
- T048 [US4] Confirm the three-level resolution in
src/holodeck/lib/test_runner/executor.pypasses new metric types through unchanged (no evaluator-kind gating). Add a small regression guard if gating exists. - T049 [US4] Update
src/holodeck/lib/test_runner/reporter.pymarkdown to renderkind: codemetrics with their reason (if present) and theirgradercallable name. - T050 [US4] Run
make format && make lint && make type-check && pytest -n auto. All green.
Phase 7: Docs + constitution-exception verification¶
- T051 [P] [US4] Update
docs/product-specs/032-multi-turn-test-cases/quickstart.md§5 to pin the grader module name tomy_benchmarks:program_equivalence(matches the fixture path in T002). Quickstart currently already uses that shape — verify no drift. - T052 [P] [US4] Confirm the
type: codesection ofcontracts/code-grader-contract.mdcarries the trust-boundary note (FR-026, A9). PR description MUST link tospec.md §Complexity Trackingper reviewer sign-off requirement. - T052a [US4] Run
grep -rn "exec\\|eval(\\|compile(" src/holodeck/lib/test_runner/code_grader.py src/holodeck/models/evaluation.pyand confirm the output is empty. Locks in that the Principle I exception does not leak dynamic-code execution beyondimportlib.import_module.
Dependencies¶
- Phase 1 → Phase 2 and Phase 4 in parallel.
- Phase 5 depends on Phase 4.
- Phase 6 depends on Phases 2, 4, 5.
- Phase 7 is docs/verification, runs any time after Phase 5.
- Within Phase 2: T003–T013 all parallel; T014–T016 serial (single evaluator module +
executor.py). - Within Phase 4: T021–T027 parallel; T028–T031a serial (same model file +
executor.py). - Within Phase 5: T032–T041 parallel; T042–T044 serial.
Independent test criteria (recap)¶
- SC-007 — grader author can wire up a working code grader in <30 min: validated by
tests/fixtures/graders/my_benchmarks.py+ T034–T040. - SC-008 — 15-pair acceptance matrix for
equality+numericis T013 (includes boundarydiff == tolerance). - SC-009 — grader exception only fails that turn: T037/T038.
- Constitution exception stays scoped to
CodeMetric+code_grader.py: T052a grep-verified.