# Matching Component Design - v0.1.0 **Version**: 0.1.0 **Date**: 2025-12-22 **Status**: Initial Design ## Introduction This document defines the Secret Santa matching algorithm for Sneaky Klaus. The matching algorithm is responsible for assigning each participant a recipient while respecting exclusion rules and following Secret Santa best practices. ## Requirements ### Functional Requirements 1. **One-to-One Assignment**: Each participant gives exactly one gift and receives exactly one gift 2. **No Self-Matching**: No participant is assigned to themselves 3. **Exclusion Compliance**: All exclusion rules must be honored 4. **Randomization**: Assignments must be unpredictable and fair 5. **Single Cycle Preferred**: When possible, create a single cycle (A→B→C→...→Z→A) 6. **Validation**: Detect impossible matching scenarios before attempting ### Non-Functional Requirements 1. **Performance**: Complete matching in <1 second for up to 100 participants 2. **Reliability**: Deterministic failure detection (no random timeouts) 3. **Transparency**: Clear error messages when matching fails 4. **Testability**: Algorithm must be unit-testable with reproducible results ## Algorithm Overview The matching algorithm uses a **graph-based approach with randomized cycle generation**. ### High-Level Flow ```mermaid flowchart TD Start([Trigger Matching]) --> Validate[Validate Preconditions] Validate -->|Invalid| Error1[Return Error: Validation Failed] Validate -->|Valid| BuildGraph[Build Assignment Graph] BuildGraph --> CheckFeasibility[Check Matching Feasibility] CheckFeasibility -->|Impossible| Error2[Return Error: Impossible to Match] CheckFeasibility -->|Possible| Attempt[Attempt to Find Valid Cycle] Attempt --> MaxAttempts{Max attempts
reached?} MaxAttempts -->|Yes| Error3[Return Error: Could Not Find Match] MaxAttempts -->|No| GenerateCycle[Generate Random Cycle] GenerateCycle --> ValidateCycle{Cycle valid?} ValidateCycle -->|No| MaxAttempts ValidateCycle -->|Yes| CreateMatches[Create Match Records] CreateMatches --> SendNotifications[Send Notifications] SendNotifications --> Success([Matching Complete]) Error1 --> End([End]) Error2 --> End Error3 --> End Success --> End ``` ## Precondition Validation Before attempting matching, validate the following: ### Validation Checks ```python def validate_matching_preconditions(exchange_id: int) -> ValidationResult: """ Validate that exchange is ready for matching. Returns: ValidationResult with is_valid and error_message """ checks = [ check_exchange_state(exchange_id), check_minimum_participants(exchange_id), check_no_withdrawn_participants(exchange_id), check_graph_connectivity(exchange_id) ] for check in checks: if not check.is_valid: return check return ValidationResult(is_valid=True) ``` ### Check 1: Exchange State **Rule**: Exchange must be in "registration_closed" state **Error Message**: "Exchange is not ready for matching. Please close registration first." **Implementation**: ```python def check_exchange_state(exchange_id: int) -> ValidationResult: exchange = Exchange.query.get(exchange_id) if exchange.state != ExchangeState.REGISTRATION_CLOSED: return ValidationResult( is_valid=False, error_message="Exchange is not ready for matching. Please close registration first." ) return ValidationResult(is_valid=True) ``` ### Check 2: Minimum Participants **Rule**: At least 3 non-withdrawn participants required **Error Message**: "At least 3 participants are required for matching. Current count: {count}" **Implementation**: ```python def check_minimum_participants(exchange_id: int) -> ValidationResult: participants = Participant.query.filter_by( exchange_id=exchange_id, withdrawn_at=None ).all() if len(participants) < 3: return ValidationResult( is_valid=False, error_message=f"At least 3 participants are required for matching. Current count: {len(participants)}" ) return ValidationResult(is_valid=True) ``` ### Check 3: Graph Connectivity **Rule**: Exclusion rules must not make matching impossible **Error Message**: Specific message based on connectivity issue **Implementation**: ```python def check_graph_connectivity(exchange_id: int) -> ValidationResult: """ Check if a valid Hamiltonian cycle is theoretically possible. This doesn't guarantee a cycle exists, but rules out obvious impossibilities. """ participants = get_active_participants(exchange_id) exclusions = get_exclusions(exchange_id) # Build graph where edge (A, B) exists if A can give to B graph = build_assignment_graph(participants, exclusions) # Check 1: Each participant must have at least one possible recipient for participant in participants: possible_recipients = graph.get_outgoing_edges(participant.id) if len(possible_recipients) == 0: return ValidationResult( is_valid=False, error_message=f"Participant '{participant.name}' has no valid recipients. Please adjust exclusion rules." ) # Check 2: Each participant must be a possible recipient for at least one other for participant in participants: possible_givers = graph.get_incoming_edges(participant.id) if len(possible_givers) == 0: return ValidationResult( is_valid=False, error_message=f"Participant '{participant.name}' cannot receive from anyone. Please adjust exclusion rules." ) # Check 3: Detect common impossible scenarios # Example: In a group of 3, if A excludes B, B excludes C, C excludes A, # no valid cycle exists if not has_potential_hamiltonian_cycle(graph): return ValidationResult( is_valid=False, error_message="The current exclusion rules make a valid assignment impossible. Please reduce the number of exclusions." ) return ValidationResult(is_valid=True) ``` ## Assignment Graph Construction ### Graph Representation Build a directed graph where: - **Nodes**: Participants - **Edges**: Valid assignments (A→B means A can give to B) **Edge exists if**: - A ≠ B (no self-matching) - No exclusion rule prevents A→B ### Implementation ```python class AssignmentGraph: """Directed graph representing valid gift assignments.""" def __init__(self, participants: list[Participant], exclusions: list[ExclusionRule]): self.nodes = {p.id: p for p in participants} self.edges = {} # {giver_id: [receiver_id, ...]} self._build_graph(participants, exclusions) def _build_graph(self, participants, exclusions): """Build adjacency list with exclusion rules applied.""" # Build exclusion lookup (bidirectional) excluded_pairs = set() for exclusion in exclusions: excluded_pairs.add((exclusion.participant_a_id, exclusion.participant_b_id)) excluded_pairs.add((exclusion.participant_b_id, exclusion.participant_a_id)) # Build edges for giver in participants: self.edges[giver.id] = [] for receiver in participants: # Can assign if: not self and not excluded if giver.id != receiver.id and (giver.id, receiver.id) not in excluded_pairs: self.edges[giver.id].append(receiver.id) def get_outgoing_edges(self, node_id: int) -> list[int]: """Get all possible recipients for a giver.""" return self.edges.get(node_id, []) def get_incoming_edges(self, node_id: int) -> list[int]: """Get all possible givers for a receiver.""" incoming = [] for giver_id, receivers in self.edges.items(): if node_id in receivers: incoming.append(giver_id) return incoming ``` ## Cycle Generation Algorithm ### Strategy: Randomized Hamiltonian Cycle Search Goal: Find a Hamiltonian cycle (visits each node exactly once) in the assignment graph. **Why Single Cycle?** - Ensures everyone gives and receives exactly once - Prevents orphaned participants or small isolated loops - Traditional Secret Santa structure ### Algorithm: Randomized Backtracking with Early Termination ```python def generate_random_cycle(graph: AssignmentGraph, max_attempts: int = 100) -> Optional[list[tuple[int, int]]]: """ Attempt to find a valid Hamiltonian cycle. Args: graph: Assignment graph with nodes and edges max_attempts: Maximum number of randomized attempts Returns: List of (giver_id, receiver_id) tuples representing the cycle, or None if no cycle found within max_attempts """ nodes = list(graph.nodes.keys()) for attempt in range(max_attempts): # Randomize starting point and node order for variety random.shuffle(nodes) start_node = nodes[0] cycle = _backtrack_cycle(graph, start_node, nodes, [], set()) if cycle is not None: return cycle return None def _backtrack_cycle( graph: AssignmentGraph, current_node: int, all_nodes: list[int], path: list[int], visited: set[int] ) -> Optional[list[tuple[int, int]]]: """ Recursive backtracking to find Hamiltonian cycle. Args: current_node: Current node being processed all_nodes: All nodes in graph path: Current path taken visited: Set of visited nodes Returns: List of edges forming cycle, or None if no cycle from this path """ # Add current node to path path.append(current_node) visited.add(current_node) # Base case: All nodes visited if len(visited) == len(all_nodes): # Check if we can return to start (complete the cycle) start_node = path[0] if start_node in graph.get_outgoing_edges(current_node): # Success! Build edge list edges = [] for i in range(len(path)): giver = path[i] receiver = path[(i + 1) % len(path)] # Wrap around for cycle edges.append((giver, receiver)) return edges else: # Can't complete cycle, backtrack path.pop() visited.remove(current_node) return None # Recursive case: Try each unvisited neighbor neighbors = graph.get_outgoing_edges(current_node) random.shuffle(neighbors) # Randomize for variety for neighbor in neighbors: if neighbor not in visited: result = _backtrack_cycle(graph, neighbor, all_nodes, path, visited) if result is not None: return result # No valid path found, backtrack path.pop() visited.remove(current_node) return None ``` ### Algorithm Complexity **Time Complexity**: - Worst case: O(n!) for Hamiltonian cycle problem (NP-complete) - In practice: O(n²) to O(n³) for typical Secret Santa scenarios - Max attempts limit prevents excessive computation **Space Complexity**: O(n) for recursion stack and visited set ### Why Randomization? 1. **Fairness**: Each valid assignment has equal probability 2. **Unpredictability**: Prevents gaming the system 3. **Variety**: Re-matching produces different results ## Validation & Error Handling ### Cycle Validation After generating a cycle, validate it before creating database records: ```python def validate_cycle(cycle: list[tuple[int, int]], graph: AssignmentGraph) -> ValidationResult: """ Validate that cycle is valid. Checks: 1. Each node appears exactly once as giver 2. Each node appears exactly once as receiver 3. All edges exist in graph (no exclusions violated) 4. No self-assignments """ givers = set() receivers = set() for giver_id, receiver_id in cycle: # Check for duplicates if giver_id in givers: return ValidationResult(is_valid=False, error_message=f"Duplicate giver: {giver_id}") if receiver_id in receivers: return ValidationResult(is_valid=False, error_message=f"Duplicate receiver: {receiver_id}") givers.add(giver_id) receivers.add(receiver_id) # Check no self-assignment if giver_id == receiver_id: return ValidationResult(is_valid=False, error_message="Self-assignment detected") # Check edge exists (no exclusion violated) if receiver_id not in graph.get_outgoing_edges(giver_id): return ValidationResult(is_valid=False, error_message=f"Invalid assignment: {giver_id} → {receiver_id}") # Check all nodes present if givers != set(graph.nodes.keys()) or receivers != set(graph.nodes.keys()): return ValidationResult(is_valid=False, error_message="Not all participants included in cycle") return ValidationResult(is_valid=True) ``` ### Error Scenarios | Scenario | Detection | Error Message | |----------|-----------|---------------| | Too few participants | Precondition check | "At least 3 participants required" | | Participant isolated by exclusions | Graph connectivity check | "Participant '{name}' has no valid recipients" | | Too many exclusions | Graph connectivity check | "Current exclusion rules make matching impossible" | | Cannot find cycle | Max attempts reached | "Unable to find valid assignment. Try reducing exclusions." | | Invalid state | Precondition check | "Exchange is not ready for matching" | ## Database Transaction Matching operation must be atomic (all-or-nothing): ```python def execute_matching(exchange_id: int) -> MatchingResult: """ Execute complete matching operation within transaction. Returns: MatchingResult with success status, matches, or error message """ from sqlalchemy import orm # Begin transaction with db.session.begin_nested(): try: # 1. Validate preconditions validation = validate_matching_preconditions(exchange_id) if not validation.is_valid: return MatchingResult(success=False, error=validation.error_message) # 2. Get participants and exclusions participants = get_active_participants(exchange_id) exclusions = get_exclusions(exchange_id) # 3. Build graph graph = AssignmentGraph(participants, exclusions) # 4. Generate cycle cycle = generate_random_cycle(graph, max_attempts=100) if cycle is None: return MatchingResult( success=False, error="Unable to find valid assignment. Please reduce exclusion rules or add more participants." ) # 5. Validate cycle validation = validate_cycle(cycle, graph) if not validation.is_valid: return MatchingResult(success=False, error=validation.error_message) # 6. Create match records matches = [] for giver_id, receiver_id in cycle: match = Match( exchange_id=exchange_id, giver_id=giver_id, receiver_id=receiver_id ) db.session.add(match) matches.append(match) # 7. Update exchange state exchange = Exchange.query.get(exchange_id) exchange.state = ExchangeState.MATCHED # Commit nested transaction db.session.commit() return MatchingResult(success=True, matches=matches) except Exception as e: db.session.rollback() logger.error(f"Matching failed for exchange {exchange_id}: {str(e)}") return MatchingResult( success=False, error="An unexpected error occurred during matching. Please try again." ) ``` ## Re-Matching When admin triggers re-match, all existing matches must be cleared: ```python def execute_rematching(exchange_id: int) -> MatchingResult: """ Clear existing matches and generate new assignments. """ with db.session.begin_nested(): try: # 1. Validate exchange is in matched state exchange = Exchange.query.get(exchange_id) if exchange.state != ExchangeState.MATCHED: return MatchingResult(success=False, error="Exchange is not in matched state") # 2. Delete existing matches Match.query.filter_by(exchange_id=exchange_id).delete() # 3. Revert state to registration_closed exchange.state = ExchangeState.REGISTRATION_CLOSED db.session.flush() # 4. Run matching again result = execute_matching(exchange_id) if result.success: db.session.commit() else: db.session.rollback() return result except Exception as e: db.session.rollback() logger.error(f"Re-matching failed for exchange {exchange_id}: {str(e)}") return MatchingResult(success=False, error="Re-matching failed. Please try again.") ``` ## Integration with Notification Service After successful matching, trigger notifications: ```python def complete_matching_workflow(exchange_id: int) -> WorkflowResult: """ Complete matching and send notifications. """ # Execute matching matching_result = execute_matching(exchange_id) if not matching_result.success: return WorkflowResult(success=False, error=matching_result.error) # Send notifications to all participants try: notification_service = NotificationService() notification_service.send_match_notifications(exchange_id) # Notify admin (if enabled) notification_service.send_admin_notification( exchange_id, NotificationType.MATCHING_COMPLETE ) return WorkflowResult(success=True) except Exception as e: logger.error(f"Failed to send match notifications for exchange {exchange_id}: {str(e)}") # Matching succeeded but notification failed # Return success but log the notification failure return WorkflowResult( success=True, warning="Matching complete but some notifications failed to send. Please check email service." ) ``` ## Testing Strategy ### Unit Tests ```python class TestMatchingAlgorithm(unittest.TestCase): def test_minimum_viable_matching(self): """Test matching with 3 participants, no exclusions.""" participants = create_test_participants(3) exclusions = [] graph = AssignmentGraph(participants, exclusions) cycle = generate_random_cycle(graph) self.assertIsNotNone(cycle) self.assertEqual(len(cycle), 3) validation = validate_cycle(cycle, graph) self.assertTrue(validation.is_valid) def test_matching_with_exclusions(self): """Test matching with valid exclusions.""" participants = create_test_participants(5) exclusions = [ create_exclusion(participants[0], participants[1]) ] graph = AssignmentGraph(participants, exclusions) cycle = generate_random_cycle(graph) self.assertIsNotNone(cycle) # Verify exclusion is respected for giver_id, receiver_id in cycle: self.assertNotEqual((giver_id, receiver_id), (participants[0].id, participants[1].id)) def test_impossible_matching_detected(self): """Test that impossible matching is detected in validation.""" # Create 3 participants where each excludes the next # A excludes B, B excludes C, C excludes A # No Hamiltonian cycle possible participants = create_test_participants(3) exclusions = [ create_exclusion(participants[0], participants[1]), create_exclusion(participants[1], participants[2]), create_exclusion(participants[2], participants[0]) ] graph = AssignmentGraph(participants, exclusions) validation = check_graph_connectivity_with_graph(graph) self.assertFalse(validation.is_valid) def test_no_self_matching(self): """Ensure no participant is matched to themselves.""" participants = create_test_participants(10) exclusions = [] graph = AssignmentGraph(participants, exclusions) cycle = generate_random_cycle(graph) for giver_id, receiver_id in cycle: self.assertNotEqual(giver_id, receiver_id) def test_everyone_gives_and_receives_once(self): """Ensure each participant gives and receives exactly once.""" participants = create_test_participants(10) exclusions = [] graph = AssignmentGraph(participants, exclusions) cycle = generate_random_cycle(graph) givers = set(giver for giver, _ in cycle) receivers = set(receiver for _, receiver in cycle) self.assertEqual(len(givers), 10) self.assertEqual(len(receivers), 10) self.assertEqual(givers, {p.id for p in participants}) self.assertEqual(receivers, {p.id for p in participants}) ``` ### Integration Tests ```python class TestMatchingIntegration(TestCase): def test_full_matching_workflow(self): """Test complete matching workflow from database to notifications.""" # Setup exchange = create_test_exchange() participants = [create_test_participant(exchange) for _ in range(5)] # Execute result = complete_matching_workflow(exchange.id) # Assert self.assertTrue(result.success) exchange = Exchange.query.get(exchange.id) self.assertEqual(exchange.state, ExchangeState.MATCHED) matches = Match.query.filter_by(exchange_id=exchange.id).all() self.assertEqual(len(matches), 5) def test_rematching_clears_old_matches(self): """Test that re-matching replaces old assignments.""" # Setup exchange = create_matched_exchange_with_5_participants() old_matches = Match.query.filter_by(exchange_id=exchange.id).all() old_match_ids = {m.id for m in old_matches} # Execute result = execute_rematching(exchange.id) # Assert self.assertTrue(result.success) new_matches = Match.query.filter_by(exchange_id=exchange.id).all() new_match_ids = {m.id for m in new_matches} # Old match records should be deleted self.assertEqual(len(old_match_ids.intersection(new_match_ids)), 0) ``` ## Performance Considerations ### Expected Performance | Participants | Exclusions | Expected Time | Notes | |--------------|------------|---------------|-------| | 3-10 | 0-5 | <10ms | Instant | | 10-50 | 0-20 | <100ms | Very fast | | 50-100 | 0-50 | <500ms | Fast enough | | 100+ | Any | Variable | May exceed max attempts | ### Optimization Strategies 1. **Graph Pruning**: Remove impossible edges early 2. **Heuristic Ordering**: Start with most constrained nodes 3. **Adaptive Max Attempts**: Increase attempts for larger groups 4. **Fallback to Multiple Cycles**: If single cycle fails, allow 2-3 small cycles ### Max Attempts Configuration ```python def get_max_attempts(num_participants: int) -> int: """Adaptive max attempts based on participant count.""" if num_participants <= 10: return 50 elif num_participants <= 50: return 100 else: return 200 ``` ## Security Considerations ### Randomization Source - Use `secrets.SystemRandom()` for cryptographic randomness - Prevents predictable assignments - Important for preventing manipulation ```python import secrets random_generator = secrets.SystemRandom() def shuffle(items: list): """Cryptographically secure shuffle.""" random_generator.shuffle(items) ``` ### Match Confidentiality - Matches only visible to: - Giver (sees their own recipient) - Admin (for troubleshooting) - Never expose matches in logs - Database queries filtered by permissions ## Future Enhancements Potential improvements for future versions: 1. **Multi-Cycle Support**: Allow multiple small cycles if single cycle impossible 2. **Preference Weighting**: Allow participants to indicate preferences 3. **Historical Avoidance**: Avoid repeating matches from previous years 4. **Couple Pairing**: Assign couples to same family/group 5. **Performance Metrics**: Track matching time and success rate 6. **Manual Override**: Allow admin to manually adjust specific assignments ## References - [Hamiltonian Cycle Problem](https://en.wikipedia.org/wiki/Hamiltonian_path_problem) - [Graph Theory Basics](https://en.wikipedia.org/wiki/Graph_theory) - [Backtracking Algorithm](https://en.wikipedia.org/wiki/Backtracking) - [Data Model Specification](../data-model.md) - [API Specification](../api-spec.md)