- Added EntitySuggestionManager.vue to manage entity suggestions and authentication. - Created EntitySuggestionModal.vue for displaying suggestions and adding new entities. - Integrated AuthManager for user authentication within the suggestion modal. - Enhanced signal handling in start-servers.sh for graceful shutdown of servers. - Improved server startup script to ensure proper cleanup and responsiveness to termination signals. - Added documentation for signal handling fixes and usage instructions.
17 KiB
Ride Ranking System Documentation
Overview
The ThrillWiki ride ranking system implements the Internet Roller Coaster Poll (IRCP) algorithm to calculate fair, accurate rankings for all rides based on user ratings. This system provides daily-updated rankings that reflect the collective preferences of the community.
Algorithm Description
Original Internet Roller Coaster Poll Algorithm
The system implements the exact methodology from the Internet Roller Coaster Poll:
"Each coaster is compared one at a time to every other coaster to see whether more people who have ridden both of them preferred one or the other. A coaster is given a 'Win' for each coaster that more mutual riders ranked behind it, given a 'Loss' for each coaster that more mutual riders ranked ahead of it, and given a 'Tie' for each coaster that the same number of mutual riders ranked above it and below it. Coasters are ranked by their overall winning percentage (where ties count as half of a win and half of a loss). In the event that two coasters end up with identical winning percentages, the tie is broken (if possible) by determining which of the two won the mutual rider comparison between those two coasters."
Our Implementation
The ThrillWiki implementation adapts this algorithm to work with our existing rating system:
- "Have You Ridden" Detection: A user is considered to have ridden a ride if they have submitted a rating/review for it
- Preference Determination: Higher ratings indicate preference (e.g., a user rating Ride A as 8/10 and Ride B as 6/10 prefers Ride A)
- Pairwise Comparisons: Every ride is compared to every other ride based on mutual riders
- Winning Percentage: Calculated as
(wins + 0.5 * ties) / total_comparisons - Tie Breaking: Head-to-head comparisons resolve ties in winning percentage
System Architecture
Database Models
RideRanking
Stores the calculated ranking for each ride:
rank: Overall rank position (1 = best)wins: Number of rides this ride beats in pairwise comparisonslosses: Number of rides that beat this rideties: Number of rides with equal preferencewinning_percentage: Win percentage where ties count as 0.5mutual_riders_count: Total users who have rated this rideaverage_rating: Average rating from all userslast_calculated: Timestamp of last calculation
RidePairComparison
Caches pairwise comparison results between two rides:
ride_a,ride_b: The two rides being comparedride_a_wins: Number of mutual riders who rated ride_a higherride_b_wins: Number of mutual riders who rated ride_b higherties: Number of mutual riders who rated both equallymutual_riders_count: Total users who rated both rides
RankingSnapshot
Historical tracking of rankings:
ride: The ride being trackedrank: Rank on the snapshot datewinning_percentage: Win percentage on the snapshot datesnapshot_date: Date of the snapshot
Service Layer
The RideRankingService (apps/rides/services/ranking_service.py) implements the core algorithm:
service = RideRankingService()
result = service.update_all_rankings(category='RC') # Optional category filter
Key methods:
update_all_rankings(): Main entry point for ranking calculation_calculate_pairwise_comparison(): Compares two rides_calculate_rankings_from_comparisons(): Converts comparisons to rankings_apply_tiebreakers(): Resolves ties using head-to-head comparisons
Usage
Manual Ranking Update
Run the management command to update rankings:
# Update all ride rankings
uv run python manage.py update_ride_rankings
# Update only roller coaster rankings
uv run python manage.py update_ride_rankings --category RC
Scheduled Updates (Cron)
Add to crontab for daily updates at 2 AM:
0 2 * * * cd /Users/talor/thrillwiki_django_no_react/backend && uv run python manage.py update_ride_rankings
Accessing Rankings
Django Admin
View rankings at /admin/rides/rideranking/
In Code
from apps.rides.models import RideRanking
# Get top 10 rides
top_rides = RideRanking.objects.select_related('ride').order_by('rank')[:10]
# Get ranking for specific ride
ranking = RideRanking.objects.get(ride__slug='millennium-force')
print(f"Rank: #{ranking.rank}, Win%: {ranking.winning_percentage:.1%}")
Web Interface
Views
The ranking system provides comprehensive web views for browsing and analyzing rankings.
Rankings List View
URL: /rides/rankings/
Features:
- Paginated list of all ranked rides (50 per page)
- Filter by ride category (Roller Coasters, Dark Rides, etc.)
- Filter by minimum mutual riders to ensure statistical significance
- HTMX support for dynamic updates without page refresh
- Shows key metrics: rank, ride name, park, wins/losses/ties, winning percentage
Query Parameters:
category: Filter by ride type (RC, DR, FR, WR, TR, OT)min_riders: Minimum number of mutual riders required (e.g., 100)page: Page number for pagination
Example URLs:
/rides/rankings/ # All rankings
/rides/rankings/?category=RC # Only roller coasters
/rides/rankings/?category=RC&min_riders=50 # Roller coasters with 50+ mutual riders
Ranking Detail View
URL: /rides/rankings/<ride-slug>/
Features:
- Comprehensive ranking metrics for a specific ride
- Head-to-head comparison results with other rides
- Historical ranking chart showing trends over time
- Rank movement indicators (up/down from previous calculation)
- Detailed breakdown of wins, losses, and ties
HTMX Endpoints:
/rides/rankings/<ride-slug>/history-chart/- Returns ranking history chart data/rides/rankings/<ride-slug>/comparisons/- Returns detailed comparison table
Template Integration
The views use Django templates with HTMX for dynamic updates:
{# rankings.html - Main rankings page #}
{% extends "base.html" %}
{% block content %}
<div class="rankings-container">
<h1>Ride Rankings</h1>
{# Filters #}
<form hx-get="/rides/rankings/" hx-target="#rankings-table">
<select name="category">
<option value="all">All Rides</option>
<option value="RC">Roller Coasters</option>
<option value="DR">Dark Rides</option>
<!-- etc. -->
</select>
<input type="number" name="min_riders" placeholder="Min riders">
<button type="submit">Filter</button>
</form>
{# Rankings table #}
<div id="rankings-table">
{% include "rides/partials/rankings_table.html" %}
</div>
</div>
{% endblock %}
API Endpoints
RESTful API
The ranking system exposes a comprehensive REST API for programmatic access.
List Rankings
Endpoint: GET /api/v1/rankings/
Description: Get paginated list of ride rankings
Query Parameters:
category(string): Filter by ride category (RC, DR, FR, WR, TR, OT)min_riders(integer): Minimum mutual riders requiredpark(string): Filter by park slugordering(string): Sort order (rank, -rank, winning_percentage, -winning_percentage)page(integer): Page numberpage_size(integer): Results per page (default: 20)
Response Example:
{
"count": 523,
"next": "http://api.example.com/api/v1/rankings/?page=2",
"previous": null,
"results": [
{
"id": 1,
"rank": 1,
"ride": {
"id": 123,
"name": "Steel Vengeance",
"slug": "steel-vengeance",
"park": {
"id": 45,
"name": "Cedar Point",
"slug": "cedar-point"
},
"category": "RC"
},
"wins": 487,
"losses": 23,
"ties": 13,
"winning_percentage": 0.9405,
"mutual_riders_count": 1543,
"comparison_count": 523,
"average_rating": 9.4,
"last_calculated": "2024-01-15T02:00:00Z",
"rank_change": 0,
"previous_rank": 1
}
]
}
Get Ranking Details
Endpoint: GET /api/v1/rankings/<ride-slug>/
Description: Get detailed ranking information for a specific ride
Response: Includes full ride details, head-to-head comparisons, and ranking history
{
"id": 1,
"rank": 1,
"ride": {
"id": 123,
"name": "Steel Vengeance",
"slug": "steel-vengeance",
"description": "Hybrid roller coaster...",
"park": {
"id": 45,
"name": "Cedar Point",
"slug": "cedar-point",
"location": {
"city": "Sandusky",
"state": "Ohio",
"country": "United States"
}
},
"category": "RC",
"manufacturer": {
"id": 12,
"name": "Rocky Mountain Construction"
},
"opening_date": "2018-05-05",
"status": "OPERATING"
},
"wins": 487,
"losses": 23,
"ties": 13,
"winning_percentage": 0.9405,
"mutual_riders_count": 1543,
"comparison_count": 523,
"average_rating": 9.4,
"last_calculated": "2024-01-15T02:00:00Z",
"calculation_version": "1.0",
"head_to_head_comparisons": [
{
"opponent": {
"id": 124,
"name": "Fury 325",
"slug": "fury-325",
"park": "Carowinds"
},
"wins": 234,
"losses": 189,
"ties": 23,
"result": "win",
"mutual_riders": 446
}
],
"ranking_history": [
{
"date": "2024-01-15",
"rank": 1,
"winning_percentage": 0.9405
},
{
"date": "2024-01-14",
"rank": 1,
"winning_percentage": 0.9398
}
]
}
Get Ranking History
Endpoint: GET /api/v1/rankings/<ride-slug>/history/
Description: Get historical ranking data for a specific ride (last 90 days)
Response: Array of ranking snapshots
Get Head-to-Head Comparisons
Endpoint: GET /api/v1/rankings/<ride-slug>/comparisons/
Description: Get detailed head-to-head comparison data for a ride
Response: Array of comparison results with all rides
Get Ranking Statistics
Endpoint: GET /api/v1/rankings/statistics/
Description: Get system-wide ranking statistics
Response Example:
{
"total_ranked_rides": 523,
"total_comparisons": 136503,
"last_calculation_time": "2024-01-15T02:00:00Z",
"calculation_duration": 45.23,
"top_rated_ride": {
"id": 123,
"name": "Steel Vengeance",
"slug": "steel-vengeance",
"park": "Cedar Point",
"rank": 1,
"winning_percentage": 0.9405,
"average_rating": 9.4
},
"most_compared_ride": {
"id": 456,
"name": "Millennium Force",
"slug": "millennium-force",
"park": "Cedar Point",
"comparison_count": 521
},
"biggest_rank_change": {
"ride": {
"id": 789,
"name": "Iron Gwazi",
"slug": "iron-gwazi"
},
"current_rank": 3,
"previous_rank": 8,
"change": 5
}
}
Trigger Ranking Calculation (Admin)
Endpoint: POST /api/v1/rankings/calculate/
Description: Manually trigger ranking calculation (requires admin authentication)
Request Body:
{
"category": "RC" // Optional - filter to specific category
}
Response:
{
"status": "success",
"rides_ranked": 523,
"comparisons_made": 136503,
"duration": 45.23,
"timestamp": "2024-01-15T02:00:45Z"
}
API Authentication
- Read endpoints (GET): No authentication required
- Calculation endpoint (POST): Requires admin authentication via token or session
API Rate Limiting
- Anonymous users: 100 requests per hour
- Authenticated users: 1000 requests per hour
- Admin users: No rate limiting
Python Client Example
import requests
# Get top 10 rankings
response = requests.get(
"https://api.thrillwiki.com/api/v1/rankings/",
params={"page_size": 10}
)
rankings = response.json()["results"]
for ranking in rankings:
print(f"#{ranking['rank']}: {ranking['ride']['name']} - {ranking['winning_percentage']:.1%}")
# Get specific ride ranking
response = requests.get(
"https://api.thrillwiki.com/api/v1/rankings/steel-vengeance/"
)
details = response.json()
print(f"{details['ride']['name']} is ranked #{details['rank']}")
print(f"Wins: {details['wins']}, Losses: {details['losses']}, Ties: {details['ties']}")
JavaScript/TypeScript Client Example
// Using fetch API
async function getTopRankings(limit: number = 10) {
const response = await fetch(
`https://api.thrillwiki.com/api/v1/rankings/?page_size=${limit}`
);
const data = await response.json();
return data.results;
}
// Using axios
import axios from 'axios';
const api = axios.create({
baseURL: 'https://api.thrillwiki.com/api/v1',
});
async function getRideRanking(slug: string) {
const { data } = await api.get(`/rankings/${slug}/`);
return data;
}
Calculation Example
Scenario
Three rides with the following ratings from users:
Ride A (Millennium Force):
- User 1: 10/10
- User 2: 9/10
- User 3: 8/10
Ride B (Maverick):
- User 1: 9/10
- User 2: 10/10
- User 4: 8/10
Ride C (Top Thrill 2):
- User 1: 8/10
- User 3: 9/10
- User 4: 10/10
Pairwise Comparisons
A vs B (mutual riders: Users 1, 2):
- User 1: A(10) > B(9) → A wins
- User 2: A(9) < B(10) → B wins
- Result: 1 win each, 0 ties
A vs C (mutual riders: Users 1, 3):
- User 1: A(10) > C(8) → A wins
- User 3: A(8) < C(9) → C wins
- Result: 1 win each, 0 ties
B vs C (mutual riders: Users 1, 4):
- User 1: B(9) > C(8) → B wins
- User 4: B(8) < C(10) → C wins
- Result: 1 win each, 0 ties
Final Rankings
All three rides have:
- Wins: 1
- Losses: 1
- Ties: 0
- Winning Percentage: 50%
Since all have identical winning percentages, the system would use additional criteria (mutual rider count, average rating) to determine final order.
Performance Considerations
Optimization Strategies
- Caching: Pairwise comparisons are cached in
RidePairComparisontable - Batch Processing: Comparisons calculated in bulk to minimize database queries
- Indexes: Database indexes on frequently queried fields:
rankfor orderingwinning_percentagefor sortingride_idfor lookups- Composite indexes for complex queries
Scalability
The algorithm complexity is O(n²) where n is the number of rides, as each ride must be compared to every other ride. For 1,000 rides:
- Comparisons needed: 499,500
- Estimated processing time: 30-60 seconds
- Database storage: ~20MB for comparison cache
Monitoring and Maintenance
Admin Interface
Access Django admin to:
- View current rankings
- Inspect pairwise comparisons
- Track historical changes
- Monitor calculation performance
Logging
The system logs:
- Calculation start/end times
- Number of rides ranked
- Number of comparisons made
- Any errors or warnings
Example log output:
INFO: Starting ranking calculation for category: RC
INFO: Found 523 rides to rank
DEBUG: Processed 100/136503 comparisons
INFO: Ranking calculation completed in 45.23 seconds
Data Cleanup
Old ranking snapshots are automatically cleaned up after 365 days to manage database size.
Algorithm Validity
Mathematical Properties
- Transitivity: Not enforced (A > B and B > C doesn't guarantee A > C)
- Consistency: Same input always produces same output
- Fairness: Every mutual rider's opinion counts equally
- Completeness: All possible comparisons are considered
Advantages Over Simple Averaging
- Reduces Selection Bias: Only compares rides among users who've experienced both
- Fair Comparisons: Popular rides aren't advantaged over less-ridden ones
- Head-to-Head Logic: Direct comparisons matter for tie-breaking
- Robust to Outliers: One extremely high/low rating doesn't skew results
Future Enhancements
Planned Features
- Real-time Updates: Update rankings immediately after new reviews
- Category-Specific Rankings: Separate rankings for different ride types
- Regional Rankings: Rankings by geographic region
- Time-Period Rankings: Best new rides, best classic rides
- API Endpoints: RESTful API for ranking data
- Ranking Trends: Visualizations of ranking changes over time
Potential Optimizations
- Incremental Updates: Only recalculate affected comparisons
- Parallel Processing: Distribute comparison calculations
- Machine Learning: Predict rankings for new rides
- Weighted Ratings: Consider recency or reviewer credibility
Technical Details
File Structure
apps/rides/
├── models/
│ └── rankings.py # Ranking data models
├── services/
│ └── ranking_service.py # Algorithm implementation
├── management/
│ └── commands/
│ └── update_ride_rankings.py # CLI command
└── admin.py # Admin interface configuration
Database Tables
rides_rideranking: Current rankingsrides_ridepaircomparison: Cached comparisonsrides_rankingsnapshot: Historical datarides_riderankingevent: Audit trail (pghistory)rides_ridepaircomparisonevent: Audit trail (pghistory)
Dependencies
- Django 5.2+
- PostgreSQL with pghistory
- Python 3.11+
References
- Internet Roller Coaster Poll - Original algorithm source
- Condorcet Method - Similar voting system theory
- Pairwise Comparison - Mathematical foundation