diff --git a/docs/design/gimin-#145-final-performance-report.md b/docs/design/gimin-#145-final-performance-report.md
new file mode 100644
index 0000000..df31753
--- /dev/null
+++ b/docs/design/gimin-#145-final-performance-report.md
@@ -0,0 +1,126 @@
+# 인덱싱·Vector 검색 최종 통합 성능 리포트 설계
+
+- 관련 이슈: [#145](https://github.com/DocGrid/backend/issues/145)
+- 작성일: 2026-08-11
+- 상태: 구현 및 검증 완료
+
+## 1. 배경
+
+Job Claim, 실제 BGE-M3 배치, Exact·HNSW 검색, Worker 수평 확장, Queue Backpressure,
+PDF·DOCX 전체 인덱싱 부하 결과는 각각 재현 가능한 문서로 남아 있다. 그러나 실험 목적과 환경이
+서로 달라 전체 병목의 이동, 권장 기본값과 운영 전 남은 검증을 한 문서에서 설명하기 어렵다.
+
+이번 작업은 기존 Benchmark를 다시 실행하거나 서로 다른 Workload를 하나의 순위로 합치지 않는다.
+검증된 결과에서 핵심 수치와 출처를 정규화하고, 같은 실험 안에서만 비교하는 그래프와 최종 결론을
+재현 가능한 형태로 제공한다.
+
+## 2. 목표와 성공 기준
+
+- 개별 결과 문서의 대표 수치를 하나의 JSON 데이터 계약으로 관리한다.
+- 모든 데이터 행은 저장소 안의 원본 결과 문서로 추적할 수 있다.
+- Python 표준 라이브러리만으로 GitHub에서 렌더링되는 SVG를 결정적으로 생성한다.
+- `--check` 실행이 데이터 계약 오류, 사라진 출처와 생성 결과 불일치를 탐지한다.
+- 최종 리포트가 비교표, 그래프, 병목 분석, 권장값, 한계와 후속 검증을 포함한다.
+
+## 3. 비교 경계
+
+### 3.1 같은 그래프에서 비교하는 범위
+
+| 영역 | 고정 조건 | 비교 변수 |
+|---|---|---|
+| Job Claim | OpenSQL 17.8, 5,000 Job, 5회 | Worker 1·5·10·20·40 |
+| BGE-M3 | 실제 `BAAI/bge-m3`, CPU, 256 Text, 3회 | Batch 1·4·8·16·32·64 |
+| Vector 검색 | PostgreSQL 17.8, pgvector 0.8.1, `vector(1024)`, topK 10 | 2천·1만·5만 Row, Exact·HNSW |
+| Worker 확장 | TXT 16문서, 문서당 8 Chunk, Batch 32 | Worker 수와 실행 Slot |
+| Backpressure | TXT 800자, Pool 4, Worker Slot 8 | 16·32·64·128문서와 업로더 수 |
+| 실제 문서 E2E | PDF·DOCX 혼합, 문서당 4 Chunk, Slot 2, Batch 32 | 50·100문서 |
+
+### 3.2 직접 비교하지 않는 범위
+
+- TXT 800자 Backpressure 처리량과 TXT 6,400자 Worker 처리량
+- PDF·DOCX 문서/분과 TXT 문서/분
+- Apple Silicon의 `linux/amd64` OpenSQL Container와 공급사 지원 Rocky Linux 원격 서버의 절대 성능
+- 합성 Random Vector Recall과 실제 문서 Corpus의 검색 품질
+- 서로 다른 실행 일자·장비의 수치를 하나의 통합 점수로 환산한 값
+
+리포트는 각 영역 안의 상대 변화만 결론의 근거로 사용한다. 영역 간 수치는 병목이 어느 단계로
+이동했는지 설명하는 참고값이며 직접적인 우열 비교가 아니다.
+
+## 4. 데이터 계약
+
+정규화 JSON은 다음 공통 정보를 가진다.
+
+- Schema Version과 생성 목적
+- Benchmark별 원본 Markdown 경로와 환경·Workload 설명
+- X축 Label과 각 Series의 이름·단위·값
+- 결과 문서에 표시할 대표 수치와 해석 경계
+
+생성기는 다음 불변식을 검증한다.
+
+1. Schema Version과 필수 Benchmark 6종이 존재한다.
+2. 모든 원본 경로가 저장소 내부의 일반 파일을 가리킨다.
+3. Series 길이가 X축 길이와 같고 모든 수치가 유한한 0 이상의 값이다.
+4. 그래프 정의가 등록된 Series와 단위만 참조한다.
+5. 출력 파일 이름이 중복되지 않고 `docs/test-results/assets/` 아래에만 생성된다.
+
+## 5. 그래프 설계
+
+| 파일 | 목적 | 표현 |
+|---|---|---|
+| `claim-throughput-tail-latency.svg` | Claim 동시성의 처리량과 Tail Latency 균형 | TPS와 p99 이중 축 Line |
+| `bge-batch-throughput-latency.svg` | Batch Size의 처리량 포화와 요청 지연 | Text/s와 p95 이중 축 Line |
+| `vector-exact-hnsw-latency-recall.svg` | HNSW 속도 향상과 Recall 비용 | 검색 p95 Log 축 Bar + Recall Line |
+| `worker-horizontal-scaling.svg` | 실행 Slot 증가의 효율과 지연 이동 | 문서/분 Line + Queue·처리 p95 Bar |
+| `queue-backpressure.svg` | 부하 증가 시 처리량 Plateau와 Queue 증가 | 문서/분과 Queue p95 이중 축 Line |
+| `pdf-docx-e2e-load.svg` | 실제 문서 수 증가 시 처리량과 E2E 구성 | 문서/분 Line + Queue·처리 p95 Bar |
+
+SVG에는 `title`, `desc`, 범례, 축 이름과 단위를 포함한다. 색상만으로 Series를 구분하지 않도록
+Line Marker와 범례를 함께 제공하고, 정확한 수치는 Markdown 표에서 확인할 수 있게 한다.
+
+## 6. 생성과 검증 흐름
+
+1. 생성기가 정규화 JSON과 저장소 Root를 읽는다.
+2. Schema, 수치, Series 길이와 원본 문서 경로를 검증한다.
+3. 고정된 크기·색상·정렬 규칙으로 SVG 6개를 Memory에서 생성한다.
+4. 기본 실행은 대상 Directory에 결과를 기록한다.
+5. `--check` 실행은 Memory 결과와 Commit된 SVG를 Byte 단위로 비교한다.
+6. 불일치 시 재생성 명령과 대상 파일을 포함한 오류로 실패한다.
+
+```bash
+python3 scripts/performance/generate_final_performance_report.py
+python3 scripts/performance/generate_final_performance_report.py --check
+```
+
+## 7. 최종 리포트 구조
+
+- Executive Summary와 단계별 핵심 결론
+- 실험별 환경·Workload 비교 경계
+- 6개 비교표와 6개 그래프
+- Claim, Embedding, Vector, Worker, Queue, 실제 문서 E2E 병목 분석
+- 측정으로 확정할 수 있는 권장값과 확정할 수 없는 운영값
+- 공급사 지원 Rocky Linux 원격 OpenSQL 검증 등 남은 작업
+- 원본 결과 문서와 재생성 명령
+
+## 8. 제외 범위
+
+- 기존 Benchmark 재실행과 원시 JSON 복구
+- 제품 코드, Database Schema 또는 운영 설정 변경
+- 현재 측정값을 운영 SLO로 확정
+- 새로운 성능 임계값으로 CI를 차단
+- 공급사 지원 Rocky Linux 환경의 최종 호환성 승인
+- Grafana Dashboard 또는 외부 Chart Service 추가
+
+## 9. 커밋 분할
+
+1. `docs: #145 최종 통합 성능 리포트 설계`
+2. `docs: #145 통합 성능 원본 데이터 추가`
+3. `build: #145 성능 그래프 생성 및 검증 도구 추가`
+4. `docs: #145 최종 성능 비교표와 그래프 기록`
+
+## 10. 완료 조건
+
+- 원본 결과 8개에서 선택한 핵심 수치가 정규화 JSON과 일치한다.
+- SVG 6개가 GitHub에서 바로 표시되고 생성 결과가 결정적이다.
+- `--check`가 정상 결과에서 성공하고 고의 불일치에서 실패한다.
+- 최종 리포트가 비교 가능한 범위와 비교 금지 범위를 명시한다.
+- 병목과 권장 기본값의 근거가 표·그래프·원본 문서로 추적된다.
diff --git a/docs/test-results/assets/final-performance-report/bge-batch-throughput-latency.svg b/docs/test-results/assets/final-performance-report/bge-batch-throughput-latency.svg
new file mode 100644
index 0000000..38f6ca8
--- /dev/null
+++ b/docs/test-results/assets/final-performance-report/bge-batch-throughput-latency.svg
@@ -0,0 +1,57 @@
+
+
diff --git a/docs/test-results/assets/final-performance-report/claim-throughput-tail-latency.svg b/docs/test-results/assets/final-performance-report/claim-throughput-tail-latency.svg
new file mode 100644
index 0000000..5617d7e
--- /dev/null
+++ b/docs/test-results/assets/final-performance-report/claim-throughput-tail-latency.svg
@@ -0,0 +1,54 @@
+
+
diff --git a/docs/test-results/assets/final-performance-report/pdf-docx-e2e-load.svg b/docs/test-results/assets/final-performance-report/pdf-docx-e2e-load.svg
new file mode 100644
index 0000000..0c1ab3c
--- /dev/null
+++ b/docs/test-results/assets/final-performance-report/pdf-docx-e2e-load.svg
@@ -0,0 +1,49 @@
+
+
diff --git a/docs/test-results/assets/final-performance-report/queue-backpressure.svg b/docs/test-results/assets/final-performance-report/queue-backpressure.svg
new file mode 100644
index 0000000..dd50c1d
--- /dev/null
+++ b/docs/test-results/assets/final-performance-report/queue-backpressure.svg
@@ -0,0 +1,51 @@
+
+
diff --git a/docs/test-results/assets/final-performance-report/vector-exact-hnsw-latency-recall.svg b/docs/test-results/assets/final-performance-report/vector-exact-hnsw-latency-recall.svg
new file mode 100644
index 0000000..d25a83a
--- /dev/null
+++ b/docs/test-results/assets/final-performance-report/vector-exact-hnsw-latency-recall.svg
@@ -0,0 +1,49 @@
+
+
diff --git a/docs/test-results/assets/final-performance-report/worker-horizontal-scaling.svg b/docs/test-results/assets/final-performance-report/worker-horizontal-scaling.svg
new file mode 100644
index 0000000..3d17192
--- /dev/null
+++ b/docs/test-results/assets/final-performance-report/worker-horizontal-scaling.svg
@@ -0,0 +1,61 @@
+
+
diff --git a/docs/test-results/gimin-#145-final-performance-report-data.json b/docs/test-results/gimin-#145-final-performance-report-data.json
new file mode 100644
index 0000000..1c96040
--- /dev/null
+++ b/docs/test-results/gimin-#145-final-performance-report-data.json
@@ -0,0 +1,423 @@
+{
+ "schemaVersion": 1,
+ "issue": 145,
+ "title": "DocGrid 인덱싱·Vector 검색 최종 통합 성능 데이터",
+ "recordedAt": "2026-08-11",
+ "sources": [
+ {
+ "id": "claim-local",
+ "path": "docs/test-results/gimin-#61-embedding-job-claim-performance.md",
+ "purpose": "PostgreSQL 14.6 기반 초기 Claim 처리량·경합 기준선"
+ },
+ {
+ "id": "opensql-compatibility",
+ "path": "docs/test-results/gimin-#124-opensql-compatibility-performance.md",
+ "purpose": "OpenSQL 17.8 호환성·Vector·Claim 실측"
+ },
+ {
+ "id": "bge-batch",
+ "path": "docs/test-results/gimin-#128-bge-m3-batch-size-performance.md",
+ "purpose": "실제 BAAI/bge-m3 Batch Size 최적화"
+ },
+ {
+ "id": "vector-search",
+ "path": "docs/test-results/gimin-#131-hnsw-exact-scale-benchmark.md",
+ "purpose": "Exact Seq Scan 대비 HNSW 지연·Recall@10 비교"
+ },
+ {
+ "id": "worker-throughput",
+ "path": "docs/test-results/gimin-#133-worker-indexing-throughput-benchmark.md",
+ "purpose": "TXT 16·32문서 자동 Worker 전체 처리량 기준선"
+ },
+ {
+ "id": "worker-scaling",
+ "path": "docs/test-results/gimin-#138-worker-horizontal-scaling-benchmark.md",
+ "purpose": "Worker 수·실행 Slot별 수평 확장"
+ },
+ {
+ "id": "queue-backpressure",
+ "path": "docs/test-results/gimin-#141-worker-queue-backpressure-benchmark.md",
+ "purpose": "Queue 적체·Hikari Pool Backpressure 경계"
+ },
+ {
+ "id": "real-document-e2e",
+ "path": "docs/test-results/gimin-#143-pdf-docx-indexing-e2e-load-benchmark.md",
+ "purpose": "PDF·DOCX 50·100문서 실제 전체 E2E 부하"
+ }
+ ],
+ "benchmarks": {
+ "claim": {
+ "title": "OpenSQL Job Claim 처리량과 Tail Latency",
+ "subtitle": "OpenSQL 17.8 · 5,000 jobs · profile별 5회 중앙값",
+ "sourceIds": [
+ "opensql-compatibility"
+ ],
+ "categories": [
+ "1",
+ "5",
+ "10",
+ "20",
+ "40"
+ ],
+ "categoryLabel": "Worker 수",
+ "series": {
+ "throughput": {
+ "label": "중앙 TPS",
+ "unit": "TPS",
+ "values": [
+ 273.61,
+ 748.45,
+ 563.07,
+ 591.32,
+ 591.89
+ ]
+ },
+ "p99": {
+ "label": "Claim p99",
+ "unit": "ms",
+ "values": [
+ 6.077,
+ 13.187,
+ 67.78,
+ 89.913,
+ 212.795
+ ]
+ }
+ },
+ "chart": {
+ "file": "claim-throughput-tail-latency.svg",
+ "leftSeries": [
+ "throughput"
+ ],
+ "rightSeries": [
+ "p99"
+ ],
+ "leftScale": "linear",
+ "leftStyle": "line",
+ "rightStyle": "line"
+ }
+ },
+ "bgeBatch": {
+ "title": "실제 BGE-M3 Batch 처리량과 요청 지연",
+ "subtitle": "BAAI/bge-m3 · CPU · 256 texts · profile별 3회",
+ "sourceIds": [
+ "bge-batch"
+ ],
+ "categories": [
+ "1",
+ "4",
+ "8",
+ "16",
+ "32",
+ "64"
+ ],
+ "categoryLabel": "Batch Size",
+ "series": {
+ "throughput": {
+ "label": "처리량",
+ "unit": "texts/s",
+ "values": [
+ 5.85,
+ 11.12,
+ 13.57,
+ 15.15,
+ 17.08,
+ 17.38
+ ]
+ },
+ "p95": {
+ "label": "요청 p95",
+ "unit": "ms",
+ "values": [
+ 222.97,
+ 409.23,
+ 637.61,
+ 1224.44,
+ 1975.7,
+ 3719.82
+ ]
+ }
+ },
+ "chart": {
+ "file": "bge-batch-throughput-latency.svg",
+ "leftSeries": [
+ "throughput"
+ ],
+ "rightSeries": [
+ "p95"
+ ],
+ "leftScale": "linear",
+ "leftStyle": "line",
+ "rightStyle": "line"
+ }
+ },
+ "vectorSearch": {
+ "title": "Exact Search 대비 HNSW 지연과 Recall@10",
+ "subtitle": "PostgreSQL 17.8 · pgvector 0.8.1 · vector(1024) · topK 10 · ef_search 40",
+ "sourceIds": [
+ "vector-search"
+ ],
+ "categories": [
+ "2천",
+ "1만",
+ "5만"
+ ],
+ "categoryLabel": "Vector 수",
+ "series": {
+ "exactP95": {
+ "label": "Exact p95",
+ "unit": "ms",
+ "values": [
+ 5.103,
+ 25.874,
+ 197.611
+ ]
+ },
+ "hnswP95": {
+ "label": "HNSW p95",
+ "unit": "ms",
+ "values": [
+ 1.139,
+ 2.015,
+ 1.994
+ ]
+ },
+ "recall": {
+ "label": "평균 Recall@10",
+ "unit": "ratio",
+ "values": [
+ 0.96,
+ 0.52,
+ 0.22
+ ]
+ }
+ },
+ "chart": {
+ "file": "vector-exact-hnsw-latency-recall.svg",
+ "leftSeries": [
+ "exactP95",
+ "hnswP95"
+ ],
+ "rightSeries": [
+ "recall"
+ ],
+ "leftScale": "log10",
+ "leftStyle": "bar",
+ "rightStyle": "line"
+ }
+ },
+ "workerScaling": {
+ "title": "Worker 수·실행 Slot별 수평 확장",
+ "subtitle": "TXT 16 docs · 6,400 chars/doc · 8 chunks/doc · BGE batch 32",
+ "sourceIds": [
+ "worker-scaling"
+ ],
+ "categories": [
+ "w1-s1",
+ "w1-s2",
+ "w2-s1",
+ "w2-s2",
+ "w4-s2"
+ ],
+ "categoryLabel": "Worker-Slot Profile",
+ "series": {
+ "throughput": {
+ "label": "처리량",
+ "unit": "docs/min",
+ "values": [
+ 19.475,
+ 21.862,
+ 21.576,
+ 21.577,
+ 21.677
+ ]
+ },
+ "queueP95": {
+ "label": "Queue p95",
+ "unit": "s",
+ "values": [
+ 43.963,
+ 38.302,
+ 38.912,
+ 33.627,
+ 22.81
+ ]
+ },
+ "processingP95": {
+ "label": "처리 p95",
+ "unit": "s",
+ "values": [
+ 3.391,
+ 5.55,
+ 5.647,
+ 11.693,
+ 22.741
+ ]
+ }
+ },
+ "chart": {
+ "file": "worker-horizontal-scaling.svg",
+ "leftSeries": [
+ "throughput"
+ ],
+ "rightSeries": [
+ "queueP95",
+ "processingP95"
+ ],
+ "leftScale": "linear",
+ "leftStyle": "line",
+ "rightStyle": "bar"
+ }
+ },
+ "queueBackpressure": {
+ "title": "Queue 적체와 DB Pool Backpressure",
+ "subtitle": "TXT 800 chars/doc · 1 chunk/doc · Hikari pool 4 · worker slots 8",
+ "sourceIds": [
+ "queue-backpressure"
+ ],
+ "categories": [
+ "16",
+ "32",
+ "64",
+ "128"
+ ],
+ "categoryLabel": "문서 수",
+ "series": {
+ "throughput": {
+ "label": "처리량",
+ "unit": "docs/min",
+ "values": [
+ 163.0,
+ 149.266,
+ 142.791,
+ 142.054
+ ]
+ },
+ "queueP95": {
+ "label": "Queue p95",
+ "unit": "s",
+ "values": [
+ 3.07301,
+ 9.964056,
+ 23.777526,
+ 49.201731
+ ]
+ }
+ },
+ "chart": {
+ "file": "queue-backpressure.svg",
+ "leftSeries": [
+ "throughput"
+ ],
+ "rightSeries": [
+ "queueP95"
+ ],
+ "leftScale": "linear",
+ "leftStyle": "line",
+ "rightStyle": "line"
+ }
+ },
+ "realDocumentE2E": {
+ "title": "PDF·DOCX 전체 인덱싱 E2E 부하",
+ "subtitle": "PDF 50% + DOCX 50% · 4 chunks/doc · worker slots 2 · BGE batch 32",
+ "sourceIds": [
+ "real-document-e2e"
+ ],
+ "categories": [
+ "50",
+ "100"
+ ],
+ "categoryLabel": "문서 수",
+ "series": {
+ "throughput": {
+ "label": "처리량",
+ "unit": "docs/min",
+ "values": [
+ 31.046,
+ 32.063
+ ]
+ },
+ "queueP95": {
+ "label": "Queue p95",
+ "unit": "s",
+ "values": [
+ 88.355,
+ 174.684
+ ]
+ },
+ "processingP95": {
+ "label": "처리 p95",
+ "unit": "s",
+ "values": [
+ 4.093,
+ 4.026
+ ]
+ }
+ },
+ "chart": {
+ "file": "pdf-docx-e2e-load.svg",
+ "leftSeries": [
+ "throughput"
+ ],
+ "rightSeries": [
+ "queueP95",
+ "processingP95"
+ ],
+ "leftScale": "linear",
+ "leftStyle": "line",
+ "rightStyle": "bar"
+ }
+ }
+ },
+ "supportingBaselines": {
+ "claimLocalPostgreSql14": {
+ "sourceIds": [
+ "claim-local"
+ ],
+ "workers": [
+ 1,
+ 5,
+ 10,
+ 20,
+ 40
+ ],
+ "medianTps": [
+ 364.29,
+ 1300.58,
+ 1301.34,
+ 1265.59,
+ 1559.8
+ ],
+ "p99Ms": [
+ 4.61,
+ 6.19,
+ 18.58,
+ 39.14,
+ 97.63
+ ],
+ "comparisonBoundary": "OpenSQL 17.8 결과와 절대값을 직접 비교하지 않는 초기 로컬 기준선"
+ },
+ "workerIndexingThroughput": {
+ "sourceIds": [
+ "worker-throughput"
+ ],
+ "documentCounts": [
+ 16,
+ 32
+ ],
+ "documentsPerMinute": [
+ 19.102,
+ 18.402
+ ],
+ "queueP95Seconds": [
+ 43.794,
+ 93.554
+ ],
+ "processingP95Seconds": [
+ 6.768,
+ 7.285
+ ],
+ "comparisonBoundary": "TXT 6,400자·문서당 8 Chunk인 자동 Worker 기준선"
+ }
+ }
+}
diff --git a/docs/test-results/gimin-#145-final-performance-report.md b/docs/test-results/gimin-#145-final-performance-report.md
new file mode 100644
index 0000000..961a51c
--- /dev/null
+++ b/docs/test-results/gimin-#145-final-performance-report.md
@@ -0,0 +1,248 @@
+# DocGrid 인덱싱·Vector 검색 최종 통합 성능 리포트
+
+- 관련 이슈: [#145](https://github.com/DocGrid/backend/issues/145)
+- 기준 일자: 2026-08-11
+- 데이터: [정규화 성능 원본](gimin-%23145-final-performance-report-data.json)
+- 재현성 검사: `python3 scripts/performance/generate_final_performance_report.py --check`
+
+## 1. Executive Summary
+
+DocGrid의 핵심 인덱싱 경로를 Job Claim부터 실제 PDF·DOCX의 `vector(1024)` 저장까지 단계별로
+측정했다. 한 장비에서 모든 수치를 하나의 처리량으로 합친 결과가 아니라, 각 단계의 고정 조건 안에서
+병목과 선택 근거를 찾은 결과다.
+
+| 단계 | 관측 결과 | 현재 판단 |
+|---|---|---|
+| Job Claim | OpenSQL 17.8에서 Worker 5가 748.45 TPS, p99 13.187ms로 처리량·Tail Latency 균형이 가장 좋음 | 검증 환경의 Claim 동시성 기준점은 5 |
+| 실제 BGE-M3 | Batch 32가 최대 처리량의 98.27%이며 Batch 64보다 p95가 46.89% 낮음 | 기본 Batch 32 유지 |
+| Vector 검색 | 5만 Vector에서 HNSW p95는 Exact보다 99.09배 빠르지만 평균 Recall@10은 0.22 | 실제 Corpus에서 `ef_search` 튜닝 전 품질 확정 금지 |
+| Worker 확장 | 2 Slot부터 약 21.6문서/분으로 포화, 8 Slot 효율 0.139 | 단일 CPU BGE 환경에서는 2 Slot이 균형점 |
+| Queue·DB Pool | 16문서부터 Pool Waiting, 64문서부터 약 142문서/분 Plateau, 128문서까지 붕괴 없음 | Queue depth·Oldest Pending Age 기반 운영 관측 필요 |
+| PDF·DOCX E2E | 50·100문서에서 31.046·32.063문서/분, 본 측정 300문서·1,200 Vector 오류 0건 | 실제 문서 Pipeline 정합성과 처리량 기준선 확보 |
+
+가장 중요한 결론은 Worker를 늘리면 무조건 빨라지지 않는다는 점이다. Claim 자체는 수백 TPS를
+처리하지만 실제 전체 Pipeline에서는 CPU BGE-M3가 공유 병목이 된다. 실행 Slot을 늘리면 Job이 빨리
+Claim되어 Queue 대기는 줄지만, Embedding 처리 대기가 늘어 E2E 시간과 처리량은 거의 개선되지 않았다.
+
+## 2. 해석 경계
+
+### 2.1 같은 표·그래프 안에서 비교 가능한 조건
+
+| 영역 | 고정 조건 | 비교 변수 |
+|---|---|---|
+| Claim | OpenSQL 17.8, 5,000 Job, Profile별 5회 | Worker 1·5·10·20·40 |
+| Embedding | 실제 `BAAI/bge-m3`, CPU, 256 Text, 3회 | Batch 1·4·8·16·32·64 |
+| Vector | PostgreSQL 17.8, pgvector 0.8.1, `vector(1024)`, topK 10 | 2천·1만·5만 Row, Exact·HNSW |
+| Worker | TXT 16문서, 6,400자/문서, 8 Chunk/문서, Batch 32 | Worker 수와 전체 실행 Slot |
+| Backpressure | TXT 800자, 1 Chunk/문서, Hikari Pool 4, Slot 8 | 16·32·64·128문서와 업로더 수 |
+| 실제 문서 | PDF 50% + DOCX 50%, 4 Chunk/문서, Slot 2, Batch 32 | 50·100문서 |
+
+### 2.2 직접 비교하면 안 되는 수치
+
+- 800자 Backpressure Workload와 6,400자 Worker Workload의 문서/분
+- TXT 처리량과 PDF·DOCX 처리량
+- Apple Silicon의 `linux/amd64` OpenSQL Container와 공급사 지원 Rocky Linux 원격 서버의 절대 성능
+- 합성 Random Vector의 Recall과 실제 문서 Corpus 검색 품질
+- 실행 일자와 장비가 다른 Benchmark의 절대 지연을 하나의 순위나 통합 점수로 환산한 값
+
+따라서 아래 그래프는 영역 안의 변화 추세를 설명한다. 서로 다른 그래프 높이는 시스템 단계 간 우열이나
+운영 SLO를 의미하지 않는다.
+
+## 3. OpenSQL Job Claim
+
+
+
+| Worker | 중앙 TPS | p95 | p99 | Hikari 대기 최대 |
+|---:|---:|---:|---:|---:|
+| 1 | 273.61 | 5.525ms | 6.077ms | 0 |
+| 5 | **748.45** | **9.872ms** | **13.187ms** | **0** |
+| 10 | 563.07 | 61.265ms | 67.780ms | 0 |
+| 20 | 591.32 | 79.235ms | 89.913ms | 0 |
+| 40 | 591.89 | 166.329ms | 212.795ms | 20 |
+
+Worker 1에서 5로 늘리면 처리량은 2.74배가 됐다. 10 이상에서는 처리량이 오히려 563~592 TPS로
+낮아지고 Tail Latency가 급증했다. Worker 40은 Hikari 대기 Thread가 최대 20개까지 발생했다.
+따라서 이 OpenSQL Container 환경에서는 Worker 5가 가장 좋은 균형점이다.
+
+초기 PostgreSQL 14.6 기준선에서는 Worker 5와 10이 약 1,301 TPS로 비슷하고 Worker 40이 가장 높은
+1,559.80 TPS였지만 p99 97.63ms와 Pool 대기가 발생했다. 이 값은 다른 DB·실행 환경의 과거 기준선이므로
+OpenSQL 17.8 절대 성능과 직접 비교하지 않고, 동시성 증가가 Tail Latency를 악화시킨다는 방향만
+교차 확인한다.
+
+## 4. 실제 BGE-M3 Batch Size
+
+
+
+| Batch | 처리량 | 요청 p95 | 평균 ms/Text | 최고 RSS |
+|---:|---:|---:|---:|---:|
+| 1 | 5.85 texts/s | 222.97ms | 170.81 | 2,176.80MiB |
+| 4 | 11.12 texts/s | 409.23ms | 89.93 | 2,176.80MiB |
+| 8 | 13.57 texts/s | 637.61ms | 73.69 | 2,176.68MiB |
+| 16 | 15.15 texts/s | 1,224.44ms | 66.02 | 2,185.02MiB |
+| 32 | **17.08 texts/s** | **1,975.70ms** | **58.56** | **2,176.68MiB** |
+| 64 | 17.38 texts/s | 3,719.82ms | 57.55 | 2,259.72MiB |
+
+Batch 32는 최대 처리량인 Batch 64의 98.27%를 확보한다. 반면 p95는 1.98초로 Batch 64보다
+46.89% 낮고 최고 RSS도 약 83.05MiB 낮다. Batch 16에서 32로 올릴 때는 처리량이 12.74%
+증가하지만, 32에서 64의 추가 이득은 1.76%뿐이다. 현재 CPU 실행 환경의 기본값은 32가 합리적이다.
+
+## 5. Exact Search 대비 HNSW
+
+
+
+| Vector 수 | Exact p95 | HNSW p95 | 속도 배율 | 평균 Recall@10 | 최소 Recall@10 |
+|---:|---:|---:|---:|---:|---:|
+| 2,000 | 5.103ms | 1.139ms | 4.48배 | 0.96 | 0.90 |
+| 10,000 | 25.874ms | 2.015ms | 12.84배 | 0.52 | 0.10 |
+| 50,000 | 197.611ms | 1.994ms | **99.09배** | **0.22** | **0.00** |
+
+HNSW는 데이터가 25배 증가해도 p95가 약 1.75배만 증가했지만, 기본 `ef_search=40`의 Recall은
+합성 1,024차원 Random Vector에서 크게 낮아졌다. 이 결과는 HNSW를 제거해야 한다는 뜻이 아니라,
+속도만 보고 운영값을 확정하면 안 된다는 뜻이다. 실제 문서 Corpus와 Query Set에서 `ef_search`별
+p95·Recall Pareto Curve를 추가 측정해야 한다.
+
+OpenSQL 17.8 호환성 Probe에서는 2천 Vector HNSW p95가 4.706ms였고 같은 Test의 로컬 PostgreSQL
+17.8 기준선은 2.344ms였다. OpenSQL 실행에 x86-64 Emulation과 Container Network가 포함됐으므로
+이 차이를 Database Engine만의 차이로 해석하지 않는다.
+
+## 6. Worker 수평 확장
+
+
+
+| Profile | 전체 Slot | 문서/분 | Speedup | Slot 효율 | Queue p95 | 처리 p95 | E2E p95 |
+|---|---:|---:|---:|---:|---:|---:|---:|
+| w1-s1 | 1 | 19.475 | 1.000x | 1.000 | 43.963초 | 3.391초 | 46.939초 |
+| w1-s2 | 2 | **21.862** | **1.123x** | **0.561** | 38.302초 | 5.550초 | 43.671초 |
+| w2-s1 | 2 | 21.576 | 1.108x | 0.554 | 38.912초 | 5.647초 | 44.225초 |
+| w2-s2 | 4 | 21.577 | 1.108x | 0.277 | 33.627초 | 11.693초 | 44.294초 |
+| w4-s2 | 8 | 21.677 | 1.113x | 0.139 | 22.810초 | 22.741초 | 44.206초 |
+
+1 Slot에서 2 Slot로 늘릴 때 처리량은 12.3% 증가했다. 이후 4·8 Slot로 늘려도 처리량은 약
+21.6문서/분에 머물렀다. 8 Slot에서는 Queue p95가 22.810초로 줄지만 처리 p95가 22.741초로
+증가한다. 공유 CPU BGE-M3 경합 때문에 대기 위치만 Queue에서 처리 단계로 이동한 것이다.
+
+별도 16·32문서 기준선에서도 처리량은 19.102에서 18.402문서/분으로 3.7% 감소하고 Queue p95는
+43.794에서 93.554초로 증가했다. 고정된 처리 용량에서 문서 수가 늘면 처리량보다 Queue 대기가 먼저
+증가한다는 결론과 일치한다.
+
+## 7. Queue 적체·DB Pool Backpressure
+
+
+
+| 문서·업로더 | 상태 | 문서/분 | Upload p95 | Queue p95 | Queue AUC | max waiting |
+|---|---|---:|---:|---:|---:|---:|
+| 16·4 | `POOL_BACKPRESSURED` | 163.000 | 24.038ms | 3.073초 | 66.087 document·s | 6 |
+| 32·8 | `POOL_BACKPRESSURED` | 149.266 | 37.815ms | 9.964초 | 250.325 document·s | 10 |
+| 64·16 | `POOL_BACKPRESSURED` | 142.791 | 100.052ms | 23.778초 | 946.828 document·s | 18 |
+| 128·32 | `POOL_BACKPRESSURED` | 142.054 | 186.350ms | 49.202초 | 3,673.122 document·s | 33 |
+
+Hikari Pool 크기 4는 가장 작은 16문서 Profile부터 최대 Active에 도달했고 Connection Waiting도
+관측됐다. 하지만 Waiting Sample 비율은 모든 Profile에서 2% 미만이었고, 128문서까지 업로드 실패,
+Job 실패와 제한 시간 미완료는 없었다. 즉 측정 범위에서는 붕괴가 아니라 대기 기반 Backpressure였다.
+
+64문서부터 처리량은 약 142문서/분으로 Plateau를 형성하지만 Queue p95와 AUC는 계속 증가한다.
+운영에서는 단순 TPS뿐 아니라 Queue Depth, Oldest Pending Age, Pool Waiting과 Upload Latency를 함께
+관측해야 한다. Admission Control 임계값은 실제 배포 자원과 SLO를 정한 뒤 별도로 결정해야 한다.
+
+## 8. 실제 PDF·DOCX 전체 E2E
+
+
+
+| 문서 수 | 구성 | 중앙 총 시간 | 문서/분 | Chunk·Embedding/초 | Queue p95 | 처리 p95 | E2E p95 |
+|---:|---|---:|---:|---:|---:|---:|---:|
+| 50 | PDF 25 + DOCX 25 | 96.633초 | 31.046 | 2.070 | 88.355초 | 4.093초 | 92.178초 |
+| 100 | PDF 50 + DOCX 50 | 187.138초 | 32.063 | 2.138 | 174.684초 | 4.026초 | 178.533초 |
+
+문서 수를 두 배로 늘려도 처리량과 개별 처리 p95는 유지됐고, Queue p95와 E2E p95가 약 두 배로
+증가했다. PDF·DOCX Parser보다 고정된 Worker 처리 용량 앞의 Queue가 전체 지연을 지배했다.
+
+50문서와 100문서 Profile을 각각 2회 실행한 본 측정은 합계 300문서다. 네 실행에서 Chunk와
+Embedding은 각각 1,200개였고 실패, Retry, 미완료, 중복 Vector는 0건이었다. 모든 Vector는
+1,024차원이었으며 PDF 페이지와 DOCX Section Metadata도 보존됐다. 스캔 PDF와 OCR은 이
+Workload 범위가 아니다.
+
+## 9. 병목 이동과 운영 판단
+
+```text
+Upload·DB Pool
+ └─ 고동시성에서 짧은 Connection Waiting 발생
+ ↓
+Job Queue
+ └─ 처리 용량을 넘으면 실패보다 Queue 대기와 AUC가 먼저 증가
+ ↓
+Worker 실행 Slot
+ └─ 2 Slot 이후 Claim은 빨라지지만 전체 처리량은 포화
+ ↓
+실제 BGE-M3
+ └─ 공유 CPU 병목, Batch 32가 처리량·지연·메모리 균형점
+ ↓
+pgvector HNSW
+ └─ 검색 지연은 억제하지만 Recall을 별도로 튜닝해야 함
+```
+
+| 결정 | 근거 | 적용 범위 |
+|---|---|---|
+| Embedding Batch 기본값 32 유지 | 최대 처리량의 98.27%, Batch 64 대비 p95 46.89% 절감 | 현재 CPU BGE-M3 환경 |
+| Worker 실행 Slot 2를 초기 기준점으로 사용 | 2 Slot 이후 처리량 포화와 Slot 효율 급락 | 현재 단일 CPU BGE-M3 환경 |
+| Claim Worker 5를 검증 기준점으로 사용 | OpenSQL 17.8에서 최고 균형 TPS·p99 | 측정 Container 환경 |
+| Queue 지표를 TPS와 함께 관측 | 처리량 Plateau 이후 Queue p95·AUC 지속 증가 | 배포 환경별 임계값은 별도 결정 |
+| HNSW `ef_search=40`을 품질 기본값으로 확정하지 않음 | 5만 합성 Vector 평균 Recall@10 0.22 | 실제 Corpus Pareto 측정 필요 |
+
+## 10. 남은 검증과 한계
+
+1. **공급사 지원 환경 최종 검증**: Rocky Linux 9.7 x86-64 원격 서버의 OpenSQL 17.8과 발급
+ 라이선스로 Flyway, Claim, Lease, HNSW, BGE-M3 E2E를 다시 검증해야 한다.
+2. **실제 Corpus ANN 품질**: 실제 문서·Query 정답 Set에서 `ef_search` 40·80·120·200의 p95와
+ Recall@10 Pareto Curve를 측정해야 한다.
+3. **운영 SLO 기반 Admission Control**: Queue Depth와 Oldest Pending Age 임계값은 CPU·Memory,
+ BGE 배포 방식과 목표 완료 시간을 확정한 뒤 정해야 한다.
+4. **환경 분리**: 현재 결과는 로컬 개발·호환성·용량 기준선이며 고정 CI Runner나 운영 부하의
+ 절대 성능 보장이 아니다.
+5. **OCR 제외**: 실제 문서 E2E는 Text Layer PDF와 OOXML DOCX만 포함한다.
+
+## 11. 재현성과 원본 출처
+
+그래프는 외부 Chart Service나 Python Package 없이 정규화 JSON에서 생성된다.
+
+```bash
+python3 scripts/performance/generate_final_performance_report.py
+python3 scripts/performance/generate_final_performance_report.py --check
+python3 -m unittest scripts/performance/test_generate_final_performance_report.py
+```
+
+| 검증 | 범위 | 결과 |
+|---|---|---|
+| 실제 PDF·DOCX 처리량 | 50·100문서 Profile, 각 2회 | PASS |
+| 실제 PDF·DOCX 완전성 | 네 Profile 합계 300문서·1,200 Vector | PASS |
+| 원본 부하 측정 회귀 | PDF·DOCX 결과 문서 작성 시 전체 일반 Java 회귀 | PASS, 746 tests |
+| 통합 리포트 회귀 | 현재 Branch 전체 일반 Java 회귀 | PASS, 728 tests |
+| 그래프 생성기 단위 테스트 | 데이터 계약·접근성·Drift 검출 | PASS, 4 tests |
+| Commit된 그래프 재현 | 정규화 JSON과 SVG 6개 바이트 비교 | PASS |
+| Swagger 수동 검증 | API 계약과 제품 동작을 변경하지 않는 Offline 통합 문서 작업 | 미실행, 범위 밖 |
+
+50·100문서 처리량과 합계 300문서 완전성 결과는
+[PDF·DOCX 원본 측정](gimin-%23143-pdf-docx-indexing-e2e-load-benchmark.md)에 기록된 실행을
+재사용했다. 이번 작업은 기존 Benchmark를 다시 실행하지 않고 검증된 수치를 정규화·시각화했다.
+
+| 결과 영역 | 원본 문서 |
+|---|---|
+| 초기 Claim 기준선 | [Embedding Job Claim 성능](gimin-%2361-embedding-job-claim-performance.md) |
+| OpenSQL 17.8 | [OpenSQL 호환성·성능](gimin-%23124-opensql-compatibility-performance.md) |
+| BGE-M3 Batch | [Batch Size 성능](gimin-%23128-bge-m3-batch-size-performance.md) |
+| Exact·HNSW | [규모별 지연·Recall](gimin-%23131-hnsw-exact-scale-benchmark.md) |
+| 자동 Worker 기준선 | [16·32문서 처리량](gimin-%23133-worker-indexing-throughput-benchmark.md) |
+| Worker 수평 확장 | [Worker·Slot 비교](gimin-%23138-worker-horizontal-scaling-benchmark.md) |
+| Queue·DB Pool | [Backpressure 측정](gimin-%23141-worker-queue-backpressure-benchmark.md) |
+| 실제 문서 E2E | [PDF·DOCX 50·100문서](gimin-%23143-pdf-docx-indexing-e2e-load-benchmark.md) |
+
+## 12. 최종 결론
+
+DocGrid는 실제 PDF·DOCX를 Parsing하고 BGE-M3로 Batch Embedding한 뒤 PostgreSQL
+`vector(1024)`에 저장하는 전체 경로를 50·100문서 Profile에서 각각 2회 측정했다. 네 실행 합계
+300문서·1,200 Vector의 완전성도 별도로 확인했다. 성능 개선의 우선순위는 Claim Worker 수를
+늘리는 것이 아니라 Embedding 실행 자원과 Queue 운영 정책을 관리하는 것이다.
+
+현재 측정 근거로는 Batch 32와 실행 Slot 2가 로컬 CPU 환경의 균형점이다. HNSW는 규모가 커질수록
+큰 지연 이점을 보이지만, 검색 품질은 실제 Corpus에서 `ef_search`를 튜닝한 뒤 확정해야 한다. 마지막
+대회 제출 전에는 공급사 지원 Rocky Linux 9.7 원격 OpenSQL 환경에서 같은 호환성·E2E·성능 검증을
+재실행해 로컬 Emulation 결과와 공식 검증 결과를 분리해 제시한다.
diff --git a/scripts/performance/generate_final_performance_report.py b/scripts/performance/generate_final_performance_report.py
new file mode 100644
index 0000000..e411133
--- /dev/null
+++ b/scripts/performance/generate_final_performance_report.py
@@ -0,0 +1,597 @@
+#!/usr/bin/env python3
+"""Validate the consolidated performance data and render deterministic SVG charts."""
+
+from __future__ import annotations
+
+import argparse
+import html
+import json
+import math
+import sys
+from pathlib import Path
+from typing import Any
+
+
+REPOSITORY_ROOT = Path(__file__).resolve().parents[2]
+DEFAULT_DATA_PATH = (
+ REPOSITORY_ROOT
+ / "docs/test-results/gimin-#145-final-performance-report-data.json"
+)
+DEFAULT_OUTPUT_DIRECTORY = (
+ REPOSITORY_ROOT
+ / "docs/test-results/assets/final-performance-report"
+)
+REQUIRED_BENCHMARKS = {
+ "claim",
+ "bgeBatch",
+ "vectorSearch",
+ "workerScaling",
+ "queueBackpressure",
+ "realDocumentE2E",
+}
+PALETTE = ("#2563eb", "#dc2626", "#059669", "#7c3aed", "#d97706")
+CANVAS_WIDTH = 1200
+CANVAS_HEIGHT = 680
+PLOT_LEFT = 105
+PLOT_RIGHT = 1095
+PLOT_TOP = 150
+PLOT_BOTTOM = 570
+
+
+class DataContractError(ValueError):
+ """Indicate that the consolidated benchmark data violates its public contract."""
+
+
+def load_data(path: Path) -> dict[str, Any]:
+ """Read the canonical JSON data as UTF-8."""
+ try:
+ return json.loads(path.read_text(encoding="utf-8"))
+ except FileNotFoundError as exc:
+ raise DataContractError(f"성능 데이터 파일을 찾을 수 없습니다: {path}") from exc
+ except json.JSONDecodeError as exc:
+ raise DataContractError(f"성능 데이터 JSON이 올바르지 않습니다: {exc}") from exc
+
+
+def validate_data(data: dict[str, Any], repository_root: Path) -> None:
+ """Validate schema, provenance, numeric values, and chart references."""
+ if data.get("schemaVersion") != 1:
+ raise DataContractError("schemaVersion은 1이어야 합니다.")
+
+ sources = data.get("sources")
+ if not isinstance(sources, list) or not sources:
+ raise DataContractError("sources는 비어 있지 않은 배열이어야 합니다.")
+
+ source_ids: set[str] = set()
+ for source in sources:
+ source_id = _require_non_empty_string(source, "id", "source")
+ if source_id in source_ids:
+ raise DataContractError(f"중복 source id입니다: {source_id}")
+ source_ids.add(source_id)
+ source_path = _require_non_empty_string(source, "path", source_id)
+ resolved = (repository_root / source_path).resolve()
+ if not resolved.is_relative_to(repository_root.resolve()):
+ raise DataContractError(f"저장소 밖의 source 경로입니다: {source_path}")
+ if not resolved.is_file():
+ raise DataContractError(f"source 문서를 찾을 수 없습니다: {source_path}")
+
+ benchmarks = data.get("benchmarks")
+ if not isinstance(benchmarks, dict):
+ raise DataContractError("benchmarks는 객체여야 합니다.")
+ missing = REQUIRED_BENCHMARKS - benchmarks.keys()
+ if missing:
+ raise DataContractError(f"필수 benchmark가 없습니다: {', '.join(sorted(missing))}")
+
+ output_files: set[str] = set()
+ for benchmark_id in sorted(REQUIRED_BENCHMARKS):
+ _validate_benchmark(
+ benchmark_id,
+ benchmarks[benchmark_id],
+ source_ids,
+ output_files,
+ )
+
+ _validate_supporting_baselines(data.get("supportingBaselines"), source_ids)
+
+
+def _validate_benchmark(
+ benchmark_id: str,
+ benchmark: dict[str, Any],
+ source_ids: set[str],
+ output_files: set[str],
+) -> None:
+ """Validate one chart-backed benchmark."""
+ if not isinstance(benchmark, dict):
+ raise DataContractError(f"{benchmark_id} benchmark는 객체여야 합니다.")
+ _require_non_empty_string(benchmark, "title", benchmark_id)
+ _require_non_empty_string(benchmark, "subtitle", benchmark_id)
+ _require_non_empty_string(benchmark, "categoryLabel", benchmark_id)
+
+ categories = benchmark.get("categories")
+ if (
+ not isinstance(categories, list)
+ or not categories
+ or any(not isinstance(value, str) or not value.strip() for value in categories)
+ ):
+ raise DataContractError(f"{benchmark_id}.categories가 올바르지 않습니다.")
+
+ referenced_sources = benchmark.get("sourceIds")
+ if not isinstance(referenced_sources, list) or not referenced_sources:
+ raise DataContractError(f"{benchmark_id}.sourceIds는 비어 있을 수 없습니다.")
+ unknown_sources = set(referenced_sources) - source_ids
+ if unknown_sources:
+ raise DataContractError(
+ f"{benchmark_id}가 알 수 없는 source를 참조합니다: "
+ f"{', '.join(sorted(unknown_sources))}"
+ )
+
+ series = benchmark.get("series")
+ if not isinstance(series, dict) or not series:
+ raise DataContractError(f"{benchmark_id}.series는 비어 있을 수 없습니다.")
+ for series_id, definition in series.items():
+ _require_non_empty_string(definition, "label", f"{benchmark_id}.{series_id}")
+ _require_non_empty_string(definition, "unit", f"{benchmark_id}.{series_id}")
+ values = definition.get("values")
+ if not isinstance(values, list) or len(values) != len(categories):
+ raise DataContractError(
+ f"{benchmark_id}.{series_id} 값 수가 category 수와 다릅니다."
+ )
+ for value in values:
+ if (
+ isinstance(value, bool)
+ or not isinstance(value, (int, float))
+ or not math.isfinite(value)
+ or value < 0
+ ):
+ raise DataContractError(
+ f"{benchmark_id}.{series_id}에는 0 이상의 유한한 수만 허용됩니다."
+ )
+
+ chart = benchmark.get("chart")
+ if not isinstance(chart, dict):
+ raise DataContractError(f"{benchmark_id}.chart는 객체여야 합니다.")
+ file_name = _require_non_empty_string(chart, "file", benchmark_id)
+ if Path(file_name).name != file_name or not file_name.endswith(".svg"):
+ raise DataContractError(f"올바르지 않은 SVG 파일 이름입니다: {file_name}")
+ if file_name in output_files:
+ raise DataContractError(f"중복 SVG 파일 이름입니다: {file_name}")
+ output_files.add(file_name)
+
+ left_series = _require_series_list(chart, "leftSeries", benchmark_id, series)
+ right_series = _require_series_list(chart, "rightSeries", benchmark_id, series)
+ if set(left_series) & set(right_series):
+ raise DataContractError(f"{benchmark_id}의 좌우 축 Series가 중복됩니다.")
+ if chart.get("leftScale") not in {"linear", "log10"}:
+ raise DataContractError(f"{benchmark_id}.leftScale이 올바르지 않습니다.")
+ for style_key in ("leftStyle", "rightStyle"):
+ if chart.get(style_key) not in {"line", "bar"}:
+ raise DataContractError(f"{benchmark_id}.{style_key}이 올바르지 않습니다.")
+ if chart["leftScale"] == "log10":
+ for series_id in left_series:
+ if any(value <= 0 for value in series[series_id]["values"]):
+ raise DataContractError(
+ f"{benchmark_id}.{series_id}의 Log 축 값은 0보다 커야 합니다."
+ )
+
+
+def _validate_supporting_baselines(
+ baselines: Any,
+ source_ids: set[str],
+) -> None:
+ """Validate provenance and aligned arrays for non-chart supporting baselines."""
+ if not isinstance(baselines, dict) or not baselines:
+ raise DataContractError("supportingBaselines는 비어 있을 수 없습니다.")
+ for baseline_id, baseline in baselines.items():
+ referenced_sources = baseline.get("sourceIds")
+ if not isinstance(referenced_sources, list) or not referenced_sources:
+ raise DataContractError(f"{baseline_id}.sourceIds는 비어 있을 수 없습니다.")
+ unknown_sources = set(referenced_sources) - source_ids
+ if unknown_sources:
+ raise DataContractError(
+ f"{baseline_id}가 알 수 없는 source를 참조합니다: "
+ f"{', '.join(sorted(unknown_sources))}"
+ )
+ _require_non_empty_string(baseline, "comparisonBoundary", baseline_id)
+
+
+def _require_non_empty_string(
+ value: dict[str, Any],
+ key: str,
+ context: str,
+) -> str:
+ """Return a required, trimmed string or raise a contextual error."""
+ result = value.get(key) if isinstance(value, dict) else None
+ if not isinstance(result, str) or not result.strip():
+ raise DataContractError(f"{context}.{key}는 비어 있지 않은 문자열이어야 합니다.")
+ return result.strip()
+
+
+def _require_series_list(
+ chart: dict[str, Any],
+ key: str,
+ benchmark_id: str,
+ series: dict[str, Any],
+) -> list[str]:
+ """Return a non-empty list of registered series identifiers."""
+ result = chart.get(key)
+ if not isinstance(result, list) or not result:
+ raise DataContractError(f"{benchmark_id}.{key}는 비어 있을 수 없습니다.")
+ unknown = set(result) - series.keys()
+ if unknown:
+ raise DataContractError(
+ f"{benchmark_id}.{key}가 알 수 없는 Series를 참조합니다: "
+ f"{', '.join(sorted(unknown))}"
+ )
+ return result
+
+
+def render_all(data: dict[str, Any]) -> dict[str, str]:
+ """Render every required benchmark to a file-name-to-SVG mapping."""
+ return {
+ benchmark["chart"]["file"]: render_chart(benchmark)
+ for benchmark_id, benchmark in data["benchmarks"].items()
+ if benchmark_id in REQUIRED_BENCHMARKS
+ }
+
+
+def render_chart(benchmark: dict[str, Any]) -> str:
+ """Render one accessible dual-axis chart as a deterministic SVG string."""
+ categories = benchmark["categories"]
+ series = benchmark["series"]
+ chart = benchmark["chart"]
+ ordered_series = chart["leftSeries"] + chart["rightSeries"]
+ colors = {
+ series_id: PALETTE[index % len(PALETTE)]
+ for index, series_id in enumerate(ordered_series)
+ }
+ left_axis = _build_axis(
+ [series[series_id]["values"] for series_id in chart["leftSeries"]],
+ chart["leftScale"],
+ series[chart["leftSeries"][0]]["unit"],
+ )
+ right_axis = _build_axis(
+ [series[series_id]["values"] for series_id in chart["rightSeries"]],
+ "linear",
+ series[chart["rightSeries"][0]]["unit"],
+ )
+
+ parts = [
+ '',
+ (
+ f'',
+ '',
+ ]
+ )
+ return "\n".join(parts)
+
+
+def _build_axis(value_groups: list[list[float]], scale: str, unit: str) -> dict[str, Any]:
+ """Create an axis descriptor and value-to-y coordinate mapping inputs."""
+ maximum = max(max(values) for values in value_groups)
+ if scale == "log10":
+ lower = 10 ** math.floor(math.log10(min(min(values) for values in value_groups)))
+ upper = 10 ** math.ceil(math.log10(maximum))
+ if lower == upper:
+ upper *= 10
+ ticks = []
+ tick = lower
+ while tick <= upper:
+ ticks.append(float(tick))
+ tick *= 10
+ return {"minimum": lower, "maximum": upper, "ticks": ticks, "scale": scale, "unit": unit}
+
+ upper = _nice_upper(maximum)
+ ticks = [upper * index / 5 for index in range(6)]
+ return {"minimum": 0.0, "maximum": upper, "ticks": ticks, "scale": scale, "unit": unit}
+
+
+def _nice_upper(maximum: float) -> float:
+ """Round a positive maximum to a stable human-readable chart bound."""
+ if maximum <= 0:
+ return 1.0
+ rough = maximum * 1.08
+ magnitude = 10 ** math.floor(math.log10(rough))
+ normalized = rough / magnitude
+ if normalized <= 1:
+ nice = 1
+ elif normalized <= 2:
+ nice = 2
+ elif normalized <= 5:
+ nice = 5
+ else:
+ nice = 10
+ return nice * magnitude
+
+
+def _category_positions(count: int) -> list[float]:
+ """Return centered x coordinates for a categorical axis."""
+ slot = (PLOT_RIGHT - PLOT_LEFT) / count
+ return [PLOT_LEFT + slot * (index + 0.5) for index in range(count)]
+
+
+def _axis_y(value: float, axis: dict[str, Any]) -> float:
+ """Map a numeric value onto the chart's y coordinate."""
+ if axis["scale"] == "log10":
+ minimum = math.log10(axis["minimum"])
+ maximum = math.log10(axis["maximum"])
+ ratio = (math.log10(value) - minimum) / (maximum - minimum)
+ else:
+ ratio = (value - axis["minimum"]) / (axis["maximum"] - axis["minimum"])
+ return PLOT_BOTTOM - ratio * (PLOT_BOTTOM - PLOT_TOP)
+
+
+def _render_legend(
+ ordered_series: list[str],
+ series: dict[str, Any],
+ colors: dict[str, str],
+) -> list[str]:
+ """Render a horizontal legend that includes a marker and unit."""
+ parts: list[str] = []
+ x = PLOT_LEFT
+ for series_id in ordered_series:
+ definition = series[series_id]
+ parts.append(
+ f'