# AI 꾸러미 — 알맞은 난이도 고르기 (적응형) — Adaptive difficulty (dynamic difficulty adjustment)
> 최근 정답률을 늘 재서 85%를 넘으면 한 단계 위, 70% 밑이면 한 단계 아래 문제를 내, 늘 「조금 어려운」 몰입 구간에 머물게 한다.  
> 견본: https://ai-techstudio.web.app/#t/i402

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

## 주문서

### 만들어 줘: 알맞은 난이도 고르기 (적응형) — Adaptive difficulty (dynamic difficulty adjustment)

#### 1. 목표
덧셈 · 곱셈 연습 모드에 알맞은 난이도 고르기를 넣어 줘 — 최근 정답률(지수 이동 평균)이 85%를 넘으면 한 단계 위, 70% 밑이면 한 단계 아래, 바꾼 뒤 2문제는 그대로. 정답률 · 난이도 그래프도 어두운 칠판 그래프(으)로 보여 줘.

#### 2. 핵심 기술 용어
- **Adaptive difficulty (dynamic difficulty adjustment)** — 알맞은 난이도 고르기
- **Exponential moving average** — 지수 이동 평균 — 최근 답일수록 무겁게 센 정답률
- **Flow channel** — 몰입 구간 — 너무 쉽지도 어렵지도 않은 70~85%
- **Hysteresis / cooldown** — 바꾼 뒤 몇 문제는 그대로 두기 (출렁임 막기)

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

#### 4. 조건
- 정답률은 지수 이동 평균(0.84 / 0.16), 처음 값은 0.78 (몰입 구간 안에서 시작)
- 올리기 85% 초과 · 내리기 70% 미만 — 두 문턱 사이에서는 그대로
- 바꾼 뒤 2문제는 쉬기 (cool = 2)
- 난이도는 1 ~ 10 범위를 넘지 않게
- 그래프에 몰입 구간 띠(70~85%)와 난이도 계단을 함께 그려, 왜 바뀌었는지 보이게

#### 5. 완성 기준 (이게 보이면 성공)
- 연달아 맞히면 정답률 선이 85% 위로 올라가고 난이도 계단이 한 칸 올라간다
- 연달아 틀리면 70% 밑으로 내려가며 한 칸 내려간다, 바뀐 순간 ▲ · ▼ 표시
- 자동 재생에서 실력이 오르는 학생은 계단을 오르면서도 정답률 선이 띠 안에 머문다
- 「고정 난이도」 선과 나란히 보면 고정 쪽은 띠 밖으로 벗어난다

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

## 원리
- 정답률은 지수 이동 평균으로 — 답할 때마다 rate = rate × 0.84 + (맞음 ? 1 : 0) × 0.16.
- 정답률이 85% 를 넘으면 난이도 +1, 70% 밑이면 −1 (1 ~ 10단계).
- 바꾼 뒤 2문제는 다시 바꾸지 않는다 — 안 그러면 한 문제마다 출렁인다.
- 난이도마다 문제 만드는 함수가 따로 있다 (1 = 한 자리 덧셈 … 10 = 두 자리 × 두 자리).

## 핵심 코드 — 답할 때마다 정답률 갱신 → 문턱 넘으면 난이도 한 칸
(발췌: demos/demosLearn.ts i402 submit() · makeQ() 를 정리 (makeQ 는 10단계 중 셋만))
```ts
const S = { rate: 0.78, level: 3, cool: 0 };
let q = makeQ(S.level, Math.random); // makeQ(단계, 난수) → { text, ans }

function submit(answer: string): 'up' | 'down' | '' {
  const ok = +answer.trim() === q.ans;
  // 지수 이동 평균 — 최근 답일수록 무겁게
  S.rate = S.rate * 0.84 + (ok ? 1 : 0) * 0.16;
  let change: 'up' | 'down' | '' = '';
  if (S.cool > 0) S.cool--; // 바꾼 뒤 2문제는 그대로
  else if (S.rate > 0.85 && S.level < 10) {
    S.level++;
    S.cool = 2;
    change = 'up';
  } else if (S.rate < 0.7 && S.level > 1) {
    S.level--;
    S.cool = 2;
    change = 'down';
  }
  q = makeQ(S.level, Math.random);
  return change;
}

// 난이도별 문제 — 예: 1단계 한 자리 덧셈, 7단계 구구단
function makeQ(lv: number, r: () => number): { text: string; ans: number } {
  const ri = (a: number, b: number): number => a + Math.floor(r() * (b - a + 1));
  if (lv === 1) { const a = ri(1, 5); const b = ri(1, 9 - a); return { text: a + ' + ' + b, ans: a + b }; }
  if (lv === 7) { const a = ri(3, 9); const b = ri(3, 9); return { text: a + ' × ' + b, ans: a * b }; }
  const a = ri(12, 39); const b = ri(12, 29);
  return { text: a + ' × ' + b, ans: a * b };
}
```

## 흔한 실수 · 확인 목록
- [ ] **쉬는 문제 없이 바로바로 바꾸면 난이도가 한 문제마다 출렁인다** — 바꾼 뒤 2문제는 그대로 두고(cool), 올리기 · 내리기 문턱 사이에 빈 구간(70~85%)을 둔다.
- [ ] **최근 몇 개의 단순 평균을 쓰면 오래된 실수가 갑자기 빠지며 튄다** — 지수 이동 평균은 옛 답의 무게가 서서히 줄어 선이 매끈하다.
- [ ] **난이도가 바뀐 것을 안 보여 주면 아이가 갑자기 어려워졌다고 느낀다** — 바뀐 순간 ▲ · ▼ 와 단계 이름(「구구단」)을 잠깐 보여 준다.

## 완성 기준 체크리스트
- [ ] 연달아 맞히면 정답률 선이 85% 위로 올라가고 난이도 계단이 한 칸 올라간다
- [ ] 연달아 틀리면 70% 밑으로 내려가며 한 칸 내려간다, 바뀐 순간 ▲ · ▼ 표시
- [ ] 자동 재생에서 실력이 오르는 학생은 계단을 오르면서도 정답률 선이 띠 안에 머문다
- [ ] 「고정 난이도」 선과 나란히 보면 고정 쪽은 띠 밖으로 벗어난다

## 이 기술 정보
- id: `i402` · 분류: 게임 시스템 · AI › 배움 · 피드백 · 공통 · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 답할 때 곱셈 두 번 · 비교 두 번. 그래프는 문제 40개만 그린다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i402
- 쓰면 좋을 때: 실력 차가 큰 아이들이 같은 연습 모드를 쓸 때 / 정해진 단계 대신 끝없이 이어지는 연습 · 오늘의 문제
- 쓰지 말 때: 정해진 단계를 차례로 깨는 퍼즐 — 깬 단계 표시가 더 분명하다 / 대결 게임 — 몰래 쉬워지면 공정하지 않다 (대신 난이도 고르기 창)

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i402 — `src/demos/demosLearn.ts:1780`
```ts
const i402: DemoDom = {
  kind: 'dom',
  caption: '최근 정답률이 85%를 넘으면 한 단계 위, 70% 밑이면 한 단계 아래 — 몰입 구간 안에서 난이도 계단이 올라가요',
  make(box) {
    const N = 40;
    const R = rig(box, 'h402', { over: `<canvas class="cv"></canvas>`, panel: `<span class="lab plab"></span><input class="ans" inputmode="numeric" maxlength="6" placeholder="답"/><button class="btn" data-a="go">${ic('check')}확인</button><span class="lab res"></span>` });
    const root = R.root;
    const cv = root.querySelector('canvas.cv') as HTMLCanvasElement;
    const input = root.querySelector('input.ans') as HTMLInputElement;
    const plab = root.querySelector('.plab') as HTMLElement;
    const res = root.querySelector('.res') as HTMLElement;
    let showFixed = true;
    interface Ser {
      rate: number[];
      diff: number[];
      ok: boolean[];
      fixed: number[];
    }
    function simulate(seed: number): Ser {
      const r = rng(seed);
      const s: Ser = { rate: [], diff: [], ok: [], fixed: [] };
      let rate = 0.78;
      let fr = 0.5;
      let diff = 2;
      let cool = 0;
      for (let i = 0; i < N; i++) {
        const skill = 1.2 + i * 0.19;
        const p = 1 / (1 + Math.exp(-(skill - diff + 1) * 1.3));
        const ok = r() < p;
        const pf = 1 / (1 + Math.exp(-(skill - 5 + 1) * 1.3));
        fr = fr * 0.82 + (r() < pf ? 1 : 0) * 0.18;
        rate = rate * 0.78 + (ok ? 1 : 0) * 0.22;
        s.ok.push(ok);
        s.rate.push(rate);
        s.diff.push(diff);
        s.fixed.push(fr);
        if (cool > 0) cool--;
        else if (rate > 0.85 && diff < 10) {
          diff++;
          cool = 1;
        } else if (rate < 0.7 && diff > 1) {
          diff--;
          cool = 1;
        }
      }
      return s;
    }
    let sim = simulate(1);
    let simK = -1;
    const man = { rate: [] as number[], diff: [] as number[], ok: [] as boolean[], cur: 0.78, d: 3, cool: 0, q: makeQ(3, rng(5)), r: rng(99), at: -9, last: '' };
    let now = 0;
    const A = autoCtl(() => undefined);
    function setQ(): void {
      man.q = makeQ(man.d, man.r);
      plab.textContent = `Lv ${man.d} · ${man.q.text} =`;
      input.value = '';
    }
    setQ();
    function submit(): void {
      if (!A.manual) {
        A.manual = true;
        man.rate = [];
        man.diff = [];
        man.ok = [];
        man.cur = 0.78;
      }
      if (!input.value.trim()) return;
      const ok = +input.value.trim() === man.q.ans;
      man.cur = man.cur * 0.84 + (ok ? 1 : 0) * 0.16;
      man.ok.push(ok);
      man.rate.push(man.cur);
      man.diff.push(man.d);
      res.textContent = ok ? '맞았어요' : `정답 ${man.q.ans}`;
      res.style.color = ok ? '#7ef0be' : '#ff9b7a';
      man.last = '';
      if (man.cool > 0) man.cool--;
      else if (man.cur > 0.85 && man.d < 10) {
        man.d++;
        man.cool = 2;
        man.last = 'up';
      } else if (man.cur < 0.7 && man.d > 1) {
        man.d--;
        man.cool = 2;
        man.last = 'down';
      }
      man.at = now;
      if (man.rate.length > N) {
        man.rate.shift();
        man.diff.shift();
        man.ok.shift();
      }
      setQ();
    }
    root.addEventListener('pointerdown', (e) => {
      const el = (e.target as Element).closest('[data-a]');
      if (!el || !R.sync()) return;
      e.preventDefault();
      submit();
    });
    input.addEventListener('keydown', (e) => {
      if (e.key === 'Enter') submit();
    });
    const X0 = 34;
    const X1 = 226;
    const Y0 = 32;
    const Y1 = 132;
    const xOf = (i: number): number => X0 + ((X1 - X0) * i) / (N - 1);
    const yOf = (v: number): number => Y1 - (Y1 - Y0) * v;
    return {
      controls: [A.ctl, { type: 'toggle', label: '고정 난이도와 비교', value: true, on: (v) => (showFixed = v) }],
      update(t) {
        now = t;
        R.sync();
        if (A.base < 0) A.base = t;
        let n: number;
        let rate: number[];
        let diff: number[];
        let oks: boolean[];
        let fixed: number[] = [];
        let change = '';
        let changeAge = 9;
        if (A.manual) {
          rate = man.rate;
          diff = man.diff;
          oks = man.ok;
          n = rate.length;
          change = man.last;
          changeAge = t - man.at;
        } else {
          const lt = t - A.base;
          const CY = 13;
          const k = Math.floor(lt / CY);
          if (k !== simK) {
            simK = k;
            sim = simulate(k + 3);
          }
          const p = lt - k * CY;
          n = Math.min(N, Math.floor(p * 3.8) + 1);
          rate = sim.rate;
          diff = sim.diff;
          oks = sim.ok;
          fixed = sim.fixed;
          if (n >= 2 && n <= N) {
            const dd = diff[n - 1]! - diff[n - 2]!;
            if (dd) {
              change = dd > 0 ? 'up' : 'down';
              changeAge = p * 3.8 - (n - 1);
            } else {
              for (let j = n - 1; j >= 1 && n - j < 3; j--) {
                const d2 = diff[j]! - diff[j - 1]!;
                if (d2) {
                  change = d2 > 0 ? 'up' : 'down';
                  changeAge = (p * 3.8 - j) / 3.8;
                  break;
                }
              }
            }
          }
        }
        const g = fitCanvas(cv);
        // 판
        rrect(g, 8, 10, 228, 182, 12);
        g.fillStyle = 'rgba(43,29,21,.92)';
        g.fill();
        g.strokeStyle = 'rgba(255,255,255,.07)';
        g.stroke();
        ctext(g, '최근 정답률', 16, 18, 8.5, '#ffe2bd', 'left', 900);
        // 몰입 띠
        g.fillStyle = 'rgba(79,214,156,.16)';
        g.fillRect(X0, yOf(0.85), X1 - X0, yOf(0.7) - yOf(0.85));
        g.strokeStyle = 'rgba(79,214,156,.6)';
        g.setLineDash([3, 3]);
        g.lineWidth = 0.8;
        g.beginPath();
        g.moveTo(X0, yOf(0.85));
        g.lineTo(X1, yOf(0.85));
        g.moveTo(X0, yOf(0.7));
        g.lineTo(X1, yOf(0.7));
        g.stroke();
        g.setLineDash([]);
        ctext(g, '몰입 구간 70~85%', X1 - 2, yOf(0.7) + 6, 7, '#7ef0be', 'right', 900);
        for (const v of [0, 0.5, 1]) {
          g.strokeStyle = 'rgba(255,255,255,.07)';
          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);
        }
        // 고정 난이도 선
        if (showFixed && fixed.length && n > 1) {
          g.strokeStyle = 'rgba(190,170,150,.55)';
          g.lineWidth = 1.4;
          g.setLineDash([3, 2.5]);
          g.beginPath();
          for (let i = 0; i < n; i++) g[i ? 'lineTo' : 'moveTo'](xOf(i), yOf(fixed[i]!));
          g.stroke();
          g.setLineDash([]);
          if (n > 6) ctext(g, '고정 난이도', xOf(3), yOf(Math.min(fixed[3]!, 0.5)) + 10, 7, '#c8b6a2', 'left', 800);
        }
        // 적응형 선
        if (n > 1) {
          g.strokeStyle = '#ffb25e';
          g.lineWidth = 2.4;
          g.lineJoin = 'round';
          g.shadowColor = '#ff9b42';
          g.shadowBlur = 6;
          g.beginPath();
          for (let i = 0; i < n; i++) g[i ? 'lineTo' : 'moveTo'](xOf(i), yOf(rate[i]!));
          g.stroke();
          g.shadowBlur = 0;
        }
        if (n > 0) {
          const v = rate[n - 1]!;
          g.fillStyle = '#fff';
          g.beginPath();
          g.arc(xOf(n - 1), yOf(v), 3, 0, Math.PI * 2);
          g.fill();
          ctext(g, `${Math.round(v * 100)}%`, xOf(n - 1), yOf(v) - 8, 8, '#ffd9a8', 'center', 900);
        }
        // 맞음/틀림 점
        for (let i = 0; i < n; i++) {
          g.fillStyle = oks[i] ? '#4fd69c' : '#ff6b5a';
          g.beginPath();
          g.arc(xOf(i), 140, 1.7, 0, Math.PI * 2);
          g.fill();
        }
        // 난이도 막대
        ctext(g, '난이도', 16, 150, 7.5, '#9a8470', 'left', 800);
        for (let i = 0; i < n; i++) {
          const d = diff[i]!;
          const h = d * 3.2;
          const hue = 30 + (d - 1) * -3;
          g.fillStyle = `hsl(${hue},85%,${48 + d * 2.5}%)`;
          g.fillRect(xOf(i) - 1.8, 186 - h, 3.6, h);
        }
        // 오른쪽 사다리
        const cd = n ? diff[n - 1]! : 1;
        rrect(g, 244, 10, 68, 182, 12);
        g.fillStyle = 'rgba(43,29,21,.92)';
        g.fill();
        ctext(g, '다음 난이도', 278, 20, 8, '#ffe2bd', 'center', 900);
        for (let lv = 1; lv <= 10; lv++) {
          const y = 182 - (lv - 1) * 15;
          const on = lv === cd;
          rrect(g, 254, y - 5.5, 48, 11, 5.5);
          g.fillStyle = on ? '#ffb25e' : lv < cd ? 'rgba(255,178,94,.25)' : 'rgba(255,255,255,.05)';
          g.fill();
          ctext(g, `Lv ${lv}`, 278, y + 0.3, 7, on ? '#2a1406' : lv < cd ? '#ffd9a8' : '#7d6a58', 'center', 900);
        }
        if (change && changeAge < 1.2) {
          const y = 176 - (cd - 1) * 15;
          const a = 1 - seg(changeAge, 0.7, 1.2);
          g.globalAlpha = a;
          const up = change === 'up';
          void y;
          ctext(g, up ? '▲ 올렸어요' : '▼ 내렸어요', 278, 31 - (up ? 1 : -1) * changeAge * 2, 7.5, up ? '#7ef0be' : '#ff9b7a', 'center', 900);
          g.globalAlpha = 1;
        }
        ctext(g, LVNAME[cd] ?? '', 120, 18, 7.5, '#d8c2a8', 'left', 700);
        if (A.manual && !n) ctext(g, '아래 문제를 풀면 그래프가 그려져요', 130, 80, 9, '#ffe2bd', 'center', 800);
      },
      dispose: () => R.dispose(),
    };
  },
};
```

## 관련 기술
- 먼저 알면 좋은 기술: [「거의 맞았어요」 판정 (근접 정답)](https://ai-techstudio.web.app/ai/t/i400.md) `i400`
- 다음에 해 볼 기술: [간격 반복 (잊을 때쯤 다시)](https://ai-techstudio.web.app/ai/t/i403.md) `i403`
- 참고 문서: [Wikipedia — Flow (psychology)](https://en.wikipedia.org/wiki/Flow_(psychology))
