From a86c1ebb83480d4b5ac6554802bafa27b0ddbe0a Mon Sep 17 00:00:00 2001 From: Dom Date: Tue, 23 Jun 2026 18:14:23 +0200 Subject: [PATCH] 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) --- core/execution/trajectory_signature.py | 34 ++++++++++++++ tests/unit/test_trajectory_signature.py | 59 +++++++++++++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 core/execution/trajectory_signature.py create mode 100644 tests/unit/test_trajectory_signature.py diff --git a/core/execution/trajectory_signature.py b/core/execution/trajectory_signature.py new file mode 100644 index 000000000..3076335d1 --- /dev/null +++ b/core/execution/trajectory_signature.py @@ -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() diff --git a/tests/unit/test_trajectory_signature.py b/tests/unit/test_trajectory_signature.py new file mode 100644 index 000000000..9417087ce --- /dev/null +++ b/tests/unit/test_trajectory_signature.py @@ -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)