diff --git a/docs/adaptive-gamification/indicators-engine.md b/docs/adaptive-gamification/indicators-engine.md index 90eb313..1de450d 100644 --- a/docs/adaptive-gamification/indicators-engine.md +++ b/docs/adaptive-gamification/indicators-engine.md @@ -46,8 +46,8 @@ flowchart TB end subgraph Ingestion["Aggregation & Context Layer"] - GIS["GamificationIndicatorsService
(Date parsing & period partitioning)"] - CTX["IndicatorComputationContext
(badges, players, checkins, periods)"] + GIS["GamificationIndicatorsService
(Date & threshold validation)"] + CTX["IndicatorComputationContext
(badges, players, checkins, threshold)"] end subgraph Strategy["Pluggable Strategy Engine"] @@ -55,13 +55,13 @@ flowchart TB VBS["VanishingBadgesStrategy
(Pure Mathematical Implementation)"] end - subgraph Metrics["Calculated Indicators"] - ET["ET_b: Estimated Awarding Time"] - I3["i_3: Individual Player Recency"] - CII["CII: Community Interest Indicator"] - PMI["PMI / relPMI: Player Motivation"] - CMI["CMI: Community Motivation Indicator"] - CAND["Adaptation Candidate (Lowest CII)"] + subgraph Metrics["Calculated Indicators (Sept 18 Revision)"] + AB["AB(p): Achievable Badges [Def 3.1]"] + I3["i_3(p, b): Individual Player Interest [Def 3.2]"] + IGN["ignored_by(p): Ignored Badges [Def 3.3]"] + CII["CII(b): Community Interest Indicator [Def 3.4]"] + FILT["Candidate Pool Filtering [§4.2.1]"] + TRIG["Adaptation Trigger & Candidate [§4.1-4.2]"] end subgraph Output["Output Response DTOs"] @@ -85,30 +85,29 @@ flowchart TB | Rayuela Domain Entity / DAO | Input Role in Engine | Output Indicator | | :--- | :--- | :--- | | **`Project`** | Establishes the bounded community and context identifier. | `projectId` | -| **`BadgeRule` (`GamificationDao`)** | Defines target requirements, current badge status (`active`, `faded`, `expired`), and prerequisite DAG relationships. | $ep(b)$ (eligible pool), Prerequisite chains | +| **`BadgeRule` (`GamificationDao`)** | Defines target requirements, current badge status (`active`, `faded`, `expired`), and prerequisite DAG relationships. | $AB(p)$, $ep(b)$ (eligible pool), Candidate filtering (`allPlayerBadges`, `unreachableBadges`, `candidateBadges`) | | **`User` (`UserDao`)** | Supplies player join dates ($t_{\text{join}}$) and registered account profiles. | $t_0(p, b)$ (baseline date for root badges) | -| **`Checkin` (`CheckInDao`)** | Provides timestamped citizen science contributions, partitioned into consecutive calendar periods $s_1, s_2, \dots, s_n$. | $cnum(p, s)$, $PMI(p)$, $relPMI(p)$, $CMI$ | -| **`Move` (`MoveDao`)** | Records historical award events, tracking the exact contributions and timestamps at which players unlocked badges. | $U_b$ (earners set), $ET_b$, $t_0(p, b)$ for child badges | +| **`Checkin` (`CheckInDao`)** | Provides timestamped citizen science contributions. | `totalContributions`, `activePlayers` | +| **`Move` (`MoveDao`)** | Records historical award events, tracking the exact timestamps at which players unlocked badges. | $B_p$, $U_b$ (earners set), $t_0(p, b)$ for child badges | --- -## 3. Mathematical Indicators Summary +## 3. Mathematical Indicators Summary (Sept 18, 2026 Revision) -The default implementation (`VanishingBadgesStrategy`) evaluates: +The default implementation (`VanishingBadgesStrategy`) evaluates Definitions 3.1–3.4 and Section 4.1–4.2 of the Vanishing Badges specification: -1. **Estimated Awarding Time ($ET_b$)**: Historical average check-ins required by players who earned badge $b$. Falls back to immediate prerequisite thresholds during cold starts. -2. **Individual Recency Interest ($i_3(p, b)$)**: Measures how recently player $p$ became eligible for badge $b$: - $$\text{recency}(p, b) = \text{asOfDate} - t_0(p, b)$$ - $$i_3(p, b) = \frac{1}{\text{recency}(p, b) + 1}$$ - If badge $b$ is unachievable for player $p$ (unmet prerequisites), $i_3(p, b) = 1.0$. -3. **Community Interest Indicator ($CII(b)$)**: The median individual interest across all players eligible to earn badge $b$ ($ep(b)$): - $$CII(b) = \text{median}(\{i_3(p, b) : p \in ep(b)\})$$ - A declining $CII$ signals that eligible players are stalling and losing interest in badge $b$. -4. **Player Motivation Indicator ($PMI(p)$)**: The number of periods where player $p$ sustained or increased contributions compared to the previous period: - $$cnum(p, s) \ge cnum(p, \text{prev}(s)) \land (cnum(p, s) + cnum(p, \text{prev}(s)) > 0)$$ -5. **Community Motivation Indicator ($CMI$)**: The median relative motivation across all active contributors: - $$\overline{PMI} = \frac{1}{|P|} \sum_{p \in P} PMI(p), \quad relPMI(p) = \frac{PMI(p)}{\overline{PMI}}, \quad CMI = \text{median}(\{relPMI(p) : p \in P\})$$ -6. **Adaptation Candidate Badge ($b^*$)**: Identifies the active badge with the lowest $CII$, representing the primary bottleneck where community interest has waned the most. +1. **Achievable Badges ($AB(p)$ — Def 3.1)**: A badge is achievable to a player when all prerequisite badges (`previousBadges`) have been earned: + $$AB(p) = \{\, b \in \text{Badges} \mid \forall prev \in \text{prerequisites}(b),\, prev \in B_p \,\}$$ +2. **Individual Interest Indicator ($i_3(p, b)$ — Def 3.2)**: Measures the inverse of the elapsed time (in days) since player $p$ met the prerequisites for badge $b$ ($t_0(p, b)$): + $$i_3(p, b) = \begin{cases} \dfrac{1}{\text{now} - t_0(p, b)} & : b \in AB(p) \\ 1.0 & : b \notin AB(p) \end{cases}$$ +3. **Ignored Badges ($\text{ignored\_by}(p)$ — Def 3.3)**: The subset of achievable, unearned badges for player $p$ whose individual interest has fallen below reference threshold $x$ (default $x = 0.20$, equivalent to $> 5$ days elapsed): + $$\text{ignored\_by}(p) = \{\, b \in AB(p) \setminus B_p \mid i_3(p, b) < x \,\}$$ +4. **Community Interest Indicator ($CII(b)$ — Def 3.4)**: The median individual interest across all players eligible to earn badge $b$ ($ep(b) = \{\, p \in P \mid b \in AB(p) \setminus B_p \,\}$): + $$CII(b) = \text{median}(\{\, i_3(p, b) \mid p \in ep(b) \,\})$$ +5. **Candidate Filtering (§4.2.1)**: Excludes badges already earned by everyone in the community (`allPlayerBadges`), badges unreachable by anyone (`unreachableBadges`), and expired badges to yield `candidateBadges`. +6. **Adaptation Trigger & Selection (§4.1 & §4.2.2–4.2.3)**: + $$\text{Trigger Adaptation} \iff \exists b \in \text{Candidates} : CII(b) < x$$ + Candidate badges are sorted ascending by $CII(b)$ to identify the primary `adaptationCandidateBadge`. --- @@ -117,20 +116,20 @@ The default implementation (`VanishingBadgesStrategy`) evaluates: ### Current State: On-Demand REST Endpoint The engine is currently exposed via an authenticated HTTP endpoint: ```http -GET /v1/gamification-indicators/:projectId?startDate=01-07-2026&asOfDate=20-07-2026&daysPerPeriod=7 +GET /v1/gamification-indicators/:projectId?asOfDate=20-07-2026&threshold=0.20 Authorization: Bearer ``` This allows: -- Real-time exploration and parameter tuning (`daysPerPeriod`, custom `startDate`, point-in-time `asOfDate`). +- Real-time exploration and parameter tuning (`threshold`, point-in-time `asOfDate`, `minActiveCheckins`). - Visual debugging through documentation tools and simulators. -- Empirical verification of community health before enabling automated rule mutations. +- Empirical verification of community interest before enabling automated rule mutations. ### Future State: Automated Badge Fading Adaptation Loop In upcoming phases, this engine will directly drive the **Badge Fading** adaptation strategy: -1. A recurring cron worker periodically runs `calculateIndicators` for active projects. -2. If community motivation drops below threshold ($CMI < \tau_{\text{trigger}}$), the adaptation pipeline is triggered. +1. Periodically or upon check-in registration, the system runs `calculateIndicators` for active projects. +2. If any candidate badge's community interest drops below threshold ($\exists b \in \text{Candidates} : CII(b) < x$), `isTriggered` becomes `true`. 3. The engine selects the candidate badge with lowest interest ($b^* = \arg\min CII(b)$). -4. The system automatically transitions badge $b^*$ to `faded` status with an expiration timestamp (`expiresAt`), broadcasting notifications to prompt community participation. +4. The system automatically transitions badge $b^*$ to `faded` status with an expiration timestamp (`expiresAt`), broadcasting notifications to prompt community participation during the Step 5 running impact window. --- @@ -142,35 +141,39 @@ In upcoming phases, this engine will directly drive the **Badge Fading** adaptat | Parameter | Type | Required | Default | Description | | :--- | :--- | :--- | :--- | :--- | -| `startDate` | string | No | Earliest checkin | Timeline origin for Period 1. Accepts ISO-8601, `DD-MM-YYYY`, or `DD/MM/YYYY`. | | `asOfDate` | string | No | Now | Horizon timestamp. Accepts ISO-8601, `DD-MM-YYYY`, or `DD/MM/YYYY`. | -| `daysPerPeriod` | number | No | `7` | Duration of each period $s$ in days (default: weekly). | -| `minActiveCheckins` | number | No | `1` | Minimum contributions required to qualify as an active player. | +| `threshold` | number | No | `0.2` | Reference threshold $x$ for ignored badges (Def 3.3) and adaptation trigger (§4.1). | +| `minActiveCheckins` | number | No | `0` | Optional minimum contributions required to include a player in the evaluated pool $P$. | #### Sample Response (`200 OK`) ```json { "projectId": "67702f23258db9ef444b0e8b", - "currentPeriod": 3, - "startDate": "2026-07-01T00:00:00.000Z", "asOfDate": "2026-07-20T23:59:59.999Z", - "daysPerPeriod": 7, - "totalPlayers": 12, + "threshold": 0.2, + "totalPlayers": 10, "activePlayers": 8, "totalContributions": 45, - "avgPMI": 1.5, - "CMI": 1.0, + "isTriggered": true, + "triggerBadges": ["b-expert-mapper"], + "communityIgnoredCount": 1, + "totalPlayerIgnored": 4, + "allPlayerBadges": ["b-first-checkin"], + "unreachableBadges": ["b-master-scientist"], + "candidateBadges": ["b-expert-mapper"], + "lowestCII": 0.1429, "adaptationCandidateBadge": { "badgeId": "b-expert-mapper", "badgeName": "Mapeador Experto", "status": "active", "earnedCount": 1, "earnedUsers": ["user_101"], - "ET_b": 15.0, "eligibleCount": 4, "eligibleUsers": ["user_102", "user_103", "user_104", "user_105"], - "CII": 0.0526, + "CII": 0.1429, + "isCommunityIgnored": true, + "isCandidate": true, "isLowestCII": true }, "badges": [ @@ -180,10 +183,11 @@ In upcoming phases, this engine will directly drive the **Badge Fading** adaptat "status": "active", "earnedCount": 1, "earnedUsers": ["user_101"], - "ET_b": 15.0, "eligibleCount": 4, "eligibleUsers": ["user_102", "user_103", "user_104", "user_105"], - "CII": 0.0526, + "CII": 0.1429, + "isCommunityIgnored": true, + "isCandidate": true, "isLowestCII": true } ], @@ -191,13 +195,14 @@ In upcoming phases, this engine will directly drive the **Badge Fading** adaptat { "playerId": "user_102", "totalContributions": 7, - "periodContributions": { - "1": 2, - "2": 2, - "3": 3 - }, - "PMI": 2, - "relPMI": 1.333 + "earnedBadges": ["b-first-checkin"], + "achievableBadges": ["b-first-checkin", "b-expert-mapper"], + "ignoredBadges": ["b-expert-mapper"], + "individualInterest": { + "b-first-checkin": 1.0, + "b-expert-mapper": 0.1429, + "b-master-scientist": 1.0 + } } ] } diff --git a/src/module/gamification-indicators/domain/formula-strategy.interface.ts b/src/module/gamification-indicators/domain/formula-strategy.interface.ts index b763c6b..9216e0c 100644 --- a/src/module/gamification-indicators/domain/formula-strategy.interface.ts +++ b/src/module/gamification-indicators/domain/formula-strategy.interface.ts @@ -22,16 +22,7 @@ export interface IndicatorFormulaStrategy { ): CommunityIndicatorResult; /** - * Definition 3.1: Estimated Awarding Time (ET_b). - * Historical average of contributions at award time for players who earned badge b. - */ - computeEstimatedAwardingTime( - badge: BadgeDefinition, - allBadges: BadgeDefinition[], - earnedPlayers: { playerId: string; contribsAtEarn: number }[], - ): number; - - /** + * Definition 3.1: Achievable Badges AB(p) & t_0(p, b). * Evaluates if badge b is achievable by player p (all prerequisites met) * and calculates t_0(p, b), the timestamp when badge b became achievable. */ @@ -43,7 +34,7 @@ export interface IndicatorFormulaStrategy { ): { isAchievable: boolean; t0Date: Date }; /** - * Definition 3.2: Individual Interest Indicator i3(p, b). + * Definition 3.2: Individual Interest Indicator i_3(p, b). * 1 / (now - t0) for achievable badges; 1.0 if not achievable. */ computeIndividualInterest( @@ -55,29 +46,20 @@ export interface IndicatorFormulaStrategy { ): number; /** - * Definition 3.3: Community Interest Indicator CII(b). - * Median of i3(p, b) across all eligible players ep(b). - * Returns null if eligible pool is empty. - */ - computeCommunityInterest(eligibleI3Values: number[]): number | null; - - /** - * Definition 3.4: Player Motivation Indicator PMI(p). - * Counts periods s in S where contributions grew or held steady with non-zero activity. + * Definition 3.3: Ignored Badges ignored_by(p). + * ignored_by(p) = { b in AB(p) - B_p : i_3(p, b) < x } */ - computePlayerMotivation( - periodContributions: Record, - currentPeriod: number, - ): number; + computeIgnoredBadges( + achievableBadgeIds: string[], + earnedBadgeIds: Set, + playerI3Map: Record, + threshold: number, + ): string[]; /** - * Definitions 3.4 & 3.5: Relative PMI and Community Motivation Indicator (CMI). - * relPMI(p) = PMI(p) / avgPMI - * CMI = median({ relPMI(p) : p in P }) + * Definition 3.4: Community Interest Indicator CII(b). + * Median of i_3(p, b) across all eligible players ep(b) = { p in P : b in AB(p) - B_p }. + * Returns null if eligible pool is empty. */ - computeCommunityMotivation(playerPMIs: { playerId: string; pmi: number }[]): { - avgPMI: number; - playerRelPMIs: Record; - CMI: number; - }; + computeCommunityInterest(eligibleI3Values: number[]): number | null; } diff --git a/src/module/gamification-indicators/domain/indicator.types.ts b/src/module/gamification-indicators/domain/indicator.types.ts index f706471..84ba5c2 100644 --- a/src/module/gamification-indicators/domain/indicator.types.ts +++ b/src/module/gamification-indicators/domain/indicator.types.ts @@ -40,9 +40,10 @@ export interface IndicatorComputationContext { badges: BadgeDefinition[]; players: PlayerProfile[]; checkins: CheckinRecord[]; - startDate?: Date; asOfDate: Date; - daysPerPeriod: number; + /** Threshold x for ignored badges (Def 3.3) and adaptation trigger (§4.1), default 0.20 */ + threshold?: number; + /** Optional minimum check-ins required to include a player in the evaluated pool P */ minActiveCheckins?: number; } @@ -52,41 +53,57 @@ export interface BadgeIndicatorResult { status: string; earnedCount: number; earnedUsers: string[]; - /** ET_b: Estimated Awarding Time (historical average contributions at award time) */ - ET_b: number; - /** ep(b): Eligible players who can achieve b and have not yet earned it */ + /** ep(b): Eligible players who can achieve b (b in AB(p)) and have not yet earned it */ eligibleCount: number; eligibleUsers: string[]; - /** CII(b): Community Interest Indicator (median of i3 across eligible players) */ + /** CII(b): Community Interest Indicator [Def 3.4] (median of i3 across eligible players) */ CII: number | null; + /** True when CII(b) < threshold x (§4.1) */ + isCommunityIgnored: boolean; + /** True when badge is in the filtered candidate pool (§4.2.1) */ + isCandidate: boolean; isLowestCII: boolean; } export interface PlayerIndicatorResult { playerId: string; totalContributions: number; - periodContributions: Record; - /** PMI(p): Player Motivation Indicator (count of periods with positive/non-declining activity) */ - PMI: number; - /** relPMI(p): Relative Player Motivation normalized to community average */ - relPMI: number; + /** B_p: Badge IDs already earned by player p */ + earnedBadges: string[]; + /** AB(p): Achievable badges for player p whose prerequisites are met [Def 3.1] */ + achievableBadges: string[]; + /** ignored_by(p): Achievable unearned badges with i3(p, b) < threshold x [Def 3.3] */ + ignoredBadges: string[]; + /** i_3(p, b): Individual interest score per badge ID [Def 3.2] */ + individualInterest: Record; } export interface CommunityIndicatorResult { projectId: string; - currentPeriod: number; - startDate: string; asOfDate: string; - daysPerPeriod: number; + /** Reference threshold x used for Def 3.3 and §4.1 */ + threshold: number; totalPlayers: number; activePlayers: number; totalContributions: number; - /** Community average PMI across players */ - avgPMI: number; - /** CMI: Community Motivation Indicator (median relPMI across players) */ - CMI: number; + /** §4.1 Trigger status: true if there exists a candidate badge b with CII(b) < x */ + isTriggered: boolean; + /** Candidate badge IDs whose CII(b) < x (§4.1 / §4.2.3) */ + triggerBadges: string[]; + /** Number of badges ignored at the community level (CII(b) < x) */ + communityIgnoredCount: number; + /** Sum of |ignored_by(p)| across all evaluated players */ + totalPlayerIgnored: number; + /** §4.2.1 Badges earned by all evaluated players (assigned_badges) */ + allPlayerBadges: string[]; + /** §4.2.1 Badges unreachable by any evaluated player (unreachable_badges) */ + unreachableBadges: string[]; + /** §4.2.1 Filtered candidate badge IDs eligible for vanishing selection */ + candidateBadges: string[]; + /** Lowest CII value across badges with non-empty eligible pools */ + lowestCII: number | null; badges: BadgeIndicatorResult[]; players: PlayerIndicatorResult[]; - /** Lowest CII candidate badge recommended for fading if adaptation is triggered */ + /** Lowest CII active candidate badge recommended for fading if adaptation is triggered */ adaptationCandidateBadge: BadgeIndicatorResult | null; } diff --git a/src/module/gamification-indicators/domain/vanishing-badges.strategy.spec.ts b/src/module/gamification-indicators/domain/vanishing-badges.strategy.spec.ts index c4331c7..a625a20 100644 --- a/src/module/gamification-indicators/domain/vanishing-badges.strategy.spec.ts +++ b/src/module/gamification-indicators/domain/vanishing-badges.strategy.spec.ts @@ -50,7 +50,6 @@ describe('VanishingBadgesStrategy', () => { players, checkins: [], asOfDate: baseDate, - daysPerPeriod: 7, }; const res = strategy.calculateIndicators(ctx); @@ -58,54 +57,72 @@ describe('VanishingBadgesStrategy', () => { expect(res.totalPlayers).toBe(2); expect(res.activePlayers).toBe(0); expect(res.totalContributions).toBe(0); - expect(res.avgPMI).toBe(0); - expect(res.CMI).toBe(0); + expect(res.threshold).toBe(0.2); + expect(res.isTriggered).toBe(false); + expect(res.triggerBadges).toEqual([]); + expect(res.allPlayerBadges).toEqual([]); + expect(res.unreachableBadges).toEqual(['b2', 'b3']); + expect(res.candidateBadges).toEqual(['b1']); // Root badge b1 should be achievable for both players const b1Metric = res.badges.find((b) => b.badgeId === 'b1')!; expect(b1Metric.eligibleCount).toBe(2); expect(b1Metric.earnedCount).toBe(0); - // Cold start ET_b fallback: reqCheckins (5) - expect(b1Metric.ET_b).toBe(5); // Both players joined on baseDate so elapsed days is 1 -> i3 = 1.0 -> median CII = 1.0 expect(b1Metric.CII).toBe(1.0); + expect(b1Metric.isCandidate).toBe(true); + expect(b1Metric.isCommunityIgnored).toBe(false); // b2 has prereq b1 which is unearned, so eligibleCount = 0 and CII = null const b2Metric = res.badges.find((b) => b.badgeId === 'b2')!; expect(b2Metric.eligibleCount).toBe(0); expect(b2Metric.CII).toBeNull(); - // Fallback ET_b: reqCheckins(b2) + reqCheckins(b1) = 10 + 5 = 15 - expect(b2Metric.ET_b).toBe(15); + expect(b2Metric.isCandidate).toBe(false); + + // Player p1 should have b1 in achievableBadges AB(p1) and empty ignoredBadges + const p1Res = res.players.find((p) => p.playerId === 'p1')!; + expect(p1Res.earnedBadges).toEqual([]); + expect(p1Res.achievableBadges).toEqual(['b1']); + expect(p1Res.ignoredBadges).toEqual([]); + expect(p1Res.individualInterest).toEqual({ + b1: 1.0, + b2: 1.0, + b3: 1.0, + }); }); - it('should accurately compute PMI and CMI across periods with growing activity', () => { + it('should compute AB(p), ignored_by(p) [Def 3.3], and trigger adaptation [§4.1] when CII(b) < threshold x', () => { const p1Join = new Date('2026-06-01T00:00:00.000Z'); - const players: PlayerProfile[] = [ - { id: 'p1', joinDate: p1Join, earnedBadges: new Map() }, - { id: 'p2', joinDate: p1Join, earnedBadges: new Map() }, - ]; + const earnedBadgesP1 = new Map(); + earnedBadgesP1.set('b1', { + badgeId: 'b1', + earnedAt: new Date('2026-06-01T00:00:00.000Z'), + contribsAtEarn: 5, + }); - // Period 1 (Day 1-7): p1 has 2 checkins, p2 has 1 - // Period 2 (Day 8-14): p1 has 4 checkins, p2 has 0 - const checkins = [ - { userId: 'p1', datetime: new Date('2026-06-02T10:00:00.000Z') }, - { userId: 'p1', datetime: new Date('2026-06-03T10:00:00.000Z') }, - { userId: 'p2', datetime: new Date('2026-06-02T10:00:00.000Z') }, - { userId: 'p1', datetime: new Date('2026-06-09T10:00:00.000Z') }, - { userId: 'p1', datetime: new Date('2026-06-10T10:00:00.000Z') }, - { userId: 'p1', datetime: new Date('2026-06-11T10:00:00.000Z') }, - { userId: 'p1', datetime: new Date('2026-06-12T10:00:00.000Z') }, + const players: PlayerProfile[] = [ + { + id: 'p1', + joinDate: p1Join, + earnedBadges: earnedBadgesP1, + }, + { + id: 'p2', + joinDate: p1Join, + earnedBadges: new Map(), + }, ]; - const asOfDate = new Date('2026-06-14T23:59:59.000Z'); + // 7 days later (now - t0 = 6 days -> i3 = 1/6 = 0.1667 < 0.20) + const asOfDate = new Date('2026-06-07T00:00:00.000Z'); const ctx: IndicatorComputationContext = { projectId: 'proj1', badges: sampleBadges, players, - checkins, + checkins: [], asOfDate, - daysPerPeriod: 7, + threshold: 0.2, }; const res = strategy.calculateIndicators(ctx); @@ -113,13 +130,25 @@ describe('VanishingBadgesStrategy', () => { const p1Res = res.players.find((p) => p.playerId === 'p1')!; const p2Res = res.players.find((p) => p.playerId === 'p2')!; - expect(p1Res.PMI).toBe(2); - expect(p2Res.PMI).toBe(1); - - expect(res.avgPMI).toBe(1.5); - expect(p1Res.relPMI).toBe(1.333); - expect(p2Res.relPMI).toBe(0.667); - expect(res.CMI).toBe(1.0); + // p1 earned b1, so AB(p1) = [b1, b2], unearned achievable = [b2] with i3 = 0.1667 < 0.20 + expect(p1Res.earnedBadges).toEqual(['b1']); + expect(p1Res.achievableBadges).toEqual(['b1', 'b2']); + expect(p1Res.ignoredBadges).toEqual(['b2']); + expect(p1Res.individualInterest.b2).toBe(0.1667); + expect(p1Res.individualInterest.b3).toBe(1.0); + + // p2 earned nothing, so AB(p2) = [b1] with i3 = 0.1667 < 0.20 + expect(p2Res.earnedBadges).toEqual([]); + expect(p2Res.achievableBadges).toEqual(['b1']); + expect(p2Res.ignoredBadges).toEqual(['b1']); + + // Both b1 and b2 have CII = 0.1667 < 0.20 -> §4.1 Triggered! + expect(res.isTriggered).toBe(true); + expect(res.triggerBadges).toEqual(['b1', 'b2']); + expect(res.communityIgnoredCount).toBe(2); + expect(res.totalPlayerIgnored).toBe(2); + expect(res.candidateBadges).toEqual(['b1', 'b2']); + expect(res.unreachableBadges).toEqual(['b3']); }); it('should identify lowest CII candidate badge when interest decays', () => { @@ -146,7 +175,6 @@ describe('VanishingBadgesStrategy', () => { players, checkins: [], asOfDate, - daysPerPeriod: 7, }; const res = strategy.calculateIndicators(ctx); @@ -154,6 +182,13 @@ describe('VanishingBadgesStrategy', () => { const b2Metric = res.badges.find((b) => b.badgeId === 'b2')!; expect(b2Metric.eligibleCount).toBe(1); expect(b2Metric.CII).toBe(0.05); + expect(b2Metric.isCommunityIgnored).toBe(true); + expect(b2Metric.isCandidate).toBe(true); + + // b1 is earned by all players (p1), so it is in allPlayerBadges and not a candidate (§4.2.1) + expect(res.allPlayerBadges).toEqual(['b1']); + expect(res.unreachableBadges).toEqual(['b3']); + expect(res.candidateBadges).toEqual(['b2']); expect(res.adaptationCandidateBadge).not.toBeNull(); expect(res.adaptationCandidateBadge?.badgeId).toBe('b2'); @@ -182,7 +217,6 @@ describe('VanishingBadgesStrategy', () => { players, checkins: [], asOfDate: baseDate, - daysPerPeriod: 7, }; const res = strategy.calculateIndicators(ctx); @@ -191,40 +225,20 @@ describe('VanishingBadgesStrategy', () => { expect(b1Metric.earnedCount).toBe(1); expect(b1Metric.eligibleCount).toBe(0); expect(b1Metric.CII).toBeNull(); + expect(b1Metric.isCandidate).toBe(false); + expect(res.allPlayerBadges).toEqual(['b1']); + expect(res.isTriggered).toBe(false); }); - it('should anchor timeline to explicit startDate when provided', () => { - const explicitStart = new Date('2026-06-01T00:00:00.000Z'); - const asOf = new Date('2026-06-22T00:00:00.000Z'); // 21 days later = 3 periods of 7 days - - const ctx: IndicatorComputationContext = { - projectId: 'proj1', - badges: sampleBadges, - players: [{ id: 'p1', joinDate: explicitStart, earnedBadges: new Map() }], - checkins: [ - // check-in 2 days after start -> Period 1 - { userId: 'p1', datetime: new Date('2026-06-03T10:00:00.000Z') }, - // check-in 10 days after start -> Period 2 - { userId: 'p1', datetime: new Date('2026-06-11T10:00:00.000Z') }, - ], - startDate: explicitStart, - asOfDate: asOf, - daysPerPeriod: 7, - }; - - const res = strategy.calculateIndicators(ctx); - expect(res.startDate).toBe(explicitStart.toISOString()); - expect(res.currentPeriod).toBe(4); - const p1 = res.players.find((p) => p.playerId === 'p1')!; - expect(p1.periodContributions[1]).toBe(1); - expect(p1.periodContributions[2]).toBe(1); - }); - - it('should filter active players and evaluate community motivation based on minActiveCheckins', () => { + it('should restrict the evaluated player pool P based on minActiveCheckins before computing AB(p), ep(b), and CII(b)', () => { const p1Join = new Date('2026-06-01T00:00:00.000Z'); const players: PlayerProfile[] = [ { id: 'p1', joinDate: p1Join, earnedBadges: new Map() }, - { id: 'p2', joinDate: p1Join, earnedBadges: new Map() }, + { + id: 'p2', + joinDate: new Date('2026-05-01T00:00:00.000Z'), + earnedBadges: new Map(), + }, ]; // p1 has 3 checkins; p2 has only 1 checkin @@ -240,22 +254,20 @@ describe('VanishingBadgesStrategy', () => { badges: sampleBadges, players, checkins, - asOfDate: new Date('2026-06-14T23:59:59.000Z'), - daysPerPeriod: 7, - minActiveCheckins: 2, // p1 qualifies (3 >= 2), p2 does not (1 < 2) + asOfDate: new Date('2026-06-03T23:59:59.000Z'), + minActiveCheckins: 2, // p1 qualifies (2 checkins up to June 3 >= 2), p2 does not (1 < 2) }; const res = strategy.calculateIndicators(ctx); expect(res.activePlayers).toBe(1); expect(res.totalPlayers).toBe(2); + expect(res.players.map((p) => p.playerId)).toEqual(['p1']); - const p1Res = res.players.find((p) => p.playerId === 'p1')!; - const p2Res = res.players.find((p) => p.playerId === 'p2')!; - - // p1 is active and sole contributor to community motivation - expect(p1Res.relPMI).toBe(1.0); - // p2 is below minActiveCheckins threshold so relPMI is 0.0 - expect(p2Res.relPMI).toBe(0.0); + // Only p1 is in the eligible pool for b1 (elapsed 3 days -> i3 = 0.3333) + const b1Metric = res.badges.find((b) => b.badgeId === 'b1')!; + expect(b1Metric.eligibleCount).toBe(1); + expect(b1Metric.eligibleUsers).toEqual(['p1']); + expect(b1Metric.CII).toBe(0.3333); }); describe('computeMedian', () => { diff --git a/src/module/gamification-indicators/domain/vanishing-badges.strategy.ts b/src/module/gamification-indicators/domain/vanishing-badges.strategy.ts index 0abdd3d..c2ac85b 100644 --- a/src/module/gamification-indicators/domain/vanishing-badges.strategy.ts +++ b/src/module/gamification-indicators/domain/vanishing-badges.strategy.ts @@ -11,38 +11,46 @@ import { } from './indicator.types'; /** - * Standard implementation of the vanishing badges indicator framework: - * Adaptive Gamification Mechanism for Citizen Science platforms. + * Standard implementation of the Vanishing Badges indicator framework (Sept 18, 2026 Revision): + * Adaptive Gamification Mechanism for Citizen Science platforms (Torres & Dalponte Ayastuy). * - * Encapsulates Definitions 3.1 through 3.5 as pure, stateless mathematical operations. + * Encapsulates Definitions 3.1 through 3.4 and Section 4.1–4.2 heuristic pipeline + * as pure, stateless mathematical operations: + * - Def 3.1: Achievable Badges AB(p) + * - Def 3.2: Individual Interest Indicator i_3(p, b) + * - Def 3.3: Ignored Badges ignored_by(p) + * - Def 3.4: Community Interest Indicator CII(b) + * - §4.1: Adaptation Trigger (exists b in Candidates : CII(b) < x) + * - §4.2: Candidate Badge Filtering, Sorting & Selection */ @Injectable() export class VanishingBadgesStrategy implements IndicatorFormulaStrategy { + /** Default reference threshold x (e.g., 0.20 <=> 5 days elapsed without earning) */ + private static readonly DEFAULT_THRESHOLD_X = 0.2; + /** * Orchestrates the complete indicator calculation pipeline. * - * 1. Sorts and segments checkins into periods s in S based on `daysPerPeriod`. - * 2. Resolves earned badge state and historical contributions at time of award. - * 3. Calculates t_0(p, b) and achievability for each player and badge. - * 4. Calculates individual interest i_3(p, b) [Def 3.2]. - * 5. Computes badge-level metrics ET_b [Def 3.1] and CII(b) [Def 3.3]. - * 6. Computes player-level motivation PMI(p), relPMI(p) [Def 3.4], and CMI [Def 3.5]. - * 7. Identifies the lowest CII candidate badge for adaptation recommendation. + * 1. Filters and sorts check-ins up to `asOfDate`, resolving cumulative contributions + * and earned badges B_p per player. + * 2. Determines the evaluated player pool P (optionally filtered by `minActiveCheckins`). + * 3. Computes Achievable Badges AB(p) and t_0(p, b) [Def 3.1]. + * 4. Computes Individual Interest i_3(p, b) [Def 3.2] and Ignored Badges ignored_by(p) [Def 3.3]. + * 5. Computes Eligible Players ep(b) and Community Interest CII(b) [Def 3.4]. + * 6. Applies Candidate Badge Filtering (§4.2.1: excluding all-earned, unreachable, and expired badges). + * 7. Evaluates Adaptation Trigger (§4.1: exists b : CII(b) < x) and ranks candidate badges (§4.2.2). */ calculateIndicators( ctx: IndicatorComputationContext, ): CommunityIndicatorResult { - const { - projectId, - badges, - players, - checkins, - startDate, - asOfDate, - daysPerPeriod, - } = ctx; - - // Filter checkins up to asOfDate + const { projectId, badges, players, checkins, asOfDate } = ctx; + + const threshold = + ctx.threshold !== undefined && Number(ctx.threshold) > 0 + ? Number(ctx.threshold) + : VanishingBadgesStrategy.DEFAULT_THRESHOLD_X; + + // 1. Filter check-ins up to asOfDate and sort chronologically const validCheckins = checkins .filter((c) => new Date(c.datetime).getTime() <= asOfDate.getTime()) .sort( @@ -50,72 +58,38 @@ export class VanishingBadgesStrategy implements IndicatorFormulaStrategy { new Date(a.datetime).getTime() - new Date(b.datetime).getTime(), ); - /** - * Timeline Origin Determination for Period Partitioning (Period 1, 2, ...): - * - * - IF `startDate` IS PROVIDED: - * The timeline explicitly anchors to `startDate`. Period 1 begins on `startDate`. - * Contributions occurring prior to `startDate` are excluded from period-over-period - * motivation (PMI) evaluation. - * - * - IF `startDate` IS NOT PROVIDED (Default Behavior): - * The engine dynamically detects the earliest recorded check-in across the project - * (`validCheckins[0].datetime`). Period 1 begins at the exact moment the community - * started contributing. If no check-ins exist yet (cold start), it defaults to `asOfDate`. - */ - const timelineStart = startDate - ? new Date(startDate).getTime() - : validCheckins.length > 0 - ? new Date(validCheckins[0].datetime).getTime() - : asOfDate.getTime(); - - const periodMs = Math.max(1, daysPerPeriod) * 24 * 60 * 60 * 1000; - const currentPeriod = Math.max( - 1, - Math.ceil((asOfDate.getTime() - timelineStart + 1) / periodMs), - ); - - // 1. Group contributions per player and per period const playerTotalContribs: Record = {}; - const playerPeriodContribs: Record> = {}; const playerEarnedBadgesMap: Map< string, Map > = new Map(); players.forEach((p) => { - playerTotalContribs[p.id] = 0; - playerPeriodContribs[p.id] = {}; - playerEarnedBadgesMap.set(p.id, new Map(p.earnedBadges || [])); + const pId = String(p.id); + playerTotalContribs[pId] = 0; + playerEarnedBadgesMap.set(pId, new Map(p.earnedBadges || [])); }); validCheckins.forEach((c) => { - const pId = c.userId; - if (!playerPeriodContribs[pId]) { + const pId = String(c.userId); + if (playerTotalContribs[pId] === undefined) { playerTotalContribs[pId] = 0; - playerPeriodContribs[pId] = {}; + } + if (!playerEarnedBadgesMap.has(pId)) { playerEarnedBadgesMap.set(pId, new Map()); } playerTotalContribs[pId] = (playerTotalContribs[pId] || 0) + 1; - const checkinTime = new Date(c.datetime).getTime(); - if (checkinTime >= timelineStart) { - const s = Math.max( - 1, - Math.ceil((checkinTime - timelineStart + 1) / periodMs), - ); - playerPeriodContribs[pId][s] = (playerPeriodContribs[pId][s] || 0) + 1; - } - // Track newly awarded badges if present on checkin + // Track newly awarded badges if present on check-in if (c.newBadges && c.newBadges.length > 0) { c.newBadges.forEach((badgeRef) => { const matchedBadge = badges.find( (b) => b.id === badgeRef || b.name === badgeRef, ); if (matchedBadge) { - const playerBadgeMap = playerEarnedBadgesMap.get(pId)!; - if (!playerBadgeMap.has(matchedBadge.id)) { + const playerBadgeMap = playerEarnedBadgesMap.get(pId); + if (playerBadgeMap && !playerBadgeMap.has(matchedBadge.id)) { playerBadgeMap.set(matchedBadge.id, { badgeId: matchedBadge.id, earnedAt: new Date(c.datetime), @@ -127,17 +101,41 @@ export class VanishingBadgesStrategy implements IndicatorFormulaStrategy { } }); - // 2. Compute achievability and t_0(p, b) for every player and badge + // 2. Determine evaluated player pool P based on optional minActiveCheckins filter + const minCheckins = + ctx.minActiveCheckins !== undefined && Number(ctx.minActiveCheckins) > 0 + ? Number(ctx.minActiveCheckins) + : 0; + + const evaluatedPlayers = + minCheckins > 0 + ? players.filter( + (p) => (playerTotalContribs[String(p.id)] || 0) >= minCheckins, + ) + : players; + + const activePlayersCount = + minCheckins > 0 + ? evaluatedPlayers.length + : players.filter((p) => (playerTotalContribs[String(p.id)] || 0) >= 1) + .length; + + // 3. Compute AB(p) [Def 3.1], t_0(p, b), i_3(p, b) [Def 3.2], and ignored_by(p) [Def 3.3] const playerBadgeT0: Record> = {}; const playerBadgeAchievable: Record> = {}; const playerBadgeI3: Record> = {}; + const playerABList: Record = {}; + const playerIgnoredList: Record = {}; players.forEach((p) => { - playerBadgeT0[p.id] = {}; - playerBadgeAchievable[p.id] = {}; - playerBadgeI3[p.id] = {}; + const pId = String(p.id); + playerBadgeT0[pId] = {}; + playerBadgeAchievable[pId] = {}; + playerBadgeI3[pId] = {}; + playerABList[pId] = []; - const earned = playerEarnedBadgesMap.get(p.id) || new Map(); + const earned = playerEarnedBadgesMap.get(pId) || new Map(); + const earnedIds = new Set(earned.keys()); badges.forEach((b) => { const { isAchievable, t0Date } = this.computeAchievabilityAndT0( @@ -146,13 +144,17 @@ export class VanishingBadgesStrategy implements IndicatorFormulaStrategy { badges, earned, ); - playerBadgeAchievable[p.id][b.id] = isAchievable; - playerBadgeT0[p.id][b.id] = t0Date; + playerBadgeAchievable[pId][b.id] = isAchievable; + playerBadgeT0[pId][b.id] = t0Date; + + if (isAchievable) { + playerABList[pId].push(b.id); + } const isEarned = earned.has(b.id); const earnedDate = isEarned ? earned.get(b.id)!.earnedAt : null; - playerBadgeI3[p.id][b.id] = this.computeIndividualInterest( + playerBadgeI3[pId][b.id] = this.computeIndividualInterest( isAchievable, isEarned, t0Date, @@ -160,84 +162,101 @@ export class VanishingBadgesStrategy implements IndicatorFormulaStrategy { asOfDate, ); }); + + playerIgnoredList[pId] = this.computeIgnoredBadges( + playerABList[pId], + earnedIds, + playerBadgeI3[pId], + threshold, + ); }); - // 3. Compute badge metrics: ET_b, eligible pool ep(b), and CII(b) - const badgeMetrics: BadgeIndicatorResult[] = badges.map((b) => { - // U_b: Players who have earned badge b - const UbPlayers: { playerId: string; contribsAtEarn: number }[] = []; - players.forEach((p) => { - const earned = playerEarnedBadgesMap.get(p.id); - if (earned && earned.has(b.id)) { - UbPlayers.push({ - playerId: p.id, - contribsAtEarn: earned.get(b.id)!.contribsAtEarn, - }); - } - }); + // 4. Section 4.2.1 Candidate Filtering (over evaluatedPlayers pool P) + // all_player_badges = assigned_badges(players, badges) (earned by everyone in P) + // unreachable_badges = unreachable_badges(players, badges) (no player in P has b in AB(p)) + const allPlayerBadges: string[] = []; + const unreachableBadges: string[] = []; + const candidateBadges: string[] = []; - const ET_b = this.computeEstimatedAwardingTime(b, badges, UbPlayers); + // 5. Compute badge metrics: U_b, eligible pool ep(b), and CII(b) [Def 3.4] + let lowestCII: number | null = null; + let communityIgnoredCount = 0; - // ep(b): Eligible players who can achieve b but have NOT earned it yet - const eligiblePlayers = players.filter((p) => { - const isAchievable = playerBadgeAchievable[p.id]?.[b.id] ?? false; - const hasEarned = playerEarnedBadgesMap.get(p.id)?.has(b.id) ?? false; + const badgeMetrics: BadgeIndicatorResult[] = badges.map((b) => { + // U_b: Players in P who have earned badge b + const UbPlayers = evaluatedPlayers.filter((p) => + playerEarnedBadgesMap.get(String(p.id))?.has(b.id), + ); + + // ep(b) = { p in P : b in AB(p) - B_p } + const eligiblePlayers = evaluatedPlayers.filter((p) => { + const pId = String(p.id); + const isAchievable = playerBadgeAchievable[pId]?.[b.id] ?? false; + const hasEarned = playerEarnedBadgesMap.get(pId)?.has(b.id) ?? false; return isAchievable && !hasEarned; }); const eligibleI3Values = eligiblePlayers.map( - (p) => playerBadgeI3[p.id][b.id], + (p) => playerBadgeI3[String(p.id)][b.id], ); const CII = this.computeCommunityInterest(eligibleI3Values); + if (CII !== null && (lowestCII === null || CII < lowestCII)) { + lowestCII = CII; + } + + const isCommunityIgnored = CII !== null && CII < threshold; + if (isCommunityIgnored) { + communityIgnoredCount++; + } + + const isAllEarned = + evaluatedPlayers.length > 0 && + UbPlayers.length === evaluatedPlayers.length; + const canAnyoneAchieve = evaluatedPlayers.some( + (p) => playerBadgeAchievable[String(p.id)]?.[b.id] ?? false, + ); + const isUnreachable = !canAnyoneAchieve; + const isExpired = b.status === 'expired'; + + if (isAllEarned) { + allPlayerBadges.push(b.id); + } + if (isUnreachable) { + unreachableBadges.push(b.id); + } + + const isCandidate = !isAllEarned && !isUnreachable && !isExpired; + if (isCandidate) { + candidateBadges.push(b.id); + } + return { badgeId: b.id, badgeName: b.name, status: b.status, earnedCount: UbPlayers.length, - earnedUsers: UbPlayers.map((u) => u.playerId), - ET_b, + earnedUsers: UbPlayers.map((u) => String(u.id)), eligibleCount: eligiblePlayers.length, - eligibleUsers: eligiblePlayers.map((p) => p.id), + eligibleUsers: eligiblePlayers.map((p) => String(p.id)), CII, + isCommunityIgnored, + isCandidate, isLowestCII: false, }; }); - const minCheckins = - ctx.minActiveCheckins !== undefined && Number(ctx.minActiveCheckins) > 0 - ? Number(ctx.minActiveCheckins) - : 1; + // 6. Section 4.1 Trigger Condition: + // Adaptation is triggered when a candidate badge has CII(b) < x + const triggerBadges = badgeMetrics + .filter((b) => b.isCandidate && b.CII !== null && b.CII < threshold) + .map((b) => b.badgeId); + const isTriggered = triggerBadges.length > 0; - // 4. Compute Player Motivation Indicator PMI(p) and relative relPMI(p) - const allPlayerPMIs = players.map((p) => { - const pmi = this.computePlayerMotivation( - playerPeriodContribs[p.id] || {}, - currentPeriod, - ); - return { playerId: p.id, pmi }; - }); - - // P: set of active players meeting the minActiveCheckins threshold - const activePlayerPMIs = allPlayerPMIs.filter( - (item) => (playerTotalContribs[item.playerId] || 0) >= minCheckins, - ); - - const { avgPMI, playerRelPMIs, CMI } = - this.computeCommunityMotivation(activePlayerPMIs); - - const playerResults: PlayerIndicatorResult[] = players.map((p) => ({ - playerId: p.id, - totalContributions: playerTotalContribs[p.id] || 0, - periodContributions: playerPeriodContribs[p.id] || {}, - PMI: allPlayerPMIs.find((item) => item.playerId === p.id)?.pmi || 0, - relPMI: playerRelPMIs[p.id] || 0.0, - })); - - // 5. Rank candidate badges by ascending CII to determine adaptation candidate - // Candidate badges must be 'active' and have an active eligible pool (CII !== null) + // 7. Section 4.2.2 Sorting & Selection: + // Rank active candidate badges by ascending CII(b) to identify adaptation candidate const rankedCandidates = badgeMetrics - .filter((b) => b.status === 'active' && b.CII !== null) + .filter((b) => b.status === 'active' && b.isCandidate && b.CII !== null) .sort((a, b) => (a.CII ?? 999) - (b.CII ?? 999)); const topCandidate = @@ -251,19 +270,39 @@ export class VanishingBadgesStrategy implements IndicatorFormulaStrategy { } } - const activePlayersCount = activePlayerPMIs.length; + // Build player indicator results for the evaluated player pool P + let totalPlayerIgnored = 0; + const playerResults: PlayerIndicatorResult[] = evaluatedPlayers.map((p) => { + const pId = String(p.id); + const earned = playerEarnedBadgesMap.get(pId) || new Map(); + const ignored = playerIgnoredList[pId] || []; + totalPlayerIgnored += ignored.length; + + return { + playerId: pId, + totalContributions: playerTotalContribs[pId] || 0, + earnedBadges: Array.from(earned.keys()), + achievableBadges: playerABList[pId] || [], + ignoredBadges: ignored, + individualInterest: playerBadgeI3[pId] || {}, + }; + }); return { projectId, - currentPeriod, - startDate: new Date(timelineStart).toISOString(), asOfDate: asOfDate.toISOString(), - daysPerPeriod, + threshold, totalPlayers: players.length, activePlayers: activePlayersCount, totalContributions: validCheckins.length, - avgPMI, - CMI, + isTriggered, + triggerBadges, + communityIgnoredCount, + totalPlayerIgnored, + allPlayerBadges, + unreachableBadges, + candidateBadges, + lowestCII, badges: badgeMetrics, players: playerResults, adaptationCandidateBadge: topCandidate, @@ -271,56 +310,17 @@ export class VanishingBadgesStrategy implements IndicatorFormulaStrategy { } /** - * Definition 3.1: Estimated Awarding Time (ET_b) + * Definition 3.1: Achievable Badges AB(p) and t_0(p, b). * * Formula: - * ET_b = (1 / |U_b|) * SUM_{p in U_b} (cnum(p, t_earned(p, b))) + * AB(p) = { b in Badges | forall prev in prerequisites(b), prev in B_p } * * Explanation: - * Represents the historical average number of check-ins/contributions a player - * accumulated at the exact moment they earned badge b. - * - * Fallback: - * When |U_b| = 0 (cold start / no player has unlocked the badge yet), - * the estimate defaults to the sum of the badge's required check-ins plus - * the required check-ins of all its direct prerequisites. - */ - computeEstimatedAwardingTime( - badge: BadgeDefinition, - allBadges: BadgeDefinition[], - earnedPlayers: { playerId: string; contribsAtEarn: number }[], - ): number { - if (earnedPlayers.length > 0) { - const sum = earnedPlayers.reduce((acc, p) => acc + p.contribsAtEarn, 0); - return parseFloat((sum / earnedPlayers.length).toFixed(2)); - } - - // Cold-start fallback: required checkins + direct prerequisite requirements - let fallback = badge.reqCheckins; - (badge.previousBadges || []).forEach((prevRef) => { - const prevBadge = allBadges.find( - (b) => b.id === prevRef || b.name === prevRef, - ); - if (prevBadge) { - fallback += prevBadge.reqCheckins; - } - }); - - return parseFloat(fallback.toFixed(2)); - } - - /** - * Computes Achievability and t_0(p, b) for a player and badge. - * - * Definition: - * t_0(p, b) represents the moment in time when badge b became available/achievable - * for player p: + * A badge is achievable to a player when all the previous badges (prerequisites) + * have been earned. + * t_0(p, b) represents the timestamp when badge b became achievable for player p: * - Root badges (no prerequisites): t_0 = player's join date. * - Child badges: t_0 = MAX(earnedAt(prereq)) across all direct prerequisites. - * - * Achievability: - * A badge is achievable for player p if and only if all prerequisite badges - * specified in `previousBadges` have been completed. */ computeAchievabilityAndT0( player: PlayerProfile, @@ -338,7 +338,6 @@ export class VanishingBadgesStrategy implements IndicatorFormulaStrategy { // Check if every prerequisite badge has been earned let maxEarnedDate = player.joinDate; for (const prereqRef of prereqs) { - // Resolve prerequisite badge by id or name const prereqDef = allBadges.find( (b) => b.id === prereqRef || b.name === prereqRef, ); @@ -346,7 +345,7 @@ export class VanishingBadgesStrategy implements IndicatorFormulaStrategy { const earnedInfo = earnedBadges.get(prereqId); if (!earnedInfo) { - // Prerequisite missing: badge is locked/unachievable + // Prerequisite missing: badge is locked/unachievable (b not in AB(p)) return { isAchievable: false, t0Date: player.joinDate }; } @@ -362,13 +361,13 @@ export class VanishingBadgesStrategy implements IndicatorFormulaStrategy { * Definition 3.2: Individual Interest Indicator i_3(p, b) * * Formula: - * i_3(p, b) = 1.0 / (now - t_0(p, b)) if badge b is achievable - * i_3(p, b) = 1.0 if badge b is NOT achievable (locked) + * i_3(p, b) = 1.0 / (now - t_0(p, b)) if b in AB(p) (b is achievable by p) + * i_3(p, b) = 1.0 if b not in AB(p) (b is not achievable by p) * * Explanation: - * Captures the urgency/momentum of player p towards badge b. As elapsed days - * grow without earning the badge, i_3 decays towards 0, reflecting waning interest - * or a bottleneck. If already earned, elapsed days is fixed to (earnedAt - t_0). + * Captures the inverse of the time elapsed since the prerequisites for badge b + * were met by player p (t_0). As elapsed days grow without earning the badge, + * i_3 decays towards 0, reflecting waning interest. * * Note on Units: * Elapsed time is measured in whole days (minimum 1 day to prevent division by zero). @@ -401,111 +400,51 @@ export class VanishingBadgesStrategy implements IndicatorFormulaStrategy { } /** - * Definition 3.3: Community Interest Indicator CII(b) - * - * Formula: - * CII(b) = median({ i_3(p, b) : p in ep(b) }) - * where ep(b) = { p in P : b is achievable for p AND p has not earned b } - * - * Explanation: - * Aggregates the median interest across all players who actively could earn badge b - * right now. Low CII(b) identifies badges that players have unlocked but are neglecting. - * - * Edge Cases: - * If ep(b) is empty (no player currently eligible), returns null. - */ - computeCommunityInterest(eligibleI3Values: number[]): number | null { - if (eligibleI3Values.length === 0) { - return null; - } - - const medianVal = this.computeMedian(eligibleI3Values); - return parseFloat(medianVal.toFixed(4)); - } - - /** - * Definition 3.4: Player Motivation Indicator PMI(p) + * Definition 3.3: Ignored Badges ignored_by(p) * * Formula: - * PMI(p) = SUM_{s in S} [ cnum(p, s) >= cnum(p, prev(s)) AND (cnum(p, s) + cnum(p, prev(s)) > 0) ] + * ignored_by(p) = { b in AB(p) - B_p : i_3(p, b) < x } * * Explanation: - * Measures consistency of player engagement across periods s in S. - * A period awards +1 point if the player made contributions and their volume - * did not drop compared to the preceding period (prev(s)). + * The set of ignored badges by a player p is a subset of AB(p) that have not + * yet been earned (AB(p) - B_p) whose individual interest i_3 is below threshold x. */ - computePlayerMotivation( - periodContributions: Record, - currentPeriod: number, - ): number { - let pmiCount = 0; - - for (let s = 1; s <= currentPeriod; s++) { - const currC = periodContributions[s] || 0; - const prevC = s > 1 ? periodContributions[s - 1] || 0 : 0; - - // Both zero -> inactive period, no point - if (currC + prevC > 0) { - if (currC >= prevC) { - pmiCount++; - } + computeIgnoredBadges( + achievableBadgeIds: string[], + earnedBadgeIds: Set, + playerI3Map: Record, + threshold: number, + ): string[] { + return achievableBadgeIds.filter((badgeId) => { + if (earnedBadgeIds.has(badgeId)) { + return false; } - } - - return pmiCount; + const i3 = playerI3Map[badgeId] ?? 1.0; + return i3 < threshold; + }); } /** - * Definitions 3.4 & 3.5: Relative Motivation relPMI(p) and Community Motivation Indicator CMI + * Definition 3.4: Community Interest Indicator CII(b) * - * Formulas: - * avgPMI = (1 / |P|) * SUM_{p in P} PMI(p) - * relPMI(p) = PMI(p) / avgPMI - * CMI = median({ relPMI(p) : p in P }) + * Formula: + * CII(b) = median({ i_3(p, b) : p in ep(b) }) + * where ep(b) = { p in P : b in AB(p) - B_p } * * Explanation: - * relPMI normalizes individual motivation against the community standard. - * CMI takes the community median. A drop in CMI below 1.0 indicates community-wide - * motivation decay, triggering the vanishing badge adaptation heuristic (§4). + * Aggregates the median individual interest across all players who are currently + * eligible for badge b. Low CII(b) (< x) identifies badges ignored by the community (§4.1). * - * Edge Case Handling: - * If avgPMI = 0 (community cold start or complete inactivity), relPMI evaluates - * to 0.0 for inactive players, preventing division-by-zero or NaN values. + * Edge Cases: + * If ep(b) is empty (no player currently eligible), returns null. */ - computeCommunityMotivation(playerPMIs: { playerId: string; pmi: number }[]): { - avgPMI: number; - playerRelPMIs: Record; - CMI: number; - } { - const totalPlayers = playerPMIs.length; - if (totalPlayers === 0) { - return { avgPMI: 0, playerRelPMIs: {}, CMI: 0.0 }; + computeCommunityInterest(eligibleI3Values: number[]): number | null { + if (eligibleI3Values.length === 0) { + return null; } - const totalPMI = playerPMIs.reduce((acc, item) => acc + item.pmi, 0); - const avgPMI = totalPMI / totalPlayers; - - const playerRelPMIs: Record = {}; - - playerPMIs.forEach((item) => { - if (avgPMI > 0) { - playerRelPMIs[item.playerId] = parseFloat( - (item.pmi / avgPMI).toFixed(3), - ); - } else { - // Cold start fallback: 1.0 if player alone has activity, else 0.0 - playerRelPMIs[item.playerId] = item.pmi > 0 ? 1.0 : 0.0; - } - }); - - const relValues = playerPMIs.map((item) => playerRelPMIs[item.playerId]); - const medianCMI = this.computeMedian(relValues); - - return { - avgPMI: parseFloat(avgPMI.toFixed(2)), - playerRelPMIs, - CMI: isNaN(medianCMI) ? 0.0 : parseFloat(medianCMI.toFixed(3)), - }; + const medianVal = this.computeMedian(eligibleI3Values); + return parseFloat(medianVal.toFixed(4)); } /** diff --git a/src/module/gamification-indicators/dto/get-indicators-query.dto.ts b/src/module/gamification-indicators/dto/get-indicators-query.dto.ts index 34c80b3..c2192b9 100644 --- a/src/module/gamification-indicators/dto/get-indicators-query.dto.ts +++ b/src/module/gamification-indicators/dto/get-indicators-query.dto.ts @@ -5,44 +5,33 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; */ export class GetIndicatorsQueryDto { /** - * Number of days per evaluation period s in S (default: 7). - */ - @ApiPropertyOptional({ - description: 'Number of days per period s in S', - default: 7, - example: 7, - }) - daysPerPeriod?: number | string; - - /** - * Optional timeline origin for period partitioning (Period 1 Day 1). - * Accepts DD-MM-YYYY (e.g. "01-06-2026") or ISO-8601. - * If omitted, defaults to the timestamp of the project's earliest recorded check-in - * (or asOfDate if no check-ins exist). + * Point-in-time calculation horizon. Accepts DD-MM-YYYY (e.g. "20-07-2026") or ISO-8601 (default: current server time). */ @ApiPropertyOptional({ description: - 'Timeline origin for period partitioning. Accepts DD-MM-YYYY or ISO-8601 format. If omitted, defaults to earliest check-in timestamp.', - example: '01-06-2026', + 'Point-in-time calculation horizon. Accepts DD-MM-YYYY or ISO-8601 format', + example: '20-07-2026', }) - startDate?: string; + asOfDate?: string; /** - * Point-in-time calculation horizon. Accepts DD-MM-YYYY (e.g. "20-07-2026") or ISO-8601 (default: current server time). + * Reference threshold x for Ignored Badges (Def 3.3: i3 < x) and Adaptation Trigger (§4.1: CII < x). + * Default: 0.20 (corresponding to > 5 days elapsed since badge became achievable). */ @ApiPropertyOptional({ description: - 'Point-in-time calculation horizon. Accepts DD-MM-YYYY or ISO-8601 format', - example: '20-07-2026', + 'Reference threshold x for ignored badges (Def 3.3) and adaptation trigger (§4.1)', + default: 0.2, + example: 0.2, }) - asOfDate?: string; + threshold?: number | string; /** - * Optional minimum check-ins to filter active players. + * Optional minimum check-ins to restrict the evaluated player pool P. */ @ApiPropertyOptional({ description: - 'Minimum contributions required for player to be considered active', + 'Minimum contributions required for a player to be included in the evaluated pool P', default: 0, example: 1, }) diff --git a/src/module/gamification-indicators/dto/indicators-response.dto.ts b/src/module/gamification-indicators/dto/indicators-response.dto.ts index 004f058..e96b569 100644 --- a/src/module/gamification-indicators/dto/indicators-response.dto.ts +++ b/src/module/gamification-indicators/dto/indicators-response.dto.ts @@ -17,35 +17,45 @@ export class BadgeIndicatorDto { earnedCount: number; @ApiProperty({ - description: 'User IDs who have earned this badge', + description: 'User IDs who have earned this badge (U_b)', type: [String], }) earnedUsers: string[]; @ApiProperty({ description: - 'Estimated Awarding Time (historical average contributions at award)', + 'Number of eligible players who unlocked prerequisites (b in AB(p)) but have not yet earned it', }) - ET_b: number; + eligibleCount: number; @ApiProperty({ - description: - 'Number of eligible players who unlocked prerequisites but not yet earned', + description: 'User IDs of eligible players ep(b)', + type: [String], }) - eligibleCount: number; - - @ApiProperty({ description: 'User IDs of eligible players', type: [String] }) eligibleUsers: string[]; @ApiProperty({ description: - 'Community Interest Indicator (median individual interest across eligible players)', + 'Community Interest Indicator [Def 3.4] (median individual interest i3 across eligible players)', nullable: true, }) CII: number | null; @ApiProperty({ - description: 'True if this badge has the lowest CII in the community', + description: + 'True if CII(b) is below reference threshold x (§4.1 ignored by community)', + }) + isCommunityIgnored: boolean; + + @ApiProperty({ + description: + 'True if badge belongs to the filtered candidate pool (§4.2.1: not all-earned, not unreachable, not expired)', + }) + isCandidate: boolean; + + @ApiProperty({ + description: + 'True if this badge has the lowest CII among active candidates', }) isLowestCII: boolean; } @@ -57,67 +67,118 @@ export class PlayerIndicatorDto { @ApiProperty({ description: 'Total lifetime check-in contributions' }) totalContributions: number; - @ApiProperty({ description: 'Contributions count per period key' }) - periodContributions: Record; + @ApiProperty({ + description: 'Badge IDs already earned by the player (B_p)', + type: [String], + }) + earnedBadges: string[]; + + @ApiProperty({ + description: + 'Achievable Badges AB(p) [Def 3.1]: badge IDs whose prerequisites have been met', + type: [String], + }) + achievableBadges: string[]; @ApiProperty({ description: - 'Player Motivation Indicator (periods with growth/sustained activity)', + 'Ignored Badges ignored_by(p) [Def 3.3]: achievable unearned badges with i3(p, b) < threshold x', + type: [String], }) - PMI: number; + ignoredBadges: string[]; @ApiProperty({ - description: 'Relative PMI normalized against community average', + description: + 'Individual Interest Indicator i3(p, b) [Def 3.2] mapped by badgeId', }) - relPMI: number; + individualInterest: Record; } export class CommunityIndicatorsResponseDto { @ApiProperty({ description: 'Project unique identifier' }) projectId: string; - @ApiProperty({ description: 'Current evaluated period number s' }) - currentPeriod: number; - - @ApiProperty({ - description: 'Timeline origin timestamp used for Period 1 (ISO-8601)', - }) - startDate: string; - @ApiProperty({ description: 'Horizon timestamp used for evaluation (ISO-8601)', }) asOfDate: string; - @ApiProperty({ description: 'Period length in days' }) - daysPerPeriod: number; + @ApiProperty({ + description: + 'Reference threshold x used for ignored badges (Def 3.3) and adaptation trigger (§4.1)', + }) + threshold: number; @ApiProperty({ description: 'Total registered players in project' }) totalPlayers: number; - @ApiProperty({ description: 'Players with at least one contribution' }) + @ApiProperty({ + description: 'Active players meeting the contribution threshold', + }) activePlayers: number; @ApiProperty({ description: 'Total valid contributions recorded' }) totalContributions: number; - @ApiProperty({ description: 'Average PMI across community players' }) - avgPMI: number; + @ApiProperty({ + description: + 'Section 4.1 Trigger status: true if there exists a candidate badge b with CII(b) < threshold x', + }) + isTriggered: boolean; + + @ApiProperty({ + description: 'Candidate badge IDs triggering adaptation (CII(b) < x)', + type: [String], + }) + triggerBadges: string[]; + + @ApiProperty({ + description: 'Count of badges ignored by the community (CII(b) < x)', + }) + communityIgnoredCount: number; + + @ApiProperty({ + description: + 'Sum of |ignored_by(p)| across all players in the evaluated pool', + }) + totalPlayerIgnored: number; @ApiProperty({ description: - 'Community Motivation Indicator (median relPMI across players)', + 'Section 4.2.1: Badge IDs earned by all evaluated players (assigned_badges)', + type: [String], }) - CMI: number; + allPlayerBadges: string[]; @ApiProperty({ - description: 'Badge indicators list sorted by CII ascending', + description: + 'Section 4.2.1: Badge IDs unreachable by any evaluated player (unreachable_badges)', + type: [String], + }) + unreachableBadges: string[]; + + @ApiProperty({ + description: + 'Section 4.2.1: Filtered candidate badge IDs eligible for vanishing selection', + type: [String], + }) + candidateBadges: string[]; + + @ApiProperty({ + description: 'Lowest CII value across badges with non-empty eligible pools', + nullable: true, + }) + lowestCII: number | null; + + @ApiProperty({ + description: 'Badge indicators list', type: [BadgeIndicatorDto], }) badges: BadgeIndicatorDto[]; @ApiProperty({ - description: 'Player motivation indicators', + description: + 'Player indicators (AB(p), ignored_by(p), and individual interest i3)', type: [PlayerIndicatorDto], }) players: PlayerIndicatorDto[]; diff --git a/src/module/gamification-indicators/gamification-indicators.controller.spec.ts b/src/module/gamification-indicators/gamification-indicators.controller.spec.ts index 6b49342..4ecb5a5 100644 --- a/src/module/gamification-indicators/gamification-indicators.controller.spec.ts +++ b/src/module/gamification-indicators/gamification-indicators.controller.spec.ts @@ -12,15 +12,19 @@ describe('GamificationIndicatorsController', () => { const mockResponse: CommunityIndicatorsResponseDto = { projectId: 'proj1', - currentPeriod: 2, - startDate: '2026-06-01T00:00:00.000Z', asOfDate: '2026-06-15T00:00:00.000Z', - daysPerPeriod: 7, + threshold: 0.2, totalPlayers: 10, activePlayers: 5, totalContributions: 25, - avgPMI: 1.8, - CMI: 1.1, + isTriggered: false, + triggerBadges: [], + communityIgnoredCount: 0, + totalPlayerIgnored: 0, + allPlayerBadges: [], + unreachableBadges: [], + candidateBadges: [], + lowestCII: null, badges: [], players: [], adaptationCandidateBadge: null, @@ -57,12 +61,12 @@ describe('GamificationIndicatorsController', () => { it('should return indicators from service', async () => { const result = await controller.getIndicators('proj1', { - daysPerPeriod: 7, + threshold: 0.2, asOfDate: '2026-06-15T00:00:00.000Z', }); expect(service.computeIndicators).toHaveBeenCalledWith('proj1', { - daysPerPeriod: 7, + threshold: 0.2, asOfDate: '2026-06-15T00:00:00.000Z', }); expect(result).toEqual(mockResponse); diff --git a/src/module/gamification-indicators/gamification-indicators.controller.ts b/src/module/gamification-indicators/gamification-indicators.controller.ts index 50ca3a7..fd101c6 100644 --- a/src/module/gamification-indicators/gamification-indicators.controller.ts +++ b/src/module/gamification-indicators/gamification-indicators.controller.ts @@ -19,13 +19,14 @@ export class GamificationIndicatorsController { /** * Triggers the calculation of adaptive gamification indicators for a project. - * Computes community metrics (CMI, avgPMI) and badge metrics (CII, ET_b). + * Computes player metrics (AB(p), i3(p,b), ignored_by(p)), badge metrics (CII(b)), + * and §4.1/§4.2 vanishing badge adaptation triggers. * Restricted to administrators to protect volunteer behavioral telemetry. */ @Get(':projectId') @ApiOperation({ summary: - 'Calculates and returns adaptive gamification indicators (CII, CMI, ET_b) for a project', + 'Calculates and returns adaptive gamification indicators (AB, i3, ignored_by, CII) for a project', }) @ApiResponse({ status: 200, diff --git a/src/module/gamification-indicators/gamification-indicators.service.spec.ts b/src/module/gamification-indicators/gamification-indicators.service.spec.ts index 444fcb3..0bde4c9 100644 --- a/src/module/gamification-indicators/gamification-indicators.service.spec.ts +++ b/src/module/gamification-indicators/gamification-indicators.service.spec.ts @@ -39,15 +39,19 @@ describe('GamificationIndicatorsService', () => { formulaStrategy = { calculateIndicators: jest.fn().mockReturnValue({ projectId: 'proj1', - currentPeriod: 1, - startDate: new Date().toISOString(), asOfDate: new Date().toISOString(), - daysPerPeriod: 7, + threshold: 0.2, totalPlayers: 0, activePlayers: 0, totalContributions: 0, - avgPMI: 0, - CMI: 0, + isTriggered: false, + triggerBadges: [], + communityIgnoredCount: 0, + totalPlayerIgnored: 0, + allPlayerBadges: [], + unreachableBadges: [], + candidateBadges: [], + lowestCII: null, badges: [], players: [], adaptationCandidateBadge: null, @@ -80,25 +84,24 @@ describe('GamificationIndicatorsService', () => { ).rejects.toThrow(BadRequestException); }); - it('should throw BadRequestException if startDate is after asOfDate', async () => { + it('should throw BadRequestException if threshold is invalid or non-positive', async () => { await expect( service.computeIndicators('proj1', { - startDate: '25-07-2026', - asOfDate: '20-07-2026', + threshold: -0.1, }), ).rejects.toThrow(BadRequestException); }); - it('should parse DD-MM-YYYY format for asOfDate and startDate', async () => { + it('should parse DD-MM-YYYY format for asOfDate and custom threshold', async () => { const res = await service.computeIndicators('proj1', { - startDate: '01-07-2026', asOfDate: '20-07-2026', + threshold: '0.25', }); expect(res).toBeDefined(); expect(formulaStrategy.calculateIndicators).toHaveBeenCalledWith( expect.objectContaining({ - startDate: expect.any(Date), asOfDate: expect.any(Date), + threshold: 0.25, }), ); }); @@ -141,8 +144,8 @@ describe('GamificationIndicatorsService', () => { ]); const result = await service.computeIndicators('proj1', { - daysPerPeriod: 7, asOfDate: '2026-06-15T00:00:00.000Z', + threshold: 0.2, }); expect(gamificationDao.getGamificationByProjectId).toHaveBeenCalledWith( diff --git a/src/module/gamification-indicators/gamification-indicators.service.ts b/src/module/gamification-indicators/gamification-indicators.service.ts index d54fd11..4aae5ab 100644 --- a/src/module/gamification-indicators/gamification-indicators.service.ts +++ b/src/module/gamification-indicators/gamification-indicators.service.ts @@ -43,16 +43,19 @@ export class GamificationIndicatorsService { projectId: string, query?: GetIndicatorsQueryDto, ): Promise { - const daysPerPeriod = Math.max(1, Number(query?.daysPerPeriod) || 7); - const startDate = query?.startDate - ? this.parseDate(query.startDate, 'start', 'startDate') - : undefined; const asOfDate = query?.asOfDate ? this.parseDate(query.asOfDate, 'end', 'asOfDate') : new Date(); - if (startDate && startDate.getTime() > asOfDate.getTime()) { - throw new BadRequestException('startDate cannot be after asOfDate'); + let threshold = 0.2; + if (query?.threshold !== undefined && query?.threshold !== '') { + const parsedThreshold = Number(query.threshold); + if (isNaN(parsedThreshold) || parsedThreshold <= 0) { + throw new BadRequestException( + `Invalid threshold value: "${query.threshold}". Must be a positive number (e.g. 0.20).`, + ); + } + threshold = parsedThreshold; } // 1. Fetch project gamification rules @@ -101,9 +104,10 @@ export class GamificationIndicatorsService { const checkins: CheckinRecord[] = rawCheckins.map((c) => { const chId = String(c.id); const move = moveByCheckinId.get(chId); + const rawUserId = c.userId || c.user?.id || (c as any).userId; return { id: chId, - userId: c.userId || c.user?.id || (c as any).userId, + userId: rawUserId ? String(rawUserId) : '', datetime: new Date(c.date), taskType: c.taskType, contributesTo: c.contributesTo, @@ -113,13 +117,14 @@ export class GamificationIndicatorsService { // Build player profiles and resolve earned badges with timestamps & contribution counts const players: PlayerProfile[] = rawUsers.map((u) => { + const uId = String(u.id); const earnedBadges = new Map(); const projectProfile = u.getGameProfileFromProject(projectId); const profileBadges = projectProfile?.badges || []; // Find user check-ins sorted chronologically const userCheckins = checkins - .filter((c) => c.userId === u.id) + .filter((c) => String(c.userId) === uId) .sort((a, b) => a.datetime.getTime() - b.datetime.getTime()); let runningContribCount = 0; @@ -156,15 +161,16 @@ export class GamificationIndicatorsService { }); return { - id: u.id, + id: uId, joinDate: u.createdAt || asOfDate, earnedBadges, }; }); - const minActiveCheckins = query.minActiveCheckins - ? parseInt(String(query.minActiveCheckins), 10) - : 1; + const minActiveCheckins = + query?.minActiveCheckins !== undefined + ? parseInt(String(query.minActiveCheckins), 10) + : 0; // 5. Build computation context and delegate to formula strategy const ctx: IndicatorComputationContext = { @@ -172,10 +178,9 @@ export class GamificationIndicatorsService { badges, players, checkins, - startDate, asOfDate, - daysPerPeriod, - minActiveCheckins: isNaN(minActiveCheckins) ? 1 : minActiveCheckins, + threshold, + minActiveCheckins: isNaN(minActiveCheckins) ? 0 : minActiveCheckins, }; return this.formulaStrategy.calculateIndicators(ctx);