22
33from __future__ import annotations
44
5+ import re
56from dataclasses import dataclass , field
67from datetime import datetime
78from enum import Enum
9+ from functools import total_ordering
810from typing import Any , ClassVar , Dict , List , Optional
911
1012
13+ class ValidationError (Exception ):
14+ """Raised when a data type fails validation."""
15+
16+
17+ # ═══════════════════════════════════════════════════════════════════════
18+ # SkillVersion — semantic versioning for skills
19+ # ═══════════════════════════════════════════════════════════════════════
20+
21+ _VERSION_RE = re .compile (r"^(\d+)(?:\.(\d+))?(?:\.(\d+))?$" )
22+
23+
24+ @total_ordering
25+ @dataclass (frozen = True )
26+ class SkillVersion :
27+ """Semantic version (major.minor.patch) for skill revisions.
28+
29+ Supports parsing, comparison, bumping, and serialization.
30+ """
31+
32+ major : int = 0
33+ minor : int = 0
34+ patch : int = 0
35+
36+ def __post_init__ (self ) -> None :
37+ if self .major < 0 or self .minor < 0 or self .patch < 0 :
38+ raise ValueError (
39+ f"Version components must be non-negative, got { self .major } .{ self .minor } .{ self .patch } "
40+ )
41+
42+ # --- Parsing ---
43+
44+ @classmethod
45+ def parse (cls , text : str ) -> "SkillVersion" :
46+ """Parse a version string like ``'1.2.3'``, ``'1.2'``, or ``'1'``."""
47+ m = _VERSION_RE .match (text .strip ())
48+ if not m :
49+ raise ValueError (f"Invalid version string: { text !r} " )
50+ major = int (m .group (1 ))
51+ minor = int (m .group (2 )) if m .group (2 ) is not None else 0
52+ patch = int (m .group (3 )) if m .group (3 ) is not None else 0
53+ return cls (major = major , minor = minor , patch = patch )
54+
55+ # --- Comparison ---
56+
57+ def _as_tuple (self ) -> tuple [int , int , int ]:
58+ return (self .major , self .minor , self .patch )
59+
60+ def __eq__ (self , other : object ) -> bool :
61+ if not isinstance (other , SkillVersion ):
62+ return NotImplemented
63+ return self ._as_tuple () == other ._as_tuple ()
64+
65+ def __lt__ (self , other : object ) -> bool :
66+ if not isinstance (other , SkillVersion ):
67+ return NotImplemented
68+ return self ._as_tuple () < other ._as_tuple ()
69+
70+ def __hash__ (self ) -> int :
71+ return hash (self ._as_tuple ())
72+
73+ # --- Bumping ---
74+
75+ def bump_patch (self ) -> "SkillVersion" :
76+ return SkillVersion (self .major , self .minor , self .patch + 1 )
77+
78+ def bump_minor (self ) -> "SkillVersion" :
79+ return SkillVersion (self .major , self .minor + 1 , 0 )
80+
81+ def bump_major (self ) -> "SkillVersion" :
82+ return SkillVersion (self .major + 1 , 0 , 0 )
83+
84+ # --- Serialization ---
85+
86+ def __str__ (self ) -> str :
87+ return f"{ self .major } .{ self .minor } .{ self .patch } "
88+
89+ def __repr__ (self ) -> str :
90+ return f"SkillVersion({ self .major } , { self .minor } , { self .patch } )"
91+
92+ def to_dict (self ) -> Dict [str , int ]:
93+ return {"major" : self .major , "minor" : self .minor , "patch" : self .patch }
94+
95+ @classmethod
96+ def from_dict (cls , data : Dict [str , Any ]) -> "SkillVersion" :
97+ try :
98+ return cls (
99+ major = int (data .get ("major" , 0 )),
100+ minor = int (data .get ("minor" , 0 )),
101+ patch = int (data .get ("patch" , 0 )),
102+ )
103+ except (TypeError , ValueError ) as exc :
104+ raise ValidationError (f"Invalid SkillVersion data: { exc } " ) from exc
105+
106+
11107class SkillCategory (str , Enum ):
12108 """Skill primary category."""
13109
@@ -126,6 +222,28 @@ class SkillLineage:
126222 created_at : datetime = field (default_factory = datetime .now )
127223 created_by : str = "" # "human" | model name (version-level actor)
128224
225+ def validate (self ) -> None :
226+ """Validate lineage rules by origin type."""
227+ if self .generation < 0 :
228+ raise ValidationError (f"generation must be >= 0, got { self .generation } " )
229+ origin = self .origin
230+ parents = self .parent_skill_ids
231+ if origin in (SkillOrigin .IMPORTED , SkillOrigin .CAPTURED ):
232+ if parents :
233+ raise ValidationError (
234+ f"{ origin .value } lineage must have no parents, got { len (parents )} "
235+ )
236+ elif origin == SkillOrigin .FIXED :
237+ if len (parents ) != 1 :
238+ raise ValidationError (
239+ f"FIXED lineage must have exactly 1 parent, got { len (parents )} "
240+ )
241+ elif origin == SkillOrigin .DERIVED :
242+ if not parents :
243+ raise ValidationError (
244+ "DERIVED lineage must have at least 1 parent"
245+ )
246+
129247 def to_dict (self ) -> Dict [str , Any ]:
130248 return {
131249 "origin" : self .origin .value ,
@@ -173,6 +291,11 @@ class SkillJudgment:
173291 skill_applied : bool = False # Whether the skill was actually applied
174292 note : str = "" # Per-skill observation (deviation, usage, etc.)
175293
294+ def validate (self ) -> None :
295+ """Validate judgment fields."""
296+ if not self .skill_id :
297+ raise ValidationError ("skill_id must be non-empty" )
298+
176299 def to_dict (self ) -> Dict [str , Any ]:
177300 return {
178301 "skill_id" : self .skill_id ,
@@ -205,6 +328,26 @@ class EvolutionSuggestion:
205328 category : Optional [SkillCategory ] = None # Desired category of the result
206329 direction : str = "" # Free-text: what to evolve / capture
207330
331+ def validate (self ) -> None :
332+ """Validate target rules per evolution type."""
333+ et = self .evolution_type
334+ targets = self .target_skill_ids
335+ if et == EvolutionType .FIX :
336+ if len (targets ) != 1 :
337+ raise ValidationError (
338+ f"FIX suggestion must have exactly 1 target, got { len (targets )} "
339+ )
340+ elif et == EvolutionType .DERIVED :
341+ if not targets :
342+ raise ValidationError (
343+ "DERIVED suggestion must have at least 1 target"
344+ )
345+ elif et == EvolutionType .CAPTURED :
346+ if targets :
347+ raise ValidationError (
348+ f"CAPTURED suggestion must have no targets, got { len (targets )} "
349+ )
350+
208351 @property
209352 def target_skill_id (self ) -> str :
210353 """Primary (or only) target skill_id. Empty string if none."""
@@ -287,6 +430,15 @@ def suggestions_by_type(self, evo_type: EvolutionType) -> List[EvolutionSuggesti
287430 """Filter evolution suggestions by type."""
288431 return [s for s in self .evolution_suggestions if s .evolution_type == evo_type ]
289432
433+ def validate (self ) -> None :
434+ """Validate analysis fields and nested objects."""
435+ if not self .task_id :
436+ raise ValidationError ("task_id must be non-empty" )
437+ for j in self .skill_judgments :
438+ j .validate ()
439+ for s in self .evolution_suggestions :
440+ s .validate ()
441+
290442 def to_dict (self ) -> Dict [str , Any ]:
291443 return {
292444 "task_id" : self .task_id ,
@@ -302,17 +454,20 @@ def to_dict(self) -> Dict[str, Any]:
302454
303455 @classmethod
304456 def from_dict (cls , data : Dict [str , Any ]) -> "ExecutionAnalysis" :
305- return cls (
306- task_id = data ["task_id" ],
307- timestamp = datetime .fromisoformat (data ["timestamp" ]),
308- task_completed = data .get ("task_completed" , False ),
309- execution_note = data .get ("execution_note" , "" ),
310- tool_issues = data .get ("tool_issues" , []),
311- skill_judgments = [SkillJudgment .from_dict (j ) for j in data .get ("skill_judgments" , [])],
312- evolution_suggestions = [EvolutionSuggestion .from_dict (s ) for s in data .get ("evolution_suggestions" , [])],
313- analyzed_by = data .get ("analyzed_by" , "" ),
314- analyzed_at = (datetime .fromisoformat (data ["analyzed_at" ]) if data .get ("analyzed_at" ) else datetime .now ()),
315- )
457+ try :
458+ return cls (
459+ task_id = str (data ["task_id" ]),
460+ timestamp = datetime .fromisoformat (data ["timestamp" ]),
461+ task_completed = data .get ("task_completed" , False ),
462+ execution_note = data .get ("execution_note" , "" ),
463+ tool_issues = data .get ("tool_issues" , []),
464+ skill_judgments = [SkillJudgment .from_dict (j ) for j in data .get ("skill_judgments" , [])],
465+ evolution_suggestions = [EvolutionSuggestion .from_dict (s ) for s in data .get ("evolution_suggestions" , [])],
466+ analyzed_by = data .get ("analyzed_by" , "" ),
467+ analyzed_at = (datetime .fromisoformat (data ["analyzed_at" ]) if data .get ("analyzed_at" ) else datetime .now ()),
468+ )
469+ except (TypeError , ValueError ) as exc :
470+ raise ValidationError (f"Invalid ExecutionAnalysis data: { exc } " ) from exc
316471
317472
318473# Full skill profile (identity + lineage + deps + quality)
@@ -385,6 +540,34 @@ def fallback_rate(self) -> float:
385540 # performed atomically in SQL by SkillStore.record_analysis().
386541 # Do NOT duplicate that logic here in Python.
387542
543+ def validate (self ) -> None :
544+ """Validate record fields and nested lineage."""
545+ if not self .skill_id :
546+ raise ValidationError ("skill_id must be non-empty" )
547+ if not self .name :
548+ raise ValidationError ("name must be non-empty" )
549+ counters = {
550+ "total_selections" : self .total_selections ,
551+ "total_applied" : self .total_applied ,
552+ "total_completions" : self .total_completions ,
553+ "total_fallbacks" : self .total_fallbacks ,
554+ }
555+ for field_name , value in counters .items ():
556+ if value < 0 :
557+ raise ValidationError (f"{ field_name } must be >= 0, got { value } " )
558+ if self .total_applied > self .total_selections :
559+ raise ValidationError (
560+ f"total_applied ({ self .total_applied } ) must not exceed "
561+ f"total_selections ({ self .total_selections } )"
562+ )
563+ if self .total_completions > self .total_applied :
564+ raise ValidationError (
565+ f"total_completions ({ self .total_completions } ) must not exceed "
566+ f"total_applied ({ self .total_applied } )"
567+ )
568+ if self .lineage is not None :
569+ self .lineage .validate ()
570+
388571 def to_dict (self ) -> Dict [str , Any ]:
389572 return {
390573 "skill_id" : self .skill_id ,
@@ -410,30 +593,33 @@ def to_dict(self) -> Dict[str, Any]:
410593
411594 @classmethod
412595 def from_dict (cls , data : Dict [str , Any ]) -> "SkillRecord" :
413- record = cls (
414- skill_id = data ["skill_id" ],
415- name = data ["name" ],
416- description = data .get ("description" , "" ),
417- path = data .get ("path" , "" ),
418- is_active = data .get ("is_active" , True ),
419- category = SkillCategory (data ["category" ]) if data .get ("category" ) else SkillCategory .WORKFLOW ,
420- tags = data .get ("tags" , []),
421- visibility = (SkillVisibility (data ["visibility" ]) if data .get ("visibility" ) else SkillVisibility .PRIVATE ),
422- creator_id = data .get ("creator_id" , "" ),
423- lineage = (
424- SkillLineage .from_dict (data ["lineage" ])
425- if data .get ("lineage" )
426- else SkillLineage (origin = SkillOrigin .IMPORTED )
427- ),
428- tool_dependencies = data .get ("tool_dependencies" , []),
429- critical_tools = data .get ("critical_tools" , []),
430- total_selections = data .get ("total_selections" , 0 ),
431- total_applied = data .get ("total_applied" , 0 ),
432- total_completions = data .get ("total_completions" , 0 ),
433- total_fallbacks = data .get ("total_fallbacks" , 0 ),
434- first_seen = (datetime .fromisoformat (data ["first_seen" ]) if data .get ("first_seen" ) else datetime .now ()),
435- last_updated = (datetime .fromisoformat (data ["last_updated" ]) if data .get ("last_updated" ) else datetime .now ()),
436- )
596+ try :
597+ record = cls (
598+ skill_id = str (data ["skill_id" ]),
599+ name = str (data ["name" ]),
600+ description = data .get ("description" , "" ),
601+ path = data .get ("path" , "" ),
602+ is_active = data .get ("is_active" , True ),
603+ category = SkillCategory (data ["category" ]) if data .get ("category" ) else SkillCategory .WORKFLOW ,
604+ tags = data .get ("tags" , []),
605+ visibility = (SkillVisibility (data ["visibility" ]) if data .get ("visibility" ) else SkillVisibility .PRIVATE ),
606+ creator_id = data .get ("creator_id" , "" ),
607+ lineage = (
608+ SkillLineage .from_dict (data ["lineage" ])
609+ if data .get ("lineage" )
610+ else SkillLineage (origin = SkillOrigin .IMPORTED )
611+ ),
612+ tool_dependencies = data .get ("tool_dependencies" , []),
613+ critical_tools = data .get ("critical_tools" , []),
614+ total_selections = int (data .get ("total_selections" , 0 )),
615+ total_applied = int (data .get ("total_applied" , 0 )),
616+ total_completions = int (data .get ("total_completions" , 0 )),
617+ total_fallbacks = int (data .get ("total_fallbacks" , 0 )),
618+ first_seen = (datetime .fromisoformat (data ["first_seen" ]) if data .get ("first_seen" ) else datetime .now ()),
619+ last_updated = (datetime .fromisoformat (data ["last_updated" ]) if data .get ("last_updated" ) else datetime .now ()),
620+ )
621+ except (TypeError , ValueError ) as exc :
622+ raise ValidationError (f"Invalid SkillRecord data: { exc } " ) from exc
437623 for a in data .get ("recent_analyses" , []):
438624 record .recent_analyses .append (ExecutionAnalysis .from_dict (a ))
439625 return record
0 commit comments