# NODA 출력 스키마 (v0.1)

이 스키마가 엔진의 계약이다. 앞으로 만들 모든 제품이 여기에 붙는다.
**바꾸기 가장 비싼 문서이므로, 변경 시 반드시 ADR을 남긴다.**

## 최상위 구조

```json
{
  "doc_id": "sha256:a3f2...",
  "schema_version": "0.1",
  "engine_version": "0.1.0",

  "source": {
    "filename": "2026년_해외농업자원개발_융자사업_공고.hwpx",
    "format": "hwpx",
    "bytes": 482113,
    "pages": 12,
    "sha256": "a3f2...",
    "container_path": ["경영평가자료.zip", "붙임3.hwpx"]
  },

  "access": {
    "readable": true,
    "drm": false,
    "drm_vendor": null,
    "password_protected": false,
    "reason": null
  },

  "blocks": [ ... ],
  "tables": [ ... ],
  "structure": { ... },
  "warnings": [ ... ],
  "metrics": { "parse_ms": 1840, "extractor_mix": {"hwpx-native": 0.92, "vlm": 0.08} }
}
```

**`engine_version` 은 어느 엔진이 썼나다** (W413) — `schema_version` 은 **계약**의 판이고
`engine_version` 은 **코드**의 판(`pyproject.toml` · `noda/__init__.py` 의 한 자리)이다. 고객 서버에 남은
JSON 을 보고 「그때 그 엔진인가」를 물을 수 있어야 한다 — 같은 계약 판이라도 파서가 고쳐지면 글자가 달라진다.

**`metrics.parse_ms` 는 시계라 실행마다 다르다** (W409). 고침을 「코퍼스 JSON 이
바이트로 같다」로 재는 것이 이 저장소의 버릇인데, 출력 전체를 해시하면 이 칸 하나
때문에 **멀쩡한 고침이 수백 건 바뀐 것처럼 보인다** (W409 에서 627건 중 295건 ·
뺀 뒤 0건). 맞댈 때 빼라 — 나머지 칸은 같은 파일에서 같은 값이다.

`source.format` 은 **확장자가 아니라 내용(매직 바이트)으로 판별한다.**
실제 공공 문서에는 `.hwpx` 확장자를 달고 있지만 내용은 HWP 바이너리인 파일이 있다.
확장자를 믿는 파서는 여기서 바로 깨진다. 불일치를 발견하면
`EXTENSION_MISMATCH` 경고를 남기되 **실제 내용에 맞는 파서로 처리한다.**

**판별한다고 읽는다는 뜻은 아니다.** `OZD` 는 `format` 에
그 이름이 찍히지만 백엔드가 없어 `readable: false` · `UNSUPPORTED_FORMAT` 이다.
`XLS` 는 UV 에서 읽기 시작했다 — XLSX 와 같은 규칙이고 `cell_range` 에 자리가 있다.
`PPTX` 는 UU 에서 읽기 시작했다 — `page` 는 슬라이드 번호, `bbox` 는 도형 위치(pt)다. 그룹 안 도형과 발표자 노트는 `bbox` 가 없다.
`TIF` 는 UT 에서 읽기 시작했다 — 그림 백엔드가 쪽마다 OCR 로 읽고 블록의 `page` 가 그 쪽이다.
`XLSX` 는 MK 에서 읽기 시작했다 — 시트마다 값이 든 범위를 표 하나로 내고, 위아래와 표 사이의 제목·비고 줄은 `paragraph` 블록으로 뗀다 (MN·MO). 자리는 `cell_range` 에 있다.

그래도 이름을 부르는 것이 값한다 — 오피스 zip 넷(`HWPX`·`DOCX`·`XLSX`·`PPTX`)은
매직 바이트가 같아서, 이름을 못 부르면 엑셀이 `unknown` 으로 신고된다.
받는 쪽에 **「엑셀인데 못 읽는다」와 「무엇인지 모른다」는 다른 말**이다 —
앞은 「그 포맷을 붙이면 된다」이고 뒤는 「이 파일이 무엇인지부터 봐야 한다」다.

**구 엑셀(`.xls`)은 한 걸음 더 나쁜 자리에 있었다** (EX). OLE 껍데기를
한글과 나눠 쓰기 때문에 `format: "hwp"` · `reason: CORRUPT` 로 신고됐다 —
**포맷도 사유도 틀린 신고**다. 지금은 OLE 안의 흐름 이름으로 가른다.

## `access` — 관문 2

파싱보다 먼저 통과해야 하는 관문. 한국 공공·금융 문서는 DRM으로 암호화된 경우가 많고,
**파일을 열지 못하면 파서 품질은 무의미하다.**

`readable: false`인 문서는 `blocks`가 빈 배열이어야 하며, `reason`이 반드시 채워진다.
빈 텍스트를 조용히 반환하는 것은 금지한다. (CLAUDE.md 규칙 6)

### **엔진은 코드로 말하고 제품이 옮긴다** (ADR-0040 (나))

`reason` 은 **계약**이다 — 고객의 말이 아니다. `DRM_ENCRYPTED` 는 기계가 거르는 값이고,
사람이 읽을 말(「**「한글 배포용 문서」라 본문을 못 엽니다 — 보호를 푼 파일로 주십시오**」)은
**엔진 밖**이 갖는다 (`service/wording.py`).

그래서 두 가지가 따라온다:

- **엔진의 CLI 는 코드를 찍는다** — `python -m noda.parse`·`noda.chunk`·`noda.search`·
  `noda.answer` 는 `이름 — CODE` 와 종료 코드로 말한다. 개발·검증용 명령이고 그 출력이
  **계약**이다. 셋이 같은 꼴인지 `tests/test_boundaries.py` 가 본다
- **고객이 보는 자리는 말을 한다** — `gateway`·`viewer`·`service`·`tools/customer_report.py`
  넷이 `wording` 을 거친다. 그래서 `python -m gateway` 와 `python -m noda.answer` 의
  출력이 **다르다** — 짝으로 소개하지만 한쪽은 제품이고 한쪽은 엔진이다

**엔진은 엔진 밖을 import 하지 않는다** (규칙 3·9). 그렇지 않으면 이 결정이 한 줄로
뒤집히므로 자가 지킨다 — `noda/` 안의 `service`·`gateway`·`viewer`·`tools` import 는 0개다.

### `reason` 코드

| 코드 | 의미 |
|---|---|
| `DRM_ENCRYPTED` | 문서보안 솔루션으로 암호화됨 |
| `PASSWORD_PROTECTED` | 비밀번호 필요 |
| `CORRUPT` | 파일 손상 |
| `UNSUPPORTED_FORMAT` | 지원하지 않는 포맷 |
| `UNSUPPORTED_VERSION` | 포맷은 맞으나 버전 미지원 — **아직 아무도 안 낸다** (W375) |

**「아직 아무도 안 낸다」는 넷은 계약에만 있다** (W375 에서 AST 로 셌다) — `noda/parse`·`semantic`·`chunk` 어디서도 안 내므로 **고객이 이 코드를 받을 길이 없다**. 그런데 **말과 자는 그 넷을 기다린다**: `service/wording.py` 가 `UNSUPPORTED_VERSION` 에 고객 문장을 달아 두었고, `noda/eval/metrics/review.py` 의 「사람이 봐야 함」 방아쇠에 나머지 셋이 들어 있다(**죽은 방아쇠라 `document_ready_rate` 를 틀리게 만들지는 않는다** — 안 켜지면 아무 일도 안 한다). **지우지 않았다** — 넷 다 **참으로 생길 수 있는 사실**이고 (버전 미지원 · 차례 불확실 · 붙임 못 찾음 · 수식 오분류) 지우면 다음 사람이 다시 짓는다. **대신 적어 두고 `tests/test_report_codes.py` 가 그 수를 못 박는다** — 늘면 울고 줄어도 운다.
| `EMPTY` | 내용 없음 |
| `NO_TEXT_LAYER` | **읽을 글자가 없다.** 세 자리에서 난다 — ① 글자층 없는 PDF(스캔): OCR 로 읽는다 · ② 그림 파일(png·jpg·tif): **OCR 이 돌았는데 글자가 없다**(사진·도장) · ③ 흐름 포맷(hwp·hwpx)인데 글자가 0자: 문단이 전부 그림이다 (W201). **②·③ 은 OCR 을 켜도 안 바뀐다** — ② 는 이미 돌았고 ③ 은 흐름 포맷에 OCR 경로가 없다. 곧 「OCR 을 켜면 읽힌다」는 ① 에서만 참이다 (W208 이 `wording.py` 에서 고친 자리) |
| `EXTRACTION_FAILED` | 읽는 과정이 돌지 못했다 — 문서가 아니라 실행 환경(메모리·모델·크기 한도) |
| `CONTAINER` | **여러 문서를 담은 묶음(zip)** — 읽지 않고 풀라고 말한다 (UQ). `python -m noda.unpack` 이 풀어 항목마다 문서를 낸다 |

**묶음에서 나온 문서**는 `source.container_path` 가 어느 묶음의 어느 경로인지 말한다
(예: `["자료.zip", "붙임/계획서.hwpx"]`). zip 안의 zip 은 깊이 3 까지 풀고, 그 아래는 `CONTAINER`
로 남긴다. 못 꺼낸 항목도 `readable: false` 문서로 나온다 — 암호는 `PASSWORD_PROTECTED`,
깨진 항목은 `CORRUPT`, 크기·개수 한도를 넘은 항목은 `EXTRACTION_FAILED`.

## `blocks` — 문서 본문

읽기 순서대로 정렬된 배열.

```json
{
  "id": "b012",
  "type": "table",
  "text": "...",
  "page": 3,
  "bbox": [72.0, 410.5, 523.0, 668.2],
  "parent": "b010",
  "level": 2,
  "extractor": "hwpx-native",
  "confidence": 0.98
}
```

| 필드 | 설명 |
|---|---|
| `type` | `heading` `paragraph` `table` `footer` — 이 넷만 난다. `list`·`image`·`footnote`·`header` 는 **아직 아무도 안 낸다** (W397 · 문서 1,444건 전수) |

**안 내는 넷은 글자를 잃어서가 아니다** (W397 에서 갈래마다 쟀다):

| 갈래 | 코퍼스에 그 꼴이 | 글자는 | 왜 안 내나 |
|---|---|---|---|
| `list` | 글머리표 문단이 **hwpx 574건 전부** | 문단으로 난다 | **ADR-0024 가 「블록 하나 = 문단 하나」로 이미 정했다** — 흐름 포맷에서 목록 항목은 문단이다 |
| `image` | 그림이 든 hwpx **489건(85.2%)** | 그림에는 글자가 없다 | 낼지는 맞바꿈이다 — 블록을 더하면 **번호가 다 밀려 라벨 85건이 깨진다**. 사람이 고를 자리 |
| `footnote` | hwpx **3건** | 칸·문단 글자에 **붙어서** 난다 (W395·W396) | 참이 3건이라 안 짓는다 — 갈래를 쓰면 그 글자를 어디에 둘지부터 정해야 한다 |
| `header` | 머리글 글자 **3개** | **셋 다 남는다**(사라짐 0) | 꼬리글(`footer`)만 참으로 난다. 조각내기·견주기 셋이 `(FOOTER, HEADER)` 로 기다리는데 **머리글 쪽은 죽은 가지**다 |
| `bbox` | 페이지 좌표. 원본 레이아웃 인용의 유일한 근거. **얻을 수 있으면 반드시 채운다** |
| `page` | 1부터. **0 은 "알 수 없음"** — HWP 바이너리처럼 페이지 정보를 주지 않는 포맷 |
| `cell_range` | **엑셀의 시트와 셀 범위** (`훈시사항!A3:K112` · 공백 든 시트는 `'업무 추진비'!A1:I21`). 좌표가 없는 대신 원문이 쓰는 주소다. 표 블록이면 그 표의 범위이고, 표의 범위는 따로 두지 않는다. 엑셀이 아니면 `null` (ADR-0025) |
| `parent` | 상위 블록 id. 문서 트리 구성. **칸 안의 표는 제 블록을 받고 `parent` 가 품은 표의 블록이다** — 줄 수 있으면 준다 (hwpx 가 그렇다). `TABLE_NESTED_ATTACHED` 는 **못 줄 때의 신고**이고 코퍼스에서 hwp 19건에만 난다 (W396 에서 그 둘을 거꾸로 읽어 라벨을 한 번 틀렸다) |
| `marker` | **글자에서 벗겨진 줄 머리 표시** (W463). 한글이 대제목의 **상자 속 번호**(`[1] 사업목적`)와 **빈 상자**(`□`)를 한컴 글꼴의 자리 글자로 찍는데, 그 글자는 `text` 에서 벗겨진다(ADR-0031 · 글꼴 안의 자리라 글자가 아니다). 그때 무엇이었나를 여기 남긴다 — `"1"`~`"9"` 상자 속 번호 · `"?"` **숫자를 잃은** 상자 번호 · `"□"` 빈 상자. 그 밖(글머리표 `▸`·`◆` · `○` · `✔`)이거나 표시가 글자로 남았으면 `null`. **`text` 는 그대로다** — 이 칸은 덧셈이다. 숫자는 한컴 자리 코드(U+F02B1~)에서 알고(한컴 글꼴로 그려 확인했다), PDF 가 코드조차 잃은 자리(U+F000)는 **판면의 꼴**(네 변 테두리 · 안쪽 잉크 · 글자 하나 크기)로 가른다 — 숫자는 지어내지 않는다. 지금은 **문단 블록만** 적는다(표 칸은 안 적는다) |
| `extractor` | `hwpx-native` `hwp-ole` `docx-native` `xlsx-native` `xls-native` `pptx-native` `pdf-text` `docling` `vlm` `ocr` — **열 가지다**(`xls-native`·`pptx-native` 가 빠져 있었다 · W395 가 채웠다) |
| `confidence` | 0~1. 어디가 약한지 측정하기 위한 값 |

### 블록의 크기는 포맷마다 다르다 (ADR-0024)

**이것은 흠이 아니라 사양이다.** 소비자가 알고 써야 한다.

    HWPX · HWP · DOCX     블록 하나 = **문단 하나**
    PDF                   블록 하나 = **줄 하나**
    OCR (스캔·그림)        블록 하나 = 인식기가 낸 **줄 하나**

PDF 에는 문단이라는 것이 없다. 줄을 문단으로 묶는 일은 레이아웃 분석의 몫이고,
손으로 추측하면 다단 편집에서 틀린 답을 조용히 만들어낸다. 그래서 묶지 않고
`LAYOUT_ANALYSIS_SKIPPED` 로 신고한다 (규칙 6).

실측 (문서 466건 · 블록 글자수 중앙값 · 2026-08 판):

    pdf 24.8자 · hwpx 40.1자 · hwp 73.4자

**어긋남은 거의 PDF 하나의 성질이다.** 원본 포맷끼리는 블록 수가 **거의** 같다 —
`block_count_agreement` 는 PDF 가 낀 짝에서 **0.841**(짝 155)인데
원본끼리 재는 `block_count_agreement_flow` 는 **0.996**(짝 107)이다 (2026-09-19).

처음 쟀을 때는 0.560(짝 55)과 **1.000**(짝 7)이었다. 흐름끼리가 1.000 에서 내려온 것은
**짝이 7 → 107 로 늘어서**이기도 하고, 한 포맷이 다른 포맷보다 **더 읽게 되어서**이기도 하다 —
예: HWP 가 글상자 글을 블록으로 내는데 HWPX 는 같은 글을 표 칸에 담는다 (RD).
**글자는 맞고 그릇이 다른** 자리라, `text_agreement` 는 오르고 이 자는 내린다.

### 그래서 무엇을 읽어야 하나

**문장 단위가 필요하면 `blocks` 가 아니라 조각(`chunk`)을 읽는다.**
조각은 블록 여럿을 이어 만들고, 답변 층(`answer/quotes.py`)은 그 조각의 줄을
도로 이어 문장으로 자른다. 그래서 **잘기 차이가 답변 품질에는 안 나타난다** —
같은 문서의 두 포맷에 같은 질문 85개를 물어 잰 값이다:

    답이 맞음   pdf 0.918 · 원본 0.918
    표 문항 52  pdf 0.923 · 원본 0.923
    본문 33     pdf 0.909 · 원본 0.909

`blocks` 는 **좌표를 들고 있는 가장 작은 단위**다. 하이라이트·인용 위치처럼
자리가 필요할 때 쓴다. 줄 블록은 그 쓰임에서는 오히려 정확하다.

**표에도 같은 말이 걸린다.** PDF 는 선언된 표 하나를 여럿으로 쪼개 보고
(괘선이 안 이어지면 다른 표다), 칸도 **24% 적게** 낸다 — 인쇄면에는
「빈 칸인지 병합인지」가 남아 있지 않아 없는 선을 병합으로 읽기 때문이다.
`tables` 를 직접 읽는 소비자는 이것을 알고 써야 한다.

**글꼴 전용 글자는 벗긴다** (ADR-0031). 사용자 영역(`E000~F8FF` 와 15·16면)
코드포인트는 **글자가 아니라 글꼴 안의 자리**다 — 글꼴이 없으면 무엇인지 알 수 없고,
같은 문서를 두 포맷으로 읽으면 다른 코드가 나온다 (HWPX `U+F02B1` · PDF `U+F000`).
원문에서는 `□`·`◦` 같은 글머리표로 보이던 자리다. 벗긴 문서는
`PRIVATE_USE_STRIPPED` 로 신고하므로, **있었다는 사실은 남는다.**

**글머리표 자리에 선 제어·미할당 코드도 같이 벗기고 같이 센다** (UI) — PDF 의 `U+0081`,
HWP 의 `U+19DB` 처럼 기호 글꼴 안의 자리다. **폭 없는 서식 문자**(`U+200B`·`U+00AD`·`U+FEFF`)는
원문에서 안 보이던 것이라 지우고 신고하지 않는다. **글꼴이 띄어쓰기로 내보낸 `U+FFFE`** 는
공백으로 읽는다 (UH). 그래서 `text` 에는 탭·줄바꿈 말고는 **보이지 않는 글자가 없다.**

**텍스트의 공백은 접는다.** 연속 공백·탭·줄바꿈은 공백 하나로 정규화한다.
백엔드마다 규칙이 다르면 같은 문서를 두 포맷으로 파싱했을 때 텍스트가 달라지고,
CER 과 TEDS 가 실제보다 나쁘게 나온다. `blocks[].text` 와 셀 내용 모두 해당한다.

**앞뒤 공백도 떼어낸다.** 접기만 하면 원문이 들여쓴 문단(`  농림축산식품부는 …`)의
앞 공백이 남아 **같은 문장이 포맷마다 한 글자 달라진다**. 이 줄은 W393 이 채웠다 —
그때까지 계약은 접기만 적었고, 기존 `blocks` 라벨의 글자 **3,723개를 세니 앞뒤 공백·
겹공백·줄바꿈이 0개**로 관행이 이미 만장일치였다 (W385 와 같은 꼴: 파서가 맞고 계약의
글이 모자랐다).

**좌표계는 포맷과 무관하게 하나다.**

    bbox = [x0, y0, x1, y1]
    단위 : 포인트 (1/72 인치)
    원점 : 페이지 **좌상단**. y 는 아래로 갈수록 커진다

PDF 는 좌하단 원점이므로 백엔드가 변환해서 넣는다
(`y0 = page_height - top`). 소비자가 포맷별로 분기하지 않아도 되게 하는 것이
이 계약의 목적이다.

**`bbox`를 나중에 붙일 수 없다.** 파서가 좌표를 버리면 파이프라인 전체를 다시 짜야 한다.
텍스트만 필요한 경우에도 좌표는 끝까지 들고 간다.

다만 HWPX·DOCX 같은 흐름 포맷은 좌표를 갖고 있지 않을 수 있다.
HWPX 는 `<hp:linesegarray>` 에 한컴이 마지막 렌더링 때 계산한 줄 위치를 캐시해 두므로
대개 얻을 수 있지만, 프로그램으로 생성된 문서에는 없다.

규칙은 셋이다 (ADR-0004).

1. 얻을 수 있으면 **반드시** 채운다. 알면서 버리는 것은 금지
2. 없으면 `null` + `LAYOUT_UNAVAILABLE` 경고
3. **지어내지 않는다.** 가짜 좌표는 조용한 실패의 한 형태다

## `tables` — 표 구조

```json
{
  "id": "t01",
  "block_id": "b012",
  "n_rows": 5,
  "n_cols": 4,
  "caption": "표 1. 지원 조건",
  "continued_from": null,
  "cells": [
    { "r": 0, "c": 0, "rowspan": 2, "colspan": 1, "text": "구분", "is_header": true,
      "bbox": [72.0, 410.5, 180.0, 452.0], "lines": null }
  ]
}
```

- **`lines` 는 칸 안의 문단 경계다 — `text` 를 바꾸지 않는 덧셈이다** (ADR-0042).
  `text` 는 문단을 **빈칸으로 이은 것**이고(KR — 안 이으면 `100`·`200` 이 붙어
  **문서에 없는 숫자**가 생긴다) 그 이음은 그대로다. 다만 이은 뒤에 경계가 사라져
  시맨틱 층이 「이 대시가 **문단 머리**인가, 한 문단 **안**인가」를 못 봤다 —
  같은 벽에 세 단위가 부딪혔다 (W322·W324·W325).

  **두 자리에서 `null` 이다**: 문단이 하나일 때(경계랄 것이 없다) · 문단이 전부
  한 글자일 때(자간을 벌린 머리말이라 `text` 가 **붙여서** 낸다 — 거기서 경계를
  내면 `["의","결","사","항"]` 이라는 뜻이 뒤집힌 값이 된다). **코퍼스 표 칸 309,326 중 6,679(2.2%)** 에만 차고 JSON 이 **1.51%** 커진다 (W335 실측).

- **지금 채우는 것은 HWPX 뿐이다** — PDF·스캔은 문단 개념이 없고 HWP·DOCX 는 아직 안 쟀다.

- **`is_header` 는 원천이 선언할 때만 참이다. 그리고 거의 안 온다** (W83, 2026-09-26).
  채우는 곳은 셋뿐이다 — HWPX 의 `hp:tc/@header="1"` · docling 어댑터 ·
  쪽을 넘은 표에서 **되풀이된 머리 행**(`pdf_continuation`). 실측:

  ```
  코퍼스 표 13,371개 중 머리 칸이 있는 표   287개 (2.1%)
    hwpx 2.4% · pdf 2.6% · pdf(스캔) 2.3% · hwp·xlsx·docx·xls **0.0%**
  HWPX 원본 XML 의 칸 4,313개 중 header="1"   21개 (0.5%)
  ```

  **PPTX 의 `firstRow="1"` 는 머리 선언이 아니다 — 재서 기각했다** (W354).
  그 깃발은 **표 스타일의 머리 행 꾸밈을 켜라**는 뜻이지 「첫 행이 머리다」가 아니다.
  코퍼스 PPTX 표 42개를 전수로 재니:

  ```
  firstRow="1"  22개 — **문서 둘뿐**(둘 다 제안서 서식)
     첫 행이 **글이 한 칸에만 있는 제목 줄**이다            13 (59%)
     한 행뿐이라 아래에 이름 붙일 것이 없다                3
     참 머리 후보                                        6  → **정밀도 0.27**
  firstRow 없음 20개 — 그중 2행 이상이 1개이고 그 첫 행도 머리가 아니다
  ```

  **미리 적은 관문은 「정밀도 0.90 이상 · 표 셋 이상」이었고 0.27 로 떨어졌다.**
  13개에서는 **진짜 머리가 둘째 행**이라, 쓰려면 「제목 줄 다음 행」이라는 **다른
  규칙**이 필요하다. **그 규칙은 병합을 못 쓴다** — 열세 줄 **전부 병합이 0**이고
  (`gridSpan`·`hMerge` 가 하나도 없다) 나머지 칸이 그냥 **빈 칸**이다.
  (**정정**: W354 가 처음에 「한 칸으로 전 열을 덮는다」고 적었는데 **재 보니
  병합이 0**이었다 — 가르는 사실은 **병합 꼴이 아니라 「글이 한 칸에만 있는가」**다.
  셈은 그 둘째 잣대로 했으므로 **13 과 정밀도 0.27 은 그대로**다.) **다시 열 조건: `firstRow="1"` 인 표가 다섯 문서 이상에서
  나오고 그중 첫 행이 제목 띠가 아닌 것이 정밀도 0.90 이상일 때** (지금 문서 2건 ·
  정밀도 0.27). **다음에 이 자리를 열 사람은 `firstRow` 를 다시 재지 마라.**

  **파서 흠이 아니다** — 속성은 모든 칸에 붙어 있고(`header=0` 이 4,292개) 값이
  0 일 뿐이다. **한글 문서가 머리 칸을 거의 선언하지 않는다.**
  **지어내지 않는다** — 첫 행이 머리처럼 보인다고 참으로 적지 않는다 (규칙 6 의 이면).
  **머리 행이 필요한 소비자는 제 규칙으로 정해야 한다.** 조각내기도 이 값을 쓴다
  (`noda/chunk/split.py`) — 표가 예산을 넘어 쪼개질 때 머리 행을 되풀이할지 여기서 정한다.
  **표 조각 8,861개 중 머리 행 없이 나가는 것은 1,222개(13.8%)다** (W84 에서 셌다 ·
  한 조각에 들어간 표는 머리 행이 첫 줄로 이미 들어 있다).
- **`n_rows`·`n_cols` 는 그 포맷이 표를 나타내는 방식 그대로다** (ADR-0033).
  포맷을 가로질러 같은 수가 나오지 **않는다**:

  | 포맷 | `n_rows`·`n_cols` 가 세는 것 |
  |---|---|
  | HWPX · HWP · DOCX · XLSX | 원천이 **선언한 좌표 격자** — 병합에 가려 안 그려진 선까지 센다 |
  | PDF · 스캔 | **그려진 선**으로 세운 격자 |

  실측: 한 법령안에서 `hwpx (31,18)` 인 표가 `pdf (16,7)` 이다. 그 HWPX 표의
  **실제 칸은 54개**이고 칸의 93%가 병합이다 — 「31행 18열」은 558칸이 아니라
  **좌표 격자**다. **둘 다 맞고 다른 것을 센다.**

  **포맷을 가로질러 견줄 때는 `n_rows`·`n_cols` 가 아니라 글자 있는 칸으로 견준다.**
  같은 문서의 두 포맷에서 격자는 최대 4.9배까지 벌어지지만 글자 있는 칸은 1.13배다.
- 병합셀은 `rowspan`/`colspan`으로 표현한다. 셀을 복제해 채우지 않는다.
- `bbox` 는 **칸의 자리**다 (ADR-0023). 블록의 `bbox` 와 같은 좌표계(포인트 · 좌상단 원점).
  괘선이 있는 PDF·스캔은 채우고, 흐름 포맷(HWP·DOCX)과 HWPX 는 `null` 이다 —
  ADR-0004 와 같은 원칙이다. 표 전체의 자리는 표 블록의 `bbox` 에 있다.
- 페이지를 걸친 표는 `continued_from`으로 연결한다.
- XLSX 칸의 `text` 는 **화면에 보이는 글자**다 — 숫자 서식을 입힌다 (`1,489,000` · `33%` · `(1,500)`, MP).
  저장된 값이 필요하면 원본을 연다. 서식을 모르면 값 글자 그대로이고, 날짜는 ISO(`2026-08-15`)다 — **일시는 `2026-08-20 12:34:00`** 이다. **다만 「ISO」는 날짜에 건 예외이고 시각만 든 칸은 아니다** (W385): `h:mm` 서식의 `12:34` 는 **원칙대로 화면에 보이는 글자**로 낸다. **이 줄이 없어 라벨이 한 번 틀렸다** — 「날짜는 ISO」를 시각까지로 읽어 `12:34:00` 이라 적었고, 다시 읽어 보니 **파서가 맞았다**.
- 평가는 TEDS로 한다. HTML 트리로 직렬화 가능한 형태를 유지할 것.
  **다만 PDF 의 끊긴 괘선에서는 못 지킬 때가 있고, 그때는 신고한다** (W379).
  칸의 span 이 덮는 자리가 **서로 겹치면** 격자를 세울 수 없다 — 한 자리를 두 칸이
  차지한다고 말하는 셈이다. 전수로 재니 표 13,715 중 **67(0.49%) · 문서 47** 이고
  **전부 PDF**(63이 글자층 · OCR 넷)다. 기전은 **끊긴 괘선에서 「가장 큰 직사각형」
  둘이 함께 선 것**이라 어느 쪽이 참인지 **그려진 선만으로 안 정해진다** — 그래서
  span 을 줄여 고치지 않는다(없는 구조를 지어내는 것이다). **글자는 어느 쪽이든 다
  들어 있어 잃는 것이 없고**, 그 문서는 `TABLE_STRUCTURE_UNCERTAIN` 으로 손을 든다.
  **격자를 세우는 소비자는 그 경고를 봐야 한다** (`noda/parse/table_grid.py`).

## `structure` — 공문 시맨틱 (차별화 지점)

**`doc_number`·`issued_date`·`issuer` 의 뜻** (W422 에서 못 박았다 — W421 이 「아무 백엔드도
채우지 않는다」고 찾은 칸이다). 셋 다 **문서가 제 서식에 스스로 적은 것**이고, 서식에 없으면
`null` 이다 — 본문·제목·사업명·문의처·저작권 문구에서 **짐작하지 않는다** (`title` 의
「없으면 null」(W280)과 같은 자리). 자는 `issue_f1`(확인 범위 `issue`)이고 **빈칸은 아예 빼고**
견준다 — 자간을 벌린 `고성 군수` 와 `고성군수` 는 같은 값이다.

| 칸 | 공문 서식 (두문 `수신·경유·제목` 이 있는 기안·시행문) | 결재 머리표 (전자결재 머리가 붙은 계획서) | 공고·고시 |
|---|---|---|---|
| `doc_number` | 결문 **시행** 줄의 `부서명-일련번호` — `행정지원과-4628` | `문서번호`·`생산등록번호` 칸의 값 — `급수부-9167` | 머리의 **발령번호 줄 그대로** (기관 접두 포함 · 둘러싼 괄호만 뺀다) — `통일부 공고 제2026–163호` |
| `issued_date` | 시행 줄 괄호 안의 날짜. **비어 있으면 `null`** — 기안문(내부결재)은 시행일이 없다. 결재선의 결재일(`관장 2026. 8. 28.`)을 옮기면 지어낸 것이다 | `등록일(자)`·`시행일` 칸 | 발신명의 **바로 위**의 날짜 (앞 문단 끝에 붙어 오기도 한다 — `… 바랍니다. 2026. 8. 20.`) |
| `issuer` | **두문의 행정기관명** (`수신` 바로 위 줄) — `서울특별시교육청강남도서관`. 발신명의(`…원장`)가 따로 있어도 두문을 낸다: 두문은 모든 공문에 있고 직명이 안 섞인다 | 표지의 기관 줄 (`군 위 군`) — 아직 안 읽는다 | **발신명의 줄 그대로** — 직명이 붙었으면 붙은 채(`통일부장관` · `(재)부산지역산업진흥원장`), 기관명뿐이면 그대로 |

- **날짜는 ISO `YYYY-MM-DD`** 다 (엑셀의 날짜 규칙 · W383 과 같은 자리). 판면의
  `2026. 09. 14.` · `2026년 9월 14일` 이 다 `2026-09-14` 다. **두 자리 연도(`'26. 6. 24.`)는 안 받는다.**
- 본문이 **남의 문서를 부르는 번호**(`관련: 교육부 입학지원·공정성팀-3180(2026. 8. 25.)`)는
  이 문서의 번호가 아니다. 결문의 시행 줄만 본다.
- **「받음」은 수신이다** (W438) — 옛 사무관리규정 서식이고 한 기관의 제 기안 서식 759건이 「문서번호 해농협회 제2025-23호 · 시행일자 2025.5.20. ·
  (경유) · 받음 내부결재 · 참조 · 결재란」으로 온다. 그 서식은 **결재 머리표 열**로 읽는다(번호 = 「문서번호」 값 그대로(기관 접두 포함) ·
  시행일 = 「시행일자」 · 발신은 안 읽는다 — 머리의 기관 줄은 아직 안 읽는 것에 든다) · 수신은 「받음」 값(`내부결재`)이다.
- **아직 안 읽는 것**: 표지·머리표의 기관 줄 (계획서의 `(상 수 도 사 업 본 부 )`) · 가이드북
  표지의 발행일. 라벨은 적되 엔진이 못 내면 그대로 빠진 몫으로 남는다.

**`recipient`·`recipients` 의 뜻** (W430 · ADR-0045 (가)). 공문 서식(두문 「수신」 + 결문 「시행」)에서만 내고
서식이 없으면 `null`·`[]` 다 — 공고·보도자료·계획서에서 「수신」을 짐작하지 않는다. 자는 `recipient_f1`(확인 범위 `recipient`).

| 칸 | 뜻 |
|---|---|
| `recipient` | 두문 「수신」 줄의 값 **그대로** — `수신자 참조` · `내부결재` · `(사)해외농업자원개발협회` · `국토교통부장관(운영지원과장)` |
| `recipients` | 「수신자 참조」면 결문 위의 `수신자 ○○장, ○○장, …` 줄을 **괄호 밖의 쉼표**로 가른 목록(글자층의 줄바꿈으로 토막난 것은 결재선 앞까지 잇는다) · 수신이 기관 하나면 그 하나 · `내부결재`면 **빈 목록**(안으로 도는 문서다 — 결문의 수신자 칸을 옮기지 않는다) · 목록 줄이 없으면 빈 목록 |

- **경유·결재선은 안 낸다** — 경유는 코퍼스·받은 문서 94건 중 1건만 값이 있어 자가 못 서고, 결재선은 사람 이름이 들어
  라벨(커밋된다 · W356)에 남길 수 없다 (ADR-0045 (나) · 다시 열 조건은 ADR 에 있다).

```json
{
  "title": "2026년 해외농업자원개발 융자사업 시행공고",
  "doc_number": "제2026-15호",
  "issued_date": "2026-01-15",
  "issuer": "농림축산식품부",
  "recipient": "수신자 참조",
  "recipients": ["한국농어촌공사사장(글로벌농업개발부)", "(사)해외농업자원개발협회장"],

  "revisions": [],          // 개정안이면 채워진다 — 아래 `revisions` 절
  "sections": [
    { "id": "s01", "label": "제1조", "title": "목적",
      "start_block": "b005", "end_block": "b008", "children": [] }
  ],

  "attachments": [
    { "label": "붙임 1", "kind": "attachment", "title": "사업계획서 서식",
      "start_block": "b040", "referenced_by": ["b012", "b031"] }
  ]
}
```

`kind`: `attachment`(붙임) · `appendix`(별지) · `table_appendix`(별표) · `form`(서식)

**포스터·발표자료의 `title` 은 문서가 가리키는 사업·행사의 이름이다** — 가장 큰 글씨가 아니다
(W295 · 사람이 골랐다). 홍보물은 제목만큼 크게 **구호**를 적는다 (`청년의 잠재력을 더 큰 기회로` ·
`AI특허분석부터 전문가 멘토링까지!`). 그것은 **읽는 사람을 부르는 말**이지 문서의 이름이 아니다 —
같은 사업의 다른 홍보물은 구호가 다르지만 이름은 같다.

**`title` 은 문서가 스스로 적은 제목이다.** 판면에 문서 자신의 제목이 **없거나**(본문을 갈무리한 그림 한 장 ·
붙임만 떼어 올린 파일) **서로 다른 제목이 여럿이면**(시트마다 제목 줄이 따로인 엑셀) `null` 이다 —
**첫 줄로 지어내지 않는다** (W280 · 사람이 골랐다). 첫 붙임의 이름이나 첫 시트의 제목은 문서 전체의 이름이 아니다.

**`outline` — 업무 문서의 목차** (W462 · ADR-0048 (나)). 문서가 **줄 머리에 스스로 적은 항목 표시**로 세운 나무다.
조문 구조(`sections` · 편장절관조)가 선 문서는 **그것이 목차라 `outline` 은 빈 목록**이다 — 조 안의 `1.`·`가.` 는 호·목이지 목차가 아니다.
자는 `outline_f1`(확인 범위 `outline`).

```json
"outline": [
  { "label": "Ⅰ.", "level": 1, "title": "추진 배경", "block": "b012", "children": [
      { "label": "1.", "level": 2, "title": "현황", "block": "b013", "children": [] } ] },
  { "label": "□", "level": 1, "title": "지원 내용", "block": "b030", "children": [] }
]
```

| 칸 | 뜻 |
|---|---|
| `label` | 표시 **그대로** — `Ⅱ.` · `3.` · `다.` · `2)` · `(1)` · `①` · `□`. 글자에서 벗겨진 표시는 `Block.marker` 를 쓴다 — 상자 속 번호는 그 숫자(`3`) · **숫자를 잃었으면 `?`** (차례로 세어 지어내지 않는다 — 원문이 번호를 건너뛰는 일이 있다) |
| `level` | 그 마디의 **깊이** — 사람이 읽는 나무에서 몇 단계 아래인가 (1 이 가장 얕다). 공문의 차례는 행정업무규정 시행규칙 제2조 ① 이 정한다 — `1.` → `가.` → `1)` → `가)` → `(1)` → `(가)` → `①` → `㉮`, 기호는 `□` · `○` · `-` · `ㆍ`. **같은 갈래가 늘 같은 단계는 아니다** — 요약서 상자의 `□` 는 1단계이고 본문의 `Ⅰ` 아래 `□` 는 2단계다(W462 의 라벨이 드러냈다) |
| `title` | 표시 뒤의 글 — **그 줄의 끝까지**(흐름 포맷은 문단의 첫 줄 · PDF 는 그 줄). 자는 빈칸을 빼고 **앞 20자**만 견준다 (긴 항목 문장이 포맷마다 줄에서 끊기는 자리가 다르다) |
| `block` | 표시가 든 블록의 id. 정답 라벨은 `null` (파서를 안 보고 적는다) |

- **마디가 되는 표시**: 번호(`Ⅰ.` 로마 숫자 · `1.` · `가.` · `1)` · `가)` · `(1)` · `(가)` · `①`·`❶` · `㉮`)와 **`□`·`■`**.
  **점 없는 번호 대제목도 번호다** — 공고문 PDF 는 번호를 색 상자에 따로 그려 `1 사업목적` · `2 사업내용` 으로 온다(W462 첫 라벨에서 찾았다 · `label` 은 `1`).
  `○`·`-`·`ㆍ`·`※` 는 **마디가 아니다** — 문서마다 중앙값 15번 나와 목차가 아니라 본문 목록이 된다 (W461).
  **상자 속 번호도 마디다**(W463) — 글자로는 벗겨지고 `Block.marker` 에 남는다. 규정에 없는 표시라 **서열이 문서마다 다르다**
  (맨 위 · `□` 아래 · `1.` 아래 — hwpx 33건의 처음 나온 차례가 갈렸다) — 그래서 **처음 나온 자리에서 단계를 정한다**: 그때 쌓인 단계 바로 아래, 비어 있으면 맨 위.
  **한글 갈래(`가.`·`가)`·`(가)`)는 `가` 부터 시작할 때만 표시다** — 점 없는 번호가 `1`·`2` 로 이어질 때만인 것과 같은 꼴이다.
  앞의 `가` 없이 선 것은 사단법인 줄임말 `(사)한국…협회` 와 앞 줄 문장 끝이 넘어온 `다. …` 였다(코퍼스 전수 44 중 헛것 약 37).
- **어디서 읽나**: 본문의 문단과 **한 행짜리 상자**다 — 제목 상자(한 칸)와 **머리표(번호 칸 + 제목 칸 · 매뉴얼의 `1 │ K-Startup 로그인`)**. **두 행 이상인 표 안의 표시는 안 센다** — 제출서류·평가표의 `1.` 과 화면 캡처 상자(설명 행 + 그림 행)의 `①` 은 목차가 아니다 (W462 에서 머리표를 보고 「여러 칸」을 「두 행 이상」으로 바로잡았다).
  **첫 붙임의 실물이 시작하는 데서 멈춘다** — 붙임의 서식 칸(`1. 신청인`)은 이 문서의 목차가 아니다.
- **날짜·쪽 번호·차례(목차 쪽)는 표시가 아니다** — `2026. 10.` (숫자 둘 이상 묶음) · `- 1 -` · `1. 사업비전 및 목표 ....... 1` (점선과 쪽 번호가 붙은 차례 줄 · W264 가 장·절에서 이미 빼는 자리).
- 표시가 없는 문서(포스터·엑셀·그림)는 **빈 목록**이다. 지어내지 않는다.

**`referenced_by`가 핵심이다.** 본문에서 "붙임 3 참조"라고 한 지점과 실제 붙임을 연결한다.
이 관계를 놓치면 답변이 본문만 보고 엉뚱하게 나온다. 범용 파서가 전부 놓치는 부분.

## `revisions` — 개정안이 무엇을 바꾸는가 (BQ)

법령 개정안은 **신·구조문대비표**로 무엇이 바뀌는지 밝힌다. **사람이 그 표에 이미
적어 둔 것을 읽을 뿐**이고 두 문서를 비교하는 것이 아니다 — 비교는 사람이 했다.

`structure.revisions` 와, 그 대비표에서 나온 `chunk.revisions` 에 실린다.

```json
{
  "kind": "amended",
  "label": "제5조제1항",
  "current": "① 실증특례를 받으려는 자는 …",
  "revised": "① 실증특례를 받으려는 자는 별지 제1호서식에 따라 …",
  "table_id": "t00003",
  "row": 7
}
```

```
kind       added · deleted · amended · unchanged · **renumbered**
           renumbered 는 **글은 그대로인데 번호가 바뀐다** — 「그대로」와 섞으면
           조문을 가리키는 인용이 **낡은 번호를 든다** (코퍼스 105줄에서 세어 정했다)
label      바뀌는 자리 (`제5조제1항` · `13.` · `별표 2`)
current    현행 쪽 글. 신설이면 `<신 설>` 이 아니라 **빈 문자열**이다
revised    개정 쪽 글. 삭제면 빈 문자열이다
table_id   **어느 대비표에서 왔나** — 인용 근거다
row        그 표의 몇째 행인가
```

**`table_id` 와 `row` 는 비어 있지 않다** (ADR-0038). 이 계약이 「대비표에서 온다」를
못 박는 자리다 — 문장으로만 적힌 별표·별지 개정은 **대비표에 행이 없어 안 낸다.**
실측에서 개정 1,359건이 **전부** `table_id` 를 든다.


## `warnings` — 파서의 자기 신고

```json
{ "code": "TABLE_SPLIT_ACROSS_PAGES", "page": 7, "block_id": "b031",
  "message": "표 3개가 한 쪽보다 높습니다. … 그 표의 page 는 표가 시작하는 쪽이고, 둘째 쪽 이후의 칸 글자도 그 쪽을 댑니다. …" }
```

| 코드 | 의미 |
|---|---|
| `TABLE_SPLIT_ACROSS_PAGES` | 표가 한 쪽보다 높다. **흐름 포맷에서는 그 표의 `page` 가 시작 쪽이고 둘째 쪽 이후의 칸도 그 쪽을 댄다** (ADR-0039). 몇 쪽인지는 안 센다 — 높이로 세는 길은 재서 접었다 (W127: 여러 쪽 표에서 맞힌 것이 11 중 4) |
| `TABLE_STRUCTURE_UNCERTAIN` | 표 구조 복원 신뢰도 낮음 |
| `TABLE_NESTED_ATTACHED` | 중첩 표가 제 블록 없이 품은 표의 블록을 함께 쓴다 — 칸·병합은 확실하다 (UK) |
| `AUTO_NUMBER_UNRESOLVED` | 자동 번호(개요 번호 등)를 되살리지 못해 번호 없이 두었다 — 숫자 번호는 되살린다 (UN) |
| `LOW_OCR_CONFIDENCE` | OCR 로 읽은 쪽. 글자가 원문과 다를 수 있다 |
| `PAGE_HAS_NO_TEXT` | 글자층도 없고 OCR 도 글자를 못 찾은 쪽 (JZ). **문서의 성질이다 — 할 일이 없다** |
| `OCR_DISABLED` | **OCR 경로가 꺼져 있어 안 읽은 쪽이 있다** (W343 · `NODA_OCR`). **문서가 아니라 설정이고 켜면 읽어 본다** — `PAGE_HAS_NO_TEXT` 와 늘 함께 붙지만 **다른 코드를 쓴다**: 그 둘과 `OCR_RUNTIME_FAILURE`(기계 사정)는 결과가 같고(쪽을 못 읽었다) **할 일이 다르다.** 한 코드에 두면 소비자가 **말을 글자로 갈라 읽어야** 한다 (규칙 3) — 실제로 그래서 고객이 OCR 이 켜져 돌았는데도 「꺼져 있습니다」라는 말을 받았다 |
| `READING_ORDER_AMBIGUOUS` | 다단 편집 등으로 순서 불확실 — **아직 아무도 안 낸다** (W375) |
| `ATTACHMENT_UNRESOLVED` | 붙임 참조를 찾지 못함 — **아직 아무도 안 낸다** (W375) |
| `EMBEDDED_OBJECT_SKIPPED` | 삽입 객체(수식·차트) 미처리 |
| `HIDDEN_SHEET_SKIPPED` | 엑셀의 숨은 시트를 읽지 않았다. 사람이 보는 문서가 아니다 (MK). **이 규칙에는 실물 근거가 있다** (W384) — 공개 포털에서 받은 정부 엑셀 한 건의 `veryHidden` 시트에 **1998년 엑셀 4.0 매크로 바이러스의 몸통**이 들어 있었고, 이 규칙이 그 글자를 **블록·표·조각 어디에도 안 들어가게** 막았다(실물로 재서 0). **우리는 실행하지 않는다** — `openpyxl` 은 XML 만 읽는다. 곧 이것은 편의가 아니라 **울타리**다 |
| `EXTENSION_MISMATCH` | 확장자와 실제 내용이 다름 |
| `LAYOUT_UNAVAILABLE` | 좌표를 얻지 못함. bbox 가 null 인 블록이 있다 |
| `LAYOUT_ANALYSIS_SKIPPED` | 레이아웃 분석 없이 줄 단위로 블록을 잘랐다 |
| `TEXT_RECONCILED` | 읽개 둘이 다른 글자를 내어 한쪽으로 바로잡았다 (JR) |
| `PRIVATE_USE_STRIPPED` | 글꼴 전용 글자를 벗겼다 (ADR-0031). 원문에서 `□`·`◦` 같은 글머리표로 보이던 자리다 |
| `FORMULA_MISCLASSIFICATION_RISK` | 한글 + 특수기호 인접 구간 — **아직 아무도 안 낸다** (W375) |

마지막 코드는 실제 사고 사례에서 나왔다. 파싱 엔진 설정 하나(`formula_enable=true`)가
한글을 수식으로 오분류해 운영 3개 망을 동시에 죽인 일이 있다. 우리는 그 구간을 신고한다.

## 예약 필드 (v0.1에서는 비워둠)

```json
"acl": { "owner": null, "groups": [], "classification": null }
```

권한 기반 검색은 Phase 4에서 첫 고객의 실제 그룹웨어를 보고 확정한다.
지금 상상으로 설계하면 100% 다시 짠다. 자리만 비워둔다.


---

## 조각 (Chunk) — Phase 2

검색에 넣을 단위. `Document` 를 자른 결과이고, **원본으로 되짚어갈 근거를
함께 들고 간다.**

```json
{
  "id": "c0007",
  "doc_id": "sha256:6f8001af1e08cbc5",
  "kind": "body",
  "text": "제5조(신청) ① 실증특례를 받으려는 자는 …",
  "blocks": ["b00042", "b00043"],
  "anchors": [{ "page": 4, "bbox": [72.0, 210.5, 523.0, 388.2] }],
  "path": ["환경오염시설법 하위법령 개정", "제2장 통합허가", "제5조 (신청)"],
  "outline": [],             // 업무 문서면 목차 길 — 예: ["2 지원내용", "가. 지원규모"]
  "table_id": null,
  "continued_from": null,
  "extractors": ["pdf-text"],
  "revisions": []            // 대비표에서 나온 조각이면 채워진다
}
```

| 필드 | 뜻 |
|---|---|
| `blocks` | 이 조각을 이루는 블록 id. **비어 있을 수 없다** — 근거 없는 조각은 만들지 않는다 |
| `anchors` | 쪽마다 하나. **엑셀은 쪽이 아니라 시트 범위로 가린다** (ADR-0022·0025) — `page` 가 0 이라 한 조각에 `page=0` 짜리 앵커가 여럿일 수 있고, 가르는 것은 `cell_range` 다 (코퍼스 조각 19,512 중 19 · 문서 5 · 전부 엑셀 · W382 가 전수로 쟀다). **다만 흐름 포맷(HWPX·HWP)의 표는 시작 쪽만 든다** (ADR-0039) — 표가 `hp:tbl` 하나라 표 안의 줄 위치가 없고, 둘째 쪽 이후의 칸 글자도 시작 쪽을 댄다 (`page_agreement_table_micro` **0.696**). PDF 는 표를 쪽마다 세우므로 이 예외가 없다. `bbox` 는 얻을 수 있을 때만 (ADR-0004). `cell_range` 는 엑셀의 시트·셀 범위 주소(`훈시사항!A3:D20`) — 엑셀이 아니면 `null`, 시트를 넘으면 시트마다 하나 (ADR-0022) |
| `path` | 문서 안에서의 자리. **맨 앞은 문서 제목**이다. 검색 결과에 "어느 문서의 어느 조항인가" 를 말해주고, 색인에도 함께 들어간다. **제목이 `null` 인 문서에서는 맨 앞이 제목이 아니다** — W280 이 「판면에 문서 자신의 제목이 없거나 여럿이면 `null`」로 정한 뒤 생긴 자리다. 그런 문서의 조각은 `path` 가 **비어 있거나** (붙임 밖의 본문) **붙임 이름으로 시작한다** (코퍼스 23건 · 조각 370 중 빈 것 301 · 이름이 든 것 69 · W382 가 전수로 쟀다). **제목을 지어내지 않는 쪽을 골랐다** — 없는 제목을 맨 앞에 끼우면 인용이 거짓말을 한다 |
| `outline` | **이 조각이 놓인 목차 길** (W465 · ADR-0048 (다)) — `structure.outline` 의 마디를 위에서부터 `표시 제목` 꼴로(숫자를 잃은 상자 번호 `?` 는 제목만). **조각의 블록들이 함께 든 가장 깊은 마디까지만** 적는다 — 조각이 두 마디에 걸치면 둘의 위 마디까지다(한쪽 이름을 다른 쪽 글에 붙이면 거짓이다). 목차가 없는 문서·첫 붙임 뒤·조문 구조가 선 문서는 빈 목록이다. **색인에는 안 든다** — `path` 는 색인되는데 거기 실으니 검색이 흔들렸다(W464: 찾은 질문 621 → 620 · 마디에서 끊으면 → 603). 그래서 `/context` 의 머리 줄과 `/search` 결과에서 **보여 주기만** 한다 |
| `kind` | `body` · `table` · `attachment` |
| `table_id` | 표 조각일 때 원본 표 |
| `continued_from` | 큰 표를 행 단위로 나눈 경우 앞 조각 |
| `extractors` | 이 조각을 이룬 블록을 **어떻게 읽었는가**. 겹치지 않게 모은 값. `ocr`·`vlm` 이 있으면 그림에서 기계가 읽어낸 글자다 |

### 계약

- **아무 글자도 잃지 않는다.** 조각을 다 모으면 원본 글자가 그대로 나온다
  (`gate4_chunk` 의 `chunk_text_coverage`)
- **겹치지 않는다.** 겹치면 인용이 어느 조각에서 왔는지 흐려지고,
  글자 보존을 잴 수 없다. 자를 자리를 구조에서 가져오므로 겹칠 필요가 없다.
  **딱 한 가지 예외가 있고 그것은 아래 281줄이 적어 둔 것이다** — 표가 예산을 넘어
  쪼개지면 **머리 행을 되풀이한다**(`is_header` 를 쓰는 까닭이다). **전수로 쟀다**
  (W380): 조각 19,440 중 **159(0.8%) · 되풀이된 글자 3,112(원본의 0.052%) · 문서 52**
  이고 **그 밖의 겹침은 0** 이다. `chunk_duplicate_rate` 가 그 몫을 잰다 —
  **`chunk_text_coverage` 로는 못 본다**(제 머리말이 「조각 쪽이 더 많을 수 있다 —
  **모자란 쪽만 본다**」고 적어 두었다. 문서를 통째로 두 번 내도 1.000 이다).
  **다중집합으로 세면 모자라게 나온다** — 되풀이된 글자가 문서 어딘가에 이미 있으면
  원본 쪽이 흡수한다(실측 2,670 대 3,112). **줄로 세야 한다.**
- **행은 쪼개지 않는다.** 표의 한 행이 예산보다 길면 조각이 커진다.
  그 사실은 `chunk_oversized_rate` 가 잰다 — 숨기지 않는다
- **어떻게 읽었는지를 함께 들고 간다.** `extractors` 는 인용까지 따라간다.
  이것이 없으면 OCR 이 잘못 읽은 글자가 문서가 그렇게 적은 것처럼 인용되고,
  **`groundedness` 는 1.000 을 유지하면서 거짓말을 한다** — OCR 글자가 곧
  근거가 되기 때문이다 (ADR-0010)
