diff --git a/apps/access-api/prisma/schema.prisma b/apps/access-api/prisma/schema.prisma index 01aac9b..c49c625 100644 --- a/apps/access-api/prisma/schema.prisma +++ b/apps/access-api/prisma/schema.prisma @@ -592,18 +592,46 @@ model Approval { } model ContributionScore { - id String @id @default(uuid()) - walletId String + id String @id @default(uuid()) + walletId String communityId String - totalScore Int @default(0) - breakdown Json? - updatedAt DateTime @updatedAt + totalScore Int @default(0) + breakdown Json? + updatedAt DateTime @updatedAt + + events ContributionEvent[] @@unique([walletId, communityId]) @@index([communityId]) @@index([totalScore]) } +// Per-recompute audit log. Each row captures one full recomputation cycle +// so scores are traceable back to the exact signal contributions and the +// triggering event. The latest row for a (walletId, communityId) pair is +// the current authoritative snapshot; older rows form an append-only +// history useful for debugging, dispute resolution, and trend analysis. +model ContributionEvent { + id String @id @default(uuid()) + walletId String + communityId String + totalScore Int + breakdown Json + explanations Json? + // The outbox event that triggered this recomputation (nullable for + // manual/admin recomputes or backfills). + triggerEventId String? + contributionScoreId String? + contributionScore ContributionScore? @relation(fields: [contributionScoreId], references: [id]) + createdAt DateTime @default(now()) + + @@index([walletId, communityId]) + @@index([communityId]) + @@index([createdAt]) + @@index([triggerEventId]) + @@index([contributionScoreId]) +} + // --- Webhook Delivery (outbox handler) --- // A community's subscription to outbox events, delivered as signed HTTP diff --git a/apps/access-api/src/handlers/contributionScoreHandler.test.ts b/apps/access-api/src/handlers/contributionScoreHandler.test.ts index 31d6bbd..d2def65 100644 --- a/apps/access-api/src/handlers/contributionScoreHandler.test.ts +++ b/apps/access-api/src/handlers/contributionScoreHandler.test.ts @@ -30,9 +30,10 @@ describe('createContributionScoreHandler', () => { }); expect(mockRecompute).toHaveBeenCalledWith( - expect.anything, + expect.anything(), '0xabc123', 'community-1', + 'evt-1', ); }); diff --git a/apps/access-api/src/handlers/contributionScoreHandler.ts b/apps/access-api/src/handlers/contributionScoreHandler.ts index 5619d0b..67b67c2 100644 --- a/apps/access-api/src/handlers/contributionScoreHandler.ts +++ b/apps/access-api/src/handlers/contributionScoreHandler.ts @@ -83,7 +83,7 @@ export function createContributionScoreHandler( try { const db = await getPrisma(); - await recomputeAndPersist(db, wallet, communityId); + await recomputeAndPersist(db, wallet, communityId, event.id); } catch (err: any) { // Log but don't throw — the score will be recomputed on the next // relevant event, and we don't want to stall the outbox worker. diff --git a/apps/access-api/src/routes.ts b/apps/access-api/src/routes.ts index 45d061d..6b050b6 100644 --- a/apps/access-api/src/routes.ts +++ b/apps/access-api/src/routes.ts @@ -25,6 +25,7 @@ import { getAuditTracesByTxHash, getAuditTracesByWallet, } from "./services/auditTraceService"; +import { getScore, getScoreHistory } from "./services/contributionService"; import { notFound, validationError, @@ -78,6 +79,7 @@ import { updateResourceSchema, archiveResourceSchema, listResourcesSchema, + getContributionScoreSchema, AccessCheckBody, } from "./schemas"; import { @@ -1638,6 +1640,57 @@ export async function registerRoutes(app: FastifyInstance): Promise { } }); + // --- Contribution Score Routes --- + + // GET /v1/communities/:communityId/members/:wallet/score + app.get( + '/v1/communities/:communityId/members/:wallet/score', + { schema: getContributionScoreSchema }, + async (request: FastifyRequest, reply: FastifyReply) => { + const { communityId, wallet } = request.params as { + communityId: string; + wallet: string; + }; + + if (!wallet || !/^0x[0-9a-fA-F]{40}$/.test(wallet)) { + return reply + .status(400) + .send( + validationErrorWithReason( + 'INVALID_WALLET', + 'Invalid wallet format', + ), + ); + } + + const community = await prisma.community.findUnique({ + where: { id: communityId }, + }); + if (!community) { + return reply + .status(404) + .send(notFound('Community not found')); + } + + const score = await getScore(prisma, wallet, communityId); + if (!score) { + return reply + .status(404) + .send(notFound('No contribution score found for this member')); + } + + const history = await getScoreHistory(prisma, wallet, communityId, 10); + + return reply.status(200).send({ + wallet, + communityId, + totalScore: score.total, + breakdown: score.breakdown, + history, + }); + }, + ); + // --- Constitutional Rule Set Management Routes --- // POST /v1/communities/:communityId/constitutional-rulesets — Create a new versioned constitutional rule set diff --git a/apps/access-api/src/schemas.ts b/apps/access-api/src/schemas.ts index 52ad65b..776dedb 100644 --- a/apps/access-api/src/schemas.ts +++ b/apps/access-api/src/schemas.ts @@ -2431,3 +2431,59 @@ export const decideSuspensionAppealSchema = { }, }, } as const; + +// --------------------------------------------------------------------------- +// GET /v1/communities/:communityId/members/:wallet/score +// --------------------------------------------------------------------------- + +export const getContributionScoreSchema = { + summary: "Get contribution score for a member", + tags: ["Contribution Scoring"], + params: { + type: "object", + required: ["communityId", "wallet"], + properties: { + communityId: { type: "string", description: "Community identifier" }, + wallet: walletAddressSchema, + }, + }, + response: { + 200: { + description: "Contribution score for the member", + type: "object", + required: ["wallet", "communityId", "totalScore", "breakdown"], + properties: { + wallet: walletAddressSchema, + communityId: { type: "string" }, + totalScore: { type: "number", description: "Aggregated contribution score" }, + breakdown: { + type: "object", + additionalProperties: { type: "number" }, + description: "Per-signal point breakdown", + }, + history: { + type: "array", + description: "Recent recomputation history (most recent first)", + items: { + type: "object", + required: ["totalScore", "breakdown", "createdAt"], + properties: { + totalScore: { type: "number" }, + breakdown: { + type: "object", + additionalProperties: { type: "number" }, + }, + explanations: { + type: "object", + additionalProperties: { type: "string" }, + }, + triggerEventId: { type: "string", nullable: true }, + createdAt: { type: "string", format: "date-time" }, + }, + }, + }, + }, + }, + 404: { description: "Member not found", ...errorSchema }, + }, +} as const; diff --git a/apps/access-api/src/services/contributionScoreRoute.test.ts b/apps/access-api/src/services/contributionScoreRoute.test.ts new file mode 100644 index 0000000..b77050e --- /dev/null +++ b/apps/access-api/src/services/contributionScoreRoute.test.ts @@ -0,0 +1,112 @@ +import { getScore, getScoreHistory } from './contributionService'; + +jest.mock('./prisma', () => ({ + getPrisma: jest.fn(), +})); + +jest.mock('../lib/wallet', () => ({ + normalizeWalletAddress: (v: string) => v.toLowerCase(), +})); + +function createMockPrisma(overrides: Record = {}) { + return { + community: { + findUnique: jest.fn().mockResolvedValue( + overrides.community ?? { id: 'community-1', name: 'Test Community' }, + ), + }, + contributionScore: { + findUnique: jest.fn().mockResolvedValue( + overrides.score !== undefined + ? overrides.score + : { + totalScore: 30, + breakdown: { tenure: 10, badge_count: 15, activity: 5 }, + }, + ), + }, + contributionEvent: { + findMany: jest.fn().mockResolvedValue( + overrides.events ?? [ + { + totalScore: 30, + breakdown: { tenure: 10, badge_count: 15, activity: 5 }, + explanations: { tenure: '10 weeks', badge_count: '3 badges', activity: '5 events' }, + triggerEventId: 'evt-1', + createdAt: new Date('2026-07-28T12:00:00Z'), + }, + ], + ), + }, + } as any; +} + +describe('contributionService — score retrieval', () => { + describe('getScore', () => { + it('should return persisted score', async () => { + const prisma = createMockPrisma(); + const result = await getScore(prisma, '0xABC123', 'community-1'); + + expect(result).not.toBeNull(); + expect(result!.total).toBe(30); + expect(result!.breakdown).toEqual({ tenure: 10, badge_count: 15, activity: 5 }); + }); + + it('should return null when no score exists', async () => { + const prisma = createMockPrisma({ score: null }); + const result = await getScore(prisma, '0xabc123', 'community-1'); + + expect(result).toBeNull(); + }); + + it('should normalise wallet address', async () => { + const prisma = createMockPrisma(); + await getScore(prisma, '0xABC123DEF', 'community-1'); + + expect(prisma.contributionScore.findUnique).toHaveBeenCalledWith({ + where: { + walletId_communityId: { + walletId: '0xabc123def', + communityId: 'community-1', + }, + }, + }); + }); + }); + + describe('getScoreHistory', () => { + it('should return recent events', async () => { + const prisma = createMockPrisma(); + const result = await getScoreHistory(prisma, '0xabc123', 'community-1'); + + expect(result).toHaveLength(1); + expect(result[0].totalScore).toBe(30); + expect(result[0].triggerEventId).toBe('evt-1'); + }); + + it('should return empty array when no events exist', async () => { + const prisma = createMockPrisma({ events: [] }); + const result = await getScoreHistory(prisma, '0xabc123', 'community-1'); + + expect(result).toEqual([]); + }); + + it('should respect limit parameter', async () => { + const prisma = createMockPrisma(); + await getScoreHistory(prisma, '0xabc123', 'community-1', 5); + + expect(prisma.contributionEvent.findMany).toHaveBeenCalledWith( + expect.objectContaining({ take: 5 }), + ); + }); + + it('should cap limit at 100', async () => { + const prisma = createMockPrisma(); + await getScoreHistory(prisma, '0xabc123', 'community-1', 500); + + expect(prisma.contributionEvent.findMany).toHaveBeenCalledWith( + expect.objectContaining({ take: 100 }), + ); + }); + }); +}); diff --git a/apps/access-api/src/services/contributionService.test.ts b/apps/access-api/src/services/contributionService.test.ts index 3b82c69..5ae5d8e 100644 --- a/apps/access-api/src/services/contributionService.test.ts +++ b/apps/access-api/src/services/contributionService.test.ts @@ -4,12 +4,12 @@ function createMockPrisma(overrides: Record = {}) { return { wallet: { findUnique: jest.fn().mockResolvedValue( - overrides.wallet ?? { id: 'wallet-1', address: '0xabc123' }, + 'wallet' in overrides ? overrides.wallet : { id: 'wallet-1', address: '0xabc123' }, ), }, member: { findFirst: jest.fn().mockResolvedValue( - overrides.member ?? { + 'member' in overrides ? overrides.member : { id: 'member-1', walletId: 'wallet-1', communityId: 'community-1', @@ -23,7 +23,7 @@ function createMockPrisma(overrides: Record = {}) { count: jest.fn().mockResolvedValue(overrides.attendanceCount ?? 5), }, contributionScore: { - upsert: jest.fn().mockResolvedValue({}), + upsert: jest.fn().mockResolvedValue({ id: 'score-1' }), findUnique: jest.fn().mockResolvedValue( overrides.score !== undefined ? overrides.score : { totalScore: 22, @@ -31,6 +31,9 @@ function createMockPrisma(overrides: Record = {}) { }, ), }, + contributionEvent: { + create: jest.fn().mockResolvedValue({}), + }, } as any; } diff --git a/apps/access-api/src/services/contributionService.ts b/apps/access-api/src/services/contributionService.ts index 0f5830b..cce626f 100644 --- a/apps/access-api/src/services/contributionService.ts +++ b/apps/access-api/src/services/contributionService.ts @@ -85,17 +85,19 @@ async function buildSignalContext( /** * Recompute the contribution score for a wallet/community pair and persist it. + * Also writes a ContributionEvent audit log entry for traceability. */ export async function recomputeAndPersist( prisma: PrismaClient, wallet: string, communityId: string, + triggerEventId?: string, ): Promise { const normalised = normaliseWallet(wallet); const ctx = await buildSignalContext(prisma, normalised, communityId); const score = engine.computeScore(ctx); - await prisma.contributionScore.upsert({ + const scoreRow = await prisma.contributionScore.upsert({ where: { walletId_communityId: { walletId: normalised, @@ -114,6 +116,19 @@ export async function recomputeAndPersist( }, }); + // Append audit log entry + await prisma.contributionEvent.create({ + data: { + walletId: normalised, + communityId, + totalScore: score.total, + breakdown: score.breakdown, + explanations: score.explanations, + triggerEventId: triggerEventId ?? null, + contributionScore: { connect: { id: scoreRow.id } }, + }, + }); + return { wallet: normalised, communityId, score }; } @@ -142,3 +157,35 @@ export async function getScore( breakdown: (record.breakdown as Record) ?? {}, }; } + +/** + * Retrieve recent score recomputation history for a wallet/community pair. + * Returns the most recent entries first. + */ +export async function getScoreHistory( + prisma: PrismaClient, + wallet: string, + communityId: string, + limit: number = 20, +): Promise; + explanations?: Record; + triggerEventId?: string | null; + createdAt: Date; +}>> { + const normalised = normaliseWallet(wallet); + const events = await prisma.contributionEvent.findMany({ + where: { walletId: normalised, communityId }, + orderBy: { createdAt: 'desc' }, + take: Math.min(limit, 100), + }); + + return events.map((e) => ({ + totalScore: e.totalScore, + breakdown: (e.breakdown as Record) ?? {}, + explanations: (e.explanations as Record) ?? undefined, + triggerEventId: e.triggerEventId, + createdAt: e.createdAt, + })); +} diff --git a/docs/CONTRIBUTION_SCORING_DESIGN.md b/docs/CONTRIBUTION_SCORING_DESIGN.md new file mode 100644 index 0000000..c171528 --- /dev/null +++ b/docs/CONTRIBUTION_SCORING_DESIGN.md @@ -0,0 +1,258 @@ +# Contribution Scoring Engine + +Issue #270 — Design and Implementation + +## Overview + +The contribution scoring engine provides a pluggable, event-driven system for +computing and persisting per-member, per-community contribution scores. Scores +are derived from weighted "signal" sources and recomputed incrementally in +response to domain events via the outbox event stream. + +The system is designed as a foundation for future features including role +eligibility thresholds, badge awards, and reward distribution. + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ Outbox Event Stream │ +│ (ROLE_ASSIGNED, BADGE_ASSIGNED, MEMBER_ATTENDED, ...) │ +└──────────────────────────┬──────────────────────────────────────┘ + │ + ┌──────▼──────┐ + │ Outbox │ + │ Worker │ + └──────┬──────┘ + │ + ┌──────────▼──────────┐ + │ ContributionScore │ + │ Handler │ + │ (outboxHandler) │ + └──────────┬──────────┘ + │ + ┌──────────▼──────────┐ + │ ContributionService │ + │ (recomputeAndPersist)│ + └──────────┬──────────┘ + │ + ┌─────────────────▼─────────────────┐ + │ ContributionEngine │ + │ ┌─────────┐ ┌──────────┐ ┌─────┐ │ + │ │ Tenure │ │ Badge │ │ ... │ │ + │ │ Signal │ │ Signal │ │ │ │ + │ └─────────┘ └──────────┘ └─────┘ │ + └─────────────────┬─────────────────┘ + │ + ┌──────────▼──────────┐ + │ Prisma / Postgres │ + │ ContributionScore │ + │ ContributionEvent │ + └─────────────────────┘ +``` + +## Package: `@guildpass/contribution-engine` + +Location: `packages/contribution-engine/` + +### Scoring Strategy Interface + +The core abstraction is `ContributionSignal`: + +```typescript +interface ContributionSignal { + readonly type: string; // unique identifier + readonly weight: number; // multiplier (default 1.0) + compute(ctx: SignalContext): SignalResult; +} + +interface SignalResult { + type: string; + points: number; + explanation: string; +} + +interface SignalContext { + wallet: string; + communityId: string; + joinedAt: Date; + badgeCount: number; + attendanceCount: number; + roles: string[]; + metadata?: Record; +} +``` + +Adding a new signal requires: +1. Create a class implementing `ContributionSignal` +2. Register it with `engine.register(new MySignal())` + +No existing code needs modification — the engine is fully open for extension. + +### Built-in Signals + +| Signal | Type Key | Default | Description | +|--------|----------|---------|-------------| +| Tenure | `tenure` | 1 pt/week, 52-week cap | Points for membership duration | +| Badge Count | `badge_count` | 5 pts/badge | Points per badge held | +| Activity | `activity` | 10 pts/event, 30d half-life | Decay-weighted attendance score | + +### Activity Signal — Time-Decay Model + +The activity signal uses exponential decay to weight recent attendance more +heavily than distant participation: + +``` +score = attendanceCount × pointsPerEvent × avgDecayFactor +avgDecayFactor = (H × ln2 / T) × (1 − 2^(−T/H)) +``` + +Where: +- `H` = half-life (default 30 days) +- `T` = member tenure in days + +This ensures that a member who attended 10 events last month scores higher +than one who attended 10 events two years ago, without requiring per-event +timestamp storage. + +### Engine + +`ContributionEngine` is a registry + aggregator: + +```typescript +const engine = new ContributionEngine(); +engine.register(new TenureSignal()); +engine.register(new BadgeSignal()); +engine.register(new ActivitySignal()); + +const result = engine.computeScore(ctx); +// result = { total: 42, breakdown: { tenure: 10, badge_count: 15, activity: 17 }, ... } +``` + +`createDefaultEngine()` returns an engine with all three built-in signals. + +## Data Model + +### `ContributionScore` (running totals) + +```prisma +model ContributionScore { + id String @id @default(uuid()) + walletId String + communityId String + totalScore Int @default(0) + breakdown Json? // { tenure: 10, badge_count: 15, activity: 17 } + updatedAt DateTime @updatedAt + + @@unique([walletId, communityId]) +} +``` + +### `ContributionEvent` (audit log) + +```prisma +model ContributionEvent { + id String @id @default(uuid()) + walletId String + communityId String + totalScore Int + breakdown Json + explanations Json? + triggerEventId String? // links to OutboxEvent that triggered this recompute + createdAt DateTime @default(now()) + + @@index([walletId, communityId]) + @@index([createdAt]) +} +``` + +Each `recomputeAndPersist` call creates an append-only `ContributionEvent` +row. The most recent row for a `(walletId, communityId)` pair is the current +score; older rows form an auditable history. + +## Event-Driven Recomputation + +Scores are recomputed automatically when relevant outbox events arrive: + +| Event Type | Trigger | +|------------|---------| +| `ROLE_ASSIGNED` | Role change affects score context | +| `ROLE_REMOVED` | Role removal affects score context | +| `BADGE_ASSIGNED` | New badge increases badge count | +| `BADGE_REVOKED` | Badge removal decreases badge count | +| `MEMBER_ATTENDED` | New attendance record | +| `MEMBERSHIP_CREATED` | New member joins | +| `MEMBERSHIP_UPDATED` | Membership state change | + +The handler (`contributionScoreHandler.ts`) extracts `wallet` and +`communityId` from the event payload and calls `recomputeAndPersist`. +Errors are logged but do NOT cause the outbox event to fail — scores +will be recomputed on the next relevant event. + +## API + +### `GET /v1/communities/:communityId/members/:wallet/score` + +Returns the current contribution score and recent recomputation history. + +**Response:** +```json +{ + "wallet": "0xabc...", + "communityId": "community-1", + "totalScore": 42, + "breakdown": { + "tenure": 10, + "badge_count": 15, + "activity": 17 + }, + "history": [ + { + "totalScore": 42, + "breakdown": { "tenure": 10, "badge_count": 15, "activity": 17 }, + "explanations": { "tenure": "10 week(s) of membership", ... }, + "triggerEventId": "evt-abc-123", + "createdAt": "2026-07-28T12:00:00.000Z" + } + ] +} +``` + +## Extension Points + +### Adding a Custom Signal + +```typescript +import { ContributionSignal, SignalContext, SignalResult } from '@guildpass/contribution-engine'; + +class CustomSignal implements ContributionSignal { + readonly type = 'custom_metric'; + readonly weight = 1.5; + + compute(ctx: SignalContext): SignalResult { + const points = /* your logic */; + return { type: this.type, points, explanation: `Custom: ${points} pts` }; + } +} + +engine.register(new CustomSignal()); +``` + +### Custom Strategies (Alternative) + +For fundamentally different scoring models (e.g. ML-based, time-series), +implement the `ContributionSignal` interface with external data access +via `SignalContext.metadata` and register the signal on a separate engine +instance. + +## Future Directions + +- **Per-event scoring**: Extend `SignalContext` with individual event + timestamps for more precise decay calculations. +- **Score thresholds**: Use contribution scores as governance rule + inputs (e.g. `MinContributionScore` rule node — already implemented + in governance engine). +- **Rewards integration**: Trigger reward distribution when scores + cross configurable thresholds. +- **Decay strategy configuration**: Allow communities to configure + half-life, weight, and cap per signal. diff --git a/docs/openapi.json b/docs/openapi.json index 5031cee..f6e7b55 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -10785,6 +10785,132 @@ } } } + }, + "/v1/communities/{communityId}/members/{wallet}/score": { + "get": { + "summary": "Get contribution score for a member", + "description": "Returns the current contribution score and recent recomputation history for a member within a community. Scores are computed from pluggable signal sources (tenure, badge count, attendance) and updated automatically via the outbox event stream.", + "tags": [ + "Contribution Scoring" + ], + "parameters": [ + { + "schema": { + "type": "string" + }, + "in": "path", + "name": "communityId", + "required": true, + "description": "Community identifier" + }, + { + "schema": { + "type": "string", + "pattern": "^0x[0-9a-fA-F]{40}$" + }, + "in": "path", + "name": "wallet", + "required": true, + "description": "EVM wallet address" + } + ], + "responses": { + "200": { + "description": "Contribution score for the member", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "wallet", + "communityId", + "totalScore", + "breakdown" + ], + "properties": { + "wallet": { + "type": "string", + "description": "Normalised wallet address" + }, + "communityId": { + "type": "string" + }, + "totalScore": { + "type": "number", + "description": "Aggregated contribution score across all signals" + }, + "breakdown": { + "type": "object", + "additionalProperties": { + "type": "number" + }, + "description": "Per-signal point breakdown" + }, + "history": { + "type": "array", + "description": "Recent recomputation audit log (most recent first)", + "items": { + "type": "object", + "required": [ + "totalScore", + "breakdown", + "createdAt" + ], + "properties": { + "totalScore": { + "type": "number" + }, + "breakdown": { + "type": "object", + "additionalProperties": { + "type": "number" + } + }, + "explanations": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "triggerEventId": { + "type": "string", + "nullable": true, + "description": "Outbox event ID that triggered this recomputation" + }, + "createdAt": { + "type": "string", + "format": "date-time" + } + } + } + } + } + } + } + } + }, + "400": { + "description": "Invalid wallet format", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "404": { + "description": "Community or member not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } } }, "servers": [ diff --git a/packages/contribution-engine/src/engine.ts b/packages/contribution-engine/src/engine.ts index e4bdb6c..2b0697b 100644 --- a/packages/contribution-engine/src/engine.ts +++ b/packages/contribution-engine/src/engine.ts @@ -77,15 +77,18 @@ export class ContributionEngine { import { TenureSignal } from './signals/tenureSignal'; import { BadgeSignal } from './signals/badgeSignal'; +import { ActivitySignal } from './signals/activitySignal'; /** - * Create a ContributionEngine with the two built-in signals registered: + * Create a ContributionEngine with the three built-in signals registered: * - tenure: points for membership duration * - badge_count: points for badge count + * - activity: decay-weighted attendance points */ export function createDefaultEngine(): ContributionEngine { const engine = new ContributionEngine(); engine.register(new TenureSignal()); engine.register(new BadgeSignal()); + engine.register(new ActivitySignal()); return engine; } diff --git a/packages/contribution-engine/src/index.ts b/packages/contribution-engine/src/index.ts index cb042a8..5de968c 100644 --- a/packages/contribution-engine/src/index.ts +++ b/packages/contribution-engine/src/index.ts @@ -22,3 +22,5 @@ export { TenureSignal } from './signals/tenureSignal'; export type { TenureSignalOptions } from './signals/tenureSignal'; export { BadgeSignal } from './signals/badgeSignal'; export type { BadgeSignalOptions } from './signals/badgeSignal'; +export { ActivitySignal } from './signals/activitySignal'; +export type { ActivitySignalOptions } from './signals/activitySignal'; diff --git a/packages/contribution-engine/src/signals/activitySignal.ts b/packages/contribution-engine/src/signals/activitySignal.ts new file mode 100644 index 0000000..1da76f1 --- /dev/null +++ b/packages/contribution-engine/src/signals/activitySignal.ts @@ -0,0 +1,100 @@ +/** + * Attendance-based contribution signal with exponential time-decay. + * + * Awards points per attendance event, decaying older events exponentially. + * Recent participation is weighted more heavily than distant participation, + * preventing score inflation from stale activity while still rewarding + * sustained engagement. + * + * Default policy: 10 points per event, half-life of 30 days. + * score = sum( pointsPerEvent * decay( daysSinceEvent ) ) + * decay(d) = 2^(-d / halfLifeDays) + */ + +import type { ContributionSignal, SignalContext, SignalResult } from '../types'; + +export interface ActivitySignalOptions { + /** Weight multiplier (default: 1.0) */ + weight?: number; + /** Points awarded per attendance event (default: 10) */ + pointsPerEvent?: number; + /** Half-life in days — events this old contribute half their points (default: 30) */ + halfLifeDays?: number; + /** Optional cap on the number of most-recent events scored (default: no cap) */ + maxEvents?: number; +} + +const DAY_MS = 24 * 60 * 60 * 1000; +const DEFAULT_POINTS_PER_EVENT = 10; +const DEFAULT_HALF_LIFE_DAYS = 30; + +/** + * ActivitySignal computes a decay-weighted attendance score. + * + * Since the current `SignalContext` only carries an `attendanceCount` (not + * individual event timestamps), this signal applies an *aggregate* decay + * approximation: the member's effective activity score is computed as + * attendanceCount * pointsPerEvent * avgDecayFactor + * where avgDecayFactor models the expected average decay over the member's + * tenure assuming roughly uniform activity. + * + * This is a deliberate simplification that avoids requiring per-event + * timestamp queries while still producing meaningful, time-decayed scores. + * A future enhancement could replace this with per-event scoring if the + * `SignalContext` is extended with event timestamps. + */ +export class ActivitySignal implements ContributionSignal { + readonly type = 'activity'; + readonly weight: number; + private readonly pointsPerEvent: number; + private readonly halfLifeDays: number; + private readonly maxEvents: number; + + constructor(options?: ActivitySignalOptions) { + this.weight = options?.weight ?? 1.0; + this.pointsPerEvent = options?.pointsPerEvent ?? DEFAULT_POINTS_PER_EVENT; + this.halfLifeDays = options?.halfLifeDays ?? DEFAULT_HALF_LIFE_DAYS; + this.maxEvents = options?.maxEvents ?? Infinity; + } + + compute(ctx: SignalContext): SignalResult { + if (ctx.attendanceCount === 0) { + return { type: this.type, points: 0, explanation: 'No attendance events recorded' }; + } + + const cappedCount = Math.min(ctx.attendanceCount, this.maxEvents); + + // Calculate the member's tenure in days for the decay model. + const now = Date.now(); + const joinedMs = ctx.joinedAt.getTime(); + const tenureDays = Math.max(0, (now - joinedMs) / DAY_MS); + + // Approximate the average decay factor over the member's tenure: + // events are assumed to be roughly uniformly distributed over the + // membership period, so the average decay is the integral of + // 2^(-t/halfLife) over [0, tenureDays] divided by tenureDays. + let avgDecay: number; + if (tenureDays <= 0) { + avgDecay = 1; + } else { + // Integral of 2^(-t/H) from 0 to T = H/ln2 * (1 - 2^(-T/H)) + // Divide by T to get the average. + const halfLifeLn2 = this.halfLifeDays * Math.LN2; + avgDecay = + (halfLifeLn2 / tenureDays) * + (1 - Math.pow(2, -tenureDays / this.halfLifeDays)); + } + + const rawPoints = cappedCount * this.pointsPerEvent * avgDecay; + const points = Math.round(rawPoints * this.weight * 100) / 100; + + let explanation: string; + if (cappedCount === ctx.attendanceCount) { + explanation = `${cappedCount} attendance event(s) × ${this.pointsPerEvent} pts (decayed, half-life ${this.halfLifeDays}d)`; + } else { + explanation = `${ctx.attendanceCount} event(s) capped to ${cappedCount} × ${this.pointsPerEvent} pts (decayed)`; + } + + return { type: this.type, points, explanation }; + } +} diff --git a/packages/contribution-engine/test/activitySignal.test.ts b/packages/contribution-engine/test/activitySignal.test.ts new file mode 100644 index 0000000..8f0a38e --- /dev/null +++ b/packages/contribution-engine/test/activitySignal.test.ts @@ -0,0 +1,84 @@ +import { ActivitySignal } from '../src/signals/activitySignal'; +import type { SignalContext } from '../src/types'; + +function makeContext(overrides?: Partial): SignalContext { + return { + wallet: '0xabc123', + communityId: 'community-1', + joinedAt: new Date(Date.now() - 10 * 7 * 24 * 60 * 60 * 1000), + badgeCount: 3, + attendanceCount: 5, + roles: ['member'], + ...overrides, + }; +} + +describe('ActivitySignal', () => { + it('should return 0 points for no attendance', () => { + const signal = new ActivitySignal(); + const result = signal.compute(makeContext({ attendanceCount: 0 })); + expect(result.type).toBe('activity'); + expect(result.points).toBe(0); + expect(result.explanation).toMatch(/No attendance/); + }); + + it('should award points proportional to attendance count', () => { + const signal = new ActivitySignal({ pointsPerEvent: 10, halfLifeDays: 30 }); + const ctx = makeContext({ + attendanceCount: 10, + joinedAt: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000), + }); + const result = signal.compute(ctx); + expect(result.points).toBeGreaterThan(0); + expect(result.type).toBe('activity'); + }); + + it('should apply time decay — longer tenure reduces average decay', () => { + const signal = new ActivitySignal({ pointsPerEvent: 10, halfLifeDays: 30 }); + + // Short tenure (7 days) — recent events, less decay + const shortCtx = makeContext({ + attendanceCount: 5, + joinedAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000), + }); + const shortResult = signal.compute(shortCtx); + + // Long tenure (90 days) — older average events, more decay + const longCtx = makeContext({ + attendanceCount: 5, + joinedAt: new Date(Date.now() - 90 * 24 * 60 * 60 * 1000), + }); + const longResult = signal.compute(longCtx); + + // Short tenure should score higher (less decayed) than long tenure + expect(shortResult.points).toBeGreaterThan(longResult.points); + }); + + it('should respect maxEvents cap', () => { + const signal = new ActivitySignal({ maxEvents: 3, pointsPerEvent: 10 }); + const result = signal.compute(makeContext({ attendanceCount: 20 })); + // Should cap at 3 events — fewer points than 20 + expect(result.explanation).toMatch(/capped/); + }); + + it('should apply weight multiplier', () => { + const base = new ActivitySignal({ weight: 1.0, pointsPerEvent: 10 }); + const weighted = new ActivitySignal({ weight: 2.0, pointsPerEvent: 10 }); + const ctx = makeContext({ + attendanceCount: 5, + joinedAt: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000), + }); + const baseResult = base.compute(ctx); + const weightedResult = weighted.compute(ctx); + expect(weightedResult.points).toBeCloseTo(baseResult.points * 2, 1); + }); + + it('should handle same-day membership', () => { + const signal = new ActivitySignal(); + const result = signal.compute(makeContext({ + attendanceCount: 5, + joinedAt: new Date(), + })); + expect(result.points).toBeGreaterThan(0); + }); +});