Skip to content

Commit 54b7bf0

Browse files
feat(P3): Epic 3.1 — SkillVersion, ValidationError, validate() + /8eyes fixes (#12)
99+16 new tests (1142 total). SkillVersion with semver parsing, validation on all types, type-safe from_dict(), recursive validation, store-level enforcement. Closes #11
1 parent c6d65cf commit 54b7bf0

4 files changed

Lines changed: 1124 additions & 35 deletions

File tree

openspace/skill_engine/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@
1313
"SkillOrigin",
1414
"SkillLineage",
1515
"SkillRecord",
16+
"SkillVersion",
1617
"SkillVisibility",
18+
"ValidationError",
1719
"SkillStore",
1820
"ExecutionAnalyzer",
1921
"SkillEvolver",
@@ -34,7 +36,9 @@
3436
"SkillOrigin": (".types", "SkillOrigin"),
3537
"SkillLineage": (".types", "SkillLineage"),
3638
"SkillRecord": (".types", "SkillRecord"),
39+
"SkillVersion": (".types", "SkillVersion"),
3740
"SkillVisibility": (".types", "SkillVisibility"),
41+
"ValidationError": (".types", "ValidationError"),
3842
"SkillStore": (".store", "SkillStore"),
3943
"ExecutionAnalyzer": (".analyzer", "ExecutionAnalyzer"),
4044
"SkillEvolver": (".evolver", "SkillEvolver"),

openspace/skill_engine/store.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
SkillOrigin,
3636
SkillRecord,
3737
SkillVisibility,
38+
ValidationError,
3839
)
3940

4041
logger = Logger.get_logger(__name__)
@@ -1089,6 +1090,12 @@ def _upsert(self, record: SkillRecord) -> None:
10891090
10901091
Called within a transaction holding ``self._mu``.
10911092
"""
1093+
try:
1094+
record.validate()
1095+
except ValidationError as exc:
1096+
raise ValidationError(
1097+
f"Cannot persist invalid SkillRecord '{record.skill_id}': {exc}"
1098+
) from exc
10921099
lin = record.lineage
10931100
# content_snapshot is Dict[str, str]; store as JSON text
10941101
snapshot_json = json.dumps(lin.content_snapshot, ensure_ascii=False)
@@ -1204,6 +1211,12 @@ def _insert_analysis(self, a: ExecutionAnalysis) -> int:
12041211
Returns:
12051212
int: The ``execution_analyses.id`` of the newly inserted row.
12061213
"""
1214+
try:
1215+
a.validate()
1216+
except ValidationError as exc:
1217+
raise ValidationError(
1218+
f"Cannot persist invalid ExecutionAnalysis '{a.task_id}': {exc}"
1219+
) from exc
12071220
cur = self._conn.execute(
12081221
"""
12091222
INSERT INTO execution_analyses (

openspace/skill_engine/types.py

Lines changed: 221 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,108 @@
22

33
from __future__ import annotations
44

5+
import re
56
from dataclasses import dataclass, field
67
from datetime import datetime
78
from enum import Enum
9+
from functools import total_ordering
810
from 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+
11107
class 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

Comments
 (0)