docs: analyze Fact Ledger output schema drift

This commit is contained in:
2026-07-22 10:43:42 +09:00
parent 4279fc70ab
commit 16b4259855
@@ -0,0 +1,468 @@
# Fact_Ledger_base.json 출력 형식 불일치 원인 분석
## 1. 분석 대상과 결론
### 1.1 분석 대상
이 보고서는 다음 파일을 상호 대조하여 작성한 것이다.
- 실행 작업명세서: `Stage_1_Part_1_Codex_v3.yml`부터 `Stage_1_Part_4_Codex_v3.yml`까지
- 실제 실행 결과: `Results_00_7_21_Codex/Fact_Ledger_base.json`
- 중간 산출물:
- `Results_00_7_21_Codex/stage1_tmp/fact_ledger/Fact_Ledger_base_candidate.json`
- `Results_00_7_21_Codex/stage1_tmp/fact_ledger/fact_exception_manifest.json`
- `Results_00_7_21_Codex/stage1_tmp/fact_ledger/fact_ledger_writer_report.json`
- 비교 기준 문서: `Analysis_Stage_1_Codex_v1.md`의 4.5.4 `Fact_Ledger_base.json`
### 1.2 최종 결론
실제 `Fact_Ledger_base.json`이 분석 문서 4.5.4의 예시와 다른 주된 이유는 LLM의 자유 추론이나 실행 중 우발적 직렬화 오류가 아니다. **Part 4 YAML의 FL1 Python 코드가 처음부터 분석 문서와 다른 자료형으로 candidate row를 만들고, FL3 Python writer가 필드명 집합만 검사한 뒤 그 값을 그대로 최종 파일에 기록하도록 구현되어 있기 때문**이다.
정확한 판정은 다음과 같다.
1. **최상위 형식과 필드명은 일치한다.** 실제 파일은 wrapper 없는 JSON 배열이고, 48개 row 모두 정확히 27개 필드를 가진다. 누락 필드와 추가 필드는 0개이다.
2. **필드 자료형과 ID 표기 규칙은 다르다.** 분석 문서가 배열ㆍ객체ㆍ불리언으로 표시한 여러 필드가 실제 결과에서는 객체ㆍ문자열ㆍ불리언 등 다른 자료형을 사용한다.
3. **실제 최종 파일은 FL1 candidate와 완전히 동일하다.** 이번 실행의 exception 수는 0개이므로 FL2 LLM adjudicator가 값을 바꾸지 않았다.
4. **FL3의 `candidate row schema is closed` 검사는 JSON Schema 검사가 아니다.** `set(row) == FINAL_FIELD_SET`만 확인하므로 키 이름이 맞으면 잘못된 자료형도 통과한다.
5. **`amount`와 `object_spec`에는 이중 인코딩 결함이 존재한다.** upstream 객체ㆍ배열이 JSON 객체ㆍ배열로 유지되지 않고 JSON 문법을 담은 문자열로 변환되었다.
6. 따라서 이번 차이는 두 층으로 나누어 보아야 한다.
- 객체ㆍ배열ㆍ불리언의 설계 차이는 분석 문서와 실행 코드 사이의 계약 drift이다.
- `amount`와 `object_spec`의 JSON 문자열화는 별도의 실질적 구현 결함이다.
---
## 2. 실제 출력 형식 조사
### 2.1 최상위 구조와 보존 상태
실제 파일을 기계적으로 검사한 결과는 다음과 같다.
| 검사 항목 | 실제 결과 | 판정 |
|---|---:|---|
| 최상위 JSON type | Array | 4.5.4와 일치 |
| 최종 Fact row 수 | 48 | BO 48건과 일치 |
| row별 필드 수 | 모든 row가 27개 | 4.5.4와 일치 |
| 27개 필드 중 누락 | 0개 | 일치 |
| 지정 외 추가 필드 | 0개 | 일치 |
| BO 누락ㆍ중복 | 0개 | conservation gate 통과 |
| unknown BOㆍ증거ㆍ구조 참조 | 0개 | reference gate 통과 |
| FL2 exception | 0개 | LLM 판정 미실행 |
| writer status | `READY_WITH_REVIEW` | 형식 차이를 오류로 인식하지 않음 |
따라서 문제를 “JSON 배열 형식이 완전히 틀렸다”거나 “27개 필드가 지켜지지 않았다”고 평가해서는 안 된다. **구조적 외형은 일치하지만 field-level type contract가 불일치하는 문제**이다.
### 2.2 실제 row의 자료형
48개 row 전체에서 관찰된 자료형은 다음과 같다.
| 필드 | 실제 자료형 | 실제 값의 성격 |
|---|---|---|
| `fact_id` | String | `F-001` 형식 |
| `source_bo_id` | String | `bh1`, `bh10` 등 upstream BO ID |
| `type` | String | `event` 등 |
| `date` | null | 48건 모두 null |
| `parties` | Array | 당사자 role 객체 배열 |
| `object_spec` | String | `"[]"`처럼 JSON 배열 문법을 담은 문자열 |
| `amount` | String | JSON 객체 문법을 담은 문자열 |
| `action` | String | 사실ㆍ행위 설명 |
| `evidence_refs` | Array | `E-###` 목록 |
| `credibility` | String | `high`, `medium`, `low` |
| `legal_centrality` | String | `critical`, `medium` 등 |
| `proof_strength` | String | `high`, `medium`, `low` |
| `legal_effect_roles` | Array | role 이름 목록 |
| `claim_chain_ref` | Object | claimㆍliability group 및 signal count |
| `linked_structures` | Object | structureㆍissueㆍassetㆍevidence index 묶음 |
| `must_not_drop_in_claim_types` | Array | downstream module 목록 |
| `must_consider` | Boolean | true 또는 false |
| `state_context` | Object | BO type 및 issueㆍasset cluster |
| `money_claim_effect` | Object | enabled와 source BO ID |
| `commercial_successor_effect` | Object | enabled와 source BO ID |
| `actio_roles` | Array | actio signal projection |
| `linked_actio_structures` | Object | structure ID와 signal count |
| `downstream_module_candidates` | Array | Stage 2 module 후보 |
| `skeleton_validation_codes` | Array | 검증 code 목록 |
| `derived_fact` | String | source-backed 사실 문장 |
| `derivation_basis` | String | 세미콜론 연결 provenance 문장 |
| `legal_calculation_object` | Object | 계산 필요 여부와 amountㆍobject basis |
### 2.3 분석 문서 4.5.4와의 차이
분석 문서 4.5.4는 최종 row 예시를 다음과 같은 자료형으로 표현한다.
- `object_spec`: Object
- `credibility`: Object
- `claim_chain_ref`: Array
- `linked_structures`: Array
- `must_consider`: Array
- `linked_actio_structures`: Array
- `derived_fact`: Boolean
- `derivation_basis`: Array
- `amount`: null 예시
- `legal_calculation_object`: null 예시
실제 결과와 직접 비교하면 다음과 같다.
| 필드 | 4.5.4 예시 | 실제 결과 | 불일치 성격 |
|---|---|---|---|
| `fact_id` | `F-0001` | `F-001` | ID 자릿수 규칙 차이 |
| `source_bo_id` | `BO-0001` | `bh1` | upstream ID 보존 규칙 차이 |
| `object_spec` | Object | String | 명백한 type 불일치 및 JSON 이중 인코딩 |
| `amount` | null | String | null 예시와 다르며 upstream Object가 JSON 문자열로 변환됨 |
| `credibility` | Object | String | 명백한 type 불일치 |
| `claim_chain_ref` | Array | Object | 명백한 type 불일치 |
| `linked_structures` | Array | Object | 명백한 type 불일치 |
| `must_consider` | Array | Boolean | 명백한 type 불일치 |
| `linked_actio_structures` | Array | Object | 명백한 type 불일치 |
| `derived_fact` | Boolean | String | 명백한 typeㆍ의미 불일치 |
| `derivation_basis` | Array | String | 명백한 type 불일치 |
| `legal_calculation_object` | null | Object | 기본값 예시와 실제 projection 차이 |
`amount`와 `legal_calculation_object`의 null은 예시 기본값일 수 있으므로 null 자체만으로 고정 type을 단정하기는 어렵다. 그러나 `amount`가 upstream Object에서 JSON 문자열로 바뀐 것은 명백한 직렬화 문제이다.
---
## 3. Part 4 실행 경로별 원인 추적
### 3.1 FL0: upstream 값은 아직 구조화되어 있음
`Task_FL0_fact_source_pack_compiler`는 `BO.json`, `legal_effect_structures.json`, 세 signal 파일, `evidence_indexed.json`을 BO ID 중심의 source pack으로 편성한다.
실제 `BO.json`과 `fact_source_pack.json`을 확인하면 첫 BO의 `amount`는 다음과 같은 Object이다.
```json
{
"currency": "KRW",
"numeric_value": null,
"value_text": null
}
```
즉, `amount`가 문자열이 된 원인은 Part 2나 FL0가 아니다. FL0 단계까지는 구조화된 Object가 보존되어 있다.
### 3.2 FL1: 실제 row format을 결정하는 직접 원인
`Task_FL1_deterministic_fact_ledger_candidate_builder`의 `_build_row()`가 최종 format을 사실상 결정한다.
#### 3.2.1 `FINAL_FIELDS`는 필드명만 지정함
FL1은 27개 필드명을 `FINAL_FIELDS`로 선언하지만 각 필드의 JSON type, null 허용 여부, enum, nested property를 선언하지 않는다.
```python
FINAL_FIELDS = [
"fact_id", "source_bo_id", "type", "date", "parties",
"object_spec", "amount", "action", ...
]
FINAL_FIELD_SET = set(FINAL_FIELDS)
```
후속 검사는 다음 조건뿐이다.
```python
if set(row) != FINAL_FIELD_SET:
raise ValueError(...)
```
따라서 `amount`가 Object인지 String인지, `derived_fact`가 Boolean인지 String인지와 무관하게 키가 27개이면 통과한다.
#### 3.2.2 `_text()`가 Object를 JSON 문자열로 변환함
FL1의 `_text()`는 String이 아닌 값을 `json.dumps()`한 뒤 String으로 반환한다.
```python
def _text(value, limit=500):
raw = value if isinstance(value, str) else json.dumps(value, ...)
return ...
```
`amount` 생성 코드는 이 함수를 직접 사용한다.
```python
"amount": _text(_first(bo.get("amount"), core.get("Amount")), 500) or None
```
그 결과 upstream의 amount Object가 다음처럼 JSON String이 된다.
```json
{
"amount": "{\"currency\": \"KRW\", \"numeric_value\": null, \"value_text\": null}"
}
```
이는 단순 표시 차이가 아니라 **double-encoded JSON**이다. 실제 48개 row의 `amount` String은 모두 다시 JSON parse가 가능한 문자열이다. 즉, 객체를 문자열로 바꾼 사실이 데이터 전체에서 일관되게 확인된다.
#### 3.2.3 `object_spec` fallback도 JSON 문자열을 직접 생성함
FL1은 `core_field_base.Object`가 없으면 다음 fallback을 사용한다.
```python
"object_spec": _first(
core.get("Object"),
json.dumps(linked["asset_cluster_ids"], ...)
)
```
따라서 빈 asset cluster는 Array `[]`가 아니라 String `"[]"`가 된다. 이번 결과의 48개 `object_spec`은 모두 다시 JSON parse가 가능한 String이다.
#### 3.2.4 나머지 type 차이는 `_build_row()`에 명시된 설계임
다음 필드는 런타임이 우연히 바꾼 것이 아니라 FL1 코드가 명시적으로 해당 type을 생성한다.
| 필드 | FL1 생성 방식 | 실제 type |
|---|---|---|
| `credibility` | `_score_evidence()`의 enum 반환 | String |
| `claim_chain_ref` | `_claim_groups()`의 named map 반환 | Object |
| `linked_structures` | `_linked_view()` 반환 | Object |
| `must_consider` | `bool(...)` | Boolean |
| `linked_actio_structures` | structure IDs와 count를 담은 literal | Object |
| `derived_fact` | `_fallback_derived()`의 사실 문장 | String |
| `derivation_basis` | `"; ".join(basis)` | String |
| `legal_calculation_object` | 계산 관련 named map | Object |
따라서 분석 문서의 ArrayㆍBoolean 예시는 FL1의 실제 구현을 반영하지 못한 것이다.
#### 3.2.5 ID 형식도 FL1에서 결정됨
FL1은 다음 코드로 Fact ID를 생성한다.
```python
"fact_id": f"F-{index:03d}"
```
따라서 실제 ID는 `F-001`이며 분석 문서의 `F-0001`과 다르다. `source_bo_id`는 새 ID로 변환하지 않고 upstream BO의 `bh1` 등을 그대로 보존한다. 분석 문서의 `BO-0001`은 실제 ID 정책과 일치하지 않는 예시이다.
### 3.3 FL2: 이번 format 차이의 원인이 아님
실제 `fact_exception_manifest.json`은 다음 상태를 보인다.
- `exception_count`: 0
- `pack_count`: 0
- LLM decision 수: 0
또한 `Fact_Ledger_base_candidate.json`의 `candidate_items`와 최종 `Fact_Ledger_base.json`은 완전히 동일하다.
그러므로 이번 자료형 차이를 FL2 LLM의 오판, 자유 생성, schema 이탈로 설명할 수 없다. FL2는 이번 실행에서 row format에 전혀 관여하지 않았다.
### 3.4 FL3: 잘못된 type을 차단하지 못한 원인
`Task_FL3_final_fact_ledger_gate_and_writer`는 최종 파일의 단일 writer이지만, `_validate_rows()`는 다음만 검사한다.
```python
if not isinstance(row, dict) or set(row) != FINAL_FIELD_SET:
raise ValueError("candidate row schema is not closed")
```
검사하지 않는 항목은 다음과 같다.
- `object_spec`이 Object인지 여부
- `amount`가 ObjectㆍNumberㆍnull인지 여부
- `claim_chain_ref`가 Array인지 Object인지 여부
- `must_consider`가 Boolean인지 여부
- `derived_fact`가 Boolean인지 String인지 여부
- nested field의 required key와 type
- ID 정규식과 자릿수
- JSON String 안에 다시 JSON이 들어가는 이중 인코딩 여부
FL3는 BO 보존, exception coverage, 참조 universe, review gate는 엄격하게 검사한다. 그러나 **field-level JSON type contract는 검사하지 않는다.** 마지막 `_verify_reread()`도 작성 전후 digest가 같은지만 확인한다. 잘못된 type을 그대로 쓰고 다시 같은 값으로 읽으면 검사를 통과한다.
### 3.5 최종 실행 흐름
이번 실행에서 format이 결정된 경로는 다음과 같다.
```text
BO.json amount = Object
|
v
FL0 source pack = Object 유지
|
v
FL1 _build_row()
- amount: _text(Object) -> JSON String
- object_spec: json.dumps(Array) -> JSON String
- 나머지 필드: FL1 코드가 Object/Boolean/String으로 명시 생성
|
v
Fact_Ledger_base_candidate.json
|
+--> exception_count = 0, FL2 미실행
|
v
FL3 _validate_rows()
- 27개 key 존재 여부만 검사
- field type은 검사하지 않음
|
v
Fact_Ledger_base.json에 candidate를 그대로 기록
```
---
## 4. 근본 원인 분석
### 4.1 단일한 machine-readable JSON Schema가 없음
Part 4에는 `FINAL_FIELDS` 배열은 있으나 `type`, `required`, `properties`, `items`, `enum`, `pattern`, `additionalProperties`를 포함하는 정식 JSON Schema가 없다. 분석 문서와 FL1ㆍFL3가 공유하는 하나의 schema artifact도 없다.
그 결과 다음 세 계약이 독립적으로 변했다.
1. 분석 문서가 설명한 개념적 format
2. FL1이 실제 생성하는 Python value type
3. FL3가 통과시키는 최소 조건
현재 가장 강한 실행 계약은 “27개 key가 정확히 존재해야 한다”는 조건뿐이다.
### 4.2 분석 문서가 실행 코드의 value type을 잘못 추상화함
`Analysis_Stage_1_Codex_v1.md`의 4.5.4는 필드명과 의미를 잘 정리했지만, `_build_row()`의 실제 반환 type을 그대로 옮기지 않았다. 예를 들어 다음 오해가 발생했다.
- “고려 대상 목록”이라는 의미로 `must_consider`를 Array로 표현했으나 코드는 Boolean flag를 사용한다.
- “파생 사실 여부”라는 의미로 `derived_fact`를 Boolean으로 표현했으나 코드는 파생 사실 문장을 저장한다.
- 구조 참조를 목록으로 단순화했으나 코드는 여러 종류의 ID를 보존하기 위해 Object를 사용한다.
따라서 4.5.4는 **필드명 목록으로는 정확하지만 JSON type 명세로는 부정확한 문서**이다.
### 4.3 “closed schema”라는 이름과 실제 검사의 불일치
FL1은 compile gate에 `candidate_schema_closed: true`를 기록하고 FL3는 “candidate row schema is not closed”라는 오류문을 사용한다. 그러나 실제로 닫혀 있는 것은 key set뿐이다.
일반적인 closed JSON Schema라면 다음을 함께 검증해야 한다.
- 각 property의 type
- nested required property
- enum과 nullability
- array item type
- ID pattern
- `additionalProperties: false`
현재 명칭은 강한 schema 검증을 수행하는 것처럼 보이지만 실제 보증 범위는 훨씬 좁다. 이 naming mismatch가 분석 문서 작성 시 과도한 신뢰를 유발한 것으로 판단된다.
### 4.4 형식 회귀 테스트가 없음
현행 gate는 다음을 잘 검증한다.
- BO 48건과 Fact 48건의 보존
- 중복ㆍ누락 BO
- exception coverage
- unknown evidenceㆍstructure reference
- upstream review와 drafting gate
그러나 expected field type과 실제 field type을 비교하는 fixture test가 없다. 그 결과 48개 row 전체의 `amount`와 `object_spec`이 JSON String이어도 writer status가 `READY_WITH_REVIEW`가 된다.
---
## 5. 법률ㆍdownstream 관점의 영향
### 5.1 `amount` 이중 인코딩
금전청구, 담보채무, 사해행위 가액배상 한도 계산은 통화, 원금, 잔액, 이율, 기준일을 구조적으로 읽어야 한다. `amount`가 Object가 아니라 JSON String이면 Stage 2가 추가 parse를 수행해야 하며, 이를 모르는 consumer는 금액을 단순 문장으로 취급하거나 계산에서 누락할 수 있다.
### 5.2 `object_spec` 이중 인코딩
부동산ㆍ지분ㆍ담보 목적물 연결은 asset ID와 fraction ID를 안정적으로 비교해야 한다. `"[]"`은 빈 Array가 아니라 truthy String이므로 PythonㆍJavaScript 조건문에서 “목적물이 존재한다”고 오판할 수 있다.
### 5.3 `derived_fact` 의미 충돌
분석 문서는 Boolean으로 설명하지만 실제 값은 사실 문장이다. Stage 2가 `derived_fact == true`를 기대하면 파생 사실을 인식하지 못한다. 반대로 String의 truthiness만 사용하면 모든 non-empty 사실을 “파생 사실”로 오인할 수 있다.
### 5.4 참조 필드의 Array/Object 차이
`claim_chain_ref`, `linked_structures`, `linked_actio_structures`는 실제 Object 구조가 법률적으로 더 유용하다. claimㆍliabilityㆍstructureㆍissueㆍasset을 named key로 분리할 수 있기 때문이다. 다만 Stage 2가 분석 문서의 Array를 기준으로 구현되어 있으면 iterationㆍmembershipㆍ`.get()` 처리에서 즉시 contract error가 발생한다.
### 5.5 provenance의 구조화 부족
`derivation_basis`가 세미콜론 연결 String이므로 source BO, evidence, structure를 다시 파싱해야 한다. 법률 감사 가능성과 기계 검증 측면에서는 reference object 배열이 더 안전하다.
---
## 6. 수정 방향
### 6.1 먼저 authoritative schema를 결정해야 함
두 선택지가 존재한다.
#### 선택지 A: 분석 문서 4.5.4를 절대 기준으로 삼음
FL1의 모든 field type을 4.5.4 예시에 맞게 변경하고 FL3에서 해당 type을 강제한다. 그러나 `claim_chain_ref`와 `linked_structures`를 단순 Array로 바꾸면 현재 Object가 보존하는 named 관계가 손실될 수 있다.
#### 선택지 B: 실행 코드의 의미 구조를 기준으로 schema를 재정의함
현재 ObjectㆍBoolean 구조 중 합리적인 것은 유지하고, 이중 인코딩과 모호한 필드만 수정한다. 법률정보 보존성과 Stage 2 확장성 면에서 이 선택지가 더 적합하다.
**권고안은 선택지 B이다.** 다만 분석 문서가 아니라 별도의 machine-readable schema를 단일 진실 원천으로 삼아야 한다.
### 6.2 권고하는 canonical type
| 필드 | 권고 type | 조치 |
|---|---|---|
| `fact_id` | String, `^F-\d{3}$` | 현행 `F-001`을 공식화하거나 자릿수를 명시적으로 변경함 |
| `source_bo_id` | String, upstream BO universe member | `bh#`를 그대로 보존하고 문서 예시를 수정함 |
| `object_spec` | Object 또는 null | JSON String 사용을 금지함 |
| `amount` | Object 또는 null | upstream amount Object를 deep copy함 |
| `credibility` | String enum | `high|medium|low`를 공식화함 |
| `claim_chain_ref` | Object | claimㆍliability group을 named key로 유지함 |
| `linked_structures` | Object | structureㆍissueㆍassetㆍevidence key를 유지함 |
| `must_consider` | Boolean | 현행 flag 의미를 공식화함 |
| `linked_actio_structures` | Object | 구조 ID와 signal count를 유지함 |
| `derived_fact` | String 또는 null | 사실 문장 필드로 공식화하거나 `derived_fact_text`로 rename함 |
| `is_derived` | Boolean | 파생 여부가 필요하면 별도 필드로 추가함 |
| `derivation_basis` | Array of Object | BOㆍevidenceㆍstructure ref를 구조화함 |
| `legal_calculation_object` | Object 또는 null | nested required key를 정의함 |
### 6.3 FL1 수정 사항
최소한 다음 두 코드는 변경해야 한다.
```python
# 현재
"amount": _text(_first(bo.get("amount"), core.get("Amount")), 500) or None
# 권고
"amount": copy.deepcopy(_first(bo.get("amount"), core.get("Amount")))
```
```python
# 현재
"object_spec": _first(
core.get("Object"),
json.dumps(linked["asset_cluster_ids"], ...)
)
# 권고 예시
"object_spec": {
"source_object": copy.deepcopy(core.get("Object")),
"asset_cluster_ids": linked["asset_cluster_ids"]
}
```
`derivation_basis`도 사람이 읽는 String과 기계가 검증하는 reference 배열을 분리하는 것이 적합하다.
### 6.4 FL3 수정 사항
FL3는 `FINAL_FIELD_SET` 검사 외에 다음을 수행해야 한다.
1. 정식 JSON Schema로 모든 candidate row를 검증한다.
2. `amount`ㆍ`object_spec`이 JSON처럼 보이는 String이면 hard failure 처리한다.
3. nested reference Object의 required key와 array item type을 검증한다.
4. `fact_id` pattern과 `source_bo_id` universe membership을 모두 검증한다.
5. FL2 patch 적용 후 schema를 다시 검증한다.
6. 최종 write 후 reread한 값에도 같은 schema 검증을 반복한다.
### 6.5 문서와 테스트 수정 사항
- `Analysis_Stage_1_Codex_v1.md` 4.5.4를 authoritative schema와 동일하게 수정해야 한다.
- 48개 실제 row의 type profile을 fixture로 저장하고 regression test에 포함해야 한다.
- `candidate_items == final rows`만 검사하지 말고 각 field type과 nested contract를 검사해야 한다.
- Stage 2 consumer가 Array/Object와 Boolean/String을 어떤 방식으로 읽는지 함께 점검해야 한다.
---
## 7. 최종 판정
`Results_00_7_21_Codex/Fact_Ledger_base.json`은 Part 4 YAML의 **현재 실행 코드에는 부합**한다. 48개 BO를 보존하고, 27개 key를 정확히 유지하며, 참조 gate도 통과했다. 그러므로 실행 결과가 FL3 writer에 의해 임의로 망가진 것은 아니다.
그러나 이 파일은 `Analysis_Stage_1_Codex_v1.md` 4.5.4가 암시한 field-level JSON type contract에는 부합하지 않는다. 가장 중요한 원인은 다음 세 가지이다.
1. 분석 문서와 실행 코드가 공유하는 정식 JSON Schema가 없다.
2. FL1이 분석 문서와 다른 type을 명시적으로 생성하고 `amount`ㆍ`object_spec`을 JSON String으로 이중 인코딩한다.
3. FL3가 key set만 검사하고 field type을 검사하지 않는다.
따라서 문제의 본질은 **LLM 출력 불안정성이 아니라 문서-코드 간 schema drift와 불완전한 deterministic validator**이다. 해결 역시 LLM prompt 강화가 아니라 canonical JSON Schema 도입, FL1의 type-safe projection, FL3의 field-level 검증 강화로 이루어져야 한다.