"""Versioned Brain-internal rule configuration and decisions.""" from __future__ import annotations from dataclasses import dataclass class RuleConfigError(ValueError): pass @dataclass(frozen=True, slots=True) class NormalizedPoint: x: float y: float def __post_init__(self) -> None: if not 0.0 <= self.x <= 1.0 or not 0.0 <= self.y <= 1.0: raise RuleConfigError("rule coordinates must be normalized to 0..1") @dataclass(frozen=True, slots=True) class AreaDefinition: rule_id: str points: tuple[NormalizedPoint, ...] @dataclass(frozen=True, slots=True) class DirectionalLineDefinition: rule_id: str start: NormalizedPoint end: NormalizedPoint trigger_direction: str deadband: float = 0.005 @dataclass(frozen=True, slots=True) class RuleSet: version: str profile_id: str width: int height: int areas: tuple[AreaDefinition, ...] = () directional_lines: tuple[DirectionalLineDefinition, ...] = () def __post_init__(self) -> None: if not self.version or not self.profile_id or self.width <= 0 or self.height <= 0: raise RuleConfigError("rule version, profile and dimensions are required") identifiers = [rule.rule_id for rule in self.areas] + [rule.rule_id for rule in self.directional_lines] if any(not identifier for identifier in identifiers) or len(set(identifiers)) != len(identifiers): raise RuleConfigError("rule ids must be non-empty and unique") for area in self.areas: if len(area.points) < 3 or abs(_polygon_area(area.points)) < 1e-9: raise RuleConfigError(f"area {area.rule_id!r} must be a non-degenerate polygon") for line in self.directional_lines: if line.start == line.end: raise RuleConfigError(f"line {line.rule_id!r} must have distinct endpoints") if line.trigger_direction not in {"left_to_right", "right_to_left"}: raise RuleConfigError(f"line {line.rule_id!r} has invalid trigger direction") if not 0.0 <= line.deadband < 0.5: raise RuleConfigError(f"line {line.rule_id!r} has invalid deadband") def _polygon_area(points: tuple[NormalizedPoint, ...]) -> float: return sum( first.x * second.y - second.x * first.y for first, second in zip(points, points[1:] + points[:1]) ) / 2.0 @dataclass(frozen=True, slots=True) class RuleDecision: rule_id: str rule_type: str track_id: str state: str triggered: bool reason: str config_version: str profile_id: str width: int height: int anchor: NormalizedPoint timestamp_ns: int