Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .jules/bolt.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,7 @@
## 2026-07-16 - [O(N*M) Nested Loop Lookups in Grading Engines]
**Learning:** In interactive scenarios (such as Bid Elevator and STR Triage), grading engines frequently iterate over user decisions and match them against scenario properties (like keywords or search terms). Performing `array.find()` inside loop bodies or filter predicates results in costly O(N*M) lookups.
**Action:** Convert arrays to `Map` lookups before entering loops/nested scans. Mapping keys once in O(M) time enables O(1) lookups during execution, transforming the time complexity of the grading logic to O(N + M).

## 2026-07-16 - [Redundant DB Queries in Badge Evaluation Engine]
**Learning:** Evaluators checking multiple rules (such as checkCriteria in the badge engine) can generate redundant database queries for identical user records or resource aggregates when looping over each rule. These can be optimized with a transient local cache (caching query promises rather than resolved values) during the evaluation lifecycle, safely collapsing database roundtrips from O(R) to O(1) where R is the number of rules.
**Action:** Store the generated database query promises inside a transient `EvaluationCache` class during the call lifecycle. Re-use those pending/resolved promises across criteria evaluations to eliminate duplicate database I/O overhead.
14 changes: 14 additions & 0 deletions src/lib/__tests__/badges.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,4 +92,18 @@ describe('badges.ts', () => {
const result = await evaluateBadges('user-1', { trigger: 'login' });
expect(result.awarded).toEqual([]);
});

it('reuses promises from EvaluationCache to prevent duplicate DB queries', async () => {
(db.badge.findMany as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([
{ id: 'b1', title: 'Module 1', criteria: JSON.stringify({ type: 'module_complete', threshold: 1 }), xpReward: 10, description: '', icon: '', tier: 'BRONZE', isPublished: true, deletedAt: null },
{ id: 'b2', title: 'Module 2', criteria: JSON.stringify({ type: 'module_complete', threshold: 2 }), xpReward: 10, description: '', icon: '', tier: 'BRONZE', isPublished: true, deletedAt: null },
]);
(db.userBadge.findMany as unknown as ReturnType<typeof vi.fn>).mockResolvedValue([]);
(db.lessonProgress.count as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(5);

await evaluateBadges('user-1', { trigger: 'login' });

// Both badge criteria check 'module_complete', but db.lessonProgress.count should only be called once!
expect(db.lessonProgress.count).toHaveBeenCalledTimes(1);
});
});
66 changes: 46 additions & 20 deletions src/lib/badges.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,43 @@ export interface BadgeEvaluationResult {
* wants to count it; the engine does NOT mutate `User.xp` to keep this function
* composable inside larger transactions.
*/
/**
* Transient cache of database query promises to prevent redundant database lookups
* when checking criteria for multiple badges within a single evaluation lifecycle.
* This collapses the database overhead from O(R) to O(1) where R is the number of rules.
*/
class EvaluationCache {
private completed: Promise<number> | null = null;
private tools = new Map<string | undefined, Promise<number>>();
private user: Promise<{ xp: number; streakDays: number } | null> | null = null;

constructor(private userId: string) {}

getCompleted(): Promise<number> {
return this.completed ??= db.lessonProgress.count({
where: { userId: this.userId, status: 'COMPLETED' },
});
}

getToolCount(toolType?: string): Promise<number> {
const cached = this.tools.get(toolType);
if (cached) return cached;

const p = db.toolSession.count({
where: { userId: this.userId, status: 'GRADED', ...(toolType ? { toolType } : {}) },
});
this.tools.set(toolType, p);
return p;
}

getUser(): Promise<{ xp: number; streakDays: number } | null> {
return this.user ??= db.user.findUnique({
where: { id: this.userId },
select: { xp: true, streakDays: true },
}) as Promise<{ xp: number; streakDays: number } | null>;
}
}

export async function evaluateBadges(
userId: string,
event: BadgeTrigger,
Expand Down Expand Up @@ -95,6 +132,8 @@ export async function evaluateBadges(
xpReward: number;
}> = [];

const cache = new EvaluationCache(userId);

for (const badge of published) {
if (alreadyAwardedSet.has(badge.id)) continue;

Expand All @@ -106,7 +145,7 @@ export async function evaluateBadges(
continue;
}

const qualifies = await checkCriteria(userId, criteria, event);
const qualifies = await checkCriteria(userId, criteria, event, cache);
if (qualifies) earnedNow.push(badge);
}

Expand Down Expand Up @@ -140,18 +179,17 @@ export async function evaluateBadges(

/**
* Returns true if the user has met the given badge criteria at this moment.
* Each branch is a narrow DB read — no writes.
* Reuses the provided transient EvaluationCache to avoid redundant database lookups.
*/
async function checkCriteria(
userId: string,
criteria: BadgeCriteria,
event: BadgeTrigger,
cache: EvaluationCache,
): Promise<boolean> {
switch (criteria.type) {
case 'module_complete': {
const completedCount = await db.lessonProgress.count({
where: { userId, status: 'COMPLETED' },
});
const completedCount = await cache.getCompleted();
// Treat each completed lesson as progress toward module_complete; the
// seed threshold is 1 so this triggers after the first lesson.
return completedCount >= criteria.threshold;
Expand All @@ -165,30 +203,18 @@ async function checkCriteria(

case 'tool_sessions': {
const scopeToolType = criteria.scope?.toolType;
const count = await db.toolSession.count({
where: {
userId,
status: 'GRADED',
...(scopeToolType ? { toolType: scopeToolType } : {}),
},
});
const count = await cache.getToolCount(scopeToolType);
return count >= criteria.threshold;
}

case 'streak_days': {
const user = await db.user.findUnique({
where: { id: userId },
select: { streakDays: true },
});
const user = await cache.getUser();
if (!user) return false;
return user.streakDays >= criteria.threshold;
}

case 'xp_threshold': {
const user = await db.user.findUnique({
where: { id: userId },
select: { xp: true },
});
const user = await cache.getUser();
if (!user) return false;
return user.xp >= criteria.threshold;
}
Expand Down
Loading