# 작업 보고: task-3026 — 랭킹·포화도 1,000건 상한 원인 규명

- 팀: dev7-team (이참나 / Itzamna)
- 레벨: Lv.2 · 저장소 `Jeon-Jonghyuk/InsuRo` · base `origin/main = 14e9596`
- 브랜치: task/task-3026-dev7 · PR #260
- 작성일: 2026-08-26

## S — 상황
06:11 파이프라인에서 t3019 머지 효과로 트렌드 수집이 0 → 32,321건으로 살아났다.
그런데 랭킹 산출이 **정확히 1,000건**, 포화도가 **정확히 1,000건**이었다.
포화도는 경고가 떴지만 랭킹은 경고가 뜨지 않았다.

## C — 복잡성
"정확히 1,000"은 세 가지 원인이 가능하고 **처방이 각각 다르다**.
(a) PostgREST 상한 잘림 → 페이지네이션 / (b) 쿼리 조건이 좁음 → 조건 수정 /
(c) 상류 수집이 실제 부족 → 코드 버그 아님, 고치면 안 됨.
섞어서 "1,000건 문제"로 뭉뚱그리면 틀린 처방이 나간다.
게다가 경고가 **안 뜨는** 랭킹이 더 위험했다.

## Q — 질문
1,000 은 잘림인가, 조건 문제인가, 실제 부족인가? 단계별로 특정할 수 있는가?

## A — 답변 (결론)
**(a) 잘림이다. PostgREST 서버 `max-rows = 1000`.**
명시적 `.range(0,4999)` 로도 1,000건만 오므로 클라이언트 기본값이 아니라 **서버 설정**이고,
처방은 **오프셋 루프**뿐이다. 포화도만 (c) 상류 부족의 **캐스케이드**이며,
어제 랭킹 0건은 잘림과 무관한 **별개 결함**이다.

- 뿌리: `daily_saturation_collect.py` keywords 읽기 — 3,500 중 1,000 (71.4% 누락)
- 2단 직렬 잘림: `daily_ranking_calc.load_keywords` 가 독립된 두 번째 지점
- 최악: `load_trends` — 29,311 중 1,000 (**96.6% 누락**)
- **최소 117일간 매일 같은 앞쪽 1,000개 키워드만** 처리돼 왔다 (집합 동일성으로 실증)

## 수정 파일별 검증 상태

| 파일 | 변경 | 검증 | 상태 |
|---|---|---|---|
| server/scripts/daily_ranking_calc.py | _fetch_all 오프셋 루프 + 4개 로더 페이지네이션 | 실 DB 재측정 3,500 / 29,311 · 변이 A·B 격추 | 통과 |
| server/scripts/daily_saturation_collect.py | _load_active_keywords 추출 + 페이지네이션 | 실 DB 재측정 3,500 · 변이 D·E 격추 | 통과 |
| server/scripts/daily_health_check.py | 커버리지·잘림지문 경고 추가 (기존 임계값 무변경) | 실 프로덕션 값 대조 · 변이 C 격추 | 통과 |
| server/tests/test_task3019_health_check_window.py | 봉인 테스트 32건 추가 | 회귀 2,848 passed | 통과 |
| server/scripts/tests/measure_truncation.py | 읽기전용 실측 하네스 | 쓰기 연산 0건 grep 확인 | 통과 |

## 3 Step Why
- **1st Why** — 랭킹이 왜 1,000건인가? → `load_keywords` 가 3,500 중 1,000만 읽어서.
- **2nd Why** — 왜 1,000만 읽히나? → PostgREST 서버 `max-rows=1000` 이 응답을 자르는데
  supabase-py 가 예외도 경고도 내지 않아 **조용히** 잘린다.
- **3rd Why** — 왜 117일간 아무도 몰랐나? → 헬스체크 `RANKING_MIN = 1` 이라
  1,000건은 "정상"으로 통과했다. **경고 유무의 차이는 결함 유무가 아니라 임계값 차이**였다.

## L1 스모크테스트
- 서버 재시작: **해당없음** — 배치 스크립트 작업이며 insuro-api 무관 (main.py 미변경)
- API 응답 확인: **해당없음**
- 실 DB 실행 확인: **수행** — 수정된 로더를 실제 프로덕션 DB에 대고 직접 호출
  · `load_keywords` → 3,500건 (4페이지) · `load_trends` → 29,311건 (30페이지)
  · 대조군 naive `.execute()` → 1,000건 · `.range(0,4999)` → 1,000건
  · 고유 id 3,500 · 정렬 오름차순 → 중복·누락 0건
  · 헬스체크 실 프로덕션 값 판정: 신규 경고 3종 실제 발화 확인 (알림 미발송)
  ※ `main()` 전체는 **의도적으로 미실행** — DB write + 네이버 API 과금 발생하므로 읽기 전용으로만 검증
- 테스트 결과: **수행** — 회귀 스위트 실행 결과 `4 failed, 2848 passed, 4 skipped` (실패 4건은 base 선재 결함)
  · 봉인 변이 5종 실행 결과: 전부 격추(KILLED) 확인
- 스크린샷: **해당없음** (UI 변경 0건 — `src/**` 미변경, 배치 스크립트 전용 작업)

## ① 어디서 잘리는지 (전부 실측, 2026-08-26)

| 단계 | 대상/필터 | 정확한 총계(header) | 코드가 읽은 수 | 쓴 건수 | 상한 도달 |
|---|---|---|---|---|---|
| ranking_calc.load_keywords (L120) | keywords is_active=true | 3,500 | 1,000 | 1,000 | YES |
| ranking_calc.load_trends (L133) | keyword_trends period_start>=2026-06-17 | 29,311 | 1,000 | 1,000 | YES |
| ranking_calc.load_saturation (L146) | keyword_saturation collected_at=오늘 | 1,000 | 1,000 | 1,000 | 잘림 아님(캐스케이드) |
| ranking_calc.load_yesterday_rankings (L159) | keyword_rankings rank_date=2026-08-25 | 0 | 0 | — | no (별개 결함) |
| saturation_collect.main (L206) | keywords is_active=true | 3,500 | 1,000 | 1,000 | YES |
| [산출물] keyword_rankings rank_date=오늘 | — | 1,000 | — | 1,000 | YES |
| [산출물] keyword_saturation collected_at=오늘 | — | 1,000 | — | 1,000 | YES |

## ② 원인 단정

- **(a) PostgREST 잘림 — 확정(실증).** 명시적 `.range(0,4999)` 로도 1,000건만 수신 →
  클라이언트 기본값이 아니라 **서버 설정**. 처방은 **오프셋 루프**뿐이며 `.range()` 부착만으론 불충분.
- 뿌리: `daily_saturation_collect.py:206` (3,500 중 1,000 = **71.4% 누락**)
- **2단 직렬 잘림**: `daily_ranking_calc.py:120` 이 독립된 두 번째 잘림 지점 —
  상류만 고치면 여기서 또 잘린다.
- `load_trends`: 29,311 중 1,000 = **96.6% 누락** (가장 심각, surge_score 왜곡)
- `load_saturation`: **(c) 캐스케이드** — 총계와 읽은 수가 둘 다 1,000으로 일치.
  단 상류를 고치면 여기가 다음 잘림 지점이 되므로 함께 수정 대상.
- `load_yesterday_rankings`: **(c) 별개 결함** — 08-25 랭킹 총계 **0건**
  (08-24·08-23 은 각 1,000건 존재). 어제 배치 미실행/실패 추정, **원인 미측정**.

### 캐스케이드 실증 (추정 아님)
무페이지네이션 `keywords` 가 돌려준 1,000개 id 집합 == 오늘 `keyword_saturation` id 집합
== 오늘 `keyword_rankings` id 집합 (차집합 0, min=46 max=11317).
`keyword_saturation` 총 **117,000행 = 1,000 × 117일** · `keyword_rankings` 총 **99,000행 = 1,000 × 99일**.
→ **최소 117일간 매일 같은 앞쪽 1,000개 키워드만** 처리. 나머지 2,500개는 한 번도 처리된 적 없음.

## ④ 조용한 실패의 정체
`daily_health_check.py` 의 `RANKING_MIN = 1`. 1,000건으로 잘려도 경고가 안 뜬다.
포화도는 `SATURATION_MIN = 1,800` 이라 경고가 떴다 — **경고 유무의 차이는 결함 유무가 아니라 임계값 차이**였다.
`check_*_count` 4종은 전부 `count="exact"` 헤더만 읽으므로 **카운트 자체는 정확**(잘림 무관).

## ★ 전수 census — 범위 밖 결함 (ANU 판단 필요, 이번 PR 미수정)
`server/**` 전수 조사 결과 무페이지네이션 읽기 **86지점**(HIGH 10). 이번 `allowed_resources` 로
고칠 수 있는 것은 **5지점뿐**. 아래는 **범위 밖이라 손대지 않았다.**

- `daily_surge_detect.py:130,160,181` — 파이프라인 Step 3.5. keywords/keyword_trends 무페이지네이션 (HIGH)
- `keyword_data_compress.py:83` — keywords 전체 맵 (HIGH). 같은 파일 `:68` 은 페이지네이션 되어 있음
- `main.py:4441` get_trend_insight_categories — 잘리면 **카테고리 자체가 통째로 소실** (HIGH)
- `main.py:860` check_cost_circuit_breaker — token_usage_log 과소집계 → **비용 차단기 fail-open** (HIGH 후보)
- `revoke_public_drive.py:159,172` — 공개 Drive 링크 **회수 누락분이 공개 상태로 잔존** (HIGH 후보)
- `policy_grouping/knowledge.py:1085` count_clauses — 잘린 데이터로 카운트 산출, 1,000 하드캡 (HIGH 후보)
- `backfill_clause_content_hash.py:147` — 1,000건만 백필하고 "완료" 종료 (HIGH 후보)
- `composite_calculator.py:322` — 잘리면 **복합설계 최적조합이 조용히 틀림** (MEDIUM)

★ 코드베이스는 이 함정을 **이미 알고 있었다**: `keyword_pool_refresh.py:395` 에
`# Supabase 기본 limit=1000이므로 페이지네이션으로 전체 조회` 주석 + `MAX_KEYWORDS = 3_500`.
같은 `keywords` 테이블을 4곳은 페이지네이션하고 4곳은 안 한다.

## 부수 발견
- **파이프라인 로그가 휘발**: 로그가 `/tmp/trend-pipeline-logs/` 에 있어 12:06 재부팅으로
  06:11 실행분이 전부 소실됐다. 사후 원인분석이 DB 상태에만 의존하게 된다. (관측성 결함)
- 봉인 테스트 **부재**: `server/tests/` 123개 파일 중 페이지네이션/잘림 검증 **0건**.
  4개 로더는 단 한 줄도 테스트되지 않았다.

## 회귀 기준선 (base 직접 재측정)
`14e9596` clean 워크트리, `python3 -m pytest tests scripts/tests -q -p no:randomly`
→ **4 failed, 2817 passed, 4 skipped (224.24s)**
실패 4건 전부 `scripts/tests/test_standby_fallback.py` — `.env` 주입 후에도 동일 실패 →
**선재 결함**이며 본 작업과 무관(해당 파일 `daily_trend_collect` 는 수정 금지 대상).


---

## ③ 수정 — 페이지네이션 (허용 범위 5지점 전부)

`daily_ranking_calc.py` · `daily_saturation_collect.py` 에 각각 상수 `PAGE_SIZE=1000` / `MAX_PAGES=1000`
과 헬퍼 `_fetch_all(build_query, *, label, ...)` 추가. 오프셋 루프로 전량을 읽고,
`max_pages` 초과 시 **조용히 자르지 않고 `RuntimeError`** 를 던진다(이번 사고가 바로 조용한 잘림이었다).

★ 모든 호출부에 `.order("id")` 부착 — `ORDER BY` 없는 offset 페이지네이션은 행이 **중복·누락**된다.

### 수정 전후 실측 (읽기 전용, `main()` 미실행)

| 지점 | 테이블 | exact_count | BEFORE | AFTER |
|---|---|---|---|---|
| load_keywords | keywords | 3,500 | 1,000 | **3,500** (4페이지) |
| load_trends | keyword_trends | 29,311 | 1,000 | **29,311** (30페이지) |
| load_saturation | keyword_saturation | 1,000 | 1,000 | 1,000 |
| load_yesterday_rankings | keyword_rankings | 0 | 0 | 0 |
| saturation_collect keywords 읽기 | keywords | 3,500 | 1,000 | **3,500** (4페이지) |

중복·누락 0건 (`keywords` 고유 id 3,500 · 정렬 오름차순 확인).
`load_saturation`=1,000 은 수정 미달이 아니라 **DB 에 오늘치가 실제로 1,000행뿐**(사고의 잔재).
수정본으로 포화도 수집이 한 번 돌면 3,500이 된다 — **이는 추정이며 다음 파이프라인 실행으로만 확인 가능**.

### 대조군 (사고 전제 독립 재현)
- naive `.execute()` → **1,000**
- `.range(0,4999)` 명시 → **1,000** ← 서버가 자름. `.range()` 부착만으론 못 고친다

## ④ 조용한 실패 노출 (`daily_health_check.py`)

기존 검사·임계값·t3019 KST 창 로직 **무변경**(함수 본문 md5 바이트 동일 대조 완료).
추가한 것만:
- `check_active_keyword_count(sb)` — `resp.count` 만 사용.
  ★ `.data` 를 세면 분모 자체가 잘려 **"1,000/1,000 = 100% 정상"이라는 최악의 거짓 안심**이 나온다
- 신규 상수 `COVERAGE_MIN_RATIO = 0.95`, `PAGE_SIZE = 1_000`
- 신규 kind: `ranking_coverage_low` / `saturation_coverage_low` / `truncation_suspected` / `active_keywords_zero`
- `looks_truncated(count, *, expected_total=None)` — 총계가 우연히 1,000 배수여도 오탐하지 않음
- `build_warning_message(..., *, active_count=None)` — 키워드 인자 + 기본값으로 **하위호환 유지**

### 실 프로덕션 값으로 신·구 대조 (순수 함수, 알림 미발송)
```
LIVE(2026-08-26): trend=32,321 sat=1,000 rank=1,000 fail=0 active=3,500
BASE: saturation_low|truncation_suspected
NEW : saturation_low|ranking_coverage_low|saturation_coverage_low|truncation_suspected
      ⚠️ 랭킹 커버리지: 1,000/3,500건 (28.6%, 기준 95% 이상)
```
**랭킹 1,000건은 `RANKING_MIN=1` 을 통과해 종전엔 완전 침묵**이었으나 이제 커버리지+지문 두 경로로 잡힌다.
전량 3,500 시나리오에서는 경고 없음(오탐 없음) 확인.

## 검증 (카마소츠 독립 재측정 — 구현자 자기보고 미승계)

### 회귀
최종: `4 failed, 2848 passed, 4 skipped, 40 warnings in 205.27s`
base(`4 failed, 2817 passed, 4 skipped`) 대비 **+31 passed, failed/skipped 동일**.
선재 실패 4건의 정체까지 규명: `test_standby_fallback.py` 는 t3019 테스트와 **같은 세션에서 돌 때만** 깨진다
(단독 실행 시 9 passed). base 에서도 동일 재현 → 본 작업과 무관.

### ★ 변이 봉인 — 5종 전부 격추
변이마다 md5 전후 대조로 **no-op 아님을 먼저 증명**.

| 변이 | 결과 | FAIL 한 테스트 |
|---|---|---|
| A `load_trends` 무페이지네이션 복귀 | **KILLED** (2 failed) | `test_ranking_loaders_read_everything`, `test_large_table_is_fully_read` |
| B `.order("id")` 제거 | **KILLED** (1 failed) | `test_pagination_is_deterministically_ordered` |
| C `COVERAGE_MIN_RATIO=0.0` | **KILLED** (4 failed) | `test_the_accident_is_no_longer_silent` 외 3 |
| D `saturation_collect` 결선부 무페이지네이션 복귀 | **최초 생존 → 보강 후 KILLED** | `test_saturation_collect_reads_everything` |
| E `main()` 이 로더를 우회 | **KILLED** | `test_saturation_main_delegates_to_the_sealed_loader` |

**D 의 정체**: 봉인 테스트가 `_fetch_all` **헬퍼만** 직접 호출하고 프로덕션 결선부인 `main()` 을
지나가지 않았다 — 헬퍼는 봉인, **호출부는 미봉인**. QA 가 감싸지 않고 그대로 보고해 잡혔다.
→ `_load_active_keywords(sb)` 추출 + 결선부 봉인 테스트로 보강.
★ **팀장이 독립 재현**: 변이 재적용(md5 `9e31eea4`→`a2d6508b`, no-op 아님을 assert 로 선증명)
→ `1 failed, 70 passed` 로 격추 확인 → 복원 후 md5 `9e31eea4` 바이트 동일·트리 clean.

### 범위·후퇴·자격증명
- 변경 파일 5개 **전부 허용 경로 내**. `alert_notify.py`·`daily_trend_collect.py`·`main.py`·`daily_surge_detect.py` **전부 미변경**
- 상수 원본 유지: `TREND_MIN=1_800` `SATURATION_MIN=1_800` `RANKING_MIN=1` `FAIL_MAX=50`
- t3019 KST 창 함수 4종 **md5 바이트 동일**
- 기존 테스트 삭제·skip **0건** (개명 1건뿐, 로직 보존)
- 실제 키 값(`eyJ...`) 커밋 유입 **0건**

## ★ ANU 판단 필요 — 범위 밖 동종 결함
- `daily_trend_collect.py` L415-436 의 **기존 페이지네이션 루프에 `.order()` 가 없다**
  → 이론상 offset 페이지 간 중복·누락 위험. 수정 금지 파일이라 미수정·미측정
- `keyword_rankings` **2026-08-25 rank_date 0건** — 어제 랭킹 배치 결번. 원인 미조사(별개 결함)
- 무페이지네이션 86지점 중 **81지점이 범위 밖**으로 남아 있음 (위 census 절 참조)

## PR
- **#260** https://github.com/Jeon-Jonghyuk/InsuRo/pull/260 (draft, base `main`)
- `auto_merge: null` — **자동 머지 미설정. 머지는 ANU 담당.**

## 세션 통계
- 총 도구 호출: 0회

