@@ -448,6 +448,24 @@ class ControlAction(BaseModel):
448448MAX_CONDITION_DEPTH = 6
449449
450450
451+ def _validate_common_control_constraints (
452+ condition : ConditionNode ,
453+ action : ControlAction ,
454+ ) -> None :
455+ """Validate control constraints shared by authoring and runtime models."""
456+ if condition .max_depth () > MAX_CONDITION_DEPTH :
457+ raise ValueError (
458+ f"Condition nesting depth exceeds maximum of { MAX_CONDITION_DEPTH } "
459+ )
460+
461+ if (
462+ action .decision == "steer"
463+ and not condition .is_leaf ()
464+ and action .steering_context is None
465+ ):
466+ raise ValueError ("Composite steer controls require action.steering_context" )
467+
468+
451469class ConditionNode (BaseModel ):
452470 """Recursive boolean condition tree for control evaluation."""
453471
@@ -689,19 +707,7 @@ def canonicalize_legacy_condition_shape(cls, data: Any) -> Any:
689707 @model_validator (mode = "after" )
690708 def validate_condition_constraints (self ) -> Self :
691709 """Validate cross-field control constraints."""
692- if self .condition .max_depth () > MAX_CONDITION_DEPTH :
693- raise ValueError (
694- f"Condition nesting depth exceeds maximum of { MAX_CONDITION_DEPTH } "
695- )
696-
697- if (
698- self .action .decision == "steer"
699- and not self .condition .is_leaf ()
700- and self .action .steering_context is None
701- ):
702- raise ValueError (
703- "Composite steer controls require action.steering_context"
704- )
710+ _validate_common_control_constraints (self .condition , self .action )
705711 has_template = self .template is not None
706712 has_template_values = self .template_values is not None
707713 if has_template != has_template_values :
@@ -710,6 +716,15 @@ def validate_condition_constraints(self) -> Self:
710716 )
711717 return self
712718
719+ def to_template_control_input (self ) -> TemplateControlInput :
720+ """Extract template-backed authoring input from a stored control definition."""
721+ if self .template is None or self .template_values is None :
722+ raise ValueError ("Control definition is not template-backed" )
723+ return TemplateControlInput (
724+ template = self .template ,
725+ template_values = dict (self .template_values ),
726+ )
727+
713728 def iter_condition_leaves (self ) -> Iterator [ConditionNode ]:
714729 """Yield leaf conditions in evaluation order."""
715730 yield from self .condition .iter_leaves ()
@@ -808,6 +823,54 @@ class ControlDefinitionRuntime(BaseModel):
808823 action : ControlAction = Field (..., description = "What action to take when control matches" )
809824 tags : list [str ] = Field (default_factory = list , description = "Tags for categorization" )
810825
826+ @model_validator (mode = "before" )
827+ @classmethod
828+ def canonicalize_legacy_condition_shape (cls , data : Any ) -> Any :
829+ """Accept legacy flat leaf payloads during runtime parsing."""
830+ return ControlDefinition .canonicalize_payload (data )
831+
832+ @model_validator (mode = "after" )
833+ def validate_condition_constraints (self ) -> Self :
834+ """Validate runtime-relevant control constraints."""
835+ _validate_common_control_constraints (self .condition , self .action )
836+ return self
837+
838+ def iter_condition_leaves (self ) -> Iterator [ConditionNode ]:
839+ """Yield leaf conditions in evaluation order."""
840+ yield from self .condition .iter_leaves ()
841+
842+ def iter_condition_leaf_parts (self ) -> Iterator [ConditionLeafParts ]:
843+ """Yield leaf selector/evaluator pairs in evaluation order."""
844+ yield from self .condition .iter_leaf_parts ()
845+
846+ def observability_identity (self ) -> ControlObservabilityIdentity :
847+ """Return a deterministic representative identity for observability."""
848+ all_evaluators : list [str ] = []
849+ all_selector_paths : list [str ] = []
850+ seen_evaluators : set [str ] = set ()
851+ seen_selector_paths : set [str ] = set ()
852+ leaf_count = 0
853+
854+ for selector , evaluator in self .iter_condition_leaf_parts ():
855+ leaf_count += 1
856+ selector_path = selector .path or "*"
857+
858+ if evaluator .name not in seen_evaluators :
859+ seen_evaluators .add (evaluator .name )
860+ all_evaluators .append (evaluator .name )
861+
862+ if selector_path not in seen_selector_paths :
863+ seen_selector_paths .add (selector_path )
864+ all_selector_paths .append (selector_path )
865+
866+ return ControlObservabilityIdentity (
867+ selector_path = all_selector_paths [0 ] if all_selector_paths else None ,
868+ evaluator_name = all_evaluators [0 ] if all_evaluators else None ,
869+ leaf_count = leaf_count ,
870+ all_evaluators = all_evaluators ,
871+ all_selector_paths = all_selector_paths ,
872+ )
873+
811874 def to_control_definition (self ) -> ControlDefinition :
812875 """Promote a runtime control to the full public model when needed."""
813876 return ControlDefinition .model_validate (
0 commit comments