Skip to content

reach.metrics

Compute standard evaluation, classification, routing, and multi-step trajectory metrics for skill selection probes.


Multi-Step Trajectory Routing

Modern agent runtimes execute multi-turn conversational trajectories where a task may require entering via one skill, performing a precursor handoff, and terminating at the target capability:

flowchart LR
    Query["User Query"] --> E["Step 1: Entrypoint Skill<br/>(Entrypoint Accuracy)"]
    E -->|Handoff| P["Step 2: Precursor Skill"]
    P -->|Handoff| T["Step 3: Target Capability<br/>(Trajectory Reachability)"]

Trajectory Metrics

Reach evaluates multi-step trajectories against the ground-truth capability target \(T\) over the scored trajectory (\(\vec{s}\) after stripping neutral acceptable_skills):

Metric Symbol Definition & Meaning
Entrypoint Accuracy \(A_{\text{entry}}\) Fraction of queries where the very first scored skill invoked matches target \(T\) (\(\vec{s}_1 = T\), or \(\vec{s} = \emptyset\) for out_of_scope queries).
Trajectory Reachability \(R_{\text{traj}}\) Fraction of queries where target \(T\) is reached anywhere in the scored trajectory (\(T \in \vec{s}\), or \(\vec{s} = \emptyset\) for out_of_scope queries).
Step Efficiency (MRR) \(\text{MRR}\) Reciprocal rank \(\frac{1}{\text{step}}\) of the first scored invocation of \(T\) for positive queries (None / excluded from macro-averages on out_of_scope queries).
Skill Selection F1 \(F_1\) Harmonic mean of set-level precision (target reached / unique scored skills invoked) and recall (target reached) for positive queries (None on out_of_scope queries).
Skill Redundancy \(\text{Redundancy}\) Excess scored invocations beyond the target: \(\max(0, \text{len}(\vec{s}) - 1)\) for positive queries, or \(\text{len}(\vec{s})\) for out_of_scope queries where any invocation is excess.

API Reference

Compute standard evaluation and routing metrics for skill selection probes.

ClassificationReport

Bases: BaseModel

Hold aggregated classification and routing metrics for an evaluation run.

Source code in src/reach/metrics.py
class ClassificationReport(BaseModel):
    """Hold aggregated classification and routing metrics for an evaluation run."""

    model_config = ConfigDict(frozen=True)

    errors: Annotated[int, Field(ge=0)]
    macro_f1: Annotated[float, Field(ge=0.0)]
    macro_precision: Annotated[float, Field(ge=0.0)]
    macro_recall: Annotated[float, Field(ge=0.0)]
    per_class: tuple[ClassMetrics, ...]
    probes: Annotated[int, Field(ge=0)]
    scored: Annotated[int, Field(ge=0)]
    abstentions: Annotated[int, Field(ge=0)] = 0
    false_abstentions: Annotated[int, Field(ge=0)] = 0
    in_scope: Annotated[int, Field(ge=0)] = 0
    out_of_scope: Annotated[int, Field(ge=0)] = 0
    out_of_scope_detected: Annotated[int, Field(ge=0)] = 0
    top1_hits: Annotated[int, Field(ge=0)] = 0
    entrypoint_hits: Annotated[int, Field(ge=0)] = 0
    trajectory_hits: Annotated[int, Field(ge=0)] = 0
    attempts: Annotated[int, Field(ge=1)] = 1
    step_efficiency: Annotated[float | None, Field(ge=0.0, le=1.0)] = None
    skill_f1: Annotated[float | None, Field(ge=0.0, le=1.0)] = None
    redundancy: Annotated[float, Field(ge=0.0)] = 0.0

    @computed_field
    @property
    def entrypoint_accuracy(self) -> float:
        """Calculate entrypoint accuracy across scored probes."""
        return self.entrypoint_hits / self.scored if self.scored else 0.0

    @computed_field
    @property
    def entrypoint_interval(self) -> Interval | None:
        """Calculate the cluster-adjusted Wilson confidence interval for entrypoint accuracy."""
        return cluster_wilson_interval(self.entrypoint_hits, self.scored, attempts=self.attempts)

    @computed_field
    @property
    def trajectory_reachability(self) -> float:
        """Calculate trajectory reachability across scored probes."""
        return self.trajectory_hits / self.scored if self.scored else 0.0

    @computed_field
    @property
    def trajectory_interval(self) -> Interval | None:
        """Calculate the cluster-adjusted Wilson confidence interval for trajectory reachability."""
        return cluster_wilson_interval(self.trajectory_hits, self.scored, attempts=self.attempts)

    @computed_field
    @property
    def top1_accuracy(self) -> float:
        """Calculate top-1 accuracy across scored probes."""
        return self.top1_hits / self.scored if self.scored else 0.0

    @computed_field
    @property
    def top1_interval(self) -> Interval | None:
        """Calculate the cluster-adjusted Wilson confidence interval for top-1 accuracy."""
        return cluster_wilson_interval(self.top1_hits, self.scored, attempts=self.attempts)

    @computed_field
    @property
    def abstention_rate(self) -> float:
        """Calculate the overall abstention rate across scored probes."""
        return self.abstentions / self.scored if self.scored else 0.0

    @computed_field
    @property
    def abstention_interval(self) -> Interval | None:
        """Calculate the cluster-adjusted Wilson confidence interval for overall abstention rate."""
        return cluster_wilson_interval(self.abstentions, self.scored, attempts=self.attempts)

    @computed_field
    @property
    def false_abstention_rate(self) -> float:
        """Calculate the false abstention rate over in-scope queries."""
        return self.false_abstentions / self.in_scope if self.in_scope else 0.0

    @computed_field
    @property
    def false_abstention_interval(self) -> Interval | None:
        """Calculate the cluster-adjusted Wilson confidence interval for false abstention rate."""
        return cluster_wilson_interval(
            self.false_abstentions,
            self.in_scope,
            attempts=self.attempts,
        )

    @computed_field
    @property
    def out_of_scope_detection(self) -> float | None:
        """Calculate the out-of-scope detection accuracy."""
        if not self.out_of_scope:
            return None
        return self.out_of_scope_detected / self.out_of_scope

    @computed_field
    @property
    def out_of_scope_interval(self) -> Interval | None:
        """Calculate the cluster Wilson interval for out-of-scope detection rate."""
        return cluster_wilson_interval(
            self.out_of_scope_detected,
            self.out_of_scope,
            attempts=self.attempts,
        )

    def by_label(self, label: str) -> ClassMetrics:
        """Return per-class metrics for a specified label."""
        for entry in self.per_class:
            if entry.label == label:
                return entry
        msg = f"no metrics for label {label!r}"
        raise KeyError(msg)

    def worst_recall(self, limit: int = 5) -> tuple[ClassMetrics, ...]:
        """Return classes with the lowest recall scores."""
        real = [c for c in self.per_class if c.label != NO_SKILL and c.support]
        return tuple(sorted(real, key=lambda c: (c.recall, c.label))[:limit])

    def top_attractors(self, limit: int = 5) -> tuple[ClassMetrics, ...]:
        """Return classes receiving the highest false-positive traffic."""
        real = [c for c in self.per_class if c.label != NO_SKILL and c.false_positives]
        return tuple(sorted(real, key=lambda c: (-c.false_positives, c.label))[:limit])

abstention_interval property

abstention_interval: Interval | None

Calculate the cluster-adjusted Wilson confidence interval for overall abstention rate.

abstention_rate property

abstention_rate: float

Calculate the overall abstention rate across scored probes.

entrypoint_accuracy property

entrypoint_accuracy: float

Calculate entrypoint accuracy across scored probes.

entrypoint_interval property

entrypoint_interval: Interval | None

Calculate the cluster-adjusted Wilson confidence interval for entrypoint accuracy.

false_abstention_interval property

false_abstention_interval: Interval | None

Calculate the cluster-adjusted Wilson confidence interval for false abstention rate.

false_abstention_rate property

false_abstention_rate: float

Calculate the false abstention rate over in-scope queries.

out_of_scope_detection property

out_of_scope_detection: float | None

Calculate the out-of-scope detection accuracy.

out_of_scope_interval property

out_of_scope_interval: Interval | None

Calculate the cluster Wilson interval for out-of-scope detection rate.

top1_accuracy property

top1_accuracy: float

Calculate top-1 accuracy across scored probes.

top1_interval property

top1_interval: Interval | None

Calculate the cluster-adjusted Wilson confidence interval for top-1 accuracy.

trajectory_interval property

trajectory_interval: Interval | None

Calculate the cluster-adjusted Wilson confidence interval for trajectory reachability.

trajectory_reachability property

trajectory_reachability: float

Calculate trajectory reachability across scored probes.

by_label

by_label(label: str) -> ClassMetrics

Return per-class metrics for a specified label.

Source code in src/reach/metrics.py
def by_label(self, label: str) -> ClassMetrics:
    """Return per-class metrics for a specified label."""
    for entry in self.per_class:
        if entry.label == label:
            return entry
    msg = f"no metrics for label {label!r}"
    raise KeyError(msg)

top_attractors

top_attractors(limit: int = 5) -> tuple[ClassMetrics, ...]

Return classes receiving the highest false-positive traffic.

Source code in src/reach/metrics.py
def top_attractors(self, limit: int = 5) -> tuple[ClassMetrics, ...]:
    """Return classes receiving the highest false-positive traffic."""
    real = [c for c in self.per_class if c.label != NO_SKILL and c.false_positives]
    return tuple(sorted(real, key=lambda c: (-c.false_positives, c.label))[:limit])

worst_recall

worst_recall(limit: int = 5) -> tuple[ClassMetrics, ...]

Return classes with the lowest recall scores.

Source code in src/reach/metrics.py
def worst_recall(self, limit: int = 5) -> tuple[ClassMetrics, ...]:
    """Return classes with the lowest recall scores."""
    real = [c for c in self.per_class if c.label != NO_SKILL and c.support]
    return tuple(sorted(real, key=lambda c: (c.recall, c.label))[:limit])

ClassMetrics

Bases: BaseModel

Report precision, recall, support, and F1 metrics for a single skill class.

Source code in src/reach/metrics.py
class ClassMetrics(BaseModel):
    """Report precision, recall, support, and F1 metrics for a single skill class."""

    model_config = ConfigDict(frozen=True)

    false_negatives: Annotated[int, Field(ge=0)]
    false_positives: Annotated[int, Field(ge=0)]
    label: str
    predicted: Annotated[int, Field(ge=0)]
    support: Annotated[int, Field(ge=0)]
    true_positives: Annotated[int, Field(ge=0)]
    trajectory_true_positives: Annotated[int, Field(ge=0)] = 0
    attempts: Annotated[int, Field(ge=1)] = 1

    @model_validator(mode="after")
    def _ensure_trajectory_at_least_top1(self) -> Self:
        """Validate that trajectory true positives are at least top-1 true positives."""
        if self.trajectory_true_positives < self.true_positives:
            msg = (
                f"trajectory_true_positives ({self.trajectory_true_positives}) "
                f"cannot be less than true_positives ({self.true_positives})"
            )
            raise ValueError(msg)
        return self

    @property
    def precision(self) -> float:
        """Calculate precision (true positives / predicted positives)."""
        denominator = self.true_positives + self.false_positives
        return self.true_positives / denominator if denominator else 0.0

    @property
    def recall(self) -> float:
        """Calculate recall (true positives / ground truth support)."""
        denominator = self.true_positives + self.false_negatives
        return self.true_positives / denominator if denominator else 0.0

    @property
    def trajectory_recall(self) -> float:
        """Calculate trajectory recall (trajectory true positives / ground truth support)."""
        return self.trajectory_true_positives / self.support if self.support else 0.0

    @property
    def f1(self) -> float:
        """Calculate F1 score (harmonic mean of precision and recall)."""
        return compute_f1(self.precision, self.recall)

    @property
    def recall_interval(self) -> Interval | None:
        """Calculate the cluster-adjusted Wilson confidence interval for recall."""
        return cluster_wilson_interval(
            self.true_positives,
            self.true_positives + self.false_negatives,
            attempts=self.attempts,
        )

    @property
    def precision_interval(self) -> Interval | None:
        """Calculate the cluster-adjusted Wilson confidence interval for precision."""
        return cluster_wilson_interval(
            self.true_positives,
            self.true_positives + self.false_positives,
            attempts=self.attempts,
        )

f1 property

f1: float

Calculate F1 score (harmonic mean of precision and recall).

precision property

precision: float

Calculate precision (true positives / predicted positives).

precision_interval property

precision_interval: Interval | None

Calculate the cluster-adjusted Wilson confidence interval for precision.

recall property

recall: float

Calculate recall (true positives / ground truth support).

recall_interval property

recall_interval: Interval | None

Calculate the cluster-adjusted Wilson confidence interval for recall.

trajectory_recall property

trajectory_recall: float

Calculate trajectory recall (trajectory true positives / ground truth support).

DecompositionResult

Bases: BaseModel

Represent decomposition of pass-rate drop between baseline and scaled catalogs.

Source code in src/reach/metrics.py
class DecompositionResult(BaseModel):
    """Represent decomposition of pass-rate drop between baseline and scaled catalogs."""

    model_config = ConfigDict(frozen=True, extra="forbid")

    baseline_pass_rate: Annotated[float, Field(ge=0.0, le=1.0)]
    scaled_pass_rate: Annotated[float, Field(ge=0.0, le=1.0)]
    delta_total: float
    delta_abstention: float = 0.0
    delta_collision: float = 0.0
    delta_truncated: float = 0.0
    delta_total_ci: tuple[float, float] = (0.0, 0.0)
    delta_abstention_ci: tuple[float, float] = (0.0, 0.0)
    delta_collision_ci: tuple[float, float] = (0.0, 0.0)
    delta_truncated_ci: tuple[float, float] = (0.0, 0.0)
    sample_size: Annotated[int, Field(ge=0)] = 0
    baseline_ci: tuple[float, float] = (0.0, 0.0)
    scaled_ci: tuple[float, float] = (0.0, 0.0)

TrajectoryScore

Bases: BaseModel

Evaluation outcomes for a single query across its invocation trajectory.

Source code in src/reach/metrics.py
class TrajectoryScore(BaseModel):
    """Evaluation outcomes for a single query across its invocation trajectory."""

    model_config = ConfigDict(frozen=True)

    entrypoint_hit: bool
    trajectory_hit: bool
    step_efficiency: Annotated[float | None, Field(ge=0.0, le=1.0)] = None
    skill_f1: Annotated[float | None, Field(ge=0.0, le=1.0)] = None
    redundancy: Annotated[int, Field(ge=0)]

classification_report

classification_report(
    results: Sequence[ProbeResult],
    queries: Sequence[Query],
    labels: Sequence[str] | None = None,
    attempts: int = 1,
) -> ClassificationReport

Generate a comprehensive classification report across all probe results.

Source code in src/reach/metrics.py
def classification_report(
    results: Sequence[ProbeResult],
    queries: Sequence[Query],
    labels: Sequence[str] | None = None,
    attempts: int = 1,
) -> ClassificationReport:
    """Generate a comprehensive classification report across all probe results."""
    pairs = _paired(results, queries)
    y_true = [q.truth_label for q, _ in pairs]
    y_pred = [q.effective_predicted_label(r) for q, r in pairs]
    universe = _label_universe(y_true, y_pred, labels)
    traj_scores = [score_trajectory(q, r.invoked_skills) for q, r in pairs]
    traj_hits_by_label = Counter(
        t for t, s in zip(y_true, traj_scores, strict=True) if s.trajectory_hit
    )
    per_class = _build_per_class(
        y_true,
        y_pred,
        universe,
        trajectory_hits_by_label=traj_hits_by_label,
        attempts=attempts,
    )
    precision, recall, f1 = _macro_averages(per_class)
    in_count, false_abs, out_count, out_detected = _scope_metrics(y_true, y_pred)

    entrypoint_hits = sum(1 for s in traj_scores if s.entrypoint_hit)
    trajectory_hits = sum(1 for s in traj_scores if s.trajectory_hit)
    in_scope_effs = [s.step_efficiency for s in traj_scores if s.step_efficiency is not None]
    in_scope_f1s = [s.skill_f1 for s in traj_scores if s.skill_f1 is not None]
    step_eff = statistics.fmean(in_scope_effs) if in_scope_effs else None
    s_f1 = statistics.fmean(in_scope_f1s) if in_scope_f1s else None
    redundancy = _mean([float(s.redundancy) for s in traj_scores])

    return ClassificationReport(
        attempts=attempts,
        probes=len(results),
        errors=sum(1 for r in results if r.error),
        scored=len(y_true),
        per_class=per_class,
        top1_hits=sum(t == p for t, p in zip(y_true, y_pred, strict=True)),
        entrypoint_hits=entrypoint_hits,
        trajectory_hits=trajectory_hits,
        step_efficiency=round(step_eff, 4) if step_eff is not None else None,
        skill_f1=round(s_f1, 4) if s_f1 is not None else None,
        redundancy=round(redundancy, 4),
        macro_precision=precision,
        macro_recall=recall,
        macro_f1=f1,
        abstentions=sum(p == NO_SKILL for p in y_pred),
        in_scope=in_count,
        false_abstentions=false_abs,
        out_of_scope=out_count,
        out_of_scope_detected=out_detected,
    )

classify_invocation_pattern

classify_invocation_pattern(
    query: Query, invoked_skills: Sequence[str]
) -> InvocationPattern

Classify trajectory invocation behavior relative to query ground truth.

Source code in src/reach/metrics.py
def classify_invocation_pattern(
    query: Query,
    invoked_skills: Sequence[str],
) -> InvocationPattern:
    """Classify trajectory invocation behavior relative to query ground truth."""
    invoked = query.scored_invocations(invoked_skills)
    if query.is_out_of_scope:
        return (
            InvocationPattern.CORRECT_ABSTENTION
            if not invoked
            else InvocationPattern.UNWANTED_TRIGGER
        )
    if not invoked:
        return InvocationPattern.ABANDONED
    valid_targets = query.valid_skills
    if set(invoked) <= valid_targets:
        return InvocationPattern.ORACLE_ONLY
    if set(invoked) & valid_targets:
        return InvocationPattern.MIXED_ORACLE
    return InvocationPattern.DISTRACTOR_HIJACK

collisions

collisions(
    results: Sequence[ProbeResult], queries: Sequence[Query]
) -> Counter[tuple[str, str]]

Count misroutes between skill pairs.

Source code in src/reach/metrics.py
def collisions(
    results: Sequence[ProbeResult],
    queries: Sequence[Query],
) -> Counter[tuple[str, str]]:
    """Count misroutes between skill pairs."""
    truth = {q.query_id: q for q in queries}
    pairs: Counter[tuple[str, str]] = Counter()
    for result in results:
        if result.error or not result.selected:
            continue
        query = truth.get(result.query_id)
        if query is None:
            continue
        effective_invoked = query.effective_invoked_skill(result)
        if effective_invoked is None or query.matches_skill(effective_invoked):
            continue
        pairs[(query.truth_label, effective_invoked)] += 1
    return pairs

confusion

confusion(
    results: Sequence[ProbeResult], queries: Sequence[Query]
) -> Counter[tuple[str, str | None]]

Count occurrences of expected-to-invoked skill selection pairs.

Source code in src/reach/metrics.py
def confusion(
    results: Sequence[ProbeResult],
    queries: Sequence[Query],
) -> Counter[tuple[str, str | None]]:
    """Count occurrences of expected-to-invoked skill selection pairs."""
    truth = {q.query_id: q for q in queries}
    pairs: Counter[tuple[str, str | None]] = Counter()
    for result in results:
        if result.error:
            continue
        query = truth.get(result.query_id)
        if query is None:
            continue
        effective_invoked = query.effective_invoked_skill(result)
        pairs[(query.truth_label, effective_invoked)] += 1
    return pairs

consistency

consistency(
    results: Sequence[ProbeResult], queries: Sequence[Query]
) -> float

Calculate the fraction of queries with unanimous selection outcomes.

Source code in src/reach/metrics.py
def consistency(results: Sequence[ProbeResult], queries: Sequence[Query]) -> float:
    """Calculate the fraction of queries with unanimous selection outcomes."""
    unanimous, observed = consistency_counts(results, queries)
    return unanimous / observed if observed else 0.0

decompose_pass_rate_drop

decompose_pass_rate_drop(
    baseline_results: Sequence[ProbeResult],
    scaled_results: Sequence[ProbeResult],
    queries: Sequence[Query] | None = None,
    iterations: int = 2000,
    seed: int = 42,
) -> DecompositionResult

Decompose overall pass-rate drop into abstention, collision, and truncation components.

Source code in src/reach/metrics.py
def decompose_pass_rate_drop(
    baseline_results: Sequence[ProbeResult],
    scaled_results: Sequence[ProbeResult],
    queries: Sequence[Query] | None = None,
    iterations: int = 2000,
    seed: int = 42,
) -> DecompositionResult:
    """Decompose overall pass-rate drop into abstention, collision, and truncation components."""
    truth_map: dict[str, Query] = {q.query_id: q for q in queries} if queries else {}
    base_by_query = _group_valid_results_by_query(baseline_results)
    scaled_by_query = _group_valid_results_by_query(scaled_results)

    common_qids = _resolve_evaluation_query_ids(base_by_query, scaled_by_query)
    if not common_qids:
        return DecompositionResult(
            baseline_pass_rate=0.0,
            scaled_pass_rate=0.0,
            delta_total=0.0,
            delta_abstention=0.0,
            delta_collision=0.0,
            delta_truncated=0.0,
            delta_total_ci=(0.0, 0.0),
            delta_abstention_ci=(0.0, 0.0),
            delta_collision_ci=(0.0, 0.0),
            delta_truncated_ci=(0.0, 0.0),
            sample_size=0,
        )

    drops = [
        _decompose_query_drop(
            base_by_query[qid],
            scaled_by_query[qid],
            truth_map.get(qid),
        )
        for qid in common_qids
    ]

    base_pass_rate = statistics.fmean(d.base_pass for d in drops)
    scaled_pass_rate = statistics.fmean(d.scaled_pass for d in drops)
    delta_total = statistics.fmean(d.delta for d in drops)
    delta_abs = statistics.fmean(d.delta_abstention for d in drops)
    delta_col = statistics.fmean(d.delta_collision for d in drops)
    delta_trunc = statistics.fmean(d.delta_truncated for d in drops)

    strata_indices: tuple[tuple[int, ...], ...] | None = None
    if truth_map:
        qid_to_idx = {qid: idx for idx, qid in enumerate(common_qids)}
        qid_strata = _build_query_strata(common_qids, queries_by_id=truth_map)
        strata_indices = tuple(tuple(qid_to_idx[qid] for qid in group) for group in qid_strata)

    delta_ci, abs_ci, col_ci, trunc_ci = _bootstrap_decomposition_ci(
        drops, iterations=iterations, seed=seed, strata_indices=strata_indices
    )

    m = len(common_qids)
    return DecompositionResult(
        baseline_pass_rate=base_pass_rate,
        scaled_pass_rate=scaled_pass_rate,
        delta_total=delta_total,
        delta_abstention=delta_abs,
        delta_collision=delta_col,
        delta_truncated=delta_trunc,
        delta_total_ci=delta_ci,
        delta_abstention_ci=abs_ci,
        delta_collision_ci=col_ci,
        delta_truncated_ci=trunc_ci,
        sample_size=m,
        baseline_ci=_compute_pass_rate_interval(base_pass_rate, m),
        scaled_ci=_compute_pass_rate_interval(scaled_pass_rate, m),
    )

score_trajectory

score_trajectory(
    query: Query, invoked_skills: Sequence[str]
) -> TrajectoryScore

Evaluate an observed skill trajectory against query target skill and acceptable skills.

Source code in src/reach/metrics.py
def score_trajectory(
    query: Query,
    invoked_skills: Sequence[str],
) -> TrajectoryScore:
    """Evaluate an observed skill trajectory against query target skill and acceptable skills."""
    invoked_seq = query.scored_invocations(invoked_skills)

    if query.is_out_of_scope:
        abstained = len(invoked_seq) == 0
        return TrajectoryScore(
            entrypoint_hit=abstained,
            trajectory_hit=abstained,
            step_efficiency=None,
            skill_f1=None,
            redundancy=len(invoked_seq),
        )

    valid_targets = query.valid_skills
    invoked_set = set(invoked_seq)
    entry_hit = bool(invoked_seq and invoked_seq[0] in valid_targets)
    traj_hit = bool(invoked_set & valid_targets)

    first_rank = next(
        (i + 1 for i, s in enumerate(invoked_seq) if s in valid_targets),
        None,
    )
    mrr = (1.0 / first_rank) if first_rank else 0.0

    prec = (
        (len(invoked_set & valid_targets) / len(invoked_set)) if (traj_hit and invoked_set) else 0.0
    )
    rec = 1.0 if traj_hit else 0.0
    f1 = (2 * prec * rec) / (prec + rec) if (prec + rec) else 0.0

    excess = max(0, len(invoked_seq) - 1)

    return TrajectoryScore(
        entrypoint_hit=entry_hit,
        trajectory_hit=traj_hit,
        step_efficiency=round(mrr, 4),
        skill_f1=round(f1, 4),
        redundancy=excess,
    )