diff --git a/docs/maestro-player/ab-listening-protocol.md b/docs/maestro-player/ab-listening-protocol.md new file mode 100644 index 0000000..2842898 --- /dev/null +++ b/docs/maestro-player/ab-listening-protocol.md @@ -0,0 +1,28 @@ +# A/B 블라인드 청취 프로토콜 + +목적: "좋은 개발 흐름이 더 듣기 좋게 들리는가" 가설을 사람 귀로 검증한다. +자동 지표 대조는 `player/tests/contrastListening.test.mjs`가 CI에서 고정한다. + +## 준비 + +- Player 골든 리스닝 팩의 `대조 A — 모범 PR 흐름` / `대조 B — 거친 이력` 사용. +- 두 데모는 같은 저장소·같은 PR 번호로 **조성(tonic)과 모티프가 동일**하게 + 통제되어 있다 — 차이는 이벤트 내용(개발 흐름의 질)뿐. + +## 절차 (평가자 1인당 약 3분) + +1. 진행자는 평가자에게 A/B 라벨과 배경 설명 없이, 무작위 순서로 두 데모를 + 자동 재생으로 들려준다 (각 1회, 원하면 재청취 허용). +2. 청취 후 3문항에 답하게 한다 (첫 번째/두 번째로 답변): + - Q1. 어느 쪽이 더 **안정적**으로 들렸는가? + - Q2. 어느 쪽이 더 **완결된 곡**처럼 끝났는가? + - Q3. 다시 듣고 싶은 쪽은? +3. 진행자는 재생 순서와 답변을 기록한다. + +## 판정 + +- 평가자 n ≥ 3, 문항별로 "모범 흐름(A)" 선택이 과반이면 가설 지지로 기록. +- 결과는 `docs/maestro-player/evidence/`에 날짜 파일로 남긴다 + (예: `2026-08-XX-ab-listening-results.md` — 평가자 수, 문항별 집계, 특이 코멘트). +- 과반 미달 시: 소리 층(보이싱·음색) 개선 후 재시험하고, 매핑 규칙 자체를 + 의심하기 전에 합성 품질 변인을 먼저 제거한다. diff --git a/docs/superpowers/specs/2026-08-04-player-ab-contrast-design.md b/docs/superpowers/specs/2026-08-04-player-ab-contrast-design.md new file mode 100644 index 0000000..824497f --- /dev/null +++ b/docs/superpowers/specs/2026-08-04-player-ab-contrast-design.md @@ -0,0 +1,40 @@ +# Maestro Player A/B 대조 청취 픽스처 설계 + +- 날짜: 2026-08-04 +- 상태: 확정 ("좋은 코딩이 듣기 좋게 들리는가" 가설의 직접 검증 트랙) +- 범위: `player/` + `docs/maestro-player/` 하위만 + +## 0. 목표 + +같은 저장소·같은 PR 번호(= 같은 조성·모티프로 **통제**)에서 이벤트 내용만 +다른 두 시나리오를 골든 팩에 추가해, 개발 흐름의 질이 소리 차이로 +번역되는지를 ① 자동 지표로, ② 블라인드 청취로 검증 가능하게 한다. + +## 1. 픽스처 쌍 (repoId `ab-contrast-showcase`, pr:7 공유) + +- **대조 A — 모범 PR 흐름**: 적정 규모 feat 커밋 3 + fix 1 + pr-open → + review-comment → 반영 → approve → **merge**. 기대: ionian(장조계), + 낮은 평균 긴장, merge가 해소 정점. +- **대조 B — 거친 이력**: 대형 덤프 커밋 + **revert×3** + request-changes + + reopen, 머지 없음, 짧은 간격 재작업. 기대: phrygian(가장 어두운 선법), + sus4(긴장 코드), 높은 평균 긴장, 해소 정점 부재. + +## 2. 자동 대조 게이트 (`tests/contrastListening.test.mjs`) + +- 두 세션의 **tonic 동일**(통제 확인) + A=ionian, B=phrygian. +- B.chordColor = sus4 (revert 지배 규칙). +- B.tensionScore ≥ A.tensionScore + 0.15 / A.resolutionScore ≥ B + 0.1. +- A의 peakResolution 이벤트 = merge, B는 merge 부재. +- 기존 정량 하니스(스케일·그리드·밀도·도약)가 두 픽스처에 자동 적용됨 + (골든 팩 엔트리로 편입되므로). + +## 3. 블라인드 청취 프로토콜 (`docs/maestro-player/ab-listening-protocol.md`) + +평가자에게 A/B 라벨을 숨기고 두 데모를 무작위 순서로 들려준 뒤 3문항 +(어느 쪽이 안정적/완결적/다시 듣고 싶은가)을 수집하는 절차. n≥3이면 +과반 일치로 가설 지지 여부를 기록. + +## 4. 영향 + +골든 팩 3→5 엔트리 (기존 개수 단언 테스트 갱신). UI는 카드 2장 추가 +(다이어트로 컴팩트 카드라 부담 없음). diff --git a/player/src/lib/goldenListeningPack.js b/player/src/lib/goldenListeningPack.js index 849e3af..8738453 100644 --- a/player/src/lib/goldenListeningPack.js +++ b/player/src/lib/goldenListeningPack.js @@ -332,6 +332,194 @@ export function buildGoldenListeningScenarios() { ], events: buildTransitionValidationFixture(), }, + { + id: 'contrast-clean-flow', + label: '대조 A — 모범 PR 흐름', + provider: 'github', + sourceUrl: 'fixture://contrast-clean-flow', + sourceLabel: '대조 A — 모범 PR 흐름', + listeningFocus: [ + '멜로디가 밝은 장조계(ionian)로 안정되게 진행해야 한다.', + '리뷰 반영 구간이 과하지 않은 긴장으로 지나가야 한다.', + 'merge에서 베이스+화음의 분명한 종지가 들려야 한다.', + ], + events: buildContrastCleanFlowFixture(), + }, + { + id: 'contrast-rough-flow', + label: '대조 B — 거친 이력', + provider: 'github', + sourceUrl: 'fixture://contrast-rough-flow', + sourceLabel: '대조 B — 거친 이력', + listeningFocus: [ + '어두운 선법(phrygian)과 sus4 긴장 화음이 유지되어야 한다.', + 'revert 반복이 재작업의 불안정함으로 들려야 한다.', + '종지 없이 끝나 미해결감이 남아야 한다.', + ], + events: buildContrastRoughFlowFixture(), + }, + ]; +} + +// A/B 대조 픽스처 (스펙 2026-08-04 대조 §1): 같은 repoId + pr:7 → 같은 조성·모티프로 통제. +const CONTRAST_REPO_ID = 'ab-contrast-showcase'; +const CONTRAST_BRANCH = 'feature/showcase'; +const CONTRAST_PR = 7; + +function contrastEvent(overrides) { + return { + repoId: CONTRAST_REPO_ID, + branchName: CONTRAST_BRANCH, + prNumber: CONTRAST_PR, + changedFiles: [], + filesChanged: 0, + linesAdded: 0, + linesDeleted: 0, + newFileCount: 0, + newDirectoryCount: 0, + ...overrides, + }; +} + +export function buildContrastCleanFlowFixture() { + return [ + contrastEvent({ + eventId: 'ab-a-commit-1', + eventType: 'commit', + timestamp: '2026-05-02T10:00:00.000Z', + message: 'feat: scaffold showcase module', + changedFiles: ['new:src/showcase/index.js', 'src/player/registry.js'], + filesChanged: 2, + linesAdded: 42, + linesDeleted: 4, + newFileCount: 1, + newDirectoryCount: 1, + }), + contrastEvent({ + eventId: 'ab-a-commit-2', + eventType: 'commit', + timestamp: '2026-05-02T10:06:00.000Z', + message: 'feat: wire showcase into player shell', + changedFiles: ['src/showcase/index.js', 'src/player/shell.js'], + filesChanged: 2, + linesAdded: 38, + linesDeleted: 9, + }), + contrastEvent({ + eventId: 'ab-a-commit-3', + eventType: 'commit', + timestamp: '2026-05-02T10:12:00.000Z', + message: 'feat: add showcase golden preset', + changedFiles: ['src/showcase/presets.js'], + filesChanged: 1, + linesAdded: 26, + linesDeleted: 2, + }), + contrastEvent({ + eventId: 'ab-a-pr-open', + eventType: 'pr-open', + timestamp: '2026-05-02T10:16:00.000Z', + message: 'Showcase flow polish', + }), + contrastEvent({ + eventId: 'ab-a-review-comment', + eventType: 'review-comment', + timestamp: '2026-05-02T10:20:00.000Z', + message: 'Naming looks off in presets.', + }), + contrastEvent({ + eventId: 'ab-a-commit-4', + eventType: 'commit', + timestamp: '2026-05-02T10:26:00.000Z', + message: 'fix: apply review naming feedback', + changedFiles: ['src/showcase/presets.js'], + filesChanged: 1, + linesAdded: 12, + linesDeleted: 8, + }), + contrastEvent({ + eventId: 'ab-a-approve', + eventType: 'review-approve', + timestamp: '2026-05-02T10:30:00.000Z', + message: 'Clean and focused. Ship it.', + }), + contrastEvent({ + eventId: 'ab-a-merge', + eventType: 'merge', + timestamp: '2026-05-02T10:33:00.000Z', + message: 'Merge pull request #7 from contributor/feature-showcase', + changedFiles: ['src/showcase/index.js', 'src/showcase/presets.js', 'src/player/shell.js'], + filesChanged: 3, + linesAdded: 118, + linesDeleted: 23, + }), + ]; +} + +export function buildContrastRoughFlowFixture() { + return [ + contrastEvent({ + eventId: 'ab-b-commit-1', + eventType: 'commit', + timestamp: '2026-05-02T10:00:00.000Z', + message: 'wip: dump everything before deadline', + changedFiles: Array.from({ length: 18 }, (_, i) => `src/dump/file-${i}.js`), + filesChanged: 18, + linesAdded: 940, + linesDeleted: 310, + }), + contrastEvent({ + eventId: 'ab-b-revert-1', + eventType: 'revert', + timestamp: '2026-05-02T10:02:00.000Z', + message: 'Revert "wip: dump everything before deadline"', + changedFiles: Array.from({ length: 18 }, (_, i) => `src/dump/file-${i}.js`), + filesChanged: 18, + linesAdded: 310, + linesDeleted: 940, + }), + contrastEvent({ + eventId: 'ab-b-commit-2', + eventType: 'commit', + timestamp: '2026-05-02T10:05:00.000Z', + message: 'wip: force it again', + changedFiles: Array.from({ length: 12 }, (_, i) => `src/dump/retry-${i}.js`), + filesChanged: 12, + linesAdded: 620, + linesDeleted: 180, + }), + contrastEvent({ + eventId: 'ab-b-request-changes', + eventType: 'review-request-changes', + timestamp: '2026-05-02T10:08:00.000Z', + message: 'This breaks the replay loader. Please split it up.', + }), + contrastEvent({ + eventId: 'ab-b-revert-2', + eventType: 'revert', + timestamp: '2026-05-02T10:10:00.000Z', + message: 'Revert "wip: force it again"', + changedFiles: Array.from({ length: 12 }, (_, i) => `src/dump/retry-${i}.js`), + filesChanged: 12, + linesAdded: 180, + linesDeleted: 620, + }), + contrastEvent({ + eventId: 'ab-b-reopen', + eventType: 'review-reopen', + timestamp: '2026-05-02T10:12:00.000Z', + message: 'Reopening — regression is still there.', + }), + contrastEvent({ + eventId: 'ab-b-revert-3', + eventType: 'revert', + timestamp: '2026-05-02T10:14:00.000Z', + message: 'Revert "hotfix attempt"', + changedFiles: ['src/dump/hotfix.js'], + filesChanged: 1, + linesAdded: 12, + linesDeleted: 260, + }), ]; } diff --git a/player/tests/contrastListening.test.mjs b/player/tests/contrastListening.test.mjs new file mode 100644 index 0000000..6767709 --- /dev/null +++ b/player/tests/contrastListening.test.mjs @@ -0,0 +1,49 @@ +// A/B 대조 게이트 (스펙 2026-08-04 대조 §2): 개발 흐름의 질 차이가 음악 지표 차이로 번역되는지 고정한다. +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + buildContrastCleanFlowFixture, + buildContrastRoughFlowFixture, + buildGoldenListeningPackEntries, +} from '../src/lib/goldenListeningPack.js'; +import { buildMusicPlan } from '../src/lib/musicIntentMapper.js'; + +function buildSessions() { + const clean = buildMusicPlan(buildContrastCleanFlowFixture(), { laneCount: 4 })[0]; + const rough = buildMusicPlan(buildContrastRoughFlowFixture(), { laneCount: 4 })[0]; + return { clean, rough }; +} + +test('대조 쌍은 같은 tonic으로 통제되고 선법이 명확히 갈린다 (ionian vs phrygian)', () => { + const { clean, rough } = buildSessions(); + + assert.equal(clean.harmony.tonic, rough.harmony.tonic, '같은 repo+PR이면 tonic이 같아야 함'); + assert.equal(clean.harmony.mode, 'ionian'); + assert.equal(rough.harmony.mode, 'phrygian'); + assert.equal(rough.harmony.chordColor, 'sus4'); // revert 지배 → 긴장 코드 +}); + +test('거친 이력은 평균 긴장이 높고, 모범 흐름은 해소가 높다', () => { + const { clean, rough } = buildSessions(); + + assert.ok( + rough.tensionScore >= clean.tensionScore + 0.15, + `tension 대조 부족: rough ${rough.tensionScore.toFixed(3)} vs clean ${clean.tensionScore.toFixed(3)}`, + ); + assert.ok( + clean.resolutionScore >= rough.resolutionScore + 0.1, + `resolution 대조 부족: clean ${clean.resolutionScore.toFixed(3)} vs rough ${rough.resolutionScore.toFixed(3)}`, + ); +}); + +test('모범 흐름의 해소 정점은 merge이고 거친 이력에는 merge가 없다', () => { + const entries = buildGoldenListeningPackEntries(); + const cleanEntry = entries.find((entry) => entry.id === 'contrast-clean-flow'); + const roughEntry = entries.find((entry) => entry.id === 'contrast-rough-flow'); + + assert.ok(cleanEntry && roughEntry, '대조 엔트리가 골든 팩에 편입되어야 함'); + assert.equal(cleanEntry.peakResolutionEvent.eventType, 'merge'); + assert.notEqual(roughEntry.peakResolutionEvent?.eventType, 'merge'); + assert.equal(roughEntry.peakTensionEvent.eventType, 'review-request-changes'); +}); diff --git a/player/tests/goldenListeningSet.test.mjs b/player/tests/goldenListeningSet.test.mjs index e2db7b3..bb08d04 100644 --- a/player/tests/goldenListeningSet.test.mjs +++ b/player/tests/goldenListeningSet.test.mjs @@ -4,21 +4,23 @@ import assert from 'node:assert/strict'; import { buildGoldenListeningPack } from '../scripts/exportGoldenListeningPack.mjs'; import { buildGoldenListeningScenarios } from '../src/lib/goldenListeningPack.js'; -test('golden listening set exposes three stable autoplay scenarios', () => { +test('golden listening set exposes five stable autoplay scenarios', () => { const scenarios = buildGoldenListeningScenarios(); - assert.equal(scenarios.length, 3); + assert.equal(scenarios.length, 5); assert.deepEqual(scenarios.map((scenario) => scenario.id), [ 'github-public-pr-cadence', 'gitlab-public-discussion-resolution', 'transition-overlay-practice', + 'contrast-clean-flow', + 'contrast-rough-flow', ]); }); test('golden listening pack summarizes each scenario with stable musical metadata', () => { const entries = buildGoldenListeningPack(); - assert.equal(entries.length, 3); + assert.equal(entries.length, 5); assert.ok(entries.every((entry) => entry.tempo >= 100)); assert.ok(entries.every((entry) => entry.noteCount >= 2)); assert.ok(entries.every((entry) => entry.cueBatchCount >= 1)); diff --git a/player/tests/musicTheoryHarness.test.mjs b/player/tests/musicTheoryHarness.test.mjs index 337ecb4..7dfa01d 100644 --- a/player/tests/musicTheoryHarness.test.mjs +++ b/player/tests/musicTheoryHarness.test.mjs @@ -89,12 +89,14 @@ test('buildChordOffsets는 선법에 맞게 구성음을 스냅한다', async () }); test('accent 노트는 chordMidis(베이스 최저음 포함)를 갖고 tap 노트는 단선율을 유지한다', () => { + let accentTotal = 0; for (const fixture of collectFixtures()) { const { plan, chart } = renderFixture(fixture.events); const harmony = plan[0].harmony; const accents = chart.notes.filter((note) => note.noteType === 'accent'); const taps = chart.notes.filter((note) => note.noteType === 'tap'); - assert.ok(accents.length > 0, `${fixture.label}: accent 노트 없음`); + // 종지 없는 픽스처(예: 거친 이력 대조 B)는 accent가 0일 수 있다 — 있는 경우만 검증 + accentTotal += accents.length; for (const note of accents) { assert.ok(Array.isArray(note.chordMidis) && note.chordMidis.length >= 3, `${fixture.label} ${note.noteId} chordMidis 없음`); assert.equal(Math.min(...note.chordMidis), note.chordMidis[0], '베이스가 최저음이어야 함'); @@ -112,4 +114,5 @@ test('accent 노트는 chordMidis(베이스 최저음 포함)를 갖고 tap 노 assert.equal(note.chordMidis ?? null, null, 'tap은 단선율'); } } + assert.ok(accentTotal > 0, '전체 픽스처에 accent가 하나도 없음'); });