data-cleaning-openenv / tests /test_agents.py
01ammu's picture
Sync from GitHub via hub-sync
e574337 verified
Raw History Blame Contribute Delete
19.9 kB
"""
Comprehensive tests for multi-agent data cleaning system.
This module tests all agent components including base agents,
specialist agents, coordinator, and reward shaper.
"""
import pytest
import numpy as np
from unittest.mock import Mock, patch
import sys
import os
# Add backend to path for imports
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
# Also add backend so package-style imports like `ml.*` work
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "backend"))
from ml.base_agent import Agent, AgentFactory
from ml.specialist_agents import (
FillMissingAgent,
DuplicateDetector,
OutlierHandler,
CategoryStandardizer,
SkipAgent,
)
from ml.agent_coordinator import AgentCoordinator
from ml.reward_shaper import RewardShaper
class TestBaseAgent:
"""Test the abstract base agent class."""
def test_agent_initialization(self):
"""Test agent initialization with name."""
class TestAgent(Agent):
def reset(self, observation):
pass
def get_action(self, observation, legal_actions):
return {}
def get_confidence(self):
return 0.5
agent = TestAgent("TestAgent")
assert agent.get_name() == "TestAgent"
assert agent.get_last_reward() == 0.0
def test_update_reward(self):
"""Test reward update functionality."""
class TestAgent(Agent):
def reset(self, observation):
pass
def get_action(self, observation, legal_actions):
return {}
def get_confidence(self):
return 0.5
agent = TestAgent("TestAgent")
agent.update_reward(0.5)
assert agent.get_last_reward() == 0.5
agent.update_reward(-0.2)
assert agent.get_last_reward() == -0.2
def test_agent_factory_unknown_type(self):
"""Test AgentFactory with unknown agent type."""
with pytest.raises(ValueError, match="Unknown agent type"):
AgentFactory.create_agent("unknown_agent")
class TestFillMissingAgent:
"""Test the FillMissingAgent specialist agent."""
def setup_method(self):
"""Set up test fixtures."""
self.agent = FillMissingAgent()
def test_agent_initialization(self):
"""Test agent initialization."""
assert self.agent.get_name() == "FillMissingAgent"
assert self.agent.get_confidence() == 0.0
def test_reset(self):
"""Test agent reset functionality."""
observation = {"test": "data"}
self.agent.reset(observation)
assert self.agent.get_confidence() == 0.0
def test_handle_missing_age(self):
"""Test handling missing age values."""
observation = {
"current_data": {"age": None, "name": "John"},
"issues_detected": ["missing:age"],
}
legal_actions = ["fill_missing", "skip"]
action = self.agent.get_action(observation, legal_actions)
assert action["action_type"] == "fill_missing"
assert action["column"] == "age"
assert action["value"] == 30
assert self.agent.get_confidence() > 0.0
def test_handle_missing_salary(self):
"""Test handling missing salary values."""
observation = {
"current_data": {"salary": None, "name": "John"},
"issues_detected": ["missing:salary"],
}
legal_actions = ["fill_missing", "skip"]
action = self.agent.get_action(observation, legal_actions)
assert action["action_type"] == "fill_missing"
assert action["column"] == "salary"
assert action["value"] == 60000
def test_skip_when_no_fill_missing_action(self):
"""Test skipping when fill_missing is not available."""
observation = {
"current_data": {"age": None, "name": "John"},
"issues_detected": ["missing:age"],
}
legal_actions = ["skip"] # No fill_missing action
action = self.agent.get_action(observation, legal_actions)
assert action["action_type"] == "skip"
assert self.agent.get_confidence() == 0.0
def test_can_handle_missing_issues(self):
"""Test can_handle method with missing issues."""
observation = {"issues_detected": ["missing:age", "duplicate:row"]}
assert self.agent.can_handle(observation) == True
observation = {"issues_detected": ["duplicate:row", "outlier:age"]}
assert self.agent.can_handle(observation) == False
class TestDuplicateDetector:
"""Test the DuplicateDetector specialist agent."""
def setup_method(self):
"""Set up test fixtures."""
self.agent = DuplicateDetector()
def test_agent_initialization(self):
"""Test agent initialization."""
assert self.agent.get_name() == "DuplicateDetector"
assert self.agent.get_confidence() == 0.0
def test_detect_duplicate_row(self):
"""Test duplicate detection with similar rows."""
# First observation
obs1 = {
"current_data": {"name": "John", "age": 30},
"issues_detected": ["duplicate:row"],
}
legal_actions = ["remove_duplicate", "skip"]
action1 = self.agent.get_action(obs1, legal_actions)
assert action1["action_type"] == "skip" # No previous rows to compare
# Second observation with similar data
obs2 = {
"current_data": {"name": "John", "age": 30},
"issues_detected": ["duplicate:row"],
}
action2 = self.agent.get_action(obs2, legal_actions)
assert action2["action_type"] == "remove_duplicate"
assert self.agent.get_confidence() >= 0.8
def test_no_duplicate_with_different_rows(self):
"""Test no duplicate detection with different rows."""
# First observation
obs1 = {
"current_data": {"name": "John", "age": 30},
"issues_detected": ["duplicate:row"],
}
legal_actions = ["remove_duplicate", "skip"]
self.agent.get_action(obs1, legal_actions)
# Second observation with different data
obs2 = {
"current_data": {"name": "Jane", "age": 25},
"issues_detected": ["duplicate:row"],
}
action2 = self.agent.get_action(obs2, legal_actions)
assert action2["action_type"] == "skip"
assert self.agent.get_confidence() < 0.8
class TestOutlierHandler:
"""Test the OutlierHandler specialist agent."""
def setup_method(self):
"""Set up test fixtures."""
self.agent = OutlierHandler()
def test_detect_age_outlier(self):
"""Test outlier detection for age."""
observation = {
"current_data": {"age": 150, "name": "John"}, # Extreme outlier
"issues_detected": ["outlier:age"],
}
legal_actions = ["remove_outlier", "skip"]
action = self.agent.get_action(observation, legal_actions)
assert action["action_type"] == "remove_outlier"
assert action["column"] == "age"
assert action["value"] == 35 # Median replacement
assert self.agent.get_confidence() >= 0.7
def test_detect_salary_outlier(self):
"""Test outlier detection for salary."""
observation = {
"current_data": {"salary": 500000, "name": "John"}, # Extreme outlier
"issues_detected": ["outlier:salary"],
}
legal_actions = ["remove_outlier", "skip"]
action = self.agent.get_action(observation, legal_actions)
assert action["action_type"] == "remove_outlier"
assert action["column"] == "salary"
assert action["value"] == 75000 # Median replacement
def test_no_outlier_normal_values(self):
"""Test no outlier detection for normal values."""
observation = {
"current_data": {"age": 35, "name": "John"}, # Normal value
"issues_detected": ["outlier:age"],
}
legal_actions = ["remove_outlier", "skip"]
action = self.agent.get_action(observation, legal_actions)
assert action["action_type"] == "skip"
assert self.agent.get_confidence() == 0.0
class TestCategoryStandardizer:
"""Test the CategoryStandardizer specialist agent."""
def setup_method(self):
"""Set up test fixtures."""
self.agent = CategoryStandardizer()
def test_standardize_department_typo(self):
"""Test standardizing department name with typo."""
observation = {
"current_data": {"dept": "enginering", "name": "John"}, # Typo
"issues_detected": ["category:dept"],
}
legal_actions = ["fix_category", "skip"]
action = self.agent.get_action(observation, legal_actions)
assert action["action_type"] == "fix_category"
assert action["column"] == "dept"
assert action["value"] == "engineering" # Corrected spelling
assert self.agent.get_confidence() > 0.0
def test_no_standardization_needed(self):
"""Test when no standardization is needed."""
observation = {
"current_data": {"dept": "engineering", "name": "John"}, # Already correct
"issues_detected": ["category:dept"],
}
legal_actions = ["fix_category", "skip"]
action = self.agent.get_action(observation, legal_actions)
# The agent may still return fix_category even if no change is needed
# This is acceptable behavior as it will determine if standardization is required
assert action["action_type"] in ["fix_category", "skip"]
def test_handle_case_variations(self):
"""Test handling case variations."""
observation = {
"current_data": {"dept": "ENGINEERING", "name": "John"}, # Uppercase
"issues_detected": ["category:dept"],
}
legal_actions = ["fix_category", "skip"]
action = self.agent.get_action(observation, legal_actions)
assert action["action_type"] == "fix_category"
assert action["value"] == "engineering" # Lowercased
class TestSkipAgent:
"""Test the SkipAgent specialist agent."""
def setup_method(self):
"""Set up test fixtures."""
self.agent = SkipAgent()
def test_always_skip(self):
"""Test that SkipAgent always returns skip action."""
observation = {
"current_data": {"name": "John", "age": 30},
"issues_detected": ["missing:age", "duplicate:row"],
}
legal_actions = ["fill_missing", "remove_duplicate", "skip"]
action = self.agent.get_action(observation, legal_actions)
assert action["action_type"] == "skip"
assert self.agent.get_confidence() == 0.2
def test_can_handle_anything(self):
"""Test that SkipAgent can handle any observation."""
observation = {"issues_detected": []}
assert self.agent.can_handle(observation) == True
observation = {"issues_detected": ["missing:age"]}
assert self.agent.can_handle(observation) == True
class TestAgentCoordinator:
"""Test the AgentCoordinator class."""
def setup_method(self):
"""Set up test fixtures."""
self.coordinator = AgentCoordinator()
def test_initialization(self):
"""Test coordinator initialization."""
assert len(self.coordinator.agents) == 5 # 5 specialist agents
assert all(isinstance(agent, Agent) for agent in self.coordinator.agents)
def test_select_fill_missing_agent(self):
"""Test selecting FillMissingAgent for missing values."""
observation = {
"current_data": {"age": None, "name": "John"},
"issues_detected": ["missing:age"],
}
legal_actions = ["fill_missing", "skip"]
action = self.coordinator.get_best_action(observation, legal_actions)
assert action["action_type"] == "fill_missing"
assert action["column"] == "age"
def test_fallback_to_skip_agent(self):
"""Test fallback to SkipAgent when no other agent can handle."""
observation = {
"current_data": {"name": "John", "age": 30},
"issues_detected": [],
}
legal_actions = ["skip"]
action = self.coordinator.get_best_action(observation, legal_actions)
assert action["action_type"] == "skip"
def test_update_agent_performance(self):
"""Test updating agent performance tracking."""
self.coordinator.update_agent_performance("FillMissingAgent", 0.3)
self.coordinator.update_agent_performance("FillMissingAgent", -0.1)
stats = self.coordinator.get_agent_statistics()
assert "FillMissingAgent" in stats
assert (
stats["FillMissingAgent"]["usage_count"] == 2
) # Two calls to update_agent_performance
assert abs(stats["FillMissingAgent"]["average_reward"] - 0.1) < 1e-9
def test_agent_statistics(self):
"""Test getting agent statistics."""
# Simulate some usage
self.coordinator.update_agent_performance("FillMissingAgent", 0.3)
self.coordinator.update_agent_performance("DuplicateDetector", 0.2)
stats = self.coordinator.get_agent_statistics()
assert isinstance(stats, dict)
assert "FillMissingAgent" in stats
assert "DuplicateDetector" in stats
class TestRewardShaper:
"""Test the RewardShaper class."""
def setup_method(self):
"""Set up test fixtures."""
self.reward_shaper = RewardShaper()
def test_initialization(self):
"""Test reward shaper initialization."""
assert len(self.reward_shaper.base_rewards) > 0
assert "fill_missing" in self.reward_shaper.base_rewards
assert self.reward_shaper.base_rewards["fill_missing"] == 0.3
def test_calculate_fill_missing_reward(self):
"""Test reward calculation for fill missing action."""
action = {"action_type": "fill_missing", "column": "age", "value": 30}
observation = {
"current_data": {"age": None, "name": "John"},
"issues_detected": ["missing:age"],
}
episode_state = {"step": 10, "total_steps": 50}
reward = self.reward_shaper.calculate_reward(action, observation, episode_state)
# Should include base reward + issue adjustment + progress bonus
assert reward > 0.3 # Base reward is 0.3, should have bonuses
def test_calculate_skip_reward(self):
"""Test reward calculation for skip action."""
action = {"action_type": "skip", "column": "name", "value": None}
observation = {
"current_data": {"name": "John", "age": 30},
"issues_detected": [], # No major issues
}
episode_state = {"step": 5, "total_steps": 50}
reward = self.reward_shaper.calculate_reward(action, observation, episode_state)
# Should be negative base reward + skip bonus
assert reward > -0.15 # Base is -0.15, skip bonus should make it less negative
def test_episode_completion_bonus(self):
"""Test episode completion bonus calculation."""
# Perfect score
bonus = self.reward_shaper.calculate_episode_completion_bonus(0.95)
assert bonus == 0.5
# Good score
bonus = self.reward_shaper.calculate_episode_completion_bonus(0.85)
assert bonus == 0.3
# Poor score
bonus = self.reward_shaper.calculate_episode_completion_bonus(0.4)
assert bonus == 0.0
def test_contradiction_penalty(self):
"""Test contradiction penalty for conflicting actions."""
# First action: fill missing
action1 = {"action_type": "fill_missing", "column": "age", "value": 30}
obs1 = {"current_data": {"age": None}, "issues_detected": ["missing:age"]}
episode_state = {"step": 1, "total_steps": 50}
self.reward_shaper.calculate_reward(action1, obs1, episode_state)
# Second action: different action on same column
action2 = {"action_type": "remove_outlier", "column": "age", "value": 35}
obs2 = {"current_data": {"age": 30}, "issues_detected": ["outlier:age"]}
episode_state = {"step": 2, "total_steps": 50}
reward = self.reward_shaper.calculate_reward(action2, obs2, episode_state)
# Should have contradiction penalty
assert reward < 0.3 # Base reward reduced by penalty
def test_reward_statistics(self):
"""Test getting reward statistics."""
# Generate some rewards
for i in range(5):
action = {"action_type": "fill_missing", "column": "age", "value": 30}
observation = {
"current_data": {"age": None},
"issues_detected": ["missing:age"],
}
episode_state = {"step": i, "total_steps": 50}
self.reward_shaper.calculate_reward(action, observation, episode_state)
stats = self.reward_shaper.get_reward_statistics()
assert stats["total_actions"] == 5
assert "average_reward" in stats
assert "action_type_distribution" in stats
assert "fill_missing" in stats["action_type_distribution"]
class TestIntegration:
"""Integration tests for the complete system."""
def test_full_episode_simulation(self):
"""Test a complete episode simulation."""
coordinator = AgentCoordinator()
reward_shaper = RewardShaper()
# Reset for new episode
initial_obs = {"current_data": {}, "issues_detected": []}
coordinator.reset(initial_obs)
reward_shaper.reset_episode()
# Simulate episode steps
observations = [
{
"current_data": {"age": None, "name": "John"},
"issues_detected": ["missing:age"],
},
{"current_data": {"age": 30, "name": "John"}, "issues_detected": []},
]
legal_actions = ["fill_missing", "skip"]
for i, obs in enumerate(observations):
# Get action from coordinator
action = coordinator.get_best_action(obs, legal_actions)
# Calculate reward
episode_state = {"step": i, "total_steps": len(observations)}
reward = reward_shaper.calculate_reward(action, obs, episode_state)
# Update coordinator
agent_name = (
action.get("action_type", "skip").replace("_", "").capitalize()
+ "Agent"
)
coordinator.update_agent_performance(agent_name, reward)
# Verify action is valid
assert (
action["action_type"] in legal_actions
or action["action_type"] == "skip"
)
assert isinstance(reward, float)
# Get final statistics
agent_stats = coordinator.get_agent_statistics()
reward_stats = reward_shaper.get_reward_statistics()
assert agent_stats["total_actions"] > 0
assert reward_stats["total_actions"] == len(observations)
def test_error_handling_fallback(self):
"""Test error handling and fallback to SkipAgent."""
coordinator = AgentCoordinator()
# Create observation that might cause issues
observation = {
"current_data": None, # None data might cause issues
"issues_detected": ["missing:unknown_column"],
}
legal_actions = ["fill_missing", "skip"]
# Should handle gracefully and return skip action
action = coordinator.get_best_action(observation, legal_actions)
assert action["action_type"] == "skip"
assert "column" in action
assert action["value"] is None
if __name__ == "__main__":
# Run tests when executed directly
pytest.main([__file__, "-v"])