Files
rpa_vision_v3/tests/property/test_visual_embedding_manager_properties.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

445 lines
19 KiB
Python

#!/usr/bin/env python3
"""
Tests de Propriété pour VisualEmbeddingManager - RPA Vision V3
Tests basés sur les propriétés pour valider le comportement universel
du gestionnaire d'embeddings visuels.
Feature: visual-rpa-properties-enhancement
Property 7: Génération de Signatures Visuelles Uniques
Auteur: Assistant IA
Date: 2026-01-07
"""
import pytest
import numpy as np
from hypothesis import given, strategies as st, settings, assume
from PIL import Image
import asyncio
import tempfile
import os
from unittest.mock import Mock, AsyncMock
from core.visual.visual_embedding_manager import (
VisualEmbeddingManager,
EmbeddingCacheEntry,
MatchResult,
SimilarityMetrics
)
from core.embedding.fusion_engine import FusionEngine
from core.models import BBox
# Configuration Hypothesis
MAX_EXAMPLES = 100
DEADLINE = 30000 # 30 secondes
class TestVisualEmbeddingManagerProperties:
"""Tests de propriété pour VisualEmbeddingManager"""
@pytest.fixture
def mock_fusion_engine(self):
"""Mock du FusionEngine pour les tests"""
engine = Mock(spec=FusionEngine)
engine.generate_embedding = AsyncMock()
return engine
@pytest.fixture
def embedding_manager(self, mock_fusion_engine):
"""Instance de VisualEmbeddingManager pour les tests"""
return VisualEmbeddingManager(
fusion_engine=mock_fusion_engine,
cache_size=100
)
@pytest.fixture
def sample_image(self):
"""Crée une image de test"""
image = Image.new('RGB', (100, 100), color='white')
return image
@pytest.fixture
def sample_embedding(self):
"""Crée un embedding de test normalisé"""
embedding = np.random.randn(256).astype(np.float32)
# Normaliser l'embedding
norm = np.linalg.norm(embedding)
if norm > 0:
embedding = embedding / norm
return embedding
@given(
width=st.integers(min_value=10, max_value=500),
height=st.integers(min_value=10, max_value=500),
color_r=st.integers(min_value=0, max_value=255),
color_g=st.integers(min_value=0, max_value=255),
color_b=st.integers(min_value=0, max_value=255)
)
@settings(max_examples=MAX_EXAMPLES, deadline=DEADLINE)
def test_property_embedding_generation_consistency(
self, embedding_manager, mock_fusion_engine,
width, height, color_r, color_g, color_b
):
"""
Propriété 7: Génération de Signatures Visuelles Uniques
Pour toute image donnée, la génération d'embedding doit être:
1. Déterministe (même image = même embedding)
2. Normalisée (norme L2 = 1.0)
3. Cohérente avec le cache
"""
# Créer une image avec les paramètres donnés
image = Image.new('RGB', (width, height), color=(color_r, color_g, color_b))
# Mock du FusionEngine pour retourner un embedding déterministe
expected_embedding = np.random.randn(256).astype(np.float32)
expected_embedding = expected_embedding / np.linalg.norm(expected_embedding)
mock_fusion_engine.generate_embedding.return_value = expected_embedding
async def test_consistency():
# Première génération
embedding1 = await embedding_manager.generate_embedding(image, use_cache=True)
# Deuxième génération (devrait utiliser le cache)
embedding2 = await embedding_manager.generate_embedding(image, use_cache=True)
# Propriété 1: Déterminisme
np.testing.assert_array_equal(embedding1, embedding2)
# Propriété 2: Normalisation
norm1 = np.linalg.norm(embedding1)
norm2 = np.linalg.norm(embedding2)
assert abs(norm1 - 1.0) < 1e-6, f"Embedding non normalisé: norme = {norm1}"
assert abs(norm2 - 1.0) < 1e-6, f"Embedding non normalisé: norme = {norm2}"
# Propriété 3: Cohérence du cache
# La deuxième génération ne devrait pas appeler le FusionEngine
assert mock_fusion_engine.generate_embedding.call_count == 1
# Vérifier que l'embedding est en cache
signature = embedding_manager._generate_image_signature(image)
cached_embedding = embedding_manager.get_cached_embedding(signature)
assert cached_embedding is not None
np.testing.assert_array_equal(cached_embedding, embedding1)
# Exécuter le test asynchrone
asyncio.run(test_consistency())
@given(
embedding_dim=st.integers(min_value=64, max_value=512),
num_embeddings=st.integers(min_value=2, max_value=10)
)
@settings(max_examples=MAX_EXAMPLES, deadline=DEADLINE)
def test_property_similarity_calculation_properties(
self, embedding_manager, embedding_dim, num_embeddings
):
"""
Propriété: Calcul de Similarité Cohérent
Pour tout ensemble d'embeddings:
1. Similarité avec soi-même = 1.0
2. Similarité symétrique: sim(A,B) = sim(B,A)
3. Similarité dans [0,1]
4. Triangle inequality respectée (approximativement)
"""
# Créer des embeddings aléatoires normalisés
embeddings = []
for _ in range(num_embeddings):
emb = np.random.randn(embedding_dim).astype(np.float32)
emb = emb / np.linalg.norm(emb)
embeddings.append(emb)
async def test_similarity_properties():
for i, emb_a in enumerate(embeddings):
# Propriété 1: Similarité avec soi-même
self_sim = await embedding_manager.compare_embeddings(emb_a, emb_a)
assert abs(self_sim - 1.0) < 1e-6, f"Similarité avec soi-même != 1.0: {self_sim}"
for j, emb_b in enumerate(embeddings):
if i != j:
# Propriété 2: Symétrie
sim_ab = await embedding_manager.compare_embeddings(emb_a, emb_b)
sim_ba = await embedding_manager.compare_embeddings(emb_b, emb_a)
assert abs(sim_ab - sim_ba) < 1e-6, f"Similarité non symétrique: {sim_ab} != {sim_ba}"
# Propriété 3: Bornes [0,1]
assert 0.0 <= sim_ab <= 1.0, f"Similarité hors bornes: {sim_ab}"
# Propriété 4: Triangle inequality (approximative pour similarité cosinus)
for k, emb_c in enumerate(embeddings):
if k != i and k != j:
sim_ac = await embedding_manager.compare_embeddings(emb_a, emb_c)
sim_bc = await embedding_manager.compare_embeddings(emb_b, emb_c)
# Pour la similarité cosinus, on vérifie une inégalité relaxée
# car la métrique de distance cosinus ne respecte pas strictement
# l'inégalité triangulaire
distance_ab = 1 - sim_ab
distance_ac = 1 - sim_ac
distance_bc = 1 - sim_bc
# Vérification relaxée (tolérance pour les erreurs numériques)
tolerance = 0.1
assert distance_ab <= distance_ac + distance_bc + tolerance
asyncio.run(test_similarity_properties())
@given(
num_candidates=st.integers(min_value=1, max_value=20),
min_confidence=st.floats(min_value=0.1, max_value=0.9)
)
@settings(max_examples=MAX_EXAMPLES, deadline=DEADLINE)
def test_property_best_match_selection(
self, embedding_manager, sample_embedding, num_candidates, min_confidence
):
"""
Propriété: Sélection de la Meilleure Correspondance
Pour toute recherche de correspondance:
1. Si aucun candidat >= min_confidence, retourner None
2. Si candidats valides, retourner celui avec la plus haute confiance
3. Le résultat doit avoir une confiance >= min_confidence
4. Les alternatives doivent être triées par confiance décroissante
"""
# Créer des candidats avec des similarités variées
candidates = []
expected_confidences = []
for i in range(num_candidates):
# Créer un embedding candidat
candidate_emb = np.random.randn(256).astype(np.float32)
candidate_emb = candidate_emb / np.linalg.norm(candidate_emb)
# Calculer une similarité déterministe basée sur l'index
# pour avoir un contrôle sur les résultats
confidence = 0.5 + (i / num_candidates) * 0.5 # Entre 0.5 et 1.0
expected_confidences.append(confidence)
signature = f"candidate_{i}"
candidates.append((signature, candidate_emb))
# Mock de la méthode compare_embeddings pour retourner des valeurs contrôlées
original_compare = embedding_manager.compare_embeddings
async def mock_compare(emb1, emb2):
# Trouver l'index du candidat basé sur l'embedding
for i, (_, candidate_emb) in enumerate(candidates):
if np.array_equal(emb2, candidate_emb):
return expected_confidences[i]
return 0.5 # Valeur par défaut
embedding_manager.compare_embeddings = mock_compare
try:
async def test_best_match():
result = await embedding_manager.find_best_match(
sample_embedding, candidates, min_confidence
)
# Trouver les candidats valides
valid_candidates = [
(sig, conf) for (sig, _), conf in zip(candidates, expected_confidences)
if conf >= min_confidence
]
if not valid_candidates:
# Propriété 1: Aucun candidat valide
assert result is None, "Devrait retourner None si aucun candidat valide"
else:
# Propriété 2: Retourner le meilleur candidat
assert result is not None, "Devrait retourner un résultat si candidats valides"
# Propriété 3: Confiance >= min_confidence
assert result.confidence >= min_confidence, \
f"Confiance {result.confidence} < min_confidence {min_confidence}"
# Propriété 4: Meilleure confiance
max_confidence = max(conf for _, conf in valid_candidates)
assert abs(result.confidence - max_confidence) < 1e-6, \
f"Pas la meilleure confiance: {result.confidence} != {max_confidence}"
asyncio.run(test_best_match())
finally:
# Restaurer la méthode originale
embedding_manager.compare_embeddings = original_compare
@given(
cache_size=st.integers(min_value=1, max_value=50),
num_operations=st.integers(min_value=1, max_value=100)
)
@settings(max_examples=MAX_EXAMPLES, deadline=DEADLINE)
def test_property_cache_behavior(
self, mock_fusion_engine, cache_size, num_operations
):
"""
Propriété: Comportement du Cache
Pour toute séquence d'opérations de cache:
1. Le cache ne dépasse jamais la taille maximale
2. Les éléments récemment accédés sont préservés (LRU)
3. Les statistiques de cache sont cohérentes
4. La récupération d'un élément en cache ne génère pas d'embedding
"""
manager = VisualEmbeddingManager(
fusion_engine=mock_fusion_engine,
cache_size=cache_size
)
# Créer des embeddings de test
test_embeddings = {}
for i in range(num_operations):
emb = np.random.randn(256).astype(np.float32)
emb = emb / np.linalg.norm(emb)
test_embeddings[f"sig_{i}"] = emb
# Simuler des opérations de cache
for i, (signature, embedding) in enumerate(test_embeddings.items()):
manager.cache_embedding(signature, embedding)
# Propriété 1: Taille du cache
stats = manager.get_cache_stats()
assert stats['cache_size'] <= cache_size, \
f"Cache dépasse la taille max: {stats['cache_size']} > {cache_size}"
# Vérifier que l'élément est bien en cache
cached = manager.get_cached_embedding(signature)
if stats['cache_size'] < cache_size or i < cache_size:
# Si le cache n'est pas plein ou si c'est un des premiers éléments
assert cached is not None, f"Élément {signature} devrait être en cache"
np.testing.assert_array_equal(cached, embedding)
# Propriété 3: Cohérence des statistiques
final_stats = manager.get_cache_stats()
assert final_stats['cache_size'] >= 0
assert final_stats['cache_size'] <= cache_size
assert final_stats['total_generations'] >= 0
@given(
batch_size=st.integers(min_value=1, max_value=20),
use_cache=st.booleans()
)
@settings(max_examples=MAX_EXAMPLES, deadline=DEADLINE)
def test_property_batch_processing_consistency(
self, embedding_manager, mock_fusion_engine, batch_size, use_cache
):
"""
Propriété: Cohérence du Traitement par Lots
Pour tout traitement par lots:
1. Le nombre de résultats = nombre d'entrées valides
2. Chaque résultat correspond à son entrée
3. Les embeddings sont normalisés
4. Le cache est utilisé de manière cohérente
"""
# Créer des images de test
images = []
for i in range(batch_size):
size = 50 + i * 10 # Tailles variées
color = (i * 30 % 256, (i * 50) % 256, (i * 70) % 256)
image = Image.new('RGB', (size, size), color=color)
signature = f"batch_image_{i}"
images.append((signature, image, None)) # (signature, image, bounding_box)
# Mock pour retourner des embeddings déterministes
def mock_generate_embedding(image, bounding_box=None):
# Créer un embedding basé sur les propriétés de l'image
width, height = image.size
seed = width * height
np.random.seed(seed % 2**32)
emb = np.random.randn(256).astype(np.float32)
emb = emb / np.linalg.norm(emb)
return emb
mock_fusion_engine.generate_embedding.side_effect = mock_generate_embedding
async def test_batch_consistency():
results = await embedding_manager.batch_generate_embeddings(
images, use_cache=use_cache
)
# Propriété 1: Nombre de résultats
assert len(results) <= len(images), \
f"Trop de résultats: {len(results)} > {len(images)}"
# Propriété 2: Correspondance des résultats
for signature, image, _ in images:
if signature in results:
embedding = results[signature]
# Propriété 3: Normalisation
norm = np.linalg.norm(embedding)
assert abs(norm - 1.0) < 1e-6, \
f"Embedding non normalisé pour {signature}: norme = {norm}"
# Vérifier la cohérence avec la génération individuelle
expected_emb = mock_generate_embedding(image)
np.testing.assert_array_almost_equal(
embedding, expected_emb, decimal=6,
err_msg=f"Embedding incohérent pour {signature}"
)
asyncio.run(test_batch_consistency())
def test_property_detailed_similarity_metrics(self, embedding_manager, sample_embedding):
"""
Propriété: Métriques de Similarité Détaillées
Pour toute paire d'embeddings:
1. Les métriques sont dans les bonnes plages
2. Le score combiné est cohérent avec les métriques individuelles
3. Les métriques sont stables (pas de NaN/Inf)
"""
# Créer un deuxième embedding
embedding2 = np.random.randn(256).astype(np.float32)
embedding2 = embedding2 / np.linalg.norm(embedding2)
async def test_detailed_metrics():
metrics = await embedding_manager.compare_embeddings(
sample_embedding, embedding2, detailed_metrics=True
)
assert isinstance(metrics, SimilarityMetrics)
# Propriété 1: Plages des métriques
assert 0.0 <= metrics.cosine_similarity <= 1.0, \
f"Similarité cosinus hors plage: {metrics.cosine_similarity}"
assert metrics.euclidean_distance >= 0.0, \
f"Distance euclidienne négative: {metrics.euclidean_distance}"
assert 0.0 <= metrics.normalized_correlation <= 1.0, \
f"Corrélation normalisée hors plage: {metrics.normalized_correlation}"
assert 0.0 <= metrics.combined_score <= 1.0, \
f"Score combiné hors plage: {metrics.combined_score}"
# Propriété 2: Stabilité (pas de NaN/Inf)
assert not np.isnan(metrics.cosine_similarity)
assert not np.isnan(metrics.euclidean_distance)
assert not np.isnan(metrics.normalized_correlation)
assert not np.isnan(metrics.combined_score)
assert not np.isinf(metrics.cosine_similarity)
assert not np.isinf(metrics.euclidean_distance)
assert not np.isinf(metrics.normalized_correlation)
assert not np.isinf(metrics.combined_score)
# Propriété 3: Cohérence du score combiné
# Le score combiné devrait être influencé par les métriques individuelles
expected_range_min = min(
metrics.cosine_similarity,
metrics.normalized_correlation
) * 0.5
expected_range_max = max(
metrics.cosine_similarity,
metrics.normalized_correlation
)
assert expected_range_min <= metrics.combined_score <= expected_range_max, \
f"Score combiné incohérent: {metrics.combined_score} pas dans [{expected_range_min}, {expected_range_max}]"
asyncio.run(test_detailed_metrics())
if __name__ == "__main__":
pytest.main([__file__, "-v", "--tb=short"])