Files
rpa_vision_v3/tests/property/test_property_based.py
Dom a27b74cf22 v1.0 - Version stable: multi-PC, détection UI-DETR-1, 3 modes exécution
- 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>
2026-01-29 11:23:51 +01:00

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"])