Initialize Sneaky Klaus project with: - uv package management and pyproject.toml - Flask application structure (app.py, config.py) - SQLAlchemy models for Admin and Exchange - Alembic database migrations - Pre-commit hooks configuration - Development tooling (pytest, ruff, mypy) Initial structure follows design documents in docs/: - src/app.py: Application factory with Flask extensions - src/config.py: Environment-based configuration - src/models/: Admin and Exchange models - migrations/: Alembic migration setup 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
733 lines
25 KiB
Markdown
733 lines
25 KiB
Markdown
# 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<br/>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)
|