- Frontend v4 accessible sur réseau local (192.168.1.40) - Ports ouverts: 3002 (frontend), 5001 (backend), 5004 (dashboard) - Ollama GPU fonctionnel - Self-healing interactif - Dashboard confiance Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
455 lines
17 KiB
Python
455 lines
17 KiB
Python
"""
|
|
Tests Property-Based pour RPA Vision V3
|
|
|
|
Ce fichier contient les tests property-based pour valider les propriétés
|
|
de correction définies dans le design document.
|
|
|
|
Utilise Hypothesis pour la génération de données aléatoires.
|
|
"""
|
|
import pytest
|
|
import numpy as np
|
|
from hypothesis import given, strategies as st, settings, assume
|
|
from hypothesis.extra.numpy import arrays
|
|
from typing import List, Tuple
|
|
from datetime import datetime
|
|
|
|
|
|
# =============================================================================
|
|
# Stratégies de génération
|
|
# =============================================================================
|
|
|
|
# Stratégie pour générer des embeddings normalisés
|
|
embedding_strategy = arrays(
|
|
dtype=np.float32,
|
|
shape=st.integers(min_value=64, max_value=512),
|
|
elements=st.floats(min_value=-1.0, max_value=1.0, allow_nan=False, allow_infinity=False)
|
|
)
|
|
|
|
# Stratégie pour générer des scores de confiance
|
|
confidence_strategy = st.floats(min_value=0.0, max_value=1.0, allow_nan=False)
|
|
|
|
# Stratégie pour générer des bounding boxes valides
|
|
bbox_strategy = st.tuples(
|
|
st.integers(min_value=0, max_value=1000), # x
|
|
st.integers(min_value=0, max_value=1000), # y
|
|
st.integers(min_value=10, max_value=500), # width
|
|
st.integers(min_value=10, max_value=500), # height
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# Property 1: Cohérence de la Qualité des Clusters
|
|
# Feature: rpa-vision-excellence, Property 1: Cluster Quality Consistency
|
|
# Validates: Requirements 1.1
|
|
# =============================================================================
|
|
|
|
@given(
|
|
embeddings=st.lists(
|
|
arrays(dtype=np.float32, shape=128, elements=st.floats(0.1, 1.0, allow_nan=False, allow_infinity=False)),
|
|
min_size=5,
|
|
max_size=20
|
|
)
|
|
)
|
|
@settings(max_examples=50, deadline=None)
|
|
def test_property_cluster_quality_consistency(embeddings):
|
|
"""
|
|
**Feature: rpa-vision-excellence, Property 1: Cluster Quality Consistency**
|
|
**Validates: Requirements 1.1**
|
|
|
|
Pour tout ensemble d'embeddings, le score de qualité du cluster
|
|
doit être dans l'intervalle [0, 1].
|
|
"""
|
|
from core.training.quality_validator import TrainingQualityValidator, ClusterMetrics
|
|
|
|
validator = TrainingQualityValidator()
|
|
embeddings_array = np.array(embeddings)
|
|
|
|
# Normaliser les embeddings
|
|
norms = np.linalg.norm(embeddings_array, axis=1, keepdims=True)
|
|
norms = np.where(norms == 0, 1, norms)
|
|
embeddings_array = embeddings_array / norms
|
|
|
|
# Ignorer les cas où tous les embeddings sont identiques
|
|
if np.allclose(embeddings_array[0], embeddings_array):
|
|
return
|
|
|
|
# Créer un ClusterMetrics directement pour tester les propriétés
|
|
# Calculer la cohésion (distance moyenne au centroïde)
|
|
centroid = np.mean(embeddings_array, axis=0)
|
|
distances = np.linalg.norm(embeddings_array - centroid, axis=1)
|
|
cohesion = 1.0 / (1.0 + np.mean(distances)) # Normaliser entre 0 et 1
|
|
|
|
# Créer les métriques
|
|
metrics = ClusterMetrics(
|
|
cluster_id="test_cluster",
|
|
silhouette_score=0.5, # Valeur par défaut pour le test
|
|
cohesion=cohesion,
|
|
separation=0.5,
|
|
sample_count=len(embeddings),
|
|
is_sufficient=len(embeddings) >= 3
|
|
)
|
|
|
|
# Propriété: le score de qualité doit être dans [0, 1]
|
|
assert 0.0 <= metrics.quality_score <= 1.0, \
|
|
f"Quality score {metrics.quality_score} hors limites [0, 1]"
|
|
assert 0.0 <= metrics.cohesion <= 1.0, \
|
|
f"Cohesion {metrics.cohesion} hors limites [0, 1]"
|
|
|
|
|
|
# =============================================================================
|
|
# Property 2: Correction de la Détection d'Outliers
|
|
# Feature: rpa-vision-excellence, Property 2: Outlier Detection Correctness
|
|
# Validates: Requirements 1.3
|
|
# =============================================================================
|
|
|
|
@given(
|
|
normal_values=st.lists(st.floats(min_value=0.8, max_value=1.0), min_size=5, max_size=20),
|
|
outlier_values=st.lists(st.floats(min_value=0.0, max_value=0.3), min_size=0, max_size=3)
|
|
)
|
|
@settings(max_examples=50, deadline=None)
|
|
def test_property_outlier_detection_correctness(normal_values, outlier_values):
|
|
"""
|
|
**Feature: rpa-vision-excellence, Property 2: Outlier Detection Correctness**
|
|
**Validates: Requirements 1.3**
|
|
|
|
Pour tout ensemble de valeurs avec des outliers connus,
|
|
la détection doit identifier les valeurs extrêmes.
|
|
"""
|
|
from core.training.quality_validator import TrainingQualityValidator
|
|
|
|
validator = TrainingQualityValidator()
|
|
|
|
# Combiner valeurs normales et outliers
|
|
all_values = normal_values + outlier_values
|
|
if len(all_values) < 4:
|
|
return # Pas assez de données pour IQR
|
|
|
|
# Créer des embeddings simulés basés sur les valeurs
|
|
embeddings = []
|
|
for val in all_values:
|
|
emb = np.ones(128, dtype=np.float32) * val
|
|
embeddings.append(emb)
|
|
|
|
embeddings_array = np.array(embeddings)
|
|
|
|
# Détecter les outliers
|
|
outlier_indices = validator.detect_outliers(embeddings_array)
|
|
|
|
# Propriété: les indices retournés doivent être valides
|
|
for idx in outlier_indices:
|
|
assert 0 <= idx < len(all_values), f"Index outlier {idx} invalide"
|
|
|
|
|
|
# =============================================================================
|
|
# Property 4: Bornes de Confiance Hiérarchique
|
|
# Feature: rpa-vision-excellence, Property 4: Hierarchical Confidence Bounds
|
|
# Validates: Requirements 2.4
|
|
# =============================================================================
|
|
|
|
@given(
|
|
window_score=confidence_strategy,
|
|
region_score=confidence_strategy,
|
|
element_score=confidence_strategy
|
|
)
|
|
@settings(max_examples=100, deadline=None)
|
|
def test_property_hierarchical_confidence_bounds(window_score, region_score, element_score):
|
|
"""
|
|
**Feature: rpa-vision-excellence, Property 4: Hierarchical Confidence Bounds**
|
|
**Validates: Requirements 2.4**
|
|
|
|
Pour toute combinaison de scores de confiance (fenêtre, région, élément),
|
|
le score combiné doit être dans [0, 1].
|
|
"""
|
|
# Formule: 0.2*fenêtre + 0.3*région + 0.5*élément
|
|
combined = 0.2 * window_score + 0.3 * region_score + 0.5 * element_score
|
|
|
|
# Propriété: le score combiné doit être dans [0, 1]
|
|
assert 0.0 <= combined <= 1.0, \
|
|
f"Score combiné {combined} hors limites pour w={window_score}, r={region_score}, e={element_score}"
|
|
|
|
|
|
# =============================================================================
|
|
# Property 5: Correction du Boost Temporel
|
|
# Feature: rpa-vision-excellence, Property 5: Temporal Boost Correctness
|
|
# Validates: Requirements 2.5
|
|
# =============================================================================
|
|
|
|
@given(
|
|
base_confidence=st.floats(min_value=0.0, max_value=1.0, allow_nan=False),
|
|
is_valid_successor=st.booleans()
|
|
)
|
|
@settings(max_examples=100, deadline=None)
|
|
def test_property_temporal_boost_correctness(base_confidence, is_valid_successor):
|
|
"""
|
|
**Feature: rpa-vision-excellence, Property 5: Temporal Boost Correctness**
|
|
**Validates: Requirements 2.5**
|
|
|
|
Pour toute confiance de base et statut de successeur,
|
|
le boost temporel doit augmenter la confiance de 0.1 pour les successeurs valides
|
|
et plafonner à 1.0.
|
|
"""
|
|
TEMPORAL_BOOST = 0.1
|
|
|
|
if is_valid_successor:
|
|
boosted = min(base_confidence + TEMPORAL_BOOST, 1.0)
|
|
else:
|
|
boosted = base_confidence
|
|
|
|
# Propriétés:
|
|
# 1. Le score boosté doit être >= au score de base
|
|
assert boosted >= base_confidence, \
|
|
f"Score boosté {boosted} < base {base_confidence}"
|
|
|
|
# 2. Le score boosté doit être <= 1.0
|
|
assert boosted <= 1.0, \
|
|
f"Score boosté {boosted} > 1.0"
|
|
|
|
# 3. Si successeur valide, le boost doit être appliqué (sauf si déjà à 1.0)
|
|
if is_valid_successor and base_confidence < 1.0:
|
|
expected_boost = min(base_confidence + TEMPORAL_BOOST, 1.0)
|
|
assert boosted == expected_boost, \
|
|
f"Boost incorrect: attendu {expected_boost}, obtenu {boosted}"
|
|
|
|
|
|
# =============================================================================
|
|
# Property 6: Mise à Jour du Prototype par EMA
|
|
# Feature: rpa-vision-excellence, Property 6: EMA Prototype Update
|
|
# Validates: Requirements 3.1
|
|
# =============================================================================
|
|
|
|
@given(
|
|
alpha=st.floats(min_value=0.01, max_value=0.5, allow_nan=False)
|
|
)
|
|
@settings(max_examples=50, deadline=None)
|
|
def test_property_ema_prototype_update(alpha):
|
|
"""
|
|
**Feature: rpa-vision-excellence, Property 6: EMA Prototype Update**
|
|
**Validates: Requirements 3.1**
|
|
|
|
Pour tout alpha EMA, la mise à jour du prototype doit:
|
|
1. Produire un vecteur de même dimension
|
|
2. Être une combinaison convexe des deux vecteurs
|
|
"""
|
|
# Créer des embeddings de test
|
|
old_prototype = np.random.randn(128).astype(np.float32)
|
|
new_observation = np.random.randn(128).astype(np.float32)
|
|
|
|
# Formule EMA: new = alpha * observation + (1 - alpha) * old
|
|
updated = alpha * new_observation + (1 - alpha) * old_prototype
|
|
|
|
# Propriété 1: même dimension
|
|
assert updated.shape == old_prototype.shape, \
|
|
f"Dimension incorrecte: {updated.shape} vs {old_prototype.shape}"
|
|
|
|
# Propriété 2: combinaison convexe (le résultat est entre les deux)
|
|
# Pour chaque composante, le résultat doit être entre min et max des deux
|
|
for i in range(len(updated)):
|
|
min_val = min(old_prototype[i], new_observation[i])
|
|
max_val = max(old_prototype[i], new_observation[i])
|
|
assert min_val <= updated[i] <= max_val or np.isclose(updated[i], min_val) or np.isclose(updated[i], max_val), \
|
|
f"Composante {i} hors limites: {updated[i]} not in [{min_val}, {max_val}]"
|
|
|
|
|
|
# =============================================================================
|
|
# Property 7: Seuil de Détection de Dérive
|
|
# Feature: rpa-vision-excellence, Property 7: Drift Detection Threshold
|
|
# Validates: Requirements 3.2
|
|
# =============================================================================
|
|
|
|
@given(
|
|
confidence_sequence=st.lists(
|
|
st.floats(min_value=0.0, max_value=1.0, allow_nan=False),
|
|
min_size=1,
|
|
max_size=10
|
|
)
|
|
)
|
|
@settings(max_examples=100, deadline=None)
|
|
def test_property_drift_detection_threshold(confidence_sequence):
|
|
"""
|
|
**Feature: rpa-vision-excellence, Property 7: Drift Detection Threshold**
|
|
**Validates: Requirements 3.2**
|
|
|
|
La dérive doit être signalée après 3 matchs consécutifs sous 0.85.
|
|
"""
|
|
DRIFT_THRESHOLD = 0.85
|
|
CONSECUTIVE_REQUIRED = 3
|
|
|
|
# Simuler la détection de dérive
|
|
consecutive_low = 0
|
|
drift_detected = False
|
|
|
|
for conf in confidence_sequence:
|
|
if conf < DRIFT_THRESHOLD:
|
|
consecutive_low += 1
|
|
if consecutive_low >= CONSECUTIVE_REQUIRED:
|
|
drift_detected = True
|
|
break
|
|
else:
|
|
consecutive_low = 0
|
|
|
|
# Vérifier la propriété
|
|
low_count = sum(1 for c in confidence_sequence if c < DRIFT_THRESHOLD)
|
|
|
|
# Si on a 3+ valeurs consécutives sous le seuil, la dérive doit être détectée
|
|
has_consecutive_low = False
|
|
count = 0
|
|
for conf in confidence_sequence:
|
|
if conf < DRIFT_THRESHOLD:
|
|
count += 1
|
|
if count >= CONSECUTIVE_REQUIRED:
|
|
has_consecutive_low = True
|
|
break
|
|
else:
|
|
count = 0
|
|
|
|
assert drift_detected == has_consecutive_low, \
|
|
f"Détection de dérive incorrecte: détecté={drift_detected}, attendu={has_consecutive_low}"
|
|
|
|
|
|
# =============================================================================
|
|
# Property 10: Symétrie des Relations Spatiales
|
|
# Feature: rpa-vision-excellence, Property 10: Spatial Relation Symmetry
|
|
# Validates: Requirements 5.1
|
|
# =============================================================================
|
|
|
|
@given(
|
|
bbox_a=bbox_strategy,
|
|
bbox_b=bbox_strategy
|
|
)
|
|
@settings(max_examples=100, deadline=None)
|
|
def test_property_spatial_relation_symmetry(bbox_a, bbox_b):
|
|
"""
|
|
**Feature: rpa-vision-excellence, Property 10: Spatial Relation Symmetry**
|
|
**Validates: Requirements 5.1**
|
|
|
|
Les relations spatiales doivent être symétriques:
|
|
- Si A est au-dessus de B, alors B est en-dessous de A
|
|
- Si A est à gauche de B, alors B est à droite de A
|
|
"""
|
|
from core.detection.spatial_analyzer import RelationType
|
|
|
|
# Calculer les centres
|
|
center_a = (bbox_a[0] + bbox_a[2]/2, bbox_a[1] + bbox_a[3]/2)
|
|
center_b = (bbox_b[0] + bbox_b[2]/2, bbox_b[1] + bbox_b[3]/2)
|
|
|
|
# Ignorer les cas où les centres sont identiques (pas de relation directionnelle)
|
|
dx = center_b[0] - center_a[0]
|
|
dy = center_b[1] - center_a[1]
|
|
|
|
assume(abs(dx) > 1 or abs(dy) > 1) # Les centres doivent être différents
|
|
|
|
# Définir les paires de relations inverses
|
|
inverse_relations = {
|
|
RelationType.ABOVE: RelationType.BELOW,
|
|
RelationType.BELOW: RelationType.ABOVE,
|
|
RelationType.LEFT_OF: RelationType.RIGHT_OF,
|
|
RelationType.RIGHT_OF: RelationType.LEFT_OF,
|
|
}
|
|
|
|
# Déterminer la relation A -> B
|
|
if abs(dx) > abs(dy):
|
|
if dx > 0:
|
|
relation_a_to_b = RelationType.LEFT_OF
|
|
else:
|
|
relation_a_to_b = RelationType.RIGHT_OF
|
|
else:
|
|
if dy > 0:
|
|
relation_a_to_b = RelationType.ABOVE
|
|
else:
|
|
relation_a_to_b = RelationType.BELOW
|
|
|
|
# Vérifier la symétrie via l'inverse mathématique
|
|
expected_b_to_a = inverse_relations[relation_a_to_b]
|
|
|
|
# La relation inverse doit être l'opposé
|
|
# dx_inv = -dx, dy_inv = -dy
|
|
dx_inv = -dx
|
|
dy_inv = -dy
|
|
|
|
if abs(dx_inv) > abs(dy_inv):
|
|
if dx_inv > 0:
|
|
relation_b_to_a = RelationType.LEFT_OF
|
|
else:
|
|
relation_b_to_a = RelationType.RIGHT_OF
|
|
else:
|
|
if dy_inv > 0:
|
|
relation_b_to_a = RelationType.ABOVE
|
|
else:
|
|
relation_b_to_a = RelationType.BELOW
|
|
|
|
assert relation_b_to_a == expected_b_to_a, \
|
|
f"Symétrie violée: A->B={relation_a_to_b}, B->A={relation_b_to_a}, attendu={expected_b_to_a}"
|
|
|
|
|
|
# =============================================================================
|
|
# Property 11: Backoff Exponentiel des Retries
|
|
# Feature: rpa-vision-excellence, Property 11: Exponential Retry Backoff
|
|
# Validates: Requirements 7.1
|
|
# =============================================================================
|
|
|
|
@given(
|
|
base_time_ms=st.integers(min_value=100, max_value=1000),
|
|
attempt=st.integers(min_value=1, max_value=5)
|
|
)
|
|
@settings(max_examples=100, deadline=None)
|
|
def test_property_exponential_retry_backoff(base_time_ms, attempt):
|
|
"""
|
|
**Feature: rpa-vision-excellence, Property 11: Exponential Retry Backoff**
|
|
**Validates: Requirements 7.1**
|
|
|
|
Le temps d'attente doit suivre le pattern: base_time * 2^(attempt-1)
|
|
"""
|
|
# Calculer le temps d'attente
|
|
wait_time = base_time_ms * (2 ** (attempt - 1))
|
|
|
|
# Propriétés:
|
|
# 1. Le temps doit être >= au temps de base
|
|
assert wait_time >= base_time_ms, \
|
|
f"Temps d'attente {wait_time} < base {base_time_ms}"
|
|
|
|
# 2. Le temps doit doubler à chaque tentative
|
|
if attempt > 1:
|
|
previous_wait = base_time_ms * (2 ** (attempt - 2))
|
|
assert wait_time == 2 * previous_wait, \
|
|
f"Temps {wait_time} != 2 * précédent {previous_wait}"
|
|
|
|
# 3. Le temps doit être exactement base * 2^(n-1)
|
|
expected = base_time_ms * (2 ** (attempt - 1))
|
|
assert wait_time == expected, \
|
|
f"Temps {wait_time} != attendu {expected}"
|
|
|
|
|
|
# =============================================================================
|
|
# Tests d'intégration property-based
|
|
# =============================================================================
|
|
|
|
@given(
|
|
num_elements=st.integers(min_value=2, max_value=10),
|
|
seed=st.integers(min_value=0, max_value=1000)
|
|
)
|
|
@settings(max_examples=20, deadline=None)
|
|
def test_property_variant_selection_best(num_elements, seed):
|
|
"""
|
|
**Feature: rpa-vision-excellence, Property 9: Best Variant Selection**
|
|
**Validates: Requirements 4.3**
|
|
|
|
La sélection de variante doit toujours retourner celle avec la plus haute similarité.
|
|
"""
|
|
np.random.seed(seed)
|
|
|
|
# Générer des similarités aléatoires
|
|
similarities = np.random.uniform(0.5, 1.0, num_elements)
|
|
|
|
# Trouver le maximum
|
|
best_idx = np.argmax(similarities)
|
|
best_similarity = similarities[best_idx]
|
|
|
|
# Propriété: le meilleur doit avoir la plus haute similarité
|
|
for i, sim in enumerate(similarities):
|
|
assert sim <= best_similarity, \
|
|
f"Variante {i} avec similarité {sim} > meilleure {best_similarity}"
|
|
|
|
|
|
if __name__ == "__main__":
|
|
pytest.main([__file__, "-v", "--tb=short"])
|