rebase
This commit is contained in:
307
app/core/state_validator.py
Normal file
307
app/core/state_validator.py
Normal file
@@ -0,0 +1,307 @@
|
||||
"""State validator for environment / entity.data / world schema.
|
||||
|
||||
All mutations of `world.environment` and `entity.data` go through this module.
|
||||
The orchestrator's `env_update` and `entity_update` tools use `apply_patch`.
|
||||
|
||||
Validation rules:
|
||||
- Required fields must be present (per `environment_schema` and entity `schemas`).
|
||||
- Field types must match the declared type.
|
||||
- Numeric ranges enforced when `max`/`min` provided.
|
||||
- Nested `object` / `array` schemas are validated recursively.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
# Supported primitive JSON-schema type names
|
||||
_PRIMITIVES = {"string", "integer", "number", "boolean"}
|
||||
_PATCH_OPS = {"set", "inc", "dec", "append", "remove"}
|
||||
|
||||
|
||||
def validate_state(state: dict[str, Any], schema_fields: list[dict]) -> tuple[bool, list[str]]:
|
||||
"""Validate `state` against a list of field definitions.
|
||||
|
||||
Each field definition has the shape:
|
||||
{
|
||||
"name": "player",
|
||||
"type": "object" | "array" | "string" | ...,
|
||||
"required": bool,
|
||||
"properties": [ ... ], # for type=object
|
||||
"items": { ... }, # for type=array
|
||||
"min": int, "max": int, # for numeric types
|
||||
"default": <any>
|
||||
}
|
||||
"""
|
||||
errors: list[str] = []
|
||||
for field in schema_fields:
|
||||
name = field.get("name")
|
||||
if not name:
|
||||
errors.append("Schema field missing 'name'")
|
||||
continue
|
||||
if name not in state:
|
||||
if field.get("required"):
|
||||
errors.append(f"Missing required field: {name}")
|
||||
continue
|
||||
_validate_value(state[name], field, path=name, errors=errors)
|
||||
return (len(errors) == 0, errors)
|
||||
|
||||
|
||||
def _validate_value(
|
||||
value: Any, field_schema: dict, path: str, errors: list[str]
|
||||
) -> None:
|
||||
ftype = field_schema.get("type", "string")
|
||||
if ftype in _PRIMITIVES:
|
||||
_validate_primitive(value, ftype, field_schema, path, errors)
|
||||
elif ftype == "object":
|
||||
if not isinstance(value, dict):
|
||||
errors.append(f"{path} must be object")
|
||||
return
|
||||
props = field_schema.get("properties", [])
|
||||
# validate child fields
|
||||
ok, child_errors = validate_state(value, props)
|
||||
if not ok:
|
||||
errors.extend(child_errors)
|
||||
elif ftype == "array":
|
||||
if not isinstance(value, list):
|
||||
errors.append(f"{path} must be array")
|
||||
return
|
||||
items_schema = field_schema.get("items")
|
||||
if items_schema:
|
||||
for i, item in enumerate(value):
|
||||
_validate_value(item, items_schema, f"{path}[{i}]", errors)
|
||||
else:
|
||||
errors.append(f"{path}: unknown type {ftype!r}")
|
||||
|
||||
|
||||
def _validate_primitive(
|
||||
value: Any, ftype: str, field_schema: dict, path: str, errors: list[str]
|
||||
) -> None:
|
||||
if ftype == "string":
|
||||
if not isinstance(value, str):
|
||||
errors.append(f"{path} must be string")
|
||||
return
|
||||
elif ftype == "integer":
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
errors.append(f"{path} must be integer")
|
||||
return
|
||||
elif ftype == "number":
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
errors.append(f"{path} must be number")
|
||||
return
|
||||
elif ftype == "boolean":
|
||||
if not isinstance(value, bool):
|
||||
errors.append(f"{path} must be boolean")
|
||||
return
|
||||
# Range checks
|
||||
if ftype in ("integer", "number"):
|
||||
mn = field_schema.get("min")
|
||||
mx = field_schema.get("max")
|
||||
if mn is not None and value < mn:
|
||||
errors.append(f"{path} must be >= {mn}, got {value}")
|
||||
if mx is not None and value > mx:
|
||||
errors.append(f"{path} must be <= {mx}, got {value}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Patch application
|
||||
# ---------------------------------------------------------------------------
|
||||
_PATH_TOKEN_RE = re.compile(r"\.?([^\.\[\]]+)|\[(\d+)\]")
|
||||
|
||||
|
||||
def _split_path(path: str) -> list[tuple[str, int | None]]:
|
||||
"""Split a dotted path into tokens. Supports `arr[0].field` syntax."""
|
||||
tokens: list[tuple[str, int | None]] = []
|
||||
for m in _PATH_TOKEN_RE.finditer(path):
|
||||
if m.group(1) is not None and m.group(1) != "":
|
||||
tokens.append((m.group(1), None))
|
||||
elif m.group(2) is not None:
|
||||
tokens.append(("", int(m.group(2))))
|
||||
return tokens
|
||||
|
||||
|
||||
def _navigate(state: Any, tokens: list[tuple[str, int | None]]) -> tuple[bool, Any, str]:
|
||||
"""Walk into state along tokens. Returns (ok, value, error)."""
|
||||
cur = state
|
||||
for i, (key, idx) in enumerate(tokens):
|
||||
if idx is not None:
|
||||
if not isinstance(cur, list):
|
||||
return False, None, f"cannot index into non-list at {'.'.join(t[0] for t in tokens[:i])}"
|
||||
if idx >= len(cur):
|
||||
return False, None, f"index {idx} out of range"
|
||||
cur = cur[idx]
|
||||
else:
|
||||
if not isinstance(cur, dict):
|
||||
return False, None, f"cannot key into non-object at {'.'.join(t[0] for t in tokens[:i])}"
|
||||
if key not in cur:
|
||||
return False, None, f"key {key!r} not found"
|
||||
cur = cur[key]
|
||||
return True, cur, ""
|
||||
|
||||
|
||||
def _set_path(state: Any, tokens: list[tuple[str, int | None]], value: Any) -> tuple[bool, str]:
|
||||
"""Set value at path, creating intermediate dicts as needed."""
|
||||
if not tokens:
|
||||
return False, "empty path"
|
||||
cur = state
|
||||
for i, (key, idx) in enumerate(tokens[:-1]):
|
||||
nxt_key, nxt_idx = tokens[i + 1]
|
||||
if idx is not None:
|
||||
# current is list — descend by index
|
||||
if not isinstance(cur, list):
|
||||
return False, "cannot index non-list"
|
||||
while len(cur) <= idx:
|
||||
cur.append({})
|
||||
cur = cur[idx]
|
||||
else:
|
||||
if not isinstance(cur, dict):
|
||||
return False, "cannot key non-object"
|
||||
if key not in cur:
|
||||
cur[key] = [] if nxt_idx is not None else {}
|
||||
cur = cur[key]
|
||||
# last token
|
||||
last_key, last_idx = tokens[-1]
|
||||
if last_idx is not None:
|
||||
if not isinstance(cur, list):
|
||||
return False, "cannot index non-list"
|
||||
while len(cur) <= last_idx:
|
||||
cur.append(None)
|
||||
cur[last_idx] = value
|
||||
else:
|
||||
if not isinstance(cur, dict):
|
||||
return False, "cannot key non-object"
|
||||
cur[last_key] = value
|
||||
return True, ""
|
||||
|
||||
|
||||
def _remove_path(state: Any, tokens: list[tuple[str, int | None]]) -> tuple[bool, str]:
|
||||
"""Remove the value at path."""
|
||||
if not tokens:
|
||||
return False, "empty path"
|
||||
parent_tokens = tokens[:-1]
|
||||
ok, parent, err = _navigate(state, parent_tokens)
|
||||
if not ok:
|
||||
return False, err
|
||||
last_key, last_idx = tokens[-1]
|
||||
if last_idx is not None:
|
||||
if not isinstance(parent, list):
|
||||
return False, "cannot index non-list"
|
||||
if last_idx >= len(parent):
|
||||
return False, "index out of range"
|
||||
parent.pop(last_idx)
|
||||
else:
|
||||
if not isinstance(parent, dict):
|
||||
return False, "cannot key non-object"
|
||||
if last_key not in parent:
|
||||
return False, f"key {last_key!r} not found"
|
||||
del parent[last_key]
|
||||
return True, ""
|
||||
|
||||
|
||||
def apply_patch(state: dict[str, Any], patch: dict[str, Any]) -> tuple[dict[str, Any], list[str]]:
|
||||
"""Apply a patch to `state`. Returns (new_state, errors).
|
||||
|
||||
Patch format: `{field_path: new_value | {op: ..., by: N | value: V}}`.
|
||||
Supported ops: `set` (default), `inc`, `dec`, `append`, `remove`.
|
||||
|
||||
The state is mutated in place — pass a deepcopy if you need to preserve the original.
|
||||
"""
|
||||
import copy
|
||||
|
||||
state = copy.deepcopy(state)
|
||||
errors: list[str] = []
|
||||
for path, op_spec in patch.items():
|
||||
tokens = _split_path(path)
|
||||
if not tokens:
|
||||
errors.append(f"invalid path: {path!r}")
|
||||
continue
|
||||
|
||||
# Determine if this is an op-dict or a direct value
|
||||
if isinstance(op_spec, dict) and "op" in op_spec and op_spec["op"] in _PATCH_OPS:
|
||||
op = op_spec["op"]
|
||||
if op == "set":
|
||||
ok, err = _set_path(state, tokens, op_spec.get("value"))
|
||||
if not ok:
|
||||
errors.append(f"{path}: {err}")
|
||||
elif op in ("inc", "dec"):
|
||||
by = op_spec.get("by", 1)
|
||||
if op == "dec":
|
||||
by = -by
|
||||
ok, cur, err = _navigate(state, tokens)
|
||||
if not ok:
|
||||
# create with the delta value
|
||||
ok2, err2 = _set_path(state, tokens, by)
|
||||
if not ok2:
|
||||
errors.append(f"{path}: {err2}")
|
||||
else:
|
||||
if isinstance(cur, bool) or not isinstance(cur, (int, float)):
|
||||
errors.append(f"{path}: cannot {op} non-number")
|
||||
else:
|
||||
ok2, err2 = _set_path(state, tokens, cur + by)
|
||||
if not ok2:
|
||||
errors.append(f"{path}: {err2}")
|
||||
elif op == "append":
|
||||
value = op_spec.get("value")
|
||||
ok, cur, err = _navigate(state, tokens)
|
||||
if not ok:
|
||||
# create empty list, then append
|
||||
ok2, err2 = _set_path(state, tokens, [value])
|
||||
if not ok2:
|
||||
errors.append(f"{path}: {err2}")
|
||||
else:
|
||||
if not isinstance(cur, list):
|
||||
errors.append(f"{path}: cannot append to non-list")
|
||||
else:
|
||||
cur.append(value)
|
||||
elif op == "remove":
|
||||
ok, err = _remove_path(state, tokens)
|
||||
if not ok:
|
||||
errors.append(f"{path}: {err}")
|
||||
else:
|
||||
# Direct value assignment
|
||||
ok, err = _set_path(state, tokens, op_spec)
|
||||
if not ok:
|
||||
errors.append(f"{path}: {err}")
|
||||
return state, errors
|
||||
|
||||
|
||||
def validate_world(world_dict: dict[str, Any]) -> tuple[bool, list[str]]:
|
||||
"""Top-level validation of a World dict.
|
||||
|
||||
Checks: presence of required keys, types of basic fields, and that
|
||||
`environment` validates against `environment_schema`.
|
||||
"""
|
||||
errors: list[str] = []
|
||||
required_top = ["name", "language", "schemas", "environment_schema", "environment"]
|
||||
for k in required_top:
|
||||
if k not in world_dict:
|
||||
errors.append(f"Missing required world field: {k}")
|
||||
|
||||
# environment must validate against environment_schema
|
||||
env_schema = world_dict.get("environment_schema", [])
|
||||
env = world_dict.get("environment", {})
|
||||
if env_schema and env:
|
||||
ok, env_errors = validate_state(env, env_schema)
|
||||
if not ok:
|
||||
errors.extend(env_errors)
|
||||
|
||||
# plot_rails structure
|
||||
pr = world_dict.get("plot_rails") or {}
|
||||
for k in ("hooks", "current_goals", "completed_goals"):
|
||||
if k not in pr:
|
||||
errors.append(f"plot_rails missing key: {k}")
|
||||
elif not isinstance(pr[k], list):
|
||||
errors.append(f"plot_rails.{k} must be list")
|
||||
|
||||
# current_time format
|
||||
ct = world_dict.get("current_time")
|
||||
if ct:
|
||||
from app.core.time_utils import GameTime
|
||||
|
||||
try:
|
||||
GameTime.parse(ct)
|
||||
except ValueError as e:
|
||||
errors.append(str(e))
|
||||
|
||||
return (len(errors) == 0, errors)
|
||||
Reference in New Issue
Block a user