Files
agentic-mobile-control/skills_learning/versioning.py
T
q792602257 a279441aee fix(skill-versioning): consume divergence_tolerance, replace vacuous catalog-isolation guard
- divergence_tolerance was loaded from config but diff_flow_versions
  never consulted it, so structural_divergence was always plain
  sequence-equality regardless of the configured tolerance. Now
  actually applied per design.md D4.
- The skill-catalog-subscription isolation guard test asserted
  'skills.catalog' not in sys.modules, which is vacuously true since
  that module doesn't exist anywhere yet. Replaced with a real check
  against store.py's actual imports.

openspec: skill-versioning capability, archived change skill-learning-runtime
2026-07-07 08:31:11 +08:00

112 lines
4.0 KiB
Python

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)