Scoring Algorithm
CivicPulse uses a contributor scoring system to recognise and reward civic participation. The algorithm is versioned β the current version is civicpulse-score-v1.
Algorithm: civicpulse-score-v1β
The score is computed as a weighted sum of all civic actions a user has taken. Actions are recorded in DynamoDB when they occur and the score is recalculated on each leaderboard fetch (or on-demand when a user views their own profile).
Weight Tableβ
| Action | Field | Points | Notes |
|---|---|---|---|
| Filed an issue report | reportFiled | 50 | Per issue submitted |
| Reported issue resolved | reportResolved | 100 | Awarded when issue status changes to resolved |
| Upvote received on a report | upvoteReceived | 2 | Per upvote on any issue you reported |
| Added an image to an issue | imageAdded | 15 | Per image uploaded via S3 |
| Added a video to an issue | videoAdded | 25 | Per video uploaded via S3 |
| Reported a high-priority issue | highPriority | 30 | Bonus awarded per high-priority report |
| Cast an upvote on another issue | voteCast | 5 | Per upvote you give to another user's report |
| Posted a message in a thread | commentMade | 3 | Per message posted to an issue thread |
| Joined a community impact event | eventJoined | 20 | Per event RSVP'd and attended |
| Weekly activity streak | streakBonus | 50 | Per complete week with at least one civic action |
| Cross-category activity | categoryBonus | 10 | Bonus per unique category you contribute to (max 6) |
Score Formulaβ
score =
(reportFiled Γ 50) +
(reportResolved Γ 100) +
(upvoteReceived Γ 2) +
(imageAdded Γ 15) +
(videoAdded Γ 25) +
(highPriority Γ 30) +
(voteCast Γ 5) +
(commentMade Γ 3) +
(eventJoined Γ 20) +
(streakBonus Γ 50) +
(categoryBonus Γ 10)
Tiersβ
Tiers are assigned based on total score thresholds:
| Tier | Score Range | Description |
|---|---|---|
| Citizen | 0 β 99 | Starting tier β every registered user |
| Contributor | 100 β 299 | Active civic participant |
| Champion | 300 β 599 | Regular, high-quality contributor |
| Legend | 600+ | Elite civic contributor |
Tier is displayed on the user's profile, in the leaderboard, and beside their name in issue threads.
Badgesβ
Badges are awarded for specific milestones. They are independent of tiers β a user can earn multiple badges at any tier.
| Badge | Criteria | Description |
|---|---|---|
| Pioneer | Among first 100 reporters on the platform | Early adopter recognition |
| Community Voice | Received 100+ total upvotes on reported issues | Your reports resonate with the community |
| Problem Solver | 5+ reported issues reached resolved status | You report issues that get fixed |
| Evidence Expert | Added 10+ images or videos across issues | Documenting issues with media proof |
| Civic Champion | Reached the Champion tier | Scored 300+ points |
| Legend | Reached the Legend tier | Scored 600+ points |
| All-Rounder | Contributed to all 6 categories | Broad civic engagement across areas |
| Civic Activist | Completed 4+ consecutive weekly activity streaks | Consistent long-term participation |
Badges are stored as an array of badge IDs on the user's score record and include the earnedAt timestamp.
Score Computationβ
Scores are not stored as a running total β they are recomputed from the raw activity log each time to ensure accuracy if weights change or actions are retrospectively credited or revoked.
The computation reads all SCORE_ACTIVITY records for a user from DynamoDB and applies the weight table:
function computeScore(activities) {
const counts = {
reportFiled: 0, reportResolved: 0, upvoteReceived: 0,
imageAdded: 0, videoAdded: 0, highPriority: 0,
voteCast: 0, commentMade: 0, eventJoined: 0,
streakBonus: 0, categoryBonus: 0,
};
for (const activity of activities) {
if (counts[activity.type] !== undefined) {
counts[activity.type]++;
}
}
// Category bonus: count distinct categories, cap at 6
const uniqueCategories = new Set(
activities
.filter(a => a.type === 'reportFiled' && a.category)
.map(a => a.category)
).size;
counts.categoryBonus = Math.min(uniqueCategories, 6);
const weights = {
reportFiled: 50, reportResolved: 100, upvoteReceived: 2,
imageAdded: 15, videoAdded: 25, highPriority: 30,
voteCast: 5, commentMade: 3, eventJoined: 20,
streakBonus: 50, categoryBonus: 10,
};
return Object.entries(counts).reduce((total, [key, count]) => {
return total + count * (weights[key] || 0);
}, 0);
}
Sharing Your Scoreβ
Each contributor has a public shareable URL:
https://civicpulse.in/contributors/<userId>
This page renders the user's score card with their tier, badges, and a contribution breakdown. The page is publicly accessible (no login required) and is designed to be shared on social media.
The API returns this URL in the shareUrl field of GET /api/scores/:userId.
API Payload Structureβ
The full score payload returned by the API:
{
"userId": "usr_01HN...",
"displayName": "Priya S.",
"city": "bangalore",
"score": 1240,
"tier": "Legend",
"rank": 1,
"globalRank": 7,
"badges": [
{
"id": "Pioneer",
"displayName": "Pioneer",
"description": "One of the first 100 reporters on the platform",
"earnedAt": "2025-01-01T00:00:00.000Z"
}
],
"breakdown": {
"reportsField": 8,
"reportsResolved": 4,
"upvotesReceived": 210,
"imagesAdded": 12,
"videosAdded": 3,
"highPriorityBonus": 5,
"votesCast": 89,
"commentsMade": 44,
"eventsJoined": 6,
"streakBonus": 2,
"categoryBonus": 4
},
"algorithm": "civicpulse-score-v1",
"shareUrl": "https://civicpulse.in/contributors/usr_01HN...",
"computedAt": "2025-01-15T10:30:00.000Z"
}
Algorithm Versioningβ
If the weight table changes in a future release, the algorithm version will be incremented to civicpulse-score-v2. The old algorithm version is stored alongside each score record, making it possible to audit or recompute historical scores under any version.
The current algorithm version is always reflected in the algorithm field of all score API responses.