Skip to content

Commit

Permalink
Merge pull request #68 from lychee-org/sm3421/remove-group-casework
Browse files Browse the repository at this point in the history
Remove theme group casework
  • Loading branch information
sreeshmaheshwar authored Apr 1, 2024
2 parents 836e386 + 0e5f347 commit 581af17
Show file tree
Hide file tree
Showing 8 changed files with 94 additions and 236 deletions.
190 changes: 62 additions & 128 deletions app/api/puzzle/nextPuzzle/nextFor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,14 @@ import sm2RandomThemeFromRatingMap from '../../../../src/sm2';
import frequentiallyRandomTheme, { isIrrelevant } from './themeGenerator';
import { ActivePuzzleColl } from '@/models/ActivePuzzle';
import { booleanWithProbability, toGroupId } from '@/lib/utils';
import {
nextLeitnerReview,
nextThemedLeitnerReview,
} from '@/src/LeitnerIntance';
import { nextLeitnerReview } from '@/src/LeitnerIntance';
import { similarBatchForCompromised } from '../similarBatch/similarBatchFor';
import { assert } from 'console';

const MAX_REPS: number = 12;
const MAX_COMPROMISE: number = 3;

const LEITNER_PROBABILITY: number = 0.2;
const MIN_CANDIDATES: number = 10; // TODO: Increase this.
const MIN_CANDIDATES: number = 10;

export type PuzzleWithUserRating = {
puzzle: Puzzle | undefined;
Expand Down Expand Up @@ -98,7 +94,7 @@ const nextPuzzleRepetitions = async (
exceptions: any
): Promise<Puzzle | undefined> => {
if (reps == MAX_REPS) {
// throw new Error('Maximum repetitions reached during puzzle selection');
console.log('Maximum repetitions reached during puzzle selection');
return undefined;
}
let rating = userRating;
Expand Down Expand Up @@ -140,16 +136,14 @@ const nextThemedPuzzlesForRepetitions = async (
expceptions: any
): Promise<Puzzle | undefined> => {
if (reps == MAX_REPS) {
// throw new Error('Maximum repetitions reached during puzzle selection');
console.log('Maximum repetitions reached during puzzle selection');
return undefined;
}

const theme = themeGroup[Math.floor(Math.random() * themeGroup.length)];
// If theme is present in rating map, use its rating for adaptive difficulty
// selection. Otherwise, use user's rating.
const rating = ratingMap.get(theme)?.rating || userRating;
console.log(`Using rating: ${rating} for theme: ${theme}`);

const p = await nextPuzzleForThemeAndRating(theme, rating, expceptions);
if (p) {
console.log(`Found grouped theme ${theme} after ${reps} reps.`);
Expand All @@ -170,160 +164,100 @@ const nextPuzzleFor = async (
themeGroup: string[] = []
): Promise<PuzzleWithUserRating> =>
getExistingUserRating(user).then(async (rating) => {
const group = themeGroup.length > 0 ? toGroupId(themeGroup) : undefined;
const groupId = toGroupId(themeGroup);

if (!woodpecker) {
const activePuzzle = await ActivePuzzleColl.findOne({
username: user.username,
});
if (activePuzzle) {
if (
(group && activePuzzle.groupID !== group) ||
(!group && activePuzzle.groupID)
) {
// TODO: Maybe preserve previous active puzzle? But we need to be
// careful in case this mode solves that puzzle, then back in
// normal mode user solves puzzle again - then errors!
console.log('Deleting different active puzzle');
if (groupId !== activePuzzle.groupID) {
// NB: Preserving active puzzles per-modes is tricky. We can't permit this mode solving that puzzle,
// then back in previous mode user solving it again.
await ActivePuzzleColl.deleteOne({ username: user.username });
} else {
let similar: Puzzle[] = [];
if (activePuzzle.isReview) {
const reviewee = JSON.parse(activePuzzle.reviewee) as Puzzle;
similar = [reviewee];
}
console.log(similar);
console.log('Found active puzzle');
return {
puzzle: JSON.parse(activePuzzle.puzzle) as Puzzle,
rating: rating,
similar: similar,
similar: activePuzzle.reviewee
? [JSON.parse(activePuzzle.reviewee) as Puzzle]
: [],
};
}
}
}

// TODO: Iterate to better handle repeat avoidance.
const exceptions: string[] = await getUserSolvedPuzzleIDs(user);

if (!woodpecker && booleanWithProbability(LEITNER_PROBABILITY)) {
console.log('Trying to use Leitner...');
const puzzleToReview = group
? await nextThemedLeitnerReview(user, group)
: await nextLeitnerReview(user);
if (puzzleToReview) {
console.log(
`Worked! Puzzle Id: ${puzzleToReview.PuzzleId} from Leitner, tags: ${puzzleToReview.hierarchy_tags}`
);
const [similarPuzzle] = await similarBatchForCompromised(
user,
[puzzleToReview],
clampRating(rating.rating),
exceptions,
MIN_CANDIDATES, // TODO: Increase this, or maybe start compromise at 3 instead, to use wider similarity radius? Unsure.
false
);

console.log(
`Got similar puzzle with tags ${similarPuzzle.hierarchy_tags} and line ${similarPuzzle.Moves}`
);

if (group) {
await ActivePuzzleColl.updateOne(
{ username: user.username },
{
username: user.username,
puzzle: JSON.stringify(similarPuzzle),
isReview: true,
reviewee: JSON.stringify(puzzleToReview),
groupID: group,
},
{ upsert: true }
const result = await (async () => {
// TODO: Iterate to better handle repeat avoidance.
const exceptions: string[] = await getUserSolvedPuzzleIDs(user);
if (!woodpecker && booleanWithProbability(LEITNER_PROBABILITY)) {
console.log('Trying to use Leitner...');
const puzzleToReview = await nextLeitnerReview(user, groupId);
if (puzzleToReview) {
console.log(
`Worked! Puzzle Id: ${puzzleToReview.PuzzleId} from Leitner, tags: ${puzzleToReview.hierarchy_tags}`
);
} else {
await ActivePuzzleColl.updateOne(
{ username: user.username },
{
username: user.username,
puzzle: JSON.stringify(similarPuzzle),
isReview: true,
reviewee: JSON.stringify(puzzleToReview),
},
{ upsert: true }
const [similarPuzzle] = await similarBatchForCompromised(
user,
[puzzleToReview],
clampRating(rating.rating),
exceptions,
MIN_CANDIDATES,
false
);
console.log(
`Got similar puzzle with tags ${similarPuzzle.hierarchy_tags} and line ${similarPuzzle.Moves}`
);
return {
puzzle: similarPuzzle,
similar: [puzzleToReview],
};
}
return {
puzzle: similarPuzzle,
similar: [puzzleToReview],
rating: rating,
};
}
}

// NB: The persisted rating map may contain irrelevant themes, but we don't
// want to include these for nextPuzzle / SM2, so we filter them out below.
const ratingMap = await getThemeRatings(user, true);

if (group) {
const puzzle = await nextThemedPuzzlesForRepetitions(
rating.rating,
ratingMap,
0,
themeGroup,
exceptions
);
if (puzzle) {
console.log(
`Got puzzle with themes ${puzzle.Themes} and rating ${puzzle.Rating} and line ${puzzle.Moves}`
);
assert(!woodpecker);
await ActivePuzzleColl.updateOne(
{ username: user.username },
{
username: user.username,
puzzle: JSON.stringify(puzzle),
isReview: false,
groupID: group,
},
{ upsert: true }
);
}
// NB: The persisted rating map may contain irrelevant themes, but we don't
// want to include these for nextPuzzle / SM2, so we filter them out below.
const ratingMap = await getThemeRatings(user, true);
return {
puzzle: puzzle,
rating: rating,
puzzle: groupId
? await nextThemedPuzzlesForRepetitions(
rating.rating,
ratingMap,
0,
themeGroup,
exceptions
)
: await nextPuzzleRepetitions(
user.username,
rating.rating,
0,
ratingMap,
exceptions
),
};
}
})();

const puzzle = await nextPuzzleRepetitions(
user.username,
rating.rating,
0,
ratingMap,
exceptions
);

if (puzzle) {
if (result.puzzle) {
console.log(
`Got puzzle with themes ${puzzle.Themes} and rating ${puzzle.Rating} and line ${puzzle.Moves}`
`Got puzzle with themes ${result.puzzle.Themes} and rating ${result.puzzle.Rating} and line ${result.puzzle.Moves}`
);

// Persist selection as active puzzle.
if (!woodpecker) {
await ActivePuzzleColl.updateOne(
{ username: user.username },
{
username: user.username,
puzzle: JSON.stringify(puzzle),
isReview: false,
puzzle: JSON.stringify(result.puzzle),
reviewee: result.similar
? JSON.stringify(result.similar[0])
: undefined,
groupID: groupId,
},
{ upsert: true }
);
}
}

return {
puzzle: puzzle,
rating: rating,
};
return { ...result, rating };
});

export default nextPuzzleFor;
53 changes: 23 additions & 30 deletions app/api/puzzle/submit/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { UserThemeColl } from '@/models/UserThemeColl';
import addRound from './addRound';
import { RatingHistory } from '@/models/RatingHistory';
import { ActivePuzzleColl } from '@/models/ActivePuzzle';
import { updateLeitner, updateThemedLeitner } from '@/src/LeitnerIntance';
import { updateLeitner } from '@/src/LeitnerIntance';
import { toGroupId } from '@/lib/utils';
import { TimeThemeColl } from '@/models/TimeThemeColl';
import updateAndScaleRatings from '@/src/rating/RatingCalculator';
Expand All @@ -35,62 +35,55 @@ export async function POST(req: NextRequest) {
throw new Error('No active puzzle found - something is wrong!');
}

const userRating = await getExistingUserRating(user);
const { successStr, themeGroupStr, timeStr } = await req.json();
const puzzle = JSON.parse(activePuzzle.puzzle) as Puzzle;
const success = successStr as boolean;
const themeGroup = themeGroupStr as string[];
const group = themeGroup.length > 0 ? toGroupId(themeGroup) : undefined;
const totalTime = (timeStr as number) / MILLISECONDS_IN_SECOND;
const userRating = await getExistingUserRating(user);
const moves = puzzle.Moves.split(' ').length / 2;
const timePerMove = totalTime / moves;
console.log(`Time per move : ${timePerMove}`);
const timePerMove = (timeStr as number) / MILLISECONDS_IN_SECOND / moves;
const isReview = !!activePuzzle.reviewee;

updateAndScaleRatings(userRating, puzzle, success, activePuzzle.isReview);

// Update user's rating.
// Update and persist user's rating.
updateAndScaleRatings(userRating, puzzle, success, isReview);
await RatingColl.updateOne({ username: user.username }, { $set: userRating });
await RatingHistory.create({
username: user.username,
theme: 'overall',
rating: userRating.rating,
});

// Mark puzzle as solved by user.
await addRound(user, puzzle);

const reviewee = activePuzzle.isReview
? (JSON.parse(activePuzzle.reviewee) as Puzzle)
: puzzle;

if (group) {
await updateThemedLeitner(user, reviewee, success, group, timePerMove);
} else {
await updateLeitner(user, reviewee, success, timePerMove);
}
// Update Leitner instance (of original puzzle if review).
await updateLeitner(
user,
isReview ? (JSON.parse(activePuzzle.reviewee) as Puzzle) : puzzle,
success,
toGroupId(themeGroupStr as string[]),
timePerMove
);

// NB: We don't filter out irrelevant themes here. Even if theme is irrelevant, we compute ratings and
// persist in the DB, as this information is useful for dashboard analysitcs.
// persist in the DB, as this information may be useful for dashboard analyitcs.
const ratingMap = await getThemeRatings(user, false);

// Update theme ratings.
const themes = puzzle.Themes.split(' ');
themes.forEach(async (theme) => {
puzzle.Themes.split(' ').forEach(async (theme) => {
const themeRating: Rating = ratingMap.get(theme) || DEFAULT_RATING;
updateAndScaleRatings(themeRating, puzzle, success, activePuzzle.isReview);
// Update and persist per-theme ratings.
updateAndScaleRatings(themeRating, puzzle, success, isReview);
await UserThemeColl.updateOne(
{ username: user.username, theme: theme },
{ $set: themeRating },
{ upsert: true } // Insert if not found.
{ upsert: true }
);
// Persist LAST time taken for each theme.
if (success) {
await TimeThemeColl.updateOne(
{ username: user.username, theme: theme },
{
$set: { time: timePerMove },
},
{ $set: { time: timePerMove } },
{ upsert: true }
);
}
// Add entry in theme's rating history.
await RatingHistory.create({
username: user.username,
theme: theme,
Expand Down
1 change: 0 additions & 1 deletion components/puzzle-ui/controls/puzzle-hint.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import StaticBoard from '../static-board';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { capitalize } from '@/lib/utils';
import { isRelevant } from '@/app/api/puzzle/nextPuzzle/themeGenerator';

const hintMode = (similar: Puzzle[] | undefined, themes: string[]) => {
return (
Expand Down
1 change: 1 addition & 0 deletions components/puzzle-ui/puzzle-mode.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
'use client';

import { Puzzle } from '@/types/lichess-api';
import React, { useState, useEffect } from 'react';
import PuzzleBoard from './puzzle-board';
Expand Down
9 changes: 4 additions & 5 deletions models/ActivePuzzle.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import mongoose, { Schema } from 'mongoose';

const activePuzzleSchema = new Schema({
username: String,
puzzle: String,
isReview: Boolean,
reviewee: String, // Original puzzle, if it's a similar one. TODO: Redundancy with above.
groupID: String,
username: { type: String, required: true },
puzzle: { type: String, required: true },
reviewee: String, // Original puzzle, if it's a similar one.
groupID: { type: String, required: true },
});

// Index on username for fast lookups.
Expand Down
Loading

0 comments on commit 581af17

Please sign in to comment.