Source code for oumi.core.configs.params.rubric_judge_params

# Copyright 2025 - Oumi
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import logging
import re
from dataclasses import dataclass, field
from enum import Enum

from oumi.core.configs.params.base_params import BaseParams
from oumi.core.configs.params.judge_params import JudgeOutputType
from oumi.exceptions import OumiConfigError

logger = logging.getLogger(__name__)

# Suffix appended to a criterion id to name its per-criterion explanation field.
EXPLANATION_SUFFIX = "_explanation"

# Criterion ids become XML tag names and JSON object keys. The judge's XML parser
# only recognizes tags matching `\w+`, and a tag name cannot start with a digit, so
# ids are restricted to an identifier-like shape.
_VALID_CRITERION_ID_PATTERN = re.compile(r"^[^\W\d]\w*$")


[docs] class JudgeAggregation(str, Enum): """How to combine per-criterion scores into a single overall score.""" NONE = "none" """Do not aggregate; the overall score is left undefined.""" WEIGHTED_MEAN = "weighted_mean" """Weighted average of the per-criterion scores, using each criterion's weight.""" MIN = "min" """The lowest per-criterion score (a single failing criterion drags it down).""" ALL = "all" """1.0 if every criterion scores 1.0, else 0.0 (the checklist case)."""
[docs] @dataclass class JudgeCriterion(BaseParams): """A single criterion that a rubric judge evaluates. Each criterion becomes one field in the judge's structured output, optionally preceded by its own explanation field. Example: >>> criterion = JudgeCriterion( # doctest: +SKIP ... id="groundedness", ... description="Every claim is supported by the provided context.", ... judgment_type=JudgeOutputType.BOOL, ... weight=2.0, ... ) """ id: str """Identifier for this criterion, e.g. "groundedness".""" description: str """What the judge should assess for this criterion.""" judgment_type: JudgeOutputType = field(default=JudgeOutputType.BOOL) """The type of output that this criterion's judgment should be provided with.""" judgment_scores: dict[str, float | None] | None = field(default=None) """For ENUM judgment_type, the mapping from category names to numeric scores. Example: {"excellent": 1.0, "good": 0.7, "poor": 0.3, "not_applicable": None} """ include_explanation: bool = field(default=True) """Whether the judge should explain this criterion before judging it.""" weight: float = field(default=1.0) """Relative weight of this criterion under WEIGHTED_MEAN aggregation.""" @property def is_scoreable(self) -> bool: """Whether this criterion can ever contribute a numeric score. BOOL criteria are scored 1.0/0.0 automatically. Anything else needs a `judgment_scores` mapping with at least one label that carries a score -- a mapping whose labels all map to None only constrains the allowed values. """ if self.judgment_scores: return any(score is not None for score in self.judgment_scores.values()) return self.judgment_type == JudgeOutputType.BOOL @property def explanation_id(self) -> str: """Output field key holding this criterion's explanation.""" return f"{self.id}{EXPLANATION_SUFFIX}"
[docs] def __post_init__(self): """Validate the criterion after initialization.""" self._validate_params()
def _validate_params(self): """Validate the criterion for consistency and completeness. Raises: OumiConfigError: If the criterion is invalid. """ if not self.id or not self.id.strip(): raise OumiConfigError("Criterion `id` cannot be empty") if not _VALID_CRITERION_ID_PATTERN.match(self.id): raise OumiConfigError( f"Criterion id '{self.id}' is invalid: an id must start with a " "letter or underscore and contain only letters, digits, and " "underscores, since it is used verbatim as an XML tag name and a " "JSON key in the judge's response." ) if self.id.endswith(EXPLANATION_SUFFIX) or self.id == "explanation": raise OumiConfigError( f"Criterion id '{self.id}' is reserved: ids named 'explanation' " f"or ending in '{EXPLANATION_SUFFIX}' name the explanation " "fields generated by `include_explanation`. Please rename the " "criterion." ) if not self.description or not self.description.strip(): raise OumiConfigError( f"Criterion '{self.id}' must have a non-empty `description`" ) if self.judgment_type == JudgeOutputType.ENUM and not self.judgment_scores: raise OumiConfigError( f"Criterion '{self.id}': judgment_scores must be provided for ENUM " "judgment_type" ) if self.judgment_scores is not None: if not self.judgment_scores: raise OumiConfigError( f"Criterion '{self.id}': judgment_scores cannot be empty when " "provided" ) # None means "this label carries no score"; anything else must be numeric. if not all( score is None or isinstance(score, int | float) for score in self.judgment_scores.values() ): raise OumiConfigError( f"Criterion '{self.id}': judgment_scores values must be numeric, " "or None for a label that carries no score" ) # Only ENUM keeps the chosen label as its value. For the other types the # label is parsed into a bool/int/float, so an unscored label would come # back as None -- indistinguishable from a failed parse. if self.judgment_type != JudgeOutputType.ENUM and any( score is None for score in self.judgment_scores.values() ): unscored = sorted( label for label, score in self.judgment_scores.items() if score is None ) raise OumiConfigError( f"Criterion '{self.id}': judgment_scores labels {unscored} map to " f"None, which is only supported for ENUM judgment_type (this " f"criterion is {self.judgment_type.value.upper()}). The value " "is parsed from the label, so an unscored label could not be " "told apart from a failed parse. Use judgment_type: ENUM." ) if self.weight < 0: raise OumiConfigError( f"Criterion '{self.id}': weight must be non-negative, got {self.weight}" )
[docs] @dataclass class RubricJudgeParams(BaseParams): """Parameters for a rubric judge, which scores several criteria at once. A rubric judge issues a single inference call per input and asks the judge model to produce one judgment per criterion, unlike a simple judge which produces one judgment overall. Example: >>> rubric_params = RubricJudgeParams( # doctest: +SKIP ... criteria=[ ... JudgeCriterion( ... id="groundedness", ... description="Every claim is supported by the context.", ... include_explanation=True, ... ), ... JudgeCriterion( ... id="formality", ... description=( ... "How formal the response's register is. 'formal' for " ... "professional prose, 'casual' for slang or contractions." ... ), ... judgment_type=JudgeOutputType.ENUM, ... judgment_scores={"formal": 1.0, "neutral": 0.5, "casual": 0.0}, ... ), ... ], ... aggregation=JudgeAggregation.WEIGHTED_MEAN, ... ) """ criteria: list[JudgeCriterion] = field(default_factory=list) """The criteria to evaluate, in the order the judge should address them.""" aggregation: JudgeAggregation = field(default=JudgeAggregation.WEIGHTED_MEAN) """How to combine per-criterion scores into the output's `aggregate_score`."""
[docs] def __post_init__(self): """Validate the parameters after initialization.""" self._validate_params() self._warn_about_aggregation_gaps()
def _warn_about_aggregation_gaps(self) -> None: """Warn about config that silently has no effect on the aggregate score.""" self._warn_about_ignored_weights() self._warn_about_unscored_criteria() def _warn_about_ignored_weights(self) -> None: """Warn when weights are set under an aggregation that does not read them. Only WEIGHTED_MEAN consults `weight`; MIN and ALL are order statistics and NONE aggregates nothing, so a weight set under those is a silent no-op. """ if self.aggregation == JudgeAggregation.WEIGHTED_MEAN: return weighted = [c.id for c in self.criteria if c.weight != 1.0] if weighted: logger.warning( f"Criteria {sorted(weighted)} set a non-default `weight`, but " f"'{self.aggregation.value}' aggregation ignores weights. The weights " "have no effect. Use 'weighted_mean' aggregation, or remove them." ) def _warn_about_unscored_criteria(self) -> None: """Warn about criteria that cannot contribute to the aggregate score.""" if self.aggregation == JudgeAggregation.NONE: return unscored = [c.id for c in self.criteria if not c.is_scoreable] if unscored: logger.warning( f"Criteria {sorted(unscored)} produce no numeric score and will be " f"excluded from the '{self.aggregation.value}' aggregate score. " "Add `judgment_scores` to include them." ) def _validate_params(self): """Validate the parameters for consistency and completeness. Raises: OumiConfigError: If parameters are invalid. """ if not self.criteria: raise OumiConfigError("`criteria` cannot be empty for a rubric judge") # Criterion ids must be unique. They cannot collide with a generated # explanation field, because JudgeCriterion reserves that suffix. seen_ids: set[str] = set() for criterion in self.criteria: if criterion.id in seen_ids: raise OumiConfigError( f"Duplicate criterion id: '{criterion.id}'. Criterion ids must " "be unique." ) seen_ids.add(criterion.id) if self.aggregation == JudgeAggregation.WEIGHTED_MEAN and not any( criterion.weight > 0 for criterion in self.criteria ): raise OumiConfigError( "WEIGHTED_MEAN aggregation requires at least one criterion with a " "positive weight; all weights are zero." )