# AI 꾸러미 — 간격 반복 (잊을 때쯤 다시) — Spaced repetition
> 틀린 문제를 1일 · 3일 · 7일 뒤에 다시 내서, 잊을 때쯤 복습할 때마다 기억이 100%로 돌아오고 더 천천히 잊게 한다.  
> 견본: https://ai-techstudio.web.app/#t/i403

이 문서를 코드 도우미(Claude Code · Cursor · ChatGPT 등)에 그대로 주면 돼요. 「## 주문서」 가 할 일, 나머지는 참고 자료예요.

## 주문서

### 만들어 줘: 간격 반복 (잊을 때쯤 다시) — Spaced repetition

#### 1. 목표
구구단 복습에 간격 반복을 넣어 줘 — 틀린 문제를 1일 · 3일 · 7일 뒤 다시 내고, 기억 곡선(복습할 때마다 100%로 돌아와 더 천천히 줄어듦)을 따뜻한 갈색 공책 그래프로 보여 줘.

#### 2. 핵심 기술 용어
- **Spaced repetition** — 간격 반복 — 간격을 늘려 가며 다시 보기
- **Forgetting curve** — 기억 곡선 — 시간이 지나며 기억이 줄어드는 모양 e^(−t/S)
- **Memory stability** — 기억 안정도 S — 클수록 천천히 잊음
- **Review schedule (Leitner system)** — 복습 날짜표 (상자 옮기기 방식)

#### 3. 환경
- 플랫폼: TypeScript (브라우저), 라이브러리 없이 — 화면과 떨어진 순수 함수로
- 화면: 브라우저 — PC · 폰 모두

#### 4. 조건
- 기억 = e^(−(오늘 − 마지막 복습일) / S[복습 횟수]), S = [2, 5, 12, 30, 60, 90]
- 추천 복습일은 1 · 3 · 7일 — 사용자가 날짜를 넣고 뺄 수 있게
- 「잊기 시작」 60% 점선을 그어, 복습이 그 선 위에 머무는지 보이게
- 복습 안 한 곡선(점선)을 함께 그려 비교
- 복습 날짜는 저장소(Storage)에 문제별로 — 날짜는 하루 단위로 반올림

#### 5. 완성 기준 (이게 보이면 성공)
- 추천 1 · 3 · 7일이면 곡선이 복습 날마다 100% 로 튀어 오르고, 14일째에도 60% 위에 있다
- 복습을 모두 빼면 곡선이 2일 만에 60% 아래로 떨어진다 (복습 안 한 점선과 같음)
- 그래프를 눌러 복습 날을 넣고 빼면 곡선이 바로 다시 그려진다

#### 6. 진행 방식
- 핵심 코드 위주로, 설명은 짧게. 내 프로젝트에 끼워 넣기 쉬운 함수 · 클래스로 나눠 줘.
- 처음 화면에 바로 결과가 보이게, 그리고 켬/끔(또는 전/후) 비교를 할 수 있게 만들어 줘.
- 그림 · 소리 · 모델 파일이 필요하면 코드로 만든 임시 대체물로 먼저 돌아가게 하고, 진짜 파일로 바꿀 자리를 표시해 줘.
- 마지막에 「확인 방법」(무엇을 보면 성공인지)과 「조절할 값」 목록을 짧게 정리해 줘.
- 답변과 코드 주석은 한국어로 해 줘.

## 원리
- 기억은 마지막 복습 뒤 지난 날 수 d 에 따라 e^(−d / S) 로 줄어든다.
- 복습할 때마다 기억은 100% 로 돌아오고, 안정도 S 가 커진다 — 견본은 2 → 5 → 12 → 30 → 60 → 90.
- 그래서 복습 간격을 1일 · 3일 · 7일처럼 점점 늘려도 기억이 「잊기 시작(60%)」 선 위에 머문다.
- 틀린 문제에 다음 복습 날짜를 적어 두고, 그날 문제 목록에 섞어 낸다.

## 핵심 코드 — 복습 날짜에 따른 기억 정도
(발췌: demos/demosLearn.ts i403 retention() · 아래 due · reviewed 는 새로 씀 (같은 1 · 3 · 7일 간격))
```ts
// 복습할 때마다 안정도가 커진다 (클수록 천천히 잊음)
const SVAL = [2, 5, 12, 30, 60, 90];
let reviews = [1, 3, 7]; // 추천 복습일

/** d 일째 기억에 남은 정도 (0~1) */
function retention(d: number, rv: number[]): number {
  let last = 0; // 마지막 복습일
  let k = 0;    // 지금까지 복습한 횟수
  for (const r of rv) if (r <= d) {
    last = r;
    k++;
  }
  return Math.exp(-(d - last) / SVAL[Math.min(k, SVAL.length - 1)]);
}

// 오늘 낼 복습 문제 고르기 — 다음 복습일이 오늘 이하인 것
interface Card { id: string; next: number; step: number }
const GAPS = [1, 2, 4]; // 1일 → 3일 → 7일 (간격이 1 · 2 · 4 일씩 늘어남)
function due(cards: Card[], today: number): Card[] {
  return cards.filter((c) => c.next <= today);
}
function reviewed(c: Card, today: number, ok: boolean): void {
  c.step = ok ? Math.min(c.step + 1, GAPS.length - 1) : 0; // 또 틀리면 처음부터
  c.next = today + GAPS[c.step];
}
```

## 흔한 실수 · 확인 목록
- [ ] **복습 간격을 늘 같게 하면 외운 것도 계속 나와 지루하다** — 맞힐 때마다 간격을 늘리고(1 → 3 → 7일), 틀리면 처음 간격으로 되돌린다.
- [ ] **시각을 그대로 저장하면 밤 11시에 푼 문제가 다음 날 아침에 안 나온다** — 날짜는 하루 단위(자정 기준 날 수)로 반올림해 저장한다.
- [ ] **기억 곡선 모양을 지어낸 숫자로 정확한 과학처럼 보여 주면 오해한다** — 안정도 값(2 · 5 · 12 …)은 모양을 보여 주는 견본 값임을 그래프 설명에 적는다.

## 완성 기준 체크리스트
- [ ] 추천 1 · 3 · 7일이면 곡선이 복습 날마다 100% 로 튀어 오르고, 14일째에도 60% 위에 있다
- [ ] 복습을 모두 빼면 곡선이 2일 만에 60% 아래로 떨어진다 (복습 안 한 점선과 같음)
- [ ] 그래프를 눌러 복습 날을 넣고 빼면 곡선이 바로 다시 그려진다

## 이 기술 정보
- id: `i403` · 분류: 게임 시스템 · AI › 배움 · 피드백 · 공통 · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 문제마다 날짜 하나 · 단계 하나만 저장. 그래프는 점 280개.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i403
- 쓰면 좋을 때: 구구단 · 단어처럼 외워야 하는 것이 많을 때 / 오답 노트 — 틀린 문제를 언제 다시 낼지 정할 때
- 쓰지 말 때: 한 번 풀면 끝나는 퍼즐 · 이야기 — 다시 볼 이유가 없다 / 날짜를 저장할 수 없는 손님 모드 — 대신 같은 날 안에서 몇 문제 뒤 다시

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i403 — `src/demos/demosLearn.ts:2048`
```ts
const i403: DemoDom = {
  kind: 'dom',
  caption: '틀린 문제를 1일 · 3일 · 7일 뒤 다시 — 복습할 때마다 기억이 100%로 돌아오고 더 천천히 잊어요',
  make(box) {
    const DAYS = 14;
    const SVAL = [2, 5, 12, 30, 60, 90];
    const R = rig(box, 'h403', { over: `<canvas class="cv"></canvas>`, panel: `<span class="lab">그래프를 눌러 복습 날짜를 넣고 빼요</span><button class="btn" data-a="rec">${ic('redo')}추천 1 · 3 · 7일</button><button class="btn ghost" data-a="clr">${ic('xc')}복습 모두 빼기</button>` });
    const root = R.root;
    const cv = root.querySelector('canvas.cv') as HTMLCanvasElement;
    let reviews = [1, 3, 7];
    let showNo = true;
    const A = autoCtl(() => (reviews = [1, 3, 7]));
    const X0 = 34;
    const X1 = 304;
    const Y0 = 36;
    const Y1 = 132;
    const xOf = (d: number): number => X0 + ((X1 - X0) * d) / DAYS;
    const yOf = (v: number): number => Y1 - (Y1 - Y0) * v;
    function retention(d: number, rv: number[]): number {
      let last = 0;
      let k = 0;
      for (const r of rv) if (r <= d) {
        last = r;
        k++;
      }
      return Math.exp(-(d - last) / SVAL[Math.min(k, SVAL.length - 1)]!);
    }
    cv.addEventListener('pointerdown', (e) => {
      if (!R.sync()) return;
      const rc = cv.getBoundingClientRect();
      const x = ((e.clientX - rc.left) / rc.width) * 320;
      const d = Math.round(((x - X0) / (X1 - X0)) * DAYS);
      if (d < 1 || d > DAYS) return;
      A.manual = true;
      reviews = reviews.includes(d) ? reviews.filter((r) => r !== d) : [...reviews, d].sort((a, b) => a - b);
    });
    cv.style.pointerEvents = 'auto';
    root.addEventListener('pointerdown', (e) => {
      const el = (e.target as Element).closest('[data-a]');
      if (!el || !R.sync()) return;
      A.manual = true;
      reviews = el.getAttribute('data-a') === 'rec' ? [1, 3, 7] : [];
    });
    return {
      controls: [A.ctl, { type: 'toggle', label: '복습 안 한 곡선 보기', value: true, on: (v) => (showNo = v) }],
      update(t) {
        const big = R.sync();
        cv.style.cursor = big ? 'pointer' : '';
        if (A.base < 0) A.base = t;
        const lt = t - A.base;
        const CY = 10;
        const p = lt % CY;
        const cur = A.manual ? DAYS : Math.min(DAYS, sm(seg(p, 0.3, 7.6)) * DAYS);
        const g = fitCanvas(cv);
        rrect(g, 8, 8, 304, 132, 12);
        g.fillStyle = 'rgba(43,29,21,.92)';
        g.fill();
        ctext(g, '기억에 남은 정도', 16, 15, 8.5, '#ffe2bd', 'left', 900);
        for (const v of [0, 0.5, 1]) {
          g.strokeStyle = 'rgba(255,255,255,.07)';
          g.lineWidth = 0.8;
          g.beginPath();
          g.moveTo(X0, yOf(v));
          g.lineTo(X1, yOf(v));
          g.stroke();
          ctext(g, `${v * 100}%`, X0 - 4, yOf(v), 7, '#9a8470', 'right', 700);
        }
        g.strokeStyle = 'rgba(255,107,90,.55)';
        g.setLineDash([3, 3]);
        g.beginPath();
        g.moveTo(X0, yOf(0.6));
        g.lineTo(X1, yOf(0.6));
        g.stroke();
        g.setLineDash([]);
        ctext(g, '이쯤에서 잊기 시작', X1 - 2, yOf(0.6) + 7, 7, '#ff9b7a', 'right', 800);
        // 복습 없는 곡선
        if (showNo) {
          g.strokeStyle = 'rgba(190,170,150,.5)';
          g.lineWidth = 1.4;
          g.setLineDash([3, 2.5]);
          g.beginPath();
          for (let i = 0; i <= 140; i++) {
            const d = (i / 140) * cur;
            g[i ? 'lineTo' : 'moveTo'](xOf(d), yOf(Math.exp(-d / SVAL[0]!)));
          }
          g.stroke();
          g.setLineDash([]);
          if (cur > 4) ctext(g, '복습 안 함', xOf(Math.min(cur, 8)) + 2, yOf(Math.exp(-Math.min(cur, 8) / 2)) - 7, 7, '#c8b6a2', 'left', 800);
        }
        // 복습 곡선 (채움 + 선)
        const pts: [number, number][] = [];
        for (let i = 0; i <= 280; i++) {
          const d = (i / 280) * cur;
          for (const r of reviews)
            if (r <= d && r > d - cur / 280 && r > 0) {
              pts.push([xOf(r), yOf(retention(r - 1e-6, reviews))]);
              pts.push([xOf(r), yOf(1)]);
            }
          pts.push([xOf(d), yOf(retention(d, reviews))]);
        }
        const gr = g.createLinearGradient(0, Y0, 0, Y1);
        gr.addColorStop(0, 'rgba(255,178,94,.35)');
        gr.addColorStop(1, 'rgba(255,178,94,0)');
        g.beginPath();
        g.moveTo(X0, Y1);
        for (const [x, y] of pts) g.lineTo(x, y);
        g.lineTo(pts[pts.length - 1]![0], Y1);
        g.closePath();
        g.fillStyle = gr;
        g.fill();
        g.strokeStyle = '#ffb25e';
        g.lineWidth = 2.3;
        g.lineJoin = 'round';
        g.shadowColor = '#ff9b42';
        g.shadowBlur = 6;
        g.beginPath();
        pts.forEach(([x, y], i) => g[i ? 'lineTo' : 'moveTo'](x, y));
        g.stroke();
        g.shadowBlur = 0;
        // 복습 점
        for (const r of reviews) {
          if (r > cur) {
            g.strokeStyle = 'rgba(255,209,102,.35)';
            g.setLineDash([2, 2]);
            g.beginPath();
            g.moveTo(xOf(r), Y0);
            g.lineTo(xOf(r), Y1);
            g.stroke();
            g.setLineDash([]);
            continue;
          }
          const age = (cur - r) * (7.3 / DAYS);
          const pop = age < 0.5 ? 1 + 0.6 * Math.sin((age / 0.5) * Math.PI) : 1;
          g.fillStyle = '#ffd166';
          g.beginPath();
          g.arc(xOf(r), yOf(1), 3.6 * pop, 0, Math.PI * 2);
          g.fill();
          g.strokeStyle = '#2a1406';
          g.lineWidth = 1.2;
          g.stroke();
          ctext(g, `${r}일`, xOf(r), yOf(1) - 8, 7.5, '#ffe6b0', 'center', 900);
        }
        // 지금 줄
        if (!A.manual) {
          g.strokeStyle = 'rgba(255,255,255,.5)';
          g.lineWidth = 1;
          g.beginPath();
          g.moveTo(xOf(cur), Y0 - 2);
          g.lineTo(xOf(cur), Y1);
          g.stroke();
        }
        for (let d = 0; d <= DAYS; d += 1) ctext(g, String(d), xOf(d), Y1 + 6, 6, d % 7 === 0 ? '#d8c2a8' : '#7d6a58', 'center', 700);
        // 달력 띠
        const cw = (X1 - X0) / DAYS;
        for (let d = 0; d <= DAYS; d++) {
          const x = xOf(d) - cw * 0.44;
          const isR = reviews.includes(d) || d === 0;
          const passed = d <= cur;
          rrect(g, x, 148, cw * 0.88, 44, 4);
          g.fillStyle = isR ? (passed ? 'rgba(255,178,94,.2)' : 'rgba(255,255,255,.06)') : 'rgba(255,255,255,.03)';
          g.fill();
          if (isR) {
            const flip = passed ? clamp((cur - d) * 2.5) : 0;
            const sx = Math.abs(Math.cos(flip * Math.PI));
            const back = flip > 0.5;
            g.save();
            g.translate(xOf(d), 168);
            g.scale(Math.max(0.05, sx), 1);
            rrect(g, -cw * 0.36, -12, cw * 0.72, 24, 3);
            g.fillStyle = back ? '#d9f6e4' : d === 0 ? '#fffaf0' : '#fff1d8';
            g.fill();
            ctext(g, back ? '56' : '7×8', 0, back ? -3 : 0, cw * 0.24, '#3b2a1e', 'center', 900);
            if (back) ctext(g, '✓', 0, 6.5, 6, '#1f8a57', 'center', 900);
            g.restore();
          }
          ctext(g, d === 0 ? '배움' : `D+${d}`, xOf(d), 188, 5.2, isR ? '#ffd9a8' : '#6d5a4a', 'center', 800);
        }
        const fin = retention(DAYS, reviews);
        ctext(g, `14일 뒤 기억 ${Math.round(fin * 100)}%`, 196, 15, 8.5, '#ffd166', 'center', 900);
      },
      dispose: () => R.dispose(),
    };
  },
};
```

## 관련 기술
- 먼저 알면 좋은 기술: [알맞은 난이도 고르기 (적응형)](https://ai-techstudio.web.app/ai/t/i402.md) `i402`
- 다음에 해 볼 기술: [「거의 맞았어요」 판정 (근접 정답)](https://ai-techstudio.web.app/ai/t/i400.md) `i400`
- 참고 문서: [Wikipedia — Spaced repetition](https://en.wikipedia.org/wiki/Spaced_repetition) · [Wikipedia — Forgetting curve](https://en.wikipedia.org/wiki/Forgetting_curve)
