from __future__ import annotations from dataclasses import dataclass from typing import Any from skills_learning.config import SkillAuthoringConfig from skills_learning.models import FlowStep, FlowTemplateSkill from skills_learning.store import SkillStore @dataclass(frozen=True) class VersionDiff: structural_divergence: bool stored_sequence: list[str] executed_sequence: list[str] argument_differences: list[tuple[int, str, Any, Any]] argument_divergence_fraction: float def diff_flow_versions( stored_steps: list[FlowStep], executed_steps: list[FlowStep], *, config: SkillAuthoringConfig | None = None, ) -> VersionDiff: """Compare a stored skill's steps against a newly executed run. Per design.md D4: a change to the tool-name *sequence* itself (insertion/deletion/reorder) always triggers a new version, with no tolerance applied. When the sequence matches (same skeleton), the fraction of step positions whose arguments differ is compared against ``config.divergence_tolerance`` (a fraction in ``[0, 1]``): a "materially different parameter set" that exceeds that tolerance is also treated as divergence worth a new version, per proposal.md's "differ beyond a configured tolerance ... or a materially different parameter set" trigger. When no ``config`` is supplied, tolerance is treated as unlimited (matching the pre-existing, backward-compatible behavior of never bumping a version for argument-only differences). """ stored_sequence = [step.tool_name for step in stored_steps] executed_sequence = [step.tool_name for step in executed_steps] sequence_diverged = stored_sequence != executed_sequence differences: list[tuple[int, str, Any, Any]] = [] diverged_positions = 0 total_positions = 0 if not sequence_diverged: for index, (stored_step, executed_step) in enumerate( zip(stored_steps, executed_steps, strict=True) ): total_positions += 1 keys = set(stored_step.args) | set(executed_step.args) position_diverged = False for key in sorted(keys): stored_value = stored_step.args.get(key) executed_value = executed_step.args.get(key) if stored_value != executed_value: differences.append((index, key, stored_value, executed_value)) position_diverged = True if position_diverged: diverged_positions += 1 argument_divergence_fraction = ( diverged_positions / total_positions if total_positions else 0.0 ) beyond_tolerance = ( not sequence_diverged and config is not None and argument_divergence_fraction > config.divergence_tolerance ) return VersionDiff( structural_divergence=sequence_diverged or beyond_tolerance, stored_sequence=stored_sequence, executed_sequence=executed_sequence, argument_differences=differences, argument_divergence_fraction=argument_divergence_fraction, ) @dataclass(frozen=True) class VersioningResult: skill: FlowTemplateSkill created_new_version: bool def store_synthesized_skill( store: SkillStore, candidate: FlowTemplateSkill, *, config: SkillAuthoringConfig | None = None, ) -> VersioningResult: latest = store.get_latest_by_name(candidate.name) if latest is None: return VersioningResult(store.create_version(candidate), True) diff = diff_flow_versions(latest.steps, candidate.steps, config=config) if diff.structural_divergence: return VersioningResult(store.create_version(candidate, parent=latest), True) merged_parameters = { **latest.parameters, **candidate.parameters, } updated = latest.with_updates( steps=candidate.steps, parameters=merged_parameters, description=candidate.description, originating_goal=candidate.originating_goal, ) return VersioningResult(store.update_skill(updated), False)