Files
kebab/crates/kebab-rag/tests/multi_hop_nli_panic.rs
altair823 685007789a style: cargo fmt --all (round 4 ingest log feature follow-up)
Phase C4 executor 의 마지막 `fix(test): clippy + fmt fixes` commit 이
test file 부분만 fmt 적용. workspace 전체 fmt 누락 발견 → cargo fmt --all
적용. 모든 import alphabetical reorder + line wrapping 정합.

추가 untracked artifact 동시 commit:
- docs/superpowers/specs/2026-05-28-v0.20-ingest-log-spec.md (491 line, ACCEPT)
- docs/superpowers/plans/2026-05-28-v0.20-ingest-log-plan.md (616 line, ACCEPT)

workspace test: 1370 passed / 0 failed / 50 ignored, ingest_log_smoke green.

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

101 lines
4.1 KiB
Rust

//! Pins the documented facade-invariant panic in `ask_multi_hop`.
//!
//! When `cfg.rag.nli_threshold > 0` but no verifier is attached via
//! `.with_verifier()`, the `expect` at `pipeline.rs` step 8.5 fires
//! with the message "verifier must be Some when nli_threshold > 0.0".
//!
//! This is a **contract test**: it documents the invariant so that a
//! future refactor replacing the `expect` with `bail!` (or a different
//! message) is caught by the test suite, prompting an explicit decision
//! rather than a silent behavior change.
//!
//! The kebab-app facade (`App::open_with_config`) always pairs
//! `nli_threshold > 0` with a constructed `OnnxNliVerifier`, so this
//! panic is unreachable via the normal CLI / MCP / TUI paths — only
//! a direct `RagPipeline::new(...)` caller without `.with_verifier()`
//! can trigger it.
mod common;
use std::sync::Arc;
use common::{RagEnv, ScriptedLm, ScriptedRetriever, id32, mk_hit};
use kebab_core::{LanguageModel, Retriever, SearchMode};
use kebab_rag::{AskOpts, RagPipeline};
/// Minimal multi-hop `AskOpts` mirroring the pattern used in
/// `multi_hop.rs` — lexical mode, deterministic seed, no streaming.
fn multi_hop_opts() -> AskOpts {
AskOpts {
k: 5,
explain: false,
mode: SearchMode::Lexical,
temperature: Some(0.0),
seed: Some(0),
stream_sink: None,
history: Vec::new(),
conversation_id: None,
turn_index: None,
multi_hop: true,
}
}
/// Building the "happy-path" scenario inline: probe retrieve passes
/// the score gate, decompose emits one sub-query, decide signals stop,
/// and synthesize produces a non-empty cited answer. This is the minimal
/// scenario that reaches step 8.5 (NLI gate) in `ask_multi_hop`.
fn setup_happy_pipeline_no_verifier(nli_threshold: f32) -> (RagPipeline, RagEnv) {
let env = RagEnv::new();
let cid = id32("c1");
let did = id32("d1");
env.seed_chunk(&cid, &did, "notes/a.md", "Body text.", &["Intro"]);
let hits = vec![mk_hit(1, &cid, &did, "notes/a.md", 0.85, &["Intro"])];
// Entry 0 = probe retrieve (pre-decompose gate check).
// Entry 1 = decompose-driven retrieve for "q1".
let retriever = Arc::new(ScriptedRetriever::new(vec![hits.clone(), hits]));
let retriever_dyn: Arc<dyn Retriever> = retriever;
// Three LLM calls: decompose → decide (stop) → synthesize.
// Synthesize emits a non-empty answer so step 8.5 is reached.
let lm = Arc::new(ScriptedLm::new(vec![
r#"["q1"]"#, // decompose
r"[]", // decide: stop signal
"answer body [#1]", // synthesize: non-empty → step 8.5 entered
]));
let lm_dyn: Arc<dyn LanguageModel> = lm;
let mut cfg = env.config.clone();
cfg.rag.nli_threshold = nli_threshold;
// Intentionally NO `.with_verifier()` — this is the condition under test.
let pipeline = RagPipeline::new(cfg, retriever_dyn, lm_dyn, env.sqlite.clone());
(pipeline, env)
}
#[test]
#[should_panic(expected = "verifier must be Some when nli_threshold > 0.0")]
fn ask_multi_hop_panics_when_threshold_positive_but_verifier_none() {
// nli_threshold = 0.5 (gate enabled) but the pipeline has no verifier
// because `.with_verifier()` was never called. The `expect` at
// pipeline.rs step 8.5 fires once synthesize produces a non-empty answer.
let (pipeline, _env) = setup_happy_pipeline_no_verifier(0.5);
// Unwrap is intentional: we're asserting the panic, not an Ok/Err return.
let _ = pipeline.ask("compound", multi_hop_opts());
}
/// Companion: threshold = 0.0 (gate disabled) with no verifier must
/// NOT panic — the `if nli_threshold > 0.0` guard short-circuits the
/// entire step 8.5 block.
#[test]
fn ask_multi_hop_does_not_panic_when_threshold_zero_and_verifier_none() {
let (pipeline, _env) = setup_happy_pipeline_no_verifier(0.0);
let answer = pipeline
.ask("compound", multi_hop_opts())
.expect("threshold = 0.0 skips NLI gate; no panic expected");
// Gate is disabled → verification summary stays None.
assert!(
answer.verification.is_none(),
"nli_threshold = 0.0 must leave Answer.verification = None"
);
}