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>
101 lines
4.1 KiB
Rust
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"
|
|
);
|
|
}
|