From 014b6b10571cc3e691da4467c929e7471220c480 Mon Sep 17 00:00:00 2001 From: Jason Wang Date: Tue, 13 Oct 2026 11:59:06 +0800 Subject: [PATCH 1/3] chore: bootstrap profile parameter rules draft From 3369bfd005f4f0b64d167088ad8037f71cfcd8cf Mon Sep 17 00:00:00 2001 From: Jason Wang Date: Tue, 13 Oct 2026 12:31:44 +0800 Subject: [PATCH 2/3] feat(model-service): validate finite parameter schemas and values --- .../profiles/parameter_schema.py | 212 ++++++++++++++++++ .../MODEL-PROFILE-PARAMETERS-20261013.yaml | 28 +++ .../SDK-MODEL-PROFILE-PARAMETERS/cases.md | 5 + .../SDK-MODEL-PROFILE-PARAMETERS/feature.yaml | 21 ++ .../model_service/test_parameter_schema.py | 165 ++++++++++++++ 5 files changed, 431 insertions(+) create mode 100644 sdk/nexent/core/model_service/profiles/parameter_schema.py create mode 100644 test-e2e/changes/requirements/MODEL-PROFILE-PARAMETERS-20261013.yaml create mode 100644 test-e2e/features/SDK-MODEL-PROFILE-PARAMETERS/cases.md create mode 100644 test-e2e/features/SDK-MODEL-PROFILE-PARAMETERS/feature.yaml create mode 100644 test/sdk/core/model_service/test_parameter_schema.py diff --git a/sdk/nexent/core/model_service/profiles/parameter_schema.py b/sdk/nexent/core/model_service/profiles/parameter_schema.py new file mode 100644 index 0000000000..5ef5433e3d --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/parameter_schema.py @@ -0,0 +1,212 @@ +"""Finite parameter schema validation without evaluation or remote resolution.""" + +import json +import math +from dataclasses import dataclass, field +from typing import Mapping + + +_KEYWORDS = frozenset( + { + "type", + "enum", + "const", + "minimum", + "maximum", + "minLength", + "maxLength", + "items", + "properties", + "required", + "additionalProperties", + } +) +_TYPES = frozenset({"null", "boolean", "integer", "number", "string", "array", "object"}) + + +class ParameterValidationError(ValueError): + """A value-free diagnostic suitable for configuration error projection.""" + + def __init__(self, category: str, path: str = "", keyword: str = ""): + self.category = category + self.path = path + self.keyword = keyword + super().__init__(f"{category}: {path}: {keyword}") + + +@dataclass(frozen=True) +class CompiledParameterSchema: + """Canonical immutable schema bytes; callers receive independent copies.""" + + _json: bytes = field(repr=False) + + @property + def schema(self) -> dict: + return json.loads(self._json) + + +def _path(parent: str, key: object) -> str: + return parent + "/" + str(key).replace("~", "~0").replace("/", "~1") + + +def _json_copy(value: object, path: str = "", ancestors: frozenset[int] = frozenset()): + if value is None or type(value) in (bool, int, str): + return value + if type(value) is float: + if math.isfinite(value): + return value + elif isinstance(value, (Mapping, list, tuple)): + if id(value) in ancestors: + raise ParameterValidationError("PARAMETER_JSON_INVALID", path) + ancestors = ancestors | {id(value)} + if isinstance(value, Mapping): + copied = {} + for key, item in value.items(): + if not isinstance(key, str): + raise ParameterValidationError("PARAMETER_JSON_INVALID", path) + copied[key] = _json_copy(item, _path(path, key), ancestors) + return copied + return [_json_copy(item, _path(path, index), ancestors) for index, item in enumerate(value)] + raise ParameterValidationError("PARAMETER_JSON_INVALID", path) + + +def json_values_equal(left: object, right: object) -> bool: + """JSON numeric equality never conflates booleans with numbers.""" + if type(left) in (int, float) and type(right) in (int, float): + return left == right + if isinstance(left, Mapping) and isinstance(right, Mapping): + return left.keys() == right.keys() and all(json_values_equal(value, right[key]) for key, value in left.items()) + if isinstance(left, (list, tuple)) and isinstance(right, (list, tuple)): + return len(left) == len(right) and all(json_values_equal(a, b) for a, b in zip(left, right)) + return type(left) is type(right) and left == right + + +def _check_schema(schema: object, path: str = "") -> None: + if not isinstance(schema, dict): + raise ParameterValidationError("PARAMETER_SCHEMA_INVALID", path) + for key in schema: + if key not in _KEYWORDS: + raise ParameterValidationError("PARAMETER_SCHEMA_KEYWORD_UNSUPPORTED", _path(path, key), key) + if "type" in schema: + types = schema["type"] if isinstance(schema["type"], list) else [schema["type"]] + if ( + not types + or any(not isinstance(kind, str) or kind not in _TYPES for kind in types) + or len(set(types)) != len(types) + ): + raise ParameterValidationError("PARAMETER_SCHEMA_INVALID", _path(path, "type"), "type") + if "enum" in schema: + values = schema["enum"] + if ( + not isinstance(values, list) + or not values + or any( + json_values_equal(value, previous) for index, value in enumerate(values) for previous in values[:index] + ) + ): + raise ParameterValidationError("PARAMETER_SCHEMA_INVALID", _path(path, "enum"), "enum") + for key in ("minimum", "maximum"): + if key in schema and type(schema[key]) not in (int, float): + raise ParameterValidationError("PARAMETER_SCHEMA_INVALID", _path(path, key), key) + for key in ("minLength", "maxLength"): + if key in schema and (type(schema[key]) is not int or schema[key] < 0): + raise ParameterValidationError("PARAMETER_SCHEMA_INVALID", _path(path, key), key) + for lower, upper in (("minimum", "maximum"), ("minLength", "maxLength")): + if lower in schema and upper in schema and schema[lower] > schema[upper]: + raise ParameterValidationError("PARAMETER_SCHEMA_INVALID", path, lower) + if "required" in schema: + keys = schema["required"] + if not isinstance(keys, list) or any(not isinstance(key, str) for key in keys) or len(set(keys)) != len(keys): + raise ParameterValidationError("PARAMETER_SCHEMA_INVALID", _path(path, "required"), "required") + if "items" in schema: + _check_schema(schema["items"], _path(path, "items")) + if "properties" in schema: + if not isinstance(schema["properties"], dict): + raise ParameterValidationError("PARAMETER_SCHEMA_INVALID", _path(path, "properties"), "properties") + for key, child in schema["properties"].items(): + _check_schema(child, _path(_path(path, "properties"), key)) + if "additionalProperties" in schema and type(schema["additionalProperties"]) is not bool: + _check_schema(schema["additionalProperties"], _path(path, "additionalProperties")) + + +def compile_parameter_schema(schema: Mapping[str, object]) -> CompiledParameterSchema: + """Reject unsupported syntax before parameter values can use this schema.""" + try: + copied = _json_copy(schema) + _check_schema(copied) + encoded = json.dumps( + copied, ensure_ascii=False, allow_nan=False, sort_keys=True, separators=(",", ":") + ).encode("utf-8") + except (RecursionError, UnicodeError, TypeError, ValueError) as error: + if isinstance(error, ParameterValidationError): + raise + raise ParameterValidationError("PARAMETER_JSON_INVALID") from None + return CompiledParameterSchema(encoded) + + +def _matches(value: object, kind: str) -> bool: + return { + "null": value is None, + "boolean": type(value) is bool, + "integer": type(value) is int, + "number": type(value) in (int, float), + "string": isinstance(value, str), + "array": isinstance(value, list), + "object": isinstance(value, dict), + }[kind] + + +def _validate(schema: dict, value: object, path: str) -> None: + if "type" in schema: + kinds = schema["type"] if isinstance(schema["type"], list) else [schema["type"]] + if not any(_matches(value, kind) for kind in kinds): + raise ParameterValidationError("PARAMETER_VALUE_INVALID", path, "type") + if "enum" in schema and not any(json_values_equal(value, option) for option in schema["enum"]): + raise ParameterValidationError("PARAMETER_VALUE_INVALID", path, "enum") + if "const" in schema and not json_values_equal(value, schema["const"]): + raise ParameterValidationError("PARAMETER_VALUE_INVALID", path, "const") + if type(value) in (int, float): + for keyword in ("minimum", "maximum"): + if keyword in schema and ( + (keyword == "minimum" and value < schema[keyword]) + or (keyword == "maximum" and value > schema[keyword]) + ): + raise ParameterValidationError("PARAMETER_VALUE_INVALID", path, keyword) + if isinstance(value, str): + for keyword in ("minLength", "maxLength"): + if keyword in schema and ( + (keyword == "minLength" and len(value) < schema[keyword]) + or (keyword == "maxLength" and len(value) > schema[keyword]) + ): + raise ParameterValidationError("PARAMETER_VALUE_INVALID", path, keyword) + if isinstance(value, list) and "items" in schema: + for index, item in enumerate(value): + _validate(schema["items"], item, _path(path, index)) + if isinstance(value, dict): + for key in schema.get("required", ()): + if key not in value: + raise ParameterValidationError("PARAMETER_VALUE_REQUIRED", _path(path, key), "required") + properties = schema.get("properties", {}) + additional = schema.get("additionalProperties", True) + for key, item in value.items(): + if key in properties: + _validate(properties[key], item, _path(path, key)) + elif additional is False: + raise ParameterValidationError( + "PARAMETER_ADDITIONAL_PROPERTY", _path(path, key), "additionalProperties" + ) + elif isinstance(additional, dict): + _validate(additional, item, _path(path, key)) + + +def validate_parameter_value(schema: CompiledParameterSchema, value: object, path: str = "") -> None: + """Validate the submitted JSON value without defaulting or coercion.""" + try: + copied = _json_copy(value, path) + # Validate UTF-8 serializability as well as the Python structure. + json.dumps(copied, ensure_ascii=False, allow_nan=False).encode("utf-8") + _validate(schema.schema, copied, path) + except (RecursionError, UnicodeError, TypeError, ValueError) as error: + if isinstance(error, ParameterValidationError): + raise + raise ParameterValidationError("PARAMETER_JSON_INVALID", path) from None diff --git a/test-e2e/changes/requirements/MODEL-PROFILE-PARAMETERS-20261013.yaml b/test-e2e/changes/requirements/MODEL-PROFILE-PARAMETERS-20261013.yaml new file mode 100644 index 0000000000..fddffaec1b --- /dev/null +++ b/test-e2e/changes/requirements/MODEL-PROFILE-PARAMETERS-20261013.yaml @@ -0,0 +1,28 @@ +schema_version: '1.0' +change: + change_id: MODEL-PROFILE-PARAMETERS-20261013 + change_type: requirement + requirement_id: MODEL-PROFILE-PARAMETERS-20261013 + title: Introduce finite profile parameter validation + description: First implement finite schema compilation and strict value validation; follow with explicit parameter permissions and application. + affected_features: + existing: [] + added: [SDK-MODEL-PROFILE-PARAMETERS] + modified: [] + retired: [] + affected_cases: + existing: [] + added: [] + modified: [] + retired: [] + traditional_ut: + decision: add + behaviors: + - Declared schema keywords, nested properties/items and bounds are checked before use; unknown keywords, invalid shapes and nonfinite values fail explicitly. + - Value validation preserves null, boolean and numeric distinctions and checks enum/const, ranges, lengths, required properties and additional fields without coercion. + - Compiled schemas are immutable snapshots and errors never echo values. + reason: Existing profile contracts validate source capabilities; they do not implement the finite configurable parameter schema. This increment is isolated domain validation, not a connected component. + test_selectors: + - test/sdk/core/model_service/test_parameter_schema.py + no_case_change_reason: Pure schema/value rules stay in UT. Component proof will be designed before connecting permissions and profile composition; existing formal Cases remain unchanged. + notes: This increment does not send API requests or alter saved model records. diff --git a/test-e2e/features/SDK-MODEL-PROFILE-PARAMETERS/cases.md b/test-e2e/features/SDK-MODEL-PROFILE-PARAMETERS/cases.md new file mode 100644 index 0000000000..f9c5984e54 --- /dev/null +++ b/test-e2e/features/SDK-MODEL-PROFILE-PARAMETERS/cases.md @@ -0,0 +1,5 @@ +# SDK-MODEL-PROFILE-PARAMETERS 关联用例 + +此页由结构化用例生成,不手工编辑。 + +当前测试基线未保留关联用例。 diff --git a/test-e2e/features/SDK-MODEL-PROFILE-PARAMETERS/feature.yaml b/test-e2e/features/SDK-MODEL-PROFILE-PARAMETERS/feature.yaml new file mode 100644 index 0000000000..9a1f42da04 --- /dev/null +++ b/test-e2e/features/SDK-MODEL-PROFILE-PARAMETERS/feature.yaml @@ -0,0 +1,21 @@ +schema_version: '1.0' +module: + id: SDK-MODEL-SERVICE + name: SDK model service +feature: + feature_id: SDK-MODEL-PROFILE-PARAMETERS + name: Model profile parameter rules and values + status: planned + description: Finite declarative parameter schemas, permissions and value application shared by outlet and conversation configuration. + business_rules: + - rule_id: SDK-MODEL-PROFILE-PARAMETERS-RULE-01 + description: Accept only the declared finite schema keywords and JSON values, with no remote references, scripts or coercion. + - rule_id: SDK-MODEL-PROFILE-PARAMETERS-RULE-02 + description: Missing, explicit null, false and zero remain distinct; defaults, fixed values, permissions and conditions are applied explicitly. + - rule_id: SDK-MODEL-PROFILE-PARAMETERS-RULE-03 + description: Configuration errors expose parameter paths and categories without echoing submitted or fixed values. + required_test_stages: [] + source: + requirement_ids: + - MODEL-PROFILE-PARAMETERS-20261013 + notes: Initial isolated schema validation belongs in traditional UT; composition and service contracts will receive component/API proof before wiring. No outlet, frontend or runtime acceptance claimed. diff --git a/test/sdk/core/model_service/test_parameter_schema.py b/test/sdk/core/model_service/test_parameter_schema.py new file mode 100644 index 0000000000..32d57a1b07 --- /dev/null +++ b/test/sdk/core/model_service/test_parameter_schema.py @@ -0,0 +1,165 @@ +"""Finite parameter schema compilation and value validation boundaries.""" + +import pytest +from nexent.core.model_service.profiles.parameter_schema import ( + ParameterValidationError, + compile_parameter_schema, + json_values_equal, + validate_parameter_value, +) + + +@pytest.mark.parametrize( + "schema,value", + [ + ({"type": "boolean"}, False), + ({"type": "integer", "minimum": 0, "maximum": 10}, 0), + ({"type": ["number", "null"]}, None), + ({"type": "number"}, 0.5), + ({"type": "string", "minLength": 0, "maxLength": 2}, ""), + ({"enum": [False, 0, None, "default"]}, False), + ({"enum": [False, 0, None]}, 0), + ({"const": None}, None), + ({"type": "array", "items": {"type": "integer"}}, [1, 2]), + ( + { + "type": "object", + "properties": {"enabled": {"type": "boolean"}}, + "required": ["enabled"], + "additionalProperties": False, + }, + {"enabled": False}, + ), + ( + {"type": "object", "additionalProperties": {"type": "number"}}, + {"custom": 0.1}, + ), + ], +) +def test_declared_schema_accepts_valid_boundary_values(schema, value): + validate_parameter_value(compile_parameter_schema(schema), value) + + +@pytest.mark.parametrize( + "schema,value,keyword,path", + [ + ({"type": "integer"}, True, "type", ""), + ({"type": "number"}, False, "type", ""), + ({"type": "integer"}, 1.0, "type", ""), + ({"type": "boolean"}, 0, "type", ""), + ({"type": "number", "minimum": 0}, -1, "minimum", ""), + ({"type": "number", "maximum": 1}, 2, "maximum", ""), + ({"type": "string", "minLength": 2}, "", "minLength", ""), + ({"type": "string", "maxLength": 1}, "ab", "maxLength", ""), + ({"enum": [0]}, False, "enum", ""), + ({"const": False}, 0, "const", ""), + ({"type": "array", "items": {"type": "boolean"}}, [False, 0], "type", "/1"), + ({"type": "object", "required": ["enabled"]}, {}, "required", "/enabled"), + ( + {"properties": {"a/b~c": {"type": "boolean"}}}, + {"a/b~c": 0}, + "type", + "/a~1b~0c", + ), + ( + {"additionalProperties": False}, + {"extra": "sensitive-marker"}, + "additionalProperties", + "/extra", + ), + ({"additionalProperties": {"type": "boolean"}}, {"extra": 0}, "type", "/extra"), + ], +) +def test_invalid_values_are_not_coerced_and_diagnostics_are_value_free( + schema, value, keyword, path +): + with pytest.raises(ParameterValidationError) as error: + validate_parameter_value(compile_parameter_schema(schema), value) + assert error.value.keyword == keyword + assert error.value.path == path + assert "sensitive-marker" not in str(error.value) + + +@pytest.mark.parametrize( + "schema", + [ + {"type": "unknown"}, + {"type": []}, + {"type": ["boolean", "boolean"]}, + {"type": [False]}, + {"enum": []}, + {"enum": [1, 1.0]}, + {"enum": "values"}, + {"minimum": False}, + {"minimum": 10, "maximum": 1}, + {"maximum": "high"}, + {"minLength": -1}, + {"maxLength": True}, + {"minLength": 5, "maxLength": 2}, + {"required": "field"}, + {"required": [False]}, + {"required": ["field", "field"]}, + {"properties": []}, + {"properties": {"field": True}}, + {"items": []}, + {"additionalProperties": None}, + ], +) +def test_invalid_schema_shapes_and_constraints_fail_before_use(schema): + with pytest.raises(ParameterValidationError, match="PARAMETER_SCHEMA_INVALID"): + compile_parameter_schema(schema) + + +@pytest.mark.parametrize( + "schema", + [ + {"$ref": "https://example.com/schema"}, + {"format": "email"}, + {"oneOf": []}, + {"properties": {"field": {"pattern": "script"}}}, + {"items": {"default": 0}}, + ], +) +def test_unregistered_keywords_and_remote_references_are_rejected(schema): + with pytest.raises(ParameterValidationError) as error: + compile_parameter_schema(schema) + assert error.value.category == "PARAMETER_SCHEMA_KEYWORD_UNSUPPORTED" + + +@pytest.mark.parametrize( + "value", [float("nan"), float("inf"), {1: "invalid-key"}, object(), "\ud800"] +) +def test_non_json_values_and_invalid_utf8_are_rejected(value): + with pytest.raises(ParameterValidationError, match="PARAMETER_JSON_INVALID"): + validate_parameter_value(compile_parameter_schema({}), value) + + +def test_cyclic_values_are_explicitly_rejected_without_recursion_failure(): + value = [] + value.append(value) + with pytest.raises(ParameterValidationError, match="PARAMETER_JSON_INVALID"): + validate_parameter_value(compile_parameter_schema({}), value) + + +def test_compiled_schema_is_an_independent_immutable_snapshot(): + source = { + "type": "object", + "properties": {"field": {"type": "boolean"}}, + "additionalProperties": False, + } + schema = compile_parameter_schema(source) + source["properties"]["field"]["type"] = "number" + copy = schema.schema + copy["properties"]["field"]["type"] = "number" + validate_parameter_value(schema, {"field": False}) + with pytest.raises(ParameterValidationError): + validate_parameter_value(schema, {"field": 0}) + + +def test_json_equality_preserves_boolean_numeric_and_structural_semantics(): + assert json_values_equal(1, 1.0) + assert not json_values_equal(False, 0) + assert not json_values_equal(True, 1) + assert json_values_equal({"x": [None, False, 1]}, {"x": (None, False, 1.0)}) + assert not json_values_equal({"x": False}, {"x": 0}) + assert not json_values_equal([1], [1, 2]) From 508eb2e53e16015a976ea61265d5d80510e4c1b4 Mon Sep 17 00:00:00 2001 From: Jason Wang Date: Tue, 13 Oct 2026 12:36:00 +0800 Subject: [PATCH 3/3] feat(model-service): enforce parameter permissions and dependencies --- .../model_service/profiles/parameter_rules.py | 189 ++++++++++++++ .../MODEL-PROFILE-PARAMETERS-20261013.yaml | 7 +- .../model_service/test_parameter_rules.py | 239 ++++++++++++++++++ 3 files changed, 433 insertions(+), 2 deletions(-) create mode 100644 sdk/nexent/core/model_service/profiles/parameter_rules.py create mode 100644 test/sdk/core/model_service/test_parameter_rules.py diff --git a/sdk/nexent/core/model_service/profiles/parameter_rules.py b/sdk/nexent/core/model_service/profiles/parameter_rules.py new file mode 100644 index 0000000000..6468d6444f --- /dev/null +++ b/sdk/nexent/core/model_service/profiles/parameter_rules.py @@ -0,0 +1,189 @@ +"""Explicit parameter policy shared by outlet and conversation configuration.""" + +import json +from dataclasses import dataclass, field +from typing import Literal, Mapping + +from pydantic import Field, JsonValue, ValidationError + +from nexent.core.model_service.profiles.contracts import NonBlankString, ProfileContract +from nexent.core.model_service.profiles.parameter_schema import ( + CompiledParameterSchema, + ParameterValidationError, + compile_parameter_schema, + json_values_equal, + validate_parameter_value, +) + + +class ParameterRule(ProfileContract): + """Declaration only; field presence distinguishes missing from explicit null.""" + + key: NonBlankString + value_schema: dict = Field(alias="schema") + visible: bool + editable: bool + required: bool + allowed_scopes: list[Literal["outlet", "conversation"]] + ui_group: NonBlankString + owner: Literal["model", "provider", "combination"] + source: dict[str, JsonValue] + default_value: JsonValue = Field(default=None, repr=False) + fixed_value: JsonValue = Field(default=None, repr=False) + condition: dict | None = None + api_binding: dict[str, JsonValue] | None = None + + +@dataclass(frozen=True) +class CompiledParameterRules: + """Validated immutable declarations, schemas and dependency ordering.""" + + _declarations: bytes = field(repr=False) + _schemas: tuple[tuple[str, CompiledParameterSchema], ...] = field(repr=False) + _order: tuple[str, ...] + + @property + def declarations(self) -> list[dict]: + return json.loads(self._declarations) + + +def _pointer(key: str) -> str: + return "/" + key.replace("~", "~0").replace("/", "~1") + + +def _check_condition(condition: object, keys: set[str], path: str) -> set[str]: + if not isinstance(condition, dict): + raise ParameterValidationError("PARAMETER_CONDITION_INVALID", path) + for operator in ("all", "any"): + if operator in condition: + children = condition[operator] + if set(condition) != {operator} or not isinstance(children, list) or not children: + raise ParameterValidationError("PARAMETER_CONDITION_INVALID", path) + dependencies = set() + for index, child in enumerate(children): + dependencies.update(_check_condition(child, keys, f"{path}/{operator}/{index}")) + return dependencies + if ( + set(condition) != {"key", "op", "value"} + or not isinstance(condition.get("key"), str) + or condition["key"] not in keys + ): + raise ParameterValidationError("PARAMETER_CONDITION_INVALID", path) + operator, value = condition["op"], condition["value"] + if operator not in ("eq", "in", "exists"): + raise ParameterValidationError("PARAMETER_CONDITION_INVALID", path) + if operator == "exists" and type(value) is not bool: + raise ParameterValidationError("PARAMETER_CONDITION_INVALID", path) + if operator == "in" and (not isinstance(value, list) or not value): + raise ParameterValidationError("PARAMETER_CONDITION_INVALID", path) + validate_parameter_value(compile_parameter_schema({}), value, path) + return {condition["key"]} + + +def _condition_matches(condition: dict | None, values: dict) -> bool: + if condition is None: + return True + if "all" in condition: + return all(_condition_matches(child, values) for child in condition["all"]) + if "any" in condition: + return any(_condition_matches(child, values) for child in condition["any"]) + key, operator, expected = condition["key"], condition["op"], condition["value"] + if operator == "exists": + return (key in values) is expected + if key not in values: + return False + if operator == "eq": + return json_values_equal(values[key], expected) + return any(json_values_equal(values[key], option) for option in expected) + + +def compile_parameter_rules(declarations: list[dict]) -> CompiledParameterRules: + """Reject ambiguous rules before they can accept user configuration.""" + validate_parameter_value(compile_parameter_schema({"type": "array", "items": {"type": "object"}}), declarations) + encoded = json.dumps(declarations, ensure_ascii=False, allow_nan=False, sort_keys=True).encode("utf-8") + # Work from a detached JSON snapshot, never retain caller-owned containers. + copied = json.loads(encoded) + rules, schemas = {}, {} + for declaration in copied: + try: + rule = ParameterRule.model_validate(declaration) + except ValidationError: + raise ParameterValidationError("PARAMETER_RULE_INVALID") from None + if rule.key in rules or len(set(rule.allowed_scopes)) != len(rule.allowed_scopes): + raise ParameterValidationError("PARAMETER_RULE_INVALID", _pointer(rule.key)) + schema = compile_parameter_schema(rule.value_schema) + for name in ("default_value", "fixed_value"): + if name in rule.model_fields_set: + validate_parameter_value(schema, getattr(rule, name), _pointer(rule.key)) + if {"default_value", "fixed_value"} <= rule.model_fields_set and not json_values_equal( + rule.default_value, rule.fixed_value + ): + raise ParameterValidationError("PARAMETER_RULE_INVALID", _pointer(rule.key)) + rules[rule.key], schemas[rule.key] = rule, schema + dependencies = { + key: _check_condition(rule.condition, set(rules), _pointer(key) + "/condition") + if rule.condition is not None + else set() + for key, rule in rules.items() + } + order = [] + while dependencies: + ready = sorted(key for key, parents in dependencies.items() if not parents) + if not ready: + raise ParameterValidationError("PARAMETER_CONDITION_CYCLE", _pointer(min(dependencies))) + order.extend(ready) + for key in ready: + del dependencies[key] + for parents in dependencies.values(): + parents.difference_update(ready) + return CompiledParameterRules(encoded, tuple(schemas.items()), tuple(order)) + + +def apply_parameter_values( + compiled: CompiledParameterRules, + submitted: Mapping[str, object], + *, + scope: Literal["outlet", "conversation"], + saved_values: Mapping[str, object], +) -> dict: + """Build a detached effective view; never mutate saved values or declarations.""" + if scope not in ("outlet", "conversation"): + raise ParameterValidationError("PARAMETER_SCOPE_INVALID") + object_schema = compile_parameter_schema({"type": "object"}) + validate_parameter_value(object_schema, submitted) + validate_parameter_value(object_schema, saved_values) + submitted = json.loads(json.dumps(dict(submitted))) + saved_values = json.loads(json.dumps(dict(saved_values))) + rules = {rule["key"]: rule for rule in compiled.declarations} + schemas = dict(compiled._schemas) + for key in submitted.keys() | saved_values.keys(): + if key not in rules: + raise ParameterValidationError("PARAMETER_UNKNOWN", _pointer(key)) + values = {} + for key, rule in rules.items(): + if "fixed_value" in rule: + values[key] = rule["fixed_value"] + if key in saved_values and not json_values_equal(saved_values[key], values[key]): + raise ParameterValidationError("FIXED_PARAMETER", _pointer(key)) + elif key in saved_values: + values[key] = saved_values[key] + elif "default_value" in rule: + values[key] = rule["default_value"] + if key in submitted: + if "fixed_value" in rule: + if not json_values_equal(submitted[key], values[key]): + raise ParameterValidationError("FIXED_PARAMETER", _pointer(key)) + elif not rule["editable"] or scope not in rule["allowed_scopes"]: + raise ParameterValidationError("PARAMETER_NOT_EDITABLE", _pointer(key)) + values[key] = submitted[key] + if key in values: + validate_parameter_value(schemas[key], values[key], _pointer(key)) + for key in compiled._order: + rule = rules[key] + if not _condition_matches(rule.get("condition"), values): + if key in submitted: + raise ParameterValidationError("PARAMETER_DEPENDENCY", _pointer(key)) + values.pop(key, None) + elif rule["required"] and key not in values: + raise ParameterValidationError("PARAMETER_VALUE_REQUIRED", _pointer(key)) + return values diff --git a/test-e2e/changes/requirements/MODEL-PROFILE-PARAMETERS-20261013.yaml b/test-e2e/changes/requirements/MODEL-PROFILE-PARAMETERS-20261013.yaml index fddffaec1b..b1e7608666 100644 --- a/test-e2e/changes/requirements/MODEL-PROFILE-PARAMETERS-20261013.yaml +++ b/test-e2e/changes/requirements/MODEL-PROFILE-PARAMETERS-20261013.yaml @@ -4,7 +4,7 @@ change: change_type: requirement requirement_id: MODEL-PROFILE-PARAMETERS-20261013 title: Introduce finite profile parameter validation - description: First implement finite schema compilation and strict value validation; follow with explicit parameter permissions and application. + description: Implement finite schema compilation, strict value validation and explicit parameter permissions/defaults/fixed values/conditions as an isolated domain policy. affected_features: existing: [] added: [SDK-MODEL-PROFILE-PARAMETERS] @@ -21,8 +21,11 @@ change: - Declared schema keywords, nested properties/items and bounds are checked before use; unknown keywords, invalid shapes and nonfinite values fail explicitly. - Value validation preserves null, boolean and numeric distinctions and checks enum/const, ranges, lengths, required properties and additional fields without coercion. - Compiled schemas are immutable snapshots and errors never echo values. + - Parameter permissions, scopes, defaults, fixed values and required flags are enforced; explicit inactive parameters fail and dependency cycles are rejected. + - Conditional effective views never mutate saved values; declaration order does not alter dependency handling. reason: Existing profile contracts validate source capabilities; they do not implement the finite configurable parameter schema. This increment is isolated domain validation, not a connected component. test_selectors: - test/sdk/core/model_service/test_parameter_schema.py - no_case_change_reason: Pure schema/value rules stay in UT. Component proof will be designed before connecting permissions and profile composition; existing formal Cases remain unchanged. + - test/sdk/core/model_service/test_parameter_rules.py + no_case_change_reason: Pure schema/value rules stay in UT. These pure schema/policy rules stay isolated; component proof will be designed before connecting TOML supplements and profile composition; existing formal Cases remain unchanged. notes: This increment does not send API requests or alter saved model records. diff --git a/test/sdk/core/model_service/test_parameter_rules.py b/test/sdk/core/model_service/test_parameter_rules.py new file mode 100644 index 0000000000..181a38d708 --- /dev/null +++ b/test/sdk/core/model_service/test_parameter_rules.py @@ -0,0 +1,239 @@ +"""Parameter policy preserves values and enforces explicit permissions.""" + +from copy import deepcopy + +import pytest +from nexent.core.model_service.profiles.parameter_rules import ( + apply_parameter_values, + compile_parameter_rules, +) +from nexent.core.model_service.profiles.parameter_schema import ParameterValidationError + + +def rule(key="temperature", **overrides): + result = { + "key": key, + "schema": {}, + "visible": True, + "editable": True, + "required": False, + "allowed_scopes": ["outlet", "conversation"], + "ui_group": "common", + "owner": "provider", + "source": {"rule_id": "test-v1"}, + } + result.update(overrides) + return result + + +def apply(rules, submitted=None, saved=None, scope="outlet"): + return apply_parameter_values( + compile_parameter_rules(rules), + submitted or {}, + saved_values=saved or {}, + scope=scope, + ) + + +@pytest.mark.parametrize("value", [None, False, 0, "", [], {}]) +def test_explicit_values_and_defaults_preserve_presence(value): + assert apply([rule(default_value="default")], {"temperature": value}) == { + "temperature": value + } + assert apply([rule(default_value=value)]) == {"temperature": value} + assert apply([rule()], saved={"temperature": value}) == {"temperature": value} + assert apply([rule()]) == {} + + +@pytest.mark.parametrize("value", [None, False, 0, "", {"private": "fixed-secret"}]) +def test_fixed_value_is_written_and_equal_submission_is_allowed(value): + fixed = rule(fixed_value=value, visible=False, editable=False, allowed_scopes=[]) + assert apply([fixed]) == {"temperature": value} + assert apply([fixed], {"temperature": value}, scope="conversation") == { + "temperature": value + } + with pytest.raises(ParameterValidationError) as captured: + apply([fixed], {"temperature": "different-secret"}) + assert captured.value.category == "FIXED_PARAMETER" + assert "secret" not in str(captured.value) + + +def test_fixed_saved_conflict_is_not_silently_overwritten(): + with pytest.raises(ParameterValidationError, match="FIXED_PARAMETER"): + apply([rule(fixed_value=False)], saved={"temperature": 0}) + + +@pytest.mark.parametrize( + "overrides", + [ + {"editable": False}, + {"visible": False, "editable": False}, + {"allowed_scopes": ["outlet"]}, + ], +) +def test_conversation_override_requires_explicit_edit_permission(overrides): + declaration = rule(default_value=0, **overrides) + with pytest.raises(ParameterValidationError, match="PARAMETER_NOT_EDITABLE"): + apply([declaration], {"temperature": 1}, scope="conversation") + assert apply([declaration], scope="conversation") == {"temperature": 0} + + +def test_visibility_and_required_flags_are_independent(): + assert apply([rule(visible=False)], {"temperature": 1}) == {"temperature": 1} + with pytest.raises(ParameterValidationError, match="PARAMETER_VALUE_REQUIRED"): + apply([rule(required=True)]) + + +@pytest.mark.parametrize( + "condition,enabled,expected", + [ + ({"key": "enabled", "op": "eq", "value": True}, True, True), + ({"key": "enabled", "op": "eq", "value": 1}, True, False), + ({"key": "enabled", "op": "in", "value": [False, None]}, False, True), + ({"key": "enabled", "op": "exists", "value": True}, None, True), + ({"all": [{"key": "enabled", "op": "eq", "value": True}]}, False, False), + ( + { + "any": [ + {"key": "enabled", "op": "eq", "value": True}, + {"key": "enabled", "op": "eq", "value": False}, + ] + }, + False, + True, + ), + ], +) +def test_conditions_filter_derived_values_and_reject_explicit_inactive_values( + condition, enabled, expected +): + declarations = [ + rule("enabled"), + rule("budget", condition=condition, default_value=0, required=True), + ] + result = apply(declarations, {"enabled": enabled}) + assert ("budget" in result) is expected + if not expected: + with pytest.raises(ParameterValidationError, match="PARAMETER_DEPENDENCY"): + apply(declarations, {"enabled": enabled, "budget": 0}) + + +def test_missing_is_distinct_from_null_for_conditions(): + declarations = [ + rule("enabled"), + rule( + "budget", + condition={"key": "enabled", "op": "eq", "value": None}, + default_value=0, + ), + ] + assert apply(declarations) == {} + assert apply(declarations, {"enabled": None}) == {"enabled": None, "budget": 0} + + +def test_dependency_order_does_not_depend_on_declaration_order(): + declarations = [ + rule( + "last", + default_value=2, + condition={"key": "middle", "op": "exists", "value": True}, + ), + rule( + "middle", + default_value=1, + condition={"key": "enabled", "op": "eq", "value": True}, + ), + rule("enabled", default_value=False), + ] + assert apply(declarations) == {"enabled": False} + assert apply(declarations, {"enabled": True}) == { + "enabled": True, + "middle": 1, + "last": 2, + } + + +@pytest.mark.parametrize( + "condition", + [ + {}, + {"all": []}, + {"any": []}, + {"all": [{}], "any": [{}]}, + {"key": "unknown", "op": "eq", "value": 1}, + {"key": "temperature", "op": "eval", "value": "secret"}, + {"key": "temperature", "op": "exists", "value": 1}, + {"key": "temperature", "op": "in", "value": []}, + {"key": [], "op": "eq", "value": 1}, + ], +) +def test_invalid_conditions_fail_before_use(condition): + with pytest.raises(ParameterValidationError, match="PARAMETER_CONDITION_INVALID"): + compile_parameter_rules([rule(condition=condition)]) + + +def test_cycles_and_duplicate_keys_are_rejected(): + with pytest.raises(ParameterValidationError, match="PARAMETER_CONDITION_CYCLE"): + compile_parameter_rules( + [rule(condition={"key": "temperature", "op": "exists", "value": True})] + ) + with pytest.raises(ParameterValidationError, match="PARAMETER_RULE_INVALID"): + compile_parameter_rules([rule(), rule()]) + + +def test_nested_condition_must_be_an_object(): + with pytest.raises(ParameterValidationError, match="PARAMETER_CONDITION_INVALID"): + compile_parameter_rules([rule(condition={"all": [True]})]) + + +def test_empty_rule_set_is_valid_and_still_rejects_unknown_parameters(): + assert apply([]) == {} + with pytest.raises(ParameterValidationError, match="PARAMETER_UNKNOWN"): + apply([], {"temperature": 1}) + + +@pytest.mark.parametrize( + "overrides", + [ + {"allowed_scopes": ["outlet", "outlet"]}, + {"editable": "true"}, + {"unknown": 1}, + {"default_value": True, "schema": {"type": "number"}}, + {"fixed_value": None, "schema": {"type": "integer"}}, + {"default_value": 0, "fixed_value": False}, + ], +) +def test_invalid_declarations_do_not_compile(overrides): + with pytest.raises(ParameterValidationError): + compile_parameter_rules([rule(**overrides)]) + + +def test_snapshots_and_inputs_are_independent(): + declarations = [rule(default_value={"nested": [1]})] + original = deepcopy(declarations) + compiled = compile_parameter_rules(declarations) + declarations[0]["default_value"]["nested"].append(2) + view = compiled.declarations + view[0]["default_value"]["nested"].append(3) + saved = {"temperature": {"nested": [4]}} + result = apply_parameter_values(compiled, {}, saved_values=saved, scope="outlet") + result["temperature"]["nested"].append(5) + assert saved == {"temperature": {"nested": [4]}} + assert compiled.declarations == original + + +@pytest.mark.parametrize( + "submitted,saved", [({"unknown": 1}, {}), ({}, {"unknown": 1})] +) +def test_unknown_values_fail_explicitly(submitted, saved): + with pytest.raises(ParameterValidationError, match="PARAMETER_UNKNOWN"): + apply([rule()], submitted, saved) + + +def test_values_are_checked_including_saved_values_and_null(): + declarations = [rule(schema={"type": "number", "minimum": 0})] + for submitted, saved in [({"temperature": None}, {}), ({}, {"temperature": -1})]: + with pytest.raises(ParameterValidationError, match="PARAMETER_VALUE_INVALID"): + apply(declarations, submitted, saved) + with pytest.raises(ParameterValidationError, match="PARAMETER_SCOPE_INVALID"): + apply(declarations, scope="unknown")