fix(parse-image): #239 얇은 검출 박스가 이미지 OCR 전체를 날리던 문제 #240

Merged
altair823 merged 5 commits from fix/paddle-onnx-rec-min-width into main 2026-08-29 01:52:14 +00:00
Owner

Closes #239.

요약

OnnxPaddleOcr::run_rec 이 크롭을 높이 48 로 리사이즈하면서 폭을 1 이상으로만 보장했다. PP-OCRv5 rec 백본은 T = ceil((w-4)/8) 개의 CTC 타임스텝을 내므로 w <= 4 에서 특징맵이 0 열로 접히고, ORT 가 세션 실행 전체를 실패시킨다. 그 오류가 recognize 의 ? 를 타고 나가면서 이미 인식해 둔 나머지 박스까지 전부 버려졌다. 바로 아래 줄(if text.is_empty() { continue; })이 "박스 하나가 비면 나머지는 살린다"는 의도를 담고 있는데 오류 경로에만 그 방어가 없었다.

색인 자체는 성공으로 끝나므로 조용한 손실이었다 — 이미지는 본문에 파일명만 남고, 스캔 PDF 페이지는 청크가 0 이 된다.

REC_MIN_WIDTH = 5 미만이면 세션에 넣지 않고 빈 문자열로 돌려보낸다.

이슈가 제안한 16 대신 5 를 쓴 이유

번들된 korean_ppocrv5_mobile_rec.onnx 를 직접 스윕했다. 서로 다른 경로로 세 번 쟀고 같은 표가 나왔다.

rec 입력 폭 (높이 48) 결과
1 ~ 4 전부 실패 — Invalid input shape: {1,0}
5 ~ 48 전부 성공

경계는 딱 떨어지고 픽셀 내용과 무관하다(흰 이미지·체커보드·글자 렌더 동일). T = ceil((w-4)/8) 이 w <= 4 에서 0 이 되고 오류의 {1,0} 이 그 0 이다. 잰 44 개 폭 전부가 이 식에 맞았다.

16 을 써도 잃는 건 사실상 없다(폭 40 대조군은 "1" 0.97 / "3" 0.999 를 읽는데 516 구간은 잉크가 있어도 전부 빈 문자열이었다). 다만 5 가 그래프의 진짜 하한이라 상수의 이름과 주석이 거짓이 되지 않고, 지금 정상 동작하는 폭 515 구간을 새로 버리지도 않는다.

검사는 crop.width() 가 아니라 리사이즈 이후의 new_w 에 건다. 3×200 크롭은 폭이 3 인데 new_w 가 1 이고, 3×10 크롭은 같은 폭 3 인데 new_w 가 14 다.

parser_version cascade — 비용을 정확히

코드만 고치면 이미 색인된 문서에 닿지 않는다. 이미지 실효 parser_version 은 image-meta-v1|chunk:…|ocr:1:paddle-onnx:<모델 blake3> 인데 이미지 파일도 모델 에셋도 안 바뀌었으니 서명이 같고 try_skip_unchanged 가 건너뛴다. #232 의 원칙 그대로다.

  • image-meta-v1 → image-meta-v2
  • pdf-text-v2 → pdf-text-v3 (스캔 PDF 도 같은 run_rec 을 탄다)

비싼 OCR 은 대부분 다시 안 돈다. 실패한 OCR 은 derivation_cache 에 저장되지 않으므로(Err 분기가 derivation_cache_put 앞에서 빠져나간다), 이미 성공했던 문서는 소스-바이트 키로 캐시에 히트해 엔진 호출을 건너뛴다. 실제로 다시 OCR 되는 건 이 버그로 실패했던 문서뿐이다.

하지만 나머지 비용은 전부 든다. id_for_doc 이 접는 건 composite 가 아니라 base PARSER_VERSION 이라, 이 bump 는 모든 이미지·PDF 문서의 doc_id 를 바꾼다. 그러면 purge_workspace_path_for_parser_bump 가 돌아 documents 행이 지워지고(blocks·chunks·embedding_records CASCADE) Lance 벡터도 전량 삭제된 뒤 재파싱·재청킹·재임베딩·재삽입이 이어진다. 그리고 이 비용은 버그가 닿지 않는 KB 에도 걸린다 — 기본 엔진은 ollama-vision, 이미지 OCR 기본 off, PdfOcrCfg::defaults() 도 enabled: false, always_on: false 다.

그래도 base 를 올리는 쪽을 택했다. 대안은 ingest_config_signature 의 ocr_engine_version_for_sig 에 paddle-onnx 전용 revision 토큰을 넣어 영향 문서만 무효화하는 것이고, 그러면 doc_id 도 유지된다. 더 정확하지만 #232 가 세운 선례 옆에 두 번째 무효화 경로를 만드는 일이고, 이 저장소는 단일 사용자용이라 손해 보는 KB 가 사용자 자신의 것 하나다. 다중 사용자 배포로 가면 다시 볼 결정이고, HOTFIXES 에 그렇게 적어 뒀다.

곁다리로 고친 것: PDF OCR 실패 기록이 원인을 잘라먹고 있었다

PDF 경로가 provenance 노트를 err={} 로 찍었는데 anyhow 의 기본 Display 는 가장 바깥 context 하나만 낸다. 그래서 저장된 문자열이 err=rec session run 에서 끝나고 ORT 원인이 사라졌다 — 이슈 진단에 쓰는 provenance_json LIKE '%Invalid input shape%' 가 스캔 PDF 를 한 건도 못 찾는 이유다. 이미지 경로는 처음부터 {err:#} 였다. {e:#} 로 맞춰 두 경로의 형식을 통일했다.

이 수정 이전에 나간 모든 릴리스가(v0.33.0 포함) 잘린 노트를 썼다. 하필 스캔 PDF 페이지 렌더링(#232) 자체가 v0.33.0 에서 처음 나갔으므로, 이 버그가 망칠 수 있었던 스캔본은 사실상 전부 그 한 릴리스가 만든 기록이다. 기존 KB 를 셀 때는 OR provenance_json LIKE '%err=rec session run%' 을 함께 걸어야 한다.

고치지 않고 남긴 것

recognize 의 박스 루프 안 self.run_rec(&crop)? 는 그대로 뒀다. 폭 가드가 알려진 유일한 방아쇠를 닫았고 T >= 1 이 w >= 5 에서 항상 성립한다. 박스 단위로 살리려면 그 ? 를 continue 로 바꿔야 하는데 그러면 클래스 수 검사까지 삼킨다. 다만 그게 ? 를 유지할 결정적 이유는 아니다 — 클래스 차원은 정적 그래프 메타데이터라 from_paths 로 옮기는 편이 낫고, 옮길 때는 continue 에 tracing::warn! 을 반드시 달아야 한다. #239 의 방아쇠와 무관한 별개 정리라 이번에는 안 했다.

검증

실측 — 도그푸딩 말뭉치 이미지 240 개 파일. 같은 엔진(ppocrv5-mobile-kor-1b55f062d055), 같은 설정(score 0.3 / unclip 1.5 / max_boxes 1000 / max_pixels 2048)으로 수정 전후 비교.

수정 전 수정 후
OCR 성공 204 / 240 240 / 240
OCR 실패 36 (15.0%) 0

실패 36 장은 charts 8 · english-text 10 · korean-text 9 · photos 9 로 특정 종류에 몰려 있지 않았다. 되살아난 본문 합계 11,747 자(중앙값 37, 최대 3,950). charts/Clickpath_Analysis.png 은 얇은 조각 하나 때문에 이미 인식된 87 개 영역을 통째로 잃고 있었다(수정 후 1,116 자).

부작용 없음: 원래 성공하던 204 장의 인식 글자 수가 한 장도 변하지 않았다.

회귀 테스트 — rec_min_width_is_the_graph_floor 가 상수를 위아래로 가둔다.

  • 아래쪽(테스트): 1..REC_MIN_WIDTH 는 세션에 닿지 않아야 하고 → 가드를 지우면 실패. 정확히 REC_MIN_WIDTH 는 진짜 세션을 통과해야 한다 → 상수가 모델의 실제 하한보다 낮으면 실패.
  • 위쪽(컴파일): 상수 옆의 const _: () = assert!(REC_MIN_WIDTH <= 16, …) → 100 으로 올리면 error[E0080] 로 빌드가 안 된다.

초안은 아래쪽만 있었고, 그래서 상수를 100 으로 바꿔도 통과했다 — 두 단언 모두 상수가 커질수록 더 잘 통과하기 때문이다. 그 상태면 폭 100 미만 크롭, 즉 글자 한두 개짜리 박스가 전부 조용히 버려진다.

테스트는 ModelPaths::from_default_dir() 대신 CARGO_MANIFEST_DIR 에서 경로를 만든다. 전자는 KEBAB_IMAGE_OCR_MODEL_DIR 을 타므로 상수를 엉뚱한 모델에 대고 재게 된다. 모델 에셋이 in-tree 라 skip 가드는 없다.

{e:#} 노트도 ocr_engine_failure_surfaces_as_warning 이 고정한다. 원래 mock 이 단층 오류를 내서 {} 로 되돌려도 통과했다 — anyhow 는 원인 없는 오류를 두 형식에서 똑같이 찍는다. mock 을 실제와 같은 두 층 오류로 바꾸고 안쪽 원인까지 단언하게 했다.

스냅샷 — pdf-text-v3 가 vector_pdf_canonical.json 을 움직인다. #232 때와 같은 형태로 파생 식별자와 버전 문자열만 바뀌고 본문·inlines·source_span·metadata 는 동일함을 diff 로 확인했다.

전체 — cargo test --workspace --no-fail-fast 1301 passed / 0 failed / 64 ignored, cargo clippy --workspace --all-targets -- -D warnings 경고 없음. (cargo fmt --check 는 main 에도 275 곳이 어긋나 있고 CI 게이트가 아니라 손대지 않았다. 이 PR 이 새로 만든 어긋남은 0 곳.)

리뷰 이력

회차 1 · 회차 2 모두 여러 관점으로 훑고 각 지적을 적대적으로 재검증한 뒤 반영했다. 회차 1 은 19 건, 회차 2 는 16 건이 검증을 통과했고 코멘트로 남겨 뒀다. 회차 2 가 잡은 것 중 무거운 것: DOGFOOD 에 새로 넣은 설정 스니펫이 schema_version = 5 에서 조용히 무시되는 옛 키([image.ocr])라 시나리오가 no-op 이 될 뻔했고, {e:#} 를 고정하는 테스트가 없었으며, 상수 주석이 여전히 테스트가 위쪽 방향까지 잡는다고 말하고 있었다.

후속

parser_version cascade 는 CLAUDE.md §Release 의 minor bump 트리거다(#232 선례). 릴리스 컷은 별도 커밋에서.

Closes #239. ## 요약 `OnnxPaddleOcr::run_rec` 이 크롭을 높이 48 로 리사이즈하면서 폭을 1 이상으로만 보장했다. PP-OCRv5 rec 백본은 `T = ceil((w-4)/8)` 개의 CTC 타임스텝을 내므로 `w <= 4` 에서 특징맵이 0 열로 접히고, ORT 가 세션 실행 전체를 실패시킨다. 그 오류가 `recognize` 의 `?` 를 타고 나가면서 **이미 인식해 둔 나머지 박스까지 전부 버려졌다.** 바로 아래 줄(`if text.is_empty() { continue; }`)이 "박스 하나가 비면 나머지는 살린다"는 의도를 담고 있는데 오류 경로에만 그 방어가 없었다. 색인 자체는 성공으로 끝나므로 조용한 손실이었다 — 이미지는 본문에 파일명만 남고, 스캔 PDF 페이지는 청크가 0 이 된다. **`REC_MIN_WIDTH = 5` 미만이면 세션에 넣지 않고 빈 문자열로 돌려보낸다.** ### 이슈가 제안한 16 대신 5 를 쓴 이유 번들된 `korean_ppocrv5_mobile_rec.onnx` 를 직접 스윕했다. 서로 다른 경로로 세 번 쟀고 같은 표가 나왔다. | rec 입력 폭 (높이 48) | 결과 | |---|---| | 1 ~ 4 | 전부 실패 — `Invalid input shape: {1,0}` | | 5 ~ 48 | 전부 성공 | 경계는 딱 떨어지고 픽셀 내용과 무관하다(흰 이미지·체커보드·글자 렌더 동일). `T = ceil((w-4)/8)` 이 `w <= 4` 에서 0 이 되고 오류의 `{1,0}` 이 그 0 이다. 잰 44 개 폭 전부가 이 식에 맞았다. 16 을 써도 잃는 건 사실상 없다(폭 40 대조군은 `"1"` 0.97 / `"3"` 0.999 를 읽는데 5~16 구간은 잉크가 있어도 전부 빈 문자열이었다). 다만 5 가 그래프의 진짜 하한이라 상수의 이름과 주석이 거짓이 되지 않고, 지금 정상 동작하는 폭 5~15 구간을 새로 버리지도 않는다. 검사는 `crop.width()` 가 아니라 리사이즈 **이후**의 `new_w` 에 건다. 3×200 크롭은 폭이 3 인데 `new_w` 가 1 이고, 3×10 크롭은 같은 폭 3 인데 `new_w` 가 14 다. ### parser_version cascade — 비용을 정확히 코드만 고치면 이미 색인된 문서에 닿지 않는다. 이미지 실효 `parser_version` 은 `image-meta-v1|chunk:…|ocr:1:paddle-onnx:<모델 blake3>` 인데 이미지 파일도 모델 에셋도 안 바뀌었으니 서명이 같고 `try_skip_unchanged` 가 건너뛴다. #232 의 원칙 그대로다. - `image-meta-v1` → `image-meta-v2` - `pdf-text-v2` → `pdf-text-v3` (스캔 PDF 도 같은 `run_rec` 을 탄다) **비싼 OCR 은 대부분 다시 안 돈다.** 실패한 OCR 은 `derivation_cache` 에 저장되지 않으므로(`Err` 분기가 `derivation_cache_put` 앞에서 빠져나간다), 이미 성공했던 문서는 소스-바이트 키로 캐시에 히트해 엔진 호출을 건너뛴다. 실제로 다시 OCR 되는 건 이 버그로 실패했던 문서뿐이다. **하지만 나머지 비용은 전부 든다.** `id_for_doc` 이 접는 건 composite 가 아니라 **base** PARSER_VERSION 이라, 이 bump 는 모든 이미지·PDF 문서의 **doc_id 를 바꾼다**. 그러면 `purge_workspace_path_for_parser_bump` 가 돌아 documents 행이 지워지고(blocks·chunks·embedding_records CASCADE) Lance 벡터도 전량 삭제된 뒤 재파싱·재청킹·재임베딩·재삽입이 이어진다. 그리고 이 비용은 **버그가 닿지 않는 KB 에도** 걸린다 — 기본 엔진은 `ollama-vision`, 이미지 OCR 기본 off, `PdfOcrCfg::defaults()` 도 `enabled: false, always_on: false` 다. **그래도 base 를 올리는 쪽을 택했다.** 대안은 `ingest_config_signature` 의 `ocr_engine_version_for_sig` 에 paddle-onnx 전용 revision 토큰을 넣어 영향 문서만 무효화하는 것이고, 그러면 doc_id 도 유지된다. 더 정확하지만 #232 가 세운 선례 옆에 두 번째 무효화 경로를 만드는 일이고, 이 저장소는 단일 사용자용이라 손해 보는 KB 가 사용자 자신의 것 하나다. 다중 사용자 배포로 가면 다시 볼 결정이고, HOTFIXES 에 그렇게 적어 뒀다. ### 곁다리로 고친 것: PDF OCR 실패 기록이 원인을 잘라먹고 있었다 PDF 경로가 provenance 노트를 `err={}` 로 찍었는데 anyhow 의 기본 Display 는 가장 바깥 context 하나만 낸다. 그래서 저장된 문자열이 `err=rec session run` 에서 끝나고 ORT 원인이 사라졌다 — 이슈 진단에 쓰는 `provenance_json LIKE '%Invalid input shape%'` 가 스캔 PDF 를 **한 건도** 못 찾는 이유다. 이미지 경로는 처음부터 `{err:#}` 였다. `{e:#}` 로 맞춰 두 경로의 형식을 통일했다. 이 수정 이전에 나간 모든 릴리스가(v0.33.0 포함) 잘린 노트를 썼다. 하필 스캔 PDF 페이지 렌더링(#232) 자체가 v0.33.0 에서 처음 나갔으므로, 이 버그가 망칠 수 있었던 스캔본은 사실상 전부 그 한 릴리스가 만든 기록이다. 기존 KB 를 셀 때는 `OR provenance_json LIKE '%err=rec session run%'` 을 함께 걸어야 한다. ### 고치지 않고 남긴 것 `recognize` 의 박스 루프 안 `self.run_rec(&crop)?` 는 그대로 뒀다. 폭 가드가 알려진 유일한 방아쇠를 닫았고 `T >= 1` 이 `w >= 5` 에서 항상 성립한다. 박스 단위로 살리려면 그 `?` 를 `continue` 로 바꿔야 하는데 그러면 클래스 수 검사까지 삼킨다. 다만 그게 `?` 를 유지할 결정적 이유는 아니다 — 클래스 차원은 정적 그래프 메타데이터라 `from_paths` 로 옮기는 편이 낫고, 옮길 때는 `continue` 에 `tracing::warn!` 을 반드시 달아야 한다. #239 의 방아쇠와 무관한 별개 정리라 이번에는 안 했다. ## 검증 **실측 — 도그푸딩 말뭉치 이미지 240 개 파일.** 같은 엔진(`ppocrv5-mobile-kor-1b55f062d055`), 같은 설정(score 0.3 / unclip 1.5 / max_boxes 1000 / max_pixels 2048)으로 수정 전후 비교. | | 수정 전 | 수정 후 | |---|---|---| | OCR 성공 | 204 / 240 | **240 / 240** | | OCR 실패 | 36 (15.0%) | **0** | 실패 36 장은 charts 8 · english-text 10 · korean-text 9 · photos 9 로 특정 종류에 몰려 있지 않았다. 되살아난 본문 합계 11,747 자(중앙값 37, 최대 3,950). `charts/Clickpath_Analysis.png` 은 얇은 조각 하나 때문에 **이미 인식된 87 개 영역**을 통째로 잃고 있었다(수정 후 1,116 자). **부작용 없음**: 원래 성공하던 204 장의 인식 글자 수가 한 장도 변하지 않았다. **회귀 테스트 — `rec_min_width_is_the_graph_floor` 가 상수를 위아래로 가둔다.** - 아래쪽(테스트): `1..REC_MIN_WIDTH` 는 세션에 닿지 않아야 하고 → **가드를 지우면 실패**. 정확히 `REC_MIN_WIDTH` 는 진짜 세션을 통과해야 한다 → 상수가 모델의 실제 하한보다 낮으면 실패. - 위쪽(컴파일): 상수 옆의 `const _: () = assert!(REC_MIN_WIDTH <= 16, …)` → 100 으로 올리면 `error[E0080]` 로 **빌드가 안 된다**. 초안은 아래쪽만 있었고, 그래서 상수를 100 으로 바꿔도 통과했다 — 두 단언 모두 상수가 커질수록 더 잘 통과하기 때문이다. 그 상태면 폭 100 미만 크롭, 즉 글자 한두 개짜리 박스가 전부 조용히 버려진다. 테스트는 `ModelPaths::from_default_dir()` 대신 `CARGO_MANIFEST_DIR` 에서 경로를 만든다. 전자는 `KEBAB_IMAGE_OCR_MODEL_DIR` 을 타므로 상수를 엉뚱한 모델에 대고 재게 된다. 모델 에셋이 in-tree 라 skip 가드는 없다. `{e:#}` 노트도 `ocr_engine_failure_surfaces_as_warning` 이 고정한다. 원래 mock 이 단층 오류를 내서 `{}` 로 되돌려도 통과했다 — anyhow 는 원인 없는 오류를 두 형식에서 똑같이 찍는다. mock 을 실제와 같은 두 층 오류로 바꾸고 안쪽 원인까지 단언하게 했다. **스냅샷** — `pdf-text-v3` 가 `vector_pdf_canonical.json` 을 움직인다. #232 때와 같은 형태로 파생 식별자와 버전 문자열만 바뀌고 본문·inlines·`source_span`·metadata 는 동일함을 diff 로 확인했다. **전체** — `cargo test --workspace --no-fail-fast` 1301 passed / 0 failed / 64 ignored, `cargo clippy --workspace --all-targets -- -D warnings` 경고 없음. (`cargo fmt --check` 는 main 에도 275 곳이 어긋나 있고 CI 게이트가 아니라 손대지 않았다. 이 PR 이 새로 만든 어긋남은 0 곳.) ### 리뷰 이력 회차 1 · 회차 2 모두 여러 관점으로 훑고 각 지적을 적대적으로 재검증한 뒤 반영했다. 회차 1 은 19 건, 회차 2 는 16 건이 검증을 통과했고 코멘트로 남겨 뒀다. 회차 2 가 잡은 것 중 무거운 것: DOGFOOD 에 새로 넣은 설정 스니펫이 `schema_version = 5` 에서 조용히 무시되는 옛 키(`[image.ocr]`)라 시나리오가 no-op 이 될 뻔했고, `{e:#}` 를 고정하는 테스트가 없었으며, 상수 주석이 여전히 테스트가 위쪽 방향까지 잡는다고 말하고 있었다. ### 후속 `parser_version` cascade 는 CLAUDE.md §Release 의 minor bump 트리거다(#232 선례). 릴리스 컷은 별도 커밋에서.
altair823 added 1 commit 2026-08-28 07:56:40 +00:00
`run_rec` 이 크롭을 높이 48 로 리사이즈하면서 폭을 1 이상으로만 보장했다.
PP-OCRv5 rec 백본은 `T = ceil((w-4)/8)` 개의 CTC 타임스텝을 내므로 `w <= 4`
에서 특징맵이 0 열로 접히고, ORT 가 세션 실행 전체를 실패시킨다. 그 오류가
`recognize` 의 `?` 를 타고 나가면서 이미 인식해 둔 나머지 박스까지 전부
버려졌다 — 색인은 성공으로 끝나므로 검색이 0 건 나올 때까지 안 드러나는
조용한 손실이었다.

`REC_MIN_WIDTH = 5` 미만이면 세션에 넣지 않고 빈 문자열로 돌려보낸다. 바로
아래 `text.is_empty()` 분기가 그 박스만 버리고 나머지를 살린다.

이슈는 16 을 제안했지만 번들 모델을 직접 스윕하니 실제 하한은 4 였다(1~4
전부 실패, 5~48 전부 성공, 픽셀 내용과 무관하게 경계가 딱 떨어짐). 16 을
써도 잃는 건 사실상 없지만(5~15 구간은 잉크가 있어도 빈 문자열을 낸다),
5 가 그래프의 진짜 하한이라 상수 이름과 주석이 거짓이 되지 않는다.

회귀 테스트는 양방향을 고정한다. `1..REC_MIN_WIDTH` 는 세션에 닿지 않아야
하고(가드를 지우면 실패), 정확히 `REC_MIN_WIDTH` 는 진짜 세션을 통과해야
한다(상수가 모델의 실제 하한보다 낮으면 실패). 모델 에셋이 in-tree 라
skip 가드는 붙이지 않았다.

parser_version cascade: 코드만 고치면 이미 색인된 문서에 닿지 않는다.
`image-meta-v1` → `image-meta-v2`, `pdf-text-v2` → `pdf-text-v3` (스캔 PDF
도 같은 `run_rec` 을 탄다). 실패한 OCR 은 derivation_cache 에 저장되지
않으므로, 다음 ingest 는 전량 재추출하되 이미 성공한 것은 캐시 히트로
엔진을 건너뛰고 이 버그로 실패했던 문서만 실제로 다시 OCR 된다.

실측 (도그푸딩 말뭉치 이미지 240 장, 같은 엔진·같은 설정):
- OCR 실패 36/240 (15.0%) → 0/240
- 되살아난 본문 11,747 자 (최대 3,950 자, `Clickpath_Analysis.png` 은 얇은
  조각 하나 때문에 이미 인식된 87 개 영역을 통째로 잃고 있었다)
- 원래 성공하던 204 장은 인식 글자 수 변화 0 — 순수 가산

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Author
Owner

회차 1 리뷰 — REQUEST_CHANGES 상당

리뷰어 토큰(작성자와 다른 계정)이 없어 정식 review 로 등록하지 못하고 코멘트로 남긴다. 판정은 REQUEST_CHANGES 상당이다.

5개 렌즈(정확성 / 버전 캐스케이드 / 테스트 / 문서 / 자잘한 지적)로 훑고, 나온 지적을 각각 적대적으로 재검증해 살아남은 것만 적는다. 32건 중 19건이 남았다.

병합을 막는 결함은 없다. 폭 하한은 독립 재측정으로 맞았고(T = ceil((w-4)/8) 이 실측 44개 폭 전부에 맞음, 5~48 전부 성공), 240장 수치 3건도 재현됐다(3950 / 1116자·87영역 / 1088자). 가드 위치(crop.width() 아닌 new_w), PDF 경로 커버, "실패한 OCR 은 캐시에 안 들어간다" 주장(양쪽 경로 모두 성립), 스냅샷 재생성, cargo fmt --check 도 문제 없었다.


무거운 것 둘

1. 테스트가 한 방향만 고정한다 — crates/kebab-parse-image/src/paddle_onnx.rs:60, 1041

REC_MIN_WIDTH 를 100 으로 올려도 테스트는 green 이다(대조: 1 로 낮추면 Invalid input shape: {1,0} 로 실패). 1..REC_MIN_WIDTH 루프는 가드가 먹으므로 상수를 올릴수록 통과 범위만 넓어지고, run_rec(crop(REC_MIN_WIDTH)) 도 폭이 클수록 세션이 더 잘 돈다. 상수가 100 이면 높이 48 기준 폭 100 미만 크롭 — 글자 한두 개짜리 박스 전부 — 이 조용히 버려지는데, 이 PR 이 고치는 것과 정확히 같은 종류의 손실이다.

:60 의 "rec_min_width_is_the_graph_floor pins both sides of that boundary" 는 사실이 아니다. REC_MIN_WIDTH - 1 을 실제 세션에 넣어 보는 건 가드가 같은 함수 안에 있어 값싸지 않으니, HOTFIXES 가 이미 기록한 실측("5~16 은 잉크가 있어도 빈 문자열")을 근거로 위쪽 상한을 한 줄 걸고 주석·테스트 이름을 실제 고정 범위에 맞출 것.

2. 캐스케이드 비용 분석이 절반이다 — tasks/HOTFIXES.md:81

id_for_doc 이 base PARSER_VERSION 을 접으므로(kebab-parse-image/src/lib.rs:109, kebab-parse-pdf/src/lib.rs:99 — composite 는 그 뒤에 canonical.parser_version 에만 찍힌다) 이 bump 는 모든 이미지·PDF 문서의 doc_id 를 바꾼다. 그러면 ingest.rs:996 의 purge_workspace_path_for_parser_bump 가 돌아 documents 행 삭제(→ blocks/chunks/embedding_records CASCADE) + 해당 chunk_id 의 Lance 벡터 전량 삭제 후 재파싱·재청킹·재임베딩·재삽입이다. doc_id 는 wire 필수 필드이자 kebab inspect doc <id> 핸들이다.

그리고 이 비용은 버그가 닿지 않는 KB 에도 걸린다 — 기본 엔진은 ollama-vision(kebab-config/src/lib.rs:606), 이미지 OCR 기본 off, PdfOcrCfg::defaults() 도 enabled: false, always_on: false.

둘 중 하나로 명시할 것. (a) bump 를 유지하고 이 문단에 빠진 비용을 적는다. (b) ingest_config_signature 의 ocr_engine_version_for_sig(ingest.rs:3669) 에 paddle-onnx 전용 revision 토큰을 넣어 영향 문서만 무효화한다 — doc_id 유지, ollama-vision·OCR off KB 무영향, OCR 캐시 히트도 그대로.


나머지

3. crates/kebab-app/src/ingest.rs 주석 4곳이 옛 버전을 현재형으로 말한다 — :1632 / :1679 image-meta-v1, :2544 / :2581 pdf-text-v2. #232(2871da1)가 자기 bump 때 PDF 두 줄을 정확히 이렇게 갱신한 선례가 있고(v0.26.2: 접두사가 붙은 :2581 포함), 이 PR 은 문서와 테스트를 다 쓸었으므로 이 4곳만 남았다. HOTFIXES·release-notes·DOGFOOD:572 의 옛 문자열은 날짜 기록이라 건드리지 말 것.

4. crates/kebab-chunk/src/pdf_page_v1.rs:403 이 ParserVersion("pdf-text-v2") 를 하드코딩한다. 아무것도 단언하지 않고 id_for_doc 입력으로만 쓰여 테스트는 통과하지만, kebab-chunk 는 §8 경계상 kebab-parse-pdf 를 import 할 수 없어 실제 버전 문자열을 넣으면 다음 bump 마다 또 상한다. "test-parser-v1" 같은 버전 중립 리터럴로 바꿀 것.

5. HOTFIXES 가 권한 진단 SQL 이 PDF 피해 문서를 하나도 못 찾는다 — tasks/HOTFIXES.md:72. PDF 경로는 노트를 err={} 로 찍는데(pdf_ocr_apply.rs:449-455) anyhow 의 기본 Display 는 가장 바깥 context 하나만 낸다. 저장되는 문자열이 err=rec session run 에서 끝나고 Invalid input shape 가 안 들어간다. 이미지 경로는 {err:#} 라(ingest.rs:2090) 걸린다. PDF 쪽을 {e:#} 로 맞춰 두 경로 노트 형식을 통일하는 편이 안내 문구를 고치는 것보다 낫다.

6. paddle_onnx.rs:299 는 이 PR 적용 전 줄 번호 — tasks/HOTFIXES.md:98. 이 커밋이 :51-58 에 상수 8줄을 넣어 실제 위치는 307 이고, 299 는 지금 박스 정렬 클로저 꼬리(});)다. HOTFIXES 는 머지 후 현재 진실이라 머지 시점에 이미 틀린 참조가 들어가면 안 된다. (같은 항목의 ingest.rs:1750·pdf_ocr_apply.rs:475 는 정확하다.)

7. "클래스 수 검사가 fatal 이라 ? 를 유지한다" 는 논거가 약하다 — tasks/HOTFIXES.md:98. 클래스 차원은 정적 그래프 메타데이터다(번들 rec 출력 shape [-1, -1, 11947], Session::outputs[0].output_type.tensor_dimensions() 로 로드 시점에 읽힘). 그러니 그 검사는 from_paths 의 dict.len() != DICT_LINES bail(:173-179) 옆으로 옮길 수 있고, 그러면 잘못된 모델은 박스가 검출되는 이미지를 기다릴 것 없이 엔진 생성에서 즉시 터진다. 병합 차단은 아니고 후속으로 충분하지만 문단의 논거는 지금 형태로는 약하다. 옮긴다면 :307 의 ? 를 continue 로 바꿀 때 tracing::warn! 을 반드시 함께 달 것 — 맨 continue 는 이 PR 이 없애려는 조용한 손실을 다른 자리로 옮기는 셈이다.

8. 테스트가 ModelPaths::from_default_dir() 을 쓴다 — paddle_onnx.rs:1034. 이건 KEBAB_IMAGE_OCR_MODEL_DIR env override 를 탄다(:113-115). 이 테스트의 목적은 상수를 번들된 모델에 붙들어 두는 것인데, 사용자가 자기 모델 디렉토리를 export 해 둔 셸에서 cargo test 를 돌리면 엉뚱하게 터지고 메시지가 원인을 못 가리킨다. tests/paddle_e2e.rs 는 같은 조건에서 SKIP 으로 빠지므로 이 테스트만 노출돼 있다.

9. DOGFOOD 에 남긴 변경이 pdf-text-v3 문자열 하나뿐이다. 부록 A 가 "HOTFIXES 의 deviation 발견 시 관련 section 의 verify step 강화"를 요구하는데 87줄짜리 deviation 을 추가하면서 §1.2 Image ingest 에는 아무것도 안 했다. #232 는 같은 상황에서 §1.4 에 config 블록 + 이슈 번호 붙인 verify 항목 + 신규 시나리오 1.4.d 를 10줄 추가했다. paddle-onnx 는 DOGFOOD.md 전체에 0번 등장하고, §13.4 Image corpus 는 아직 "(P6 dogfood — 향후 추가)" 한 줄이다. 이번 근거가 바로 그 코퍼스 스윕인데 카탈로그가 비어 있다.

장수를 적을 때 §1.2 의 ingest 대상(*.png/*.jpg/*.jpeg)만 세면 239 이고 240 은 대상 아닌 .tif 1장을 포함한 수다. HOTFIXES 의 "240 / 240" 과 어긋난 채 굳지 않도록 어느 쪽을 세는지 밝힐 것.

10. docs/components/parse/README.md 안에서 비대칭이 생겼다. PDF 불릿(:104)에는 #232·#239 사유를 다 적었는데 다이어그램의 Image 값만 image-meta-v2 로 올리고 Image 불릿(:107-112)에는 PARSER_VERSION 줄이 없다. 덧붙여 :110 의 "OllamaVisionOcr { … } — v1 유일 구현" 은 v0.27.0 의 OnnxPaddleOcr(paddle_onnx.rs:220 의 impl OcrEngine) 때문에 틀렸고 :37-43 다이어그램도 백엔드가 하나뿐이다. ARCHITECTURE.md:23 은 "2 백엔드"라고 적고 있다. 이 PR 이 만든 staleness 는 아니지만 바로 그 두 줄 사이를 건드렸다.

11. (§3.4, v0.31.0 #217) 는 따라갈 곳이 없는 참조 — tasks/HOTFIXES.md:81. 문서 이름이 없고, 남아 있는 설계 계약에서 §3.4 는 CanonicalDocument 다(그 번호가 가리키던 2026-05-31 스펙은 doc-reorg 에서 삭제됐다). 살아 있는 docs/ARCHITECTURE.md:35 를 가리키거나 절 번호를 뺄 것.

12. ### 스냅샷 낙수 — tasks/HOTFIXES.md:83. 낙수 는 저장소 전체에서 이 줄에만 있고 낙수 효과 아니면 落穗 로 읽힌다. 같은 항목 :81·:131 이 이미 캐스케이드 를 쓴다.

13. "서로 다른 조사 레인 + 검증 레인" — tasks/HOTFIXES.md:29. 저장소 독자가 알 수 없는 내부 용어이고 grep -rn "레인" --include=*.md . 결과 이 줄뿐이다. "서로 다른 경로로 세 번 재 봤고 같은 표가 나왔다" 로 풀 것.

14. 소제목과 본문의 숫자가 엇갈려 읽힌다 — tasks/HOTFIXES.md:27 "하한은 16 이 아니라 5" vs :29 "실제 한계는 4". 둘 다 맞지만(4 = 마지막 실패 폭, 5 = 첫 성공 폭) 같은 "16 을 제안했지만 → 실제 X" 구조를 X 만 바꿔 반복해서 4 가 상수 값으로 읽힌다.

## 회차 1 리뷰 — REQUEST_CHANGES 상당 > 리뷰어 토큰(작성자와 다른 계정)이 없어 정식 review 로 등록하지 못하고 코멘트로 남긴다. 판정은 **REQUEST_CHANGES** 상당이다. 5개 렌즈(정확성 / 버전 캐스케이드 / 테스트 / 문서 / 자잘한 지적)로 훑고, 나온 지적을 각각 적대적으로 재검증해 살아남은 것만 적는다. 32건 중 19건이 남았다. 병합을 막는 결함은 없다. 폭 하한은 독립 재측정으로 맞았고(`T = ceil((w-4)/8)` 이 실측 44개 폭 전부에 맞음, 5~48 전부 성공), 240장 수치 3건도 재현됐다(3950 / 1116자·87영역 / 1088자). 가드 위치(`crop.width()` 아닌 `new_w`), PDF 경로 커버, "실패한 OCR 은 캐시에 안 들어간다" 주장(양쪽 경로 모두 성립), 스냅샷 재생성, `cargo fmt --check` 도 문제 없었다. --- ### 무거운 것 둘 **1. 테스트가 한 방향만 고정한다** — `crates/kebab-parse-image/src/paddle_onnx.rs:60, 1041` `REC_MIN_WIDTH` 를 100 으로 올려도 테스트는 green 이다(대조: 1 로 낮추면 `Invalid input shape: {1,0}` 로 실패). `1..REC_MIN_WIDTH` 루프는 가드가 먹으므로 상수를 올릴수록 통과 범위만 넓어지고, `run_rec(crop(REC_MIN_WIDTH))` 도 폭이 클수록 세션이 더 잘 돈다. 상수가 100 이면 높이 48 기준 폭 100 미만 크롭 — 글자 한두 개짜리 박스 전부 — 이 조용히 버려지는데, 이 PR 이 고치는 것과 정확히 같은 종류의 손실이다. :60 의 "`rec_min_width_is_the_graph_floor` pins both sides of that boundary" 는 사실이 아니다. `REC_MIN_WIDTH - 1` 을 실제 세션에 넣어 보는 건 가드가 같은 함수 안에 있어 값싸지 않으니, HOTFIXES 가 이미 기록한 실측("5~16 은 잉크가 있어도 빈 문자열")을 근거로 위쪽 상한을 한 줄 걸고 주석·테스트 이름을 실제 고정 범위에 맞출 것. **2. 캐스케이드 비용 분석이 절반이다** — `tasks/HOTFIXES.md:81` `id_for_doc` 이 **base** PARSER_VERSION 을 접으므로(`kebab-parse-image/src/lib.rs:109`, `kebab-parse-pdf/src/lib.rs:99` — composite 는 그 뒤에 `canonical.parser_version` 에만 찍힌다) 이 bump 는 모든 이미지·PDF 문서의 **doc_id 를 바꾼다**. 그러면 `ingest.rs:996` 의 `purge_workspace_path_for_parser_bump` 가 돌아 documents 행 삭제(→ blocks/chunks/embedding_records CASCADE) + 해당 chunk_id 의 Lance 벡터 전량 삭제 후 재파싱·재청킹·재임베딩·재삽입이다. doc_id 는 wire 필수 필드이자 `kebab inspect doc <id>` 핸들이다. 그리고 이 비용은 **버그가 닿지 않는 KB 에도** 걸린다 — 기본 엔진은 `ollama-vision`(`kebab-config/src/lib.rs:606`), 이미지 OCR 기본 off, `PdfOcrCfg::defaults()` 도 `enabled: false, always_on: false`. 둘 중 하나로 명시할 것. (a) bump 를 유지하고 이 문단에 빠진 비용을 적는다. (b) `ingest_config_signature` 의 `ocr_engine_version_for_sig`(`ingest.rs:3669`) 에 paddle-onnx 전용 revision 토큰을 넣어 영향 문서만 무효화한다 — doc_id 유지, ollama-vision·OCR off KB 무영향, OCR 캐시 히트도 그대로. --- ### 나머지 **3. `crates/kebab-app/src/ingest.rs` 주석 4곳이 옛 버전을 현재형으로 말한다** — :1632 / :1679 `image-meta-v1`, :2544 / :2581 `pdf-text-v2`. #232(`2871da1`)가 자기 bump 때 PDF 두 줄을 정확히 이렇게 갱신한 선례가 있고(`v0.26.2:` 접두사가 붙은 :2581 포함), 이 PR 은 문서와 테스트를 다 쓸었으므로 이 4곳만 남았다. HOTFIXES·release-notes·DOGFOOD:572 의 옛 문자열은 날짜 기록이라 건드리지 말 것. **4. `crates/kebab-chunk/src/pdf_page_v1.rs:403`** 이 `ParserVersion("pdf-text-v2")` 를 하드코딩한다. 아무것도 단언하지 않고 `id_for_doc` 입력으로만 쓰여 테스트는 통과하지만, `kebab-chunk` 는 §8 경계상 `kebab-parse-pdf` 를 import 할 수 없어 실제 버전 문자열을 넣으면 다음 bump 마다 또 상한다. `"test-parser-v1"` 같은 버전 중립 리터럴로 바꿀 것. **5. HOTFIXES 가 권한 진단 SQL 이 PDF 피해 문서를 하나도 못 찾는다** — `tasks/HOTFIXES.md:72`. PDF 경로는 노트를 `err={}` 로 찍는데(`pdf_ocr_apply.rs:449-455`) anyhow 의 기본 Display 는 가장 바깥 context 하나만 낸다. 저장되는 문자열이 `err=rec session run` 에서 끝나고 `Invalid input shape` 가 안 들어간다. 이미지 경로는 `{err:#}` 라(`ingest.rs:2090`) 걸린다. PDF 쪽을 `{e:#}` 로 맞춰 두 경로 노트 형식을 통일하는 편이 안내 문구를 고치는 것보다 낫다. **6. `paddle_onnx.rs:299` 는 이 PR 적용 전 줄 번호** — `tasks/HOTFIXES.md:98`. 이 커밋이 :51-58 에 상수 8줄을 넣어 실제 위치는 **307** 이고, 299 는 지금 박스 정렬 클로저 꼬리(`});`)다. HOTFIXES 는 머지 후 현재 진실이라 머지 시점에 이미 틀린 참조가 들어가면 안 된다. (같은 항목의 `ingest.rs:1750`·`pdf_ocr_apply.rs:475` 는 정확하다.) **7. "클래스 수 검사가 fatal 이라 `?` 를 유지한다" 는 논거가 약하다** — `tasks/HOTFIXES.md:98`. 클래스 차원은 정적 그래프 메타데이터다(번들 rec 출력 shape `[-1, -1, 11947]`, `Session::outputs[0].output_type.tensor_dimensions()` 로 로드 시점에 읽힘). 그러니 그 검사는 `from_paths` 의 `dict.len() != DICT_LINES` bail(:173-179) 옆으로 옮길 수 있고, 그러면 잘못된 모델은 박스가 검출되는 이미지를 기다릴 것 없이 엔진 생성에서 즉시 터진다. 병합 차단은 아니고 후속으로 충분하지만 문단의 논거는 지금 형태로는 약하다. 옮긴다면 :307 의 `?` 를 `continue` 로 바꿀 때 `tracing::warn!` 을 반드시 함께 달 것 — 맨 `continue` 는 이 PR 이 없애려는 조용한 손실을 다른 자리로 옮기는 셈이다. **8. 테스트가 `ModelPaths::from_default_dir()` 을 쓴다** — `paddle_onnx.rs:1034`. 이건 `KEBAB_IMAGE_OCR_MODEL_DIR` env override 를 탄다(:113-115). 이 테스트의 목적은 상수를 **번들된** 모델에 붙들어 두는 것인데, 사용자가 자기 모델 디렉토리를 export 해 둔 셸에서 `cargo test` 를 돌리면 엉뚱하게 터지고 메시지가 원인을 못 가리킨다. `tests/paddle_e2e.rs` 는 같은 조건에서 SKIP 으로 빠지므로 이 테스트만 노출돼 있다. **9. DOGFOOD 에 남긴 변경이 `pdf-text-v3` 문자열 하나뿐이다.** 부록 A 가 "HOTFIXES 의 deviation 발견 시 관련 section 의 verify step 강화"를 요구하는데 87줄짜리 deviation 을 추가하면서 §1.2 Image ingest 에는 아무것도 안 했다. #232 는 같은 상황에서 §1.4 에 config 블록 + 이슈 번호 붙인 verify 항목 + 신규 시나리오 1.4.d 를 10줄 추가했다. `paddle-onnx` 는 DOGFOOD.md 전체에 0번 등장하고, §13.4 Image corpus 는 아직 "(P6 dogfood — 향후 추가)" 한 줄이다. 이번 근거가 바로 그 코퍼스 스윕인데 카탈로그가 비어 있다. > 장수를 적을 때 §1.2 의 ingest 대상(`*.png`/`*.jpg`/`*.jpeg`)만 세면 **239** 이고 240 은 대상 아닌 `.tif` 1장을 포함한 수다. HOTFIXES 의 "240 / 240" 과 어긋난 채 굳지 않도록 어느 쪽을 세는지 밝힐 것. **10. `docs/components/parse/README.md` 안에서 비대칭이 생겼다.** PDF 불릿(:104)에는 #232·#239 사유를 다 적었는데 다이어그램의 Image 값만 `image-meta-v2` 로 올리고 Image 불릿(:107-112)에는 PARSER_VERSION 줄이 없다. 덧붙여 :110 의 "`OllamaVisionOcr { … }` — v1 유일 구현" 은 v0.27.0 의 `OnnxPaddleOcr`(`paddle_onnx.rs:220` 의 `impl OcrEngine`) 때문에 틀렸고 :37-43 다이어그램도 백엔드가 하나뿐이다. ARCHITECTURE.md:23 은 "2 백엔드"라고 적고 있다. 이 PR 이 만든 staleness 는 아니지만 바로 그 두 줄 사이를 건드렸다. **11. `(§3.4, v0.31.0 #217)` 는 따라갈 곳이 없는 참조** — `tasks/HOTFIXES.md:81`. 문서 이름이 없고, 남아 있는 설계 계약에서 §3.4 는 CanonicalDocument 다(그 번호가 가리키던 2026-05-31 스펙은 doc-reorg 에서 삭제됐다). 살아 있는 `docs/ARCHITECTURE.md:35` 를 가리키거나 절 번호를 뺄 것. **12. `### 스냅샷 낙수`** — `tasks/HOTFIXES.md:83`. `낙수` 는 저장소 전체에서 이 줄에만 있고 낙수 효과 아니면 落穗 로 읽힌다. 같은 항목 :81·:131 이 이미 `캐스케이드` 를 쓴다. **13. "서로 다른 조사 레인 + 검증 레인"** — `tasks/HOTFIXES.md:29`. 저장소 독자가 알 수 없는 내부 용어이고 `grep -rn "레인" --include=*.md .` 결과 이 줄뿐이다. "서로 다른 경로로 세 번 재 봤고 같은 표가 나왔다" 로 풀 것. **14. 소제목과 본문의 숫자가 엇갈려 읽힌다** — `tasks/HOTFIXES.md:27` "하한은 16 이 아니라 5" vs :29 "실제 한계는 4". 둘 다 맞지만(4 = 마지막 실패 폭, 5 = 첫 성공 폭) 같은 "16 을 제안했지만 → 실제 X" 구조를 X 만 바꿔 반복해서 4 가 상수 값으로 읽힌다.
altair823 added 1 commit 2026-08-28 08:40:22 +00:00
리뷰가 잡은 것 중 무거운 둘.

1. 테스트가 한 방향으로만 샜다. `REC_MIN_WIDTH` 를 100 으로 올려도 통과했다
   — 앞의 두 단언은 상수가 커질수록 더 잘 통과하기 때문이다(가드가 더 많이
   잡아 주고, 세션은 폭이 클수록 잘 돈다). 그 상태면 높이 48 기준 폭 100
   미만 크롭, 즉 글자 한두 개짜리 박스가 전부 조용히 버려진다. 이 PR 이
   없애려는 손실과 같은 종류다. 상수 옆에 `const _: () = assert!(REC_MIN_WIDTH
   <= 16, …)` 를 걸어 컴파일 자체를 막는다 — 상수를 만지는 사람이 테스트를
   돌리기 전에 걸리는 편이 낫다. 상한 16 은 "5~16 은 잉크가 있어도 빈 문자열,
   40 은 제대로 읽음" 실측에서 온 수다. 상수 주석의 "pins both sides" 도
   사실이 아니었으므로 함께 고쳤다.

2. 캐스케이드 비용 분석이 절반이었다. `id_for_doc` 이 접는 건 composite 가
   아니라 base PARSER_VERSION 이라 이 bump 는 모든 이미지·PDF 문서의 doc_id 를
   바꾸고, `purge_workspace_path_for_parser_bump` 로 store 를 통째로 다시 쓴다.
   게다가 기본 엔진이 `ollama-vision` 이고 OCR 이 기본 off 라 버그가 닿지 않는
   KB 도 같은 비용을 낸다. 엔진 범위로 좁히는 대안(`ocr_engine_version_for_sig`
   전용 토큰)이 더 정확하지만, #232 선례 옆에 두 번째 무효화 경로를 만드는
   값이 단일 사용자 저장소에서는 이득보다 크다고 보고 base bump 를 유지했다.
   빠져 있던 비용과 이 판단 근거를 HOTFIXES 에 적었다.

나머지:
- 테스트가 `ModelPaths::from_default_dir()` 을 써서 `KEBAB_IMAGE_OCR_MODEL_DIR`
  를 탔다. 상수를 배포되는 모델에 붙들어 두는 게 목적이므로 `CARGO_MANIFEST_DIR`
  에서 경로를 직접 만든다.
- PDF OCR 실패 노트가 `err={}` 라 anyhow 의 가장 바깥 context(`rec session run`)
  만 남고 ORT 원인이 잘렸다. 이슈가 안내한 `provenance_json LIKE '%Invalid input
  shape%'` 가 PDF 문서를 한 건도 못 찾는 원인이다. `{e:#}` 로 이미지 경로와
  형식을 맞췄다.
- `ingest.rs` 주석 4곳이 옛 버전을 현재형으로 말했다 (#232 가 자기 bump 때
  같은 자리를 갱신한 선례).
- `pdf_page_v1.rs` 테스트 픽스처의 하드코딩된 버전 문자열을 버전 중립으로.
  `kebab-chunk` 는 §8 경계상 `kebab-parse-pdf` 를 import 할 수 없어 실제 버전을
  넣으면 bump 마다 또 상한다.
- HOTFIXES: 이 커밋이 밀어 버린 줄 번호 참조 제거, 클래스 수 검사 논거를 약한
  대로 정직하게 다시 씀(정적 그래프 메타데이터라 `from_paths` 로 옮길 수 있다),
  따라갈 수 없는 `§3.4` 참조 정리, 내부 용어와 어색한 소제목 정리.
- DOGFOOD: §13.4 이미지 코퍼스를 실제 분류·장수로 채우고 §1.2 에 paddle-onnx
  설정 + 시나리오 1.2.f. 240 은 파일 수이고 ingest 대상은 tif 를 뺀 239 라는
  차이를 HOTFIXES 와 양쪽에 밝혔다.
- parse 컴포넌트 README: Image 에 빠져 있던 PARSER_VERSION 줄, "v1 유일 구현"
  이라는 틀린 서술과 다이어그램에 없던 `OnnxPaddleOcr`.

검증: 워크스페이스 1301 passed / 0 failed, clippy -D warnings 경고 없음.
`run_rec` 과 상수는 240 장 측정 때와 바이트 동일 — 이번 변경은 컴파일 시점
단언과 테스트 본문뿐이라 36/240 → 0/240 결과는 그대로다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Author
Owner

회차 2 리뷰 — REQUEST_CHANGES 상당

리뷰어 토큰(작성자와 다른 계정)이 없어 정식 review 로 등록하지 못하고 코멘트로 남긴다.

세 갈래로 봤다. (1) 회차 1 의 11 개 항목이 실제로 해소됐는지 직접 재현, (2) 회차 2 델타만 새 코드로 보기, (3) 두 회차가 모두 놓친 것. 나온 지적을 각각 적대적으로 재검증해 23 건 중 16 건이 남았다.

회차 1 은 실험으로 확인했다. REC_MIN_WIDTH 를 100 과 17 로 각각 바꿔 빌드 → 둘 다 error[E0080]: evaluation panicked: REC_MIN_WIDTH is past the measured no-loss ceiling (16) 로 컴파일 실패. 가드만 지우고 테스트 → w=1 must never reach the rec session: … Invalid input shape: {1,0}. 상수를 1 로 내리고 테스트 → REC_MIN_WIDTH=1 must survive the rec session: …. 네 방향 모두 의도대로 잡힌다. KEBAB_IMAGE_OCR_MODEL_DIR 을 빈 디렉토리로 export 한 채 테스트 → 통과(경로 고정 동작). 코퍼스 표도 실측과 한 글자도 안 틀린다(charts 50 / english-text 70 / korean-text 70 / photos 50, jpg 172 · png 63 · jpeg 4 · tif 1, 실패 8+10+9+9=36).


무거운 것

1. 새로 넣은 DOGFOOD 설정 스니펫이 동작하지 않는 키다 — docs/DOGFOOD.md:127

이 PR 이 §1.2 에 추가한 블록이 [image.ocr] 인데, 옛 키 자동 이관은 파일의 schema_version 이 5 보다 낮을 때만 돈다(crates/kebab-config/src/lib.rs:1307-1325, unwrap_or(1)). deny_unknown_fields 가 없어서 현행 schema_version = 5 파일에 붙여넣으면 serde 가 통째로 버리고 경고 한 줄 없이 무시한다. kebab doctor 조차 "config up to date (schema v5)" 라고 답한다.

격리 KB 실측: v5 config + 이 스니펫 → parse 1ms · chunk 723ms · embed 0ms, OCR 단계 자체가 없음. [ingest.image.ocr] 로 바꾸면 → ocr(ppocrv5-mobile-kor)… + ocr 10ms. 도그푸딩 스토어의 실제 config 도 schema_version = 5 + [ingest.image.ocr] 다.

그래서 같은 PR 이 추가한 1.2.f 가 무력화된다 — OCR 이 꺼지면 모든 이미지가 "본문은 파일명뿐 + provenance 에 오류 없음" 으로 나오는데, 이건 문서가 "글자 없는 사진이라 정상적으로 0 자" 라고 읽으라고 적어 둔 바로 그 모양이다. 도그푸더가 no-op 을 성공으로 오독한다.

2. HOTFIXES 의 릴리스 경계가 한 칸 어긋났다 — tasks/HOTFIXES.md:78

"v0.33.0 이전에 색인된 스캔 PDF 는 이 필터에 안 걸린다" 인데, refs/tags/v0.33.0 = 5596d41 = 이 PR 의 base 이고 그 커밋의 pdf_ocr_apply.rs:450 이 err={} 를 그대로 쓴다. {e:#} 는 이 PR 이 넣는 것이다. 게다가 git merge-base --is-ancestor 2871da1 v0.32.0 → 아님, … 5596d41 → 맞음 — 즉 스캔 PDF 페이지 렌더링(#232) 자체가 v0.33.0 에서 처음 나갔다.

그래서 v0.33.0 은 영향 범위 "안" 에 있는 정도가 아니라, 페이지 렌더 스캔본에 관한 한 영향 범위 전부다. 이 문장대로 감사하면 첫 LIKE 만 돌려 보고 0 건이 나오니 "내 KB 는 깨끗하다" 는 정반대 결론에 닿는다.

3. 상수 주석이 아직도 테스트가 위쪽 방향까지 잡는다고 말한다 — crates/kebab-parse-image/src/paddle_onnx.rs:60-62

"rec_min_width_is_the_graph_floor holds the constant from both directions" — 주어가 테스트 이름이므로 위쪽까지 그 테스트가 붙잡는다는 주장이다. 회차 1 이 지적한 바로 그 거짓 진술이고, git show 77ed432 를 보면 "pins both sides of that boundary" 를 같은 뜻의 더 긴 문장으로 바꿨을 뿐이다. 세 문단 뒤 HOTFIXES 가 스스로 반박한다.

4. {e:#} 를 고정하는 테스트가 없다 — crates/kebab-app/tests/pdf_ocr_apply.rs:310

{:#} 로 되돌려도 ocr_engine_failure_surfaces_as_warning 이 통과한다. mock 의 오류(mock_ocr.rs:55)가 단층이고, anyhow 는 원인 없는 오류를 {} 와 {:#} 에서 똑같이 찍기 때문이다(실측: 단층은 양쪽 다 [mock failure], 두 층은 [rec session run] vs [rec session run: Invalid input shape]). mock 을 두 층으로 만들고 안쪽 원인을 단언하면 한 줄로 막힌다.


나머지

5. pdf_ocr_apply.rs:475 가 회차 1 반영 커밋 자신에 밀려 어긋났다 — tasks/HOTFIXES.md:87. d6654ab 에서는 475 가 continue; 였는데 77ed432 가 같은 Err 분기에 주석 3 줄을 넣어 478 로 밀렸다. 지금 475 는 image_height, 다. 회차 1 의 paddle_onnx.rs:299 와 같은 종류가 한 PR 에서 두 번 났다. 숫자를 빼고 함수명으로 고정할 것.

6. const-assert 주석이 안 잰 구간을 잰 것처럼 말한다 — paddle_onnx.rs:67. "Above 16 the guard starts discarding crops the graph would have read" 인데 잰 건 516(빈 문자열)과 40(0.97+)뿐이고 1739 는 안 쟀다. 게다가 가드가 new_w < REC_MIN_WIDTH 를 버리므로 17 로 올리면 새로 버려지는 건 폭 16 — 프로젝트가 직접 비어 있다고 잰 폭이다. 첫걸음부터 반례다.

7. "영향 범위는 전체 PDF" 정정이 HOTFIXES 에서 멈췄다 — docs/ARCHITECTURE.md:27, docs/components/parse/README.md:109. 둘 다 아직 "기존 색인 스캔본 재처리 유발" 이라고 적는다. kebab-parse-pdf/src/lib.rs:99 가 스캔 여부 판정 전에 PARSER_VERSION 을 id_for_doc 에 접으므로 재처리 대상은 PDF 전부다.

8. 존재하지 않는 trait 시그니처 — docs/components/parse/README.md:115 의 engine_id() / run(...) -> OcrText. 실제는 ocr.rs:57/62/67/74 의 engine_name / engine_version / recognize / model 이고 engine_id 는 저장소 전체에서 코드에 0 건이다. 같은 파일 37~40 줄 mermaid 다이어그램도 같은 거짓을 반복하는데, 회차 2 가 OnnxPaddleOcr 를 추가한 자리가 바로 그 블록 아래다. 이 PR 이 만든 것은 아니지만(문서 작성 시점부터 틀림) 바로 그 두 줄 사이를 건드렸다. 한 곳만 고치면 산문과 다이어그램이 서로 어긋난다.

9. §1.2 verify 의 grep 문자열 절반이 이미지 경로에서 안 나온다 — docs/DOGFOOD.md:136. err= 접두사는 pdf_ocr_apply.rs:453 만 찍고, 이미지 경로는 ingest.rs:2090 의 OcrFailed: OCR failed (engine=…): rec session run: … Invalid input shape 형태다. KB 전체 집계용 문자열을 이미지 전용 절에 복사해 온 것.

10. 집계 쿼리에 실행 시점이 빠졌다 — tasks/HOTFIXES.md:78. 이 항목이 올린 bump 때문에 다음 kebab ingest 가 documents 행을 purge 하고 다시 쓰므로, 색인을 한 번 돌린 뒤에는 이 쿼리가 항상 0 을 준다. "업그레이드 → 색인 → 릴리스 노트 ���기" 순서면 안 당한 것처럼 보인다. PDF 쪽은 pdf_ocr_events(V008)가 documents 에 FK 가 없어 살아남으므로 WHERE success=0 AND reason='ocr_error' 로 사후에도 셀 수 있다.

11. PR 본문이 최초 작성 상태에 멈춰 있다. "재처리 비용은 싸다" 로 끝나는데 브랜치의 HOTFIXES 는 "하지만 나머지 비용은 전부 든다" 로 뒤집었다. const _ 상한, {e:#} 통일, CARGO_MANIFEST_DIR 고정도 본문에 없다. 본문이 릴리스 노트의 재료라는 점(CLAUDE.md §Release)에서 정리할 값이 있다.

12. 커밋 d6654ab 메시지의 "실제 하한은 4 였다" — 하한은 5, 4 는 가장 큰 실패 폭. 같은 메시지 두 문단 뒤 "5 가 그래프의 진짜 하한" 과 자기모순이다. 트리는 전부 정확하다. 머지가 Merge pull request 형태라 그대로 두면 main 히스토리에 남지만, 고치려면 리뷰 도중 강제 푸시가 필요하므로 실행 영향 0 인 이 건만으로는 하지 않는 편이 낫다.


확인했고 문제 없던 것: const _ 는 무조건·비-cfg const item 이라 모든 타깃·의존성 사용에서 평가된다(교차 컴파일 위험 없음). {:#} 위치 인자 4 개가 정확히 대응하고 실제로 전체 체인을 뱉는다. pdf_page_v1.rs 픽스처 변경은 아무 골든도 안 건드린다. ingest.rs 주석 4 곳 전부 정확. det 쪽은 det_target_dims 가 양축을 32 로 바닥 처리해 같은 부류의 형제 버그가 없다. README/HANDOFF/INDEX/CHANGELOG 를 안 건드린 것도 CLAUDE.md 기준 정당하다(사용자 표면 무변경).

## 회차 2 리뷰 — REQUEST_CHANGES 상당 > 리뷰어 토큰(작성자와 다른 계정)이 없어 정식 review 로 등록하지 못하고 코멘트로 남긴다. 세 갈래로 봤다. (1) 회차 1 의 11 개 항목이 실제로 해소됐는지 직접 재현, (2) 회차 2 델타만 새 코드로 보기, (3) 두 회차가 모두 놓친 것. 나온 지적을 각각 적대적으로 재검증해 23 건 중 16 건이 남았다. **회차 1 은 실험으로 확인했다.** `REC_MIN_WIDTH` 를 100 과 17 로 각각 바꿔 빌드 → 둘 다 `error[E0080]: evaluation panicked: REC_MIN_WIDTH is past the measured no-loss ceiling (16)` 로 컴파일 실패. 가드만 지우고 테스트 → `w=1 must never reach the rec session: … Invalid input shape: {1,0}`. 상수를 1 로 내리고 테스트 → `REC_MIN_WIDTH=1 must survive the rec session: …`. 네 방향 모두 의도대로 잡힌다. `KEBAB_IMAGE_OCR_MODEL_DIR` 을 빈 디렉토리로 export 한 채 테스트 → 통과(경로 고정 동작). 코퍼스 표도 실측과 한 글자도 안 틀린다(charts 50 / english-text 70 / korean-text 70 / photos 50, jpg 172 · png 63 · jpeg 4 · tif 1, 실패 8+10+9+9=36). --- ### 무거운 것 **1. 새로 넣은 DOGFOOD 설정 스니펫이 동작하지 않는 키다** — `docs/DOGFOOD.md:127` 이 PR 이 §1.2 에 추가한 블록이 `[image.ocr]` 인데, 옛 키 자동 이관은 파일의 `schema_version` 이 5 보다 **낮을 때만** 돈다(`crates/kebab-config/src/lib.rs:1307-1325`, `unwrap_or(1)`). `deny_unknown_fields` 가 없어서 현행 `schema_version = 5` 파일에 붙여넣으면 serde 가 통째로 버리고 경고 한 줄 없이 무시한다. `kebab doctor` 조차 "config up to date (schema v5)" 라고 답한다. 격리 KB 실측: v5 config + 이 스니펫 → `parse 1ms · chunk 723ms · embed 0ms`, OCR 단계 자체가 없음. `[ingest.image.ocr]` 로 바꾸면 → `ocr(ppocrv5-mobile-kor)…` + `ocr 10ms`. 도그푸딩 스토어의 실제 config 도 `schema_version = 5` + `[ingest.image.ocr]` 다. 그래서 같은 PR 이 추가한 1.2.f 가 무력화된다 — OCR 이 꺼지면 모든 이미지가 "본문은 파일명뿐 + provenance 에 오류 없음" 으로 나오는데, 이건 문서가 "글자 없는 사진이라 정상적으로 0 자" 라고 읽으라고 적어 둔 바로 그 모양이다. 도그푸더가 no-op 을 성공으로 오독한다. **2. HOTFIXES 의 릴리스 경계가 한 칸 어긋났다** — `tasks/HOTFIXES.md:78` "**v0.33.0 이전에** 색인된 스캔 PDF 는 이 필터에 안 걸린다" 인데, `refs/tags/v0.33.0` = `5596d41` = **이 PR 의 base** 이고 그 커밋의 `pdf_ocr_apply.rs:450` 이 `err={}` 를 그대로 쓴다. `{e:#}` 는 이 PR 이 넣는 것이다. 게다가 `git merge-base --is-ancestor 2871da1 v0.32.0` → 아님, `… 5596d41` → 맞음 — 즉 스캔 PDF 페이지 렌더링(#232) 자체가 v0.33.0 에서 처음 나갔다. 그래서 v0.33.0 은 영향 범위 "안" 에 있는 정도가 아니라, 페이지 렌더 스캔본에 관한 한 영향 범위 **전부**다. 이 문장대로 감사하면 첫 LIKE 만 돌려 보고 0 건이 나오니 "내 KB 는 깨끗하다" 는 정반대 결론에 닿는다. **3. 상수 주석이 아직도 테스트가 위쪽 방향까지 잡는다고 말한다** — `crates/kebab-parse-image/src/paddle_onnx.rs:60-62` "`rec_min_width_is_the_graph_floor` holds the constant from both directions" — 주어가 테스트 이름이므로 위쪽까지 그 테스트가 붙잡는다는 주장이다. 회차 1 이 지적한 바로 그 거짓 진술이고, `git show 77ed432` 를 보면 "pins both sides of that boundary" 를 같은 뜻의 더 긴 문장으로 바꿨을 뿐이다. 세 문단 뒤 HOTFIXES 가 스스로 반박한다. **4. `{e:#}` 를 고정하는 테스트가 없다** — `crates/kebab-app/tests/pdf_ocr_apply.rs:310` `{:#}` 로 되돌려도 `ocr_engine_failure_surfaces_as_warning` 이 통과한다. mock 의 오류(`mock_ocr.rs:55`)가 단층이고, anyhow 는 원인 없는 오류를 `{}` 와 `{:#}` 에서 똑같이 찍기 때문이다(실측: 단층은 양쪽 다 `[mock failure]`, 두 층은 `[rec session run]` vs `[rec session run: Invalid input shape]`). mock 을 두 층으로 만들고 안쪽 원인을 단언하면 한 줄로 막힌다. --- ### 나머지 **5. `pdf_ocr_apply.rs:475` 가 회차 1 반영 커밋 자신에 밀려 어긋났다** — `tasks/HOTFIXES.md:87`. `d6654ab` 에서는 475 가 `continue;` 였는데 `77ed432` 가 같은 `Err` 분기에 주석 3 줄을 넣어 478 로 밀렸다. 지금 475 는 `image_height,` 다. 회차 1 의 `paddle_onnx.rs:299` 와 같은 종류가 한 PR 에서 두 번 났다. 숫자를 빼고 함수명으로 고정할 것. **6. const-assert 주석이 안 잰 구간을 잰 것처럼 말한다** — `paddle_onnx.rs:67`. "Above 16 the guard starts discarding crops the graph would have read" 인데 잰 건 5~16(빈 문자열)과 40(0.97+)뿐이고 17~39 는 안 쟀다. 게다가 가드가 `new_w < REC_MIN_WIDTH` 를 버리므로 17 로 올리면 새로 버려지는 건 폭 16 — 프로젝트가 직접 비어 있다고 잰 폭이다. 첫걸음부터 반례다. **7. "영향 범위는 전체 PDF" 정정이 HOTFIXES 에서 멈췄다** — `docs/ARCHITECTURE.md:27`, `docs/components/parse/README.md:109`. 둘 다 아직 "기존 색인 **스캔본** 재처리 유발" 이라고 적는다. `kebab-parse-pdf/src/lib.rs:99` 가 스캔 여부 판정 전에 `PARSER_VERSION` 을 `id_for_doc` 에 접으므로 재처리 대상은 PDF 전부다. **8. 존재하지 않는 trait 시그니처** — `docs/components/parse/README.md:115` 의 `engine_id() / run(...) -> OcrText`. 실제는 `ocr.rs:57/62/67/74` 의 `engine_name` / `engine_version` / `recognize` / `model` 이고 `engine_id` 는 저장소 전체에서 코드에 0 건이다. 같은 파일 37~40 줄 mermaid 다이어그램도 같은 거짓을 반복하는데, 회차 2 가 `OnnxPaddleOcr` 를 추가한 자리가 바로 그 블록 아래다. 이 PR 이 만든 것은 아니지만(문서 작성 시점부터 틀림) 바로 그 두 줄 사이를 건드렸다. 한 곳만 고치면 산문과 다이어그램이 서로 어긋난다. **9. §1.2 verify 의 grep 문자열 절반이 이미지 경로에서 안 나온다** — `docs/DOGFOOD.md:136`. `err=` 접두사는 `pdf_ocr_apply.rs:453` 만 찍고, 이미지 경로는 `ingest.rs:2090` 의 `OcrFailed: OCR failed (engine=…): rec session run: … Invalid input shape` 형태다. KB 전체 집계용 문자열을 이미지 전용 절에 복사해 온 것. **10. 집계 쿼리에 실행 시점이 빠졌다** — `tasks/HOTFIXES.md:78`. 이 항목이 올린 bump 때문에 다음 `kebab ingest` 가 documents 행을 purge 하고 다시 쓰므로, 색인을 한 번 돌린 뒤에는 이 쿼리가 항상 0 을 준다. "업그레이드 → 색인 → 릴리스 노트 ���기" 순서면 안 당한 것처럼 보인다. PDF 쪽은 `pdf_ocr_events`(V008)가 documents 에 FK 가 없어 살아남으므로 `WHERE success=0 AND reason='ocr_error'` 로 사후에도 셀 수 있다. **11. PR 본문이 최초 작성 상태에 멈춰 있다.** "재처리 비용은 싸다" 로 끝나는데 브랜치의 HOTFIXES 는 "하지만 나머지 비용은 전부 든다" 로 뒤집었다. `const _` 상한, `{e:#}` 통일, `CARGO_MANIFEST_DIR` 고정도 본문에 없다. 본문이 릴리스 노트의 재료라는 점(CLAUDE.md §Release)에서 정리할 값이 있다. **12. 커밋 `d6654ab` 메시지의 "실제 하한은 4 였다"** — 하한은 5, 4 는 가장 큰 실패 폭. 같은 메시지 두 문단 뒤 "5 가 그래프의 진짜 하한" 과 자기모순이다. 트리는 전부 정확하다. 머지가 `Merge pull request` 형태라 그대로 두면 main 히스토리에 남지만, 고치려면 리뷰 도중 강제 푸시가 필요하므로 실행 영향 0 인 이 건만으로는 하지 않는 편이 낫다. --- 확인했고 문제 없던 것: `const _` 는 무조건·비-`cfg` const item 이라 모든 타깃·의존성 사용에서 평가된다(교차 컴파일 위험 없음). `{:#}` 위치 인자 4 개가 정확히 대응하고 실제로 전체 체인을 뱉는다. `pdf_page_v1.rs` 픽스처 변경은 아무 골든도 안 건드린다. `ingest.rs` 주석 4 곳 전부 정확. det 쪽은 `det_target_dims` 가 양축을 32 로 바닥 처리해 같은 부류의 형제 버그가 없다. README/HANDOFF/INDEX/CHANGELOG 를 안 건드린 것도 CLAUDE.md 기준 정당하다(사용자 표면 무변경).
altair823 added 1 commit 2026-08-28 09:09:08 +00:00
가장 나쁜 것부터.

1. 회차 1 에서 DOGFOOD §1.2 에 새로 넣은 config 스니펫이 `[image.ocr]` 였다.
   옛 키 자동 이관은 파일의 `schema_version` 이 5 보다 낮을 때만 돌고
   (`kebab-config/src/lib.rs` 의 `unwrap_or(1)` 게이트), `deny_unknown_fields`
   가 없어서 현행 v5 파일에 붙여넣으면 serde 가 통째로 버리고 경고도 안 낸다.
   `kebab doctor` 도 "config up to date" 라고 답한다. 격리 KB 로 확인: v5 +
   옛 키 → OCR 단계 자체가 없음(`parse 1ms · chunk 723ms · embed 0ms`),
   `[ingest.image.ocr]` 로 바꾸면 `ocr(ppocrv5-mobile-kor)` 가 돈다.
   그래서 같은 커밋이 추가한 1.2.f 가 no-op 이 될 뻔했다 — OCR 이 꺼지면 모든
   이미지가 "본문 0 자 + provenance 깨끗" 으로 나오는데, 이건 문서가 "글자
   없는 사진이라 정상" 이라고 읽으라고 적어 둔 바로 그 모양이다. 키를 고치고,
   같은 함정을 다음 사람이 안 밟게 §1.4 가 쓰는 형식의 주의 문단을 붙였다.
   1.2.f 에도 "진행 출력에 ocr 단계가 찍히는지 먼저 보라" 를 넣었다.

2. HOTFIXES 의 "v0.33.0 이전에 색인된" 이 한 릴리스 어긋났다. v0.33.0 태그가
   이 PR 의 base(5596d41) 자체이고 그 커밋이 `err={}` 를 담고 있다. 게다가
   스캔 PDF 페이지 렌더링(#232)이 v0.33.0 에서 처음 나갔으므로, 이 버그가
   망칠 수 있었던 스캔본은 사실상 전부 그 한 릴리스가 만든 기록이다. 문장대로
   감사하면 첫 LIKE 만 돌려 0 건을 보고 정반대 결론에 닿는다.

3. 상수 주석이 아직도 테스트가 위쪽 방향까지 잡는다고 말했다. 회차 1 은
   문장을 길게 바꿨을 뿐 주장은 그대로 뒀다. 위쪽은 `const _` 가 잡고 테스트는
   못 잡는다는 걸 그대로 적었다.

4. `{e:#}` 를 고정하는 테스트가 없었다. mock 오류가 단층이라 anyhow 가 `{}` 와
   `{:#}` 에서 똑같이 찍었고, 그래서 되돌려도 통과했다. mock 을 실제와 같은 두
   층으로 만들고 안쪽 원인을 단언한다. `{}` 로 되돌리면 실패하는 것 확인.

나머지:
- `pdf_ocr_apply.rs:475` 가 회차 1 반영 커밋 자신에 밀려 어긋났다 (같은 종류가
  한 PR 에서 두 번). 줄 번호를 빼고 함수명으로 고정했다.
- const-assert 주석이 안 잰 구간(17~39)을 잰 것처럼 말했다. 게다가 17 로
  올리면 새로 버려지는 건 폭 16 — 직접 비어 있다고 잰 폭이다.
- "재처리 대상은 PDF 전부" 정정이 HOTFIXES 에서 멈춰 ARCHITECTURE 와 컴포넌트
  README 에는 아직 "스캔본" 이라고 적혀 있었다.
- 컴포넌트 README 의 `engine_id() / run(...)` 은 존재한 적 없는 시그니처다.
  산문과 mermaid 다이어그램 양쪽을 실제 trait 에 맞췄다.
- §1.2 verify 의 `err=rec session run` 은 이미지 경로가 안 내는 문자열이다
  (KB 전체 집계용을 이미지 전용 절에 복사해 왔다).
- 영향 문서 집계 쿼리에 실행 시점이 빠져 있었다. bump 가 documents 행을
  purge 하므로 한 번 색인한 뒤에는 항상 0 이 나온다. "색인 전에 세라" 와,
  이미 색인했을 때 쓸 `pdf_ocr_events` 대안을 적었다.
- Gitea PR 본문을 현재 브랜치에 맞게 다시 썼다.

알고 남긴 것: 커밋 d6654ab 메시지의 "실제 하한은 4 였다" 는 틀렸다 (하한은 5,
4 는 가장 큰 실패 폭 — 같은 메시지 두 문단 뒤와 자기모순). 코드와 문서는 전부
정확하다. 고치려면 리뷰 중인 브랜치에 강제 푸시가 필요해서 두었다.

검증: 워크스페이스 1301 passed / 0 failed, clippy -D warnings 무경고.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Author
Owner

회차 3 리뷰 — 지적 3 건, 병합 차단 없음

리뷰어 토큰(작성자와 다른 계정)이 없어 정식 review 로 등록하지 못하고 코멘트로 남긴다.

회차 2 의 11 개 항목 중 9 개가 해소됐고, 남은 것과 새로 찾은 것을 합쳐 11 건이 나와 검증 후 3 건이 남았다. 회차 1 은 19 건, 회차 2 는 16 건이었으니 수렴하고 있다. 두 렌즈 중 하나는 "지금 상태로 머지하겠다" 는 판정이다.

실행으로 확인한 것 (읽어서가 아니라 돌려서):

  • 회차 2 항목 1 — 격리 config + target/debug/kebab 로 재현. [ingest.image.ocr] enabled/engine 은 실제로 paddle-onnx 를 켜고 ocr(ppocrv5-mobile-kor) 를 찍는 반면, schema_version = 5 에 옛 [image.ocr] 를 붙이면 경고도 OCR 단계도 없이 조용히 버려진다. 새 주의 문단이 말하는 그대로다.
  • 회차 2 항목 4 — pdf_ocr_apply.rs:453 을 {:#} → {} 로 되돌리면 ocr_engine_failure_surfaces_as_warning 이 실패한다. 두 층 mock 은 fail=true 호출부 한 곳만 쓰므로 다른 MockOcrEngine 테스트는 약해지지 않았다.
  • HOTFIXES 의 서로 다른 아홉 개 주장을 처음부터 다시 유도 — 코퍼스 수(172+63+4+1=240, 디렉토리별 50/70/70/50), 실패 분포 8+10+9+9=36, w=12/13/45 의 CTC 식, V008 DDL(FK 없음·컬럼명·'ocr_error' 실제 기록·retention 30 일), ARCHITECTURE.md:35 가 정말 derivation_cache 행인지, 인용한 두 함수명, 양쪽 경로의 Err-before-derivation_cache_put, OcrCfg/PdfOcrCfg 기본값, 번들 dict 11945 줄에 대한 REC_CLASSES = DICT_LINES + 2. 전부 성립.
  • mermaid 두 블록이 mermaid 11 에서 파싱된다. cargo clippy --workspace --all-targets -- -D warnings exit 0.

남은 3 건

1. 회차 2 가 과잉 수정했다 — tasks/HOTFIXES.md:84

"하필 스캔 PDF 페이지 렌더링(#232) 자체가 v0.33.0 에서 처음 나갔으므로, 이 버그가 망칠 수 있었던 스캔본은 사실상 전부 그 한 릴리스가 만든 기록이다" 는 전제는 맞지만 결론이 안 따라온다. 페이지 렌더링 이 v0.33.0 신규인 건 맞으나 스캔 PDF OCR 자체는 그보다 오래됐다.

build_pdf_ocr_engine 에 paddle 분기가 생긴 시점을 태그별로 세면 v0.28.0 = 0, v0.30.0 = 0, v0.31.0 = 1, v0.32.0 = 1 이다. 즉 v0.31.0 부터 PDF OCR 엔진으로 paddle-onnx 를 고를 수 있었고, 그 시점의 run_rec 에는 폭 가드가 없다(git show v0.31.0:… | grep -c REC_MIN_WIDTH → 0). 다만 v0.32.0 까지는 extract_dctdecode_page_image 경로라 단일 DCTDecode 이미지 페이지만 OCR 대상이었다. #232 는 래스터 경로를 임의 인코딩까지 넓힌 것이지 만든 것이 아니다.

기록의 대부분이 v0.33.0 산인 건 맞���지만 "사실상 전부" 는 과하다. 마지막 문장(OR … 을 반드시 함께 걸어야 한다)은 그대로 맞다.

2. pdf_ocr_events 대안 쿼리가 단위와 엔진 양쪽에서 어긋난다 — tasks/HOTFIXES.md:82

앞 문장이 세라는 건 영향 문서 수인데(provenance_json 은 documents 에 있고 workspace_path 에 UNIQUE), 대안은 pdf_ocr_events 행을 센다. 이 표는 페이지마다 한 행이다(pdf_ocr_apply.rs:476 이 페이지 루프 안에서 failure_reason: Some("ocr_error") 를 넣고 continue, ingest.rs:2693 이 Finished 마다 삽입). 40 쪽을 잃은 문서 하나가 첫 수치에 1, 둘째에 40 을 더한다.

게다가 record_pdf_ocr_event 는 맨 INSERT 이고 V008 에 유니크 제약이 없다. 실패 경로는 derivation_cache_put 앞에서 빠져나가므로 수정 전 색인을 여러 번 돌렸으면 매번 다시 실패하고 다시 삽입됐다. count(*) 는 페이지 × 실행 횟수만큼 부풀고 count(DISTINCT doc_id) 가 둘 다 접는다. 이 경로는 ingest.rs:2695 가 Some(&doc_id_for_log) 를 무조건 넘기므로 NULL 로 빠지는 행은 없다.

그리고 'ocr_error' 는 engine.recognize() 의 모든 Err 를 받는 통칭이다(다른 값을 내는 곳은 pdf_ocr_apply.rs:392 의 no_renderer/render_error/unopenable_pdf 뿐이고, 주석에 적힌 "timeout" 은 생산자가 아예 없다). 그래서 기본 엔진인 ollama-vision 을 쓰는 KB — 같은 항목이 두 문단 아래에서 그게 기본이라고 적어 둔 그 KB — 도 #239 와 무관한 행을 쌓고 이 쿼리는 0 이 아닌 수를 준다. V008 에 ocr_engine 컬럼이 이미 있고 engine.engine_name() 으로 채워지므로 면책 문구 대신 조건으로 거를 수 있다.

SELECT count(DISTINCT doc_id) FROM pdf_ocr_events WHERE success = 0 AND reason = 'ocr_error' AND ocr_engine = 'paddle-onnx' 로 바꿀 것. paddle-onnx 안에서도 상한이라는 점은 한 구절로 밝히면 된다.

3. 이 PR 이 다시 쓴 줄이 하필 이 PR 이 "조용히 무시된다" 고 문서화한 키를 쓴다 — docs/ARCHITECTURE.md:23

engine 선택은 [image.ocr] engine 인데, 같은 표 바로 아래 행(:27, PDF parser)은 이미 [ingest.pdf.ocr] render_library 형태를 쓴다. 즉 문서 전반의 축약 표기가 아니라 홀로 남은 옛 키다.

동작으로 재현했다: Config::defaults() 를 직렬화하면 schema_version = 5 파일이 나오고, 거기에 [image.ocr] enabled = true / engine = "paddle-onnx" 를 덧붙여 Config::from_file 로 읽으면 결과가 enabled=false, engine=ollama-vision 이다. 경고 한 줄 없다. migrate.rs:377 의 image.ocr → ingest.image.ocr 이동이 step_2_to_3 에만 있고 lib.rs:1311 이 from < CURRENT_SCHEMA_VERSION(=5) 일 때만 변환을 태우기 때문이다. 이 PR 이 이 함정을 DOGFOOD 에 적어 놓고 ARCHITECTURE 에서는 밟고 있다.

(같은 파일 25 행의 image.caption.enabled 와 docs/SMOKE.md:714/747 의 image.ocr.enabled 도 같은 옛 표기지만 이 PR 이 안 건드린 줄이라 범위 밖이다. 별건으로 쓸어도 좋다.)


곁다리

회차 3 델타가 crates/kebab-app/tests/common/mock_ocr.rs 에 rustfmt 어긋남을 1 건 새로 만들었다(main 0 → 1). 이 PR 은 그동안 새 어긋남 0 을 유지해 왔으므로 맞춰 두는 편이 낫다.

무효 처리한 지적

"PR 본문이 아직 최초 상태" 라는 지적이 나왔으나 사실이 아니다 — API 로 확인하면 본문이 5,314 자로 갱신돼 있고 하지만 나머지 비용은 전부 든다 / const _ 상한 / 리뷰 이력이 모두 들어 있다(updated 09:09:08Z). 검증 단계에서 걸러졌다.

## 회차 3 리뷰 — 지적 3 건, 병합 차단 없음 > 리뷰어 토큰(작성자와 다른 계정)이 없어 정식 review 로 등록하지 못하고 코멘트로 남긴다. 회차 2 의 11 개 항목 중 9 개가 해소됐고, 남은 것과 새로 찾은 것을 합쳐 11 건이 나와 검증 후 **3 건**이 남았다. 회차 1 은 19 건, 회차 2 는 16 건이었으니 수렴하고 있다. 두 렌즈 중 하나는 "지금 상태로 머지하겠다" 는 판정이다. **실행으로 확인한 것** (읽어서가 아니라 돌려서): - 회차 2 항목 1 — 격리 config + `target/debug/kebab` 로 재현. `[ingest.image.ocr] enabled/engine` 은 실제로 paddle-onnx 를 켜고 `ocr(ppocrv5-mobile-kor)` 를 찍는 반면, `schema_version = 5` 에 옛 `[image.ocr]` 를 붙이면 경고도 OCR 단계도 없이 조용히 버려진다. 새 주의 문단이 말하는 그대로다. - 회차 2 항목 4 — `pdf_ocr_apply.rs:453` 을 `{:#}` → `{}` 로 되돌리면 `ocr_engine_failure_surfaces_as_warning` 이 실패한다. 두 층 mock 은 `fail=true` 호출부 한 곳만 쓰므로 다른 MockOcrEngine 테스트는 약해지지 않았다. - HOTFIXES 의 서로 다른 아홉 개 주장을 처음부터 다시 유도 — 코퍼스 수(172+63+4+1=240, 디렉토리별 50/70/70/50), 실패 분포 8+10+9+9=36, `w=12/13/45` 의 CTC 식, V008 DDL(FK 없음·컬럼명·`'ocr_error'` 실제 기록·retention 30 일), `ARCHITECTURE.md:35` 가 정말 derivation_cache 행인지, 인용한 두 함수명, 양쪽 경로의 `Err`-before-`derivation_cache_put`, `OcrCfg`/`PdfOcrCfg` 기본값, 번들 dict 11945 줄에 대한 `REC_CLASSES = DICT_LINES + 2`. 전부 성립. - mermaid 두 블록이 mermaid 11 에서 파싱된다. `cargo clippy --workspace --all-targets -- -D warnings` exit 0. --- ### 남은 3 건 **1. 회차 2 가 과잉 수정했다** — `tasks/HOTFIXES.md:84` "하필 스캔 PDF 페이지 렌더링(#232) 자체가 v0.33.0 에서 처음 나갔으므로, 이 버그가 망칠 수 있었던 스캔본은 사실상 전부 그 한 릴리스가 만든 기록이다" 는 전제는 맞지만 결론이 안 따라온다. 페이지 *렌더링* 이 v0.33.0 신규인 건 맞으나 스캔 PDF OCR 자체는 그보다 오래됐다. `build_pdf_ocr_engine` 에 paddle 분기가 생긴 시점을 태그별로 세면 v0.28.0 = 0, v0.30.0 = 0, **v0.31.0 = 1**, v0.32.0 = 1 이다. 즉 v0.31.0 부터 PDF OCR 엔진으로 paddle-onnx 를 고를 수 있었고, 그 시점의 `run_rec` 에는 폭 가드가 없다(`git show v0.31.0:… | grep -c REC_MIN_WIDTH` → 0). 다만 v0.32.0 까지는 `extract_dctdecode_page_image` 경로라 단일 DCTDecode 이미지 페이지만 OCR 대상이었다. #232 는 래스터 경로를 임의 인코딩까지 **넓힌** 것이지 만든 것이 아니다. 기록의 대부분이 v0.33.0 산인 건 맞���지만 "사실상 전부" 는 과하다. 마지막 문장(`OR …` 을 반드시 함께 걸어야 한다)은 그대로 맞다. **2. `pdf_ocr_events` 대안 쿼리가 단위와 엔진 양쪽에서 어긋난다** — `tasks/HOTFIXES.md:82` 앞 문장이 세라는 건 영향 **문서** 수인데(`provenance_json` 은 `documents` 에 있고 workspace_path 에 UNIQUE), 대안은 `pdf_ocr_events` 행을 센다. 이 표는 **페이지마다** 한 행이다(`pdf_ocr_apply.rs:476` 이 페이지 루프 안에서 `failure_reason: Some("ocr_error")` 를 넣고 `continue`, `ingest.rs:2693` 이 `Finished` 마다 삽입). 40 쪽을 잃은 문서 하나가 첫 수치에 1, 둘째에 40 을 더한다. 게다가 `record_pdf_ocr_event` 는 맨 INSERT 이고 V008 에 유니크 제약이 없다. 실패 경로는 `derivation_cache_put` 앞에서 빠져나가므로 수정 전 색인을 여러 번 돌렸으면 매번 다시 실패하고 다시 삽입됐다. `count(*)` 는 페이지 × 실행 횟수만큼 부풀고 `count(DISTINCT doc_id)` 가 둘 다 접는다. 이 경로는 `ingest.rs:2695` 가 `Some(&doc_id_for_log)` 를 무조건 넘기므로 NULL 로 빠지는 행은 없다. 그리고 `'ocr_error'` 는 `engine.recognize()` 의 **모든** Err 를 받는 통칭이다(다른 값을 내는 곳은 `pdf_ocr_apply.rs:392` 의 `no_renderer`/`render_error`/`unopenable_pdf` 뿐이고, 주석에 적힌 `"timeout"` 은 생산자가 아예 없다). 그래서 기본 엔진인 ollama-vision 을 쓰는 KB — 같은 항목이 두 문단 아래에서 그게 기본이라고 적어 둔 그 KB — 도 #239 와 무관한 행을 쌓고 이 쿼리는 0 이 아닌 수를 준다. V008 에 `ocr_engine` 컬럼이 이미 있고 `engine.engine_name()` 으로 채워지므로 면책 문구 대신 조건으로 거를 수 있다. `SELECT count(DISTINCT doc_id) FROM pdf_ocr_events WHERE success = 0 AND reason = 'ocr_error' AND ocr_engine = 'paddle-onnx'` 로 바꿀 것. paddle-onnx 안에서도 상한이라는 점은 한 구절로 밝히면 된다. **3. 이 PR 이 다시 쓴 줄이 하필 이 PR 이 "조용히 무시된다" 고 문서화한 키를 쓴다** — `docs/ARCHITECTURE.md:23` `engine 선택은 [image.ocr] engine` 인데, 같은 표 바로 아래 행(:27, PDF parser)은 이미 `[ingest.pdf.ocr] render_library` 형태를 쓴다. 즉 문서 전반의 축약 표기가 아니라 홀로 남은 옛 키다. 동작으로 재현했다: `Config::defaults()` 를 직렬화하면 `schema_version = 5` 파일이 나오고, 거기에 `[image.ocr] enabled = true / engine = "paddle-onnx"` 를 덧붙여 `Config::from_file` 로 읽으면 결과가 `enabled=false, engine=ollama-vision` 이다. 경고 한 줄 없다. `migrate.rs:377` 의 image.ocr → ingest.image.ocr 이동이 `step_2_to_3` 에만 있고 `lib.rs:1311` 이 `from < CURRENT_SCHEMA_VERSION`(=5) 일 때만 변환을 태우기 때문이다. 이 PR 이 이 함정을 DOGFOOD 에 적어 놓고 ARCHITECTURE 에서는 밟고 있다. (같은 파일 25 행의 `image.caption.enabled` 와 `docs/SMOKE.md:714/747` 의 `image.ocr.enabled` 도 같은 옛 표기지만 이 PR 이 안 건드린 줄이라 범위 밖이다. 별건으로 쓸어도 좋다.) --- ### 곁다리 회차 3 델타가 `crates/kebab-app/tests/common/mock_ocr.rs` 에 rustfmt 어긋남을 1 건 새로 만들었다(main 0 → 1). 이 PR 은 그동안 새 어긋남 0 을 유지해 왔으므로 맞춰 두는 편이 낫다. ### 무효 처리한 지적 "PR 본문이 아직 최초 상태" 라는 지적이 나왔으나 사실이 아니다 — API 로 확인하면 본문이 5,314 자로 갱신돼 있고 `하지만 나머지 비용은 전부 든다` / `const _` 상한 / 리뷰 이력이 모두 들어 있다(updated 09:09:08Z). 검증 단계에서 걸러졌다.
altair823 added 1 commit 2026-08-28 09:31:56 +00:00
회차 3 은 3 건으로 수렴했고 그중 둘이 회차 2 가 만든 것이다.

1. 릴리스 경계를 바로잡다가 반대쪽으로 과하게 밀었다. "이 버그가 망칠 수
   있었던 스캔본은 사실상 전부 v0.33.0 이 만든 기록" 이라고 썼는데, 페이지
   렌더링(#232)이 v0.33.0 신규인 건 맞지만 스캔 PDF OCR 자체는 그보다
   오래됐다. `build_pdf_ocr_engine` 의 paddle 분기를 태그별로 세면 v0.28.0=0,
   v0.30.0=0, v0.31.0=1, v0.32.0=1 이다. v0.31.0 부터 PDF OCR 엔진으로
   paddle-onnx 를 고를 수 있었고 그때 `run_rec` 에는 폭 가드가 없었다. 다만
   v0.32.0 까지는 단일 DCTDecode 이미지 페이지만 대상이었다. #232 는 래스터
   경로를 임의 인코딩까지 넓힌 것이지 만든 것이 아니다.
   (리뷰는 v0.28.0 이라고 했으나 태그별로 직접 세어 v0.31.0 로 적었다.)

2. 색인 후에 쓸 대안 쿼리의 단위가 틀렸다. 앞 문장은 영향 **문서** 수를
   세라는데 `pdf_ocr_events` 는 페이지마다 한 행이고 유니크 제약도 없다 —
   40 쪽을 잃은 문서 하나가 40 을 더하고, 수정 전 색인을 두 번 돌렸으면 또
   두 배다. `count(DISTINCT doc_id)` 가 둘 다 접는다(이 경로는 doc_id 를
   무조건 넘기므로 NULL 로 빠지는 행이 없다). 그리고 `'ocr_error'` 는
   `recognize()` 의 모든 실패를 받는 통칭이라 기본 엔진 ollama-vision 을 쓰는
   KB 도 #239 와 무관한 행을 쌓는다. V008 에 `ocr_engine` 컬럼이 이미 있으니
   조건으로 거른다. paddle-onnx 안에서도 상한이라는 점은 문장으로 밝혔다.

3. ARCHITECTURE.md 에서 이 PR 이 다시 쓴 줄이 하필 이 PR 이 "조용히
   무시된다" 고 문서화한 옛 키(`[image.ocr] engine`)를 쓰고 있었다. 같은 표
   바로 아래 PDF 행은 이미 `[ingest.pdf.ocr]` 형태다.

곁다리: 회차 3 델타가 mock_ocr.rs 에 rustfmt 어긋남을 1 건 만들었다
(main 0 → 1). 이 PR 은 새 어긋남 0 을 유지해 왔으므로 되돌렸다.

검증: 워크스페이스 1301 passed / 0 failed, clippy -D warnings 무경고,
손댄 파일의 rustfmt 어긋남이 main 과 동일(새로 만든 것 0).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
altair823 added 1 commit 2026-08-28 09:50:30 +00:00
회차 3 에서 내가 맞는 리뷰를 잘못된 측정으로 뒤집었다. paddle-onnx 를 PDF OCR
엔진으로 고를 수 있게 된 릴리스를 v0.31.0 이라고 적었는데 v0.28.0 이 맞다.

원인은 세는 방법이었다. `crates/kebab-app/src/ingest.rs` 를 태그별로 grep 했는데
그 파일 자체가 v0.31.0 에서 생겼다 — 그 전에는 `build_pdf_ocr_engine` 이
`crates/kebab-app/src/lib.rs` 에 있었다. 내가 본 0 은 기능이 없다는 뜻이 아니라
파일이 없다는 뜻이었다. 파일을 먼저 찾은 뒤 세면 v0.28.0 · v0.30.0 · v0.30.1 ·
v0.31.0 · v0.32.0 전부 paddle 분기가 있다.

v0.28.0 에서 실제로 도달 가능했다는 것도 확인했다: `KEBAB_PDF_OCR_ENGINE` env 와
config 파일 양쪽으로 켤 수 있었고(같은 태그의 테스트가
`c.ingest.pdf.ocr.engine == "paddle-onnx"` 를 고정), `pdf_ocr_apply.rs` 가 이미
`engine.recognize(...)` 를 부르며 `err={}` 로 찍었고, `REC_MIN_WIDTH` 는 어느
태그에도 없다. 더 이르지도 않다 — v0.26.2 는 PDF OCR 엔진이
`Option<OllamaVisionOcr>` 로 타입이 박혀 있어 선택 자체가 불가능했다.

다음 사람이 같은 실수를 안 하도록 "ingest.rs 만 훑으면 v0.31.0 처럼 보인다,
파일부터 찾아라" 를 문장에 적어 뒀다. 직전 커밋(478dafe) 메시지에도 같은 잘못된
집계가 남아 있다.

문단의 나머지(v0.32.0 까지 단일 DCTDecode 페이지 한정, #232 가 대상을 넓힌
것이지 만든 것이 아니라는 점, OR 필터)는 검증 결과 정확하다.

이번 변경은 문서 한 줄이라 코드·테스트에 영향 없다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Author
Owner

회차 4 리뷰 — 지적 1 건 (반영 완료), 그 외 병합 가능

리뷰어 토큰(작성자와 다른 계정)이 없어 정식 review 로 등록하지 못하고 코멘트로 남긴다.

두 관점이 같은 한 건으로 모였고, 그 하나만 고치면 남는 게 없다는 판정이다. 회차별로 19 → 16 → 3 → 1 이다.

유일한 지적 — 회차 3 이 맞는 리뷰를 잘못된 측정으로 뒤집었다

tasks/HOTFIXES.md:84 가 "PDF OCR 엔진으로 paddle-onnx 를 고를 수 있게 된 것이 v0.31.0" 이라고 적었으나 v0.28.0 이 맞다. 회차 2 리뷰어가 옳았고 회차 3 이 틀린 근거로 덮었다.

원인은 세는 방법이다. 태그별로 crates/kebab-app/src/ingest.rs 를 grep 했는데 그 파일이 v0.31.0 에서 생겼다 — 그 전에는 build_pdf_ocr_engine 이 crates/kebab-app/src/lib.rs 에 있었다(v0.28.0 은 lib.rs:869, paddle 분기 :890). 0 은 기능이 없다는 뜻이 아니라 파일이 없다는 뜻이었다. 파일을 먼저 찾고 세면:

v0.26.2: build_pdf_ocr_engine 없음
v0.28.0: crates/kebab-app/src/lib.rs      paddle분기=1
v0.30.0: crates/kebab-app/src/lib.rs      paddle분기=1
v0.30.1: crates/kebab-app/src/lib.rs      paddle분기=1
v0.31.0: crates/kebab-app/src/ingest.rs   paddle분기=1   <- 이동일 뿐
v0.32.0: crates/kebab-app/src/ingest.rs   paddle분기=1

도입 커밋 901416d ("feat(ocr): T7-T9 — config overrides + engine factory + signature cascade", 2026-06-04) 는 git describe --contains 로 v0.28.0~1^2~4 다. v0.28.0 에서 실제로 도달 가능했다는 것도 확인됐다 — KEBAB_PDF_OCR_ENGINE env 와 config 파일 양쪽으로 켤 수 있었고(같은 태그 테스트가 c.ingest.pdf.ocr.engine == "paddle-onnx" 를 고정), pdf_ocr_apply.rs 가 이미 engine.recognize(...) 를 부르며 err={} 로 찍었고, REC_MIN_WIDTH 는 v0.28.0~v0.32.0 어느 태그에도 없다. 더 이르지도 않다 — v0.26.2 는 PDF OCR 엔진이 Option<OllamaVisionOcr> 로 타입이 박혀 선택 자체가 불가능했다.

문단의 나머지(v0.32.0 까지 단일 DCTDecode 페이지 한정, #232 가 대상을 넓힌 것이지 만든 것이 아니라는 점, OR 필터)는 검증 결과 정확하다. → 5815f74 에서 반영, 다음 사람이 같은 실수를 안 하도록 "ingest.rs 만 훑으면 v0.31.0 처럼 보인다, 파일부터 찾아라" 를 문장에 남겼다.


근거를 처음부터 다시 만들어 확인했다

수치를 읽어서가 아니라 수정 전 바이너리를 5596d41 아카이브로, 수정 후를 HEAD 로 각각 새로 빌드해 같은 번들 모델·같은 설정으로 코퍼스를 다시 돌렸다. HOTFIXES 의 모든 headline 수치가 정확히 재현됐다.

  • 수정 전 204 OK + 36 ERR, 수정 후 240 OK + 0 ERR
  • 분류별 실패 8 / 10 / 9 / 9
  • 되살아난 본문 11,747 자, 중앙값 37, 최대 3,950
  • 36 장 중 4 장은 여전히 0 자, 성공 204 장 중 31 장도 정상적으로 0 자
  • 원래 성공하던 204 장 중 글자 수가 바뀐 것 0 ("순수 가산")
  • 이름을 든 세 이미지가 글자 단위로 일치 (3950 / 1116·87 영역 / 1088)
  • ORT 노드명과 상태 메시지가 "이슈 본문과 다른 점 하나" 절과 문자 그대로 일치

이전 회차들이 다시 유도하지 않았던 주장도 확인

id_for_doc 이 base PARSER_VERSION 을 접는 것, PdfOcrCfg::defaults() 의 enabled/always_on false, retention_days 30, V008 에 documents FK 없고 유니크 제약도 없다는 것(그래서 count(DISTINCT doc_id) 가 맞는 모양), ocr_engine 컬럼이 TEXT NOT NULL 이고 engine_name() 으로 채워진다는 것, doc_id 가 이 경로에서 non-NULL 인 것, 'ocr_error' 가 모든 recognize() Err 에 대한 단일 리터럴인 것, 양쪽 Err 분기가 derivation_cache_put 앞에서 빠져나가는 것, rec 그래프 출력이 [-1,-1,11947] 이라 클래스 검사 이전 방안이 성립한다는 것, §13.4 코퍼스 표(50/70/70/50, jpg 172 / png 63 / jpeg 4 / tif 1). 전부 성립.

게이트

cargo clippy --workspace --all-targets -- -D warnings exit 0, 경고 0. cargo test --workspace --no-fail-fast 188 개 바이너리에서 1301 passed / 0 failed / 64 ignored, exit 0. 이 PR 이 새로 만든 rustfmt 어긋남 0 (손댄 .rs 파일 전부 main 과 동일하거나 깨끗; 회차 2~4 가 건드린 테스트 파일은 깨끗하고, mock_ocr 편집은 git diff -w 가 비어 있는 공백 변경뿐이라 두 층 anyhow 오류는 그대로다).

회차 3 항목 확인

  1. 과잉 수정 → 위 지적으로 대체·반영. 2. pdf_ocr_events 쿼리 → ocr_engine 이 V008 의 실제 TEXT NOT NULL 컬럼이고 paddle-onnx 리터럴로 채워지며 doc_id 도 non-NULL 임을 확인, 새 문장이 "상한이라는 점은 남는다" 로 스스로 한계를 밝히는 것도 적절. 3. [ingest.image.ocr] 정정 확인 + 이 PR 이 추가한 마크다운 줄의 config 키를 전부 훑어 살아 있는 키임을 확인(남은 [image.ocr] 한 곳은 이관 함정을 설명하려고 일부러 옛 키를 부르는 자리). 4. rustfmt 파리티는 요구 이상 — 손댄 10 개 파일이 HEAD 에서 rustfmt 깨끗.
## 회차 4 리뷰 — 지적 1 건 (반영 완료), 그 외 병합 가능 > 리뷰어 토큰(작성자와 다른 계정)이 없어 정식 review 로 등록하지 못하고 코멘트로 남긴다. 두 관점이 같은 한 건으로 모였고, 그 하나만 고치면 남는 게 없다는 판정이다. 회차별로 19 → 16 → 3 → **1** 이다. ### 유일한 지적 — 회차 3 이 맞는 리뷰를 잘못된 측정으로 뒤집었다 `tasks/HOTFIXES.md:84` 가 "PDF OCR 엔진으로 paddle-onnx 를 고를 수 있게 된 것이 **v0.31.0**" 이라고 적었으나 **v0.28.0** 이 맞다. 회차 2 리뷰어가 옳았고 회차 3 이 틀린 근거로 덮었다. 원인은 세는 방법이다. 태그별로 `crates/kebab-app/src/ingest.rs` 를 grep 했는데 **그 파일이 v0.31.0 에서 생겼다** — 그 전에는 `build_pdf_ocr_engine` 이 `crates/kebab-app/src/lib.rs` 에 있었다(v0.28.0 은 `lib.rs:869`, paddle 분기 `:890`). 0 은 기능이 없다는 뜻이 아니라 파일이 없다는 뜻이었다. 파일을 먼저 찾고 세면: ``` v0.26.2: build_pdf_ocr_engine 없음 v0.28.0: crates/kebab-app/src/lib.rs paddle분기=1 v0.30.0: crates/kebab-app/src/lib.rs paddle분기=1 v0.30.1: crates/kebab-app/src/lib.rs paddle분기=1 v0.31.0: crates/kebab-app/src/ingest.rs paddle분기=1 <- 이동일 뿐 v0.32.0: crates/kebab-app/src/ingest.rs paddle분기=1 ``` 도입 커밋 `901416d` ("feat(ocr): T7-T9 — config overrides + engine factory + signature cascade", 2026-06-04) 는 `git describe --contains` 로 `v0.28.0~1^2~4` 다. v0.28.0 에서 실제로 도달 가능했다는 것도 확인됐다 — `KEBAB_PDF_OCR_ENGINE` env 와 config 파일 양쪽으로 켤 수 있었고(같은 태그 테스트가 `c.ingest.pdf.ocr.engine == "paddle-onnx"` 를 고정), `pdf_ocr_apply.rs` 가 이미 `engine.recognize(...)` 를 부르며 `err={}` 로 찍었고, `REC_MIN_WIDTH` 는 v0.28.0~v0.32.0 어느 태그에도 없다. 더 이르지도 않다 — v0.26.2 는 PDF OCR 엔진이 `Option<OllamaVisionOcr>` 로 타입이 박혀 선택 자체가 불가능했다. 문단의 나머지(v0.32.0 까지 단일 DCTDecode 페이지 한정, #232 가 대상을 넓힌 것이지 만든 것이 아니라는 점, `OR` 필터)는 검증 결과 정확하다. → `5815f74` 에서 반영, 다음 사람이 같은 실수를 안 하도록 "ingest.rs 만 훑으면 v0.31.0 처럼 보인다, 파일부터 찾아라" 를 문장에 남겼다. --- ### 근거를 처음부터 다시 만들어 확인했다 수치를 읽어서가 아니라 **수정 전 바이너리를 `5596d41` 아카이브로, 수정 후를 HEAD 로 각각 새로 빌드**해 같은 번들 모델·같은 설정으로 코퍼스를 다시 돌렸다. HOTFIXES 의 모든 headline 수치가 정확히 재현됐다. - 수정 전 204 OK + 36 ERR, 수정 후 240 OK + 0 ERR - 분류별 실패 8 / 10 / 9 / 9 - 되살아난 본문 11,747 자, 중앙값 37, 최대 3,950 - 36 장 중 4 장은 여전히 0 자, 성공 204 장 중 31 장도 정상적으로 0 자 - **원래 성공하던 204 장 중 글자 수가 바뀐 것 0** ("순수 가산") - 이름을 든 세 이미지가 글자 단위로 일치 (3950 / 1116·87 영역 / 1088) - ORT 노드명과 상태 메시지가 "이슈 본문과 다른 점 하나" 절과 문자 그대로 일치 ### 이전 회차들이 다시 유도하지 않았던 주장도 확인 `id_for_doc` 이 base PARSER_VERSION 을 접는 것, `PdfOcrCfg::defaults()` 의 enabled/always_on false, `retention_days` 30, V008 에 documents FK 없고 유니크 제약도 없다는 것(그래서 `count(DISTINCT doc_id)` 가 맞는 모양), `ocr_engine` 컬럼이 `TEXT NOT NULL` 이고 `engine_name()` 으로 채워진다는 것, `doc_id` 가 이 경로에서 non-NULL 인 것, `'ocr_error'` 가 모든 `recognize()` Err 에 대한 단일 리터럴인 것, 양쪽 `Err` 분기가 `derivation_cache_put` 앞에서 빠져나가는 것, rec 그래프 출력이 `[-1,-1,11947]` 이라 클래스 검사 이전 방안이 성립한다는 것, §13.4 코퍼스 표(50/70/70/50, jpg 172 / png 63 / jpeg 4 / tif 1). 전부 성립. ### 게이트 `cargo clippy --workspace --all-targets -- -D warnings` exit 0, 경고 0. `cargo test --workspace --no-fail-fast` 188 개 바이너리에서 **1301 passed / 0 failed / 64 ignored**, exit 0. 이 PR 이 새로 만든 rustfmt 어긋남 0 (손댄 .rs 파일 전부 main 과 동일하거나 깨끗; 회차 2~4 가 건드린 테스트 파일은 깨끗하고, mock_ocr 편집은 `git diff -w` 가 비어 있는 공백 변경뿐이라 두 층 anyhow 오류는 그대로다). ### 회차 3 항목 확인 1. 과잉 수정 → 위 지적으로 대체·반영. 2. `pdf_ocr_events` 쿼리 → `ocr_engine` 이 V008 의 실제 `TEXT NOT NULL` 컬럼이고 `paddle-onnx` 리터럴로 채워지며 `doc_id` 도 non-NULL 임을 확인, 새 문장이 "상한이라는 점은 남는다" 로 스스로 한계를 밝히는 것도 적절. 3. `[ingest.image.ocr]` 정정 확인 + 이 PR 이 추가한 마크다운 줄의 config 키를 전부 훑어 살아 있는 키임을 확인(남은 `[image.ocr]` 한 곳은 이관 함정을 설명하려고 일부러 옛 키를 부르는 자리). 4. rustfmt 파리티는 요구 이상 — 손댄 10 개 파일이 HEAD 에서 rustfmt 깨끗.
Author
Owner

회차 5 리뷰 — APPROVE, 지적 0 건

리뷰어 토큰(작성자와 다른 계정)이 없어 정식 review 로 등록하지 못하고 코멘트로 남긴다. 판정은 APPROVE 다.

회차 4 의 유일한 지적(노출 구간 v0.31.0 → v0.28.0)이 5815f74 로 반영됐고, 그 한 줄만 확인했다.

정정이 사실이다. paddle 분기를 넣은 커밋 901416d(2026-06-04) 에 대해 git tag --contains 의 첫 태그가 v0.28.0 이다. git grep paddle v0.26.2 -- crates/ 는 아무것도 안 나오고 저장소에 v0.27.x 태그는 없으므로, pdf.ocr.engine = "paddle-onnx" 를 고를 수 있었던 첫 릴리스는 v0.28.0 이 맞다.

덧붙인 괄호도 사실이다. git ls-tree 로 보면 crates/kebab-app/src/ingest.rs 는 v0.28.0 · v0.30.0 · v0.30.1 에 없고 v0.31.0 에서 처음 나타난다(00ead29, lib.rs → src/ingest 분리). 함수 위치는 lib.rs:869(v0.28.0) → :926(v0.30.0, v0.30.1) → ingest.rs:727(v0.31.0) → :725(v0.32.0). "ingest.rs 만 훑으면 v0.31.0 처럼 보인다" 는 실재하는 함정이고, 적어 둔 범위("v0.28.0~v0.30.1 은 lib.rs")도 구간 내 모든 태그와 맞는다.

새 시작점에서 전제도 성립한다. v0.28.0 시점에 pdf_ocr_apply.rs:180 이 이미 err={} 로 노트를 찍었고 paddle_onnx.rs:379 에 .context("rec session run") 이 있었으며 v0.32.0 까지 그대로다. 그러니 그때부터 스캔 PDF 가 같은 조용한 손실을 겪을 수 있었다는 서술이 맞다.

문서 한 줄뿐이다. --stat 이 tasks/HOTFIXES.md 1 파일 1 삽입 1 삭제. 코드·테스트·clippy·fmt 에 닿을 수 없다.

문단이 어긋나지 않는다. 이제 v0.28.0 부터 선택 가능 → v0.32.0 까지는 단일 DCTDecode 페이지로 좁음 → v0.33.0 의 페이지 렌더링(#232)이 임의 인코딩까지 넓힘, 으로 단조롭게 읽힌다. 시작점을 앞당긴 것이 이웃 문장들과 충돌하지 않고, 오히려 뒤따르는 유보("기록의 대부분은 그 릴리스가 만든 것이겠지만, 전부는 아니다")를 더 정확하게 만든다. 항목 전체에서 시작 릴리스를 말하는 다른 문장은 없으므로 남은 v0.31.0 참조도 없다.


다섯 회차 요약

회차 검증 통과 지적
1 19
2 16
3 3
4 1
5 0

회차마다 여러 관점으로 훑고 각 지적을 적대적으로 재검증했다. 특히 회차 3 이 회차 2 의 맞는 지적을 잘못된 측정(존재하지 않는 파일을 grep)으로 뒤집었던 것을 회차 4 가 잡아냈고, 회차 5 에서 확정했다.

머지에 동의한다.

## 회차 5 리뷰 — APPROVE, 지적 0 건 > 리뷰어 토큰(작성자와 다른 계정)이 없어 정식 review 로 등록하지 못하고 코멘트로 남긴다. 판정은 **APPROVE** 다. 회차 4 의 유일한 지적(노출 구간 v0.31.0 → v0.28.0)이 `5815f74` 로 반영됐고, 그 한 줄만 확인했다. **정정이 사실이다.** paddle 분기를 넣은 커밋 `901416d`(2026-06-04) 에 대해 `git tag --contains` 의 첫 태그가 v0.28.0 이다. `git grep paddle v0.26.2 -- crates/` 는 아무것도 안 나오고 저장소에 v0.27.x 태그는 없으므로, `pdf.ocr.engine = "paddle-onnx"` 를 고를 수 있었던 첫 릴리스는 v0.28.0 이 맞다. **덧붙인 괄호도 사실이다.** `git ls-tree` 로 보면 `crates/kebab-app/src/ingest.rs` 는 v0.28.0 · v0.30.0 · v0.30.1 에 없고 v0.31.0 에서 처음 나타난다(`00ead29`, lib.rs → src/ingest 분리). 함수 위치는 `lib.rs:869`(v0.28.0) → `:926`(v0.30.0, v0.30.1) → `ingest.rs:727`(v0.31.0) → `:725`(v0.32.0). "ingest.rs 만 훑으면 v0.31.0 처럼 보인다" 는 실재하는 함정이고, 적어 둔 범위("v0.28.0~v0.30.1 은 lib.rs")도 구간 내 모든 태그와 맞는다. **새 시작점에서 전제도 성립한다.** v0.28.0 시점에 `pdf_ocr_apply.rs:180` 이 이미 `err={}` 로 노트를 찍었고 `paddle_onnx.rs:379` 에 `.context("rec session run")` 이 있었으며 v0.32.0 까지 그대로다. 그러니 그때부터 스캔 PDF 가 같은 조용한 손실을 겪을 수 있었다는 서술이 맞다. **문서 한 줄뿐이다.** `--stat` 이 `tasks/HOTFIXES.md` 1 파일 1 삽입 1 삭제. 코드·테스트·clippy·fmt 에 닿을 수 없다. **문단이 어긋나지 않는다.** 이제 v0.28.0 부터 선택 가능 → v0.32.0 까지는 단일 DCTDecode 페이지로 좁음 → v0.33.0 의 페이지 렌더링(#232)이 임의 인코딩까지 넓힘, 으로 단조롭게 읽힌다. 시작점을 앞당긴 것이 이웃 문장들과 충돌하지 않고, 오히려 뒤따르는 유보("기록의 대부분은 그 릴리스가 만든 것이겠지만, 전부는 아니다")를 더 정확하게 만든다. 항목 전체에서 시작 릴리스를 말하는 다른 문장은 없으므로 남은 v0.31.0 참조도 없다. --- ### 다섯 회차 요약 | 회차 | 검증 통과 지적 | |---|---| | 1 | 19 | | 2 | 16 | | 3 | 3 | | 4 | 1 | | 5 | **0** | 회차마다 여러 관점으로 훑고 각 지적을 적대적으로 재검증했다. 특히 회차 3 이 회차 2 의 맞는 지적을 잘못된 측정(존재하지 않는 파일을 grep)으로 뒤집었던 것을 회차 4 가 잡아냈고, 회차 5 에서 확정했다. 머지에 동의한다.
altair823 merged commit 07094f453a into main 2026-08-29 01:52:14 +00:00
altair823 deleted branch fix/paddle-onnx-rec-min-width 2026-08-29 01:52:16 +00:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: altair823-org/kebab#240