"""Strict model-facing Authoring CDSL contract. This is intentionally separate from the runtime CDSL: model output contains only document-local names and declarative references. Runtime identities are allocated by :mod:`authoring_compiler`. """ from __future__ import annotations import math import re from typing import Any, Literal from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator _NAME = r"^[a-z][a-z0-9_]{0,63}$" _FORBIDDEN = { "id", "task_id", "revision_id", "candidate_id", "action_id", "requirement_id", "claim_id", "evidence_id", "feature_id", "sketch_id", "body_id", "stable_id", "snapshot_id", "owner_feature_id", "working_head", "selector_token", "selector_tokens", "selector token", "selector-token", "selectorToken", "host_face", "mirror_plane", } def validation_error_code(error: Exception) -> str: """Map strict model validation failures to a stable public diagnostic.""" return "AUTHOR_FORBIDDEN_FIELD" if "AUTHOR_FORBIDDEN_FIELD:" in str(error) else "AUTHOR_SCHEMA_INVALID" class AuthorModel(BaseModel): model_config = ConfigDict(extra="forbid", strict=True) @model_validator(mode="before") @classmethod def reject_internal_fields(cls, value: Any) -> Any: if isinstance(value, dict): found = sorted( key for key in value if isinstance(key, str) and (key in _FORBIDDEN or key.lower().replace("-", "_").replace(" ", "_") in _FORBIDDEN) ) if found: raise ValueError(f"AUTHOR_FORBIDDEN_FIELD: {found[0]}") for nested in value.values(): cls.reject_internal_fields(nested) return value if isinstance(value, list): for item in value: cls.reject_internal_fields(item) return value class AuthorWorkplane(AuthorModel): """A fully explicit local sketch frame in world millimetres.""" origin_mm: list[float] = Field(min_length=3, max_length=3) x_dir: list[float] = Field(min_length=3, max_length=3) normal: list[float] = Field(min_length=3, max_length=3) class AuthorCircleProfile(AuthorModel): """A declarative circle expressed with the user-facing diameter.""" type: Literal["circle"] diameter_mm: float = Field(gt=0) center_mm: list[float] = Field(default_factory=lambda: [0.0, 0.0], min_length=2, max_length=2) class AuthorPolygonProfile(AuthorModel): """A closed polygon in the local sketch workplane.""" type: Literal["polygon"] vertices: list[list[float]] = Field(min_length=3) @field_validator("vertices") @classmethod def require_planar_points(cls, value: list[list[float]]) -> list[list[float]]: if any(len(point) != 2 for point in value): raise ValueError("polygon vertices must have exactly two coordinates") return value class AuthorSketch(AuthorModel): """The only authoring sketch form currently accepted by the compiler.""" workplane: AuthorWorkplane profile: AuthorCircleProfile | AuthorPolygonProfile class SelectorIntent(AuthorModel): """A local feature-output reference, never a Runtime selector token.""" kind: Literal["face", "edge", "axis", "plane", "vertex", "body"] source: str = Field( min_length=3, max_length=160, description="A local feature output in the form ..", ) match: Literal["unique", "all"] = "unique" @field_validator("source") @classmethod def require_feature_output_reference(cls, value: str) -> str: feature, separator, role = value.partition(".") if not separator or not re.fullmatch(_NAME, feature) or not re.fullmatch(r"[a-z][a-z0-9_.-]{0,80}", role): raise ValueError("selector source must be .") return value class AuthorFeature(AuthorModel): name: str = Field(pattern=_NAME) operation: str = Field(pattern=r"^[a-z][a-z0-9_]{0,80}$") params: dict[str, Any] = Field( default_factory=dict, description="Only parameters from this feature operation's supplied params_schema.", ) depends_on: list[str] = Field(default_factory=list, max_length=32) selectors: list[SelectorIntent] = Field(default_factory=list, max_length=32) sketch: AuthorSketch | None = Field( default=None, description="For sketch operations: exactly {workplane, profile}. Circle profiles use diameter_mm and center_mm.", ) class AuthorBody(AuthorModel): name: str = Field(pattern=_NAME) features: list[AuthorFeature] = Field(min_length=1, max_length=256) class AuthoringDocument(AuthorModel): schema_version: str = Field(default="cad.author.v1", pattern=r"^cad\.author\.v1$") units: str = Field(default="mm", pattern=r"^mm$") coordinate_system: str = Field(default="right_handed", pattern=r"^[a-z][a-z0-9_-]{0,40}$") assumptions: list[str] = Field(default_factory=list, max_length=64) bodies: list[AuthorBody] = Field(min_length=1, max_length=32) acceptance_targets: list[dict[str, Any]] = Field(default_factory=list, max_length=128) @model_validator(mode="after") def validate_symbols(self) -> "AuthoringDocument": validate_finite(self.model_dump(mode="python")) bodies = [b.name for b in self.bodies] if len(bodies) != len(set(bodies)): raise ValueError("duplicate body name") names: set[str] = set() for body in self.bodies: for feature in body.features: if feature.name in names: raise ValueError(f"duplicate feature name: {feature.name}") names.add(feature.name) for body in self.bodies: for feature in body.features: if len(feature.depends_on) != len(set(feature.depends_on)): raise ValueError(f"duplicate dependency: {feature.name}") if any(dep not in names for dep in feature.depends_on): missing = next(dep for dep in feature.depends_on if dep not in names) raise ValueError(f"unknown feature reference: {missing}") return self def validate_finite(value: Any, path: str = "$") -> None: if isinstance(value, float) and not math.isfinite(value): raise ValueError(f"non-finite number at {path}") if isinstance(value, dict): for key, item in value.items(): validate_finite(item, f"{path}.{key}") elif isinstance(value, list): for index, item in enumerate(value): validate_finite(item, f"{path}[{index}]")