205 lines
7.9 KiB
Python
205 lines
7.9 KiB
Python
"""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 <feature_name>.<output_role>.",
|
|
)
|
|
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 <feature_name>.<output_role>")
|
|
return value
|
|
|
|
|
|
class FeatureIntent(AuthorModel):
|
|
"""Feature-level semantic annotation for training data.
|
|
|
|
Mirrors ``$defs/featureIntent`` in ``cdsl_schema.json``: purely
|
|
descriptive, never read by the compiler for geometry decisions.
|
|
"""
|
|
|
|
label: str | None = Field(default=None, pattern=r"^[a-z][a-z0-9_]{2,63}$")
|
|
summary: str = Field(min_length=1, max_length=80)
|
|
why: str | None = Field(default=None, min_length=1, max_length=400)
|
|
ties_to_requirement: str | None = Field(default=None, pattern=r"^[A-Za-z0-9_.:-]{1,80}$")
|
|
provenance: Literal["authored", "annotated", "imported"]
|
|
|
|
|
|
class DocumentMeta(AuthorModel):
|
|
"""Document-level semantic annotation (part description and function)."""
|
|
|
|
description: str | None = Field(default=None, min_length=1, max_length=60)
|
|
function: str | None = Field(default=None, min_length=1, max_length=200)
|
|
|
|
@model_validator(mode="after")
|
|
def require_at_least_one(self) -> "DocumentMeta":
|
|
if self.description is None and self.function is None:
|
|
raise ValueError("meta requires at least one of description or function")
|
|
return self
|
|
|
|
|
|
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.",
|
|
)
|
|
intent: FeatureIntent | None = Field(
|
|
default=None,
|
|
description="Optional semantic annotation for training; never consumed by geometry.",
|
|
)
|
|
|
|
|
|
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)
|
|
meta: DocumentMeta | None = Field(
|
|
default=None,
|
|
description="Optional document-level semantic annotation (description/function).",
|
|
)
|
|
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}]")
|