Source code for oumi.environments.deterministic_environment

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

"""Deterministic environment with fixed lookup responses."""

from __future__ import annotations

import copy
import json
import random
from dataclasses import dataclass, field
from typing import Any

import jsonschema
from pydantic import JsonValue
from pydantic import ValidationError as PydanticValidationError

from oumi.core.configs.params.base_params import BaseParams
from oumi.core.configs.params.environment_params import EnvironmentParams
from oumi.core.configs.params.grounding_params import GroundingFact
from oumi.core.configs.params.tool_params import (
    ToolArgumentError,
    ToolLookupError,
    ToolParams,
)
from oumi.core.registry import register_environment
from oumi.core.types.tool_call import ToolResult
from oumi.environments.base_environment import BaseEnvironment
from oumi.environments.utils import parse_env_kwargs
from oumi.utils.logging import logger

# JSON Schema keywords whose values contain subschemas.
_SUBSCHEMA = frozenset(
    {
        "additionalItems",
        "additionalProperties",
        "contains",
        "else",
        "if",
        "items",
        "not",
        "propertyNames",
        "then",
        "unevaluatedItems",
        "unevaluatedProperties",
    }
)
_SUBSCHEMA_LIST = frozenset({"allOf", "anyOf", "oneOf", "prefixItems"})
_SUBSCHEMA_MAP = frozenset({"dependentSchemas", "patternProperties", "properties"})


def _resolve_ref(schema: dict[str, Any], root: dict[str, Any]) -> dict[str, Any]:
    """Resolve a local ``$ref``, preserving sibling overrides."""
    ref = schema.get("$ref")
    if not isinstance(ref, str) or not ref.startswith("#"):
        return schema
    target: Any = root
    for part in ref[1:].split("/"):
        if not part:
            continue
        part = part.replace("~1", "/").replace("~0", "~")
        if not isinstance(target, dict) or part not in target:
            return schema
        target = target[part]
    if not isinstance(target, dict):
        return schema
    return {**target, **{k: v for k, v in schema.items() if k != "$ref"}}


def _fill_argument_defaults(
    arguments: dict[str, Any],
    schema: dict[str, Any],
    root: dict[str, Any] | None = None,
    seen: frozenset[str] = frozenset(),
) -> dict[str, Any]:
    """Return a copy of arguments with property defaults applied recursively."""
    if root is None:
        root = schema
    result = copy.deepcopy(arguments)
    properties = _resolve_ref(schema, root).get("properties", {})
    if not isinstance(properties, dict):
        return result

    for name, property_schema in properties.items():
        if not isinstance(property_schema, dict):
            continue
        ref = property_schema.get("$ref")
        property_schema = _resolve_ref(property_schema, root)
        cyclic = isinstance(ref, str) and ref in seen
        if name not in result and "default" in property_schema and not cyclic:
            result[name] = copy.deepcopy(property_schema["default"])
        if isinstance(result.get(name), dict):
            result[name] = _fill_argument_defaults(
                result[name],
                property_schema,
                root,
                seen | {ref} if isinstance(ref, str) else seen,
            )
    return result


def _unfillable_default_paths(
    node: Any,
    path: str,
    root: dict[str, Any],
    reachable: bool = True,
    seen: frozenset[tuple[str, bool]] = frozenset(),
) -> list[str]:
    """Find defaults outside ``properties`` chains."""
    if not isinstance(node, dict):
        return []

    ref = node.get("$ref")
    if isinstance(ref, str):
        if (ref, reachable) in seen:
            return []
        node, seen = _resolve_ref(node, root), seen | {(ref, reachable)}

    found: list[str] = []
    if not reachable and "default" in node:
        found.append(f"{path}.default")
    for key, value in node.items():
        if key in _SUBSCHEMA:
            found += _unfillable_default_paths(
                value, f"{path}.{key}", root, False, seen
            )
        elif key in _SUBSCHEMA_LIST and isinstance(value, list):
            for index, item in enumerate(value):
                found += _unfillable_default_paths(
                    item, f"{path}.{key}[{index}]", root, False, seen
                )
        elif key in _SUBSCHEMA_MAP and isinstance(value, dict):
            child_reachable = reachable and key == "properties"
            for name, subschema in value.items():
                found += _unfillable_default_paths(
                    subschema, f"{path}.{key}.{name}", root, child_reachable, seen
                )
    return found


[docs] @dataclass class ToolLookupEntry(BaseParams): """One (input, output) pair in a deterministic env's lookup table. ``output`` may be any JSON value (scalar, list, object, or null). """ input: dict[str, Any] = field(default_factory=dict) output: JsonValue = None
[docs] def input_key(self) -> str: """Canonical JSON form of ``input`` for matching and dedup.""" return json.dumps(self.input, sort_keys=True)
[docs] def matches(self, arguments: dict[str, Any]) -> bool: """Check if the input matches the given arguments.""" return self.input_key() == json.dumps(arguments, sort_keys=True)
[docs] @dataclass class DeterministicEnvironmentKwargs(BaseParams): """Type-specific kwargs for DeterministicEnvironment.""" lookup_table: dict[str, list[ToolLookupEntry]] = field(default_factory=dict) """Per-tool list of (input, output) entries, keyed by tool id."""
[docs] def __post_init__(self) -> None: """Coerce raw entry dicts into ``ToolLookupEntry`` instances.""" self.lookup_table = { tool_id: [ entry if isinstance(entry, ToolLookupEntry) else ToolLookupEntry(**entry) for entry in entries ] for tool_id, entries in self.lookup_table.items() }
[docs] @register_environment("deterministic") class DeterministicEnvironment(BaseEnvironment): """Environment that resolves tools from a per-tool lookup table. The env's ``env_kwargs.lookup_table`` is the source of truth for tool behavior. Tools listed in ``params.tools`` declare contracts only; their data lives on the env. """ tool_params_cls = ToolParams def __init__( self, params: EnvironmentParams, kwargs: DeterministicEnvironmentKwargs, ) -> None: """Initialize a DeterministicEnvironment.""" self._params = params self._kwargs = kwargs self._tools_by_id: dict[str, ToolParams] = { tool.id: tool for tool in params.tools } self._validate_lookup_table() self._warn_grounding_key_collisions()
[docs] def step(self, calls: list[tuple[str, dict[str, Any]]]) -> list[ToolResult]: """Resolve a batch of deterministic tool calls to their outputs.""" return [self._resolve_one(tool_id, args) for tool_id, args in calls]
def _resolve_one(self, tool_id: str, arguments: dict[str, Any]) -> ToolResult: tool = self._tools_by_id.get(tool_id) if tool is None: raise ValueError( f"Tool '{tool_id}' not found in environment '{self._params.id}'. " f"Available tools: {sorted(self._tools_by_id)}" ) arguments = _fill_argument_defaults(arguments, tool.parameters) entries = self._kwargs.lookup_table.get(tool_id, []) for entry in entries: if entry.matches(arguments): return ToolResult(output=entry.output) available = [entry.input for entry in entries] raise ToolLookupError( f"No deterministic output matches arguments " f"{json.dumps(arguments, sort_keys=True)} for tool '{tool_id}'. " f"Configured inputs: {json.dumps(available, sort_keys=True)}" )
[docs] def sample_grounding( self, n: int, *, rng: random.Random, tool_ids: set[str] | None = None, ) -> list[GroundingFact]: """Sample grounding facts from per-tool projected pools. Walks every tool that has a per-tool entry in ``params.grounding.tools``. Each entry in that tool's lookup table is projected to its ``input`` fields (merged with ``output`` when the output is a dict), filtered through the configured ``fields`` whitelist. Tools without a grounding entry contribute nothing. """ grounding = self._params.grounding if grounding is None or not grounding.tools: return [] pool: list[GroundingFact] = [] for tool in self._params.tools: tool_grounding = grounding.tools.get(tool.id) if tool_grounding is None: continue if tool_ids is not None and tool.id not in tool_ids: continue whitelist = set(tool_grounding.fields) for entry in self._kwargs.lookup_table.get(tool.id, []): # Non-dict outputs (scalars/lists) have no named fields to # project, so they ground on their input fields only; dict # outputs merge both. row = dict(entry.input) if isinstance(entry.output, dict): row.update(entry.output) projected = { key: value for key, value in row.items() if key in whitelist } pool.append(GroundingFact(data=projected)) return rng.sample(pool, min(n, len(pool)))
[docs] @classmethod def from_params(cls, params: EnvironmentParams) -> DeterministicEnvironment: """Build a DeterministicEnvironment from its params object.""" kwargs = parse_env_kwargs( DeterministicEnvironmentKwargs, params, env_label="DeterministicEnvironment", ) return cls(params, kwargs)
def _validate_lookup_table(self) -> None: """Validate and normalize the lookup table. Entries under stale keys (no matching tool) are neither normalized nor validated. Inputs must conform to the tool's ``parameters``; outputs must be JSON values, and conform to ``output_schema`` only when the tool declares one. """ for tool_id in self._kwargs.lookup_table: if tool_id not in self._tools_by_id: logger.warning( "Environment '%s': lookup_table.'%s' references unknown " "tool. Entries will be ignored.", self._params.id, tool_id, ) for tool in self._params.tools: entries = self._kwargs.lookup_table.get(tool.id, []) if not entries: raise ValueError( f"Tool '{tool.id}' has no entries in lookup_table for " f"environment '{self._params.id}'." ) unfillable = _unfillable_default_paths( tool.parameters, "parameters", tool.parameters ) if unfillable: raise ValueError( f"Tool '{tool.id}' in environment '{self._params.id}' declares " "schema defaults that are never applied, at " f"{sorted(unfillable)}. Defaults are only filled along " "'properties' chains; move them onto a property." ) seen: set[str] = set() for entry in entries: entry.input = _fill_argument_defaults(entry.input, tool.parameters) try: tool.validate_arguments(entry.input) except ToolArgumentError as e: raise ValueError( f"Tool '{tool.id}' has lookup_table entry with invalid " f"input {entry.input}: {e}" ) from e # jsonschema accepts non-JSON values a JsonValue output rejects # (e.g. a dict with int keys), so check against the consumer first. try: ToolResult(output=entry.output) except PydanticValidationError as e: raise ValueError( f"Tool '{tool.id}' has lookup_table entry with non-JSON " f"output {entry.output} for input {entry.input}: {e}" ) from e if tool.output_schema is not None: try: jsonschema.validate(entry.output, tool.output_schema) except jsonschema.ValidationError as e: raise ValueError( f"Tool '{tool.id}' has lookup_table entry with invalid " f"output {entry.output} for input {entry.input}: {e}" ) from e key = entry.input_key() if key in seen: raise ValueError( f"Tool '{tool.id}' has duplicate input entry: {entry.input}" ) seen.add(key) def _warn_grounding_key_collisions(self) -> None: """Warn once when a dict output shadows a whitelisted input field. Only whitelisted fields matter — a collision on any other key is dropped by the projection and never reaches a grounding fact. """ grounding = self._params.grounding if grounding is None or not grounding.tools: return for tool_id, tool_grounding in grounding.tools.items(): whitelist = set(tool_grounding.fields) shadowed: set[str] = set() for entry in self._kwargs.lookup_table.get(tool_id, []): if isinstance(entry.output, dict): shadowed |= entry.input.keys() & entry.output.keys() & whitelist if shadowed: logger.warning( "Environment '%s': tool '%s' grounding field(s) %s appear " "in both input and output; the output value shadows the " "input in grounding facts.", self._params.id, tool_id, sorted(shadowed), )