feat: 캘리브레이션 측정 도구 + 스코어러 구조적 거동 첫 실측

SCORER_VERSION 이 v1 에서 v4 까지 갔는데 v3(제공자별 풀링)와 v4(신뢰 출처)는
밴드 분포를 한 번도 측정하지 않고 나갔다. 2026-08-08 평가가 쓴 224조합 하네스는
저장소에 남아 있지 않고, 그 문서의 두 수치(174/224=77.7% 와 82.6%)가 서로 맞지
않아 격자가 달랐음을 시사한다 — 과거 숫자와 비교 가능한 재현은 불가능하다.
그래서 격자를 스크립트에 명시적으로 고정해 새 기준선으로 삼는다.

  scripts/score_distribution.py   432조합 전수, 사람 없이 돈다
  scripts/sweep_thresholds.py     판정 시점 스냅샷 기준 임계 스윕
  docs/operations/calibration-corpus.md  코퍼스 절차

실측 결과(v4, 432조합):

                     신뢰출처 미등록   신뢰출처 등록
  high                      92%            63%
  100점 포화                80%            38%
  웹 신호만으로 high      13/24           0/24

두 가지가 드러났다.

1. v2 가 세운 "웹 존재만으로는 high 가 되지 않는다" 원칙이 이미 깨져 있다.
   presence 풀(최대 60)과 entity 풀(최대 20)이 각각 임계 아래로 캡되지만 합은
   캡되지 않아, full+partial + 엔티티 1개만으로 80/high 가 된다.

2. v3 의 제공자별 풀링이 그 경향을 키웠다. 독립 제공자가 하나 더 있으면 웹 전용
   조합의 high 가 4/12 -> 9/12, 전체 high 가 88% -> 97% 가 된다. 교차 확증이
   high 에 닿아야 한다는 v3 의 의도대로 동작한 결과지만 총량 통제가 없다.

대응 후보(웹 유래 점수 총합 상한)는 적용하지 않았다. 적용하면 또 한 번의
미측정 변경이 되고, 이번 실측이 바로 그 패턴을 지적한 것이기 때문이다.
결정 후 진행한다 — 적용 시 SCORER_VERSION 을 올리고 이 표를 다시 측정한다.

이 격자는 신호 축을 균등 샘플링하므로 실제 큐 분포가 아니다. 92% 는 "실제 큐의
92% 가 high"라는 뜻이 아니고 임계가 맞는지도 말해주지 않는다. 구조적 거동의
기준선일 뿐이다.

임계 검증은 실이미지 30~50장과 **사람의 판정**이 있어야 성립한다. 내가 판정을
만들면 도구의 밴드를 내 라벨과 대조하는 꼴이라 아무것도 검증되지 않는다.
sweep_thresholds.py 는 그때를 위한 도구이고, 지금은 usable pairs 0 을 정직하게
보고한다(가짜 판정 6건을 넣은 사본 DB 로 출력 동작은 확인했다).

검증: ruff clean, pytest 645 passed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BStHFrjQj2VypecSG9ex1V
This commit is contained in:
changukyu 2026-09-22 08:56:57 +09:00
parent e169e24584
commit c740c2e6ba
6 changed files with 507 additions and 1 deletions

View file

@ -99,4 +99,5 @@ python -m pytest tests/operator_gui/
- [프로젝트 소개 및 기술 구현](docs/project-introduction-and-technical-implementation.md) - [프로젝트 소개 및 기술 구현](docs/project-introduction-and-technical-implementation.md)
- [운영 워크리스트](docs/operations/copyrighter-operation-worklist.md) - [운영 워크리스트](docs/operations/copyrighter-operation-worklist.md)
- [오프라인 설치 절차](docs/operations/offline-install.md) - [오프라인 설치 절차](docs/operations/offline-install.md)
- [캘리브레이션 코퍼스 절차](docs/operations/calibration-corpus.md) — 실이미지로 밴드 임계를 검증하는 법
- [구조 다이어그램 4종](docs/diagrams/) — 시스템 구성 · 증거 데이터 흐름 · 운영자 심사 · 제출물 상태 전이 (**내부 문서**, 납품물 아님) - [구조 다이어그램 4종](docs/diagrams/) — 시스템 구성 · 증거 데이터 흐름 · 운영자 심사 · 제출물 상태 전이 (**내부 문서**, 납품물 아님)

View file

@ -1,5 +1,7 @@
# Project INDEX # Project INDEX
- 2026-09-22 · **캘리브레이션 측정 도구 신설 + 구조적 거동 첫 실측.** `SCORER_VERSION` 이 v1→v4 로 갔는데 v3(제공자별 풀링)·v4(신뢰 출처)는 분포를 한 번도 측정하지 않고 나갔다 — 8/8 평가의 224조합 하네스가 저장소에 없고(그 문서의 두 수치 174/224=77.7% 와 82.6% 가 서로 안 맞아 격자가 달랐음을 시사) 과거와 비교 가능한 재현이 불가능해, 격자를 명시적으로 고정한 `scripts/score_distribution.py`(432조합)를 새 기준선으로 만들었다. **실측 결과(v4)**: 신뢰 출처 미등록 시 high 92% · 100점 포화 80% · **웹 신호만으로 high 13/24**, 신뢰 출처 등록 시 high 63% · 포화 38% · 웹 전용 high 0/24. 드러난 것 — v2 가 세운 "웹 존재만으로 high 불가" 원칙이 깨져 있다(presence 풀 60 + entity 풀 20 = 80, 두 풀의 **합**은 캡되지 않음), 그리고 **v3 의 제공자별 풀링이 이를 키웠다**(웹 전용 high 4/12 → 9/12, 전체 high 88% → 97%). 대응 후보(웹 유래 총합 상한)는 미적용 — 적용하면 또 한 번의 미측정 변경이 되므로 결정 후 진행한다. 함께 `scripts/sweep_thresholds.py`(판정 시점 `decisionSnapshot.riskScore` 기준 임계 스윕, 가짜 판정 6건으로 동작 검증)와 `docs/operations/calibration-corpus.md`(이미지 구성·쿼터 준비·투입·판정·측정 절차) 신설. **실이미지 코퍼스와 판정은 사람이 해야 한다** — 판정을 내가 만들면 측정이 성립하지 않는다. **ruff clean · pytest 645 passed**
- 2026-09-21 · **반려 키보드 완결** — 승인·보류는 1타인데 반려만 메모 필수라 손이 마우스로 갔다(8/8 평가 P2-10). `R` 이 메모 필드에 포커스만 주고, 입력 중에는 전역 단축키가 막혀서 거기서부터는 **마우스로만 제출**할 수 있는 구조였다 — 법적으로 가장 중요한 경로가 가장 느렸다. `R` → 사유 선택 모드(프리셋 1~9 번호 표시) → 숫자 1타로 반려 완료, 자유 서술은 메모 안에서 `Ctrl+Enter`. 화면의 번호가 곧 단축키가 되도록 프리셋 칩에 키 배지를 붙였다(외울 필요 없음). **숫자는 사유 선택 모드 안에서만 의미를 갖는다** — 모드 밖에서 잘못 누른 키 하나로 반려가 나가면 안 되고, 그 가드 순서를 테스트로 고정했다. 실렌더(Playwright): `R`→`2` 로 `{"decision":"rejected","memo":"라이선스 확인 불가로 반려"}`, 메모+`Ctrl+Enter` 로 `{"decision":"rejected","memo":"자유 서술 사유"}`, 콘솔 에러 0. 반려 후 승인·보류와 같이 다음 미심사 케이스로 이동한다. **ruff clean · pytest 645 passed**(정적 3건) - 2026-09-21 · **반려 키보드 완결** — 승인·보류는 1타인데 반려만 메모 필수라 손이 마우스로 갔다(8/8 평가 P2-10). `R` 이 메모 필드에 포커스만 주고, 입력 중에는 전역 단축키가 막혀서 거기서부터는 **마우스로만 제출**할 수 있는 구조였다 — 법적으로 가장 중요한 경로가 가장 느렸다. `R` → 사유 선택 모드(프리셋 1~9 번호 표시) → 숫자 1타로 반려 완료, 자유 서술은 메모 안에서 `Ctrl+Enter`. 화면의 번호가 곧 단축키가 되도록 프리셋 칩에 키 배지를 붙였다(외울 필요 없음). **숫자는 사유 선택 모드 안에서만 의미를 갖는다** — 모드 밖에서 잘못 누른 키 하나로 반려가 나가면 안 되고, 그 가드 순서를 테스트로 고정했다. 실렌더(Playwright): `R`→`2` 로 `{"decision":"rejected","memo":"라이선스 확인 불가로 반려"}`, 메모+`Ctrl+Enter` 로 `{"decision":"rejected","memo":"자유 서술 사유"}`, 콘솔 에러 0. 반려 후 승인·보류와 같이 다음 미심사 케이스로 이동한다. **ruff clean · pytest 645 passed**(정적 3건)
- 2026-09-21 · **점수 구성 패널** — "증거 51개인데 왜 35점"에 답한다. 스코어러가 풀 상한(v2/v3)과 신뢰 출처 제외(v4)를 갖게 되면서 증거 개수와 점수의 간극이 벌어졌는데, 그 이유가 화면 어디에도 없었다 — 제가 그 간극을 만든 장본인이라 자체 부채였다. `ScoreResult.breakdown` 을 신설해 종류별 기여·풀 상한에 삼켜진 점수·제외된 행을 기록하고, 제출물에 `riskBreakdown` 으로 영속화한 뒤 증거 요약과 증거 목록 **사이**(그 질문이 생기는 지점)에 렌더한다. 실렌더 예: `지식DB 지문 매칭 80 / 웹 게시(google) 60 (원래 125 · 상한 60) / 웹 엔티티 라벨 20 (원래 114 · 상한 20) / 신뢰 출처 posar.info 3행 제외 / 합계 115점이 상한 100에 걸렸습니다`. **점수는 하나도 안 바뀐다** — 기존 스코어러 테스트 31건이 그대로 통과하므로 `SCORER_VERSION` 은 v4 유지(가중치·버킷·밴드 불변). 분해 합계가 항상 점수와 일치하는지도 테스트로 고정했다(분해가 다른 이야기를 하면 분해가 아니라 또 하나의 주장이 된다). **ruff clean · pytest 642 passed**(스코어러 5 + 정적 1) - 2026-09-21 · **점수 구성 패널** — "증거 51개인데 왜 35점"에 답한다. 스코어러가 풀 상한(v2/v3)과 신뢰 출처 제외(v4)를 갖게 되면서 증거 개수와 점수의 간극이 벌어졌는데, 그 이유가 화면 어디에도 없었다 — 제가 그 간극을 만든 장본인이라 자체 부채였다. `ScoreResult.breakdown` 을 신설해 종류별 기여·풀 상한에 삼켜진 점수·제외된 행을 기록하고, 제출물에 `riskBreakdown` 으로 영속화한 뒤 증거 요약과 증거 목록 **사이**(그 질문이 생기는 지점)에 렌더한다. 실렌더 예: `지식DB 지문 매칭 80 / 웹 게시(google) 60 (원래 125 · 상한 60) / 웹 엔티티 라벨 20 (원래 114 · 상한 20) / 신뢰 출처 posar.info 3행 제외 / 합계 115점이 상한 100에 걸렸습니다`. **점수는 하나도 안 바뀐다** — 기존 스코어러 테스트 31건이 그대로 통과하므로 `SCORER_VERSION` 은 v4 유지(가중치·버킷·밴드 불변). 분해 합계가 항상 점수와 일치하는지도 테스트로 고정했다(분해가 다른 이야기를 하면 분해가 아니라 또 하나의 주장이 된다). **ruff clean · pytest 642 passed**(스코어러 5 + 정적 1)

View file

@ -220,7 +220,8 @@ app-shell CSP(`script-src 'self'`, `http_app.py:514`)가 인라인 스크립트
|---|---|---| |---|---|---|
| `_perceptual_hash` 가 pHash가 아닌 aHash | `analysis/fingerprints.py:47` | 균일/저디테일 이미지 유사도 오탐. 교체 시 0.9 임계 재보정 필요 | | `_perceptual_hash` 가 pHash가 아닌 aHash | `analysis/fingerprints.py:47` | 균일/저디테일 이미지 유사도 오탐. 교체 시 0.9 임계 재보정 필요 |
| src 4파일이 800줄 초과 | `store_page_scrape.py`(972) `store_operations.py`(968) `sqlite_store.py`(904) `store_enrichment.py`(853) | 저장소 코어라 분할 리스크 있음. 별도 작업으로 | | src 4파일이 800줄 초과 | `store_page_scrape.py`(972) `store_operations.py`(968) `sqlite_store.py`(904) `store_enrichment.py`(853) | 저장소 코어라 분할 리스크 있음. 별도 작업으로 |
| 밴드 임계·풀 상한 미캘리브레이션 | `analysis/risk_scoring.py` | 판정 이력 0건이라 70/30 임계가 실제 판정과 대조된 적 없다. v3 의 두 풀(`WEB_PRESENCE_MAX_POINTS` 60 은 **제공자별** / `WEB_ENTITY_MAX_POINTS` 20)은 신호의 구조적 성질로 정한 값이고 상관 검증 전이다 — 판정 30~50건 후 `decision_stats` 교차표가 첫 근거가 된다 | | 웹 신호만으로 high 도달 | `analysis/risk_scoring.py` | v2 설계 원칙은 "웹 존재만으로 high 불가"인데 presence 풀(60) + entity 풀(20) = 80 이라 합이 캡되지 않는다. v3 의 제공자별 풀링이 이를 키웠다 — 웹 전용 조합의 high 가 4/12 → 9/12(9/22 실측, `scripts/score_distribution.py`). 대응 후보는 웹 유래 총합 상한이고 적용 시 `SCORER_VERSION` 을 올려야 한다 |
| 밴드 임계·풀 상한 미캘리브레이션 | `analysis/risk_scoring.py` | 판정 이력 0건이라 70/30 임계가 실제 판정과 대조된 적 없다. v3 의 두 풀(`WEB_PRESENCE_MAX_POINTS` 60 은 **제공자별** / `WEB_ENTITY_MAX_POINTS` 20)은 신호의 구조적 성질로 정한 값이고 상관 검증 전이다 — 판정 30~50건 후 `decision_stats` 교차표가 첫 근거가 된다 — 절차는 `docs/operations/calibration-corpus.md`, 확정 판단은 `scripts/sweep_thresholds.py` |
| 출처 신호 2종 0점 | `analysis/provenance.py` | 캘리브레이션 전. 실측 코퍼스에서 가치 미입증. 방송캡처는 `MIN_CAPTURE_CATEGORIES=2` 에서 geometry 가 1개 카테고리라 자막 없는 캡처는 구조적으로 도달 불가 | | 출처 신호 2종 0점 | `analysis/provenance.py` | 캘리브레이션 전. 실측 코퍼스에서 가치 미입증. 방송캡처는 `MIN_CAPTURE_CATEGORIES=2` 에서 geometry 가 1개 카테고리라 자막 없는 캡처는 구조적으로 도달 불가 |
| 테스트가 로컬 LLM 에 실접속 | `integrations/env_clients.py:260` | `_llm_assistant` 가 항상 `OLLAMA_BASE_URL` 기본값(`127.0.0.1:11434`)으로 클라이언트를 만들므로, provider_runtime 없이 만든 store 를 쓰는 테스트는 **ollama 가 떠 있으면 실호출**을 한다. `test_http_app.py` 3건이 그 상태에서 소켓 타임아웃으로 실패한다(`OLLAMA_BASE_URL=http://127.0.0.1:1` 로 돌리면 통과). 폐쇄망 프로젝트의 테스트가 외부 서비스의 **부재**에 의존하는 상태 | | 테스트가 로컬 LLM 에 실접속 | `integrations/env_clients.py:260` | `_llm_assistant` 가 항상 `OLLAMA_BASE_URL` 기본값(`127.0.0.1:11434`)으로 클라이언트를 만들므로, provider_runtime 없이 만든 store 를 쓰는 테스트는 **ollama 가 떠 있으면 실호출**을 한다. `test_http_app.py` 3건이 그 상태에서 소켓 타임아웃으로 실패한다(`OLLAMA_BASE_URL=http://127.0.0.1:1` 로 돌리면 통과). 폐쇄망 프로젝트의 테스트가 외부 서비스의 **부재**에 의존하는 상태 |
| 다이어그램에 한글 폰트 미번들 (내부 문서라 low) | `docs/diagrams/*.html` | 임베드된 JetBrains Mono 의 unicode-range 6개에 **한글 구간이 없다**(`U+AC00` 매치 0). 폴백 스택은 중국어 폰트뿐(`Noto Sans Mono CJK SC`/`PingFang SC`/`Microsoft YaHei`). 렌더 텍스트의 40~72% 가 한글이라 타깃 폰트에 의존한다 — 한국어 폰트 없는 최소 설치 호스트에서는 두부(tofu). archify 에 저자용 폰트 옵션이 없어 spec 으로는 못 고친다(레이아웃 폭은 전각으로 측정되므로 깨짐이 아니라 글자 문제). **내부 문서로 확정(9/21)** 되어 현재는 low — 사내 머신에는 한글 폰트가 있다. 인수물로 승격하면 blocker | | 다이어그램에 한글 폰트 미번들 (내부 문서라 low) | `docs/diagrams/*.html` | 임베드된 JetBrains Mono 의 unicode-range 6개에 **한글 구간이 없다**(`U+AC00` 매치 0). 폴백 스택은 중국어 폰트뿐(`Noto Sans Mono CJK SC`/`PingFang SC`/`Microsoft YaHei`). 렌더 텍스트의 40~72% 가 한글이라 타깃 폰트에 의존한다 — 한국어 폰트 없는 최소 설치 호스트에서는 두부(tofu). archify 에 저자용 폰트 옵션이 없어 spec 으로는 못 고친다(레이아웃 폭은 전각으로 측정되므로 깨짐이 아니라 글자 문제). **내부 문서로 확정(9/21)** 되어 현재는 low — 사내 머신에는 한글 폰트가 있다. 인수물로 승격하면 blocker |

View file

@ -0,0 +1,134 @@
# 캘리브레이션 코퍼스 — 실이미지로 밴드 임계를 검증하는 절차
이 프로젝트의 가장 큰 미검증 항목은 **위험도 밴드 임계(70/30)가 실제 판정과 한 번도
대조된 적이 없다**는 것이다. 판정이 0건이라 `decision_stats` 도 빈 표를 낸다.
이 문서는 그 공백을 메우는 절차다.
두 가지를 구분한다.
| 대상 | 도구 | 사람 필요? |
|---|---|---|
| 스코어러의 **구조적 거동** (어떤 신호 조합이 어느 밴드로 가나) | `scripts/score_distribution.py` | 아니오 |
| **임계가 맞는지** (밴드 vs 운영자 판단) | `scripts/sweep_thresholds.py` | **예 — 실이미지 + 실판정** |
전자는 지금 당장 돌릴 수 있고 스코어러를 바꿀 때마다 돌려야 한다. 후자는 아래 절차로
코퍼스를 만든 뒤에만 의미가 있다.
## 1. 이미지 구성이 성패를 가른다
기술이 아니라 여기서 갈린다. 현재 밴드 분포에 **low 가 0건**이고, 저위험으로 떨어지는
이미지가 섞이지 않으면 미탐 지표(`lowBandRejectionRate`)의 분모가 0 이 된다.
그런데 `_ratio` 가 `0/0 → 0.0` 을 돌려주므로 화면에는 **"미탐 0%"로 표시된다** —
측정된 0 과 측정 불가가 구분되지 않는다(`store_workflow.py:398-399`).
40장 기준 권장 구성:
| 군 | 장수 | 기대 밴드 | 넣는 이유 |
|---|---|---|---|
| 자사 제작·오리지널 (웹에 없음) | 10 | low | **미탐 지표의 유일한 분모** |
| 라이선스 구매·계약처 제공분 | 8 | low~medium | 신뢰 출처 기능도 함께 검증 |
| 명백한 무단 사용 후보 (뉴스 사진·연예인·캐릭터) | 12 | high | 과탐 지표의 분모 |
| 애매한 것 (웹에 있으나 출처 불명, SNS 유통) | 8 | 경계 | **임계가 실제로 가르는 지점** |
| 위 중 2~3장을 리사이즈·재저장한 재제출 | 4 | — | 선례 승계·중복 탐지 검증 |
## 2. 준비 — 안 하면 코퍼스가 오염된다
**네이버 일일 한도.** `COPYRIGHTER_NAVER_DAILY_LIMIT=100` 인데 자동 검색이 장당 최대
5콜(이미지 3 + 블로그 1 + 웹 1)이라 20장째에 고갈된다. 고갈분은 `SEARCH_SKIPPED`
**0점으로 조용히** 처리되므로(`risk_scoring.py` 가 skipped 를 운영 상태로 보고 건너뛴다)
앞쪽과 뒤쪽 이미지의 증거 밀도가 달라진다.
```
.env: COPYRIGHTER_NAVER_DAILY_LIMIT=400
COPYRIGHTER_GOOGLE_DAILY_LIMIT=200
```
**Google Custom Search 는 현재 꺼져 있다.** `GOOGLE_CUSTOM_SEARCH_CX` 는 있는데
`GOOGLE_CUSTOM_SEARCH_API_KEY` 가 없어 어댑터가 `None` 이다(`env_clients.py:243-246`).
이 상태로 돌리면 `google_search` 증거가 0행이다. 키를 넣거나, **증거원 2종
(Vision+Naver)으로 간다고 코퍼스 메타에 명시**한다. 키를 넣으면
`COPYRIGHTER_GOOGLE_CUSTOM_SEARCH_DAILY_LIMIT=100` 도 같이 넣는다 — 없으면 로컬
게이트가 무제한이라 구글 무료 한도를 막아주지 못한다.
**DB 를 분리한다.** 기존 제출물은 스탬프 도입 이전 행이라 판정해도 스냅샷에
`scorerVersion='legacy'` 로 찍혀 스윕에서 제외된다.
```powershell
python run_copyrighter_server.py --db data/corpus.sqlite3 --images C:/corpus
```
## 3. 투입
**파일명을 새로 매긴다.** 파일명 stem 이 곧 제출 id 이고, 같은 stem 이면
**조용히 1건으로 병합된다**(`image_store.py:94-101` 이 알려진 한계로 명시). 매니페스트
(`submissions.json`)는 필요 없다 — 폴더를 재귀 스캔한다. HEIC/TIFF 는 미지원이다.
```powershell
# 중복 검사 (결과가 비어야 한다)
Get-ChildItem C:/corpus -Recurse -File | Group-Object BaseName | Where-Object { $_.Count -gt 1 }
# 10~15장씩 나눠서 — seed 가 write lock 을 잡는다
Invoke-RestMethod -Method Post -Uri http://127.0.0.1:9500/api/submissions/reload
```
reload 는 멱등이다(분석된 건은 건너뛴다). 다만 **LLM 서술이 seed 1회당 20건 캡**
(`store_constants.py` `MAX_LLM_SUMMARIES_PER_SEED`)이라 40장이면 reload 를 2~3번 더
호출해야 전부 채워진다.
투입 시 자동으로 도는 것: 내부 분석(지문·얼굴·출처) → Vision 웹탐지 장당 1콜 →
검색결과 유사도 비교 → 얼굴 크롭 생성 → 네이버 자동 검색 → 재스코어링 → LLM 서술.
**얼굴 크롭의 외부 전송은 시드에 없다** — 케이스별 재분석을 눌러야 발생한다
(`COPYRIGHTER_GOOGLE_FACE_CROP_SEARCH=true` 면 장당 Vision 최대 4콜을 더 쓴다).
## 4. 판정
- **근거로 쓴 증거를 '사용'으로 표시한다.** 안 하면 내보내기의 `evidenceUsed` 가
빈칸이라 "무엇을 보고 판정했나"가 사라진다.
- **판정 중에 새 이미지를 넣지 않는다.** 폴더 리로드마다 전체 리스코어가 돌아
이미 판정한 케이스의 밴드가 움직이고 교차표가 소급 변동한다.
- 키보드: `A` 승인 · `H` 보류 · `R`→숫자 반려 · `N` 다음 미심사.
## 5. 측정
```powershell
# 분모부터 — 콘솔 좌측 'S 통계' 에서 highBandDecided / lowBandDecided
# 각 5건 미만이면 비율을 해석하지 않는다 (콘솔은 색만 죽이고 숫자는 그대로 보여준다)
python scripts/sweep_thresholds.py data/corpus.sqlite3
```
스윕은 `decisionSnapshot.riskScore`(판정 시점 각인)를 쓴다. `decision_stats` 의 교차표는
**현재** riskBand 기준이라 리스코어에 흔들리므로 확정 판단에 쓰지 않는다.
읽는 법: `over` = high 로 분류했는데 승인한 건수(과탐, 많으면 high 임계가 낮다).
`under` = low 로 분류했는데 반려한 건수(미탐, 0 이 아니면 medium 임계가 높다).
## 참고 — 2026-09-22 시점의 구조적 거동 (판정 없이 측정한 것)
`scripts/score_distribution.py` 로 432조합을 전수 계산한 결과(스코어러 v4):
| | 신뢰 출처 미등록 | 신뢰 출처 등록 |
|---|---|---|
| high | 92% | 63% |
| medium | 6% | 29% |
| low | 0% | 7% |
| 100점 포화 | 80% | 38% |
| 웹 신호만으로 high | 13/24 | 0/24 |
**이 격자는 신호 축을 균등 샘플링하므로 실제 큐 분포가 아니다.** 92% 가 "실제 큐의
92% 가 high"라는 뜻이 아니며, 임계가 맞는지도 말해주지 않는다. 구조적 거동의
기준선일 뿐이고, 스코어러를 바꿀 때 전후 비교에 쓴다.
여기서 드러난 것 두 가지:
1. **웹 신호만으로 high 에 도달하는 조합이 있다.** 2026-08-09 v2 가 세운 설계 원칙은
"웹 존재만으로는 high 가 되지 않는다. 두 번째 **종류**의 신호가 필요하다"였는데,
presence 풀(최대 60)과 entity 풀(최대 20)의 합이 80 이라 `full+partial` + 엔티티 1개
만으로 high 가 된다. 두 풀이 각각 임계 아래로 캡됐지만 합은 캡되지 않는다.
2. **v3 의 제공자별 풀링이 그 경향을 키웠다.** 독립 제공자가 하나 더 있으면 웹 전용
조합의 high 가 4/12 → 9/12 로, 전체 high 비율이 88% → 97% 로 올라간다. 교차 확증이
high 에 도달해야 한다는 v3 의 의도대로 동작한 결과지만, 총량 통제가 없다.
대응 후보(미적용, 결정 필요): 웹 유래 점수 총합에 상한을 두어 제공자 수와 무관하게
웹 신호만으로는 high 에 닿지 않게 한다. 적용하면 `SCORER_VERSION` 을 올리고 이 표를
다시 측정해야 한다.

View file

@ -0,0 +1,224 @@
"""위험도 스코어러의 밴드 분포를 신호 조합 전수로 측정한다.
**이 스크립트가 재는 것과 재지 않는 것**
재는 것: 스코어러의 구조적 거동. 어떤 신호 조합이 어느 밴드로 떨어지는지,
100점 포화가 얼마나 되는지, "웹 신호만으로 high 에 도달하는가"(v2 이후의 설계
원칙)가 지켜지는지.
재지 않는 것: **임계가 맞는지.** 이 격자는 신호 축을 균등 샘플링하므로 실제 큐의
분포가 아니다. 임계 검증은 실이미지 + 운영자 판정으로만 가능하고 그것은
scripts/sweep_thresholds.py 가 담당한다.
**왜 만들었나**: SCORER_VERSION 이 v1→v2→v3→v4 로 갔는데 v3(제공자별 풀링)와
v4(신뢰 출처)는 분포를 한 번도 측정하지 않고 나갔다. 2026-08-08 평가가 쓴
224조합 하네스는 저장소에 남아 있지 않고, 그 문서의 두 수치(174/224=77.7% 와
82.6%)가 서로 맞지 않아 격자가 달랐음을 시사한다 — 즉 과거 숫자와 비교 가능한
재현은 불가능하다. 그래서 격자를 여기에 **명시적으로** 고정해 새 기준선으로 삼는다.
앞으로 스코어러를 바꾸면 이 스크립트를 돌려 전후를 비교한다.
사용법:
python scripts/score_distribution.py
python scripts/score_distribution.py --json
"""
from __future__ import annotations
import argparse
import itertools
import json
import sys
from collections import Counter
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "src"))
from rights_filter.analysis.risk_scoring import SCORER_VERSION, RiskScorer # noqa: E402
from rights_filter.domain.records import Evidence, EvidenceSource # noqa: E402
TRUSTED_HOST = "trusted.example"
OUTSIDE_HOST = "outside.example"
# --- 신호 축 -----------------------------------------------------------------
#
# 각 축은 스코어러가 실제로 구분하는 입력만 담는다. provider 를 명시하는 이유는
# v3 부터 풀과 dedup 버킷이 제공자별로 갈리기 때문이다 - 같은 match 타입이라도
# 제공자가 다르면 점수가 달라진다.
WEB_PRESENCE = {
"none": [],
"full": [("full", 0.9)],
"full+partial": [("full", 0.9), ("partial", 0.8)],
"all4": [("full", 0.9), ("partial", 0.8), ("page", 0.8), ("visual", 0.6)],
}
ENTITY_COUNT = {"0": 0, "1": 1, "3": 3}
FACE = {"none": None, "background": "background", "identifiable": "identifiable"}
FINGERPRINT = {"none": None, "weak": 0.5, "strong": 0.95}
NAVER = {"none": False, "promoted": True}
SECOND_PROVIDER = {"none": False, "cse_page": True}
def build_evidence(presence, entities, face, fingerprint, naver, second, host):
evidence = []
for match, confidence in WEB_PRESENCE[presence]:
evidence.append(
Evidence(
source=EvidenceSource.WEB_DETECTION,
reason=f"vision {match}",
confidence=confidence,
data={"match": match, "provider": "google", "host": host},
)
)
for index in range(ENTITY_COUNT[entities]):
evidence.append(
Evidence(
source=EvidenceSource.WEB_DETECTION,
reason=f"entity {index}",
confidence=0.9,
data={"match": "entity", "entity": f"E{index}", "provider": "google"},
)
)
if SECOND_PROVIDER[second]:
# 독립 제공자가 따로 찾은 게시본. v3 의 제공자별 풀이 이것을 Vision 과
# 합산하지 않고 따로 세는지가 여기서 드러난다.
evidence.append(
Evidence(
source=EvidenceSource.WEB_DETECTION,
reason="cse page",
confidence=0.35,
data={"match": "page", "provider": "google_custom_search", "host": host},
)
)
if FACE[face]:
evidence.append(
Evidence(
source=EvidenceSource.FACE_PERSON,
reason="face",
confidence=1.0,
data={"tier": FACE[face]},
)
)
if FINGERPRINT[fingerprint] is not None:
evidence.append(
Evidence(
source=EvidenceSource.FINGERPRINT,
reason="knowledge match",
confidence=1.0,
data={"similarity": FINGERPRINT[fingerprint]},
)
)
if NAVER[naver]:
evidence.append(
Evidence(
source=EvidenceSource.NAVER_SEARCH,
reason="naver promoted",
confidence=0.8,
data={"promoted": True},
)
)
return evidence
def combinations():
return itertools.product(
WEB_PRESENCE, ENTITY_COUNT, FACE, FINGERPRINT, NAVER, SECOND_PROVIDER
)
def measure(trusted: bool):
"""trusted=True 면 모든 웹 행이 등록된 신뢰 출처에 게시된 것으로 본다."""
host = TRUSTED_HOST if trusted else OUTSIDE_HOST
scorer = RiskScorer(frozenset({TRUSTED_HOST}) if trusted else None)
bands = Counter()
saturated = 0
web_only_high = 0
web_only_total = 0
rows = []
for presence, entities, face, fingerprint, naver, second in combinations():
evidence = build_evidence(presence, entities, face, fingerprint, naver, second, host)
result = scorer.score(evidence)
bands[result.band] += 1
if result.score == 100:
saturated += 1
# "웹 신호만" = 인물·지식DB·네이버가 전부 없는 조합. v2 가 세운 설계
# 원칙은 이 부류가 high 에 도달하지 않는다는 것이다.
if face == "none" and fingerprint == "none" and naver == "none":
web_only_total += 1
if result.band == "high":
web_only_high += 1
rows.append(
{
"presence": presence,
"entities": entities,
"face": face,
"fingerprint": fingerprint,
"naver": naver,
"second": second,
"score": result.score,
"band": result.band,
}
)
total = sum(bands.values())
return {
"total": total,
"bands": {band: bands.get(band, 0) for band in ("high", "medium", "low")},
"bandPct": {
band: round(100 * bands.get(band, 0) / total, 1) for band in ("high", "medium", "low")
},
"saturated": saturated,
"saturatedPct": round(100 * saturated / total, 1),
"webOnlyHigh": web_only_high,
"webOnlyTotal": web_only_total,
"rows": rows,
}
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--json", action="store_true", help="전체 행을 JSON 으로 출력")
args = parser.parse_args()
plain = measure(trusted=False)
trusted = measure(trusted=True)
if args.json:
print(json.dumps({"scorer": SCORER_VERSION, "plain": plain, "trusted": trusted}, ensure_ascii=False))
return 0
# 비ASCII 는 stdout 에 넣지 않는다 (콘솔이 cp949 라 죽은 이력).
print("scorer %s | grid %d combinations" % (SCORER_VERSION, plain["total"]))
print("")
print("%-22s %8s %8s" % ("", "default", "trusted"))
for band in ("high", "medium", "low"):
print(
"%-22s %7d%% %7d%%"
% (band, plain["bandPct"][band], trusted["bandPct"][band])
)
print(
"%-22s %7d%% %7d%%"
% ("saturated (==100)", plain["saturatedPct"], trusted["saturatedPct"])
)
print("")
print(
"web-signal-only high : %d/%d (default) | %d/%d (trusted)"
% (
plain["webOnlyHigh"],
plain["webOnlyTotal"],
trusted["webOnlyHigh"],
trusted["webOnlyTotal"],
)
)
print("")
print("counts default: %s" % plain["bands"])
print("counts trusted: %s" % trusted["bands"])
return 0
if __name__ == "__main__":
sys.exit(main())

144
scripts/sweep_thresholds.py Normal file
View file

@ -0,0 +1,144 @@
"""운영자 판정과 대조해 밴드 임계(기본 70/30)가 맞는지 스윕한다.
**왜 decision_stats 가 아니라 이것인가**
`decision_stats` 의 교차표는 제출물의 **현재** riskBand 를 쓴다. 그런데
`_rescore_submission` 이 14개 지점에서 점수를 덮어쓰고 폴더 리로드마다 큐 전체를
리스코어하므로, 판정한 뒤 이미지를 더 넣으면 이미 판정된 케이스의 밴드가 바뀌어
교차표가 소급 변동한다. 판정 시점의 점수를 보존하는 것은 `decisionSnapshot` 뿐이다.
그래서 이 스크립트는 스냅샷에서 (판정 시점 점수, 판정) 쌍만 뽑아 임계를 스윕한다.
**제외되는 행** — 제외 사유를 반드시 함께 읽어야 한다:
unreviewed 아직 판정 안 함
no_snapshot 판정은 있으나 스냅샷이 없음 (스냅샷 도입 이전)
legacy_pre_stamp 스코어러 스탬프 도입 이전 행. 원점수를 복원할 수 없다
**읽는 법**
over = high 로 분류했는데 운영자가 승인한 건수 → 과탐. 많으면 high 임계가 낮다.
under = low 로 분류했는데 운영자가 반려한 건수 → 미탐. 0 이 아니면 medium 임계가 높다.
분모(high_n / low_n)가 5 미만인 줄은 해석하지 않는다. 특히 low_n 이 0 이면
under 는 언제나 0 이므로 "미탐 없음"이 아니라 "측정 불가"다 — 코퍼스에 저위험으로
떨어지는 이미지가 섞이지 않으면 이 열은 영원히 0 이다.
사용법:
python scripts/sweep_thresholds.py data/corpus.sqlite3
"""
from __future__ import annotations
import argparse
import json
import sqlite3
import sys
from collections import Counter
HIGH_CANDIDATES = (50, 60, 70, 80)
MEDIUM_CANDIDATES = (20, 30, 40)
MIN_DENOMINATOR = 5
def collect(db_path: str):
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
pairs = []
skipped = Counter()
try:
for row in conn.execute("select payload from submissions"):
submission = json.loads(row["payload"])
decision = submission.get("decisionStatus", "unreviewed")
if decision == "unreviewed":
skipped["unreviewed"] += 1
continue
snapshot = submission.get("decisionSnapshot")
if not snapshot:
skipped["no_snapshot"] += 1
continue
if snapshot.get("scorerVersion") == "legacy":
skipped["legacy_pre_stamp"] += 1
continue
score = snapshot.get("riskScore")
if score is None:
skipped["no_score_in_snapshot"] += 1
continue
pairs.append(
{
"id": str(submission.get("id", "")),
"score": int(score),
"band": snapshot.get("riskBand"),
"decision": decision,
"scorerVersion": snapshot.get("scorerVersion"),
}
)
finally:
conn.close()
return pairs, skipped
def sweep(pairs):
results = []
for high in HIGH_CANDIDATES:
for medium in MEDIUM_CANDIDATES:
if medium >= high:
continue
high_rows = [p for p in pairs if p["score"] >= high]
low_rows = [p for p in pairs if p["score"] < medium]
results.append(
{
"high": high,
"medium": medium,
"highN": len(high_rows),
"overDetection": sum(1 for p in high_rows if p["decision"] == "approved"),
"lowN": len(low_rows),
"underDetection": sum(1 for p in low_rows if p["decision"] == "rejected"),
}
)
return results
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("db", help="판정이 쌓인 SQLite 경로")
parser.add_argument("--json", action="store_true")
args = parser.parse_args()
pairs, skipped = collect(args.db)
results = sweep(pairs)
if args.json:
print(json.dumps({"pairs": pairs, "skipped": dict(skipped), "sweep": results}, ensure_ascii=False))
return 0
# 비ASCII 는 stdout 에 넣지 않는다 (콘솔이 cp949 라 죽은 이력).
print("usable pairs: %d skipped: %s" % (len(pairs), dict(skipped)))
if not pairs:
print("")
print("no decisions to sweep yet - see docs/operations/calibration-corpus.md")
return 0
versions = Counter(p["scorerVersion"] for p in pairs)
print("scorer versions in sample: %s" % dict(versions))
if len(versions) > 1:
print("WARNING: mixed scorer versions - scores are not comparable across them")
print("")
print("%-18s %6s %6s %6s %6s" % ("thresholds", "highN", "over", "lowN", "under"))
for item in results:
flag = "" if item["highN"] >= MIN_DENOMINATOR and item["lowN"] >= MIN_DENOMINATOR else " (n<%d)" % MIN_DENOMINATOR
print(
"high>=%-3d med>=%-3d %6d %6d %6d %6d%s"
% (
item["high"],
item["medium"],
item["highN"],
item["overDetection"],
item["lowN"],
item["underDetection"],
flag,
)
)
return 0
if __name__ == "__main__":
sys.exit(main())