Files
kebab/docs/superpowers/specs/2026-05-25-p9-fb-41-finalize-spec.md
altair823 98cf4e8a04 chore(release): bump version 0.17.2 → 0.18.0 + cut fb-41 multi-hop
v0.18.0 cut PR. fb-41 multi-hop RAG + NLI verification 의 user-visible surface (PR #176-180) + post-PR9 cleanup/refactor (PR #181) ship 마무리.

## 변경 사항

### Version
- workspace `Cargo.toml`: 0.17.2 → 0.18.0. Cargo.lock 자동 cascade (24 kebab-* crate 모두 0.18.0).

### Frozen design contract
- `docs/superpowers/specs/2026-04-27-kebab-final-form-design.md`:
  - §3.8 RAG types — RefusalReason 에 NliVerificationFailed + NliModelUnavailable + MultiHopDecomposeFailed 추가 + Multi-hop RAG + NLI verification 의 ask_multi_hop facade + step 8.5 NLI hook + HopRecord / VerificationSummary 명시.
  - §9 versioning rules 표 — nli_model_version row 신규 (선택 — v0.19+ second adapter 시 wire surface candidate).

### Status transitions
- `docs/superpowers/specs/2026-05-25-p9-fb-41-finalize-spec.md`: status approved-by-team → completed.
- `docs/superpowers/plans/2026-05-25-p9-fb-41-finalize-plan.md`: status approved-by-team → completed (spec_status 도).

### User-facing docs
- `README.md`: 명령 표의 `kebab ask` row 에 `--multi-hop` flag + NLI 옵션 안내 한 단락 (mDeBERTa-v3 XNLI 280 MB 자동 다운로드 / RAM peak ~7-8 GB / threshold tuning 0.5 prod / 0.0 disable).
- `docs/SMOKE.md`: `[rag] nli_threshold = 0.0` config 예시 + 활성화 절차 + first-run download + RAM 권장 inline 안내.

### Handoff + dashboard
- `HANDOFF.md`: 한 줄 요약 의 현재 version 0.17.2 → 0.18.0. v0.18.0 cut entry 추가 (fb-41 multi-hop + NLI + cleanup ship). Component 카운트 단락에 fb-41 PR-9 의 kebab-nli + ask_multi_hop 추가 명시. 머지 후 결정 절 맨 위에 v0.18.0 fb-41 entry 신규.
- `tasks/INDEX.md`: p9-fb-41  머지 (v0.18.0). v0.18.0 subsection 신규 — PR #176-181 의 6 sub-PR + cleanup 각 한 줄 요약.

## 비범위 / 별 작업
- HOTFIXES.md 의 fb-41 entry 는 이미 PR #180 (PR-9d closure) 에서 작성 완료 — 본 cut PR 에서 추가 anchor 불필요.
- SKILL.md 의 v0.18+ NLI 안내는 이미 PR-9c-2 에서 inline 추가 완료.

## 검증
- `cargo check --workspace -j 1` 통과 (모든 24 crate v0.18.0 확인).
- frozen design 의 RefusalReason enum 확장이 kebab-core 의 production code 와 정합 (PR-9c-1 시점부터 동일 variants 있음).

Wire 영향: 없음 (additive minor 는 PR-9c-1 에서 이미 ship, 본 commit 은 documentation cascade only).
Behavior 영향: 없음.

머지 후 `gitea-release v0.18.0` 으로 tag + release notes 작성.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 05:18:08 +00:00

832 lines
55 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
title: "p9-fb-41 finalize — multi-hop RAG post-dogfood safety hardening + v0.18.0 cut"
date: 2026-05-25
task_id: p9-fb-41-finalize
phase: P9
status: completed
target_version: 0.18.0
contract_source: ./2026-04-27-kebab-final-form-design.md
contract_sections: [§3.8 RAG, §7 RAG pipeline]
predecessor: ./2026-05-25-p9-fb-41-multi-hop-rag-design.md
review_round: 5
review_outcome: |
All 4 OMC team reviewers APPROVE after 5-round convergence.
- architect: APPROVE (round 2)
- planner: APPROVE (round 2)
- document-specialist: APPROVE (round 3)
- critic: APPROVE (round 5)
Δ-severity 5-round 단조감소: 1C+9M+3m → 0+0+1NIT.
잔존 NIT (R5-NEW-NIT-1) closure 됨 (release notes wording).
---
# p9-fb-41 finalize — multi-hop RAG post-dogfood safety hardening
## 동기
predecessor spec (`2026-05-25-p9-fb-41-multi-hop-rag-design.md`) 가 정의한 multi-hop pipeline 의 PR-1 ~ PR-8 모두 머지 완료. v0.18 pre-cut 도그푸딩 (`/build/cache/dogfood-v018/results/SUMMARY.md`) 에서 발견된 *safety regression* 을 닫고 v0.18.0 cut 으로 가는 finalize spec.
predecessor 의 frozen contract 는 변경 없음 — 본 spec 는 *delta* 만:
1. dogfood 발견 (S7 hallucination) 의 진단 + fix path 정리.
2. PR-7 (probe gate) + PR-8 (pool 축소 + prompt rule) 의 부분 fix 결과.
3. PR-9 (NLI-based post-synthesis verification) 의 최종 fix 설계.
4. v0.18.0 cut steps.
## 1. 도그푸딩 진단 (S7)
**Query**: `What is the chemical formula of caffeine?` (KB 에 없는 fact).
| path | top_score | grounded | latency | answer |
|---|---|---|---|---|
| single-pass (hybrid) | 0.5 | false (LlmSelfJudge) | 30s | "근거가 부족하다" ✓ |
| multi-hop pre-fix | 0.5 | true ✗ | 614s | hallucination: "C₉H₁₅N₃O [#6]" (Adam optimizer 의 g_t 수식을 인용) |
| multi-hop PR-7 | 0.5 | true ✗ | 143s | hallucination (probe gate top_score 0.5 > 0.30 통과) |
| multi-hop PR-8 | 0.5 | true ✗ | **158s** (4× 개선) | hallucination (LLM 새 prompt rule 무시) |
### 1.1 진단 정합
1. **single-pass 의 LlmSelfJudge** = LLM 의 self-judgement 가 *uncorrelated chunks* 에 대해 "근거 부족" 인지. *probabilistic safety* — gemma3:4b 환경에서 우연히 정답 path. 다른 케이스 / 다른 LLM 에서 동일 reliability 보장 없음.
2. **multi-hop pre-fix 의 hallucination** = synthesize prompt 가 *5 sub-questions + 30 chunks* 의 large context 에서 LLM 의 self-judgement 잃음. `score_gate``hits[0].fusion_score` 만 검사 — multi-hop pool 의 union 이 한 sub-query 의 top score 가 gate 위면 통과.
3. **PR-7 probe gate** = single-pass 와 동일한 *원본 query* retrieve top_score 검사. 그러나 hybrid mode 의 RRF default score 가 0.5 (vector embedding 의 false positive — caffeine 와 Adam optimizer 수식 chunk 사이 semantic 유사도) → probe 도 통과.
4. **PR-8 prompt rule + pool 15** = synthesize prompt 강화 + size 축소 → latency 4× 개선. 그러나 gemma3:4b 의 prompt-following ceiling — strong rule 도 무시.
**근본 원인**: LLM-self-judge 기반 groundedness check 의 ceiling (gemma3:4b 한정 관측 — larger LLM 의 ceiling 도 unknown). *deterministic external verifier* 필요.
### 1.2 alternative root cause 검토 (왜 NLI path 인가)
다음 lighter alternatives 도 검토했으나 NLI path 채택:
| alternative | 효과 | 한계 / 거부 이유 |
|---|---|---|
| `[rag] vector_min_score = 0.4` knob (RRF *원본 vector cosine* threshold 추가) | caffeine ↔ Adam optimizer 의 vector 유사도 차단 가능 | RRF formula `score = sum(1/(60+rank))` 가 top-K 통과 시 *원본 cosine 낮아도* RRF 0.5 → vector_min_score 추가 = retrieval-side fix. Synthesis-side hallucination 의 *근본 원인 (LLM 의 prompt-following ceiling)* 미해결. 다른 query 패턴 (paraphrase chunk 가 retrieve) 의 hallucination 같은 path. |
| LLM 모델 업그레이드 (gemma2:9b / qwen2.5:7b) | larger LLM 의 prompt-following 능력 강화 → "근거 부족" rule 잘 따를 가능성 | CPU only 16 GB RAM 환경에서 9B+ Q4 모델은 RAM/latency 부담 ↑ (HOTFIXES 2026-05-25 v0.17.0 post-dogfood entry). 사용자 환경 의존성 ↑. *모델 무관 safety floor* 가 본 spec 의 목표. |
| LLM-as-judge (별 LLM call 으로 yes/no) | 모델 prompt-following 안 의존 — 별 call 의 binary judgement | 추가 LLM call → multi-hop latency 더 늘어남 (현재 158s + 10-30s). 그리고 *judge LLM 도 prompt-following ceiling* 가짐 — 같은 문제 재발 가능. |
| **NLI post-synthesis verification (선택)** | deterministic + lightweight + 학계 표준 | model dep + first-run download 부담. *그러나 단일 280 MB ONNX 가 모든 multi-hop ask 의 safety floor 제공*. |
NLI 가 *deterministic verifier* 의 약속 (LLM 의 stochastic self-judge 와 직교) + production proven (Auto-GDA, MedTrust-RAG) + multilingual 가능 (multilingual NLI model) 의 3 axis 모두 만족.
(향후 v0.19+ 의 ceiling 측정 / dogfood iteration 에서 LLM-as-judge 또는 cross-encoder reranker 도 보조 path 로 검토 가능 — `nli_threshold = 0.0` disable 옵션 항상 보존.)
### 1.3 LLM upgrade vs NLI 의 future 관계
§1.2 의 LLM 업그레이드 path 가 v0.19+ 의 *NLI 와 병행* 또는 *NLI 대체* 가능성:
- **병행**: larger LLM 도 hallucinate 가능 — NLI 가 safety floor 유지. *defense in depth*.
- **대체**: 만약 future LLM (예: gemma4:e4b 의 instruction-tuned variant) 가 prompt-following ceiling 가 사라지면 NLI cost 정당화 약화 — `[rag] nli_threshold = 0.0` disable 로 opt-out.
본 v0.18 spec 의 NLI 는 *opt-in default OFF* (§2.6) 이라 사용자가 환경에 맞춰 enable. v0.19+ 의 measurement 후 default ON / OFF 결정.
## 2. PR-9 — NLI-based post-synthesis verification
학계 / industry 표준 (Self-RAG / CRAG / Auto-GDA / MedTrust-RAG) 의 결론: *post-synthesis groundedness verification* 이 정답 path. **multilingual NLI ONNX model** (~280 MB) 이 `(premise = packed_chunks, hypothesis = answer)` entailment 검사 → score < threshold 면 refuse.
### 2.1 Model
- **HuggingFace repo (production default)**: `Xenova/mDeBERTa-v3-base-xnli-multilingual-nli-2mil7` — Xenova org 의 ONNX export.
- **원본 PyTorch weight**: `MoritzLaurer/mDeBERTa-v3-base-xnli-multilingual-nli-2mil7` (Apache-2.0 lic.). Xenova 의 ONNX export 는 *Optimum* 으로 생성된 변환본. config 의 default 는 ONNX 호스팅하는 `Xenova/...` 사용.
- 280 MB ONNX (FP32). Q8 양자화 variant 도 Xenova 에 별 file (`onnx/model_quantized.onnx`) 있음 — v0.19+ 에서 옵션 추가 검토.
- 3-way classifier: `[entailment, neutral, contradiction]` (XNLI `id2label` 표준).
- 100+ multilingual (Korean + English 필수).
- CPU inference: ~10-50 ms per (premise, hypothesis) pair (mDeBERTa-base 기준).
**pre-flight check (PR-9a 시작 전 manual 확인)**:
```sh
curl -I https://huggingface.co/Xenova/mDeBERTa-v3-base-xnli-multilingual-nli-2mil7/resolve/main/onnx/model.onnx
curl -I https://huggingface.co/Xenova/mDeBERTa-v3-base-xnli-multilingual-nli-2mil7/resolve/main/tokenizer.json
```
두 HEAD 모두 `200 OK` 면 진행. 404 면 PR-9 의 design re-evaluation (다른 ONNX repo 또는 self-export via Optimum).
#### 2.1.1 대안 모델 trade-off (informational)
| 모델 | size | lang | quality 차이 | 적합도 |
|---|---|---|---|---|
| `xlm-roberta-large-xnli` | 1.5 GB | 100+ multilingual | ~3-5% 더 높음 | 16 GB RAM 환경에서 LLM + lance + NLI 동시 cold start 부담 (overflow risk). |
| **`Xenova/mDeBERTa-v3-base-xnli-multilingual-nli-2mil7` (선택)** | **280 MB** | **100+ multilingual** | **baseline** | **균형 — 본 spec 의 default** |
| `MiniLM-L12-mnli-xnli` | 110 MB | multilingual (좁음) | ~5-10% 낮음 | Korean 의 quality 약함 — kebab corpus 의 KR+EN mix 와 부적합. |
선택 사유: kebab 의 사용자 환경 (CPU only, 16 GB RAM, KR+EN mix) 에서 *유일한 균형점*. 모델 교체 시 본 표 + dogfood retest 측정값 함께 갱신.
### 2.2 Architecture
```
crates/kebab-nli/ (신규 crate, trait + impl 한 곳)
├── Cargo.toml
└── src/
├── lib.rs — NliVerifier trait + NliScores struct + softmax helper
└── onnx.rs — OnnxNliVerifier (ort + tokenizers + hf-hub)
```
**Trait + impl 동일 crate 정당화** (vs `kebab-embed` + `kebab-embed-local` 패턴):
- v0.18 scope = ONNX adapter 하나만. trait split crate 의 *현재* 가치 0.
- v0.19+ 에서 candle / CUDA / remote adapter 등장 시 `kebab-nli-onnx` 분리 가능 — *그때 breaking change* 는 internal API only (kebab-app 만 영향, *wire 무관*). PR-9 단순화 우선.
- §8 self-review 에 향후 split 시 trigger 명시.
#### 2.2.1 Trait surface
```rust
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct NliScores {
pub entailment: f32, // production accept signal
pub neutral: f32,
pub contradiction: f32, // observability
}
impl NliScores {
pub fn faithfulness(&self) -> f32 { self.entailment }
pub fn from_xnli_logits(logits: [f32; 3]) -> Self { /* softmax + wrap */ }
}
pub trait NliVerifier: Send + Sync {
fn score(&self, premise: &str, hypothesis: &str) -> Result<NliScores>;
}
```
`NliScores::faithfulness()``entailment` channel 반환 — production accept rule (`entailment >= threshold` = grounded).
#### 2.2.2 OnnxNliVerifier
- `ort::Session` (transitive download-binaries 으로 fastembed 와 같은 ONNX runtime).
- `tokenizers::Tokenizer` (SentencePiece via mDeBERTa tokenizer.json).
- `hf-hub::api::sync::Api` 가 first-run model + tokenizer download.
- **Lazy init**: 첫 `score` 호출 시 model + tokenizer load. 후속 호출 reuse (OnceLock 또는 OnceCell).
- **Cache dir**: `{config.storage.model_dir}/nli/<sanitized-model-id>/{model.onnx, tokenizer.json}`. fastembed 의 model cache 와 sibling. sanitization 은 `/``_` 로 (`Xenova/mDeBERTa-...``Xenova_mDeBERTa-...`).
- **Failure handling**: download 실패 (network / disk / corrupt) 시 `RefusalReason::NliModelUnavailable` (단일 ask) 또는 facade construction 시 verifier=None 으로 graceful — §2.6 참조.
#### 2.2.3 Input encoding + truncation
mDeBERTa-v3 의 `max_seq_len = 512` token. multi-hop 의 packed_chunks (15 chunks × ~300-500 token = 4500-7500 token) 가 무조건 초과 → **명시적 truncation 정책 필수**:
```rust
let mut encoding_params = tokenizers::EncodeInput::Dual(premise, hypothesis);
tokenizer
.with_truncation(Some(tokenizers::TruncationParams {
max_length: 512,
strategy: tokenizers::TruncationStrategy::OnlyFirst, // premise (chunks) 만 truncate
stride: 0,
direction: tokenizers::TruncationDirection::Right, // 끝부터 잘림
}))?
.encode(encoding_params, /*add_special_tokens=*/true)?
```
- **`OnlyFirst`**: hypothesis (answer) 는 보전, premise (chunks) 끝부터 truncate. answer 가 잘리면 entailment 가 *임의로 fail* — 절대 회피.
- **packed_text pre-budget in pipeline (옵션)**: `kebab-rag` 가 NLI 호출 전 packed_chunks 를 self-truncate. PR-9c-2 에서 helper `truncate_for_nli(premise: &str, hypothesis: &str) -> (String, bool)` 작성 — `max_seq_len = 512` 는 helper 내부 상수 `MAX_NLI_PREMISE_CHARS` 로 hardcode (v0.18 scope 단일 NLI model 가정). signature single source of truth = §3 PR-9c-2.
회귀 핀 (PR-9c unit test): `long_premise_truncation_preserves_hypothesis_score` — premise 가 10000-token 일 때 score 가 정상 (panic / NaN 없음). truncation indicator (`encoding.get_overflowing()`) 비어 있지 않음 검증.
#### 2.2.4 Inference
```
input_ids : [1, seq_len] i64
attention_mask : [1, seq_len] i64
→ Session::run
→ logits : [1, 3] f32
→ softmax(logits) → NliScores
```
mDeBERTa-v3 는 token_type_ids 없음 (single-segment encoding). ort input name 확정:
- input: `input_ids`, `attention_mask`
- output: `logits`
(PR-9a 의 pre-flight check 에서 ONNX 의 `onnx.SessionInfo::inputs()` / `outputs()` 출력으로 검증 후 lock — 다른 name 이면 spec 갱신.)
### 2.3 Pipeline integration
`RagPipeline::ask_multi_hop` 의 step 8.5 (synthesize 후, citation extract 전):
**Empty answer (stream abort / LM crash) 의 처리**: synthesize 가 empty `acc` 반환 시 step 8.5 *skip* — 이미 별 path 의 refusal 처리 (예: `RefusalReason::LlmStreamAborted` for fb-33 cancel) 가 이전 단계에서 결정. 본 step 8.5 의 NLI verify 는 *non-empty answer* 에 대해서만 호출 — empty hypothesis 가 NLI tokenizer 의 edge case 진입 회피. PR-9c-2 의 `ask_multi_hop` integration 시 `if !acc.trim().is_empty() { /* step 8.5 */ }` 가드 추가.
```rust
// 8.5 — NLI groundedness verification (multi-hop only in v0.18 scope)
// §2.7: single-pass `ask` 는 LlmSelfJudge 그대로. NLI 미적용.
let verification = if self.config.rag.nli_threshold > 0.0 {
let v = self.verifier.as_ref().expect(
"verifier must be Some when nli_threshold > 0.0 \
(facade enforces this invariant in App::new)"
);
let (truncated_premise, _) = truncate_for_nli(&packed_text, &acc);
match v.score(&truncated_premise, &acc) {
Ok(scores) => {
let passed = scores.entailment >= self.config.rag.nli_threshold;
Some(VerificationSummary {
nli_score: scores.entailment,
nli_threshold: self.config.rag.nli_threshold,
nli_passed: passed,
})
}
Err(e) => {
// model unavailable / inference error → refusal path
tracing::warn!(target: "kebab-rag", error=%e, "NLI verifier failed");
return self.refuse_nli_model_unavailable(query, &opts, hops, started);
}
}
} else {
None
};
if let Some(v) = &verification {
if !v.nli_passed {
return self.refuse_nli_verification(query, &opts, hops, v.clone(), started);
}
}
```
- `nli_threshold = 0.0` (config default) → verify skip (backwards-compat for environments without model). 명시적 *single source of truth*`enabled` field 별도 안 둠 (§2.6 참조).
- `nli_threshold > 0.0` → verify ON. 권장 production 0.5 (multilingual NLI 의 한국어 보수). dogfood iteration 으로 tuning.
- Inference error (model download fail, ONNX runtime panic 등) → `RefusalReason::NliModelUnavailable` (fail-closed).
### 2.4 RefusalReason
`kebab_core::RefusalReason` 에 신규 2 variant + wire mapping:
| Rust variant | answer.v1 `refusal_reason` (snake) | error.v1 `code` (snake) | identical? |
|---|---|---|---|
| `NliVerificationFailed` | `"nli_verification_failed"` | `"nli_verification_failed"` | ✓ (predecessor `MultiHopDecomposeFailed` 패턴 정합 — noun + verb + state 순서) |
| `NliModelUnavailable` | `"nli_model_unavailable"` | `"nli_model_unavailable"` | ✓ |
두 wire string 모두 RefusalReason 과 error.v1.code 가 *동일* — consumer agent translation table 불요. predecessor `MultiHopDecomposeFailed` / `"multi_hop_decompose_failed"` 패턴 일관.
**구현 시 결정**:
- `RefusalReason::NliVerificationFailed` (Rust variant) → `#[serde(rename_all="snake_case")]` 가 자동으로 `"nli_verification_failed"` emit.
- `answer.schema.json``refusal_reason.anyOf[0].enum` 에 두 값 추가.
- `error.v1.code` enum 에 두 reservation 추가.
- `error.v1.details.description` 의 per-code section 추가:
- `nli_verification_failed: { score, threshold }` (forward-looking, reserved).
- `nli_model_unavailable: { source }` (download / inference 실패 chain).
### 2.5 Wire schema
`answer.v1``verification` optional field:
```json
{
"schema_version": "answer.v1",
...
"verification": {
"nli_score": 0.12,
"nli_threshold": 0.5,
"nli_passed": false
}
}
```
- field naming: **`nli_score`** (단일 entire-answer NLI). future v0.19+ 의 atomic claim split 도입 시 `nli_min_score` / `nli_mean_score` 추가 가능 — 그때 별 wire bump.
- `#[serde(default, skip_serializing_if = "Option::is_none")]` — additive minor. pre-v0.18 reader 무영향.
- `$defs.VerificationSummary` 인라인 정의 (기존 `$defs.HopRecord` 패턴):
```json
"$defs": {
"VerificationSummary": {
"type": "object",
"required": ["nli_score", "nli_threshold", "nli_passed"],
"properties": {
"nli_score": { "type": "number" },
"nli_threshold": { "type": "number" },
"nli_passed": { "type": "boolean" }
}
}
}
```
`required` array 가 3 field 모두 present-when-non-null 명시 — strict consumer 정합 (HopRecord 패턴 답습).
`refusal_reason.enum` 갱신 (`answer.schema.json` 의 `anyOf[0].enum` 에 추가):
- `"nli_verification_failed"`
- `"nli_model_unavailable"`
### 2.6 Config knobs
```toml
[models.nli]
# Production default = Xenova 의 ONNX export. 원본 PyTorch weight 는
# MoritzLaurer/mDeBERTa-v3-base-xnli-multilingual-nli-2mil7.
model = "Xenova/mDeBERTa-v3-base-xnli-multilingual-nli-2mil7"
provider = "onnx" # only one supported in v0.18
[rag]
# 0.0 = NLI disabled (v0.18 default). > 0.0 = enable.
# 권장 production 0.5 (multilingual NLI 의 한국어 confidence 보수).
# strict 환경 0.9 (Auto-GDA / MedTrust-RAG paper 의 production threshold).
nli_threshold = 0.0
```
**default 결정 — `nli_threshold = 0.0` (disabled)**:
- backward-compat: 옛 config / 새 사용자 모두 *NLI off* 로 시작 → 280 MB first-run download 강제 없음.
- opt-in flag: 사용자가 `[rag] nli_threshold = 0.5` 설정 시 NLI active.
- single source of truth: code path `if self.config.rag.nli_threshold > 0.0 { verify } else { skip }`. `enabled` flag 별도 안 둠 — 두 gate 의 모순 회피.
- **edge case — `nli_threshold = 0.0` 의 entailment=0.0 비교**: §2.3 코드의 outer guard `if self.config.rag.nli_threshold > 0.0 { ... }` 가 disabled path 를 *short-circuit* — `>=` 비교 (`entailment >= threshold`) 는 *active 분기 (threshold > 0.0) 에서만* 도달. 즉 `entailment=0.0` + `threshold=0.0` 시나리오는 guard 가 verify 자체 skip → `>= 0.0 = true` 통과 path 절대 발생 안 함. doc reader 헷갈림 회피.
**default 결정 — `enabled` field 제거**:
round-1 review 의 D3 / A3 발견: `[models.nli].enabled` + `[rag].nli_threshold` 두 gate 모순 위험. **`enabled` field 미도입** — single gate `nli_threshold` 만:
- `nli_threshold = 0.0` → verify skip + model never loaded.
- `nli_threshold > 0.0` → verify on + model lazy-loaded on first multi-hop ask.
env override: `KEBAB_MODELS_NLI_MODEL`, `KEBAB_RAG_NLI_THRESHOLD`. legacy config 의 `#[serde(default)]` backward-compat — 옛 config.toml 그대로 parse + `nli_threshold = 0.0` (skip).
**model download 실패 fallback**:
- `nli_threshold > 0.0` + first-run model download 실패 (network / disk full / corrupt) → 모든 multi-hop ask 가 `RefusalReason::NliModelUnavailable` (fail-closed). stderr warn 명시. 사용자가 (a) `nli_threshold = 0.0` 으로 임시 disable 또는 (b) network / disk 복구 후 재시도.
- 사유: silent skip (verify 우회) 은 *S7 hallucination 재발* — 보안 측면에서 fail-closed 가 안전.
**download progress indicator**:
- first-run `score` 호출 시 hf-hub download — stderr 에 simple progress (예: `kebab-nli: downloading model.onnx (280 MB)...`).
- non-`--json` mode 만 progress emit. `--json` mode 는 quiet (wire output 의 노이즈 회피).
### 2.7 Single-pass NLI 도 적용?
학계 표준은 single-pass + multi-hop 양쪽. 그러나 single-pass 의 LlmSelfJudge 가 *gemma3:4b 환경에서* 작동 (S7 single-pass 가 grounded=false). 본 spec 의 v0.18 scope:
- **multi-hop 만 NLI 적용** — large prompt + pool union 의 hallucination risk 가 single-pass 보다 압도적.
- single-pass NLI 는 *v0.18.1 priority candidate* — §1.1 의 "LlmSelfJudge probabilistic safety" 인정 위에 *defense in depth*. config knob `[rag] nli_single_pass_enabled = false` (default) 별 PR 에서 추가.
(round-1 wording "redundant safety" → "v0.18 scope priority" 로 조정 — §1.4 의 ceiling 주장과 일관.)
## 3. PR-9 단계별 sub-PRs
### PR-9a — kebab-nli crate skeleton
**Goal**: trait surface + scaffolding + workspace dep chain 도입. implementation 없이도 build 가능.
**Files**:
- `Cargo.toml` (workspace):
- `members` 에 `"crates/kebab-nli"` 추가.
- `workspace.dependencies` 에 추가 (fastembed 의 transitive 와 *정확히 일치*):
- `ort = { version = "=2.0.0-rc.9", default-features = false, features = ["ndarray"] }` (download-binaries 는 fastembed 의 transitive 활성화 의존 — features union).
- `tokenizers = { version = "0.21", default-features = false, features = ["onig"] }`.
- `hf-hub = { version = "0.4", default-features = false, features = ["ureq", "rustls-tls"] }` (fastembed 의 `hf-hub-native-tls` 와 cargo features union 처리 — `rustls-tls` 둘 다 활성화는 build OK).
- `ndarray = "0.16"`.
- `crates/kebab-nli/Cargo.toml` 신규 (skeleton 만, PR-9b 가 추가):
- `dependencies`: `kebab-config`, `anyhow`, `serde`.
- `dev-dependencies`: `tempfile`.
- `crates/kebab-nli/src/lib.rs`:
- `NliScores` struct + `faithfulness()` + `from_xnli_logits()`.
- `NliVerifier` trait.
- private `softmax3` helper.
- `crates/kebab-nli/src/onnx.rs`:
- `OnnxNliVerifier` placeholder struct.
- `OnnxNliVerifier::new(&Config) -> Result<Self>` placeholder.
- `impl NliVerifier::score → bail!("PR-9a stub")`.
**Pre-flight check (PR-9a 시작 전, manual)**:
1. **Model + tokenizer file 존재 검증** — §2.1 의 `curl -I` 두 commands → `200 OK` 확인. 실패 시 PR-9 design re-evaluation.
2. **`tokenizers` features 검증** — mDeBERTa-v3 tokenizer.json 이 `Tokenizer::from_file` 로 *어떤 feature set* 필요한지 standalone repro 로 확인:
```sh
cargo new --bin /tmp/nli-tok-probe
cd /tmp/nli-tok-probe
cargo add tokenizers --no-default-features -F onig
wget https://huggingface.co/Xenova/mDeBERTa-v3-base-xnli-multilingual-nli-2mil7/resolve/main/tokenizer.json
# main.rs: tokenizers::Tokenizer::from_file("tokenizer.json").expect("load");
cargo run --release
```
성공 시 PR-9a 의 `tokenizers = { ..., features = ["onig"] }` lock. 실패 시 *진단*:
- `unstable_wasm` 또는 다른 feature 가 SentencePiece 모듈 활성화에 필요한지 확인 (tokenizers 0.21 docs 참조).
- `default-features = true` 가 가장 안전 path — features 결정에 confidence 부족 시.
**Cargo features 의 결정 trace**: 본 pre-flight 결과는 PR-9a 의 PR description 의 `## Cargo features 결정 trace` 절에 첨부 (`cargo run` 출력 + 최종 features set). spec lock value.
**Tests** (6 unit):
- `softmax3_normalises_to_unit` — sum = 1, monotonic.
- `softmax3_is_invariant_to_constant_shift` — log-sum-exp 안전성.
- `nli_scores_from_xnli_logits_orders_correctly` — high entailment → entailment 최대.
- `faithfulness_returns_entailment_channel`.
- `new_succeeds_on_default_config`.
- `score_returns_err_in_skeleton` — stub 의 명시적 err 메시지.
**검증**:
- `cargo test -p kebab-nli -j 1` — 6 통과.
- `cargo clippy -p kebab-nli --all-targets -j 1 -- -D warnings` clean.
**Wire 영향**: 없음 (crate 만 도입).
**Risks**:
- workspace 의 ort / tokenizers / hf-hub 추가 → 전체 build 재 link (큰 변화 없음, fastembed 가 이미 transitive).
- features union 위험 — `download-binaries` (fastembed) + `ndarray` (kebab-nli) 동시 활성화는 build OK 검증 필수.
**시간**: 2-3h.
### PR-9b — OnnxNliVerifier 의 ONNX inference + model download
**Goal**: `OnnxNliVerifier::score` 의 진짜 implementation. model + tokenizer download / cache / inference 완성.
**Dependency**: PR-9a 머지 완료.
**Files**:
- `crates/kebab-nli/Cargo.toml`:
- `ort`, `tokenizers`, `hf-hub`, `ndarray`, `tracing` 추가 (workspace.dependencies 에서).
- `crates/kebab-nli/src/onnx.rs`:
- `OnnxNliVerifier` 의 fields:
- `model_id: String`.
- `cache_dir: PathBuf` (`config.storage.model_dir.join("nli").join(sanitize(model_id))`).
- `session: OnceLock<ort::Session>`.
- `tokenizer: OnceLock<tokenizers::Tokenizer>`.
- `OnnxNliVerifier::new(&Config) -> Result<Self>`:
- `model_id`, `cache_dir` stamp. actual session/tokenizer load *deferred*.
- `ensure_loaded(&self) -> Result<(&Session, &Tokenizer)>`:
- hf-hub download (cache hit 시 skip + warn 에서 hit/miss 명시).
- tokenizer.json 로드 → `Tokenizer::from_file`.
- model.onnx 로드 → `Session::builder().commit_from_file`.
- truncation params 설정 (§2.2.3).
- 두 OnceLock 에 store.
- `score(premise, hypothesis)`:
- `ensure_loaded()` 호출.
- `tokenizer.encode((premise, hypothesis), add_special_tokens=true)`.
- input_ids + attention_mask ndarray `[1, seq_len]` i64.
- `session.run(ort::inputs![...])`.
- `outputs["logits"].try_extract_tensor::<f32>()` → shape `[1, 3]`.
- `NliScores::from_xnli_logits([l0, l1, l2])`.
- `sanitize_model_id(s: &str) -> String` helper — `/` → `_`.
- `crates/kebab-nli/tests/inference.rs` 신규:
- `#[ignore]` integration test — real model download + 5 forward pass cases:
1. `premise = "Caffeine is a stimulant.", hypothesis = "Caffeine is a stimulant."` → entailment 매우 높음 (>0.8).
2. `premise = "Caffeine is a stimulant.", hypothesis = "The chemical formula of caffeine is C8H10N4O2."` → entailment 낮음 (<0.3) — neutral/contradiction.
3. Korean: `premise = "사과는 빨갛다.", hypothesis = "사과는 색이 있다."` → entailment 높음.
4. Long premise (10000 char) → truncation 적용 후 정상 score (panic 없음).
5. Empty hypothesis → graceful error (panic 없음, err 반환).
**Manual smoke protocol (PR-9b PR description 강제)**:
PR description 의 `## 검증` 절에 다음 *manual run* 결과 첨부:
```sh
cargo test -p kebab-nli -j 1 --test inference -- --ignored 2>&1 | tail -20
```
- 5 test 모두 PASS 확인.
- 첫 case (entailment 높음) 의 NliScores dump (예: `entailment=0.92, neutral=0.05, contradiction=0.03`).
CI 부담 회피 위해 unit test (no `--ignored`) 만 CI 실행. ignored test 는 PR 작업자 manual.
**검증**:
- unit test 통과 + clippy clean.
- `--ignored` integration test 의 manual run (PR 작업자 책임, PR body 첨부).
**Wire 영향**: 없음 (crate-internal).
**Risks**:
- `ort` 2.0-rc.9 의 API stability — rc 라 minor 사이 incompat 가능. *=mitigation*: workspace `ort = "=2.0.0-rc.9"` pin (fastembed 와 정확히 일치).
- mDeBERTa-v3 의 ONNX export 가 Xenova HF Hub 에 존재 — §2.1 의 pre-flight check 가 PR-9a 시작 전 검증. 없으면 PR-9 design re-evaluation (다른 ONNX repo 또는 Optimum self-export).
- `tokenizers` 0.21 의 SentencePiece 지원 — fastembed 가 BERT tokenizer 사용 (multilingual-e5-small), kebab-nli 가 mDeBERTa SentencePiece 사용 (다른 patterns). 첫 통합 위험.
- `hf-hub` 0.4 의 `ureq + rustls-tls` features 가 workspace 의 다른 deps 와 incompat 없는지 — fastembed 의 `hf-hub-native-tls` 와 cargo features union 시 build OK 가정 (rustls-tls + native-tls 동시 활성화는 hf-hub crate features 가 mutually compatible 검증 필요).
**시간**: **8-12h** (round-1 planner 의 6-8h underestimated 지적 반영). 첫 시도 실패 시 fallback (Optimum self-export) 까지 포함.
### PR-9c — Pipeline integration (split: 9c-1 core + 9c-2 pipeline)
**Goal**: kebab-rag pipeline 의 `ask_multi_hop` 에 NLI verify 통합. core types + wire + config 추가.
**Dependency**: PR-9b 머지 완료.
**round-1 review (P1 / M2) 분할 권장 반영** — 9c 를 **별 PR 2개로 분할** (9c-1 → 9c-2 sequential 머지). review 부담 분산 + git bisect 시 surface (wire/types) vs behavior (pipeline integration) 분리. 한 PR 의 commit 2개 방식보다 *별 PR* 채택 — round-1 P1 의 목적 (review 부담 ↓) 정합.
#### PR-9c-1 — Core types + wire scaffolding (breaking surface)
**Files**:
- `crates/kebab-core/src/answer.rs`:
- `RefusalReason::NliVerificationFailed` + `RefusalReason::NliModelUnavailable` 신규.
- `Answer.verification: Option<VerificationSummary>` field.
- `VerificationSummary { nli_score: f32, nli_threshold: f32, nli_passed: bool }` 신규 struct.
- `crates/kebab-config/src/lib.rs`:
- `NliCfg` 신규 struct + `[models.nli]`:
- `model: String` (default `"Xenova/mDeBERTa-v3-base-xnli-multilingual-nli-2mil7"`).
- `provider: String` (default `"onnx"`).
- `RagCfg.nli_threshold: f32` (default `0.0`).
- env override + legacy parse 단위 test.
- `crates/kebab-rag/src/pipeline.rs`:
- `RagPipeline` 의 새 field: `verifier: Option<Arc<dyn NliVerifier>>` (None = verify off).
- **시그니처 widening 결정 = Option B (builder pattern)**:
- 기존 `RagPipeline::new(config, retriever, llm, sqlite)` 시그니처 *유지* (backward-compat for 18+ existing call sites).
- 신규 `pub fn with_verifier(self, v: Arc<dyn NliVerifier>) -> Self` builder.
- `kebab-app` facade 만 `with_verifier` 호출. 다른 caller (cli/tui/mcp tests) 무영향.
- Cargo.toml: `kebab-rag` 가 `kebab-nli` 의존 추가.
- `docs/wire-schema/v1/answer.schema.json`:
- `verification` field 추가 (anyOf [object, null]) + `$defs.VerificationSummary` 인라인.
- `refusal_reason.enum` 에 `"nli_verification_failed"`, `"nli_model_unavailable"` 추가.
- `docs/wire-schema/v1/error.schema.json`:
- `code` enum 에 `nli_verification_failed`, `nli_model_unavailable` 추가.
- `details.description` 에 두 항목 추가 (`multi_hop_decompose_failed: {}` 패턴 그대로).
**Tests**:
- `crates/kebab-config/src/lib.rs::tests`:
- `default_nli_threshold_is_zero`.
- `default_nli_model_is_xenova_mdeberta`.
- `legacy_config_without_nli_uses_defaults`.
- `env_override_nli_threshold`.
- `crates/kebab-cli/tests/wire_ask_multi_hop.rs`:
- `answer_schema_declares_verification_field_and_defs`.
- `answer_schema_refusal_reason_enum_includes_nli_verification_failed` (+ `nli_model_unavailable`).
- `error_schema_code_enum_includes_nli_verification_failed` (+ `nli_model_unavailable`).
**검증**:
- `cargo test --workspace -j 1` — 회귀 0 (기존 multi-hop tests pass, RagPipeline::new 시그니처 unchanged).
- `cargo clippy --workspace --all-targets -j 1 -- -D warnings` clean.
**시간**: 2-3h.
#### PR-9c-2 — Pipeline integration + mock test
**Dependency**: PR-9c-1 머지 완료 (core types: `RefusalReason::Nli*` variants + `Answer.verification` field + `RagPipeline.verifier` field + `kebab-nli` 의 trait + config knobs 가 9c-1 에서 도입). 9c-2 가 그 위에 *behavior* 통합.
**Files**:
- `crates/kebab-rag/src/pipeline.rs`:
- `ask_multi_hop` 의 step 8.5 NLI hook (§2.3 코드).
- `refuse_nli_verification` helper (`refuse_*` 패턴) — `verification: Some(...)` 채움.
- `refuse_nli_model_unavailable` helper — `verification: None`.
- `pub fn truncate_for_nli(premise: &str, hypothesis: &str) -> (String, bool)` helper (§2.2.3 packed_text pre-budget). signature: 첫 return = truncated premise (max char count = `MAX_NLI_PREMISE_CHARS = 4 * 400` ≈ 1600 chars, hypothesis 길이 빼고 special tokens 32 char budget 적용 후 자연 보존). 둘째 return = was_truncated boolean (caller 가 tracing log 또는 wire 의 `verification` extension 에서 사용 가능 — v0.18 wire 추가 안 함, future v0.19+ candidate).
- **`MAX_NLI_PREMISE_CHARS` 의 token ratio 가정**: 4 char ≈ 1 token (영어 BPE 기준, mDeBERTa-v3 의 default). 한국어 SentencePiece 는 1-2 char/token (한 음절 = 1 token 통상) — 1600 chars 한국어 = 800-1600 tokens, max_seq_len 512 초과 가능. 이때 tokenizer 의 `OnlyFirst` truncation 가 backup 으로 작동 (premise 끝부터 잘림, hypothesis 보전). dogfood retest 의 S10 (KR) NLI score 측정 후 가능하면 *token-count 기반 budget* 으로 v0.18.1 갱신 — char-based budget 의 EN-biased 보정.
- `crates/kebab-app`:
- `App::new` 또는 `pipeline_from_config` 가 NliVerifier 생성:
- `config.rag.nli_threshold > 0.0` → `OnnxNliVerifier::new(config)` 호출 + `Arc::new` wrap.
- `config.rag.nli_threshold == 0.0` → verifier = None.
- **facade invariant 결정 — `Result<App, anyhow::Error>` (construction-time error)**: `App::new` 가 `Result<Self, anyhow::Error>` 반환. `config.rag.nli_threshold > 0.0` + `OnnxNliVerifier::new` 실패 시 `bail!()` — user-facing crash 회피. `RagPipeline.verifier == None` + `config.rag.nli_threshold > 0.0` 의 *unreachable* 조합은 `expect("App::new enforces invariant")` safety net 만 — 정상 path 도달 불가능. round-2 critic NEW-M2 closure.
- `crates/kebab-rag/tests/multi_hop.rs`:
- `common/mod.rs` 에 `MockNliVerifier { scores: NliScores }` helper.
- `multi_hop_nli_pass_keeps_grounded` — entailment 0.9 → grounded=true, verification.nli_passed=true.
- `multi_hop_nli_fail_refuses` — entailment 0.1 → refusal=NliVerificationFailed.
- `multi_hop_nli_disabled_skip_verify` — threshold = 0.0 → verify skip, verification=None.
- `multi_hop_nli_model_unavailable_refuses` — verifier Err → refusal=NliModelUnavailable.
- `multi_hop_truncate_for_nli_preserves_hypothesis` — long premise + 짧은 hypothesis → hypothesis 그대로.
- `integrations/claude-code/kebab/SKILL.md`:
- `mcp__kebab__ask` 절에 NLI 안내 한 줄 — `answer.v1.verification.nli_passed` 의미 + threshold tuning 가이드 + `nli_verification_failed` / `nli_model_unavailable` refusal 처리.
**Tests**: 5 신규 multi-hop tests (위 list) + 기존 tests 회귀 0.
**검증**:
- `cargo test --workspace -j 1` — 모든 test 통과 + 신규 5 multi-hop pass.
- `cargo clippy --workspace --all-targets -j 1 -- -D warnings` clean.
**Wire 영향**: PR-9c-1 의 wire schema 변경에 *behavior wiring* — `verification` field 가 multi-hop ask 의 happy path / refuse path 양쪽에서 채움.
**시간**: 3-4h.
**Total PR-9c (1+2)**: 5-7h (round-1 4-6h underestimated 반영 → 5-7h).
### PR-9d — Dogfood retest + HOTFIXES closure
**Goal**: PR-9c 머지 후 같은 dogfood corpus 에서 S7 + S1 + S3 + S10 retest. PR-9 의 진짜 작동 확인.
**Dependency**: PR-9c 머지 완료.
**Scope**: 본 *PR* 가 아닌 *별 commit* 로 가능성 ↑:
- repo 변경 = `tasks/HOTFIXES.md` 의 "PR-9 closure" sub-section 추가 + (선택) `docs/dogfood/v0.18.0/` 의 dogfood result snapshot.
- `/build/cache/dogfood-v018/results/post-pr9/` 는 repo 외 (gitignore 처럼).
- **결정**: PR (gitea-pr) 또는 main 직접 commit 둘 다 가능. 작업자 선택 — review 부담 ↓ 우선이면 commit, audit trail 우선이면 PR. *본 spec 의 default = PR* (다른 PR 패턴과 일관).
**Files**:
- `tasks/HOTFIXES.md`:
- "PR-9 closure (post-v0.18 dogfood retest)" sub-section 추가 — pre/post 결과 비교 표.
- `docs/dogfood/v0.18.0/` (신규 디렉토리):
- `SUMMARY.md` — sanitized dogfood 보고서 (원본 `/build/cache/dogfood-v018/results/SUMMARY.md` 의 repo 포함 가능 부분).
- `s7-multihop-post-pr9.json` — S7 multi-hop NLI 결과 sample (refuse + nli_score).
- `s1-multihop-post-pr9.json` — S1 multi-hop NLI 결과 sample (grounded + nli_score).
- `/build/cache/dogfood-v018/results/post-pr9/` (작업 디렉토리, repo 외):
- 시나리오별 JSON dump + findings.md.
**Tests**: 자동화 없음. 사용자 환경 (release binary + Ollama gemma3:4b + NLI model first-run) 에서 manual run:
- `[rag] nli_threshold = 0.5` config (production 권장값).
- S7 / S1 / S3 / S10 query → 각각 NLI score 측정 + grounded/refuse 확인.
- **RAM peak 측정 protocol** (round-2 critic gap 반영) — 시작 전 `ps -o rss=,vsz=,comm= -p $(pgrep -f 'ollama|kebab')` baseline. multi-hop ask 진행 중 1초 간격 sampling (5분 cap) — `while sleep 1; do ps ... ; done > /tmp/ram-S<N>.log`. peak RSS = `awk '{sum+=$1} END {print max}'` (Ollama + kebab + NLI model 합산). 16 GB 환경 OOM 없는지 + peak < 10 GB 확인. release notes 의 권장 RAM (peak + 4 GB headroom) 한 줄 명시.
**Pre-run prereq (manual + subagent 양쪽 적용)**: PR-9d 시작 전 환경 검증 — manual run 작업자 또는 subagent dispatch 모두 동일 prereq:
- Ollama service running (`curl -s 127.0.0.1:11434/api/tags`).
- dogfood corpus 디렉토리 존재 (`/build/cache/dogfood-v018/queries/*.txt`).
- network reachable (hf-hub 의 280 MB NLI model first-run download 가능).
- free RAM ≥ 6 GB (peak headroom).
- release binary path: `/build/out/cargo-target/release/kebab` (CARGO_TARGET_DIR 활용 environment) 또는 `./target/release/kebab` (default in-tree).
prereq 실패 시 subagent 가 *조기 abort* + 사용자 보고. *partial* dogfood 결과 commit 회피.
**Expected (PASS criteria)**: §7 verification plan 의 acceptance criteria 표 단일 source of truth. 본 절에서는 *워크플로우 설명* 만 — measurement value 와 threshold 결정은 §7 표에서. duplication 회피 (round-4 R4-NEW-M1 + R4-NEW-N1 closure).
dogfood iteration 결과에 따른 default 조정 trigger:
- S1 의 entailment 가 0.6 미만이면 *legitimate answer 가 reject* 의 false positive — threshold 조정 (`nli_threshold = 0.3` 등) 또는 NLI model 교체 (xlm-roberta-large) 검토.
- S3/S10 의 acceptable degraded outcome 이 50% 이상이면 multilingual NLI 의 한국어 confidence 약함 — model 교체 또는 token-count budget 갱신 (R3-NEW-N1 의 v0.18.1 candidate).
**Wire 영향**: 없음 (docs only).
**시간**: 4-6h (round-1 P3 PR vs commit 결정 + RAM 측정 + dogfood corpus 보존 추가).
## 4. 이미 머지된 PR-1 ~ PR-8 의 결과
| PR | 변경 | 상태 |
|---|---|---|
| #166 PR-1 | multi-hop eval golden set | ✅ |
| #167 PR-2 | `ask_multi_hop` skeleton (fixed depth=2) | ✅ |
| #168 PR-3a | HopRecord wire + RagCfg knobs | ✅ |
| #169 PR-3b-i | dynamic decide loop + helpers | ✅ |
| #170 PR-3b-ii | ScriptedLm + 7 multi-hop tests + refusal hop trace | ✅ |
| #171 PR-4 | CLI `--multi-hop` flag + wire schema | ✅ |
| #172 PR-5 | MCP `multi_hop: bool` arg + SKILL.md | ✅ |
| #173 PR-6 | TUI F2 toggle + badge + hops summary | ✅ |
| #174 PR-7 | pre-decompose probe gate (S7 1차 fix) | ✅ |
| #175 PR-8 | synthesize prompt rule + pool 30→15 (S7 2차 partial mitigation) | ✅ |
frozen design contract (`2026-05-25-p9-fb-41-multi-hop-rag-design.md`) 의 PR-3 분할 (3a/3b-i/3b-ii) + PR-7 / PR-8 추가는 *post-merge deviation*. HOTFIXES 에 기록 (이미 dated entries 존재).
## 5. v0.18.0 cut PR (PR-9d 머지 후, 별 PR `chore: cut v0.18.0`)
**바람직한 patterns** (round-1 M7 / D2 / M6 모두 반영):
- `v0.18.0` bump + tag = **같은 commit** (CLAUDE.md "Release / binary version bump" rule).
- frozen design §3.8 갱신은 *본 cut PR 안* 에서 (PR-9c 가 design contract 변경 안 함, 머지 후 한꺼번에).
- `gitea-release` tag 는 본 PR 머지 commit 위에 즉시.
**한 commit 내용 (또는 짧은 PR scope)**:
1. **Workspace `Cargo.toml` version** 0.17.2 → 0.18.0 (minor bump).
- surface 확장: CLI `--multi-hop`, MCP `multi_hop`, TUI F2, answer.v1 `hops` + `verification`.
- prompt_template_version: `rag-multi-hop-v1` (PR-2 이후, 변경 없음).
- safety fix: PR-7 + PR-8 + PR-9.
- `Cargo.lock` 자동 cascade.
2. **HANDOFF.md**:
- 한 줄 요약 (P0~P9 + P10 + v0.18.0 fb-41 multi-hop ship).
- 머지 후 결정 절에 fb-41 entry 단락 (PR-1~PR-9 + dogfood + NLI 한 문단).
3. **HOTFIXES.md**:
- PR-9 closure sub-section anchor 정리 (`post-v0.18`).
- 기존 fb-41 entry 들 `post-v0.18` anchor.
4. **INDEX.md**:
- fb-41 status `open` → `completed`.
- v0.18.0 release subheader (fb-41 multi-hop + NLI verification).
5. **frozen design** (`docs/superpowers/specs/2026-04-27-kebab-final-form-design.md`):
- §3.8 RAG 의 multi-hop sub-section 추가 — 본 finalize spec 의 §1-§3 요약을 verbatim 형식으로 inline.
- §9 versioning cascade 표에 (선택) `nli_model_version` row — *config knob 변경만 cascade 영향, embedding 처럼 chunks 재-index 불요* 명시.
6. **integrations/claude-code/kebab/SKILL.md**:
- PR-9c-2 에서 *비활성* 상태 NLI 안내 추가됨. cut PR 에서 v0.18.0 release notes link 한 줄.
7. **README**:
- `kebab ask --multi-hop` + NLI 옵션 안내 한 단락 (model first-run download cost, RAM 권장).
- binary path confusion (round-1 N1 / dogfood SUMMARY §부수 발견) 한 줄 — `CARGO_TARGET_DIR` 활용 시 `/build/out/cargo-target/release/kebab` 명시.
8. **`docs/SMOKE.md`**:
- NLI 옵션 활성화 절차 ([rag] nli_threshold = 0.5).
- first-run model download 안내 (~280 MB to `{data_dir}/models/nli/`).
- RAM 권장 (NLI active + Ollama **gemma3:4b** (권장 모델) 동시 — peak RSS ~5-6 GB; 16 GB 머신에서 OK). **8B+ Q4 모델** (gemma4:e4b 8B / gemma2:9b 등) 사용 시 *추정* peak ~10 GB — 16 GB 환경 경계, OOM risk 별 안내 한 줄.
**같은 commit 의 PR title + tag**:
- Commit msg: `chore: bump version 0.17.2 → 0.18.0 + cut fb-41 multi-hop`.
- gitea-release: `v0.18.0` tag *본 commit* 위.
- Release notes (자동 `--auto-notes`):
```
# v0.18.0 — fb-41 multi-hop RAG ship + NLI verification
## 새 surface
- CLI: `kebab ask --multi-hop <query>` — multi-hop reasoning.
- MCP: `ask` tool `multi_hop: true` argument.
- TUI: Ask 패널 F2 toggle + multi-hop badge + hops summary.
## 새 wire
- `answer.v1.hops` — multi-hop per-iter trace (decompose/decide/synthesize).
- `answer.v1.verification` — NLI groundedness score (`nli_threshold > 0.0` 일 때).
- `error.v1.code` enum 확장: `multi_hop_decompose_failed`, `nli_verification_failed`, `nli_model_unavailable`.
## 새 config
- `[rag] multi_hop_max_depth` (default 3), `multi_hop_max_sub_queries_per_iter` (5), `multi_hop_max_pool_chunks` (15).
- `[rag] nli_threshold` (default 0.0 — disabled; 권장 production 0.5).
- `[models.nli] model` (default `Xenova/mDeBERTa-v3-base-xnli-multilingual-nli-2mil7`).
## 새 RefusalReason
- `multi_hop_decompose_failed`, `nli_verification_failed`, `nli_model_unavailable`.
## 권장 환경
- LLM: gemma3:4b (CPU only, 16 GB RAM 권장).
- NLI 활성화 시: ~280 MB first-run download to `{data_dir}/models/nli/`.
- RAM peak (NLI active + Ollama 동시, **gemma3:4b 기준**): ~5-6 GB (16 GB 환경 OK). 8B+ Q4 모델 (gemma4:e4b 8B / gemma2:9b 등) 은 *추정* peak ~10 GB — 16 GB 경계.
## Known limitations
- single-pass NLI 미적용 (v0.18.1 priority).
- atomic claim split 미적용 (entire answer = 1 NLI call).
- GPU acceleration 미지원 (CPU ONNX runtime).
## 도그푸딩
- dogfood corpus snapshot: `docs/dogfood/v0.18.0/`.
- HOTFIXES dated entries 의 PR-9 closure 절 참조.
```
## 6. 한계 / 미해결 (v0.18.1+ 또는 P+)
- **NLI single-pass 적용** — v0.18 scope 외. `[rag] nli_single_pass_enabled = false` (default) 별 PR.
- **NLI threshold tuning** — production 표준 0.9, kebab 권장 enable 값 0.5 (config default 는 0.0 disabled, §2.6 참조; multilingual NLI 의 한국어 confidence 보수). PR-9d dogfood 후 적정값 결정 — *measured value* 가 default 갱신 또는 doc 권장값 갱신.
- **Atomic claim split** — 현재 entire answer 1 claim. LLM-based claim extraction (별 LLM call) 은 v0.19+. wire field `nli_score` 가 *single* 인 이유.
- **NLI false negative** — strong paraphrase → reject. dogfood 측정 후 threshold 조정 또는 model 교체 (xlm-roberta-large 1.5 GB).
- **GPU acceleration** — ort 의 CUDA execution provider 가능. v0.19+ 사용자 환경 의존.
- **release binary path confusion** — `target/release/kebab` (in-tree) vs `/build/cache/...` (CARGO_TARGET_DIR). v0.18.0 cut PR 의 README 한 줄 (§5 의 step 7 포함) — *deferred 아닌 closure*.
- **Future LLM 의 ceiling 측정** — gemma4:e4b / qwen2.5:7b / larger 의 prompt-following 측정. NLI vs LLM-upgrade 의 ROI 재평가. v0.19+ dogfood agenda.
- **NLI model 양자화 (Q8 INT8)** — 280 MB FP32 → ~70 MB INT8 (`Xenova/.../onnx/model_quantized.onnx`). accuracy 미세 저하. v0.19+ config knob `[models.nli] quantization = "fp32" | "q8"`.
## 7. 검증 plan (PR-9d acceptance criteria)
각 sub-PR 가 자체 회귀 핀. PR-9d 의 dogfood retest 가 *integration-level* 검증.
**측정 환경 (전체 표 공통)**: `[rag] nli_threshold = 0.5` (production 권장값). *NLI score 자체* 가 expected range 안인지가 PASS — `nli_passed` boolean 은 threshold 함수라 redundant. dogfood 작업자가 다른 threshold 로 측정 시 (예: 0.3) 결과 해석 다를 수 있어 spec 가 *threshold lock*.
| 시나리오 | path | primary expected | acceptable degraded | nli_score range | latency expected |
|---|---|---|---|---|---|
| S7 (caffeine, KB outside, EN) | multi-hop NLI | grounded=false, refusal=`nli_verification_failed` | (없음 — 반드시 NLI refuse) | **< 0.3** | 158s + NLI ~50ms (PR-7 probe gate 가 RRF top_score 0.5 > 0.30 통과시키므로 multi-hop pipeline 전체 진행 후 step 8.5 NLI refuse) |
| S1 (compiler compound, KR) | multi-hop NLI | grounded=true, refusal=None | (없음 — 반드시 grounded) | **≥ 0.6** | 158-200s + NLI |
| S3 (retrieval stack, **EN**) | multi-hop NLI | grounded=true, refusal=None | grounded=false + LlmSelfJudge (paraphrase 강한 EN→KR sub-queries 의 entailment 약함) — *citation marker 누락 잔존 issue, NLI 자체는 통과* | **≥ 0.5** | 같은 range |
| S10 (dinosaur, KB outside, KR) | multi-hop NLI | grounded=false, refusal=`nli_verification_failed` | grounded=false + LlmSelfJudge (NLI 의 한국어 confidence 낮으면 LLM self-judge 가 reject path) | **< 0.4** | 590s |
| S7 single-pass | single-pass (NLI 미적용) | grounded=false, LlmSelfJudge | (없음) | n/a (verification field 없음) | 30s |
**Primary vs degraded acceptable** (round-2 critic P-M5 closure):
- S7: NLI refuse 가 본 PR-9 의 *core 검증* — degraded outcome 허용 안 함. NLI 가 안 refuse 면 *PR-9 가 작동 안 함*.
- S1: legitimate compound query — NLI 가 reject 시 *false positive*. degraded outcome 허용 안 함.
- S3 / S10: NLI 의 한국어 confidence / paraphrase 강도가 multilingual NLI 의 known weakness. primary 우선 기대지만 degraded LlmSelfJudge 도 안전한 fail-closed path 라 acceptable. *그러나 degraded 가 50% 이상* 시 NLI 효과 약함 — threshold 조정 또는 model 교체 (xlm-roberta-large) 검토.
PR-9d 의 PASS: S7 + S1 primary expectation 모두 충족 + S3/S10 의 primary 또는 acceptable degraded. range 밖 시 threshold 또는 model 재검토.
**RAM peak 측정** (protocol 은 §3 PR-9d 참조):
- Ollama RSS + kebab-cli RSS + NLI model RSS = peak 약 ~5-6 GB.
- 16 GB 환경에서 OOM 없는지 확인. release notes 의 권장 RAM 명시.
## 8. self-review notes
- **PR-9 의 ONNX integration** 가 *새 dep chain* (ort + tokenizers + hf-hub) 도입 — 첫 사용 안정화 필요. PR-9b 의 `#[ignore]` test 의 manual smoke protocol (PR description 강제 첨부) 이 *production binary 의 실제 동작 검증* path.
- **multi-hop NLI 의 latency 추가** — current multi-hop synthesize 158s + NLI ~50ms ≈ 158s. negligible.
- **Model first-run download (~280 MB)** — 사용자 도그푸딩 환경 (CPU only) 의 disk + download bandwidth 1회 비용. README + SMOKE 안내. fail-closed download failure 정책.
- **`RagPipeline::new` 시그니처 widening — Option B (builder) 결정** (round-1 A2 반영). 기존 시그니처 유지 + `with_verifier` builder. 18+ existing call sites 무영향.
- **frozen design contract §3.8 갱신 timing — v0.18.0 cut PR 안** (round-1 M6 / D2 반영). PR-9c 가 contract 변경 안 함 — implementation 만. cut PR 에서 contract + implementation 결과 동시 갱신.
- **kebab-nli 의 trait + impl 동일 crate** (round-1 A4 deferred 결정 명시) — v0.18 scope = adapter 1개. v0.19+ 에 multi-adapter 등장 시 `kebab-nli-onnx` 분리 (그 시점에 internal API breaking, wire 무관).
- **single-pass NLI deferred wording** "v0.18 scope priority — multi-hop hallucination risk 가 single-pass 보다 큼" 으로 round-1 wording 조정 (M9 반영).
- **alternative root cause 검토** (M1 반영) — §1.2 의 4 alternatives 비교 표. NLI 채택 ROI justification 강화.
- **PR-9c 분할** (M2 / P1 반영) — 9c-1 (core types) + 9c-2 (pipeline integration).
- **PR-9d PR vs commit** (P3 반영) — PR default, 작업자 선택 가능.
- **dogfood corpus 보존** (P5 반영) — `docs/dogfood/v0.18.0/` 신규 dir + sanitized SUMMARY + sample JSON.
- **RAM cold-start 측정** (P6 반영) — PR-9d 의 PASS criteria 에 포함.
- **ort version pin** (P7 반영) — `workspace.dependencies.ort = "=2.0.0-rc.9"` (fastembed transitive 와 정확히 일치).
- **integrations/claude-code/kebab/SKILL.md NLI 안내** (D6 반영) — PR-9c-2 에서 추가.
## 9. round-1 review 의 issue closure 매트릭스
| reviewer | issue | resolution |
|---|---|---|
| architect | A1 model ID 불일치 | §2.1 — Xenova/... config default + MoritzLaurer/... 원본 출처 명시 |
| architect | A2 widening path 미결정 | §8 / §3 PR-9c-1 — Option B (builder) 결정 |
| architect | A3 config default 모순 | §2.6 — `enabled` field 제거 + `nli_threshold` single gate |
| architect | A4 crate split | §2.2 + §8 — v0.18 단일 crate, future split trigger 명시 |
| architect | A5 ort version + feature | §3 PR-9a + §8 — `ort = "=2.0.0-rc.9"` pin, fastembed transitive 와 정확히 일치 |
| architect | A6 cache_dir → model_dir | §2.2.2 — `config.storage.model_dir.join("nli")` |
| architect | A7 §2.3 single-pass 주석 | §2.3 — 주석에서 single-pass 제거 + §2.7 cross-ref |
| critic | C1 truncation strategy | §2.2.3 — `OnlyFirst` + `truncate_for_nli` helper + 회귀 핀 |
| critic | M1 alternative root cause | §1.2 — 4 alternatives 비교 표 |
| critic | M2 PR-9c scope 과대 | §3 — 9c-1 + 9c-2 분할 |
| critic | M3 9b smoke protocol | §3 PR-9b — manual smoke + PR description 강제 첨부 |
| critic | M4 threshold default 모순 | §2.6 + §7 — default 0.0 (disabled), production 권장 0.5, dogfood 측정값 별 명시 |
| critic | M5 S1 acceptance criteria | §7 — measured value range 표 |
| critic | M6 frozen design timing | §5 + §8 — cut PR 안에 통합 |
| critic | M7 bump same-commit | §5 — 같은 commit 명시 + tag |
| critic | M8 download fallback | §2.6 — fail-closed + NliModelUnavailable + warn |
| critic | M9 single-pass deferred wording | §2.7 + §8 — wording 조정 |
| critic | N1 binary path | §5 step 7 — README 한 줄 |
| critic | N2 threshold 0.0 edge | §2.6 — doc comment 명시 |
| critic | N3 wire naming | §2.4 — `nli_verification_failed` + `nli_model_unavailable` (snake 통일) |
| planner | P1 9c scope | M2 와 같음 — 분할 |
| planner | P2 9b 시간 | §3 PR-9b — 8-12h 갱신 |
| planner | P3 9d PR vs commit | §3 PR-9d — PR default, 작업자 선택 |
| planner | P4 model pre-flight | §2.1 + §3 PR-9a — pre-flight curl check |
| planner | P5 dogfood 보존 | §3 PR-9d + §5 step 5 — `docs/dogfood/v0.18.0/` 신규 |
| planner | P6 RAM cold-start | §7 — PR-9d acceptance criteria + release notes |
| planner | P7 ort pin | §3 PR-9a — `"=2.0.0-rc.9"` |
| planner | P8 frozen design timing | M6 와 같음 — cut PR 안 |
| document | D1 schema refusal_reason.enum | §3 PR-9c-1 — `nli_verification_failed` + `nli_model_unavailable` |
| document | D2 frozen design timing | M6 와 같음 |
| document | D3 enabled/threshold | A3 와 같음 — `enabled` 제거 |
| document | D4 error.v1 description | §2.4 — per-code description 갱신 |
| document | D5 Xenova vs MoritzLaurer | A1 와 같음 — §2.1 명시 |
| document | D6 SKILL.md | §3 PR-9c-2 — multi-hop ask 절에 NLI 안내 |
### Round-2 issues (post-spec-v2 review)
| reviewer | round-2 issue | round-3 resolution |
|---|---|---|
| document | ISSUE-1 RefusalReason rename | §2.4 — `FailedNliVerification` → `NliVerificationFailed`. wire `"nli_verification_failed"` 가 RefusalReason + error.v1.code 양쪽 동일. mapping 표 §2.4 inline. |
| document | NIT-2 VerificationSummary required | §2.5 — `$defs.VerificationSummary` 의 `required: ["nli_score", "nli_threshold", "nli_passed"]` 명시. HopRecord 패턴. |
| critic | P-M4 threshold context | §7 — 측정 환경 명시 (`nli_threshold = 0.5` lock). |
| critic | P-M5 S3/S10 multi-outcome | §7 — primary expected + acceptable degraded 컬럼 + 50% 이상 degraded 시 model 재검토 명시. |
| critic | P-N2 entailment=0.0 edge | §2.6 — outer guard `> 0.0` 가 disabled path short-circuit + `>=` 비교는 active 분기 도달. doc comment 명시. |
| critic | P-N3 wire naming | §2.4 — RefusalReason wire 도 `nli_verification_failed` 통일. mapping 표 명시. document ISSUE-1 와 같음. |
| critic | NEW-M1 tokenizers features | §3 PR-9a — pre-flight 의 standalone repro (`cargo new --bin nli-tok-probe ...`). Cargo features 결정 trace 를 PR description 의 별 절에 첨부. |
| critic | NEW-M2 facade panic vs error | §3 PR-9c-2 — `App::new` 가 `Result<App, anyhow::Error>` 반환. `OnnxNliVerifier::new` 실패 시 `bail!`. unreachable safety net 만 `expect()`. |
| critic | NEW-N1 truncate_for_nli signature | §3 PR-9c-2 — `pub fn truncate_for_nli(premise: &str, hypothesis: &str) -> (String, bool)` 명시. second = was_truncated. |
| critic | NEW-N2 empty hypothesis | §2.3 — `if !acc.trim().is_empty()` guard. empty answer 는 step 8.5 skip — 다른 path (LlmStreamAborted 등) 가 처리. |
| critic | What's missing RAM protocol | §3 PR-9d — `ps -o rss` 1초 sampling. peak < 10 GB 검증. |
| critic | What's missing S3 EN | §7 — S3 표 row 의 language `(EN)` 명시. |
| planner | round-2 nit #1 9c-1/9c-2 별 PR | §3 PR-9c — "별 PR 2개로 분할 (9c-1 → 9c-2 sequential 머지)" 명시. |
| planner | round-2 nit #2 9c-2 dependency | §3 PR-9c-2 — "Dependency: PR-9c-1 머지 완료" 명시. |
| planner | round-2 nit #3 시간 합산 | spec self-review 의 시간 합산 19-28h (plan v2 갱신 시 정정 예정). |
| planner | round-2 nit #4 9d subagent prereq | §3 PR-9d — Ollama running + corpus 존재 + network reachable + free RAM 검증 prereq 명시. |
### Round-3 issues (post-spec-v3 review)
| reviewer | round-3 issue | round-4 resolution |
|---|---|---|
| critic | R3-NEW-M1 truncate_for_nli signature mismatch | §2.2.3 — *3-arg* recommendation 제거, `(premise, hypothesis) -> (String, bool)` 단일 source. signature lock = §3 PR-9c-2. |
| critic | R3-NEW-M2 S7 latency wrong baseline | §7 표 S7 row — `158s + NLI ~50ms` (multi-hop pipeline 전체 진행 후 step 8.5 refuse). probe gate pass 가 원인 설명 inline. |
| critic | R3-NEW-N1 MAX_NLI_PREMISE_CHARS 한국어 token ratio | §3 PR-9c-2 — 4 char ≈ 1 token (EN BPE), 한국어 SentencePiece 1-2 char/token. tokenizer OnlyFirst backup 명시 + dogfood S10 (KR) 측정 후 v0.18.1 token-count 기반 budget 갱신. |
| critic | R3-NEW-N2 LLM 모델 환경 모순 | §5 step 8 + release notes — RAM peak 의 모델 명시 (gemma3:4b 기준 ~5-6 GB, 9B+ 모델 *추정* ~10 GB / 16 GB 경계). |
| critic | R3-NEW-N3 prereq scope | §3 PR-9d — "Pre-run prereq (manual + subagent 양쪽)" wording 갱신. |
### Round-4 issues (post-spec-v4 review)
| reviewer | round-4 issue | round-5 resolution |
|---|---|---|
| critic | R4-NEW-M1 §3 PR-9d 표 vs §7 표 latency contradiction (S7) | §3 PR-9d — Expected 표 전체 제거, "§7 verification plan 표 단일 source of truth" cross-ref 로 대체. duplication 회피. |
| critic | R4-NEW-N1 §3 PR-9d 표 format inconsistency | R4-NEW-M1 와 동시 closure (cross-ref 가 양쪽 해결). |
| critic | R4-NEW-NIT-1 9B+ 모델 naming | §5 step 8 — "9B+ 모델" → "8B+ Q4 모델 (gemma4:e4b 8B / gemma2:9b 등)" |
| critic | R4-NEW-NIT-2 §6 default 0.5 wording | §6 — "kebab default 0.5" → "kebab 권장 enable 값 0.5 (config default 는 0.0 disabled, §2.6 참조)" |