# AI 꾸러미 — 반복 실험 시뮬레이션 (분포가 쌓이는 그래프) — Galton board (bean machine)
> 공 하나하나가 핀마다 왼쪽 · 오른쪽을 반반 확률로 실제로 골라 떨어지며 칸에 쌓이고, 그 막대 위에 이론 이항 분포 선을 겹쳐 반복 실험이 종 모양으로 모이는 것을 보여 준다.  
> 견본: https://ai-techstudio.web.app/#t/i380

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

## 주문서

### 만들어 줘: 반복 실험 시뮬레이션 (분포가 쌓이는 그래프) — Galton board (bean machine)

#### 1. 목표
갈톤 판 체험에 반복 실험 시뮬레이션을 넣어 줘 — 공이 핀마다 반반으로 튀어 칸에 쌓이고, 쌓인 막대 위에 이론 곡선을 겹쳐 보여 주게. 분위기는 어두운 판 · 금빛 공.

#### 2. 핵심 기술 용어
- **Galton board (bean machine)** — 갈톤 판 — 핀에 튀는 공
- **Binomial distribution B(n, ½)** — 이항 분포 — n 번 중 오른쪽이 k 번
- **Monte Carlo simulation** — 진짜 무작위로 여러 번 시행
- **Sum of dice → normal shape** — 주사위 여러 개의 합도 종 모양으로

#### 3. 환경
- 플랫폼: HTML Canvas 2D (TypeScript), requestAnimationFrame 루프, 라이브러리 없이
- 화면: 2D · 브라우저 — PC 와 폰(390px 폭) 모두, devicePixelRatio 맞춰 또렷하게, 60fps 목표

#### 4. 조건
- 무작위는 공마다 실제로 (분포를 흉내 내지 않는다)
- 막대와 이론 선은 같은 배율로
- 공 수 · 줄 수 R 을 화면에 보여 주고 바꿀 수 있게
- 다 떨어지면 잠시 보여 준 뒤 다시 시작

#### 5. 완성 기준 (이게 보이면 성공)
- 공이 핀에 튀며 떨어져 칸에 쌓이고, 막대가 종 모양으로 자란다
- 흰 이론 선(B(R, ½))이 막대 위에 겹쳐 보이고, 공이 늘수록 막대가 선에 가까워진다
- 줄 수를 바꾸면 칸 수와 이론 선이 함께 바뀐다
- 주사위 모드에서 주사위 개수를 늘리면 합의 분포가 점점 종 모양이 된다

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

## 원리
- 공이 생길 때 줄마다 Math.random() < 0.5 로 오른쪽(1) · 왼쪽(0)을 미리 정해 「몇 번째 칸으로 가나」 누적 배열(ks)을 만든다 — 진짜 무작위 시행.
- 그림은 그 길을 따라 줄 사이를 보간하며 튀는 호(sin)로 움직이고, 바닥 막대 높이에 닿으면 그 칸 개수를 1 늘린다.
- 이론: 공 N 개면 칸 k 의 기대 개수 = N · C(R, k) / 2^R. 이 값을 점 · 선으로 막대 위에 겹친다.
- 막대 · 이론 선은 같은 배율(가장 큰 값 기준)로 그려야 비교된다. 공이 늘수록 막대가 선에 붙는다.
- 주사위 m 개의 합은 한 개 분포를 m 번 겹쳐(합성곱) 이론 분포를 구하고, 실제로 굴린 합의 막대와 비교한다.

## 핵심 코드 — 공마다 진짜 무작위 길 · 이론 기대 개수
(발췌: demos/demosMathB.ts i380 galton() · choose() · diceDist() 를 정리)
```ts
interface Ball { age: number; ks: Uint8Array }   // ks[r] = r 줄까지 오른쪽으로 간 횟수

function newBall(R: number): Ball {
  const ks = new Uint8Array(R + 1);
  for (let r = 0; r < R; r++) ks[r + 1] = ks[r] + (Math.random() < 0.5 ? 1 : 0);   // 핀마다 반반
  return { age: -Math.random() * 0.05, ks };
}

const choose = (n: number, k: number): number => {
  let r = 1;
  for (let i = 1; i <= k; i++) r = (r * (n - k + i)) / i;
  return r;
};
/** 공 total 개일 때 칸 k 의 기대 개수 — 이항 분포 B(R, 1/2) */
const expected = (total: number, R: number, k: number) => (total * choose(R, k)) / 2 ** R;

/** 주사위 m 개 합의 분포 (합성곱) */
function diceDist(m: number): number[] {
  let d = [1];
  for (let k = 0; k < m; k++) {
    const nd = new Array(d.length + 5).fill(0);
    d.forEach((v, i) => { for (let f = 0; f < 6; f++) nd[i + f] += v / 6; });
    d = nd;
  }
  return d;                                           // d[i] = 합이 m + i 일 확률
}

// 막대 · 이론 선은 같은 배율로: mx = max(bins[k], expected(...)) 로 sc = 높이 / mx
```

## 흔한 실수 · 확인 목록
- [ ] **이론 분포에서 결과를 뽑아 막대를 그리면 「실험」이 아니다** — 공마다 핀마다 실제로 반반을 골라 떨어뜨린다 — 막대가 흔들리며 선에 다가가는 것이 핵심.
- [ ] **막대와 이론 선의 배율이 다르면 맞는지 비교가 안 된다** — 둘 중 가장 큰 값으로 같은 배율을 정한다.
- [ ] **이항 계수를 팩토리얼로 구하면 줄 수가 커질 때 넘친다** — 곱하고 나누기를 번갈아(r = r · (n − k + i) / i) 계산한다.
- [ ] **떨어진 공을 계속 그리면 수백 개가 쌓여 느려진다** — 바닥에 닿으면 칸 숫자만 올리고 공은 지운다.

## 완성 기준 체크리스트
- [ ] 공이 핀에 튀며 떨어져 칸에 쌓이고, 막대가 종 모양으로 자란다
- [ ] 흰 이론 선(B(R, ½))이 막대 위에 겹쳐 보이고, 공이 늘수록 막대가 선에 가까워진다
- [ ] 줄 수를 바꾸면 칸 수와 이론 선이 함께 바뀐다
- [ ] 주사위 모드에서 주사위 개수를 늘리면 합의 분포가 점점 종 모양이 된다

## 이 기술 정보
- id: `i380` · 분류: 물리 · 수학 원리 › 수학 시각화 기법 · 2D · 난이도 보통 · 폰 부담 가벼움 (폰 OK) — 공은 초당 60개, 최대 500개 · 떨어지면 칸 숫자 하나. 움직이는 공 수십 개만 그린다.
- 라이브 견본 (브라우저에서 직접 조작): https://ai-techstudio.web.app/#t/i380
- 쓰면 좋을 때: 확률 · 통계를 「직접 해 보면 이렇게 된다」로 보여 줄 때 / 게임 결과(주사위 · 뽑기)의 분포를 그래프로
- 쓰지 말 때: 결과를 이론값에서 바로 그리기 — 실험 느낌이 없다. 진짜 무작위로 하나씩 / 공 수만 개를 하나씩 그리기 — 떨어진 공은 칸 숫자로만 남긴다

## 견본 실제 코드 (라이브 견본이 돌리는 코드 — three.js · TypeScript)
### i380 — `src/demos/demosMathB.ts:1860`
```ts
const i380 = cv('공이 핀마다 왼쪽 · 오른쪽 반반 — 쌓인 막대가 이항 분포의 종 모양, 주사위 여러 개의 합도 같은 모양', () => {
  let mode = 0;
  let R = 10;
  let m = 4;
  let speed = 1;
  let clock = 0;
  interface Ball {
    age: number;
    ks: Uint8Array;
  }
  let balls: Ball[] = [];
  let bins: number[] = [];
  let landed = 0;
  let spawn = 0;
  let hold = 0;
  let dHist: number[] = [];
  let rolls = 0;
  let last: number[] = [];
  let rollAcc = 0;
  const RATE = 60;
  const ROWS_PER_S = 9;
  const NB = 500;
  const choose = (nn: number, k: number): number => {
    let r = 1;
    for (let i = 1; i <= k; i++) r = (r * (nn - k + i)) / i;
    return r;
  };
  function resetG(): void {
    balls = [];
    bins = new Array(R + 1).fill(0);
    landed = 0;
    hold = 0;
  }
  function resetD(): void {
    dHist = new Array(5 * m + 1).fill(0);
    rolls = 0;
    last = [];
  }
  resetG();
  resetD();
  let dist: number[] = [];
  let distM = 0;
  function diceDist(): number[] {
    if (distM === m) return dist;
    let d = [1];
    for (let k = 0; k < m; k++) {
      const nd = new Array(d.length + 5).fill(0);
      d.forEach((v, i) => {
        for (let f = 0; f < 6; f++) nd[i + f] += v / 6;
      });
      d = nd;
    }
    dist = d;
    distM = m;
    return d;
  }
  function galton(g: G, dt: number): void {
    const apex = 140;
    const dx = Math.min(19, 236 / (R + 1));
    const dy = Math.min(9, 74 / R);
    const top = 24;
    const hTop = top + R * dy + 4;
    const hBot = 162;
    if (landed + balls.length < NB) {
      spawn += dt * RATE;
      while (spawn >= 1 && landed + balls.length < NB) {
        spawn -= 1;
        const ks = new Uint8Array(R + 1);
        for (let r = 0; r < R; r++) ks[r + 1] = ks[r]! + (Math.random() < 0.5 ? 1 : 0);
        balls.push({ age: -Math.random() * 0.05, ks });
      }
    } else if (!balls.length) {
      hold += dt;
      if (hold > 1.4) resetG();
    }
    const px = (r: number, k: number): number => apex + (k - r / 2) * dx;
    // 깔때기
    g.strokeStyle = inkA(0.25);
    g.lineWidth = 1;
    g.beginPath();
    g.moveTo(apex - 14, 6);
    g.lineTo(apex - 4, top - 6);
    g.moveTo(apex + 14, 6);
    g.lineTo(apex + 4, top - 6);
    g.stroke();
    // 핀
    const pr = Math.min(1.9, dx * 0.12);
    for (let r = 0; r < R; r++) for (let k = 0; k <= r; k++) {
      const x = px(r, k);
      const y = top + r * dy + 2.6;
      g.fillStyle = 'rgba(0,0,0,0.35)';
      g.beginPath();
      g.arc(x + 0.4, y + 0.6, pr, 0, Math.PI * 2);
      g.fill();
      g.fillStyle = '#8f9cc8';
      g.beginPath();
      g.arc(x, y, pr, 0, Math.PI * 2);
      g.fill();
      g.fillStyle = 'rgba(255,255,255,0.8)';
      g.beginPath();
      g.arc(x - pr * 0.3, y - pr * 0.35, pr * 0.4, 0, Math.PI * 2);
      g.fill();
    }
    // 칸막이 · 막대
    const total = landed;
    const E = (k: number): number => (total * choose(R, k)) / 2 ** R;
    let mx = 1;
    for (let k = 0; k <= R; k++) mx = Math.max(mx, bins[k]!, E(k));
    const sc = (hBot - hTop - 6) / mx;
    for (let k = 0; k <= R; k++) {
      const x = apex + (k - R / 2) * dx;
      const hh = bins[k]! * sc;
      if (hh > 0) {
        rr(g, x - dx * 0.4, hBot - hh, dx * 0.8, hh, Math.min(2, dx * 0.2));
        g.fillStyle = accFill(g, hTop, hBot);
        g.fill();
      }
      g.strokeStyle = inkA(0.1);
      g.lineWidth = 0.5;
      g.beginPath();
      g.moveTo(x + dx / 2, hTop + 4);
      g.lineTo(x + dx / 2, hBot);
      g.stroke();
    }
    g.strokeStyle = inkA(0.3);
    g.beginPath();
    g.moveTo(apex - (R / 2 + 0.6) * dx, hBot);
    g.lineTo(apex + (R / 2 + 0.6) * dx, hBot);
    g.stroke();
    // 이론 (이항 분포)
    if (total > 0) {
      g.beginPath();
      for (let k = 0; k <= R; k++) {
        const x = apex + (k - R / 2) * dx;
        const y = hBot - E(k) * sc;
        if (k) g.lineTo(x, y);
        else g.moveTo(x, y);
      }
      g.strokeStyle = 'rgba(255,255,255,0.85)';
      g.lineWidth = 1;
      g.stroke();
      for (let k = 0; k <= R; k++) {
        g.fillStyle = '#fff';
        g.beginPath();
        g.arc(apex + (k - R / 2) * dx, hBot - E(k) * sc, 1.3, 0, Math.PI * 2);
        g.fill();
      }
    }
    // 공
    const keep: Ball[] = [];
    const br = Math.min(2.4, dx * 0.15);
    g.fillStyle = '#ffe2a0';
    for (const b of balls) {
      b.age += dt;
      const s = b.age * ROWS_PER_S - 1;
      let x: number;
      let y: number;
      if (s < 0) {
        x = apex;
        y = lerp(4, top, s + 1);
      } else if (s < R) {
        const r = Math.floor(s);
        const fr = s - r;
        x = lerp(px(r, b.ks[r]!), px(r + 1, b.ks[r + 1]!), fr);
        y = lerp(top + r * dy, top + (r + 1) * dy, fr) - Math.sin(Math.PI * fr) * dy * 0.55 - br;
      } else {
        const k = b.ks[R]!;
        x = apex + (k - R / 2) * dx;
        const fall = (s - R) / ROWS_PER_S;
        y = top + R * dy + fall * 140;
        if (y >= hBot - bins[k]! * sc - 1.5) {
          bins[k]!++;
          landed++;
          continue;
        }
      }
      keep.push(b);
      g.beginPath();
      g.arc(x, y, br, 0, Math.PI * 2);
      g.fill();
    }
    balls = keep;
    text(g, `공 ${comma(landed)}개`, 12, 16, 9, INK, 'left', 700);
    mtext(g, ['막대: 실제   선: ', ['i', 'B'], `(${R}, `, ['/', '1', '2'], ')'], 268, 16, 7.5, 'r', inkA(0.6));
  }
  function diceSum(g: G, dt: number): void {
    const total = 600;
    if (rolls < total) {
      rollAcc += dt * 45;
      while (rollAcc >= 1 && rolls < total) {
        rollAcc -= 1;
        last = [];
        let s = 0;
        for (let i = 0; i < m; i++) {
          const f = 1 + Math.floor(Math.random() * 6);
          last.push(f);
          s += f;
        }
        dHist[s - m]!++;
        rolls++;
      }
    } else {
      hold += dt;
      if (hold > 1.4) {
        hold = 0;
        resetD();
      }
    }
    // 주사위 그림
    const ds = 15;
    const gap = 5;
    const wAll = m * ds + (m - 1) * gap;
    const sx0 = 120 - wAll / 2;
    const PIPS: number[][][] = [[], [[0, 0]], [[-1, -1], [1, 1]], [[-1, -1], [0, 0], [1, 1]], [[-1, -1], [1, -1], [-1, 1], [1, 1]], [[-1, -1], [1, -1], [0, 0], [-1, 1], [1, 1]], [[-1, -1], [1, -1], [-1, 0], [1, 0], [-1, 1], [1, 1]]];
    last.forEach((f, i) => {
      const x = sx0 + i * (ds + gap);
      rr(g, x, 9, ds, ds, 3.5);
      g.fillStyle = '#f4f0e6';
      g.fill();
      g.fillStyle = DARK;
      for (const [a, b] of PIPS[f]!) {
        g.beginPath();
        g.arc(x + ds / 2 + a! * ds * 0.27, 9 + ds / 2 + b! * ds * 0.27, 1.45, 0, Math.PI * 2);
        g.fill();
      }
    });
    if (last.length) mtext(g, [' = ', ['c', ACC, String(last.reduce((a, b) => a + b, 0))]], sx0 + wAll + 2, 16.5, 12, 'l', INK, 800);
    text(g, `${comma(rolls)}번`, 268, 16, 9, INK, 'right', 700);
    // 막대
    const nb = 5 * m + 1;
    const x0 = 16;
    const x1 = 264;
    const bw = (x1 - x0) / nb;
    const hTop = 38;
    const hBot = 148;
    const d = diceDist();
    let mx = 1;
    for (let i = 0; i < nb; i++) mx = Math.max(mx, dHist[i]!, (d[i] ?? 0) * rolls);
    const sc = (hBot - hTop) / mx;
    for (let i = 0; i < nb; i++) {
      const hh = dHist[i]! * sc;
      if (hh > 0) {
        rr(g, x0 + i * bw + bw * 0.1, hBot - hh, bw * 0.8, hh, Math.min(2, bw * 0.2));
        g.fillStyle = accFill(g, hTop, hBot);
        g.fill();
      }
      const every = bw > 9 ? 1 : bw > 5 ? 2 : 5;
      if ((i + m) % every === 0) text(g, String(i + m), x0 + (i + 0.5) * bw, hBot + 7, Math.min(7, bw * 0.8), inkA(0.5), 'center');
    }
    if (rolls > 0) {
      g.beginPath();
      for (let i = 0; i < nb; i++) {
        const x = x0 + (i + 0.5) * bw;
        const y = hBot - (d[i] ?? 0) * rolls * sc;
        if (i) g.lineTo(x, y);
        else g.moveTo(x, y);
      }
      g.strokeStyle = 'rgba(255,255,255,0.85)';
      g.lineWidth = 1;
      g.stroke();
    }
    text(g, `주사위 ${m}개의 합`, 16, 167, 7.5, inkA(0.6));
    text(g, '선: 정확한 확률 × 던진 횟수', 264, 167, 7, inkA(0.5), 'right');
  }
  return {
    draw(g, _t, dt0) {
      const dt = dt0 * speed;
      clock += dt;
      let which = mode;
      if (mode === 0) which = Math.floor(clock / 10) % 2 === 0 ? 1 : 2;
      if (which === 1) galton(g, dt);
      else diceSum(g, dt);
    },
    controls: [
      { type: 'range', label: '보기 (0 번갈아 · 1 갈톤 판 · 2 주사위 합)', min: 0, max: 2, step: 1, value: 0, on: (v) => ((mode = v), (clock = 0), resetG(), resetD()) },
      { type: 'range', label: '핀 줄 수', min: 4, max: 14, step: 1, value: 10, on: (v) => ((R = v), resetG()) },
      { type: 'range', label: '주사위 개수', min: 1, max: 6, step: 1, value: 4, on: (v) => ((m = v), resetD()) },
      { type: 'range', label: '빠르기', min: 0.5, max: 4, step: 0.5, value: 1, on: (v) => (speed = v) },
    ],
  };
});
```

## 관련 기술
- 다음에 해 볼 기술: [재귀로 그리는 도형 (프랙탈)](https://ai-techstudio.web.app/ai/t/i382.md) `i382`
- 참고 문서: [Wikipedia — Galton board](https://en.wikipedia.org/wiki/Galton_board) · [Wikipedia — Binomial distribution](https://en.wikipedia.org/wiki/Binomial_distribution)
