kiln_ai.datamodel.eval

   1import json
   2import math
   3from enum import Enum
   4from threading import Lock
   5from typing import TYPE_CHECKING, Annotated, Any, Dict, List, Literal, Union
   6
   7from pydantic import (
   8    BaseModel,
   9    ConfigDict,
  10    Discriminator,
  11    Field,
  12    GetJsonSchemaHandler,
  13    JsonValue,
  14    SerializationInfo,
  15    SerializerFunctionWrapHandler,
  16    TypeAdapter,
  17    ValidationInfo,
  18    model_serializer,
  19    model_validator,
  20)
  21from pydantic.json_schema import JsonSchemaValue
  22from pydantic_core import CoreSchema
  23from typing_extensions import Self
  24
  25from kiln_ai.datamodel.basemodel import (
  26    ID_TYPE,
  27    FilenameString,
  28    FilenameStringShort,
  29    KilnParentedModel,
  30    KilnParentModel,
  31)
  32from kiln_ai.datamodel.code_file_storage import (
  33    read_code_from_sibling_file,
  34    write_code_to_sibling_file,
  35)
  36from kiln_ai.datamodel.datamodel_enums import (
  37    EvalStatus,
  38    Priority,
  39    TaskOutputRatingType,
  40)
  41from kiln_ai.datamodel.dataset_filters import DatasetFilterId, EvalInputFilterId
  42from kiln_ai.datamodel.json_schema import string_to_json_key
  43from kiln_ai.datamodel.task_run import Usage
  44from kiln_ai.datamodel.tool_id import ToolId, validate_tool_allowlist
  45from kiln_ai.utils.exhaustive_error import raise_exhaustive_enum_error
  46
  47if TYPE_CHECKING:
  48    from kiln_ai.datamodel.spec import Spec
  49    from kiln_ai.datamodel.task import Task
  50    from kiln_ai.datamodel.task_run import TaskRun
  51
  52EvalScores = Dict[str, float]
  53
  54# Module-level set to track evals currently being migrated (to prevent recursion)
  55# Protected by _migration_lock to ensure thread-safe access
  56_migration_lock = Lock()
  57_currently_migrating_eval_ids: set[ID_TYPE] = set()
  58
  59# Fixed name of the sibling file that holds a code judge's Python source, stored
  60# beside its eval_config.kiln. Fixed so authored tests can `from scorer import score`.
  61SCORER_CODE_FILENAME = "scorer.py"
  62
  63
  64class EvalTemplateId(str, Enum):
  65    """
  66    An eval template is a pre-defined eval that can be used as a starting point for a new eval.
  67    """
  68
  69    kiln_requirements = "kiln_requirements"
  70    desired_behaviour = "desired_behaviour"
  71    issue = "kiln_issue"
  72    tool_call = "tool_call"
  73    toxicity = "toxicity"
  74    bias = "bias"
  75    maliciousness = "maliciousness"
  76    factual_correctness = "factual_correctness"
  77    jailbreak = "jailbreak"
  78    rag = "rag"
  79
  80
  81class EvalConfigType(str, Enum):
  82    """The type of eval configuration, determining how scores are generated."""
  83
  84    g_eval = "g_eval"
  85    llm_as_judge = "llm_as_judge"
  86    v2 = "v2"
  87
  88
  89class V2EvalType(str, Enum):
  90    """V2-only eval type enum. Each value maps to a typed properties class
  91    and a V2 adapter."""
  92
  93    llm_judge = "llm_judge"
  94    exact_match = "exact_match"
  95    pattern_match = "pattern_match"
  96    set_check = "set_check"
  97    tool_call_check = "tool_call_check"
  98    contains = "contains"
  99    step_count_check = "step_count_check"
 100    code_eval = "code_eval"
 101
 102
 103class LlmJudgeProperties(BaseModel):
 104    type: Literal[V2EvalType.llm_judge] = V2EvalType.llm_judge
 105    model_name: str
 106    model_provider: str
 107    system_prompt: str | None = None
 108    prompt_template: str
 109    reference_keys: list[str] = []
 110    thinking_instruction: str | None = None
 111    g_eval: bool = False
 112    # User-written evaluation steps, bound to {{ judge_instructions }} when the
 113    # prompt template is rendered. Used by evals with no spec or template to
 114    # derive default steps from.
 115    judge_instructions: list[str] | None = None
 116
 117
 118class ExactMatchProperties(BaseModel):
 119    type: Literal[V2EvalType.exact_match] = V2EvalType.exact_match
 120    value_expression: str | None = None
 121    expected_value: str | None = None
 122    reference_key: str | None = Field(default=None, min_length=1)
 123    case_sensitive: bool = True
 124
 125    @model_validator(mode="after")
 126    def validate_value_source(self) -> Self:
 127        if (self.expected_value is None) == (self.reference_key is None):
 128            raise ValueError(
 129                "Exactly one of expected_value or reference_key must be set"
 130            )
 131        return self
 132
 133
 134class PatternMatchProperties(BaseModel):
 135    type: Literal[V2EvalType.pattern_match] = V2EvalType.pattern_match
 136    value_expression: str | None = None
 137    pattern: str
 138    mode: Literal["must_match", "must_not_match"] = "must_match"
 139
 140    @model_validator(mode="after")
 141    def validate_pattern(self) -> Self:
 142        import re
 143
 144        try:
 145            re.compile(self.pattern)
 146        except re.error as e:
 147            raise ValueError(f"Invalid regex pattern '{self.pattern}': {e}") from e
 148        return self
 149
 150
 151class ContainsProperties(BaseModel):
 152    type: Literal[V2EvalType.contains] = V2EvalType.contains
 153    value_expression: str | None = None
 154    substring: str | None = None
 155    reference_key: str | None = Field(default=None, min_length=1)
 156    case_sensitive: bool = True
 157    mode: Literal["must_contain", "must_not_contain"] = "must_contain"
 158
 159    @model_validator(mode="after")
 160    def validate_value_source(self) -> Self:
 161        if (self.substring is None) == (self.reference_key is None):
 162            raise ValueError("Exactly one of substring or reference_key must be set")
 163        return self
 164
 165
 166class SetCheckProperties(BaseModel):
 167    type: Literal[V2EvalType.set_check] = V2EvalType.set_check
 168    value_expression: str | None = None
 169    expected_set: list[str] | None = None
 170    reference_key: str | None = Field(default=None, min_length=1)
 171    mode: Literal["subset", "superset", "equal"]
 172
 173    @model_validator(mode="after")
 174    def validate_value_source(self) -> Self:
 175        if (self.expected_set is None) == (self.reference_key is None):
 176            raise ValueError("Exactly one of expected_set or reference_key must be set")
 177        return self
 178
 179
 180class ArgMatch(BaseModel):
 181    value: JsonValue
 182    match_mode: Literal["exact", "contains", "regex"] = "exact"
 183
 184    @model_validator(mode="after")
 185    def validate_regex(self) -> Self:
 186        if self.match_mode == "regex":
 187            import re
 188
 189            try:
 190                re.compile(str(self.value))
 191            except re.error as e:
 192                raise ValueError(f"Invalid regex value '{self.value}': {e}") from e
 193        return self
 194
 195
 196class ToolCallSpec(BaseModel):
 197    tool_name: str
 198    expected_args: dict[str, ArgMatch] | None = None
 199
 200
 201class ToolCallCheckProperties(BaseModel):
 202    type: Literal[V2EvalType.tool_call_check] = V2EvalType.tool_call_check
 203    expected_tools: list[ToolCallSpec] = Field(min_length=1)
 204    match_mode: Literal["any", "all", "ordered", "never"] = "all"
 205    on_unexpected_tools: Literal["ignore", "fail"] = "ignore"
 206
 207
 208class StepCountCheckProperties(BaseModel):
 209    type: Literal[V2EvalType.step_count_check] = V2EvalType.step_count_check
 210    count_type: Literal["tool_calls", "model_responses", "turns"]
 211    min_count: int | None = None
 212    max_count: int | None = None
 213
 214    @model_validator(mode="after")
 215    def check_bounds(self) -> Self:
 216        if self.min_count is None and self.max_count is None:
 217            raise ValueError(
 218                "step_count_check requires at least one of min_count / max_count"
 219            )
 220        if (
 221            self.min_count is not None
 222            and self.max_count is not None
 223            and self.min_count > self.max_count
 224        ):
 225            raise ValueError("min_count must be <= max_count")
 226        return self
 227
 228
 229class CodeEvalProperties(BaseModel):
 230    type: Literal[V2EvalType.code_eval] = V2EvalType.code_eval
 231    code: str
 232    reference_keys: list[str] = []
 233    timeout_seconds: int = Field(default=180, ge=1, le=300)
 234    tool_allowlist: list[ToolId] = Field(
 235        default_factory=list,
 236        description="Explicit per-tool allowlist of tools the scorer code may call.",
 237    )
 238
 239    @model_validator(mode="after")
 240    def validate_allowlist(self) -> Self:
 241        # No self-reference check: a code eval is not itself a tool.
 242        validate_tool_allowlist(self.tool_allowlist, caller="code evals")
 243        return self
 244
 245    @model_validator(mode="before")
 246    @classmethod
 247    def _read_code_file(cls, data: Any, info: ValidationInfo) -> Any:
 248        """When loading from disk, inject `code` from the sibling scorer.py.
 249
 250        The source is stored in scorer.py beside eval_config.kiln, not inline in
 251        the JSON. CodeEvalProperties is a nested member of the
 252        V2EvalConfigProperties discriminated union in EvalConfig.properties, so
 253        the load context set on the parent EvalConfig (`source_dir`) propagates
 254        down to this validator. The shared helper reads the file here, before
 255        field validation, so the existing validate_code trio runs against the
 256        loaded string unchanged.
 257        """
 258        # Explicit type-gate (defense-in-depth): this validator only ever runs
 259        # for code_eval properties — it lives on CodeEvalProperties, and both the
 260        # discriminated union and the eager parse route only code_eval dicts
 261        # here. Assert that gate so a future refactor can't quietly read
 262        # scorer.py for another eval type. None (type omitted, field defaults)
 263        # and the enum form both pass; only a present, mismatched type is
 264        # rejected, so valid-input behavior is unchanged.
 265        if isinstance(data, dict) and data.get("type") not in (
 266            None,
 267            V2EvalType.code_eval.value,
 268        ):
 269            raise ValueError(
 270                "CodeEvalProperties can only load code_eval properties, "
 271                f"got type: {data.get('type')!r}"
 272            )
 273        return read_code_from_sibling_file(
 274            data,
 275            info.context or {},
 276            filename=SCORER_CODE_FILENAME,
 277            kiln_filename="eval_config.kiln",
 278            model_label="CodeEvalProperties",
 279        )
 280
 281    @model_serializer(mode="wrap")
 282    def _serialize(
 283        self, handler: SerializerFunctionWrapHandler, info: SerializationInfo
 284    ) -> dict[str, Any]:
 285        """On disk-save, write `code` to scorer.py and omit it from the .kiln JSON.
 286
 287        Delegates to the shared sibling-file helper, which uses the same save
 288        context attachments use (`save_attachments` + `dest_path`); it propagates
 289        from the parent EvalConfig's save_to_file() down to this nested union
 290        member. Without that context — normal model_dump / API responses —
 291        `code` is left in the output and no file is written, so the API contract
 292        is unchanged. The default handler preserves `type` (needed by the
 293        discriminator), `reference_keys`, and `timeout_seconds`.
 294
 295        Schema note: a custom model_serializer would otherwise collapse the
 296        *serialization-mode* JSON schema to an untyped object
 297        (`model_json_schema(mode="serialization")` loses per-field typing).
 298        Unlike CodeTool — which is never a FastAPI response_model — this model is
 299        nested in EvalConfig, and EvalConfig IS the declared `response_model` on
 300        several endpoints (eval_api.py). FastAPI generates response schemas in
 301        serialization mode, so a collapsed schema here would split
 302        CodeEvalProperties into an untyped `-Output` component and drift the
 303        checked-in api_schema.d.ts (breaking check_schema.sh and the web types
 304        that key off `components["schemas"]["CodeEvalProperties"]`). The
 305        `__get_pydantic_json_schema__` override below is therefore REQUIRED (not
 306        optional): it keeps the serialization-mode schema identical to
 307        validation mode. Do not remove either the serializer (runtime file
 308        storage) or the override (schema stability).
 309        """
 310        return write_code_to_sibling_file(
 311            handler(self),
 312            info.context or {},
 313            filename=SCORER_CODE_FILENAME,
 314            code=self.code,
 315        )
 316
 317    @classmethod
 318    def __get_pydantic_json_schema__(
 319        cls, core_schema: CoreSchema, handler: GetJsonSchemaHandler
 320    ) -> JsonSchemaValue:
 321        """Keep the serialization-mode JSON schema identical to validation mode.
 322
 323        The wrap serializer above returns an untyped `dict`, which would collapse
 324        this model's serialization-mode JSON schema to `{additionalProperties:
 325        true, type: object}` (dropping `code`, `type`, etc.). Because EvalConfig
 326        (which nests this model) is a FastAPI response_model, that collapse would
 327        drift the committed OpenAPI/api_schema.d.ts. Dropping the `serialization`
 328        core-schema entries makes JSON-schema generation use the field-based
 329        (validation) representation in both modes, so `code` stays present and
 330        typed and there is no `-Input`/`-Output` split. The custom serializer
 331        lives on the inner `model` core schema (the before/after validators wrap
 332        it in function schemas), so the strip must be recursive. This affects
 333        only schema generation, never runtime (de)serialization.
 334        """
 335
 336        def strip_serialization(schema: Any) -> Any:
 337            if isinstance(schema, dict):
 338                return {
 339                    key: strip_serialization(value)
 340                    for key, value in schema.items()
 341                    if key != "serialization"
 342                }
 343            if isinstance(schema, list):
 344                return [strip_serialization(item) for item in schema]
 345            return schema
 346
 347        return handler(strip_serialization(core_schema))
 348
 349    @model_validator(mode="after")
 350    def validate_code(self) -> Self:
 351        code_bytes = self.code.encode("utf-8")
 352        if len(code_bytes) > 64 * 1024:
 353            raise ValueError(
 354                f"Code is too large ({len(code_bytes)} bytes). Maximum size is 64KB."
 355            )
 356
 357        try:
 358            compile(self.code, "<code_eval>", "exec")
 359        except SyntaxError as e:
 360            raise ValueError(f"Code has a syntax error: {e}") from e
 361
 362        import ast
 363
 364        tree = ast.parse(self.code)
 365        # Both sync and async score functions are accepted here.
 366        # Async coroutines are transparently awaited in sandbox_worker.execute_scorer_bridged.
 367        has_score_fn = any(
 368            isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
 369            and node.name == "score"
 370            for node in ast.iter_child_nodes(tree)
 371        )
 372        if not has_score_fn:
 373            raise ValueError(
 374                "Code must define a module-level 'score' function (def score(...))."
 375            )
 376
 377        return self
 378
 379
 380V2EvalConfigProperties = Annotated[
 381    Union[
 382        LlmJudgeProperties,
 383        ExactMatchProperties,
 384        PatternMatchProperties,
 385        SetCheckProperties,
 386        ToolCallCheckProperties,
 387        ContainsProperties,
 388        StepCountCheckProperties,
 389        CodeEvalProperties,
 390    ],
 391    Discriminator("type"),
 392]
 393
 394# Parses a raw properties dict into its typed V2 class via the "type" discriminator.
 395_V2_PROPERTIES_ADAPTER: TypeAdapter[Any] = TypeAdapter(V2EvalConfigProperties)
 396
 397# Explicit tuple of V2 property types for isinstance() checks.
 398# Must list exactly the same types as the V2EvalConfigProperties union above.
 399V2_PROPERTY_TYPES: tuple[type[BaseModel], ...] = (
 400    LlmJudgeProperties,
 401    ExactMatchProperties,
 402    PatternMatchProperties,
 403    SetCheckProperties,
 404    ToolCallCheckProperties,
 405    ContainsProperties,
 406    StepCountCheckProperties,
 407    CodeEvalProperties,
 408)
 409
 410
 411def reference_data_keys(props: V2EvalConfigProperties) -> list[str]:
 412    """Return the reference-data keys a single judge needs.
 413
 414    Exhaustive match over the V2 properties union: adding a new V2 type
 415    without handling it here will fail ``ty`` type-checking.
 416    """
 417    match props:
 418        case ExactMatchProperties():
 419            return [props.reference_key] if props.reference_key else []
 420        case ContainsProperties():
 421            return [props.reference_key] if props.reference_key else []
 422        case SetCheckProperties():
 423            return [props.reference_key] if props.reference_key else []
 424        case LlmJudgeProperties():
 425            return list(props.reference_keys)
 426        case CodeEvalProperties():
 427            return list(props.reference_keys)
 428        case PatternMatchProperties():
 429            return []
 430        case ToolCallCheckProperties():
 431            return []
 432        case StepCountCheckProperties():
 433            return []
 434        case _:
 435            raise_exhaustive_enum_error(props)
 436
 437
 438def _eager_parse_code_eval_on_load(
 439    data: dict[str, Any], ctx: dict[str, Any]
 440) -> dict[str, Any]:
 441    """Eagerly parse a code_eval EvalConfig's `properties` on file load.
 442
 443    V2 code judges store their score() source in a sibling scorer.py, not inline
 444    in the JSON. On load, parse a code_eval properties dict through
 445    CodeEvalProperties (which reads scorer.py via the propagated load context) so
 446    any error surfaces directly. Without this, the outer
 447    `V2EvalConfigProperties | dict | None` union would recover from the nested
 448    member's error by falling back to the dict branch, masking the real cause
 449    (e.g. a missing scorer.py or a bad score() function) behind a generic
 450    "V2 config requires typed properties". See functional spec §2.2 / §4.
 451
 452    Only touches code_eval properties during a file load, gated explicitly on
 453    `type == code_eval`; every other input passes through unchanged. Lifted
 454    verbatim from EvalConfig.dispatch_properties_parsing so the code-eval load
 455    path is a clearly-named, code-eval-local step rather than smeared into the
 456    generic dispatcher.
 457    """
 458    if not ctx.get("loading_from_file"):
 459        return data
 460    props = data.get("properties")
 461    if isinstance(props, dict) and props.get("type") == V2EvalType.code_eval.value:
 462        data = dict(data)
 463        data["properties"] = CodeEvalProperties.model_validate(props, context=ctx)
 464    return data
 465
 466
 467def validate_scores_against_output_scores(
 468    scores: EvalScores,
 469    output_scores: list["EvalOutputScore"],
 470) -> list[str]:
 471    """Validate that *scores* fall within the expected range for each output score.
 472
 473    Returns a list of human-readable problem strings (empty list means all OK).
 474    This is a pure function — it does NOT raise; callers decide how to surface errors.
 475    """
 476
 477    def _is_numeric(v: object) -> bool:
 478        # NaN compares False against every range bound, so it passes every check
 479        # below, then serializes to null and makes the saved file fail on reload.
 480        if not isinstance(v, (int, float)) or isinstance(v, bool):
 481            return False
 482        try:
 483            return math.isfinite(v)
 484        except OverflowError:
 485            # isfinite coerces int args to float, which an int like 10**400 can't be.
 486            return False
 487
 488    problems: list[str] = []
 489    for output_score in output_scores:
 490        key = output_score.json_key()
 491        if key not in scores:
 492            continue
 493        value = scores[key]
 494
 495        match output_score.type:
 496            case TaskOutputRatingType.five_star:
 497                if not _is_numeric(value) or value < 1.0 or value > 5.0:
 498                    problems.append(
 499                        f"Score {output_score.name} is a five_star rating and must be a number between 1.0 and 5.0 inclusive. Got: {value}"
 500                    )
 501            case TaskOutputRatingType.pass_fail:
 502                if not _is_numeric(value) or value < 0.0 or value > 1.0:
 503                    problems.append(
 504                        f"Score {output_score.name} is a pass_fail rating and must be a number between 0.0 and 1.0 inclusive. Got: {value}"
 505                    )
 506            case TaskOutputRatingType.pass_fail_critical:
 507                if not _is_numeric(value) or value < -1.0 or value > 1.0:
 508                    problems.append(
 509                        f"Score {output_score.name} is a pass_fail_critical rating and must be a number between -1.0 and 1.0 inclusive. Got: {value}"
 510                    )
 511            case TaskOutputRatingType.custom:
 512                problems.append(
 513                    f"Custom scores are not supported in evaluators. '{output_score.name}' was set to a custom score."
 514                )
 515            case _:
 516                raise_exhaustive_enum_error(output_score.type)
 517    return problems
 518
 519
 520class SkippedReason(str, Enum):
 521    """Terminal skip reasons stored as str for back/forward-compat."""
 522
 523    missing_reference_key = "missing_reference_key"
 524    extraction_failed = "extraction_failed"
 525    missing_trace = "missing_trace"
 526    missing_drive_config = "missing_drive_config"
 527    incompatible_input_shape = "incompatible_input_shape"
 528    code_eval_not_trusted = "code_eval_not_trusted"
 529    type_not_available = "type_not_available"
 530
 531
 532class V2EvalResult(BaseModel):
 533    """Result of a single V2 eval ``evaluate()`` call."""
 534
 535    scores: EvalScores = Field(default_factory=dict)
 536    skipped_reason: SkippedReason | None = None
 537    skipped_detail: str | None = None
 538    intermediate_outputs: Dict[str, str] | None = None
 539    usage: Usage | None = Field(
 540        default=None,
 541        description="What the judgment itself cost, if it called a model. None for the deterministic eval types, which call none. Stored on the resulting EvalRun as eval_usage.",
 542    )
 543
 544
 545class UserMessage(BaseModel):
 546    text: str
 547
 548
 549class SyntheticUserInfo(BaseModel):
 550    """The synthetic user's character sheet: who they are and what they want.
 551
 552    This is both the persisted form on multi-turn synthetic eval inputs and
 553    the runtime shape the synthetic-user driver renders its system prompt
 554    from. The XML-tagged blob some wire formats carry is parsed into this at
 555    the wire boundary (kiln_ai.synthetic_user.parser) — it is never stored.
 556
 557    extra="allow": unknown fields from newer generators survive load/save
 558    round-trips instead of being dropped.
 559    """
 560
 561    model_config = ConfigDict(extra="allow")
 562
 563    persona: str
 564    goal: str
 565    behavior_guidance: str | None = None
 566
 567
 568class SingleTurnEvalInputData(BaseModel):
 569    type: Literal["single_turn"] = "single_turn"
 570    user_message: UserMessage
 571
 572
 573class MultiTurnDriveConfig(BaseModel):
 574    """Settings for re-driving a multi-turn synthetic input at eval time.
 575
 576    A multi-turn eval run regenerates each conversation: the agent under test
 577    comes from the run config being evaluated, while the synthetic user
 578    (customer) configured here is held constant across run configs — so a
 579    comparison varies only the agent. Stored per item, on
 580    MultiTurnSyntheticEvalInputData.drive_config.
 581    """
 582
 583    model_name: str = Field(
 584        description="The model that plays the synthetic user during re-drives."
 585    )
 586    # A plain string rather than the provider enum so persisted items load on
 587    # builds that don't know the provider yet (same choice as LlmJudgeProperties).
 588    model_provider: str = Field(description="The provider of the synthetic-user model.")
 589    turns: int = Field(
 590        ge=1,
 591        le=20,
 592        description="Ceiling on the assistant turns per re-driven conversation.",
 593    )
 594
 595
 596class MultiTurnSyntheticEvalInputData(BaseModel):
 597    """A re-drivable multi-turn case: the opening user message, the synthetic
 598    user who continues the conversation at eval time, and the drive settings
 599    that synthetic user runs with.
 600
 601    Together these make the item a self-contained replication recipe: with the
 602    persona, first_message, and drive_config it re-drives identically under any
 603    eval that references it, which is what makes conversation traces keyed to
 604    the item reusable across evals.
 605
 606    first_message may be None; such items carry no seed to open a
 607    conversation with, so the eval runner skips them instead of re-driving.
 608    """
 609
 610    type: Literal["multi_turn_synthetic"] = "multi_turn_synthetic"
 611    first_message: UserMessage | None = None
 612    synthetic_user_info: SyntheticUserInfo
 613    drive_config: MultiTurnDriveConfig | None = Field(
 614        default=None,
 615        description="How this item's conversation is re-driven: the "
 616        "synthetic-user model and turn count, stamped when the item is minted. "
 617        "This is the ONLY home for drive settings — no eval-level copy exists; "
 618        "displays and prefills derive from items. Held constant across run "
 619        "configs so a comparison varies only the agent under test. Immutable "
 620        "once minted: changing the synthetic-user setup means minting new "
 621        "items, which keeps traces keyed to this item valid. None only on "
 622        "items minted before drive settings were stamped; the eval runner "
 623        "skips such items with a clear reason rather than guessing a config.",
 624    )
 625
 626    @model_serializer(mode="wrap")
 627    def _omit_unset_drive_config(
 628        self, handler: SerializerFunctionWrapHandler
 629    ) -> Dict[str, Any]:
 630        # Items that predate drive_config carry no key on disk, and absent and
 631        # null load identically — omit an unset config instead of churning
 632        # every legacy file with a null on resave.
 633        data: Dict[str, Any] = handler(self)
 634        if data.get("drive_config") is None:
 635            data.pop("drive_config", None)
 636        return data
 637
 638
 639EvalInputData = Annotated[
 640    Union[
 641        SingleTurnEvalInputData,
 642        MultiTurnSyntheticEvalInputData,
 643    ],
 644    Discriminator("type"),
 645]
 646
 647
 648class EvalInput(KilnParentedModel):
 649    """A single evaluation input item, stored as a child of a Task.
 650
 651    Each EvalInput contains the data needed to run an evaluation (e.g. a user
 652    message) plus optional reference data for comparison and tags for filtering.
 653    """
 654
 655    data: EvalInputData = Field(
 656        description="The input data for this eval item.",
 657    )
 658    reference: dict[str, JsonValue] | None = Field(
 659        default=None,
 660        description="Optional reference data (ground truth) for this eval input, keyed by reference name.",
 661    )
 662    tags: list[str] = Field(
 663        default_factory=list,
 664        description="Tags for filtering eval inputs.",
 665    )
 666
 667    @model_validator(mode="after")
 668    def validate_tags(self) -> Self:
 669        # Empty or space-containing tags can't be selected by tag filters, so
 670        # reject them at creation instead of silently dropping the item later.
 671        for tag in self.tags:
 672            if not tag:
 673                raise ValueError("Tags cannot be empty strings")
 674            if " " in tag:
 675                raise ValueError("Tags cannot contain spaces. Try underscores.")
 676        return self
 677
 678
 679class EvalTaskInput(BaseModel):
 680    """The runtime data bundle passed to V2 evaluators.
 681
 682    Assembled by the eval runner from the item being evaluated and the task run that
 683    was scored. The item is either an EvalInput or a TaskRun drawn from the dataset;
 684    which one it is determines where `reference_data` and `task_input` come from.
 685    """
 686
 687    final_message: str = Field(
 688        description="The final model output (task output text).",
 689    )
 690    trace: list[dict[str, Any]] | None = Field(
 691        default=None,
 692        description="The full conversation trace, if available.",
 693    )
 694    reference_data: dict[str, JsonValue] | None = Field(
 695        default=None,
 696        description=(
 697            "Ground-truth data for the item being evaluated, keyed by reference name. "
 698            "Taken from EvalInput.reference for an EvalInput-backed item; for a "
 699            "TaskRun-backed dataset item it is the item's own stored output under the "
 700            "key 'reference_answer', since that output is the curated answer. None "
 701            "when a TaskRun is scored as itself (judge calibration), where the item "
 702            "and the scored run are the same record."
 703        ),
 704    )
 705    task_input: str | None = Field(
 706        default=None,
 707        description="The original task input text.",
 708    )
 709
 710    @classmethod
 711    def from_trace(
 712        cls, trace: "TaskRun", source: "TaskRun | EvalInput"
 713    ) -> "EvalTaskInput":
 714        """What a judge sees: the trace that was produced, plus the item it came from.
 715
 716        The two are separate arguments because they are separate records once eval traces
 717        live on their own TaskRun — the trace holds what the model said, and the source
 718        item holds the ground truth to compare it against. They are the same object only
 719        for calibration, where the golden dataset item is itself what gets scored.
 720        """
 721        from kiln_ai.datamodel.task_run import TaskRun as _TaskRun
 722
 723        if not isinstance(trace, _TaskRun):
 724            raise TypeError("Expected a TaskRun instance for trace")
 725
 726        trace_data: list[dict[str, Any]] | None = None
 727        if trace.trace is not None:
 728            trace_data = [dict(msg) for msg in trace.trace]
 729
 730        if isinstance(source, EvalInput):
 731            reference_data = source.reference
 732            # The item's own text, not the trace's: an EvalInput is the canonical
 733            # statement of the input, and the adapter may have reserialized it.
 734            if isinstance(source.data, SingleTurnEvalInputData):
 735                task_input = source.data.user_message.text
 736            elif isinstance(source.data, MultiTurnSyntheticEvalInputData):
 737                # Multi-turn: the first message opened the conversation, and the
 738                # rest of the exchange lives in the trace. Items minted without a
 739                # first message have no canonical input text to offer.
 740                task_input = (
 741                    source.data.first_message.text
 742                    if source.data.first_message
 743                    else None
 744                )
 745            else:
 746                raise ValueError(
 747                    f"Unsupported EvalInput data type: {type(source.data).__name__}"
 748                )
 749        elif isinstance(source, _TaskRun):
 750            # A TaskRun-backed dataset item stores the curated answer as its output, so
 751            # that output is the ground truth to compare the trace against. Skipped when
 752            # source *is* trace (calibration, and `from_task_run`): there the golden item
 753            # is itself what gets scored, so a reference would be byte-identical to
 754            # `final_message` and every judge comparing them would pass.
 755            reference_data = (
 756                None if source is trace else {"reference_answer": source.output.output}
 757            )
 758            task_input = trace.input
 759        else:
 760            raise TypeError("Expected a TaskRun or EvalInput instance for source")
 761
 762        return cls(
 763            final_message=trace.output.output,
 764            trace=trace_data,
 765            reference_data=reference_data,
 766            task_input=task_input,
 767        )
 768
 769    @classmethod
 770    def from_task_run(cls, task_run: "TaskRun") -> "EvalTaskInput":
 771        """A TaskRun scored as itself, with no separate source item."""
 772        return cls.from_trace(task_run, task_run)
 773
 774    @classmethod
 775    def from_eval_input(
 776        cls, eval_input: "EvalInput", run_output: "TaskRun"
 777    ) -> "EvalTaskInput":
 778        """A generated run scored against the EvalInput it was generated from.
 779
 780        The argument-order counterpart of `from_trace` for callers holding the item
 781        first. The explicit type check stays: passed a TaskRun by mistake,
 782        `from_trace` would silently take its TaskRun branch instead of failing.
 783        """
 784        if not isinstance(eval_input, EvalInput):
 785            raise TypeError("Expected an EvalInput instance")
 786        return cls.from_trace(run_output, eval_input)
 787
 788
 789class EvalOutputScore(BaseModel):
 790    """
 791    A definition of a score that an evaluator will produce.
 792
 793    Very similar to TaskRequirement, but conceptually different keeping in a separate models.
 794    """
 795
 796    name: FilenameStringShort = Field(
 797        description="The name of the score. Will be provided to the model so use a descriptive name. Should align to the model's TaskRequirement name if you want to use human evals to evaluate the evaluator's performance."
 798    )
 799    instruction: str | None = Field(
 800        default=None,
 801        description="A description of the score, used to help the model understand the goal of the score. Will be provided to evaluator models, so should be written for the model, not the team/user.",
 802    )
 803    type: TaskOutputRatingType = Field(
 804        description="The type of rating to use ('five_star', 'pass_fail', 'pass_fail_critical').",
 805    )
 806
 807    def json_key(self) -> str:
 808        """
 809        The JSON key for the score, used when running the evaluator with a LLM and we need JSON output.
 810
 811        For example, "Overall Rating" -> "overall_rating"
 812        """
 813        return string_to_json_key(self.name)
 814
 815    @model_validator(mode="after")
 816    def validate_type(self) -> Self:
 817        if self.type == TaskOutputRatingType.custom:
 818            raise ValueError(
 819                f"Custom scores are not supported in evaluators. Score '{self.name}' was set to a custom score."
 820            )
 821        return self
 822
 823
 824LEGACY_TRACE_FIELDS = ("output", "task_run_trace", "task_run_usage", "reference_answer")
 825"""The EvalRun fields that hold a copy of the trace a score was computed over.
 826
 827Deprecated: new records point at a TaskRun with `scored_run_id` instead. Kept declared
 828and loadable forever - every record written before the split still carries them. A
 829pointer-mode record must leave all of them None, which `validate_record_mode` enforces.
 830`input` is deprecated alongside these but is not in this tuple: it is the one a legacy
 831record is *required* to have, so it is checked separately.
 832
 833Three ways to mark a pydantic field deprecated; these fields use two of them:
 834
 8351. A `DEPRECATED:` prefix in the `description`. Reaches a human reading the SDK docs or
 836   the OpenAPI schema, and nothing else. Used.
 8372. `json_schema_extra={"deprecated": True}`. Puts `"deprecated": true` in the JSON
 838   schema, which `openapi-typescript` turns into a `/** @deprecated */` JSDoc tag, so
 839   the TS compiler and editors strike through every web call site. No runtime effect.
 840   Used.
 8413. `Field(deprecated=True)`. Same schema output as (2), but pydantic also raises a
 842   DeprecationWarning on every attribute *read* — and reading these is the correct,
 843   permanent way to render a legacy record, so it would be a warning storm. Not used.
 844   Do not "fix" this to (3) without silencing that first; (2) already provides the
 845   tooling signal (3) would be reached for."""
 846
 847
 848class EvalRun(KilnParentedModel):
 849    """
 850    The scores an eval produced for a single dataset item.
 851
 852    A run serves one of two purposes:
 853    - eval_config_eval=False (scoring): evaluating a task run config — the item's
 854      input was run through the task with task_run_config_id (which must be set)
 855      and the evaluator scored that output.
 856    - eval_config_eval=True (calibration): evaluating the eval config itself — an
 857      existing human-rated dataset item's input and output were scored so the
 858      evaluator can be compared against those human ratings. task_run_config_id
 859      must be None.
 860
 861    A record is described by two independent facts — whether it points at a TaskRun, and
 862    whether it was skipped — which `validate_record_mode` constrains to three legal
 863    shapes. What is exclusive is where the trace lives: on the record, or on the TaskRun,
 864    never both.
 865
 866    - **Pointer** (new): `scored_run_id` names the TaskRun that holds the trace. All
 867      inline trace fields must be None.
 868    - **Skipped**: `skipped_reason` set, so scores are not required. It also carries a
 869      `scored_run_id` if the trace existed and only scoring was skipped — so a skip can
 870      be a pointer record too — and none if the skip happened before generation.
 871    - **Legacy inline**: no `scored_run_id`; the trace lives on this record, and `input`
 872      is required unless the record was skipped. Every record written before the
 873      trace/score split is in this state, and it stays valid forever.
 874    """
 875
 876    dataset_id: ID_TYPE | None = Field(
 877        default=None,
 878        description="The ID of the dataset item (TaskRun) that was used for this run. Mutually exclusive with eval_input_id.",
 879    )
 880    scored_run_id: ID_TYPE | None = Field(
 881        default=None,
 882        description="The ID of the TaskRun this score was computed over. None for legacy records that carry their trace inline. A dangling reference is tolerated: the score still renders and still aggregates, only the trace drill-through is unavailable.",
 883    )
 884    task_run_config_id: ID_TYPE | None = Field(
 885        description="The ID of the TaskRunConfig that was run, if this eval run was based on a task run. Must belong to the same Task as this eval. Can be None if this eval run is based on an eval config."
 886    )
 887    eval_config_eval: bool = Field(
 888        description="Whether this eval run to evaluate the parent eval config (evaluating the config using an existing dataset item). If true, task_run_config_id must be None, as we're not running the task.",
 889        default=False,
 890    )
 891    input: str | None = Field(
 892        default=None,
 893        json_schema_extra={"deprecated": True},
 894        description="DEPRECATED: the trace now lives on the TaskRun named by scored_run_id; read TaskRun.input instead. The input to the task. JSON formatted for structured input, plaintext for unstructured input. Required on legacy records (those with neither a scored_run_id nor a skipped_reason), never set on new ones.",
 895    )
 896    output: str | None = Field(
 897        default=None,
 898        json_schema_extra={"deprecated": True},
 899        description="DEPRECATED: the trace now lives on the TaskRun named by scored_run_id; read TaskRun.output.output instead. The output of the task. None for skipped-before-execution runs.",
 900    )
 901    reference_answer: str | None = Field(
 902        default=None,
 903        json_schema_extra={"deprecated": True},
 904        description="DEPRECATED: the trace now lives on the TaskRun named by scored_run_id. The reference answer for the input. JSON formatted for structured reference answer, plaintext for unstructured reference answer. Used for reference answer evals.",
 905    )
 906    intermediate_outputs: Dict[str, str] | None = Field(
 907        default=None,
 908        description="The intermediate outputs of the task (example, eval thinking).",
 909    )
 910    task_run_trace: str | None = Field(
 911        default=None,
 912        json_schema_extra={"deprecated": True},
 913        description="DEPRECATED: the trace now lives on the TaskRun named by scored_run_id; read TaskRun.trace instead. The JSON formatted trace of the task run that produced the output.",
 914    )
 915    scores: EvalScores = Field(
 916        default={},
 917        description="The output scores of the evaluator (aligning to those required by the grand-parent Eval this object is a child of).",
 918    )
 919    task_run_usage: Usage | None = Field(
 920        default=None,
 921        json_schema_extra={"deprecated": True},
 922        description="DEPRECATED: the trace now lives on the TaskRun named by scored_run_id; read TaskRun.usage instead. The usage of the task run that produced this eval run output (not the usage by the evaluation model).",
 923    )
 924    eval_usage: Usage | None = Field(
 925        default=None,
 926        description="The usage of the evaluation model (judge) that produced this eval run's scores, aggregated across every LLM call the judgment made. Distinct from task_run_usage, which is the evaluated task run's usage. None for non-LLM evals (e.g. code evals) and for records that predate this field.",
 927    )
 928
 929    eval_input_id: ID_TYPE | None = Field(
 930        default=None,
 931        description="ID of the EvalInput used for this run (V2 evals). Mutually exclusive with dataset_id.",
 932    )
 933    skipped_reason: str | None = Field(
 934        default=None,
 935        description="If set, this run was skipped. Stored as str for back/forward-compat; conventionally a SkippedReason value.",
 936    )
 937    skipped_detail: str | None = Field(
 938        default=None,
 939        description="Case-specific detail for skipped runs (e.g. missing key name).",
 940    )
 941
 942    def parent_eval_config(self) -> Union["EvalConfig", None]:
 943        if self.parent is not None and self.parent.__class__.__name__ != "EvalConfig":
 944            raise ValueError("parent must be an EvalConfig")
 945        return self.parent  # type: ignore
 946
 947    @model_validator(mode="after")
 948    def validate_input_source(self) -> Self:
 949        if (self.dataset_id is None) == (self.eval_input_id is None):
 950            raise ValueError(
 951                "Exactly one of dataset_id (V1 TaskRun source) or "
 952                "eval_input_id (V2 EvalInput source) must be set"
 953            )
 954        return self
 955
 956    @model_validator(mode="after")
 957    def validate_record_mode(self) -> Self:
 958        """Keep the three record states (pointer / skipped / legacy inline) exclusive.
 959
 960        The forbidding half of the pointer rule is the one that earns its keep: a record
 961        that points at a TaskRun must never also carry a second copy of what it scored,
 962        which nothing would keep in sync.
 963        """
 964        inline_set = [f for f in LEGACY_TRACE_FIELDS if getattr(self, f) is not None]
 965
 966        if self.scored_run_id is not None:
 967            # Checked before the skip branch on purpose: a record skipped at *scoring*
 968            # time still has a scored_run_id, and still must not carry inline data.
 969            if self.input is not None or inline_set:
 970                carried = (["input"] if self.input is not None else []) + inline_set
 971                raise ValueError(
 972                    "An EvalRun with scored_run_id must not carry inline trace data "
 973                    f"(set: {', '.join(carried)}). "
 974                    "The trace lives on the referenced TaskRun."
 975                )
 976            return self
 977
 978        if self.skipped_reason is not None:
 979            # Skipped before generation: there is nothing to point at, and nothing to
 980            # require. Legacy skipped records that do carry inline data stay valid.
 981            return self
 982
 983        if self.input is None:
 984            raise ValueError("A legacy EvalRun (no scored_run_id) requires input.")
 985        return self
 986
 987    @model_validator(mode="after")
 988    def validate_output_fields(self, info: ValidationInfo) -> Self:
 989        parent_eval_config = self.parent_eval_config()
 990        if self.scored_run_id is not None:
 991            # Pointer mode: the trace lives on the referenced TaskRun, and
 992            # validate_record_mode already forbids inline copies here. The checks
 993            # below are about data carried on this record, so none of them apply.
 994            return self
 995        parent_eval = parent_eval_config.parent_eval() if parent_eval_config else None
 996        if not parent_eval:
 997            return self
 998
 999        evaluation_data_type = parent_eval.evaluation_data_type
1000
1001        # A full_trace eval scores the conversation trace, so a successful task
1002        # run must carry it. Both V1 and V2 writers attach the trace for exactly
1003        # this shape (a scored, non-skipped task-run eval of a full_trace eval),
1004        # so demanding it back makes a writer that drops the trace fail loudly
1005        # instead of persisting a record that can't be re-scored. Historical
1006        # files predating this gate are exempt so they still load; new writes
1007        # and rebuilds are held to it.
1008        if (
1009            not self.eval_config_eval
1010            and self.skipped_reason is None
1011            and evaluation_data_type == EvalDataType.full_trace
1012            and self.task_run_trace is None
1013            and not self.loaded_from_file(info)
1014        ):
1015            raise ValueError("full_trace task run eval runs should include trace")
1016
1017        # Remaining checks are V1-only. V2 deliberately relaxes them: skipped
1018        # runs carry no output, and V2 writers never attach a trace to a
1019        # final_answer run in the first place.
1020        if parent_eval_config.config_type == EvalConfigType.v2:
1021            return self
1022
1023        if self.output is None and self.skipped_reason is None:
1024            raise ValueError("V1 EvalRun requires output to be set")
1025
1026        if (
1027            evaluation_data_type == EvalDataType.final_answer
1028            and self.task_run_trace is not None
1029        ):
1030            raise ValueError("final_answer runs should not set trace")
1031
1032        return self
1033
1034    @model_validator(mode="after")
1035    def validate_eval_run_types(self) -> Self:
1036        if self.eval_config_eval and self.task_run_config_id is not None:
1037            raise ValueError(
1038                "task_run_config_id must be None if eval_config_eval is true"
1039            )
1040        if not self.eval_config_eval and self.task_run_config_id is None:
1041            raise ValueError(
1042                "task_run_config_id must be set if eval_config_eval is false"
1043            )
1044        if self.eval_config_eval and self.dataset_id is None:
1045            raise ValueError(
1046                "eval_config_eval records must score a dataset item: judge "
1047                "calibration compares against human ratings, which only "
1048                "dataset items (TaskRuns) carry"
1049            )
1050        return self
1051
1052    @model_validator(mode="after")
1053    def validate_scores(self) -> Self:
1054        if self.skipped_reason is not None:
1055            return self
1056
1057        if self.scores is None or len(self.scores) == 0:
1058            raise ValueError("scores are required, and must have at least one score.")
1059
1060        parent_eval_config = self.parent_eval_config()
1061        eval = parent_eval_config.parent_eval() if parent_eval_config else None
1062        if not eval:
1063            return self
1064
1065        output_score_keys = [score.json_key() for score in eval.output_scores]
1066        if set(output_score_keys) != set(self.scores.keys()):
1067            raise ValueError(
1068                f"The scores produced by the evaluator must match the scores expected by the eval. Got: [{', '.join(self.scores.keys())}] and expected: [{', '.join(output_score_keys)}]"
1069            )
1070
1071        problems = validate_scores_against_output_scores(
1072            self.scores, eval.output_scores
1073        )
1074        if problems:
1075            raise ValueError(problems[0])
1076        return self
1077
1078    @model_validator(mode="after")
1079    def validate_reference_answer(self) -> Self:
1080        parent_eval_config = self.parent_eval_config()
1081        if parent_eval_config and parent_eval_config.config_type == EvalConfigType.v2:
1082            return self
1083        parent_eval = parent_eval_config.parent_eval() if parent_eval_config else None
1084        if not parent_eval:
1085            return self
1086
1087        evaluation_data_type = parent_eval.evaluation_data_type
1088        if (
1089            self.reference_answer is not None
1090            and evaluation_data_type is not None
1091            and evaluation_data_type != EvalDataType.reference_answer
1092        ):
1093            raise ValueError(
1094                f"reference_answer is only valid for reference answer evals. Got: {evaluation_data_type.value}"
1095            )
1096        return self
1097
1098
1099class EvalConfig(KilnParentedModel, KilnParentModel, parent_of={"runs": EvalRun}):
1100    """
1101    A configuration for running an eval. This includes anything needed to run the eval on a dataset like the prompt, model, thresholds, etc.
1102
1103    A eval might have many configs, example running the same eval with 2 different models. Comparing eval results is only valid within the scope of the same config.
1104    """
1105
1106    name: FilenameString = Field(description="The name of the eval config.")
1107    model_name: str | None = Field(
1108        default=None,
1109        description="The name of the model to use for this eval config. Required for legacy configs, None for V2.",
1110    )
1111    model_provider: str | None = Field(
1112        default=None,
1113        description="The provider of the model to use for this eval config. Required for legacy configs, None for V2.",
1114    )
1115    config_type: EvalConfigType = Field(
1116        default=EvalConfigType.g_eval,
1117        description="This is used to determine the type of eval to run.",
1118    )
1119    properties: dict[str, Any] | V2EvalConfigProperties | None = Field(
1120        default=None,
1121        description="Properties to be used to execute the eval config. Legacy configs use a dict; V2 configs use typed properties.",
1122    )
1123
1124    @model_validator(mode="before")
1125    @classmethod
1126    def dispatch_properties_parsing(cls, data: Any, info: ValidationInfo) -> Any:
1127        # The union lists dict first, so a raw dict always stays a plain dict —
1128        # even one whose keys happen to match a typed V2 shape (legacy configs
1129        # store arbitrary dicts). V2 configs persist properties as a dict too,
1130        # so parse those into the typed union here, before field validation.
1131        if not isinstance(data, dict):
1132            return data
1133        if data.get("config_type", EvalConfigType.g_eval) == EvalConfigType.v2:
1134            # code_eval stores its score() source in a sibling scorer.py: on file
1135            # load, the type-gated helper parses those props through
1136            # CodeEvalProperties (reading the sibling via the load context) so a
1137            # bad or missing scorer.py surfaces directly instead of being masked
1138            # by a union fallback. Other property types pass through unchanged.
1139            data = _eager_parse_code_eval_on_load(data, info.context or {})
1140            props = data.get("properties")
1141            if isinstance(props, dict):
1142                data = dict(data)
1143                data["properties"] = _V2_PROPERTIES_ADAPTER.validate_python(props)
1144        return data
1145
1146    def parent_eval(self) -> Union["Eval", None]:
1147        if self.parent is not None and self.parent.__class__.__name__ != "Eval":
1148            raise ValueError("parent must be an Eval")
1149        return self.parent  # type: ignore
1150
1151    def runs(self, readonly: bool = False) -> list[EvalRun]:
1152        return super().runs(readonly=readonly)  # type: ignore
1153
1154    @model_validator(mode="after")
1155    def validate_properties(self) -> Self:
1156        if self.config_type in (EvalConfigType.g_eval, EvalConfigType.llm_as_judge):
1157            if not isinstance(self.properties, dict):
1158                raise ValueError("Legacy config properties must be a dict")
1159            if "eval_steps" not in self.properties or not isinstance(
1160                self.properties["eval_steps"], list
1161            ):
1162                raise ValueError("eval_steps is required and must be a list for g_eval")
1163            if "task_description" in self.properties and not isinstance(
1164                self.properties["task_description"], str
1165            ):
1166                raise ValueError(
1167                    "task_description is optional, but if provided must be a string"
1168                )
1169            if self.model_name is None or self.model_provider is None:
1170                raise ValueError(
1171                    "model_name and model_provider are required for legacy configs"
1172                )
1173            return self
1174        elif self.config_type == EvalConfigType.v2:
1175            if not isinstance(self.properties, BaseModel):
1176                raise ValueError("V2 config requires typed properties")
1177            if self.model_name is not None or self.model_provider is not None:
1178                raise ValueError(
1179                    "V2 configs must not set root-level model_name/model_provider"
1180                )
1181            return self
1182        else:
1183            raise ValueError(f"Invalid eval config type: {self.config_type}")
1184
1185    @model_validator(mode="after")
1186    def validate_v2_templates_and_expressions(self) -> Self:
1187        if self.config_type != EvalConfigType.v2 or not isinstance(
1188            self.properties, BaseModel
1189        ):
1190            return self
1191
1192        from kiln_ai.utils.jinja_engine import (
1193            compile_expression_or_raise,
1194            compile_template_or_raise,
1195        )
1196
1197        props = self.properties
1198        if isinstance(props, LlmJudgeProperties):
1199            compile_template_or_raise(props.prompt_template)
1200            from jinja2 import meta
1201
1202            from kiln_ai.utils.jinja_engine import _template_env
1203
1204            referenced = meta.find_undeclared_variables(
1205                _template_env.parse(props.prompt_template)
1206            )
1207            meaningful = {"final_message", "trace", "task_input"}
1208            if not (referenced & meaningful):
1209                raise ValueError(
1210                    "prompt_template never references the model output. "
1211                    "A template that uses only reference_data (or no variables) "
1212                    "produces the same judge prompt for every run. "
1213                    "Reference the output, e.g. {{ final_message }}."
1214                )
1215
1216        if isinstance(
1217            props,
1218            (
1219                ExactMatchProperties,
1220                PatternMatchProperties,
1221                ContainsProperties,
1222                SetCheckProperties,
1223            ),
1224        ):
1225            if props.value_expression is not None:
1226                compile_expression_or_raise(props.value_expression)
1227
1228        return self
1229
1230    @model_validator(mode="after")
1231    def validate_json_serializable(self) -> "EvalConfig":
1232        if self.config_type == EvalConfigType.v2:
1233            return self
1234        if self.properties is None:
1235            return self
1236        try:
1237            json.dumps(self.properties, ensure_ascii=False)
1238        except TypeError as e:
1239            raise ValueError(f"Properties must be JSON serializable: {e!s}")
1240        return self
1241
1242
1243class EvalDataType(str, Enum):
1244    """The type of task output data to evaluate."""
1245
1246    final_answer = "final_answer"
1247    full_trace = "full_trace"
1248    reference_answer = "reference_answer"
1249
1250
1251class TaskRunSplit(BaseModel):
1252    """A split whose items are TaskRuns, selected by a dataset filter."""
1253
1254    # Fields a future build adds are preserved rather than dropped, for the same reason
1255    # Eval.splits keeps unknown split names: these files sync between app versions. It is
1256    # also why the legacy-field migration never overwrites a split that `splits` already
1257    # describes — rebuilding one from a bare filter-id string would drop everything else
1258    # on it.
1259    model_config = ConfigDict(extra="allow")
1260
1261    source: Literal["task_run"] = "task_run"
1262    filter_id: DatasetFilterId
1263
1264
1265class EvalInputSplit(BaseModel):
1266    """A split whose items are EvalInputs, selected by an eval-input filter."""
1267
1268    model_config = ConfigDict(extra="allow")
1269
1270    source: Literal["eval_input"] = "eval_input"
1271    filter_id: EvalInputFilterId
1272
1273
1274SplitRef = Annotated[
1275    Union[TaskRunSplit, EvalInputSplit],
1276    Discriminator("source"),
1277]
1278"""One of an eval's splits: which store its items come from, and which filter selects them.
1279Discriminated on `source`, so a split's backing is part of its value rather than a
1280convention a reader has to know."""
1281
1282EvalSplitName = Literal["train", "val", "test"]
1283"""The split names the API exposes. `Eval.splits` is keyed by plain `str` so a file
1284written by a build that knows a fourth split still loads here (see Eval.splits)."""
1285
1286LEGACY_SPLIT_FIELDS: Dict[str, str] = {
1287    "test": "eval_set_filter_id",
1288    "train": "train_set_filter_id",
1289}
1290"""Split name -> the deprecated flat `Eval` field a Kiln build predating `splits` stored it
1291in. These fields are an input format and nothing else: `Eval.migrate_legacy_split_fields`
1292reads each one once, on the way in, and clears it. Nothing else in the codebase reads or
1293writes them, and they are never written to disk again — see `Eval.splits`."""
1294
1295
1296class Eval(KilnParentedModel, KilnParentModel, parent_of={"configs": EvalConfig}):
1297    """An evaluator definition that specifies what to evaluate and how scores should be produced."""
1298
1299    name: FilenameString = Field(description="The name of the eval.")
1300    description: str | None = Field(
1301        default=None, description="The description of the eval"
1302    )
1303    template: EvalTemplateId | None = Field(
1304        default=None,
1305        description="The template selected when creating this eval. Useful for suggesting eval steps and output scores.",
1306    )
1307    current_config_id: ID_TYPE = Field(
1308        default=None,
1309        description="The id of the current config to use for this eval. This can be changed over time to run the same eval with different configs.",
1310    )
1311    eval_set_filter_id: DatasetFilterId | None = Field(
1312        default=None,
1313        deprecated=True,
1314        description="Deprecated, and neither read nor written. It exists only so evals written by a Kiln build that predates `splits` still load: on load its value is migrated into splits['test'] once, and the field is then cleared. It is always saved as null. Read splits['test'] instead.",
1315    )
1316    eval_configs_filter_id: DatasetFilterId | None = Field(
1317        default=None,
1318        description="The id of the dataset filter which defines which dataset items are included when comparing the quality of the eval configs under this eval. Should consist of dataset items with ratings.",
1319    )
1320    train_set_filter_id: DatasetFilterId | None = Field(
1321        default=None,
1322        deprecated=True,
1323        description="Deprecated, and neither read nor written. It exists only so evals written by a Kiln build that predates `splits` still load: on load its value is migrated into splits['train'] once, and the field is then cleared. It is always saved as null. Read splits['train'] instead.",
1324    )
1325    splits: Dict[str, SplitRef] = Field(
1326        default_factory=dict,
1327        description="The eval's dataset splits, keyed by split name ('test', 'train', 'val'), and the only place they are stored. Each split names the store its items come from and the filter that selects them. Keys this build doesn't know are preserved but not exposed. 'golden' is not a split and does not belong here: the golden set must be dataset (TaskRun) based, because human ratings only exist on dataset items, so it is stored in eval_configs_filter_id instead. Nothing reads splits['golden'] — writing it is accepted and silently ignored. In Python, prefer Eval.set_split() to assigning into this dict: it refuses to mutate a readonly (cached) eval, and marks the field as set so exclude_unset dumps keep it.",
1328    )
1329    output_scores: List[EvalOutputScore] = Field(
1330        description="The scores this evaluator should produce."
1331    )
1332    favourite: bool = Field(
1333        default=False,
1334        description="Whether this eval is a favourite of the user. Rendered as a star icon in the UI.",
1335    )
1336    priority: Priority | None = Field(
1337        default=None,
1338        description="The priority of the eval. None on evals created before priority lived on evals; read through resolved_priority(), which falls back to the associated spec.",
1339    )
1340    status: EvalStatus | None = Field(
1341        default=None,
1342        description="The status of the eval. None on evals created before status lived on evals; read through resolved_status(), which falls back to the associated spec.",
1343    )
1344    template_properties: dict[str, str | int | bool | float] | None = Field(
1345        default=None,
1346        description="Properties to be used to execute the eval. This is template_type specific and should serialize to a json dict.",
1347    )
1348    evaluation_data_type: EvalDataType | None = Field(
1349        default=EvalDataType.final_answer,
1350        description="The output of the task run to evaluate. Can be final answer, full trace, or None for V2 evals.",
1351    )
1352
1353    @model_validator(mode="after")
1354    def migrate_legacy_split_fields(self) -> Self:
1355        """Migrate the deprecated flat filter fields into `splits`, once, and clear them.
1356
1357        `splits` is the only home a split has. These fields are an input format for evals
1358        written before it existed, so each one is read exactly once — here — and only for
1359        a split `splits` doesn't already describe. `splits` winning is what makes the
1360        migration one-way: once a value is in `splits` it is the eval's answer, and a
1361        legacy field left over beside it (a hand-edited file, or one an older build wrote
1362        after a newer one) is ignored rather than allowed to overwrite it. Overwriting
1363        would also drop any extra fields on the existing split object, which
1364        `TaskRunSplit`/`EvalInputSplit` keep on purpose (`extra="allow"`).
1365
1366        Both fields are then cleared, unconditionally. That is what makes this a
1367        migration rather than a second home: nothing downstream can read a stale value,
1368        the eval saves with both fields null, and re-running the validator — which
1369        `validate_assignment` does on every attribute set, including `self.path = path`
1370        at the end of save_to_file — has nothing left to do. An older Kiln build reading
1371        the saved file sees no test set rather than the wrong one; that is the accepted
1372        cost of a single home, and the eval list surfaces the evals it can't read.
1373
1374        Reads and writes go through `__dict__` because the fields are
1375        `deprecated=True`: attribute access on them emits a DeprecationWarning, which is
1376        meant for callers, not for the one place that is supposed to touch them.
1377
1378        Must stay declared before validate_splits, which requires a test split: an eval
1379        that carries only legacy fields gets its test split from here.
1380        """
1381        for name, field_name in LEGACY_SPLIT_FIELDS.items():
1382            filter_id = self.__dict__.get(field_name)
1383            if filter_id is not None and name not in self.splits:
1384                self.splits[name] = TaskRunSplit(filter_id=filter_id)
1385                # The split now lives only in `splits`, so an exclude_unset dump has to
1386                # carry it: on a legacy eval `splits` was never explicitly set.
1387                self.__pydantic_fields_set__.add("splits")
1388            self.__dict__[field_name] = None
1389        return self
1390
1391    @model_validator(mode="after")
1392    def validate_splits(self) -> Self:
1393        if "test" not in self.splits:
1394            raise ValueError("An eval must have a test split. Set splits['test'].")
1395        return self
1396
1397    def set_split(self, name: str, split: SplitRef) -> None:
1398        """Set one of the eval's splits.
1399
1400        Equivalent to `eval.splits[name] = split` plus the two things item assignment on
1401        a dict can't do for itself, because it never reaches `__setattr__`: refusing to
1402        mutate a readonly (cached) eval, and marking `splits` as set so an
1403        exclude_unset dump still carries it.
1404        """
1405        # Readonly instances are the cached ones, shared with every other holder of the
1406        # same file, so this check has to be explicit here.
1407        self._ensure_not_readonly("splits")
1408        self.splits[name] = split
1409        # Validated evals always have `splits` marked already (their test split came from
1410        # `splits` or from the legacy migration, which marks it), so this is for instances
1411        # built by model_construct, where nothing did.
1412        self.__pydantic_fields_set__.add("splits")
1413
1414    # Workaround to return typed parent without importing Task
1415    def parent_task(self) -> Union["Task", None]:
1416        if self.parent is not None and self.parent.__class__.__name__ != "Task":
1417            raise ValueError("parent must be a Task")
1418        return self.parent  # type: ignore
1419
1420    def configs(self, readonly: bool = False) -> list[EvalConfig]:
1421        return super().configs(readonly=readonly)  # type: ignore
1422
1423    # Workaround to return typed parent without importing Spec
1424    def associated_spec(self, readonly: bool = False) -> Union["Spec", None]:
1425        """
1426        Get the spec associated with this eval, if any.
1427        Returns None for legacy evals that are not associated with a spec.
1428        """
1429
1430        task = self.parent_task()
1431        if not task or not self.id:
1432            return None
1433
1434        specs = task.specs(readonly=readonly)
1435        for spec in specs:
1436            if spec.eval_id == self.id:
1437                return spec
1438        return None
1439
1440    def resolved_priority(self, spec: Union["Spec", None] = None) -> Priority:
1441        """
1442        The eval's effective priority. Priority lives on the eval; evals created
1443        before that (spec-backed legacy files) fall back to their spec's value.
1444        Pass *spec* when the caller already has it, to avoid a re-scan.
1445        """
1446        if self.priority is not None:
1447            return self.priority
1448        spec = spec or self.associated_spec(readonly=True)
1449        if spec is not None:
1450            return spec.priority
1451        return Priority.p1
1452
1453    def resolved_status(self, spec: Union["Spec", None] = None) -> EvalStatus:
1454        """
1455        The eval's effective status, with the same spec fallthrough as
1456        resolved_priority().
1457        """
1458        if self.status is not None:
1459            return self.status
1460        spec = spec or self.associated_spec(readonly=True)
1461        if spec is not None:
1462            return spec.status
1463        return EvalStatus.active
1464
1465    def eval_reference_data_keys(self) -> list[str]:
1466        """Union of reference-data keys across all of this eval's V2 configs.
1467
1468        Returns deduplicated keys in stable insertion order.
1469        """
1470        seen: set[str] = set()
1471        result: list[str] = []
1472        for config in self.configs(readonly=True):
1473            if config.config_type != EvalConfigType.v2:
1474                continue
1475            if not isinstance(config.properties, V2_PROPERTY_TYPES):
1476                continue
1477            for key in reference_data_keys(config.properties):  # type: ignore[arg-type]
1478                if key not in seen:
1479                    seen.add(key)
1480                    result.append(key)
1481        return result
1482
1483    @model_validator(mode="after")
1484    def upgrade_old_reference_answer_eval_config(self) -> Self:
1485        """
1486        Migration: Set the first judge config as the default for existing reference answer evals that don't have a current_config_id set.
1487
1488        For reference_answer evals that don't have a current_config_id set, this migration
1489        will set the first config (by created_at) as the default.
1490        """
1491        if self.id is None:
1492            return self
1493
1494        # Only run during file loading
1495        if not self._loaded_from_file:
1496            return self
1497
1498        # Skip if already migrated (has a current_config_id set)
1499        if self.current_config_id is not None:
1500            return self
1501
1502        # Only migrate reference_answer evals
1503        if self.evaluation_data_type != EvalDataType.reference_answer:
1504            return self
1505
1506        # Prevent recursion: self.configs() loads child files, which re-loads this parent
1507        # (see basemodel.py where we iterate_children_paths_of_parent_path calls load_from_file)
1508        # This causes the validator to run again, creating an infinite loop without this guard.
1509        with _migration_lock:
1510            if self.id in _currently_migrating_eval_ids:
1511                return self
1512            _currently_migrating_eval_ids.add(self.id)
1513
1514        try:
1515            # Get the configs - these are loaded from child files
1516            configs_list = self.configs(readonly=True)
1517            if configs_list and len(configs_list) > 0:
1518                # Sort by created_at to get the oldest (first created) config
1519                sorted_configs = sorted(configs_list, key=lambda c: c.created_at)
1520                self.current_config_id = sorted_configs[0].id
1521        finally:
1522            with _migration_lock:
1523                _currently_migrating_eval_ids.discard(self.id)
1524
1525        return self
1526
1527    @model_validator(mode="after")
1528    def validate_scores(self) -> Self:
1529        if self.output_scores is None or len(self.output_scores) == 0:
1530            raise ValueError(
1531                "output_scores are required, and must have at least one score."
1532            )
1533
1534        # check for duplicate names (once transformed to JSON keys)
1535        output_score_keys = [score.json_key() for score in self.output_scores]
1536        if len(output_score_keys) != len(set(output_score_keys)):
1537            raise ValueError(
1538                f"output_scores must have unique names (once transformed to JSON keys). Got: [{', '.join(output_score_keys)}]"
1539            )
1540        return self
1541
1542    @model_validator(mode="after")
1543    def validate_template_properties(self) -> Self:
1544        if self.template is None:
1545            return self
1546
1547        if (
1548            self.template is not EvalTemplateId.rag
1549            and self.eval_configs_filter_id is None
1550        ):
1551            raise ValueError(
1552                "eval_configs_filter_id is required for all templates except 'rag'"
1553            )
1554
1555        # For spec-based evals, template_properties will be None and validation happens in the spec
1556        # For legacy evals, template_properties contains the data and we validate here
1557        if self.template_properties is None:
1558            return self
1559
1560        # Check for properties that are required for the issue template (legacy evals only)
1561        if self.template == EvalTemplateId.issue:
1562            if "issue_prompt" not in self.template_properties or not isinstance(
1563                self.template_properties["issue_prompt"], str
1564            ):
1565                raise ValueError("issue_prompt is required for issue template")
1566            if "failure_example" in self.template_properties and not isinstance(
1567                self.template_properties["failure_example"], str
1568            ):
1569                raise ValueError(
1570                    "failure_example is optional for issue template, but if provided must be a string"
1571                )
1572            if "pass_example" in self.template_properties and not isinstance(
1573                self.template_properties["pass_example"], str
1574            ):
1575                raise ValueError(
1576                    "pass_example is optional for issue template, but if provided must be a string"
1577                )
1578
1579        if self.template == EvalTemplateId.tool_call:
1580            if self.evaluation_data_type != EvalDataType.full_trace:
1581                raise ValueError(
1582                    "tool_call template should have evaluation_data_type set to full_trace"
1583                )
1584            if (
1585                "tool" not in self.template_properties
1586                or not isinstance(self.template_properties["tool"], str)
1587                or not self.template_properties["tool"].strip()
1588            ):
1589                raise ValueError("tool is required for tool call template")
1590            if "tool_function_name" not in self.template_properties or not isinstance(
1591                self.template_properties["tool_function_name"], str
1592            ):
1593                raise ValueError(
1594                    "tool_function_name is required for tool call template"
1595                )
1596            if (
1597                "appropriate_tool_use_guidelines" not in self.template_properties
1598                or not isinstance(
1599                    self.template_properties["appropriate_tool_use_guidelines"], str
1600                )
1601                or not self.template_properties[
1602                    "appropriate_tool_use_guidelines"
1603                ].strip()
1604            ):
1605                raise ValueError(
1606                    "appropriate_tool_use_guidelines is required for tool call template"
1607                )
1608            if (
1609                "inappropriate_tool_use_guidelines" in self.template_properties
1610                and not isinstance(
1611                    self.template_properties["inappropriate_tool_use_guidelines"], str
1612                )
1613            ):
1614                raise ValueError(
1615                    "inappropriate_tool_use_guidelines is optional for tool call template, but if provided must be a string"
1616                )
1617        return self
EvalScores = typing.Dict[str, float]
SCORER_CODE_FILENAME = 'scorer.py'
class EvalTemplateId(builtins.str, enum.Enum):
65class EvalTemplateId(str, Enum):
66    """
67    An eval template is a pre-defined eval that can be used as a starting point for a new eval.
68    """
69
70    kiln_requirements = "kiln_requirements"
71    desired_behaviour = "desired_behaviour"
72    issue = "kiln_issue"
73    tool_call = "tool_call"
74    toxicity = "toxicity"
75    bias = "bias"
76    maliciousness = "maliciousness"
77    factual_correctness = "factual_correctness"
78    jailbreak = "jailbreak"
79    rag = "rag"

An eval template is a pre-defined eval that can be used as a starting point for a new eval.

kiln_requirements = <EvalTemplateId.kiln_requirements: 'kiln_requirements'>
desired_behaviour = <EvalTemplateId.desired_behaviour: 'desired_behaviour'>
issue = <EvalTemplateId.issue: 'kiln_issue'>
tool_call = <EvalTemplateId.tool_call: 'tool_call'>
toxicity = <EvalTemplateId.toxicity: 'toxicity'>
bias = <EvalTemplateId.bias: 'bias'>
maliciousness = <EvalTemplateId.maliciousness: 'maliciousness'>
factual_correctness = <EvalTemplateId.factual_correctness: 'factual_correctness'>
jailbreak = <EvalTemplateId.jailbreak: 'jailbreak'>
rag = <EvalTemplateId.rag: 'rag'>
class EvalConfigType(builtins.str, enum.Enum):
82class EvalConfigType(str, Enum):
83    """The type of eval configuration, determining how scores are generated."""
84
85    g_eval = "g_eval"
86    llm_as_judge = "llm_as_judge"
87    v2 = "v2"

The type of eval configuration, determining how scores are generated.

g_eval = <EvalConfigType.g_eval: 'g_eval'>
llm_as_judge = <EvalConfigType.llm_as_judge: 'llm_as_judge'>
v2 = <EvalConfigType.v2: 'v2'>
class V2EvalType(builtins.str, enum.Enum):
 90class V2EvalType(str, Enum):
 91    """V2-only eval type enum. Each value maps to a typed properties class
 92    and a V2 adapter."""
 93
 94    llm_judge = "llm_judge"
 95    exact_match = "exact_match"
 96    pattern_match = "pattern_match"
 97    set_check = "set_check"
 98    tool_call_check = "tool_call_check"
 99    contains = "contains"
100    step_count_check = "step_count_check"
101    code_eval = "code_eval"

V2-only eval type enum. Each value maps to a typed properties class and a V2 adapter.

llm_judge = <V2EvalType.llm_judge: 'llm_judge'>
exact_match = <V2EvalType.exact_match: 'exact_match'>
pattern_match = <V2EvalType.pattern_match: 'pattern_match'>
set_check = <V2EvalType.set_check: 'set_check'>
tool_call_check = <V2EvalType.tool_call_check: 'tool_call_check'>
contains = <V2EvalType.contains: 'contains'>
step_count_check = <V2EvalType.step_count_check: 'step_count_check'>
code_eval = <V2EvalType.code_eval: 'code_eval'>
class LlmJudgeProperties(pydantic.main.BaseModel):
104class LlmJudgeProperties(BaseModel):
105    type: Literal[V2EvalType.llm_judge] = V2EvalType.llm_judge
106    model_name: str
107    model_provider: str
108    system_prompt: str | None = None
109    prompt_template: str
110    reference_keys: list[str] = []
111    thinking_instruction: str | None = None
112    g_eval: bool = False
113    # User-written evaluation steps, bound to {{ judge_instructions }} when the
114    # prompt template is rendered. Used by evals with no spec or template to
115    # derive default steps from.
116    judge_instructions: list[str] | None = None

!!! abstract "Usage Documentation" Models

A base class for creating Pydantic models.

Attributes: __class_vars__: The names of the class variables defined on the model. __private_attributes__: Metadata about the private attributes of the model. __signature__: The synthesized __init__ [Signature][inspect.Signature] of the model.

__pydantic_complete__: Whether model building is completed, or if there are still undefined fields.
__pydantic_core_schema__: The core schema of the model.
__pydantic_custom_init__: Whether the model has a custom `__init__` function.
__pydantic_decorators__: Metadata containing the decorators defined on the model.
    This replaces `Model.__validators__` and `Model.__root_validators__` from Pydantic V1.
__pydantic_generic_metadata__: A dictionary containing metadata about generic Pydantic models.
    The `origin` and `args` items map to the [`__origin__`][genericalias.__origin__]
    and [`__args__`][genericalias.__args__] attributes of [generic aliases][types-genericalias],
    and the `parameter` item maps to the `__parameter__` attribute of generic classes.
__pydantic_parent_namespace__: Parent namespace of the model, used for automatic rebuilding of models.
__pydantic_post_init__: The name of the post-init method for the model, if defined.
__pydantic_root_model__: Whether the model is a [`RootModel`][pydantic.root_model.RootModel].
__pydantic_serializer__: The `pydantic-core` `SchemaSerializer` used to dump instances of the model.
__pydantic_validator__: The `pydantic-core` `SchemaValidator` used to validate instances of the model.

__pydantic_fields__: A dictionary of field names and their corresponding [`FieldInfo`][pydantic.fields.FieldInfo] objects.
__pydantic_computed_fields__: A dictionary of computed field names and their corresponding [`ComputedFieldInfo`][pydantic.fields.ComputedFieldInfo] objects.

__pydantic_extra__: A dictionary containing extra values, if [`extra`][pydantic.config.ConfigDict.extra]
    is set to `'allow'`.
__pydantic_fields_set__: The names of fields explicitly set during instantiation.
__pydantic_private__: Values of private attributes set on the model instance.
type: Literal[<V2EvalType.llm_judge: 'llm_judge'>]
model_name: str
model_provider: str
system_prompt: str | None
prompt_template: str
reference_keys: list[str]
thinking_instruction: str | None
g_eval: bool
judge_instructions: list[str] | None
model_config: ClassVar[pydantic.config.ConfigDict] = {}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

class ExactMatchProperties(pydantic.main.BaseModel):
119class ExactMatchProperties(BaseModel):
120    type: Literal[V2EvalType.exact_match] = V2EvalType.exact_match
121    value_expression: str | None = None
122    expected_value: str | None = None
123    reference_key: str | None = Field(default=None, min_length=1)
124    case_sensitive: bool = True
125
126    @model_validator(mode="after")
127    def validate_value_source(self) -> Self:
128        if (self.expected_value is None) == (self.reference_key is None):
129            raise ValueError(
130                "Exactly one of expected_value or reference_key must be set"
131            )
132        return self

!!! abstract "Usage Documentation" Models

A base class for creating Pydantic models.

Attributes: __class_vars__: The names of the class variables defined on the model. __private_attributes__: Metadata about the private attributes of the model. __signature__: The synthesized __init__ [Signature][inspect.Signature] of the model.

__pydantic_complete__: Whether model building is completed, or if there are still undefined fields.
__pydantic_core_schema__: The core schema of the model.
__pydantic_custom_init__: Whether the model has a custom `__init__` function.
__pydantic_decorators__: Metadata containing the decorators defined on the model.
    This replaces `Model.__validators__` and `Model.__root_validators__` from Pydantic V1.
__pydantic_generic_metadata__: A dictionary containing metadata about generic Pydantic models.
    The `origin` and `args` items map to the [`__origin__`][genericalias.__origin__]
    and [`__args__`][genericalias.__args__] attributes of [generic aliases][types-genericalias],
    and the `parameter` item maps to the `__parameter__` attribute of generic classes.
__pydantic_parent_namespace__: Parent namespace of the model, used for automatic rebuilding of models.
__pydantic_post_init__: The name of the post-init method for the model, if defined.
__pydantic_root_model__: Whether the model is a [`RootModel`][pydantic.root_model.RootModel].
__pydantic_serializer__: The `pydantic-core` `SchemaSerializer` used to dump instances of the model.
__pydantic_validator__: The `pydantic-core` `SchemaValidator` used to validate instances of the model.

__pydantic_fields__: A dictionary of field names and their corresponding [`FieldInfo`][pydantic.fields.FieldInfo] objects.
__pydantic_computed_fields__: A dictionary of computed field names and their corresponding [`ComputedFieldInfo`][pydantic.fields.ComputedFieldInfo] objects.

__pydantic_extra__: A dictionary containing extra values, if [`extra`][pydantic.config.ConfigDict.extra]
    is set to `'allow'`.
__pydantic_fields_set__: The names of fields explicitly set during instantiation.
__pydantic_private__: Values of private attributes set on the model instance.
type: Literal[<V2EvalType.exact_match: 'exact_match'>]
value_expression: str | None
expected_value: str | None
reference_key: str | None
case_sensitive: bool
@model_validator(mode='after')
def validate_value_source(self) -> Self:
126    @model_validator(mode="after")
127    def validate_value_source(self) -> Self:
128        if (self.expected_value is None) == (self.reference_key is None):
129            raise ValueError(
130                "Exactly one of expected_value or reference_key must be set"
131            )
132        return self
model_config: ClassVar[pydantic.config.ConfigDict] = {}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

class PatternMatchProperties(pydantic.main.BaseModel):
135class PatternMatchProperties(BaseModel):
136    type: Literal[V2EvalType.pattern_match] = V2EvalType.pattern_match
137    value_expression: str | None = None
138    pattern: str
139    mode: Literal["must_match", "must_not_match"] = "must_match"
140
141    @model_validator(mode="after")
142    def validate_pattern(self) -> Self:
143        import re
144
145        try:
146            re.compile(self.pattern)
147        except re.error as e:
148            raise ValueError(f"Invalid regex pattern '{self.pattern}': {e}") from e
149        return self

!!! abstract "Usage Documentation" Models

A base class for creating Pydantic models.

Attributes: __class_vars__: The names of the class variables defined on the model. __private_attributes__: Metadata about the private attributes of the model. __signature__: The synthesized __init__ [Signature][inspect.Signature] of the model.

__pydantic_complete__: Whether model building is completed, or if there are still undefined fields.
__pydantic_core_schema__: The core schema of the model.
__pydantic_custom_init__: Whether the model has a custom `__init__` function.
__pydantic_decorators__: Metadata containing the decorators defined on the model.
    This replaces `Model.__validators__` and `Model.__root_validators__` from Pydantic V1.
__pydantic_generic_metadata__: A dictionary containing metadata about generic Pydantic models.
    The `origin` and `args` items map to the [`__origin__`][genericalias.__origin__]
    and [`__args__`][genericalias.__args__] attributes of [generic aliases][types-genericalias],
    and the `parameter` item maps to the `__parameter__` attribute of generic classes.
__pydantic_parent_namespace__: Parent namespace of the model, used for automatic rebuilding of models.
__pydantic_post_init__: The name of the post-init method for the model, if defined.
__pydantic_root_model__: Whether the model is a [`RootModel`][pydantic.root_model.RootModel].
__pydantic_serializer__: The `pydantic-core` `SchemaSerializer` used to dump instances of the model.
__pydantic_validator__: The `pydantic-core` `SchemaValidator` used to validate instances of the model.

__pydantic_fields__: A dictionary of field names and their corresponding [`FieldInfo`][pydantic.fields.FieldInfo] objects.
__pydantic_computed_fields__: A dictionary of computed field names and their corresponding [`ComputedFieldInfo`][pydantic.fields.ComputedFieldInfo] objects.

__pydantic_extra__: A dictionary containing extra values, if [`extra`][pydantic.config.ConfigDict.extra]
    is set to `'allow'`.
__pydantic_fields_set__: The names of fields explicitly set during instantiation.
__pydantic_private__: Values of private attributes set on the model instance.
type: Literal[<V2EvalType.pattern_match: 'pattern_match'>]
value_expression: str | None
pattern: str
mode: Literal['must_match', 'must_not_match']
@model_validator(mode='after')
def validate_pattern(self) -> Self:
141    @model_validator(mode="after")
142    def validate_pattern(self) -> Self:
143        import re
144
145        try:
146            re.compile(self.pattern)
147        except re.error as e:
148            raise ValueError(f"Invalid regex pattern '{self.pattern}': {e}") from e
149        return self
model_config: ClassVar[pydantic.config.ConfigDict] = {}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

class ContainsProperties(pydantic.main.BaseModel):
152class ContainsProperties(BaseModel):
153    type: Literal[V2EvalType.contains] = V2EvalType.contains
154    value_expression: str | None = None
155    substring: str | None = None
156    reference_key: str | None = Field(default=None, min_length=1)
157    case_sensitive: bool = True
158    mode: Literal["must_contain", "must_not_contain"] = "must_contain"
159
160    @model_validator(mode="after")
161    def validate_value_source(self) -> Self:
162        if (self.substring is None) == (self.reference_key is None):
163            raise ValueError("Exactly one of substring or reference_key must be set")
164        return self

!!! abstract "Usage Documentation" Models

A base class for creating Pydantic models.

Attributes: __class_vars__: The names of the class variables defined on the model. __private_attributes__: Metadata about the private attributes of the model. __signature__: The synthesized __init__ [Signature][inspect.Signature] of the model.

__pydantic_complete__: Whether model building is completed, or if there are still undefined fields.
__pydantic_core_schema__: The core schema of the model.
__pydantic_custom_init__: Whether the model has a custom `__init__` function.
__pydantic_decorators__: Metadata containing the decorators defined on the model.
    This replaces `Model.__validators__` and `Model.__root_validators__` from Pydantic V1.
__pydantic_generic_metadata__: A dictionary containing metadata about generic Pydantic models.
    The `origin` and `args` items map to the [`__origin__`][genericalias.__origin__]
    and [`__args__`][genericalias.__args__] attributes of [generic aliases][types-genericalias],
    and the `parameter` item maps to the `__parameter__` attribute of generic classes.
__pydantic_parent_namespace__: Parent namespace of the model, used for automatic rebuilding of models.
__pydantic_post_init__: The name of the post-init method for the model, if defined.
__pydantic_root_model__: Whether the model is a [`RootModel`][pydantic.root_model.RootModel].
__pydantic_serializer__: The `pydantic-core` `SchemaSerializer` used to dump instances of the model.
__pydantic_validator__: The `pydantic-core` `SchemaValidator` used to validate instances of the model.

__pydantic_fields__: A dictionary of field names and their corresponding [`FieldInfo`][pydantic.fields.FieldInfo] objects.
__pydantic_computed_fields__: A dictionary of computed field names and their corresponding [`ComputedFieldInfo`][pydantic.fields.ComputedFieldInfo] objects.

__pydantic_extra__: A dictionary containing extra values, if [`extra`][pydantic.config.ConfigDict.extra]
    is set to `'allow'`.
__pydantic_fields_set__: The names of fields explicitly set during instantiation.
__pydantic_private__: Values of private attributes set on the model instance.
type: Literal[<V2EvalType.contains: 'contains'>]
value_expression: str | None
substring: str | None
reference_key: str | None
case_sensitive: bool
mode: Literal['must_contain', 'must_not_contain']
@model_validator(mode='after')
def validate_value_source(self) -> Self:
160    @model_validator(mode="after")
161    def validate_value_source(self) -> Self:
162        if (self.substring is None) == (self.reference_key is None):
163            raise ValueError("Exactly one of substring or reference_key must be set")
164        return self
model_config: ClassVar[pydantic.config.ConfigDict] = {}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

class SetCheckProperties(pydantic.main.BaseModel):
167class SetCheckProperties(BaseModel):
168    type: Literal[V2EvalType.set_check] = V2EvalType.set_check
169    value_expression: str | None = None
170    expected_set: list[str] | None = None
171    reference_key: str | None = Field(default=None, min_length=1)
172    mode: Literal["subset", "superset", "equal"]
173
174    @model_validator(mode="after")
175    def validate_value_source(self) -> Self:
176        if (self.expected_set is None) == (self.reference_key is None):
177            raise ValueError("Exactly one of expected_set or reference_key must be set")
178        return self

!!! abstract "Usage Documentation" Models

A base class for creating Pydantic models.

Attributes: __class_vars__: The names of the class variables defined on the model. __private_attributes__: Metadata about the private attributes of the model. __signature__: The synthesized __init__ [Signature][inspect.Signature] of the model.

__pydantic_complete__: Whether model building is completed, or if there are still undefined fields.
__pydantic_core_schema__: The core schema of the model.
__pydantic_custom_init__: Whether the model has a custom `__init__` function.
__pydantic_decorators__: Metadata containing the decorators defined on the model.
    This replaces `Model.__validators__` and `Model.__root_validators__` from Pydantic V1.
__pydantic_generic_metadata__: A dictionary containing metadata about generic Pydantic models.
    The `origin` and `args` items map to the [`__origin__`][genericalias.__origin__]
    and [`__args__`][genericalias.__args__] attributes of [generic aliases][types-genericalias],
    and the `parameter` item maps to the `__parameter__` attribute of generic classes.
__pydantic_parent_namespace__: Parent namespace of the model, used for automatic rebuilding of models.
__pydantic_post_init__: The name of the post-init method for the model, if defined.
__pydantic_root_model__: Whether the model is a [`RootModel`][pydantic.root_model.RootModel].
__pydantic_serializer__: The `pydantic-core` `SchemaSerializer` used to dump instances of the model.
__pydantic_validator__: The `pydantic-core` `SchemaValidator` used to validate instances of the model.

__pydantic_fields__: A dictionary of field names and their corresponding [`FieldInfo`][pydantic.fields.FieldInfo] objects.
__pydantic_computed_fields__: A dictionary of computed field names and their corresponding [`ComputedFieldInfo`][pydantic.fields.ComputedFieldInfo] objects.

__pydantic_extra__: A dictionary containing extra values, if [`extra`][pydantic.config.ConfigDict.extra]
    is set to `'allow'`.
__pydantic_fields_set__: The names of fields explicitly set during instantiation.
__pydantic_private__: Values of private attributes set on the model instance.
type: Literal[<V2EvalType.set_check: 'set_check'>]
value_expression: str | None
expected_set: list[str] | None
reference_key: str | None
mode: Literal['subset', 'superset', 'equal']
@model_validator(mode='after')
def validate_value_source(self) -> Self:
174    @model_validator(mode="after")
175    def validate_value_source(self) -> Self:
176        if (self.expected_set is None) == (self.reference_key is None):
177            raise ValueError("Exactly one of expected_set or reference_key must be set")
178        return self
model_config: ClassVar[pydantic.config.ConfigDict] = {}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

class ArgMatch(pydantic.main.BaseModel):
181class ArgMatch(BaseModel):
182    value: JsonValue
183    match_mode: Literal["exact", "contains", "regex"] = "exact"
184
185    @model_validator(mode="after")
186    def validate_regex(self) -> Self:
187        if self.match_mode == "regex":
188            import re
189
190            try:
191                re.compile(str(self.value))
192            except re.error as e:
193                raise ValueError(f"Invalid regex value '{self.value}': {e}") from e
194        return self

!!! abstract "Usage Documentation" Models

A base class for creating Pydantic models.

Attributes: __class_vars__: The names of the class variables defined on the model. __private_attributes__: Metadata about the private attributes of the model. __signature__: The synthesized __init__ [Signature][inspect.Signature] of the model.

__pydantic_complete__: Whether model building is completed, or if there are still undefined fields.
__pydantic_core_schema__: The core schema of the model.
__pydantic_custom_init__: Whether the model has a custom `__init__` function.
__pydantic_decorators__: Metadata containing the decorators defined on the model.
    This replaces `Model.__validators__` and `Model.__root_validators__` from Pydantic V1.
__pydantic_generic_metadata__: A dictionary containing metadata about generic Pydantic models.
    The `origin` and `args` items map to the [`__origin__`][genericalias.__origin__]
    and [`__args__`][genericalias.__args__] attributes of [generic aliases][types-genericalias],
    and the `parameter` item maps to the `__parameter__` attribute of generic classes.
__pydantic_parent_namespace__: Parent namespace of the model, used for automatic rebuilding of models.
__pydantic_post_init__: The name of the post-init method for the model, if defined.
__pydantic_root_model__: Whether the model is a [`RootModel`][pydantic.root_model.RootModel].
__pydantic_serializer__: The `pydantic-core` `SchemaSerializer` used to dump instances of the model.
__pydantic_validator__: The `pydantic-core` `SchemaValidator` used to validate instances of the model.

__pydantic_fields__: A dictionary of field names and their corresponding [`FieldInfo`][pydantic.fields.FieldInfo] objects.
__pydantic_computed_fields__: A dictionary of computed field names and their corresponding [`ComputedFieldInfo`][pydantic.fields.ComputedFieldInfo] objects.

__pydantic_extra__: A dictionary containing extra values, if [`extra`][pydantic.config.ConfigDict.extra]
    is set to `'allow'`.
__pydantic_fields_set__: The names of fields explicitly set during instantiation.
__pydantic_private__: Values of private attributes set on the model instance.
value: JsonValue
match_mode: Literal['exact', 'contains', 'regex']
@model_validator(mode='after')
def validate_regex(self) -> Self:
185    @model_validator(mode="after")
186    def validate_regex(self) -> Self:
187        if self.match_mode == "regex":
188            import re
189
190            try:
191                re.compile(str(self.value))
192            except re.error as e:
193                raise ValueError(f"Invalid regex value '{self.value}': {e}") from e
194        return self
model_config: ClassVar[pydantic.config.ConfigDict] = {}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

class ToolCallSpec(pydantic.main.BaseModel):
197class ToolCallSpec(BaseModel):
198    tool_name: str
199    expected_args: dict[str, ArgMatch] | None = None

!!! abstract "Usage Documentation" Models

A base class for creating Pydantic models.

Attributes: __class_vars__: The names of the class variables defined on the model. __private_attributes__: Metadata about the private attributes of the model. __signature__: The synthesized __init__ [Signature][inspect.Signature] of the model.

__pydantic_complete__: Whether model building is completed, or if there are still undefined fields.
__pydantic_core_schema__: The core schema of the model.
__pydantic_custom_init__: Whether the model has a custom `__init__` function.
__pydantic_decorators__: Metadata containing the decorators defined on the model.
    This replaces `Model.__validators__` and `Model.__root_validators__` from Pydantic V1.
__pydantic_generic_metadata__: A dictionary containing metadata about generic Pydantic models.
    The `origin` and `args` items map to the [`__origin__`][genericalias.__origin__]
    and [`__args__`][genericalias.__args__] attributes of [generic aliases][types-genericalias],
    and the `parameter` item maps to the `__parameter__` attribute of generic classes.
__pydantic_parent_namespace__: Parent namespace of the model, used for automatic rebuilding of models.
__pydantic_post_init__: The name of the post-init method for the model, if defined.
__pydantic_root_model__: Whether the model is a [`RootModel`][pydantic.root_model.RootModel].
__pydantic_serializer__: The `pydantic-core` `SchemaSerializer` used to dump instances of the model.
__pydantic_validator__: The `pydantic-core` `SchemaValidator` used to validate instances of the model.

__pydantic_fields__: A dictionary of field names and their corresponding [`FieldInfo`][pydantic.fields.FieldInfo] objects.
__pydantic_computed_fields__: A dictionary of computed field names and their corresponding [`ComputedFieldInfo`][pydantic.fields.ComputedFieldInfo] objects.

__pydantic_extra__: A dictionary containing extra values, if [`extra`][pydantic.config.ConfigDict.extra]
    is set to `'allow'`.
__pydantic_fields_set__: The names of fields explicitly set during instantiation.
__pydantic_private__: Values of private attributes set on the model instance.
tool_name: str
expected_args: dict[str, ArgMatch] | None
model_config: ClassVar[pydantic.config.ConfigDict] = {}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

class ToolCallCheckProperties(pydantic.main.BaseModel):
202class ToolCallCheckProperties(BaseModel):
203    type: Literal[V2EvalType.tool_call_check] = V2EvalType.tool_call_check
204    expected_tools: list[ToolCallSpec] = Field(min_length=1)
205    match_mode: Literal["any", "all", "ordered", "never"] = "all"
206    on_unexpected_tools: Literal["ignore", "fail"] = "ignore"

!!! abstract "Usage Documentation" Models

A base class for creating Pydantic models.

Attributes: __class_vars__: The names of the class variables defined on the model. __private_attributes__: Metadata about the private attributes of the model. __signature__: The synthesized __init__ [Signature][inspect.Signature] of the model.

__pydantic_complete__: Whether model building is completed, or if there are still undefined fields.
__pydantic_core_schema__: The core schema of the model.
__pydantic_custom_init__: Whether the model has a custom `__init__` function.
__pydantic_decorators__: Metadata containing the decorators defined on the model.
    This replaces `Model.__validators__` and `Model.__root_validators__` from Pydantic V1.
__pydantic_generic_metadata__: A dictionary containing metadata about generic Pydantic models.
    The `origin` and `args` items map to the [`__origin__`][genericalias.__origin__]
    and [`__args__`][genericalias.__args__] attributes of [generic aliases][types-genericalias],
    and the `parameter` item maps to the `__parameter__` attribute of generic classes.
__pydantic_parent_namespace__: Parent namespace of the model, used for automatic rebuilding of models.
__pydantic_post_init__: The name of the post-init method for the model, if defined.
__pydantic_root_model__: Whether the model is a [`RootModel`][pydantic.root_model.RootModel].
__pydantic_serializer__: The `pydantic-core` `SchemaSerializer` used to dump instances of the model.
__pydantic_validator__: The `pydantic-core` `SchemaValidator` used to validate instances of the model.

__pydantic_fields__: A dictionary of field names and their corresponding [`FieldInfo`][pydantic.fields.FieldInfo] objects.
__pydantic_computed_fields__: A dictionary of computed field names and their corresponding [`ComputedFieldInfo`][pydantic.fields.ComputedFieldInfo] objects.

__pydantic_extra__: A dictionary containing extra values, if [`extra`][pydantic.config.ConfigDict.extra]
    is set to `'allow'`.
__pydantic_fields_set__: The names of fields explicitly set during instantiation.
__pydantic_private__: Values of private attributes set on the model instance.
type: Literal[<V2EvalType.tool_call_check: 'tool_call_check'>]
expected_tools: list[ToolCallSpec]
match_mode: Literal['any', 'all', 'ordered', 'never']
on_unexpected_tools: Literal['ignore', 'fail']
model_config: ClassVar[pydantic.config.ConfigDict] = {}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

class StepCountCheckProperties(pydantic.main.BaseModel):
209class StepCountCheckProperties(BaseModel):
210    type: Literal[V2EvalType.step_count_check] = V2EvalType.step_count_check
211    count_type: Literal["tool_calls", "model_responses", "turns"]
212    min_count: int | None = None
213    max_count: int | None = None
214
215    @model_validator(mode="after")
216    def check_bounds(self) -> Self:
217        if self.min_count is None and self.max_count is None:
218            raise ValueError(
219                "step_count_check requires at least one of min_count / max_count"
220            )
221        if (
222            self.min_count is not None
223            and self.max_count is not None
224            and self.min_count > self.max_count
225        ):
226            raise ValueError("min_count must be <= max_count")
227        return self

!!! abstract "Usage Documentation" Models

A base class for creating Pydantic models.

Attributes: __class_vars__: The names of the class variables defined on the model. __private_attributes__: Metadata about the private attributes of the model. __signature__: The synthesized __init__ [Signature][inspect.Signature] of the model.

__pydantic_complete__: Whether model building is completed, or if there are still undefined fields.
__pydantic_core_schema__: The core schema of the model.
__pydantic_custom_init__: Whether the model has a custom `__init__` function.
__pydantic_decorators__: Metadata containing the decorators defined on the model.
    This replaces `Model.__validators__` and `Model.__root_validators__` from Pydantic V1.
__pydantic_generic_metadata__: A dictionary containing metadata about generic Pydantic models.
    The `origin` and `args` items map to the [`__origin__`][genericalias.__origin__]
    and [`__args__`][genericalias.__args__] attributes of [generic aliases][types-genericalias],
    and the `parameter` item maps to the `__parameter__` attribute of generic classes.
__pydantic_parent_namespace__: Parent namespace of the model, used for automatic rebuilding of models.
__pydantic_post_init__: The name of the post-init method for the model, if defined.
__pydantic_root_model__: Whether the model is a [`RootModel`][pydantic.root_model.RootModel].
__pydantic_serializer__: The `pydantic-core` `SchemaSerializer` used to dump instances of the model.
__pydantic_validator__: The `pydantic-core` `SchemaValidator` used to validate instances of the model.

__pydantic_fields__: A dictionary of field names and their corresponding [`FieldInfo`][pydantic.fields.FieldInfo] objects.
__pydantic_computed_fields__: A dictionary of computed field names and their corresponding [`ComputedFieldInfo`][pydantic.fields.ComputedFieldInfo] objects.

__pydantic_extra__: A dictionary containing extra values, if [`extra`][pydantic.config.ConfigDict.extra]
    is set to `'allow'`.
__pydantic_fields_set__: The names of fields explicitly set during instantiation.
__pydantic_private__: Values of private attributes set on the model instance.
type: Literal[<V2EvalType.step_count_check: 'step_count_check'>]
count_type: Literal['tool_calls', 'model_responses', 'turns']
min_count: int | None
max_count: int | None
@model_validator(mode='after')
def check_bounds(self) -> Self:
215    @model_validator(mode="after")
216    def check_bounds(self) -> Self:
217        if self.min_count is None and self.max_count is None:
218            raise ValueError(
219                "step_count_check requires at least one of min_count / max_count"
220            )
221        if (
222            self.min_count is not None
223            and self.max_count is not None
224            and self.min_count > self.max_count
225        ):
226            raise ValueError("min_count must be <= max_count")
227        return self
model_config: ClassVar[pydantic.config.ConfigDict] = {}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

class CodeEvalProperties(pydantic.main.BaseModel):
230class CodeEvalProperties(BaseModel):
231    type: Literal[V2EvalType.code_eval] = V2EvalType.code_eval
232    code: str
233    reference_keys: list[str] = []
234    timeout_seconds: int = Field(default=180, ge=1, le=300)
235    tool_allowlist: list[ToolId] = Field(
236        default_factory=list,
237        description="Explicit per-tool allowlist of tools the scorer code may call.",
238    )
239
240    @model_validator(mode="after")
241    def validate_allowlist(self) -> Self:
242        # No self-reference check: a code eval is not itself a tool.
243        validate_tool_allowlist(self.tool_allowlist, caller="code evals")
244        return self
245
246    @model_validator(mode="before")
247    @classmethod
248    def _read_code_file(cls, data: Any, info: ValidationInfo) -> Any:
249        """When loading from disk, inject `code` from the sibling scorer.py.
250
251        The source is stored in scorer.py beside eval_config.kiln, not inline in
252        the JSON. CodeEvalProperties is a nested member of the
253        V2EvalConfigProperties discriminated union in EvalConfig.properties, so
254        the load context set on the parent EvalConfig (`source_dir`) propagates
255        down to this validator. The shared helper reads the file here, before
256        field validation, so the existing validate_code trio runs against the
257        loaded string unchanged.
258        """
259        # Explicit type-gate (defense-in-depth): this validator only ever runs
260        # for code_eval properties — it lives on CodeEvalProperties, and both the
261        # discriminated union and the eager parse route only code_eval dicts
262        # here. Assert that gate so a future refactor can't quietly read
263        # scorer.py for another eval type. None (type omitted, field defaults)
264        # and the enum form both pass; only a present, mismatched type is
265        # rejected, so valid-input behavior is unchanged.
266        if isinstance(data, dict) and data.get("type") not in (
267            None,
268            V2EvalType.code_eval.value,
269        ):
270            raise ValueError(
271                "CodeEvalProperties can only load code_eval properties, "
272                f"got type: {data.get('type')!r}"
273            )
274        return read_code_from_sibling_file(
275            data,
276            info.context or {},
277            filename=SCORER_CODE_FILENAME,
278            kiln_filename="eval_config.kiln",
279            model_label="CodeEvalProperties",
280        )
281
282    @model_serializer(mode="wrap")
283    def _serialize(
284        self, handler: SerializerFunctionWrapHandler, info: SerializationInfo
285    ) -> dict[str, Any]:
286        """On disk-save, write `code` to scorer.py and omit it from the .kiln JSON.
287
288        Delegates to the shared sibling-file helper, which uses the same save
289        context attachments use (`save_attachments` + `dest_path`); it propagates
290        from the parent EvalConfig's save_to_file() down to this nested union
291        member. Without that context — normal model_dump / API responses —
292        `code` is left in the output and no file is written, so the API contract
293        is unchanged. The default handler preserves `type` (needed by the
294        discriminator), `reference_keys`, and `timeout_seconds`.
295
296        Schema note: a custom model_serializer would otherwise collapse the
297        *serialization-mode* JSON schema to an untyped object
298        (`model_json_schema(mode="serialization")` loses per-field typing).
299        Unlike CodeTool — which is never a FastAPI response_model — this model is
300        nested in EvalConfig, and EvalConfig IS the declared `response_model` on
301        several endpoints (eval_api.py). FastAPI generates response schemas in
302        serialization mode, so a collapsed schema here would split
303        CodeEvalProperties into an untyped `-Output` component and drift the
304        checked-in api_schema.d.ts (breaking check_schema.sh and the web types
305        that key off `components["schemas"]["CodeEvalProperties"]`). The
306        `__get_pydantic_json_schema__` override below is therefore REQUIRED (not
307        optional): it keeps the serialization-mode schema identical to
308        validation mode. Do not remove either the serializer (runtime file
309        storage) or the override (schema stability).
310        """
311        return write_code_to_sibling_file(
312            handler(self),
313            info.context or {},
314            filename=SCORER_CODE_FILENAME,
315            code=self.code,
316        )
317
318    @classmethod
319    def __get_pydantic_json_schema__(
320        cls, core_schema: CoreSchema, handler: GetJsonSchemaHandler
321    ) -> JsonSchemaValue:
322        """Keep the serialization-mode JSON schema identical to validation mode.
323
324        The wrap serializer above returns an untyped `dict`, which would collapse
325        this model's serialization-mode JSON schema to `{additionalProperties:
326        true, type: object}` (dropping `code`, `type`, etc.). Because EvalConfig
327        (which nests this model) is a FastAPI response_model, that collapse would
328        drift the committed OpenAPI/api_schema.d.ts. Dropping the `serialization`
329        core-schema entries makes JSON-schema generation use the field-based
330        (validation) representation in both modes, so `code` stays present and
331        typed and there is no `-Input`/`-Output` split. The custom serializer
332        lives on the inner `model` core schema (the before/after validators wrap
333        it in function schemas), so the strip must be recursive. This affects
334        only schema generation, never runtime (de)serialization.
335        """
336
337        def strip_serialization(schema: Any) -> Any:
338            if isinstance(schema, dict):
339                return {
340                    key: strip_serialization(value)
341                    for key, value in schema.items()
342                    if key != "serialization"
343                }
344            if isinstance(schema, list):
345                return [strip_serialization(item) for item in schema]
346            return schema
347
348        return handler(strip_serialization(core_schema))
349
350    @model_validator(mode="after")
351    def validate_code(self) -> Self:
352        code_bytes = self.code.encode("utf-8")
353        if len(code_bytes) > 64 * 1024:
354            raise ValueError(
355                f"Code is too large ({len(code_bytes)} bytes). Maximum size is 64KB."
356            )
357
358        try:
359            compile(self.code, "<code_eval>", "exec")
360        except SyntaxError as e:
361            raise ValueError(f"Code has a syntax error: {e}") from e
362
363        import ast
364
365        tree = ast.parse(self.code)
366        # Both sync and async score functions are accepted here.
367        # Async coroutines are transparently awaited in sandbox_worker.execute_scorer_bridged.
368        has_score_fn = any(
369            isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
370            and node.name == "score"
371            for node in ast.iter_child_nodes(tree)
372        )
373        if not has_score_fn:
374            raise ValueError(
375                "Code must define a module-level 'score' function (def score(...))."
376            )
377
378        return self

!!! abstract "Usage Documentation" Models

A base class for creating Pydantic models.

Attributes: __class_vars__: The names of the class variables defined on the model. __private_attributes__: Metadata about the private attributes of the model. __signature__: The synthesized __init__ [Signature][inspect.Signature] of the model.

__pydantic_complete__: Whether model building is completed, or if there are still undefined fields.
__pydantic_core_schema__: The core schema of the model.
__pydantic_custom_init__: Whether the model has a custom `__init__` function.
__pydantic_decorators__: Metadata containing the decorators defined on the model.
    This replaces `Model.__validators__` and `Model.__root_validators__` from Pydantic V1.
__pydantic_generic_metadata__: A dictionary containing metadata about generic Pydantic models.
    The `origin` and `args` items map to the [`__origin__`][genericalias.__origin__]
    and [`__args__`][genericalias.__args__] attributes of [generic aliases][types-genericalias],
    and the `parameter` item maps to the `__parameter__` attribute of generic classes.
__pydantic_parent_namespace__: Parent namespace of the model, used for automatic rebuilding of models.
__pydantic_post_init__: The name of the post-init method for the model, if defined.
__pydantic_root_model__: Whether the model is a [`RootModel`][pydantic.root_model.RootModel].
__pydantic_serializer__: The `pydantic-core` `SchemaSerializer` used to dump instances of the model.
__pydantic_validator__: The `pydantic-core` `SchemaValidator` used to validate instances of the model.

__pydantic_fields__: A dictionary of field names and their corresponding [`FieldInfo`][pydantic.fields.FieldInfo] objects.
__pydantic_computed_fields__: A dictionary of computed field names and their corresponding [`ComputedFieldInfo`][pydantic.fields.ComputedFieldInfo] objects.

__pydantic_extra__: A dictionary containing extra values, if [`extra`][pydantic.config.ConfigDict.extra]
    is set to `'allow'`.
__pydantic_fields_set__: The names of fields explicitly set during instantiation.
__pydantic_private__: Values of private attributes set on the model instance.
type: Literal[<V2EvalType.code_eval: 'code_eval'>]
code: str
reference_keys: list[str]
timeout_seconds: int
tool_allowlist: list[typing.Annotated[str, AfterValidator(func=<function <lambda> at 0x7f5eb9365b20>)]]
@model_validator(mode='after')
def validate_allowlist(self) -> Self:
240    @model_validator(mode="after")
241    def validate_allowlist(self) -> Self:
242        # No self-reference check: a code eval is not itself a tool.
243        validate_tool_allowlist(self.tool_allowlist, caller="code evals")
244        return self
@model_validator(mode='after')
def validate_code(self) -> Self:
350    @model_validator(mode="after")
351    def validate_code(self) -> Self:
352        code_bytes = self.code.encode("utf-8")
353        if len(code_bytes) > 64 * 1024:
354            raise ValueError(
355                f"Code is too large ({len(code_bytes)} bytes). Maximum size is 64KB."
356            )
357
358        try:
359            compile(self.code, "<code_eval>", "exec")
360        except SyntaxError as e:
361            raise ValueError(f"Code has a syntax error: {e}") from e
362
363        import ast
364
365        tree = ast.parse(self.code)
366        # Both sync and async score functions are accepted here.
367        # Async coroutines are transparently awaited in sandbox_worker.execute_scorer_bridged.
368        has_score_fn = any(
369            isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
370            and node.name == "score"
371            for node in ast.iter_child_nodes(tree)
372        )
373        if not has_score_fn:
374            raise ValueError(
375                "Code must define a module-level 'score' function (def score(...))."
376            )
377
378        return self
model_config: ClassVar[pydantic.config.ConfigDict] = {}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

V2EvalConfigProperties = typing.Annotated[typing.Union[LlmJudgeProperties, ExactMatchProperties, PatternMatchProperties, SetCheckProperties, ToolCallCheckProperties, ContainsProperties, StepCountCheckProperties, CodeEvalProperties], Discriminator(discriminator='type', custom_error_type=None, custom_error_message=None, custom_error_context=None)]
V2_PROPERTY_TYPES: tuple[type[pydantic.main.BaseModel], ...] = (<class 'LlmJudgeProperties'>, <class 'ExactMatchProperties'>, <class 'PatternMatchProperties'>, <class 'SetCheckProperties'>, <class 'ToolCallCheckProperties'>, <class 'ContainsProperties'>, <class 'StepCountCheckProperties'>, <class 'CodeEvalProperties'>)
def reference_data_keys( props: Annotated[Union[LlmJudgeProperties, ExactMatchProperties, PatternMatchProperties, SetCheckProperties, ToolCallCheckProperties, ContainsProperties, StepCountCheckProperties, CodeEvalProperties], Discriminator(discriminator='type', custom_error_type=None, custom_error_message=None, custom_error_context=None)]) -> list[str]:
412def reference_data_keys(props: V2EvalConfigProperties) -> list[str]:
413    """Return the reference-data keys a single judge needs.
414
415    Exhaustive match over the V2 properties union: adding a new V2 type
416    without handling it here will fail ``ty`` type-checking.
417    """
418    match props:
419        case ExactMatchProperties():
420            return [props.reference_key] if props.reference_key else []
421        case ContainsProperties():
422            return [props.reference_key] if props.reference_key else []
423        case SetCheckProperties():
424            return [props.reference_key] if props.reference_key else []
425        case LlmJudgeProperties():
426            return list(props.reference_keys)
427        case CodeEvalProperties():
428            return list(props.reference_keys)
429        case PatternMatchProperties():
430            return []
431        case ToolCallCheckProperties():
432            return []
433        case StepCountCheckProperties():
434            return []
435        case _:
436            raise_exhaustive_enum_error(props)

Return the reference-data keys a single judge needs.

Exhaustive match over the V2 properties union: adding a new V2 type without handling it here will fail ty type-checking.

def validate_scores_against_output_scores( scores: Dict[str, float], output_scores: list[EvalOutputScore]) -> list[str]:
468def validate_scores_against_output_scores(
469    scores: EvalScores,
470    output_scores: list["EvalOutputScore"],
471) -> list[str]:
472    """Validate that *scores* fall within the expected range for each output score.
473
474    Returns a list of human-readable problem strings (empty list means all OK).
475    This is a pure function — it does NOT raise; callers decide how to surface errors.
476    """
477
478    def _is_numeric(v: object) -> bool:
479        # NaN compares False against every range bound, so it passes every check
480        # below, then serializes to null and makes the saved file fail on reload.
481        if not isinstance(v, (int, float)) or isinstance(v, bool):
482            return False
483        try:
484            return math.isfinite(v)
485        except OverflowError:
486            # isfinite coerces int args to float, which an int like 10**400 can't be.
487            return False
488
489    problems: list[str] = []
490    for output_score in output_scores:
491        key = output_score.json_key()
492        if key not in scores:
493            continue
494        value = scores[key]
495
496        match output_score.type:
497            case TaskOutputRatingType.five_star:
498                if not _is_numeric(value) or value < 1.0 or value > 5.0:
499                    problems.append(
500                        f"Score {output_score.name} is a five_star rating and must be a number between 1.0 and 5.0 inclusive. Got: {value}"
501                    )
502            case TaskOutputRatingType.pass_fail:
503                if not _is_numeric(value) or value < 0.0 or value > 1.0:
504                    problems.append(
505                        f"Score {output_score.name} is a pass_fail rating and must be a number between 0.0 and 1.0 inclusive. Got: {value}"
506                    )
507            case TaskOutputRatingType.pass_fail_critical:
508                if not _is_numeric(value) or value < -1.0 or value > 1.0:
509                    problems.append(
510                        f"Score {output_score.name} is a pass_fail_critical rating and must be a number between -1.0 and 1.0 inclusive. Got: {value}"
511                    )
512            case TaskOutputRatingType.custom:
513                problems.append(
514                    f"Custom scores are not supported in evaluators. '{output_score.name}' was set to a custom score."
515                )
516            case _:
517                raise_exhaustive_enum_error(output_score.type)
518    return problems

Validate that scores fall within the expected range for each output score.

Returns a list of human-readable problem strings (empty list means all OK). This is a pure function — it does NOT raise; callers decide how to surface errors.

class SkippedReason(builtins.str, enum.Enum):
521class SkippedReason(str, Enum):
522    """Terminal skip reasons stored as str for back/forward-compat."""
523
524    missing_reference_key = "missing_reference_key"
525    extraction_failed = "extraction_failed"
526    missing_trace = "missing_trace"
527    missing_drive_config = "missing_drive_config"
528    incompatible_input_shape = "incompatible_input_shape"
529    code_eval_not_trusted = "code_eval_not_trusted"
530    type_not_available = "type_not_available"

Terminal skip reasons stored as str for back/forward-compat.

missing_reference_key = <SkippedReason.missing_reference_key: 'missing_reference_key'>
extraction_failed = <SkippedReason.extraction_failed: 'extraction_failed'>
missing_trace = <SkippedReason.missing_trace: 'missing_trace'>
missing_drive_config = <SkippedReason.missing_drive_config: 'missing_drive_config'>
incompatible_input_shape = <SkippedReason.incompatible_input_shape: 'incompatible_input_shape'>
code_eval_not_trusted = <SkippedReason.code_eval_not_trusted: 'code_eval_not_trusted'>
type_not_available = <SkippedReason.type_not_available: 'type_not_available'>
class V2EvalResult(pydantic.main.BaseModel):
533class V2EvalResult(BaseModel):
534    """Result of a single V2 eval ``evaluate()`` call."""
535
536    scores: EvalScores = Field(default_factory=dict)
537    skipped_reason: SkippedReason | None = None
538    skipped_detail: str | None = None
539    intermediate_outputs: Dict[str, str] | None = None
540    usage: Usage | None = Field(
541        default=None,
542        description="What the judgment itself cost, if it called a model. None for the deterministic eval types, which call none. Stored on the resulting EvalRun as eval_usage.",
543    )

Result of a single V2 eval evaluate() call.

scores: Dict[str, float]
skipped_reason: SkippedReason | None
skipped_detail: str | None
intermediate_outputs: Optional[Dict[str, str]]
usage: kiln_ai.utils.usage.Usage | None
model_config: ClassVar[pydantic.config.ConfigDict] = {}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

class UserMessage(pydantic.main.BaseModel):
546class UserMessage(BaseModel):
547    text: str

!!! abstract "Usage Documentation" Models

A base class for creating Pydantic models.

Attributes: __class_vars__: The names of the class variables defined on the model. __private_attributes__: Metadata about the private attributes of the model. __signature__: The synthesized __init__ [Signature][inspect.Signature] of the model.

__pydantic_complete__: Whether model building is completed, or if there are still undefined fields.
__pydantic_core_schema__: The core schema of the model.
__pydantic_custom_init__: Whether the model has a custom `__init__` function.
__pydantic_decorators__: Metadata containing the decorators defined on the model.
    This replaces `Model.__validators__` and `Model.__root_validators__` from Pydantic V1.
__pydantic_generic_metadata__: A dictionary containing metadata about generic Pydantic models.
    The `origin` and `args` items map to the [`__origin__`][genericalias.__origin__]
    and [`__args__`][genericalias.__args__] attributes of [generic aliases][types-genericalias],
    and the `parameter` item maps to the `__parameter__` attribute of generic classes.
__pydantic_parent_namespace__: Parent namespace of the model, used for automatic rebuilding of models.
__pydantic_post_init__: The name of the post-init method for the model, if defined.
__pydantic_root_model__: Whether the model is a [`RootModel`][pydantic.root_model.RootModel].
__pydantic_serializer__: The `pydantic-core` `SchemaSerializer` used to dump instances of the model.
__pydantic_validator__: The `pydantic-core` `SchemaValidator` used to validate instances of the model.

__pydantic_fields__: A dictionary of field names and their corresponding [`FieldInfo`][pydantic.fields.FieldInfo] objects.
__pydantic_computed_fields__: A dictionary of computed field names and their corresponding [`ComputedFieldInfo`][pydantic.fields.ComputedFieldInfo] objects.

__pydantic_extra__: A dictionary containing extra values, if [`extra`][pydantic.config.ConfigDict.extra]
    is set to `'allow'`.
__pydantic_fields_set__: The names of fields explicitly set during instantiation.
__pydantic_private__: Values of private attributes set on the model instance.
text: str
model_config: ClassVar[pydantic.config.ConfigDict] = {}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

class SyntheticUserInfo(pydantic.main.BaseModel):
550class SyntheticUserInfo(BaseModel):
551    """The synthetic user's character sheet: who they are and what they want.
552
553    This is both the persisted form on multi-turn synthetic eval inputs and
554    the runtime shape the synthetic-user driver renders its system prompt
555    from. The XML-tagged blob some wire formats carry is parsed into this at
556    the wire boundary (kiln_ai.synthetic_user.parser) — it is never stored.
557
558    extra="allow": unknown fields from newer generators survive load/save
559    round-trips instead of being dropped.
560    """
561
562    model_config = ConfigDict(extra="allow")
563
564    persona: str
565    goal: str
566    behavior_guidance: str | None = None

The synthetic user's character sheet: who they are and what they want.

This is both the persisted form on multi-turn synthetic eval inputs and the runtime shape the synthetic-user driver renders its system prompt from. The XML-tagged blob some wire formats carry is parsed into this at the wire boundary (kiln_ai.synthetic_user.parser) — it is never stored.

extra="allow": unknown fields from newer generators survive load/save round-trips instead of being dropped.

model_config = {'extra': 'allow'}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

persona: str
goal: str
behavior_guidance: str | None
class SingleTurnEvalInputData(pydantic.main.BaseModel):
569class SingleTurnEvalInputData(BaseModel):
570    type: Literal["single_turn"] = "single_turn"
571    user_message: UserMessage

!!! abstract "Usage Documentation" Models

A base class for creating Pydantic models.

Attributes: __class_vars__: The names of the class variables defined on the model. __private_attributes__: Metadata about the private attributes of the model. __signature__: The synthesized __init__ [Signature][inspect.Signature] of the model.

__pydantic_complete__: Whether model building is completed, or if there are still undefined fields.
__pydantic_core_schema__: The core schema of the model.
__pydantic_custom_init__: Whether the model has a custom `__init__` function.
__pydantic_decorators__: Metadata containing the decorators defined on the model.
    This replaces `Model.__validators__` and `Model.__root_validators__` from Pydantic V1.
__pydantic_generic_metadata__: A dictionary containing metadata about generic Pydantic models.
    The `origin` and `args` items map to the [`__origin__`][genericalias.__origin__]
    and [`__args__`][genericalias.__args__] attributes of [generic aliases][types-genericalias],
    and the `parameter` item maps to the `__parameter__` attribute of generic classes.
__pydantic_parent_namespace__: Parent namespace of the model, used for automatic rebuilding of models.
__pydantic_post_init__: The name of the post-init method for the model, if defined.
__pydantic_root_model__: Whether the model is a [`RootModel`][pydantic.root_model.RootModel].
__pydantic_serializer__: The `pydantic-core` `SchemaSerializer` used to dump instances of the model.
__pydantic_validator__: The `pydantic-core` `SchemaValidator` used to validate instances of the model.

__pydantic_fields__: A dictionary of field names and their corresponding [`FieldInfo`][pydantic.fields.FieldInfo] objects.
__pydantic_computed_fields__: A dictionary of computed field names and their corresponding [`ComputedFieldInfo`][pydantic.fields.ComputedFieldInfo] objects.

__pydantic_extra__: A dictionary containing extra values, if [`extra`][pydantic.config.ConfigDict.extra]
    is set to `'allow'`.
__pydantic_fields_set__: The names of fields explicitly set during instantiation.
__pydantic_private__: Values of private attributes set on the model instance.
type: Literal['single_turn']
user_message: UserMessage
model_config: ClassVar[pydantic.config.ConfigDict] = {}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

class MultiTurnDriveConfig(pydantic.main.BaseModel):
574class MultiTurnDriveConfig(BaseModel):
575    """Settings for re-driving a multi-turn synthetic input at eval time.
576
577    A multi-turn eval run regenerates each conversation: the agent under test
578    comes from the run config being evaluated, while the synthetic user
579    (customer) configured here is held constant across run configs — so a
580    comparison varies only the agent. Stored per item, on
581    MultiTurnSyntheticEvalInputData.drive_config.
582    """
583
584    model_name: str = Field(
585        description="The model that plays the synthetic user during re-drives."
586    )
587    # A plain string rather than the provider enum so persisted items load on
588    # builds that don't know the provider yet (same choice as LlmJudgeProperties).
589    model_provider: str = Field(description="The provider of the synthetic-user model.")
590    turns: int = Field(
591        ge=1,
592        le=20,
593        description="Ceiling on the assistant turns per re-driven conversation.",
594    )

Settings for re-driving a multi-turn synthetic input at eval time.

A multi-turn eval run regenerates each conversation: the agent under test comes from the run config being evaluated, while the synthetic user (customer) configured here is held constant across run configs — so a comparison varies only the agent. Stored per item, on MultiTurnSyntheticEvalInputData.drive_config.

model_name: str
model_provider: str
turns: int
model_config: ClassVar[pydantic.config.ConfigDict] = {}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

class MultiTurnSyntheticEvalInputData(pydantic.main.BaseModel):
597class MultiTurnSyntheticEvalInputData(BaseModel):
598    """A re-drivable multi-turn case: the opening user message, the synthetic
599    user who continues the conversation at eval time, and the drive settings
600    that synthetic user runs with.
601
602    Together these make the item a self-contained replication recipe: with the
603    persona, first_message, and drive_config it re-drives identically under any
604    eval that references it, which is what makes conversation traces keyed to
605    the item reusable across evals.
606
607    first_message may be None; such items carry no seed to open a
608    conversation with, so the eval runner skips them instead of re-driving.
609    """
610
611    type: Literal["multi_turn_synthetic"] = "multi_turn_synthetic"
612    first_message: UserMessage | None = None
613    synthetic_user_info: SyntheticUserInfo
614    drive_config: MultiTurnDriveConfig | None = Field(
615        default=None,
616        description="How this item's conversation is re-driven: the "
617        "synthetic-user model and turn count, stamped when the item is minted. "
618        "This is the ONLY home for drive settings — no eval-level copy exists; "
619        "displays and prefills derive from items. Held constant across run "
620        "configs so a comparison varies only the agent under test. Immutable "
621        "once minted: changing the synthetic-user setup means minting new "
622        "items, which keeps traces keyed to this item valid. None only on "
623        "items minted before drive settings were stamped; the eval runner "
624        "skips such items with a clear reason rather than guessing a config.",
625    )
626
627    @model_serializer(mode="wrap")
628    def _omit_unset_drive_config(
629        self, handler: SerializerFunctionWrapHandler
630    ) -> Dict[str, Any]:
631        # Items that predate drive_config carry no key on disk, and absent and
632        # null load identically — omit an unset config instead of churning
633        # every legacy file with a null on resave.
634        data: Dict[str, Any] = handler(self)
635        if data.get("drive_config") is None:
636            data.pop("drive_config", None)
637        return data

A re-drivable multi-turn case: the opening user message, the synthetic user who continues the conversation at eval time, and the drive settings that synthetic user runs with.

Together these make the item a self-contained replication recipe: with the persona, first_message, and drive_config it re-drives identically under any eval that references it, which is what makes conversation traces keyed to the item reusable across evals.

first_message may be None; such items carry no seed to open a conversation with, so the eval runner skips them instead of re-driving.

type: Literal['multi_turn_synthetic']
first_message: UserMessage | None
synthetic_user_info: SyntheticUserInfo
drive_config: MultiTurnDriveConfig | None
model_config: ClassVar[pydantic.config.ConfigDict] = {}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

EvalInputData = typing.Annotated[typing.Union[SingleTurnEvalInputData, MultiTurnSyntheticEvalInputData], Discriminator(discriminator='type', custom_error_type=None, custom_error_message=None, custom_error_context=None)]
class EvalInput(kiln_ai.datamodel.basemodel.KilnParentedModel):
649class EvalInput(KilnParentedModel):
650    """A single evaluation input item, stored as a child of a Task.
651
652    Each EvalInput contains the data needed to run an evaluation (e.g. a user
653    message) plus optional reference data for comparison and tags for filtering.
654    """
655
656    data: EvalInputData = Field(
657        description="The input data for this eval item.",
658    )
659    reference: dict[str, JsonValue] | None = Field(
660        default=None,
661        description="Optional reference data (ground truth) for this eval input, keyed by reference name.",
662    )
663    tags: list[str] = Field(
664        default_factory=list,
665        description="Tags for filtering eval inputs.",
666    )
667
668    @model_validator(mode="after")
669    def validate_tags(self) -> Self:
670        # Empty or space-containing tags can't be selected by tag filters, so
671        # reject them at creation instead of silently dropping the item later.
672        for tag in self.tags:
673            if not tag:
674                raise ValueError("Tags cannot be empty strings")
675            if " " in tag:
676                raise ValueError("Tags cannot contain spaces. Try underscores.")
677        return self

A single evaluation input item, stored as a child of a Task.

Each EvalInput contains the data needed to run an evaluation (e.g. a user message) plus optional reference data for comparison and tags for filtering.

data: Annotated[Union[SingleTurnEvalInputData, MultiTurnSyntheticEvalInputData], Discriminator(discriminator='type', custom_error_type=None, custom_error_message=None, custom_error_context=None)]
reference: dict[str, JsonValue] | None
tags: list[str]
@model_validator(mode='after')
def validate_tags(self) -> Self:
668    @model_validator(mode="after")
669    def validate_tags(self) -> Self:
670        # Empty or space-containing tags can't be selected by tag filters, so
671        # reject them at creation instead of silently dropping the item later.
672        for tag in self.tags:
673            if not tag:
674                raise ValueError("Tags cannot be empty strings")
675            if " " in tag:
676                raise ValueError("Tags cannot contain spaces. Try underscores.")
677        return self
def relationship_name() -> str:
859        def relationship_name_method() -> str:
860            return filesystem_name

The type of the None singleton.

def parent_type() -> Type[kiln_ai.datamodel.basemodel.KilnParentModel]:
852        def parent_class_method() -> Type[KilnParentModel]:
853            return cls

The type of the None singleton.

model_config = {'validate_assignment': True}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

def model_post_init(self: pydantic.main.BaseModel, context: Any, /) -> None:
365def init_private_attributes(self: BaseModel, context: Any, /) -> None:
366    """This function is meant to behave like a BaseModel method to initialize private attributes.
367
368    It takes context as an argument since that's what pydantic-core passes when calling it.
369
370    Args:
371        self: The BaseModel instance.
372        context: The context.
373    """
374    if getattr(self, '__pydantic_private__', None) is None:
375        pydantic_private = {}
376        for name, private_attr in self.__private_attributes__.items():
377            # Avoid needlessly creating a new dict for the validated data:
378            if private_attr.default_factory_takes_validated_data:
379                default = private_attr.get_default(
380                    call_default_factory=True, validated_data={**self.__dict__, **pydantic_private}
381                )
382            else:
383                default = private_attr.get_default(call_default_factory=True)
384            if default is not PydanticUndefined:
385                pydantic_private[name] = default
386        object_setattr(self, '__pydantic_private__', pydantic_private)

This function is meant to behave like a BaseModel method to initialize private attributes.

It takes context as an argument since that's what pydantic-core passes when calling it.

Args: self: The BaseModel instance. context: The context.

class EvalTaskInput(pydantic.main.BaseModel):
680class EvalTaskInput(BaseModel):
681    """The runtime data bundle passed to V2 evaluators.
682
683    Assembled by the eval runner from the item being evaluated and the task run that
684    was scored. The item is either an EvalInput or a TaskRun drawn from the dataset;
685    which one it is determines where `reference_data` and `task_input` come from.
686    """
687
688    final_message: str = Field(
689        description="The final model output (task output text).",
690    )
691    trace: list[dict[str, Any]] | None = Field(
692        default=None,
693        description="The full conversation trace, if available.",
694    )
695    reference_data: dict[str, JsonValue] | None = Field(
696        default=None,
697        description=(
698            "Ground-truth data for the item being evaluated, keyed by reference name. "
699            "Taken from EvalInput.reference for an EvalInput-backed item; for a "
700            "TaskRun-backed dataset item it is the item's own stored output under the "
701            "key 'reference_answer', since that output is the curated answer. None "
702            "when a TaskRun is scored as itself (judge calibration), where the item "
703            "and the scored run are the same record."
704        ),
705    )
706    task_input: str | None = Field(
707        default=None,
708        description="The original task input text.",
709    )
710
711    @classmethod
712    def from_trace(
713        cls, trace: "TaskRun", source: "TaskRun | EvalInput"
714    ) -> "EvalTaskInput":
715        """What a judge sees: the trace that was produced, plus the item it came from.
716
717        The two are separate arguments because they are separate records once eval traces
718        live on their own TaskRun — the trace holds what the model said, and the source
719        item holds the ground truth to compare it against. They are the same object only
720        for calibration, where the golden dataset item is itself what gets scored.
721        """
722        from kiln_ai.datamodel.task_run import TaskRun as _TaskRun
723
724        if not isinstance(trace, _TaskRun):
725            raise TypeError("Expected a TaskRun instance for trace")
726
727        trace_data: list[dict[str, Any]] | None = None
728        if trace.trace is not None:
729            trace_data = [dict(msg) for msg in trace.trace]
730
731        if isinstance(source, EvalInput):
732            reference_data = source.reference
733            # The item's own text, not the trace's: an EvalInput is the canonical
734            # statement of the input, and the adapter may have reserialized it.
735            if isinstance(source.data, SingleTurnEvalInputData):
736                task_input = source.data.user_message.text
737            elif isinstance(source.data, MultiTurnSyntheticEvalInputData):
738                # Multi-turn: the first message opened the conversation, and the
739                # rest of the exchange lives in the trace. Items minted without a
740                # first message have no canonical input text to offer.
741                task_input = (
742                    source.data.first_message.text
743                    if source.data.first_message
744                    else None
745                )
746            else:
747                raise ValueError(
748                    f"Unsupported EvalInput data type: {type(source.data).__name__}"
749                )
750        elif isinstance(source, _TaskRun):
751            # A TaskRun-backed dataset item stores the curated answer as its output, so
752            # that output is the ground truth to compare the trace against. Skipped when
753            # source *is* trace (calibration, and `from_task_run`): there the golden item
754            # is itself what gets scored, so a reference would be byte-identical to
755            # `final_message` and every judge comparing them would pass.
756            reference_data = (
757                None if source is trace else {"reference_answer": source.output.output}
758            )
759            task_input = trace.input
760        else:
761            raise TypeError("Expected a TaskRun or EvalInput instance for source")
762
763        return cls(
764            final_message=trace.output.output,
765            trace=trace_data,
766            reference_data=reference_data,
767            task_input=task_input,
768        )
769
770    @classmethod
771    def from_task_run(cls, task_run: "TaskRun") -> "EvalTaskInput":
772        """A TaskRun scored as itself, with no separate source item."""
773        return cls.from_trace(task_run, task_run)
774
775    @classmethod
776    def from_eval_input(
777        cls, eval_input: "EvalInput", run_output: "TaskRun"
778    ) -> "EvalTaskInput":
779        """A generated run scored against the EvalInput it was generated from.
780
781        The argument-order counterpart of `from_trace` for callers holding the item
782        first. The explicit type check stays: passed a TaskRun by mistake,
783        `from_trace` would silently take its TaskRun branch instead of failing.
784        """
785        if not isinstance(eval_input, EvalInput):
786            raise TypeError("Expected an EvalInput instance")
787        return cls.from_trace(run_output, eval_input)

The runtime data bundle passed to V2 evaluators.

Assembled by the eval runner from the item being evaluated and the task run that was scored. The item is either an EvalInput or a TaskRun drawn from the dataset; which one it is determines where reference_data and task_input come from.

final_message: str
trace: list[dict[str, typing.Any]] | None
reference_data: dict[str, JsonValue] | None
task_input: str | None
@classmethod
def from_trace( cls, trace: kiln_ai.datamodel.TaskRun, source: kiln_ai.datamodel.TaskRun | EvalInput) -> EvalTaskInput:
711    @classmethod
712    def from_trace(
713        cls, trace: "TaskRun", source: "TaskRun | EvalInput"
714    ) -> "EvalTaskInput":
715        """What a judge sees: the trace that was produced, plus the item it came from.
716
717        The two are separate arguments because they are separate records once eval traces
718        live on their own TaskRun — the trace holds what the model said, and the source
719        item holds the ground truth to compare it against. They are the same object only
720        for calibration, where the golden dataset item is itself what gets scored.
721        """
722        from kiln_ai.datamodel.task_run import TaskRun as _TaskRun
723
724        if not isinstance(trace, _TaskRun):
725            raise TypeError("Expected a TaskRun instance for trace")
726
727        trace_data: list[dict[str, Any]] | None = None
728        if trace.trace is not None:
729            trace_data = [dict(msg) for msg in trace.trace]
730
731        if isinstance(source, EvalInput):
732            reference_data = source.reference
733            # The item's own text, not the trace's: an EvalInput is the canonical
734            # statement of the input, and the adapter may have reserialized it.
735            if isinstance(source.data, SingleTurnEvalInputData):
736                task_input = source.data.user_message.text
737            elif isinstance(source.data, MultiTurnSyntheticEvalInputData):
738                # Multi-turn: the first message opened the conversation, and the
739                # rest of the exchange lives in the trace. Items minted without a
740                # first message have no canonical input text to offer.
741                task_input = (
742                    source.data.first_message.text
743                    if source.data.first_message
744                    else None
745                )
746            else:
747                raise ValueError(
748                    f"Unsupported EvalInput data type: {type(source.data).__name__}"
749                )
750        elif isinstance(source, _TaskRun):
751            # A TaskRun-backed dataset item stores the curated answer as its output, so
752            # that output is the ground truth to compare the trace against. Skipped when
753            # source *is* trace (calibration, and `from_task_run`): there the golden item
754            # is itself what gets scored, so a reference would be byte-identical to
755            # `final_message` and every judge comparing them would pass.
756            reference_data = (
757                None if source is trace else {"reference_answer": source.output.output}
758            )
759            task_input = trace.input
760        else:
761            raise TypeError("Expected a TaskRun or EvalInput instance for source")
762
763        return cls(
764            final_message=trace.output.output,
765            trace=trace_data,
766            reference_data=reference_data,
767            task_input=task_input,
768        )

What a judge sees: the trace that was produced, plus the item it came from.

The two are separate arguments because they are separate records once eval traces live on their own TaskRun — the trace holds what the model said, and the source item holds the ground truth to compare it against. They are the same object only for calibration, where the golden dataset item is itself what gets scored.

@classmethod
def from_task_run( cls, task_run: kiln_ai.datamodel.TaskRun) -> EvalTaskInput:
770    @classmethod
771    def from_task_run(cls, task_run: "TaskRun") -> "EvalTaskInput":
772        """A TaskRun scored as itself, with no separate source item."""
773        return cls.from_trace(task_run, task_run)

A TaskRun scored as itself, with no separate source item.

@classmethod
def from_eval_input( cls, eval_input: EvalInput, run_output: kiln_ai.datamodel.TaskRun) -> EvalTaskInput:
775    @classmethod
776    def from_eval_input(
777        cls, eval_input: "EvalInput", run_output: "TaskRun"
778    ) -> "EvalTaskInput":
779        """A generated run scored against the EvalInput it was generated from.
780
781        The argument-order counterpart of `from_trace` for callers holding the item
782        first. The explicit type check stays: passed a TaskRun by mistake,
783        `from_trace` would silently take its TaskRun branch instead of failing.
784        """
785        if not isinstance(eval_input, EvalInput):
786            raise TypeError("Expected an EvalInput instance")
787        return cls.from_trace(run_output, eval_input)

A generated run scored against the EvalInput it was generated from.

The argument-order counterpart of from_trace for callers holding the item first. The explicit type check stays: passed a TaskRun by mistake, from_trace would silently take its TaskRun branch instead of failing.

model_config: ClassVar[pydantic.config.ConfigDict] = {}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

class EvalOutputScore(pydantic.main.BaseModel):
790class EvalOutputScore(BaseModel):
791    """
792    A definition of a score that an evaluator will produce.
793
794    Very similar to TaskRequirement, but conceptually different keeping in a separate models.
795    """
796
797    name: FilenameStringShort = Field(
798        description="The name of the score. Will be provided to the model so use a descriptive name. Should align to the model's TaskRequirement name if you want to use human evals to evaluate the evaluator's performance."
799    )
800    instruction: str | None = Field(
801        default=None,
802        description="A description of the score, used to help the model understand the goal of the score. Will be provided to evaluator models, so should be written for the model, not the team/user.",
803    )
804    type: TaskOutputRatingType = Field(
805        description="The type of rating to use ('five_star', 'pass_fail', 'pass_fail_critical').",
806    )
807
808    def json_key(self) -> str:
809        """
810        The JSON key for the score, used when running the evaluator with a LLM and we need JSON output.
811
812        For example, "Overall Rating" -> "overall_rating"
813        """
814        return string_to_json_key(self.name)
815
816    @model_validator(mode="after")
817    def validate_type(self) -> Self:
818        if self.type == TaskOutputRatingType.custom:
819            raise ValueError(
820                f"Custom scores are not supported in evaluators. Score '{self.name}' was set to a custom score."
821            )
822        return self

A definition of a score that an evaluator will produce.

Very similar to TaskRequirement, but conceptually different keeping in a separate models.

name: Annotated[str, BeforeValidator(func=<function name_validator.<locals>.fn at 0x7f5eb7c1ade0>, json_schema_input_type=PydanticUndefined), StringConstraints(strip_whitespace=None, to_upper=None, to_lower=None, strict=None, min_length=1, max_length=32, pattern=None, ascii_only=None)]
instruction: str | None
def json_key(self) -> str:
808    def json_key(self) -> str:
809        """
810        The JSON key for the score, used when running the evaluator with a LLM and we need JSON output.
811
812        For example, "Overall Rating" -> "overall_rating"
813        """
814        return string_to_json_key(self.name)

The JSON key for the score, used when running the evaluator with a LLM and we need JSON output.

For example, "Overall Rating" -> "overall_rating"

@model_validator(mode='after')
def validate_type(self) -> Self:
816    @model_validator(mode="after")
817    def validate_type(self) -> Self:
818        if self.type == TaskOutputRatingType.custom:
819            raise ValueError(
820                f"Custom scores are not supported in evaluators. Score '{self.name}' was set to a custom score."
821            )
822        return self
model_config: ClassVar[pydantic.config.ConfigDict] = {}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

LEGACY_TRACE_FIELDS = ('output', 'task_run_trace', 'task_run_usage', 'reference_answer')

The EvalRun fields that hold a copy of the trace a score was computed over.

Deprecated: new records point at a TaskRun with scored_run_id instead. Kept declared and loadable forever - every record written before the split still carries them. A pointer-mode record must leave all of them None, which validate_record_mode enforces. input is deprecated alongside these but is not in this tuple: it is the one a legacy record is required to have, so it is checked separately.

Three ways to mark a pydantic field deprecated; these fields use two of them:

  1. A DEPRECATED: prefix in the description. Reaches a human reading the SDK docs or the OpenAPI schema, and nothing else. Used.
  2. json_schema_extra={"deprecated": True}. Puts "deprecated": true in the JSON schema, which openapi-typescript turns into a /** @deprecated */ JSDoc tag, so the TS compiler and editors strike through every web call site. No runtime effect. Used.
  3. Field(deprecated=True). Same schema output as (2), but pydantic also raises a DeprecationWarning on every attribute read — and reading these is the correct, permanent way to render a legacy record, so it would be a warning storm. Not used. Do not "fix" this to (3) without silencing that first; (2) already provides the tooling signal (3) would be reached for.
class EvalRun(kiln_ai.datamodel.basemodel.KilnParentedModel):
 849class EvalRun(KilnParentedModel):
 850    """
 851    The scores an eval produced for a single dataset item.
 852
 853    A run serves one of two purposes:
 854    - eval_config_eval=False (scoring): evaluating a task run config — the item's
 855      input was run through the task with task_run_config_id (which must be set)
 856      and the evaluator scored that output.
 857    - eval_config_eval=True (calibration): evaluating the eval config itself — an
 858      existing human-rated dataset item's input and output were scored so the
 859      evaluator can be compared against those human ratings. task_run_config_id
 860      must be None.
 861
 862    A record is described by two independent facts — whether it points at a TaskRun, and
 863    whether it was skipped — which `validate_record_mode` constrains to three legal
 864    shapes. What is exclusive is where the trace lives: on the record, or on the TaskRun,
 865    never both.
 866
 867    - **Pointer** (new): `scored_run_id` names the TaskRun that holds the trace. All
 868      inline trace fields must be None.
 869    - **Skipped**: `skipped_reason` set, so scores are not required. It also carries a
 870      `scored_run_id` if the trace existed and only scoring was skipped — so a skip can
 871      be a pointer record too — and none if the skip happened before generation.
 872    - **Legacy inline**: no `scored_run_id`; the trace lives on this record, and `input`
 873      is required unless the record was skipped. Every record written before the
 874      trace/score split is in this state, and it stays valid forever.
 875    """
 876
 877    dataset_id: ID_TYPE | None = Field(
 878        default=None,
 879        description="The ID of the dataset item (TaskRun) that was used for this run. Mutually exclusive with eval_input_id.",
 880    )
 881    scored_run_id: ID_TYPE | None = Field(
 882        default=None,
 883        description="The ID of the TaskRun this score was computed over. None for legacy records that carry their trace inline. A dangling reference is tolerated: the score still renders and still aggregates, only the trace drill-through is unavailable.",
 884    )
 885    task_run_config_id: ID_TYPE | None = Field(
 886        description="The ID of the TaskRunConfig that was run, if this eval run was based on a task run. Must belong to the same Task as this eval. Can be None if this eval run is based on an eval config."
 887    )
 888    eval_config_eval: bool = Field(
 889        description="Whether this eval run to evaluate the parent eval config (evaluating the config using an existing dataset item). If true, task_run_config_id must be None, as we're not running the task.",
 890        default=False,
 891    )
 892    input: str | None = Field(
 893        default=None,
 894        json_schema_extra={"deprecated": True},
 895        description="DEPRECATED: the trace now lives on the TaskRun named by scored_run_id; read TaskRun.input instead. The input to the task. JSON formatted for structured input, plaintext for unstructured input. Required on legacy records (those with neither a scored_run_id nor a skipped_reason), never set on new ones.",
 896    )
 897    output: str | None = Field(
 898        default=None,
 899        json_schema_extra={"deprecated": True},
 900        description="DEPRECATED: the trace now lives on the TaskRun named by scored_run_id; read TaskRun.output.output instead. The output of the task. None for skipped-before-execution runs.",
 901    )
 902    reference_answer: str | None = Field(
 903        default=None,
 904        json_schema_extra={"deprecated": True},
 905        description="DEPRECATED: the trace now lives on the TaskRun named by scored_run_id. The reference answer for the input. JSON formatted for structured reference answer, plaintext for unstructured reference answer. Used for reference answer evals.",
 906    )
 907    intermediate_outputs: Dict[str, str] | None = Field(
 908        default=None,
 909        description="The intermediate outputs of the task (example, eval thinking).",
 910    )
 911    task_run_trace: str | None = Field(
 912        default=None,
 913        json_schema_extra={"deprecated": True},
 914        description="DEPRECATED: the trace now lives on the TaskRun named by scored_run_id; read TaskRun.trace instead. The JSON formatted trace of the task run that produced the output.",
 915    )
 916    scores: EvalScores = Field(
 917        default={},
 918        description="The output scores of the evaluator (aligning to those required by the grand-parent Eval this object is a child of).",
 919    )
 920    task_run_usage: Usage | None = Field(
 921        default=None,
 922        json_schema_extra={"deprecated": True},
 923        description="DEPRECATED: the trace now lives on the TaskRun named by scored_run_id; read TaskRun.usage instead. The usage of the task run that produced this eval run output (not the usage by the evaluation model).",
 924    )
 925    eval_usage: Usage | None = Field(
 926        default=None,
 927        description="The usage of the evaluation model (judge) that produced this eval run's scores, aggregated across every LLM call the judgment made. Distinct from task_run_usage, which is the evaluated task run's usage. None for non-LLM evals (e.g. code evals) and for records that predate this field.",
 928    )
 929
 930    eval_input_id: ID_TYPE | None = Field(
 931        default=None,
 932        description="ID of the EvalInput used for this run (V2 evals). Mutually exclusive with dataset_id.",
 933    )
 934    skipped_reason: str | None = Field(
 935        default=None,
 936        description="If set, this run was skipped. Stored as str for back/forward-compat; conventionally a SkippedReason value.",
 937    )
 938    skipped_detail: str | None = Field(
 939        default=None,
 940        description="Case-specific detail for skipped runs (e.g. missing key name).",
 941    )
 942
 943    def parent_eval_config(self) -> Union["EvalConfig", None]:
 944        if self.parent is not None and self.parent.__class__.__name__ != "EvalConfig":
 945            raise ValueError("parent must be an EvalConfig")
 946        return self.parent  # type: ignore
 947
 948    @model_validator(mode="after")
 949    def validate_input_source(self) -> Self:
 950        if (self.dataset_id is None) == (self.eval_input_id is None):
 951            raise ValueError(
 952                "Exactly one of dataset_id (V1 TaskRun source) or "
 953                "eval_input_id (V2 EvalInput source) must be set"
 954            )
 955        return self
 956
 957    @model_validator(mode="after")
 958    def validate_record_mode(self) -> Self:
 959        """Keep the three record states (pointer / skipped / legacy inline) exclusive.
 960
 961        The forbidding half of the pointer rule is the one that earns its keep: a record
 962        that points at a TaskRun must never also carry a second copy of what it scored,
 963        which nothing would keep in sync.
 964        """
 965        inline_set = [f for f in LEGACY_TRACE_FIELDS if getattr(self, f) is not None]
 966
 967        if self.scored_run_id is not None:
 968            # Checked before the skip branch on purpose: a record skipped at *scoring*
 969            # time still has a scored_run_id, and still must not carry inline data.
 970            if self.input is not None or inline_set:
 971                carried = (["input"] if self.input is not None else []) + inline_set
 972                raise ValueError(
 973                    "An EvalRun with scored_run_id must not carry inline trace data "
 974                    f"(set: {', '.join(carried)}). "
 975                    "The trace lives on the referenced TaskRun."
 976                )
 977            return self
 978
 979        if self.skipped_reason is not None:
 980            # Skipped before generation: there is nothing to point at, and nothing to
 981            # require. Legacy skipped records that do carry inline data stay valid.
 982            return self
 983
 984        if self.input is None:
 985            raise ValueError("A legacy EvalRun (no scored_run_id) requires input.")
 986        return self
 987
 988    @model_validator(mode="after")
 989    def validate_output_fields(self, info: ValidationInfo) -> Self:
 990        parent_eval_config = self.parent_eval_config()
 991        if self.scored_run_id is not None:
 992            # Pointer mode: the trace lives on the referenced TaskRun, and
 993            # validate_record_mode already forbids inline copies here. The checks
 994            # below are about data carried on this record, so none of them apply.
 995            return self
 996        parent_eval = parent_eval_config.parent_eval() if parent_eval_config else None
 997        if not parent_eval:
 998            return self
 999
1000        evaluation_data_type = parent_eval.evaluation_data_type
1001
1002        # A full_trace eval scores the conversation trace, so a successful task
1003        # run must carry it. Both V1 and V2 writers attach the trace for exactly
1004        # this shape (a scored, non-skipped task-run eval of a full_trace eval),
1005        # so demanding it back makes a writer that drops the trace fail loudly
1006        # instead of persisting a record that can't be re-scored. Historical
1007        # files predating this gate are exempt so they still load; new writes
1008        # and rebuilds are held to it.
1009        if (
1010            not self.eval_config_eval
1011            and self.skipped_reason is None
1012            and evaluation_data_type == EvalDataType.full_trace
1013            and self.task_run_trace is None
1014            and not self.loaded_from_file(info)
1015        ):
1016            raise ValueError("full_trace task run eval runs should include trace")
1017
1018        # Remaining checks are V1-only. V2 deliberately relaxes them: skipped
1019        # runs carry no output, and V2 writers never attach a trace to a
1020        # final_answer run in the first place.
1021        if parent_eval_config.config_type == EvalConfigType.v2:
1022            return self
1023
1024        if self.output is None and self.skipped_reason is None:
1025            raise ValueError("V1 EvalRun requires output to be set")
1026
1027        if (
1028            evaluation_data_type == EvalDataType.final_answer
1029            and self.task_run_trace is not None
1030        ):
1031            raise ValueError("final_answer runs should not set trace")
1032
1033        return self
1034
1035    @model_validator(mode="after")
1036    def validate_eval_run_types(self) -> Self:
1037        if self.eval_config_eval and self.task_run_config_id is not None:
1038            raise ValueError(
1039                "task_run_config_id must be None if eval_config_eval is true"
1040            )
1041        if not self.eval_config_eval and self.task_run_config_id is None:
1042            raise ValueError(
1043                "task_run_config_id must be set if eval_config_eval is false"
1044            )
1045        if self.eval_config_eval and self.dataset_id is None:
1046            raise ValueError(
1047                "eval_config_eval records must score a dataset item: judge "
1048                "calibration compares against human ratings, which only "
1049                "dataset items (TaskRuns) carry"
1050            )
1051        return self
1052
1053    @model_validator(mode="after")
1054    def validate_scores(self) -> Self:
1055        if self.skipped_reason is not None:
1056            return self
1057
1058        if self.scores is None or len(self.scores) == 0:
1059            raise ValueError("scores are required, and must have at least one score.")
1060
1061        parent_eval_config = self.parent_eval_config()
1062        eval = parent_eval_config.parent_eval() if parent_eval_config else None
1063        if not eval:
1064            return self
1065
1066        output_score_keys = [score.json_key() for score in eval.output_scores]
1067        if set(output_score_keys) != set(self.scores.keys()):
1068            raise ValueError(
1069                f"The scores produced by the evaluator must match the scores expected by the eval. Got: [{', '.join(self.scores.keys())}] and expected: [{', '.join(output_score_keys)}]"
1070            )
1071
1072        problems = validate_scores_against_output_scores(
1073            self.scores, eval.output_scores
1074        )
1075        if problems:
1076            raise ValueError(problems[0])
1077        return self
1078
1079    @model_validator(mode="after")
1080    def validate_reference_answer(self) -> Self:
1081        parent_eval_config = self.parent_eval_config()
1082        if parent_eval_config and parent_eval_config.config_type == EvalConfigType.v2:
1083            return self
1084        parent_eval = parent_eval_config.parent_eval() if parent_eval_config else None
1085        if not parent_eval:
1086            return self
1087
1088        evaluation_data_type = parent_eval.evaluation_data_type
1089        if (
1090            self.reference_answer is not None
1091            and evaluation_data_type is not None
1092            and evaluation_data_type != EvalDataType.reference_answer
1093        ):
1094            raise ValueError(
1095                f"reference_answer is only valid for reference answer evals. Got: {evaluation_data_type.value}"
1096            )
1097        return self

The scores an eval produced for a single dataset item.

A run serves one of two purposes:

  • eval_config_eval=False (scoring): evaluating a task run config — the item's input was run through the task with task_run_config_id (which must be set) and the evaluator scored that output.
  • eval_config_eval=True (calibration): evaluating the eval config itself — an existing human-rated dataset item's input and output were scored so the evaluator can be compared against those human ratings. task_run_config_id must be None.

A record is described by two independent facts — whether it points at a TaskRun, and whether it was skipped — which validate_record_mode constrains to three legal shapes. What is exclusive is where the trace lives: on the record, or on the TaskRun, never both.

  • Pointer (new): scored_run_id names the TaskRun that holds the trace. All inline trace fields must be None.
  • Skipped: skipped_reason set, so scores are not required. It also carries a scored_run_id if the trace existed and only scoring was skipped — so a skip can be a pointer record too — and none if the skip happened before generation.
  • Legacy inline: no scored_run_id; the trace lives on this record, and input is required unless the record was skipped. Every record written before the trace/score split is in this state, and it stays valid forever.
dataset_id: Optional[str]
scored_run_id: Optional[str]
task_run_config_id: Optional[str]
eval_config_eval: bool
input: str | None
output: str | None
reference_answer: str | None
intermediate_outputs: Optional[Dict[str, str]]
task_run_trace: str | None
scores: Dict[str, float]
task_run_usage: kiln_ai.utils.usage.Usage | None
eval_usage: kiln_ai.utils.usage.Usage | None
eval_input_id: Optional[str]
skipped_reason: str | None
skipped_detail: str | None
def parent_eval_config(self) -> Optional[EvalConfig]:
943    def parent_eval_config(self) -> Union["EvalConfig", None]:
944        if self.parent is not None and self.parent.__class__.__name__ != "EvalConfig":
945            raise ValueError("parent must be an EvalConfig")
946        return self.parent  # type: ignore
@model_validator(mode='after')
def validate_input_source(self) -> Self:
948    @model_validator(mode="after")
949    def validate_input_source(self) -> Self:
950        if (self.dataset_id is None) == (self.eval_input_id is None):
951            raise ValueError(
952                "Exactly one of dataset_id (V1 TaskRun source) or "
953                "eval_input_id (V2 EvalInput source) must be set"
954            )
955        return self
@model_validator(mode='after')
def validate_record_mode(self) -> Self:
957    @model_validator(mode="after")
958    def validate_record_mode(self) -> Self:
959        """Keep the three record states (pointer / skipped / legacy inline) exclusive.
960
961        The forbidding half of the pointer rule is the one that earns its keep: a record
962        that points at a TaskRun must never also carry a second copy of what it scored,
963        which nothing would keep in sync.
964        """
965        inline_set = [f for f in LEGACY_TRACE_FIELDS if getattr(self, f) is not None]
966
967        if self.scored_run_id is not None:
968            # Checked before the skip branch on purpose: a record skipped at *scoring*
969            # time still has a scored_run_id, and still must not carry inline data.
970            if self.input is not None or inline_set:
971                carried = (["input"] if self.input is not None else []) + inline_set
972                raise ValueError(
973                    "An EvalRun with scored_run_id must not carry inline trace data "
974                    f"(set: {', '.join(carried)}). "
975                    "The trace lives on the referenced TaskRun."
976                )
977            return self
978
979        if self.skipped_reason is not None:
980            # Skipped before generation: there is nothing to point at, and nothing to
981            # require. Legacy skipped records that do carry inline data stay valid.
982            return self
983
984        if self.input is None:
985            raise ValueError("A legacy EvalRun (no scored_run_id) requires input.")
986        return self

Keep the three record states (pointer / skipped / legacy inline) exclusive.

The forbidding half of the pointer rule is the one that earns its keep: a record that points at a TaskRun must never also carry a second copy of what it scored, which nothing would keep in sync.

@model_validator(mode='after')
def validate_output_fields(self, info: pydantic_core.core_schema.ValidationInfo) -> Self:
 988    @model_validator(mode="after")
 989    def validate_output_fields(self, info: ValidationInfo) -> Self:
 990        parent_eval_config = self.parent_eval_config()
 991        if self.scored_run_id is not None:
 992            # Pointer mode: the trace lives on the referenced TaskRun, and
 993            # validate_record_mode already forbids inline copies here. The checks
 994            # below are about data carried on this record, so none of them apply.
 995            return self
 996        parent_eval = parent_eval_config.parent_eval() if parent_eval_config else None
 997        if not parent_eval:
 998            return self
 999
1000        evaluation_data_type = parent_eval.evaluation_data_type
1001
1002        # A full_trace eval scores the conversation trace, so a successful task
1003        # run must carry it. Both V1 and V2 writers attach the trace for exactly
1004        # this shape (a scored, non-skipped task-run eval of a full_trace eval),
1005        # so demanding it back makes a writer that drops the trace fail loudly
1006        # instead of persisting a record that can't be re-scored. Historical
1007        # files predating this gate are exempt so they still load; new writes
1008        # and rebuilds are held to it.
1009        if (
1010            not self.eval_config_eval
1011            and self.skipped_reason is None
1012            and evaluation_data_type == EvalDataType.full_trace
1013            and self.task_run_trace is None
1014            and not self.loaded_from_file(info)
1015        ):
1016            raise ValueError("full_trace task run eval runs should include trace")
1017
1018        # Remaining checks are V1-only. V2 deliberately relaxes them: skipped
1019        # runs carry no output, and V2 writers never attach a trace to a
1020        # final_answer run in the first place.
1021        if parent_eval_config.config_type == EvalConfigType.v2:
1022            return self
1023
1024        if self.output is None and self.skipped_reason is None:
1025            raise ValueError("V1 EvalRun requires output to be set")
1026
1027        if (
1028            evaluation_data_type == EvalDataType.final_answer
1029            and self.task_run_trace is not None
1030        ):
1031            raise ValueError("final_answer runs should not set trace")
1032
1033        return self
@model_validator(mode='after')
def validate_eval_run_types(self) -> Self:
1035    @model_validator(mode="after")
1036    def validate_eval_run_types(self) -> Self:
1037        if self.eval_config_eval and self.task_run_config_id is not None:
1038            raise ValueError(
1039                "task_run_config_id must be None if eval_config_eval is true"
1040            )
1041        if not self.eval_config_eval and self.task_run_config_id is None:
1042            raise ValueError(
1043                "task_run_config_id must be set if eval_config_eval is false"
1044            )
1045        if self.eval_config_eval and self.dataset_id is None:
1046            raise ValueError(
1047                "eval_config_eval records must score a dataset item: judge "
1048                "calibration compares against human ratings, which only "
1049                "dataset items (TaskRuns) carry"
1050            )
1051        return self
@model_validator(mode='after')
def validate_scores(self) -> Self:
1053    @model_validator(mode="after")
1054    def validate_scores(self) -> Self:
1055        if self.skipped_reason is not None:
1056            return self
1057
1058        if self.scores is None or len(self.scores) == 0:
1059            raise ValueError("scores are required, and must have at least one score.")
1060
1061        parent_eval_config = self.parent_eval_config()
1062        eval = parent_eval_config.parent_eval() if parent_eval_config else None
1063        if not eval:
1064            return self
1065
1066        output_score_keys = [score.json_key() for score in eval.output_scores]
1067        if set(output_score_keys) != set(self.scores.keys()):
1068            raise ValueError(
1069                f"The scores produced by the evaluator must match the scores expected by the eval. Got: [{', '.join(self.scores.keys())}] and expected: [{', '.join(output_score_keys)}]"
1070            )
1071
1072        problems = validate_scores_against_output_scores(
1073            self.scores, eval.output_scores
1074        )
1075        if problems:
1076            raise ValueError(problems[0])
1077        return self
@model_validator(mode='after')
def validate_reference_answer(self) -> Self:
1079    @model_validator(mode="after")
1080    def validate_reference_answer(self) -> Self:
1081        parent_eval_config = self.parent_eval_config()
1082        if parent_eval_config and parent_eval_config.config_type == EvalConfigType.v2:
1083            return self
1084        parent_eval = parent_eval_config.parent_eval() if parent_eval_config else None
1085        if not parent_eval:
1086            return self
1087
1088        evaluation_data_type = parent_eval.evaluation_data_type
1089        if (
1090            self.reference_answer is not None
1091            and evaluation_data_type is not None
1092            and evaluation_data_type != EvalDataType.reference_answer
1093        ):
1094            raise ValueError(
1095                f"reference_answer is only valid for reference answer evals. Got: {evaluation_data_type.value}"
1096            )
1097        return self
def relationship_name() -> str:
859        def relationship_name_method() -> str:
860            return filesystem_name

The type of the None singleton.

def parent_type() -> Type[kiln_ai.datamodel.basemodel.KilnParentModel]:
852        def parent_class_method() -> Type[KilnParentModel]:
853            return cls

The type of the None singleton.

model_config = {'validate_assignment': True}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

def model_post_init(self: pydantic.main.BaseModel, context: Any, /) -> None:
365def init_private_attributes(self: BaseModel, context: Any, /) -> None:
366    """This function is meant to behave like a BaseModel method to initialize private attributes.
367
368    It takes context as an argument since that's what pydantic-core passes when calling it.
369
370    Args:
371        self: The BaseModel instance.
372        context: The context.
373    """
374    if getattr(self, '__pydantic_private__', None) is None:
375        pydantic_private = {}
376        for name, private_attr in self.__private_attributes__.items():
377            # Avoid needlessly creating a new dict for the validated data:
378            if private_attr.default_factory_takes_validated_data:
379                default = private_attr.get_default(
380                    call_default_factory=True, validated_data={**self.__dict__, **pydantic_private}
381                )
382            else:
383                default = private_attr.get_default(call_default_factory=True)
384            if default is not PydanticUndefined:
385                pydantic_private[name] = default
386        object_setattr(self, '__pydantic_private__', pydantic_private)

This function is meant to behave like a BaseModel method to initialize private attributes.

It takes context as an argument since that's what pydantic-core passes when calling it.

Args: self: The BaseModel instance. context: The context.

class EvalConfig(kiln_ai.datamodel.basemodel.KilnParentedModel, kiln_ai.datamodel.basemodel.KilnParentModel):
1100class EvalConfig(KilnParentedModel, KilnParentModel, parent_of={"runs": EvalRun}):
1101    """
1102    A configuration for running an eval. This includes anything needed to run the eval on a dataset like the prompt, model, thresholds, etc.
1103
1104    A eval might have many configs, example running the same eval with 2 different models. Comparing eval results is only valid within the scope of the same config.
1105    """
1106
1107    name: FilenameString = Field(description="The name of the eval config.")
1108    model_name: str | None = Field(
1109        default=None,
1110        description="The name of the model to use for this eval config. Required for legacy configs, None for V2.",
1111    )
1112    model_provider: str | None = Field(
1113        default=None,
1114        description="The provider of the model to use for this eval config. Required for legacy configs, None for V2.",
1115    )
1116    config_type: EvalConfigType = Field(
1117        default=EvalConfigType.g_eval,
1118        description="This is used to determine the type of eval to run.",
1119    )
1120    properties: dict[str, Any] | V2EvalConfigProperties | None = Field(
1121        default=None,
1122        description="Properties to be used to execute the eval config. Legacy configs use a dict; V2 configs use typed properties.",
1123    )
1124
1125    @model_validator(mode="before")
1126    @classmethod
1127    def dispatch_properties_parsing(cls, data: Any, info: ValidationInfo) -> Any:
1128        # The union lists dict first, so a raw dict always stays a plain dict —
1129        # even one whose keys happen to match a typed V2 shape (legacy configs
1130        # store arbitrary dicts). V2 configs persist properties as a dict too,
1131        # so parse those into the typed union here, before field validation.
1132        if not isinstance(data, dict):
1133            return data
1134        if data.get("config_type", EvalConfigType.g_eval) == EvalConfigType.v2:
1135            # code_eval stores its score() source in a sibling scorer.py: on file
1136            # load, the type-gated helper parses those props through
1137            # CodeEvalProperties (reading the sibling via the load context) so a
1138            # bad or missing scorer.py surfaces directly instead of being masked
1139            # by a union fallback. Other property types pass through unchanged.
1140            data = _eager_parse_code_eval_on_load(data, info.context or {})
1141            props = data.get("properties")
1142            if isinstance(props, dict):
1143                data = dict(data)
1144                data["properties"] = _V2_PROPERTIES_ADAPTER.validate_python(props)
1145        return data
1146
1147    def parent_eval(self) -> Union["Eval", None]:
1148        if self.parent is not None and self.parent.__class__.__name__ != "Eval":
1149            raise ValueError("parent must be an Eval")
1150        return self.parent  # type: ignore
1151
1152    def runs(self, readonly: bool = False) -> list[EvalRun]:
1153        return super().runs(readonly=readonly)  # type: ignore
1154
1155    @model_validator(mode="after")
1156    def validate_properties(self) -> Self:
1157        if self.config_type in (EvalConfigType.g_eval, EvalConfigType.llm_as_judge):
1158            if not isinstance(self.properties, dict):
1159                raise ValueError("Legacy config properties must be a dict")
1160            if "eval_steps" not in self.properties or not isinstance(
1161                self.properties["eval_steps"], list
1162            ):
1163                raise ValueError("eval_steps is required and must be a list for g_eval")
1164            if "task_description" in self.properties and not isinstance(
1165                self.properties["task_description"], str
1166            ):
1167                raise ValueError(
1168                    "task_description is optional, but if provided must be a string"
1169                )
1170            if self.model_name is None or self.model_provider is None:
1171                raise ValueError(
1172                    "model_name and model_provider are required for legacy configs"
1173                )
1174            return self
1175        elif self.config_type == EvalConfigType.v2:
1176            if not isinstance(self.properties, BaseModel):
1177                raise ValueError("V2 config requires typed properties")
1178            if self.model_name is not None or self.model_provider is not None:
1179                raise ValueError(
1180                    "V2 configs must not set root-level model_name/model_provider"
1181                )
1182            return self
1183        else:
1184            raise ValueError(f"Invalid eval config type: {self.config_type}")
1185
1186    @model_validator(mode="after")
1187    def validate_v2_templates_and_expressions(self) -> Self:
1188        if self.config_type != EvalConfigType.v2 or not isinstance(
1189            self.properties, BaseModel
1190        ):
1191            return self
1192
1193        from kiln_ai.utils.jinja_engine import (
1194            compile_expression_or_raise,
1195            compile_template_or_raise,
1196        )
1197
1198        props = self.properties
1199        if isinstance(props, LlmJudgeProperties):
1200            compile_template_or_raise(props.prompt_template)
1201            from jinja2 import meta
1202
1203            from kiln_ai.utils.jinja_engine import _template_env
1204
1205            referenced = meta.find_undeclared_variables(
1206                _template_env.parse(props.prompt_template)
1207            )
1208            meaningful = {"final_message", "trace", "task_input"}
1209            if not (referenced & meaningful):
1210                raise ValueError(
1211                    "prompt_template never references the model output. "
1212                    "A template that uses only reference_data (or no variables) "
1213                    "produces the same judge prompt for every run. "
1214                    "Reference the output, e.g. {{ final_message }}."
1215                )
1216
1217        if isinstance(
1218            props,
1219            (
1220                ExactMatchProperties,
1221                PatternMatchProperties,
1222                ContainsProperties,
1223                SetCheckProperties,
1224            ),
1225        ):
1226            if props.value_expression is not None:
1227                compile_expression_or_raise(props.value_expression)
1228
1229        return self
1230
1231    @model_validator(mode="after")
1232    def validate_json_serializable(self) -> "EvalConfig":
1233        if self.config_type == EvalConfigType.v2:
1234            return self
1235        if self.properties is None:
1236            return self
1237        try:
1238            json.dumps(self.properties, ensure_ascii=False)
1239        except TypeError as e:
1240            raise ValueError(f"Properties must be JSON serializable: {e!s}")
1241        return self

A configuration for running an eval. This includes anything needed to run the eval on a dataset like the prompt, model, thresholds, etc.

A eval might have many configs, example running the same eval with 2 different models. Comparing eval results is only valid within the scope of the same config.

name: Annotated[str, BeforeValidator(func=<function name_validator.<locals>.fn at 0x7f5eb7c19e40>, json_schema_input_type=PydanticUndefined), StringConstraints(strip_whitespace=None, to_upper=None, to_lower=None, strict=None, min_length=1, max_length=120, pattern=None, ascii_only=None)]
model_name: str | None
model_provider: str | None
config_type: EvalConfigType
properties: Union[dict[str, Any], Annotated[Union[LlmJudgeProperties, ExactMatchProperties, PatternMatchProperties, SetCheckProperties, ToolCallCheckProperties, ContainsProperties, StepCountCheckProperties, CodeEvalProperties], Discriminator(discriminator='type', custom_error_type=None, custom_error_message=None, custom_error_context=None)], NoneType]
@model_validator(mode='before')
@classmethod
def dispatch_properties_parsing(cls, data: Any, info: pydantic_core.core_schema.ValidationInfo) -> Any:
1125    @model_validator(mode="before")
1126    @classmethod
1127    def dispatch_properties_parsing(cls, data: Any, info: ValidationInfo) -> Any:
1128        # The union lists dict first, so a raw dict always stays a plain dict —
1129        # even one whose keys happen to match a typed V2 shape (legacy configs
1130        # store arbitrary dicts). V2 configs persist properties as a dict too,
1131        # so parse those into the typed union here, before field validation.
1132        if not isinstance(data, dict):
1133            return data
1134        if data.get("config_type", EvalConfigType.g_eval) == EvalConfigType.v2:
1135            # code_eval stores its score() source in a sibling scorer.py: on file
1136            # load, the type-gated helper parses those props through
1137            # CodeEvalProperties (reading the sibling via the load context) so a
1138            # bad or missing scorer.py surfaces directly instead of being masked
1139            # by a union fallback. Other property types pass through unchanged.
1140            data = _eager_parse_code_eval_on_load(data, info.context or {})
1141            props = data.get("properties")
1142            if isinstance(props, dict):
1143                data = dict(data)
1144                data["properties"] = _V2_PROPERTIES_ADAPTER.validate_python(props)
1145        return data
def parent_eval(self) -> Optional[Eval]:
1147    def parent_eval(self) -> Union["Eval", None]:
1148        if self.parent is not None and self.parent.__class__.__name__ != "Eval":
1149            raise ValueError("parent must be an Eval")
1150        return self.parent  # type: ignore
def runs(self, readonly=False) -> List[EvalRun]:
841        def child_method(self, readonly: bool = False) -> list[child_class]:  # type: ignore[invalid-type-form]
842            return child_class.all_children_of_parent_path(self.path, readonly=readonly)

The type of the None singleton.

@model_validator(mode='after')
def validate_properties(self) -> Self:
1155    @model_validator(mode="after")
1156    def validate_properties(self) -> Self:
1157        if self.config_type in (EvalConfigType.g_eval, EvalConfigType.llm_as_judge):
1158            if not isinstance(self.properties, dict):
1159                raise ValueError("Legacy config properties must be a dict")
1160            if "eval_steps" not in self.properties or not isinstance(
1161                self.properties["eval_steps"], list
1162            ):
1163                raise ValueError("eval_steps is required and must be a list for g_eval")
1164            if "task_description" in self.properties and not isinstance(
1165                self.properties["task_description"], str
1166            ):
1167                raise ValueError(
1168                    "task_description is optional, but if provided must be a string"
1169                )
1170            if self.model_name is None or self.model_provider is None:
1171                raise ValueError(
1172                    "model_name and model_provider are required for legacy configs"
1173                )
1174            return self
1175        elif self.config_type == EvalConfigType.v2:
1176            if not isinstance(self.properties, BaseModel):
1177                raise ValueError("V2 config requires typed properties")
1178            if self.model_name is not None or self.model_provider is not None:
1179                raise ValueError(
1180                    "V2 configs must not set root-level model_name/model_provider"
1181                )
1182            return self
1183        else:
1184            raise ValueError(f"Invalid eval config type: {self.config_type}")
@model_validator(mode='after')
def validate_v2_templates_and_expressions(self) -> Self:
1186    @model_validator(mode="after")
1187    def validate_v2_templates_and_expressions(self) -> Self:
1188        if self.config_type != EvalConfigType.v2 or not isinstance(
1189            self.properties, BaseModel
1190        ):
1191            return self
1192
1193        from kiln_ai.utils.jinja_engine import (
1194            compile_expression_or_raise,
1195            compile_template_or_raise,
1196        )
1197
1198        props = self.properties
1199        if isinstance(props, LlmJudgeProperties):
1200            compile_template_or_raise(props.prompt_template)
1201            from jinja2 import meta
1202
1203            from kiln_ai.utils.jinja_engine import _template_env
1204
1205            referenced = meta.find_undeclared_variables(
1206                _template_env.parse(props.prompt_template)
1207            )
1208            meaningful = {"final_message", "trace", "task_input"}
1209            if not (referenced & meaningful):
1210                raise ValueError(
1211                    "prompt_template never references the model output. "
1212                    "A template that uses only reference_data (or no variables) "
1213                    "produces the same judge prompt for every run. "
1214                    "Reference the output, e.g. {{ final_message }}."
1215                )
1216
1217        if isinstance(
1218            props,
1219            (
1220                ExactMatchProperties,
1221                PatternMatchProperties,
1222                ContainsProperties,
1223                SetCheckProperties,
1224            ),
1225        ):
1226            if props.value_expression is not None:
1227                compile_expression_or_raise(props.value_expression)
1228
1229        return self
@model_validator(mode='after')
def validate_json_serializable(self) -> EvalConfig:
1231    @model_validator(mode="after")
1232    def validate_json_serializable(self) -> "EvalConfig":
1233        if self.config_type == EvalConfigType.v2:
1234            return self
1235        if self.properties is None:
1236            return self
1237        try:
1238            json.dumps(self.properties, ensure_ascii=False)
1239        except TypeError as e:
1240            raise ValueError(f"Properties must be JSON serializable: {e!s}")
1241        return self
def relationship_name() -> str:
859        def relationship_name_method() -> str:
860            return filesystem_name

The type of the None singleton.

def parent_type() -> Type[kiln_ai.datamodel.basemodel.KilnParentModel]:
852        def parent_class_method() -> Type[KilnParentModel]:
853            return cls

The type of the None singleton.

model_config = {'validate_assignment': True}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

def model_post_init(self: pydantic.main.BaseModel, context: Any, /) -> None:
365def init_private_attributes(self: BaseModel, context: Any, /) -> None:
366    """This function is meant to behave like a BaseModel method to initialize private attributes.
367
368    It takes context as an argument since that's what pydantic-core passes when calling it.
369
370    Args:
371        self: The BaseModel instance.
372        context: The context.
373    """
374    if getattr(self, '__pydantic_private__', None) is None:
375        pydantic_private = {}
376        for name, private_attr in self.__private_attributes__.items():
377            # Avoid needlessly creating a new dict for the validated data:
378            if private_attr.default_factory_takes_validated_data:
379                default = private_attr.get_default(
380                    call_default_factory=True, validated_data={**self.__dict__, **pydantic_private}
381                )
382            else:
383                default = private_attr.get_default(call_default_factory=True)
384            if default is not PydanticUndefined:
385                pydantic_private[name] = default
386        object_setattr(self, '__pydantic_private__', pydantic_private)

This function is meant to behave like a BaseModel method to initialize private attributes.

It takes context as an argument since that's what pydantic-core passes when calling it.

Args: self: The BaseModel instance. context: The context.

class EvalDataType(builtins.str, enum.Enum):
1244class EvalDataType(str, Enum):
1245    """The type of task output data to evaluate."""
1246
1247    final_answer = "final_answer"
1248    full_trace = "full_trace"
1249    reference_answer = "reference_answer"

The type of task output data to evaluate.

final_answer = <EvalDataType.final_answer: 'final_answer'>
full_trace = <EvalDataType.full_trace: 'full_trace'>
reference_answer = <EvalDataType.reference_answer: 'reference_answer'>
class TaskRunSplit(pydantic.main.BaseModel):
1252class TaskRunSplit(BaseModel):
1253    """A split whose items are TaskRuns, selected by a dataset filter."""
1254
1255    # Fields a future build adds are preserved rather than dropped, for the same reason
1256    # Eval.splits keeps unknown split names: these files sync between app versions. It is
1257    # also why the legacy-field migration never overwrites a split that `splits` already
1258    # describes — rebuilding one from a bare filter-id string would drop everything else
1259    # on it.
1260    model_config = ConfigDict(extra="allow")
1261
1262    source: Literal["task_run"] = "task_run"
1263    filter_id: DatasetFilterId

A split whose items are TaskRuns, selected by a dataset filter.

model_config = {'extra': 'allow'}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

source: Literal['task_run']
filter_id: Annotated[str, AfterValidator(func=<function <lambda> at 0x7f5eb7b144a0>)]
class EvalInputSplit(pydantic.main.BaseModel):
1266class EvalInputSplit(BaseModel):
1267    """A split whose items are EvalInputs, selected by an eval-input filter."""
1268
1269    model_config = ConfigDict(extra="allow")
1270
1271    source: Literal["eval_input"] = "eval_input"
1272    filter_id: EvalInputFilterId

A split whose items are EvalInputs, selected by an eval-input filter.

model_config = {'extra': 'allow'}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

source: Literal['eval_input']
filter_id: Annotated[str, AfterValidator(func=<function <lambda> at 0x7f5eb7b14a40>)]
SplitRef = typing.Annotated[typing.Union[TaskRunSplit, EvalInputSplit], Discriminator(discriminator='source', custom_error_type=None, custom_error_message=None, custom_error_context=None)]

One of an eval's splits: which store its items come from, and which filter selects them. Discriminated on source, so a split's backing is part of its value rather than a convention a reader has to know.

EvalSplitName = typing.Literal['train', 'val', 'test']

The split names the API exposes. Eval.splits is keyed by plain str so a file written by a build that knows a fourth split still loads here (see Eval.splits).

LEGACY_SPLIT_FIELDS: Dict[str, str] = {'test': 'eval_set_filter_id', 'train': 'train_set_filter_id'}

Split name -> the deprecated flat Eval field a Kiln build predating splits stored it in. These fields are an input format and nothing else: Eval.migrate_legacy_split_fields reads each one once, on the way in, and clears it. Nothing else in the codebase reads or writes them, and they are never written to disk again — see Eval.splits.

class Eval(kiln_ai.datamodel.basemodel.KilnParentedModel, kiln_ai.datamodel.basemodel.KilnParentModel):
1297class Eval(KilnParentedModel, KilnParentModel, parent_of={"configs": EvalConfig}):
1298    """An evaluator definition that specifies what to evaluate and how scores should be produced."""
1299
1300    name: FilenameString = Field(description="The name of the eval.")
1301    description: str | None = Field(
1302        default=None, description="The description of the eval"
1303    )
1304    template: EvalTemplateId | None = Field(
1305        default=None,
1306        description="The template selected when creating this eval. Useful for suggesting eval steps and output scores.",
1307    )
1308    current_config_id: ID_TYPE = Field(
1309        default=None,
1310        description="The id of the current config to use for this eval. This can be changed over time to run the same eval with different configs.",
1311    )
1312    eval_set_filter_id: DatasetFilterId | None = Field(
1313        default=None,
1314        deprecated=True,
1315        description="Deprecated, and neither read nor written. It exists only so evals written by a Kiln build that predates `splits` still load: on load its value is migrated into splits['test'] once, and the field is then cleared. It is always saved as null. Read splits['test'] instead.",
1316    )
1317    eval_configs_filter_id: DatasetFilterId | None = Field(
1318        default=None,
1319        description="The id of the dataset filter which defines which dataset items are included when comparing the quality of the eval configs under this eval. Should consist of dataset items with ratings.",
1320    )
1321    train_set_filter_id: DatasetFilterId | None = Field(
1322        default=None,
1323        deprecated=True,
1324        description="Deprecated, and neither read nor written. It exists only so evals written by a Kiln build that predates `splits` still load: on load its value is migrated into splits['train'] once, and the field is then cleared. It is always saved as null. Read splits['train'] instead.",
1325    )
1326    splits: Dict[str, SplitRef] = Field(
1327        default_factory=dict,
1328        description="The eval's dataset splits, keyed by split name ('test', 'train', 'val'), and the only place they are stored. Each split names the store its items come from and the filter that selects them. Keys this build doesn't know are preserved but not exposed. 'golden' is not a split and does not belong here: the golden set must be dataset (TaskRun) based, because human ratings only exist on dataset items, so it is stored in eval_configs_filter_id instead. Nothing reads splits['golden'] — writing it is accepted and silently ignored. In Python, prefer Eval.set_split() to assigning into this dict: it refuses to mutate a readonly (cached) eval, and marks the field as set so exclude_unset dumps keep it.",
1329    )
1330    output_scores: List[EvalOutputScore] = Field(
1331        description="The scores this evaluator should produce."
1332    )
1333    favourite: bool = Field(
1334        default=False,
1335        description="Whether this eval is a favourite of the user. Rendered as a star icon in the UI.",
1336    )
1337    priority: Priority | None = Field(
1338        default=None,
1339        description="The priority of the eval. None on evals created before priority lived on evals; read through resolved_priority(), which falls back to the associated spec.",
1340    )
1341    status: EvalStatus | None = Field(
1342        default=None,
1343        description="The status of the eval. None on evals created before status lived on evals; read through resolved_status(), which falls back to the associated spec.",
1344    )
1345    template_properties: dict[str, str | int | bool | float] | None = Field(
1346        default=None,
1347        description="Properties to be used to execute the eval. This is template_type specific and should serialize to a json dict.",
1348    )
1349    evaluation_data_type: EvalDataType | None = Field(
1350        default=EvalDataType.final_answer,
1351        description="The output of the task run to evaluate. Can be final answer, full trace, or None for V2 evals.",
1352    )
1353
1354    @model_validator(mode="after")
1355    def migrate_legacy_split_fields(self) -> Self:
1356        """Migrate the deprecated flat filter fields into `splits`, once, and clear them.
1357
1358        `splits` is the only home a split has. These fields are an input format for evals
1359        written before it existed, so each one is read exactly once — here — and only for
1360        a split `splits` doesn't already describe. `splits` winning is what makes the
1361        migration one-way: once a value is in `splits` it is the eval's answer, and a
1362        legacy field left over beside it (a hand-edited file, or one an older build wrote
1363        after a newer one) is ignored rather than allowed to overwrite it. Overwriting
1364        would also drop any extra fields on the existing split object, which
1365        `TaskRunSplit`/`EvalInputSplit` keep on purpose (`extra="allow"`).
1366
1367        Both fields are then cleared, unconditionally. That is what makes this a
1368        migration rather than a second home: nothing downstream can read a stale value,
1369        the eval saves with both fields null, and re-running the validator — which
1370        `validate_assignment` does on every attribute set, including `self.path = path`
1371        at the end of save_to_file — has nothing left to do. An older Kiln build reading
1372        the saved file sees no test set rather than the wrong one; that is the accepted
1373        cost of a single home, and the eval list surfaces the evals it can't read.
1374
1375        Reads and writes go through `__dict__` because the fields are
1376        `deprecated=True`: attribute access on them emits a DeprecationWarning, which is
1377        meant for callers, not for the one place that is supposed to touch them.
1378
1379        Must stay declared before validate_splits, which requires a test split: an eval
1380        that carries only legacy fields gets its test split from here.
1381        """
1382        for name, field_name in LEGACY_SPLIT_FIELDS.items():
1383            filter_id = self.__dict__.get(field_name)
1384            if filter_id is not None and name not in self.splits:
1385                self.splits[name] = TaskRunSplit(filter_id=filter_id)
1386                # The split now lives only in `splits`, so an exclude_unset dump has to
1387                # carry it: on a legacy eval `splits` was never explicitly set.
1388                self.__pydantic_fields_set__.add("splits")
1389            self.__dict__[field_name] = None
1390        return self
1391
1392    @model_validator(mode="after")
1393    def validate_splits(self) -> Self:
1394        if "test" not in self.splits:
1395            raise ValueError("An eval must have a test split. Set splits['test'].")
1396        return self
1397
1398    def set_split(self, name: str, split: SplitRef) -> None:
1399        """Set one of the eval's splits.
1400
1401        Equivalent to `eval.splits[name] = split` plus the two things item assignment on
1402        a dict can't do for itself, because it never reaches `__setattr__`: refusing to
1403        mutate a readonly (cached) eval, and marking `splits` as set so an
1404        exclude_unset dump still carries it.
1405        """
1406        # Readonly instances are the cached ones, shared with every other holder of the
1407        # same file, so this check has to be explicit here.
1408        self._ensure_not_readonly("splits")
1409        self.splits[name] = split
1410        # Validated evals always have `splits` marked already (their test split came from
1411        # `splits` or from the legacy migration, which marks it), so this is for instances
1412        # built by model_construct, where nothing did.
1413        self.__pydantic_fields_set__.add("splits")
1414
1415    # Workaround to return typed parent without importing Task
1416    def parent_task(self) -> Union["Task", None]:
1417        if self.parent is not None and self.parent.__class__.__name__ != "Task":
1418            raise ValueError("parent must be a Task")
1419        return self.parent  # type: ignore
1420
1421    def configs(self, readonly: bool = False) -> list[EvalConfig]:
1422        return super().configs(readonly=readonly)  # type: ignore
1423
1424    # Workaround to return typed parent without importing Spec
1425    def associated_spec(self, readonly: bool = False) -> Union["Spec", None]:
1426        """
1427        Get the spec associated with this eval, if any.
1428        Returns None for legacy evals that are not associated with a spec.
1429        """
1430
1431        task = self.parent_task()
1432        if not task or not self.id:
1433            return None
1434
1435        specs = task.specs(readonly=readonly)
1436        for spec in specs:
1437            if spec.eval_id == self.id:
1438                return spec
1439        return None
1440
1441    def resolved_priority(self, spec: Union["Spec", None] = None) -> Priority:
1442        """
1443        The eval's effective priority. Priority lives on the eval; evals created
1444        before that (spec-backed legacy files) fall back to their spec's value.
1445        Pass *spec* when the caller already has it, to avoid a re-scan.
1446        """
1447        if self.priority is not None:
1448            return self.priority
1449        spec = spec or self.associated_spec(readonly=True)
1450        if spec is not None:
1451            return spec.priority
1452        return Priority.p1
1453
1454    def resolved_status(self, spec: Union["Spec", None] = None) -> EvalStatus:
1455        """
1456        The eval's effective status, with the same spec fallthrough as
1457        resolved_priority().
1458        """
1459        if self.status is not None:
1460            return self.status
1461        spec = spec or self.associated_spec(readonly=True)
1462        if spec is not None:
1463            return spec.status
1464        return EvalStatus.active
1465
1466    def eval_reference_data_keys(self) -> list[str]:
1467        """Union of reference-data keys across all of this eval's V2 configs.
1468
1469        Returns deduplicated keys in stable insertion order.
1470        """
1471        seen: set[str] = set()
1472        result: list[str] = []
1473        for config in self.configs(readonly=True):
1474            if config.config_type != EvalConfigType.v2:
1475                continue
1476            if not isinstance(config.properties, V2_PROPERTY_TYPES):
1477                continue
1478            for key in reference_data_keys(config.properties):  # type: ignore[arg-type]
1479                if key not in seen:
1480                    seen.add(key)
1481                    result.append(key)
1482        return result
1483
1484    @model_validator(mode="after")
1485    def upgrade_old_reference_answer_eval_config(self) -> Self:
1486        """
1487        Migration: Set the first judge config as the default for existing reference answer evals that don't have a current_config_id set.
1488
1489        For reference_answer evals that don't have a current_config_id set, this migration
1490        will set the first config (by created_at) as the default.
1491        """
1492        if self.id is None:
1493            return self
1494
1495        # Only run during file loading
1496        if not self._loaded_from_file:
1497            return self
1498
1499        # Skip if already migrated (has a current_config_id set)
1500        if self.current_config_id is not None:
1501            return self
1502
1503        # Only migrate reference_answer evals
1504        if self.evaluation_data_type != EvalDataType.reference_answer:
1505            return self
1506
1507        # Prevent recursion: self.configs() loads child files, which re-loads this parent
1508        # (see basemodel.py where we iterate_children_paths_of_parent_path calls load_from_file)
1509        # This causes the validator to run again, creating an infinite loop without this guard.
1510        with _migration_lock:
1511            if self.id in _currently_migrating_eval_ids:
1512                return self
1513            _currently_migrating_eval_ids.add(self.id)
1514
1515        try:
1516            # Get the configs - these are loaded from child files
1517            configs_list = self.configs(readonly=True)
1518            if configs_list and len(configs_list) > 0:
1519                # Sort by created_at to get the oldest (first created) config
1520                sorted_configs = sorted(configs_list, key=lambda c: c.created_at)
1521                self.current_config_id = sorted_configs[0].id
1522        finally:
1523            with _migration_lock:
1524                _currently_migrating_eval_ids.discard(self.id)
1525
1526        return self
1527
1528    @model_validator(mode="after")
1529    def validate_scores(self) -> Self:
1530        if self.output_scores is None or len(self.output_scores) == 0:
1531            raise ValueError(
1532                "output_scores are required, and must have at least one score."
1533            )
1534
1535        # check for duplicate names (once transformed to JSON keys)
1536        output_score_keys = [score.json_key() for score in self.output_scores]
1537        if len(output_score_keys) != len(set(output_score_keys)):
1538            raise ValueError(
1539                f"output_scores must have unique names (once transformed to JSON keys). Got: [{', '.join(output_score_keys)}]"
1540            )
1541        return self
1542
1543    @model_validator(mode="after")
1544    def validate_template_properties(self) -> Self:
1545        if self.template is None:
1546            return self
1547
1548        if (
1549            self.template is not EvalTemplateId.rag
1550            and self.eval_configs_filter_id is None
1551        ):
1552            raise ValueError(
1553                "eval_configs_filter_id is required for all templates except 'rag'"
1554            )
1555
1556        # For spec-based evals, template_properties will be None and validation happens in the spec
1557        # For legacy evals, template_properties contains the data and we validate here
1558        if self.template_properties is None:
1559            return self
1560
1561        # Check for properties that are required for the issue template (legacy evals only)
1562        if self.template == EvalTemplateId.issue:
1563            if "issue_prompt" not in self.template_properties or not isinstance(
1564                self.template_properties["issue_prompt"], str
1565            ):
1566                raise ValueError("issue_prompt is required for issue template")
1567            if "failure_example" in self.template_properties and not isinstance(
1568                self.template_properties["failure_example"], str
1569            ):
1570                raise ValueError(
1571                    "failure_example is optional for issue template, but if provided must be a string"
1572                )
1573            if "pass_example" in self.template_properties and not isinstance(
1574                self.template_properties["pass_example"], str
1575            ):
1576                raise ValueError(
1577                    "pass_example is optional for issue template, but if provided must be a string"
1578                )
1579
1580        if self.template == EvalTemplateId.tool_call:
1581            if self.evaluation_data_type != EvalDataType.full_trace:
1582                raise ValueError(
1583                    "tool_call template should have evaluation_data_type set to full_trace"
1584                )
1585            if (
1586                "tool" not in self.template_properties
1587                or not isinstance(self.template_properties["tool"], str)
1588                or not self.template_properties["tool"].strip()
1589            ):
1590                raise ValueError("tool is required for tool call template")
1591            if "tool_function_name" not in self.template_properties or not isinstance(
1592                self.template_properties["tool_function_name"], str
1593            ):
1594                raise ValueError(
1595                    "tool_function_name is required for tool call template"
1596                )
1597            if (
1598                "appropriate_tool_use_guidelines" not in self.template_properties
1599                or not isinstance(
1600                    self.template_properties["appropriate_tool_use_guidelines"], str
1601                )
1602                or not self.template_properties[
1603                    "appropriate_tool_use_guidelines"
1604                ].strip()
1605            ):
1606                raise ValueError(
1607                    "appropriate_tool_use_guidelines is required for tool call template"
1608                )
1609            if (
1610                "inappropriate_tool_use_guidelines" in self.template_properties
1611                and not isinstance(
1612                    self.template_properties["inappropriate_tool_use_guidelines"], str
1613                )
1614            ):
1615                raise ValueError(
1616                    "inappropriate_tool_use_guidelines is optional for tool call template, but if provided must be a string"
1617                )
1618        return self

An evaluator definition that specifies what to evaluate and how scores should be produced.

name: Annotated[str, BeforeValidator(func=<function name_validator.<locals>.fn at 0x7f5eb7c19e40>, json_schema_input_type=PydanticUndefined), StringConstraints(strip_whitespace=None, to_upper=None, to_lower=None, strict=None, min_length=1, max_length=120, pattern=None, ascii_only=None)]
description: str | None
template: EvalTemplateId | None
current_config_id: Optional[str]
eval_set_filter_id: Optional[Annotated[str, AfterValidator(func=<function <lambda> at 0x7f5eb7b144a0>)]]

Read-only data descriptor used to emit a runtime deprecation warning before accessing a deprecated field.

Attributes: msg: The deprecation message to be emitted. wrapped_property: The property instance if the deprecated field is a computed field, or None. field_name: The name of the field being deprecated.

eval_configs_filter_id: Optional[Annotated[str, AfterValidator(func=<function <lambda> at 0x7f5eb7b144a0>)]]
train_set_filter_id: Optional[Annotated[str, AfterValidator(func=<function <lambda> at 0x7f5eb7b144a0>)]]

Read-only data descriptor used to emit a runtime deprecation warning before accessing a deprecated field.

Attributes: msg: The deprecation message to be emitted. wrapped_property: The property instance if the deprecated field is a computed field, or None. field_name: The name of the field being deprecated.

splits: Dict[str, Annotated[Union[TaskRunSplit, EvalInputSplit], Discriminator(discriminator='source', custom_error_type=None, custom_error_message=None, custom_error_context=None)]]
output_scores: List[EvalOutputScore]
favourite: bool
priority: kiln_ai.datamodel.Priority | None
status: kiln_ai.datamodel.datamodel_enums.EvalStatus | None
template_properties: dict[str, str | int | bool | float] | None
evaluation_data_type: EvalDataType | None
@model_validator(mode='after')
def migrate_legacy_split_fields(self) -> Self:
1354    @model_validator(mode="after")
1355    def migrate_legacy_split_fields(self) -> Self:
1356        """Migrate the deprecated flat filter fields into `splits`, once, and clear them.
1357
1358        `splits` is the only home a split has. These fields are an input format for evals
1359        written before it existed, so each one is read exactly once — here — and only for
1360        a split `splits` doesn't already describe. `splits` winning is what makes the
1361        migration one-way: once a value is in `splits` it is the eval's answer, and a
1362        legacy field left over beside it (a hand-edited file, or one an older build wrote
1363        after a newer one) is ignored rather than allowed to overwrite it. Overwriting
1364        would also drop any extra fields on the existing split object, which
1365        `TaskRunSplit`/`EvalInputSplit` keep on purpose (`extra="allow"`).
1366
1367        Both fields are then cleared, unconditionally. That is what makes this a
1368        migration rather than a second home: nothing downstream can read a stale value,
1369        the eval saves with both fields null, and re-running the validator — which
1370        `validate_assignment` does on every attribute set, including `self.path = path`
1371        at the end of save_to_file — has nothing left to do. An older Kiln build reading
1372        the saved file sees no test set rather than the wrong one; that is the accepted
1373        cost of a single home, and the eval list surfaces the evals it can't read.
1374
1375        Reads and writes go through `__dict__` because the fields are
1376        `deprecated=True`: attribute access on them emits a DeprecationWarning, which is
1377        meant for callers, not for the one place that is supposed to touch them.
1378
1379        Must stay declared before validate_splits, which requires a test split: an eval
1380        that carries only legacy fields gets its test split from here.
1381        """
1382        for name, field_name in LEGACY_SPLIT_FIELDS.items():
1383            filter_id = self.__dict__.get(field_name)
1384            if filter_id is not None and name not in self.splits:
1385                self.splits[name] = TaskRunSplit(filter_id=filter_id)
1386                # The split now lives only in `splits`, so an exclude_unset dump has to
1387                # carry it: on a legacy eval `splits` was never explicitly set.
1388                self.__pydantic_fields_set__.add("splits")
1389            self.__dict__[field_name] = None
1390        return self

Migrate the deprecated flat filter fields into splits, once, and clear them.

splits is the only home a split has. These fields are an input format for evals written before it existed, so each one is read exactly once — here — and only for a split splits doesn't already describe. splits winning is what makes the migration one-way: once a value is in splits it is the eval's answer, and a legacy field left over beside it (a hand-edited file, or one an older build wrote after a newer one) is ignored rather than allowed to overwrite it. Overwriting would also drop any extra fields on the existing split object, which TaskRunSplit/EvalInputSplit keep on purpose (extra="allow").

Both fields are then cleared, unconditionally. That is what makes this a migration rather than a second home: nothing downstream can read a stale value, the eval saves with both fields null, and re-running the validator — which validate_assignment does on every attribute set, including self.path = path at the end of save_to_file — has nothing left to do. An older Kiln build reading the saved file sees no test set rather than the wrong one; that is the accepted cost of a single home, and the eval list surfaces the evals it can't read.

Reads and writes go through __dict__ because the fields are deprecated=True: attribute access on them emits a DeprecationWarning, which is meant for callers, not for the one place that is supposed to touch them.

Must stay declared before validate_splits, which requires a test split: an eval that carries only legacy fields gets its test split from here.

@model_validator(mode='after')
def validate_splits(self) -> Self:
1392    @model_validator(mode="after")
1393    def validate_splits(self) -> Self:
1394        if "test" not in self.splits:
1395            raise ValueError("An eval must have a test split. Set splits['test'].")
1396        return self
def set_split( self, name: str, split: Annotated[Union[TaskRunSplit, EvalInputSplit], Discriminator(discriminator='source', custom_error_type=None, custom_error_message=None, custom_error_context=None)]) -> None:
1398    def set_split(self, name: str, split: SplitRef) -> None:
1399        """Set one of the eval's splits.
1400
1401        Equivalent to `eval.splits[name] = split` plus the two things item assignment on
1402        a dict can't do for itself, because it never reaches `__setattr__`: refusing to
1403        mutate a readonly (cached) eval, and marking `splits` as set so an
1404        exclude_unset dump still carries it.
1405        """
1406        # Readonly instances are the cached ones, shared with every other holder of the
1407        # same file, so this check has to be explicit here.
1408        self._ensure_not_readonly("splits")
1409        self.splits[name] = split
1410        # Validated evals always have `splits` marked already (their test split came from
1411        # `splits` or from the legacy migration, which marks it), so this is for instances
1412        # built by model_construct, where nothing did.
1413        self.__pydantic_fields_set__.add("splits")

Set one of the eval's splits.

Equivalent to eval.splits[name] = split plus the two things item assignment on a dict can't do for itself, because it never reaches __setattr__: refusing to mutate a readonly (cached) eval, and marking splits as set so an exclude_unset dump still carries it.

def parent_task(self) -> Optional[kiln_ai.datamodel.Task]:
1416    def parent_task(self) -> Union["Task", None]:
1417        if self.parent is not None and self.parent.__class__.__name__ != "Task":
1418            raise ValueError("parent must be a Task")
1419        return self.parent  # type: ignore
def configs(self, readonly=False) -> List[EvalConfig]:
841        def child_method(self, readonly: bool = False) -> list[child_class]:  # type: ignore[invalid-type-form]
842            return child_class.all_children_of_parent_path(self.path, readonly=readonly)

The type of the None singleton.

def associated_spec(self, readonly: bool = False) -> Optional[kiln_ai.datamodel.spec.Spec]:
1425    def associated_spec(self, readonly: bool = False) -> Union["Spec", None]:
1426        """
1427        Get the spec associated with this eval, if any.
1428        Returns None for legacy evals that are not associated with a spec.
1429        """
1430
1431        task = self.parent_task()
1432        if not task or not self.id:
1433            return None
1434
1435        specs = task.specs(readonly=readonly)
1436        for spec in specs:
1437            if spec.eval_id == self.id:
1438                return spec
1439        return None

Get the spec associated with this eval, if any. Returns None for legacy evals that are not associated with a spec.

def resolved_priority( self, spec: Optional[kiln_ai.datamodel.spec.Spec] = None) -> kiln_ai.datamodel.Priority:
1441    def resolved_priority(self, spec: Union["Spec", None] = None) -> Priority:
1442        """
1443        The eval's effective priority. Priority lives on the eval; evals created
1444        before that (spec-backed legacy files) fall back to their spec's value.
1445        Pass *spec* when the caller already has it, to avoid a re-scan.
1446        """
1447        if self.priority is not None:
1448            return self.priority
1449        spec = spec or self.associated_spec(readonly=True)
1450        if spec is not None:
1451            return spec.priority
1452        return Priority.p1

The eval's effective priority. Priority lives on the eval; evals created before that (spec-backed legacy files) fall back to their spec's value. Pass spec when the caller already has it, to avoid a re-scan.

def resolved_status( self, spec: Optional[kiln_ai.datamodel.spec.Spec] = None) -> kiln_ai.datamodel.datamodel_enums.EvalStatus:
1454    def resolved_status(self, spec: Union["Spec", None] = None) -> EvalStatus:
1455        """
1456        The eval's effective status, with the same spec fallthrough as
1457        resolved_priority().
1458        """
1459        if self.status is not None:
1460            return self.status
1461        spec = spec or self.associated_spec(readonly=True)
1462        if spec is not None:
1463            return spec.status
1464        return EvalStatus.active

The eval's effective status, with the same spec fallthrough as resolved_priority().

def eval_reference_data_keys(self) -> list[str]:
1466    def eval_reference_data_keys(self) -> list[str]:
1467        """Union of reference-data keys across all of this eval's V2 configs.
1468
1469        Returns deduplicated keys in stable insertion order.
1470        """
1471        seen: set[str] = set()
1472        result: list[str] = []
1473        for config in self.configs(readonly=True):
1474            if config.config_type != EvalConfigType.v2:
1475                continue
1476            if not isinstance(config.properties, V2_PROPERTY_TYPES):
1477                continue
1478            for key in reference_data_keys(config.properties):  # type: ignore[arg-type]
1479                if key not in seen:
1480                    seen.add(key)
1481                    result.append(key)
1482        return result

Union of reference-data keys across all of this eval's V2 configs.

Returns deduplicated keys in stable insertion order.

@model_validator(mode='after')
def upgrade_old_reference_answer_eval_config(self) -> Self:
1484    @model_validator(mode="after")
1485    def upgrade_old_reference_answer_eval_config(self) -> Self:
1486        """
1487        Migration: Set the first judge config as the default for existing reference answer evals that don't have a current_config_id set.
1488
1489        For reference_answer evals that don't have a current_config_id set, this migration
1490        will set the first config (by created_at) as the default.
1491        """
1492        if self.id is None:
1493            return self
1494
1495        # Only run during file loading
1496        if not self._loaded_from_file:
1497            return self
1498
1499        # Skip if already migrated (has a current_config_id set)
1500        if self.current_config_id is not None:
1501            return self
1502
1503        # Only migrate reference_answer evals
1504        if self.evaluation_data_type != EvalDataType.reference_answer:
1505            return self
1506
1507        # Prevent recursion: self.configs() loads child files, which re-loads this parent
1508        # (see basemodel.py where we iterate_children_paths_of_parent_path calls load_from_file)
1509        # This causes the validator to run again, creating an infinite loop without this guard.
1510        with _migration_lock:
1511            if self.id in _currently_migrating_eval_ids:
1512                return self
1513            _currently_migrating_eval_ids.add(self.id)
1514
1515        try:
1516            # Get the configs - these are loaded from child files
1517            configs_list = self.configs(readonly=True)
1518            if configs_list and len(configs_list) > 0:
1519                # Sort by created_at to get the oldest (first created) config
1520                sorted_configs = sorted(configs_list, key=lambda c: c.created_at)
1521                self.current_config_id = sorted_configs[0].id
1522        finally:
1523            with _migration_lock:
1524                _currently_migrating_eval_ids.discard(self.id)
1525
1526        return self

Migration: Set the first judge config as the default for existing reference answer evals that don't have a current_config_id set.

For reference_answer evals that don't have a current_config_id set, this migration will set the first config (by created_at) as the default.

@model_validator(mode='after')
def validate_scores(self) -> Self:
1528    @model_validator(mode="after")
1529    def validate_scores(self) -> Self:
1530        if self.output_scores is None or len(self.output_scores) == 0:
1531            raise ValueError(
1532                "output_scores are required, and must have at least one score."
1533            )
1534
1535        # check for duplicate names (once transformed to JSON keys)
1536        output_score_keys = [score.json_key() for score in self.output_scores]
1537        if len(output_score_keys) != len(set(output_score_keys)):
1538            raise ValueError(
1539                f"output_scores must have unique names (once transformed to JSON keys). Got: [{', '.join(output_score_keys)}]"
1540            )
1541        return self
@model_validator(mode='after')
def validate_template_properties(self) -> Self:
1543    @model_validator(mode="after")
1544    def validate_template_properties(self) -> Self:
1545        if self.template is None:
1546            return self
1547
1548        if (
1549            self.template is not EvalTemplateId.rag
1550            and self.eval_configs_filter_id is None
1551        ):
1552            raise ValueError(
1553                "eval_configs_filter_id is required for all templates except 'rag'"
1554            )
1555
1556        # For spec-based evals, template_properties will be None and validation happens in the spec
1557        # For legacy evals, template_properties contains the data and we validate here
1558        if self.template_properties is None:
1559            return self
1560
1561        # Check for properties that are required for the issue template (legacy evals only)
1562        if self.template == EvalTemplateId.issue:
1563            if "issue_prompt" not in self.template_properties or not isinstance(
1564                self.template_properties["issue_prompt"], str
1565            ):
1566                raise ValueError("issue_prompt is required for issue template")
1567            if "failure_example" in self.template_properties and not isinstance(
1568                self.template_properties["failure_example"], str
1569            ):
1570                raise ValueError(
1571                    "failure_example is optional for issue template, but if provided must be a string"
1572                )
1573            if "pass_example" in self.template_properties and not isinstance(
1574                self.template_properties["pass_example"], str
1575            ):
1576                raise ValueError(
1577                    "pass_example is optional for issue template, but if provided must be a string"
1578                )
1579
1580        if self.template == EvalTemplateId.tool_call:
1581            if self.evaluation_data_type != EvalDataType.full_trace:
1582                raise ValueError(
1583                    "tool_call template should have evaluation_data_type set to full_trace"
1584                )
1585            if (
1586                "tool" not in self.template_properties
1587                or not isinstance(self.template_properties["tool"], str)
1588                or not self.template_properties["tool"].strip()
1589            ):
1590                raise ValueError("tool is required for tool call template")
1591            if "tool_function_name" not in self.template_properties or not isinstance(
1592                self.template_properties["tool_function_name"], str
1593            ):
1594                raise ValueError(
1595                    "tool_function_name is required for tool call template"
1596                )
1597            if (
1598                "appropriate_tool_use_guidelines" not in self.template_properties
1599                or not isinstance(
1600                    self.template_properties["appropriate_tool_use_guidelines"], str
1601                )
1602                or not self.template_properties[
1603                    "appropriate_tool_use_guidelines"
1604                ].strip()
1605            ):
1606                raise ValueError(
1607                    "appropriate_tool_use_guidelines is required for tool call template"
1608                )
1609            if (
1610                "inappropriate_tool_use_guidelines" in self.template_properties
1611                and not isinstance(
1612                    self.template_properties["inappropriate_tool_use_guidelines"], str
1613                )
1614            ):
1615                raise ValueError(
1616                    "inappropriate_tool_use_guidelines is optional for tool call template, but if provided must be a string"
1617                )
1618        return self
def relationship_name() -> str:
859        def relationship_name_method() -> str:
860            return filesystem_name

The type of the None singleton.

def parent_type() -> Type[kiln_ai.datamodel.basemodel.KilnParentModel]:
852        def parent_class_method() -> Type[KilnParentModel]:
853            return cls

The type of the None singleton.

model_config = {'validate_assignment': True}

Configuration for the model, should be a dictionary conforming to [ConfigDict][pydantic.config.ConfigDict].

def model_post_init(self: pydantic.main.BaseModel, context: Any, /) -> None:
365def init_private_attributes(self: BaseModel, context: Any, /) -> None:
366    """This function is meant to behave like a BaseModel method to initialize private attributes.
367
368    It takes context as an argument since that's what pydantic-core passes when calling it.
369
370    Args:
371        self: The BaseModel instance.
372        context: The context.
373    """
374    if getattr(self, '__pydantic_private__', None) is None:
375        pydantic_private = {}
376        for name, private_attr in self.__private_attributes__.items():
377            # Avoid needlessly creating a new dict for the validated data:
378            if private_attr.default_factory_takes_validated_data:
379                default = private_attr.get_default(
380                    call_default_factory=True, validated_data={**self.__dict__, **pydantic_private}
381                )
382            else:
383                default = private_attr.get_default(call_default_factory=True)
384            if default is not PydanticUndefined:
385                pydantic_private[name] = default
386        object_setattr(self, '__pydantic_private__', pydantic_private)

This function is meant to behave like a BaseModel method to initialize private attributes.

It takes context as an argument since that's what pydantic-core passes when calling it.

Args: self: The BaseModel instance. context: The context.