Skip to content

[Bug]: Re-scoring with evaluate_experiment() silently drops every metric that needs task_span #8396

Description

@feiiiiii5

What component(s) are affected?

  • Opik Python SDK
  • Opik Typescript SDK
  • Opik Agent Optimizer SDK
  • Opik UI
  • Opik Server
  • Documentation

Opik version

opik 2.2.70, and current main at 5cfff7b013107d44a449bd2b085c1b16f8d92eea. Both carry the same line quoted below.

Describe the problem

evaluate_experiment() re-scores an existing experiment. Its docstring lists task_span as one of the inputs a scoring function may take ("task_span - the data collected during the LLM task execution [optional]"), and evaluate() does support such metrics (covered by tests/e2e/evaluation/test_evaluate_task_span.py). In the re-scoring path, however, every metric that requires task_span is discarded before it can be scored, and nothing reports that it was dropped:

sdks/python/src/opik/evaluation/engine/engine.py:653 (EvaluationEngine.score_test_cases)

regular_metrics, _ = metrics_evaluator.split_into_regular_and_task_span_metrics(
    scoring_metrics
)

The second half of the split is thrown away, and only regular_metrics reaches _compute_test_result_for_test_case. So a user who adds a span-based scorer to an existing experiment gets a run that completes normally, a report that lists only the other metrics, no log line, no failed score, and no entry in aggregate_evaluation_scores(). The experiment looks fully evaluated.

score_test_cases has exactly one caller — evaluator.py:930, inside evaluate_experiment — so this is confined to that entry point. Every other evaluation entry point goes through run_and_score, which does handle span metrics.

This is the failure class the SDK already ruled on elsewhere:

  • tests/unit/evaluation/test_silently_skipped_scores.py — "OPIK-6925: an evaluation the user asked for must never vanish without a trace."
  • sdks/python/src/opik/evaluation/types.py (ErrorTolerance.METRIC_ERRORS) — "Of the failures that happen before score is entered, a missing required score argument and an item-level evaluator that cannot be built abort the run".

score_test_cases is the remaining place where a configured metric is dropped before argument validation is entered, so neither documented outcome happens. evaluate_experiment builds its engine with ErrorTolerance.METRIC_ERRORS ("This entrypoint does not expose the setting; it runs strict"), i.e. the strictest level, which makes the silence worse rather than better.

Reproduction steps and code snippets

At the public entry point (needs a running Opik instance):

import opik
from opik.evaluation.metrics.score_result import ScoreResult


def answer_is_long_enough(dataset_item, task_outputs):
    return ScoreResult(
        name="answer_is_long_enough", value=float(len(task_outputs["output"]) > 20)
    )


def task_ran_under_the_token_budget(dataset_item, task_outputs, task_span):
    # task_span carries the LLM call metadata collected while the task ran
    return ScoreResult(
        name="task_ran_under_the_token_budget",
        value=float((task_span.usage or {}).get("total_tokens", 0) <= 4096),
    )


opik.evaluate_experiment(
    experiment_name="my-experiment",
    scoring_metrics=[],
    scoring_functions=[answer_is_long_enough, task_ran_under_the_token_budget],
)
# Completes without error. The experiment receives "answer_is_long_enough" only;
# "task_ran_under_the_token_budget" appears nowhere, and nothing says it could not run.

Without a server, the same thing at engine level (fixtures and import style copied from tests/unit/evaluation/):

from typing import Any

import pytest

from opik import exceptions
from opik.evaluation.metrics import base_metric, score_result
from opik.evaluation.types import ErrorTolerance


class RequiresTaskSpan(base_metric.BaseMetric):
    def __init__(self) -> None:
        super().__init__(name="requires_task_span", track=False)

    def score(self, task_span: Any, **ignored: Any) -> score_result.ScoreResult:
        return score_result.ScoreResult(name=self.name, value=1.0)


# `score_test_cases` with a metric that needs the span, both tolerances:
#   METRIC_ERRORS        -> expected ScoreMethodMissingArguments, nothing raised
#   ALL_SCORING_ERRORS   -> expected a failed ScoreResult, key absent from results

Error logs or stack trace

Running that expectation against main at 5cfff7b01:

E  Failed: DID NOT RAISE <class 'opik.exceptions.ScoreMethodMissingArguments'>   # ErrorTolerance.METRIC_ERRORS
E  KeyError: 'requires_task_span'                                                 # ErrorTolerance.ALL_SCORING_ERRORS

The KeyError is the bug: the score result the user configured is not in the returned results at all, so there is nothing to inspect.

Proposed fix — needs a direction call

Two shapes, and I would rather you pick than have me guess:

  1. Minimal, no new semantics. Stop discarding the split result: let task-span metrics reach argument validation with nothing bound to task_span. _compute_metric_scores already handles ScoreMethodMissingArguments exactly as documented — abort below ALL_SCORING_ERRORS, otherwise append _build_failed_score_result(...) with error_info, and keep it out of the persisted feedback scores. That is the same code path evaluate() uses for any other unsatisfiable argument, so the change stays inside score_test_cases (plus how it builds its evaluator) and the existing test file patterns cover it. One detail worth a call: a scoring_functions scorer is wrapped as ScorerWrapperMetricTaskSpan, whose score defaults task_span=None, so it would not trip argument validation and would instead fail inside the wrapped user function. I would make that shape report the same way rather than inherit a TypeError.
  2. Full support. Reconstruct the task span for each test case from the stored trace and score it properly. This is a better outcome for users but a bigger change: it needs a TracePublic/SpanPublic → models.SpanModel route, a rule for which span of a stored trace is "the" task span (run_and_score currently uses the first span of the locally recorded tree), and one extra read per item.

A related question for option 1: evaluate_experiment hard-codes ErrorTolerance.METRIC_ERRORS and does not expose the parameter, so a user who wants the run to continue with a reported failure has no way to ask for it. Should this entry point take error_tolerance like evaluate() does? That is a public-API decision, so I am not folding it into a fix unless you want it.

Healthcheck results

Not applicable — reproduced at unit level against source, with no server involved.

Disclosure: this was found by an AI-assisted code read of score_test_cases and the OPIK-6925 tests, and confirmed by running the reproduction above locally against 2.2.70 and main; I have reviewed the cited code and the claimed behavior myself.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions