Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions py/src/braintrust/parameters.py
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,25 @@ def _create_prompt(name: str, prompt_data: dict[str, Any]) -> "Prompt":
return Prompt.from_prompt_data(name, PromptData.from_dict_deep(prompt_data))


def _copy_parameter_containers(value: Any, memo: dict[int, Any] | None = None) -> Any:
"""Isolate JSON containers without deep-copying opaque parameter values."""
if not isinstance(value, (dict, list)):
return value
if memo is None:
memo = {}
if id(value) in memo:
return memo[id(value)]
if isinstance(value, dict):
copied_dict: dict[str, Any] = {}
memo[id(value)] = copied_dict
copied_dict.update({key: _copy_parameter_containers(item, memo) for key, item in value.items()})
return copied_dict
copied_list: list[Any] = []
memo[id(value)] = copied_list
copied_list.extend(_copy_parameter_containers(item, memo) for item in value)
return copied_list


def _apply_defaults_to_json_schema_instance(instance: Any, schema: Mapping[str, Any]) -> Any:
if not isinstance(instance, dict):
return instance
Expand All @@ -268,7 +287,7 @@ def _apply_defaults_to_json_schema_instance(instance: Any, schema: Mapping[str,
continue

if name not in instance and "default" in property_schema:
instance[name] = property_schema["default"]
instance[name] = _copy_parameter_containers(property_schema["default"])

if name in instance:
value = instance[name]
Expand All @@ -284,7 +303,7 @@ def _apply_defaults_to_json_schema_instance(instance: Any, schema: Mapping[str,


def validate_json_schema(parameters: dict[str, Any], schema: ParametersSchema) -> dict[str, Any]:
candidate = dict(parameters)
candidate = _copy_parameter_containers(dict(parameters))
_apply_defaults_to_json_schema_instance(candidate, schema)

validator = Draft7Validator(schema)
Expand Down
76 changes: 76 additions & 0 deletions py/src/braintrust/test_parameters.py
Original file line number Diff line number Diff line change
Expand Up @@ -674,3 +674,79 @@ def schema(cls):
assert serialized["judge_model"]["type"] == "model"
for name in ("template", "main", "judge_model"):
assert "description" not in serialized[name]


def _remote_parameters_for_isolation(schema, data=None):
return RemoteEvalParameters(
id="params-123",
project_id="project-123",
name="Saved parameters",
slug="saved-parameters",
version="v1",
schema=schema,
data={} if data is None else data,
)


def test_remote_parameter_defaults_are_isolated_between_validations():
parameters = _remote_parameters_for_isolation(
{"type": "object", "properties": {"labels": {"type": "array", "items": {"type": "string"}, "default": []}}}
)
first = validate_parameters({}, parameters)
first["labels"].append("first-run-only")
assert validate_parameters({}, parameters) == {"labels": []}
assert parameters.schema["properties"]["labels"]["default"] == []


def test_remote_parameter_nested_defaults_do_not_mutate_inputs():
parameters = _remote_parameters_for_isolation(
{
"type": "object",
"properties": {
"configs": {
"type": "array",
"items": {"type": "object", "properties": {"limit": {"type": "integer", "default": 3}}},
}
},
}
)
supplied = {"configs": [{}]}
assert validate_parameters(supplied, parameters) == {"configs": [{"limit": 3}]}
assert supplied == {"configs": [{}]}


def test_remote_parameter_results_do_not_mutate_saved_data():
parameters = _remote_parameters_for_isolation(
{"type": "object", "properties": {"config": {"type": "object"}}}, {"config": {"limit": 3}}
)
first = validate_parameters({}, parameters)
first["config"]["limit"] = 9
assert validate_parameters({}, parameters) == {"config": {"limit": 3}}


def test_remote_parameter_validation_preserves_opaque_values():
class Opaque:
def __deepcopy__(self, memo):
raise AssertionError("Opaque parameter values must not be deep-copied")

value = Opaque()
parameters = _remote_parameters_for_isolation({"type": "object"})
result = validate_parameters({"config": {"value": value}}, parameters)
assert result["config"]["value"] is value


def test_invalid_remote_parameters_do_not_apply_defaults_to_input():
parameters = _remote_parameters_for_isolation(
{
"type": "object",
"properties": {
"config": {"type": "object", "properties": {"limit": {"type": "integer", "default": 3}}},
"required_value": {"type": "integer"},
},
"required": ["required_value"],
}
)
supplied = {"config": {}}
with pytest.raises(ValueError, match="Invalid parameters"):
validate_parameters(supplied, parameters)
assert supplied == {"config": {}}