feat(core): signature de trajectoire stable pour identite workflow (Phase 0, F1)

Primitive partagee (SP-4/SP-2/competences) : hashe la sequence ordonnee
(action_type, target) d'un parcours en ignorant les champs session-specifiques
(node_id, timestamp, coordonnees) -> deux apprentissages du meme parcours = meme
signature = base du create-or-update (decision F1). Le target stable peut etre
compose avec screen_signature() existante.

Test TDD: tests/unit/test_trajectory_signature.py (5 tests, RED->GREEN).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dom
2026-06-23 18:14:23 +02:00
parent 2cabc6cb7e
commit a86c1ebb83
2 changed files with 93 additions and 0 deletions

View File

@@ -0,0 +1,34 @@
"""Signature de trajectoire — identité stable d'un parcours appris (décision F1).
Une trajectoire = séquence ordonnée d'actions sur des cibles stables. La signature
hashe uniquement `(action_type, target)` de chaque étape, dans l'ordre, en **ignorant
les champs session-spécifiques** (IDs de nœuds, timestamps, coordonnées). Deux
apprentissages du même parcours produisent donc la même signature → create-or-update.
Primitive partagée (Phase 0) : consommée par SP-4 (dédup/persist), SP-2 (rejeu) et le
cycle compétences (dédup des skills). Pour composer avec un descripteur d'écran stable,
passer `core.execution.screen_signature.screen_signature(...)` comme valeur de `target`.
"""
import hashlib
from typing import Any, Iterable, Mapping
_FIELD_SEP = "\x1f" # sépare action_type et target dans une étape
_STEP_SEP = "\x1e" # sépare les étapes
def _normalize_step(step: Mapping[str, Any]) -> str:
action_type = str(step.get("action_type", "unknown")).strip().lower()
target = str(step.get("target", "")).strip()
return f"{action_type}{_FIELD_SEP}{target}"
def trajectory_signature(steps: Iterable[Mapping[str, Any]]) -> str:
"""Retourne la signature SHA-256 (hex, 64 car.) d'une séquence d'étapes.
Chaque étape est un mapping ; seuls `action_type` et `target` sont pris en compte.
Tous les autres champs (node_id, timestamp, coordonnées…) sont ignorés afin de
garantir la stabilité de la signature entre deux sessions du même parcours.
"""
canonical = _STEP_SEP.join(_normalize_step(step) for step in steps)
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()

View File

@@ -0,0 +1,59 @@
"""TDD — signature de trajectoire (Phase 0 ; primitive partagée SP-4 / SP-2 / compétences).
Propriété centrale : la signature identifie une TRAJECTOIRE (séquence d'actions sur des
cibles stables). Elle doit être **stable entre sessions** — donc indépendante des champs
session-spécifiques (IDs de nœuds, timestamps, coordonnées). C'est ce qui rend le
create-or-update (décision F1) possible : deux apprentissages du même parcours = même id.
"""
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from core.execution.trajectory_signature import trajectory_signature
def test_deterministic_same_sequence():
steps = [
{"action_type": "mouse_click", "target": "menu Fichier"},
{"action_type": "text_input", "target": "champ recherche"},
]
assert trajectory_signature(steps) == trajectory_signature(steps)
def test_ignores_session_specific_fields():
"""Deux sessions du MÊME parcours (mêmes action_type+target) mais IDs de nœuds /
timestamps / coords différents → MÊME signature."""
session_a = [
{"action_type": "mouse_click", "target": "menu Fichier",
"node_id": "n_abc", "timestamp": 1000, "x": 12, "y": 34},
{"action_type": "text_input", "target": "champ recherche",
"node_id": "n_def", "timestamp": 1100, "x": 50, "y": 60},
]
session_b = [
{"action_type": "mouse_click", "target": "menu Fichier",
"node_id": "n_zzz", "timestamp": 9000, "x": 99, "y": 88},
{"action_type": "text_input", "target": "champ recherche",
"node_id": "n_yyy", "timestamp": 9100, "x": 11, "y": 22},
]
assert trajectory_signature(session_a) == trajectory_signature(session_b)
def test_order_sensitive():
a = [{"action_type": "mouse_click", "target": "A"},
{"action_type": "text_input", "target": "B"}]
b = list(reversed(a))
assert trajectory_signature(a) != trajectory_signature(b)
def test_target_discriminates():
a = [{"action_type": "mouse_click", "target": "bouton Valider"}]
b = [{"action_type": "mouse_click", "target": "bouton Annuler"}]
assert trajectory_signature(a) != trajectory_signature(b)
def test_returns_sha256_hex():
sig = trajectory_signature([{"action_type": "mouse_click", "target": "x"}])
assert len(sig) == 64
assert all(c in "0123456789abcdef" for c in sig)