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