feat(rag): fb-41 PR-3b-ii ScriptedLm + multi-hop tests + refusal hop trace #170
Reference in New Issue
Block a user
Delete Branch "feat/fb-41-pr-3b-ii-scripted-lm-tests"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
요약
fb-41 multi-hop RAG 의 PR-3b-ii (PR-3b 의 분할 두 번째 PR, test 인프라 + 회차 1 carry-over). PR-3b-i 의 dynamic decide loop 위에서
ScriptedLmtest helper + 5 multi-hop integration tests + refuse_* helper 의 hops trace 보존 + 회차 1 carry-over (HopRecord doc / const reconcile / decide preview doc).설계: docs/superpowers/specs/2026-05-25-p9-fb-41-multi-hop-rag-design.md
계획: docs/superpowers/plans/2026-05-25-p9-fb-41-multi-hop-rag.md (PR-3b 단락)
Test 인프라
ScriptedLm(tests/common/mod.rs) —Vec<&str>받아 call sequence 순서대로 emit 하는 LanguageModel impl. multi-hop 의 decompose / decide×N / synthesize 각 call 에 다른 response 주입 가능. Send + Sync. Exhaustion 시 call index 명시한 panic.ScriptedRetriever(tests/common/mod.rs) —Vec<Vec<SearchHit>>받아 call 순서대로 hits 반환. pool dedup / iter 별 다른 retrieve 결과 시뮬레이션. exhaustion 시 empty Vec (pipeline 의 정상 path 와 정합).5 multi-hop integration tests (
tests/multi_hop.rs신규)decide_stop_triggers_synthesize— decide[]→ 즉시 synthesize, hops=[Decompose, Decide(stop), Synthesize].decide_continue_adds_more_chunks— decide[\"q2\"]→ iter 2 retrieve + pool 확장, hops 길이 4.max_depth_force_stops—multi_hop_max_depth=1→ depth cap 발동, decide LLM call skip + forced_stop=true.pool_chunks_dedup_by_chunk_id— 두 sub-query 가 같은 chunk_id → dedup 후 pool 1 개,context_chunks_added=1.decide_parse_failure_falls_through_to_synthesize— decide non-JSON garbage → graceful synthesize (NOT refusal, spec §9).refuse_* helper 의 hops trace 보존
refuse_no_chunks/refuse_score_gate시그니처에hops: Option<Vec<HopRecord>>추가. ask_multi_hop 의 score-gate / no-chunks refusal 시 누적된 hops 그대로 Answer.hops 에 보존 — `--multi-hop` 사용자가 refusal 경로에서도 어느 decompose / decide 신호까지 발생했는지 볼 수 있음. single-pass ask 는 None 전달, wire 변동 없음 (`skip_serializing_if = None`).회차 1 carry-over (PR #168 + #169 의 review 항목)
변경 없음
검증
시험 항목 (Test Plan)
다음 PR
Assisted-by: Claude Code
PR-3b 의 분할 두 번째 PR — PR-3b-i 의 dynamic decide loop 위에서: 1. **ScriptedLm + ScriptedRetriever helper** (kebab-rag tests/common/mod.rs) per-call 다른 response 반환. decompose / decide×N / synthesize 의 각 LLM call 을 구분하는 다단계 multi-hop 시나리오를 mock-only 로 exercise 가능. `Vec<&str>` / `Vec<Vec<SearchHit>>` 받아 call sequence 순서대로 emit. Send + Sync. 2. **5 multi-hop integration tests** (kebab-rag tests/multi_hop.rs 신규) - decide_stop_triggers_synthesize: decide [] → 즉시 synthesize - decide_continue_adds_more_chunks: decide ["q2"] → iter 2 retrieve + pool 확장 - max_depth_force_stops: depth cap → forced_stop + decide LLM call skip - pool_chunks_dedup_by_chunk_id: 같은 chunk_id 두 sub-query 에서 1 회 - decide_parse_failure_falls_through_to_synthesize: parse fail = graceful synthesize (refusal 아님, spec §9) 3. **refuse_* helper hops trace 보존** (회차 1 carry-over) refuse_no_chunks / refuse_score_gate 시그니처에 `hops: Option<Vec<HopRecord>>` 인자 추가. ask_multi_hop 의 score-gate / no-chunks refusal 시 누적된 hops 그대로 Answer.hops 에 보존. single-pass ask 는 None 전달 — wire 변동 없음 (skip_serializing_if). 4. **HopRecord doc 보강** (회차 1 carry-over) sub_queries 의 per-kind 의미 명시 (Decompose=initial / Decide=next-iter or empty=stop / Synthesize=always empty). llm_call_ms=0 의 ambiguity (no call vs 0ms call) doc 명시. 5. **MULTI_HOP_MAX_SUB_QUERIES_DEFAULT → _HARD_CAP rename** (회차 1 carry-over) const 의 의도 명확화 — config knob `multi_hop_max_sub_queries_per_iter` (5, prompt-side soft hint) 와 const (10, parse-side hard ceiling) 분리. 두 layer 의 책임 doc 동기화. test 도 rename. 6. **decide guard 단순화 + preview budget doc** (회차 1 carry-over) parse_decompose_response 의 post-condition (Some=non-empty 보장) doc 명시. defensive `Some(qs) if !qs.is_empty()` → `decide_result.unwrap_or_default()` 단순화. decide preview 의 snippet-only path (full chunk text 안 fetch) 의도 doc. 검증 - `cargo test -p kebab-rag -j 1` — 31 unit + 19 pipeline + 5 multi_hop + 3 prompt_template + 3 streaming 모두 통과. - `cargo clippy -p kebab-rag --all-targets -j 1 -- -D warnings` clean. Spec / plan - design: docs/superpowers/specs/2026-05-25-p9-fb-41-multi-hop-rag-design.md - plan: docs/superpowers/plans/2026-05-25-p9-fb-41-multi-hop-rag.md (PR-3b 단락) 다음 단계 = PR-4 (CLI --multi-hop + wire schema + error_wire). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>회차 1 — 4 actionable 항목.
작은 doc / nit 두 개 (A, B):
ScriptedLm의 module doc 두 군데가 실제 구현과 어긋난다.Arc<Vec<String>>표기 + 존재하지 않는with_*builder 언급. 두 줄 정정.가장 무거운 한 항목 (C): PR 의 두 핵심 변경 중 하나 —
refuse_*helper 의 hops widening + ask_multi_hop refusal path 의 hops 보존 wiring — 가 5 integration test 중 어느 것에도 직접 핀되지 않는다. happy-path 만 검증하니 누군가 widening 부분을 reverting 해도 5 test 모두 통과. 짝 test 1~2 개로 NoChunks / ScoreGate refusal 시Answer.hops가 None 이 아니라 partial trace 임을 회귀 핀.nit (D): test 5 의
assert_eq!(.., None)+assert_ne!(.., Some(MultiHopDecomposeFailed))— 두 번째가 첫 번째에 trivial 하게 함의됨. 한 assertion 으로 합치고 메시지에 의도 명시.회차 2 에 (A) (B) (C) (D) 모두 반영.
설계·계획 정합 / 코드 흐름 / clippy 측면은 깨끗. test 1~4 의 시나리오 / mock 패턴 / hop trace 검증 / dedup 검증은 잘 짜여 있고, ScriptedLm + ScriptedRetriever 의 exhaustion policy 비대칭 (panic vs empty Vec) 도 doc 으로 rationale 명시 — 적절.
doc-code mismatch:
Internally \Arc<Vec> + AtomicUsize`라고 적혀 있지만 실제 필드는responses: Vec+next: AtomicUsize—Arc가 안에 있는 게 아니라 *밖에서* 사용자가Arc::new(ScriptedLm::new(...))로 감싼다. Send + Sync 가 되는 이유도Vec이 Send + Sync 라서지Arc가 들어 있어서가 아니다. doc 한 줄을Internally `Vec` (immutable after construction) + `AtomicUsize` so the type is `Send + Sync` ...` 로 정정.미존재 API 언급:
Call \with_*` builders if a test needs to override the defaults라고 했지만 코드에with_model_id/with_provider` 같은 builder 가 없다. 둘 중 하나로:현 PR 의 범위 측면에서는 line 제거가 단순함. 향후 필요해지면 그때 builder + doc 같이 추가.
@@ -0,0 +172,4 @@assert_eq!(hops[3].kind, HopKind::Synthesize);}// ── 3. max_depth=1 → forced_stop, decide LLM call skipped ─────────────────PR 의 핵심 변경에 대한 회귀 핀 누락: 이 PR 이
refuse_no_chunks/refuse_score_gate시그니처를 widening 해서hops: Option<Vec<HopRecord>>받게 했고, ask_multi_hop 의 refusal 경로에서 누적된 hops 를 보존하도록 wiring 했다. 하지만 5 integration test 중 하나도 "multi-hop refusal 경로에서 Answer.hops 가 None 이 아니라 partial trace" 를 직접 검증하지 않는다.구체 시나리오:
ScriptedRetriever::new(vec![vec![]])(iter 1 retrieve 가 0 hits) + decompose ok → pool 비어 있음 →refuse_no_chunks(Some(hops))분기. assert:answer.refusal_reason == Some(NoChunks)answer.hopsisSome(non_empty)— 최소 Decompose + Decide(empty) 두 entryhops[0].kind == Decomposerefuse_score_gate 쪽도 비슷한 케이스 하나 추가하면 양쪽 path 모두 핀. 회귀 위험 ↑ — 누군가 widening 을 reverting 해도 happy-path tests 5 건은 그대로 통과한다.
@@ -0,0 +282,4 @@assert_eq!(hops[0].sub_queries, vec!["q1", "q2"]);assert_eq!(hops[1].context_chunks_added, 1,"dedup reduces 2 retrievals → 1 new pool entry"redundant assertion (nit):
assert_eq!(answer.refusal_reason, None)다음에 바로assert_ne!(answer.refusal_reason, Some(MultiHopDecomposeFailed))가 있는데,None != Some(_)은 trivial 하게 참. 메시지의 의도 ("MultiHopDecomposeFailed is reserved for the initial decompose hop") 자체는 가치 있지만 assert 로는 정보 추가 안 함.선택지:
assert_eq!만 남기고assert_ne!제거 (메시지를assert_eq!의 msg 로 합치기 — "decide parse failure is graceful degrade, not refusal — MultiHopDecomposeFailed is reserved for the initial decompose hop").assert_ne!만 두고assert_eq!제거 — 의도는 "MultiHopDecomposeFailed 가 아니다" 가 핵심이므로.둘 다 의도 동일. 한 assertion 으로 줄이고 메시지를 합치는 게 깔끔.
(A) ScriptedLm doc 의 `Arc<Vec<String>>` 표기 → 실제 구현 (`Vec<String>` + `AtomicUsize`, 외부에서 `Arc::new(ScriptedLm::new(...))` 로 wrap) 반영. (B) ScriptedLm::new doc 의 미존재 `with_*` builder 언급 제거. (C) refuse path 의 hops 보존 회귀 핀 2 건 추가 (`tests/multi_hop.rs`): - `multi_hop_refuse_no_chunks_preserves_hops_trace`: empty pool → `refuse_no_chunks(Some(hops))` → Answer.hops = Some([Decompose, Decide]). - `multi_hop_refuse_score_gate_preserves_hops_trace`: top score 0.10 < 0.30 gate → `refuse_score_gate(Some(hops))` → 같은 shape. refuse_* widening + ask_multi_hop 의 forwarding wiring 이 reverting 되면 두 test 가 회귀 잡음. (D) test 5 의 redundant `assert_ne!(.., Some(MultiHopDecomposeFailed))` 제거 — `assert_eq!(.., None)` 이미 함의. 메시지에 의도 통합. 검증 - `cargo test -p kebab-rag -j 1 --test multi_hop` — 7 (5+2) 모두 통과. - `cargo clippy -p kebab-rag --all-targets -j 1 -- -D warnings` clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>회차 2 — 회차 1 의 (A) (B) (C) (D) 모두 반영. 작은 함수 이름 nit 1 건 남음.
반영 확인:
Arc<Vec<String>>표기 정정 ✓with_*builder 미존재 언급 제거 ✓multi_hop_refuse_no_chunks_preserves_hops_trace+multi_hop_refuse_score_gate_preserves_hops_trace. NoChunks (decide 도 skip) 와 ScoreGate (decide ran) 두 케이스가 다른 hop trace shape 까지 핀해서 강도 ↑ ✓assert_ne!제거 + 메시지 통합 ✓회차 2 nit: test 7 의
i32_below_gate_chunkhelper 함수 이름이i32Rust 타입과 충돌해 첫 인상이 헷갈린다. inline 코멘트에 rename + 반환 shape 정리 제안.회차 3 에서 이 한 가지만 반영하면 APPROVE.
@@ -0,0 +450,4 @@fn i32_below_gate_chunk(env: &RagEnv) -> String {let cid = id32("c_low");let did = id32("d_low");env.seed_chunk(&cid, &did, "notes/low.md", "low score text", &["Low"]);함수 이름 nit (actionable):
i32_below_gate_chunk의i32_prefix 는 Rust integer 타입i32와 충돌해서 첫인상이 헷갈린다 (의도는id32헬퍼를 부르는 wrapper 라는 뜻이겠지만 그 정보도 호출자에게 가치 없음). 더 의도가 드러나는 이름:seed_below_gate_chunk(action + condition)seed_low_score_chunk(action + 성격)동시에 함수가 cid 만 반환하면 호출자가
id32("d_low")를 다시 만들어야 하는데 (line 410 의mk_hit(1, &cid, &id32("d_low"), ...)), 이 doc_id 도 함수 안의 seed 와 같은 id32 결과라 dual source-of-truth.(chunk_id, doc_id)tuple 또는 바로SearchHit까지 만들어 반환하면 호출자 본문이 더 짧아지고 헬퍼-호출자 사이의 id 동기화 깨질 위험도 없어진다.test 7 의 `i32_below_gate_chunk` helper rename → `seed_low_score_chunk` + 반환 shape 을 `(chunk_id, doc_id)` tuple 로 확장. `i32` prefix 가 Rust integer 타입과 충돌하던 가독성 문제 해소 + 호출자가 `id32("d_low")` 를 재계산하지 않도록 id 페어를 single source of truth 로 통합. 검증 - `cargo test -p kebab-rag -j 1 --test multi_hop` — 7 모두 통과. - `cargo clippy -p kebab-rag --all-targets -j 1 -- -D warnings` clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>회차 3 — 회차 2 의 함수 이름 nit 완벽 반영.
i32_below_gate_chunk→seed_low_score_chunkrename +(chunk_id, doc_id)tuple 반환 + 호출자가id32("d_low")를 재계산하지 않도록 id 페어 통합. doc 도 의도 명확하게 갱신.추가 actionable 항목 없음 — 머지에 동의.
요약:
cargo test -p kebab-rag -j 131 unit + 19 pipeline + 7 multi_hop + 3 prompt_template + 3 streaming 통과.cargo clippy -p kebab-rag --all-targets -j 1 -- -D warningsclean.