feat: SM-2 + 페르소나 하이브리드 복습 알고리즘

- SM-2 Easiness Factor + 페르소나별 망각속도 보정 (senior 1.3x ~ crammer 0.6x)
- 최초 학습(iter=0): hard=1d, medium=3d, easy=7d 차등 간격
- 2회차~: 반복할수록 간격 증가 (6 × EF^(iter-1) × pf)
- hard → EF 하락 + 간격 리셋(1일), easy → EF 상승 + 간격 확대
- EF 는 SkillSnapshot.s0 에 저장 (태그별 개인 학습 용이도)
- 프론트 3버튼에 SM-2 기반 동적 간격 미리보기 표시
- 기존 PersonaForgetService.schedule/updateS0 유지 (selfDifficulty 없는 경우 fallback)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
reloop
2026-04-16 15:31:47 +09:00
parent 25092bf680
commit 8aa87ed257
5 changed files with 284 additions and 109 deletions

View File

@@ -38,6 +38,34 @@ export const PERSONA_LAMBDA: Record<Persona, number> = {
crammer: 0.6,
};
// ── SM-2 Hybrid ──────────────────────────────────────────────────────────────
export const PERSONA_FACTOR: Record<Persona, number> = {
senior: 1.3,
mid: 1.0,
junior: 0.8,
crammer: 0.6,
};
export const DEFAULT_EF = 2.5;
export const MIN_EF = 1.3;
export type SelfDifficulty = 'hard' | 'medium' | 'easy';
export interface SM2Input {
iteration: number;
selfDifficulty: SelfDifficulty;
currentEF: number;
persona: Persona;
now?: Date;
}
export interface SM2Output {
intervalDays: number;
newEF: number;
scheduledAt: Date;
}
export const INTENSITY_THRESHOLD: Record<ReviewIntensity, number> = {
strict: 0.7,
moderate: 0.5,
@@ -183,6 +211,69 @@ export class PersonaForgetService {
return points;
}
/**
* SM-2 + 페르소나 하이브리드 스케줄러.
*
* iteration=0 (최초 학습):
* hard → 1일
* medium → round(3 × pf)일
* easy → round(7 × pf)일
*
* iteration=1 (첫 복습):
* hard → 1일
* else → round(6 × pf)일
*
* iteration≥2:
* hard → 1일 (EF 하락 + 리셋)
* else → round(6 × EF^(iteration-1) × pf)일
*
* EF 업데이트:
* hard → max(MIN_EF, EF - 0.3)
* easy → max(MIN_EF, EF + 0.15)
* medium → 변화 없음
*
* 결과는 [1, MAX_INTERVAL_DAYS] 범위로 클램핑.
*/
sm2Schedule(input: SM2Input): SM2Output {
const { iteration, selfDifficulty, currentEF, persona } = input;
const now = input.now ?? new Date();
const pf = PERSONA_FACTOR[persona] ?? 1.0;
// EF 업데이트
let newEF = currentEF;
if (selfDifficulty === 'hard') {
newEF = Math.max(MIN_EF, currentEF - 0.3);
} else if (selfDifficulty === 'easy') {
newEF = Math.max(MIN_EF, currentEF + 0.15);
}
// 간격 계산
let intervalDays: number;
if (selfDifficulty === 'hard') {
intervalDays = 1;
} else if (iteration <= 0) {
// 최초 학습 — selfDifficulty 에 따라 차등
if (selfDifficulty === 'medium') {
intervalDays = Math.round(3 * pf);
} else {
// easy
intervalDays = Math.round(7 * pf);
}
} else if (iteration === 1) {
intervalDays = Math.round(6 * pf);
} else {
intervalDays = Math.round(6 * Math.pow(newEF, iteration - 1) * pf);
}
intervalDays = Math.max(1, Math.min(MAX_INTERVAL_DAYS, intervalDays));
return {
intervalDays,
newEF,
scheduledAt: addDays(now, intervalDays),
};
}
sigmoid(x: number): number {
return 1 / (1 + Math.exp(-x));
}

View File

@@ -6,7 +6,7 @@ import {
} from '@nestjs/common';
import { StudyResult, ReviewStatus } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { PersonaForgetService } from '../forget/persona-forget.service';
import { PersonaForgetService, DEFAULT_EF } from '../forget/persona-forget.service';
@Injectable()
export class ReviewsService {
@@ -89,58 +89,96 @@ export class ReviewsService {
where: { id: reviewId },
});
// b/c. Snapshot upsert inside tx
let newS0: number;
if (review.studyLog.tagId) {
const existing = await tx.skillSnapshot.findUnique({
where: {
userId_tagId: { userId, tagId: review.studyLog.tagId },
},
});
newS0 = this.forget.updateS0({
previousS0: existing?.s0 ?? null,
result,
baseCorrectRate: review.studyLog.baseCorrectRate ?? undefined,
});
await tx.skillSnapshot.upsert({
where: {
userId_tagId: { userId, tagId: review.studyLog.tagId },
},
create: {
userId,
tagId: review.studyLog.tagId,
s0: newS0,
lastUpdatedAt: now,
sampleCount: 1,
},
update: {
s0: newS0,
lastUpdatedAt: now,
sampleCount: { increment: 1 },
},
});
} else {
newS0 = this.forget.updateS0({
previousS0: null,
result,
baseCorrectRate: review.studyLog.baseCorrectRate ?? undefined,
});
}
// d. Compute next schedule
const SELF_DIFFICULTY_DAYS: Record<string, number> = {
hard: 1,
medium: 7,
easy: 30,
};
// b/c. Snapshot upsert + next schedule (SM-2 or legacy)
let nextScheduledAt: Date;
let nextPredictedP: number | null = null;
// e. Fetch the true latest iteration for this studyLog inside tx
const lastDone = await tx.reviewSchedule.findFirst({
where: { studyLogId: review.studyLogId, status: 'done' },
orderBy: { iteration: 'desc' },
});
const nextIteration = (lastDone?.iteration ?? flipped.iteration) + 1;
if (selfDifficulty) {
const days = SELF_DIFFICULTY_DAYS[selfDifficulty];
nextScheduledAt = new Date(now.getTime() + days * 24 * 3_600_000);
// ── SM-2 하이브리드 경로 ──────────────────────────────────────────────
let currentEF = DEFAULT_EF;
if (review.studyLog.tagId) {
const snap = await tx.skillSnapshot.findUnique({
where: { userId_tagId: { userId, tagId: review.studyLog.tagId } },
});
if (snap && snap.s0 >= 1.3 && snap.s0 <= 4.0) {
currentEF = snap.s0;
}
}
const sm2 = this.forget.sm2Schedule({
iteration: nextIteration,
selfDifficulty,
currentEF,
persona: user.persona,
now,
});
nextScheduledAt = sm2.scheduledAt;
nextPredictedP = null;
// EF 업데이트: tagId 있는 경우 저장
if (review.studyLog.tagId) {
await tx.skillSnapshot.upsert({
where: { userId_tagId: { userId, tagId: review.studyLog.tagId } },
create: {
userId,
tagId: review.studyLog.tagId,
s0: sm2.newEF,
lastUpdatedAt: now,
sampleCount: nextIteration,
},
update: {
s0: sm2.newEF,
lastUpdatedAt: now,
sampleCount: nextIteration,
},
});
}
} else {
// ── 기존 망각곡선 경로 (selfDifficulty 없는 경우) ────────────────────
let newS0: number;
if (review.studyLog.tagId) {
const existing = await tx.skillSnapshot.findUnique({
where: {
userId_tagId: { userId, tagId: review.studyLog.tagId },
},
});
newS0 = this.forget.updateS0({
previousS0: existing?.s0 ?? null,
result,
baseCorrectRate: review.studyLog.baseCorrectRate ?? undefined,
});
await tx.skillSnapshot.upsert({
where: {
userId_tagId: { userId, tagId: review.studyLog.tagId },
},
create: {
userId,
tagId: review.studyLog.tagId,
s0: newS0,
lastUpdatedAt: now,
sampleCount: 1,
},
update: {
s0: newS0,
lastUpdatedAt: now,
sampleCount: { increment: 1 },
},
});
} else {
newS0 = this.forget.updateS0({
previousS0: null,
result,
baseCorrectRate: review.studyLog.baseCorrectRate ?? undefined,
});
}
const schedule = this.forget.schedule({
s0: newS0,
persona: user.persona,
@@ -152,13 +190,6 @@ export class ReviewsService {
nextPredictedP = schedule.predictedP;
}
// e. Fetch the true latest iteration for this studyLog inside tx
const lastDone = await tx.reviewSchedule.findFirst({
where: { studyLogId: review.studyLogId, status: 'done' },
orderBy: { iteration: 'desc' },
});
const nextIteration = (lastDone?.iteration ?? flipped.iteration) + 1;
// f. Insert next review row
const nextRow = await tx.reviewSchedule.create({
data: {
@@ -171,7 +202,7 @@ export class ReviewsService {
},
});
return { updated: flipped, nextReview: nextRow, s0: newS0 };
return { updated: flipped, nextReview: nextRow };
});
return txResult;

View File

@@ -12,7 +12,7 @@ import {
StudyResult,
} from "@prisma/client";
import { PrismaService } from "../prisma/prisma.service";
import { PersonaForgetService } from "../forget/persona-forget.service";
import { PersonaForgetService, DEFAULT_EF } from "../forget/persona-forget.service";
export interface CreateStudyLogInput {
subjectId: number;
@@ -551,53 +551,86 @@ export class StudyLogsService {
},
});
let s0 = 0.3;
if (input.tagId) {
const existing = await tx.skillSnapshot.findUnique({
where: { userId_tagId: { userId, tagId: input.tagId } },
});
const prevS0 = existing?.s0 ?? null;
s0 = this.forget.updateS0({
previousS0: prevS0,
result: input.result,
baseCorrectRate: input.baseCorrectRate ?? undefined,
});
await tx.skillSnapshot.upsert({
where: { userId_tagId: { userId, tagId: input.tagId } },
create: {
userId,
tagId: input.tagId,
s0,
lastUpdatedAt: now,
sampleCount: 1,
},
update: {
s0,
lastUpdatedAt: now,
sampleCount: { increment: 1 },
},
});
} else {
s0 = this.forget.updateS0({
previousS0: null,
result: input.result,
baseCorrectRate: input.baseCorrectRate ?? undefined,
});
}
const SELF_DIFFICULTY_DAYS: Record<string, number> = {
hard: 1,
medium: 7,
easy: 30,
};
let scheduledAt: Date;
let predictedP: number | null = null;
if (input.selfDifficulty) {
const days = SELF_DIFFICULTY_DAYS[input.selfDifficulty];
scheduledAt = new Date(now.getTime() + days * 24 * 3_600_000);
// ── SM-2 하이브리드 경로 ────────────────────────────────────────────────
// tagId 있으면 SkillSnapshot.s0 에서 EF 로드 (EF 범위 1.3~4.0 이면 사용)
let currentEF = DEFAULT_EF;
if (input.tagId) {
const snap = await tx.skillSnapshot.findUnique({
where: { userId_tagId: { userId, tagId: input.tagId } },
});
if (snap && snap.s0 >= 1.3 && snap.s0 <= 4.0) {
currentEF = snap.s0;
}
}
const sm2 = this.forget.sm2Schedule({
iteration: 0,
selfDifficulty: input.selfDifficulty,
currentEF,
persona: user.persona,
now,
});
scheduledAt = sm2.scheduledAt;
predictedP = null;
// EF 저장: tagId 있는 경우 SkillSnapshot.s0 에 EF 값으로 upsert
if (input.tagId) {
await tx.skillSnapshot.upsert({
where: { userId_tagId: { userId, tagId: input.tagId } },
create: {
userId,
tagId: input.tagId,
s0: sm2.newEF,
lastUpdatedAt: now,
sampleCount: 1,
},
update: {
s0: sm2.newEF,
lastUpdatedAt: now,
sampleCount: { increment: 1 },
},
});
}
} else {
// ── 기존 망각곡선 경로 (selfDifficulty 없는 경우) ────────────────────────
let s0 = 0.3;
if (input.tagId) {
const existing = await tx.skillSnapshot.findUnique({
where: { userId_tagId: { userId, tagId: input.tagId } },
});
const prevS0 = existing?.s0 ?? null;
s0 = this.forget.updateS0({
previousS0: prevS0,
result: input.result,
baseCorrectRate: input.baseCorrectRate ?? undefined,
});
await tx.skillSnapshot.upsert({
where: { userId_tagId: { userId, tagId: input.tagId } },
create: {
userId,
tagId: input.tagId,
s0,
lastUpdatedAt: now,
sampleCount: 1,
},
update: {
s0,
lastUpdatedAt: now,
sampleCount: { increment: 1 },
},
});
} else {
s0 = this.forget.updateS0({
previousS0: null,
result: input.result,
baseCorrectRate: input.baseCorrectRate ?? undefined,
});
}
const schedule = this.forget.schedule({
s0,
persona: user.persona,
@@ -620,7 +653,7 @@ export class StudyLogsService {
},
});
return { studyLog: log, nextReview: reviewRow, s0 };
return { studyLog: log, nextReview: reviewRow };
}
}

View File

@@ -15,10 +15,12 @@ import type { SampleProblem } from '../data/types';
type SelfDifficulty = 'hard' | 'medium' | 'easy';
const SELF_DIFFICULTY_DAYS: Record<SelfDifficulty, number> = {
// SM-2 기반 최초 학습(iteration=0) 간격 미리보기
// 서버와 동일한 공식: persona_factor 는 클라이언트 미지이므로 pf=1.0 기준
const INITIAL_INTERVAL_DAYS: Record<SelfDifficulty, number> = {
hard: 1,
medium: 7,
easy: 30,
medium: 3,
easy: 7,
};
const DIFFICULTY_SCORE: Record<SampleProblem['difficulty'], number> = {
@@ -611,7 +613,7 @@ function EbookViewer({ id, title, grade, problems }: EbookViewerProps) {
>
<SelfEvalEmoji>🔴</SelfEvalEmoji>
<SelfEvalLabel></SelfEvalLabel>
<SelfEvalInterval>1 </SelfEvalInterval>
<SelfEvalInterval>{INITIAL_INTERVAL_DAYS.hard} </SelfEvalInterval>
</SelfEvalButton>
<SelfEvalButton
type="button"
@@ -621,7 +623,7 @@ function EbookViewer({ id, title, grade, problems }: EbookViewerProps) {
>
<SelfEvalEmoji>🟡</SelfEvalEmoji>
<SelfEvalLabel></SelfEvalLabel>
<SelfEvalInterval>7 </SelfEvalInterval>
<SelfEvalInterval>{INITIAL_INTERVAL_DAYS.medium} </SelfEvalInterval>
</SelfEvalButton>
<SelfEvalButton
type="button"
@@ -631,7 +633,7 @@ function EbookViewer({ id, title, grade, problems }: EbookViewerProps) {
>
<SelfEvalEmoji>🟢</SelfEvalEmoji>
<SelfEvalLabel></SelfEvalLabel>
<SelfEvalInterval>30 </SelfEvalInterval>
<SelfEvalInterval>{INITIAL_INTERVAL_DAYS.easy} </SelfEvalInterval>
</SelfEvalButton>
</SelfEvalButtons>
</>

View File

@@ -29,6 +29,24 @@ import { animations, theme } from '@/styles/theme';
type ReviewAction = 'hard' | 'medium' | 'easy' | 'skip';
// SM-2 간격 미리보기 (클라이언트 측 근사값, 서버가 정확한 값 계산)
// persona 를 모르므로 기본 pf=1.0, EF=2.5 기준
const DEFAULT_EF_PREVIEW = 2.5;
const MAX_INTERVAL_PREVIEW = 60;
function previewIntervalDays(
iteration: number,
selfDifficulty: 'hard' | 'medium' | 'easy',
): number {
if (selfDifficulty === 'hard') return 1;
if (iteration <= 0) {
return selfDifficulty === 'medium' ? 3 : 7;
}
if (iteration === 1) return 6;
const days = Math.round(6 * Math.pow(DEFAULT_EF_PREVIEW, iteration - 1));
return Math.max(1, Math.min(MAX_INTERVAL_PREVIEW, days));
}
const SELF_DIFFICULTY_RESULT: Record<'hard' | 'medium' | 'easy', StudyResult> = {
hard: 'incorrect',
medium: 'partial',
@@ -695,7 +713,7 @@ export default function ReviewPage() {
<ActionLabel>
🔴
</ActionLabel>
<ActionHelper>1 </ActionHelper>
<ActionHelper>{previewIntervalDays(currentItem.iteration, 'hard')} </ActionHelper>
</ActionCopy>
<ActionShortcut>1</ActionShortcut>
</ActionButton>
@@ -710,7 +728,7 @@ export default function ReviewPage() {
<ActionLabel>
🟡
</ActionLabel>
<ActionHelper>7 </ActionHelper>
<ActionHelper>{previewIntervalDays(currentItem.iteration, 'medium')} </ActionHelper>
</ActionCopy>
<ActionShortcut>2</ActionShortcut>
</ActionButton>
@@ -725,7 +743,7 @@ export default function ReviewPage() {
<ActionLabel>
🟢
</ActionLabel>
<ActionHelper>30 </ActionHelper>
<ActionHelper>{previewIntervalDays(currentItem.iteration, 'easy')} </ActionHelper>
</ActionCopy>
<ActionShortcut>3</ActionShortcut>
</ActionButton>