kopitiam_ai/translate.rs
1//! Local-first two-pass translation (token-max card **III-6**).
2//!
3//! The **local** model drafts *every* segment; the **cloud** model reviews
4//! *only* the segments the draft is least confident about. The saving is, by
5//! construction, the fraction of segments handled entirely locally — those cost
6//! **zero cloud tokens**. This module produces the *plan* (draft + per-segment
7//! routing decision + the signals behind it); actually dispatching the
8//! low-confidence segments to a cloud adapter is left to the caller (see the
9//! deferred-wiring note below).
10//!
11//! `kopitiam_token_max.md` §0.5 — *the local model absorbs volume, the cloud
12//! model spends judgment*. Here the local model absorbs the whole document
13//! (one draft per segment) and the cloud model spends its judgment only where a
14//! composable confidence signal says the draft cannot be trusted.
15//!
16//! # Be honest about capability (this is the card's central requirement)
17//!
18//! §13 Task III-6: *"a 0.5B model's translations of technical prose will need
19//! real review. Default conservative, widen the local share only with measured
20//! evidence."* This module takes that literally and mirrors the II-6
21//! preprocessing module's non-authoritative stance ([`crate::preprocess`]):
22//!
23//! 1. **[`Decision::AcceptLocal`] is a routing decision, not a correctness
24//! guarantee.** [`TwoPassPlan::AUTHORITATIVE`] is a compile-time `false`.
25//! "Accept locally" means only *"the signals do not justify spending a cloud
26//! review on this one"* — never *"this translation is correct"*.
27//! 2. **Conservative by default.** [`TwoPassConfig::default`] sets a **high**
28//! [`TwoPassConfig::accept_threshold`] (see [`DEFAULT_ACCEPT_THRESHOLD`]), so
29//! most segments route to the cloud until measured evidence (a reviewer
30//! comparing accepted-local drafts against cloud output on a real corpus)
31//! justifies lowering it to widen the local share. The threshold is the
32//! single knob that trades saving for safety, and it starts safe.
33//! 3. **Never silently accept.** Every [`SegmentPlan`] records the
34//! [`ConfidenceSignals`] behind its decision, so any acceptance is auditable
35//! and reproducible. The plan is a pure function of its inputs.
36//! 4. **The echo stub is detected and discounted.** When the injected adapter
37//! is [`crate::EchoAdapter`] (no local `.gguf`), the "draft" is the source
38//! echoed back and the round-trip agreement is trivially perfect and
39//! *meaningless*. Rather than let that fake-perfect signal inflate
40//! confidence, the round-trip signal is marked **untrusted** and folded in as
41//! neutral, and [`ECHO_PASSTHROUGH_NOTE`] is stamped into the plan (mirroring
42//! II-6's stub honesty, enforced at the library layer via
43//! [`ModelAdapter::name`]).
44//!
45//! # The confidence signal (composable, three parts)
46//!
47//! [`ConfidenceSignals`] combines three independent 0..1 sub-scores into one
48//! 0..1 `confidence`, each capturing a different way a draft can be unreliable:
49//!
50//! * **Length** — a very short segment (a stray heading, a garbage line, an
51//! orphaned number) or a very long one gives a 0.5B model the least to work
52//! with and the most room to drift; only a comfortable middle band scores
53//! high. See [`length_score`].
54//! * **Glossary-hit rate** — reusing the III-5 [`Glossary`], the more
55//! project-decided terms the draft contains (now pinned *deterministically*,
56//! not by the model), the more of the segment is already trustworthy. Hits
57//! raise confidence with diminishing returns; zero hits are neutral, never a
58//! penalty. See [`glossary_score`].
59//! * **Round-trip disagreement** — the draft is translated *back* to the source
60//! language with the same local model and compared to the original source
61//! (character-bigram Sørensen–Dice, [`round_trip_similarity`]). High
62//! divergence means the model could not preserve meaning through the round
63//! trip → low confidence. Under the echo stub this is trivially perfect and is
64//! therefore discounted (see above).
65//!
66//! The three are combined by a fixed, documented weighted mean
67//! ([`ConfidenceSignals::combine`]); no single signal can carry a draft over a
68//! conservative threshold on its own.
69//!
70//! # Measuring the cloud-token saving
71//!
72//! The split *is* the measurement. [`TwoPassPlan::summary`] reports `total`,
73//! `accepted_local`, `sent_to_cloud`, and `local_fraction`:
74//!
75//! ```text
76//! cloud tokens saved ≈ local_fraction × total × (per-segment cloud review cost)
77//! ```
78//!
79//! i.e. every segment marked [`Decision::AcceptLocal`] is one segment the cloud
80//! model never has to read, so its would-be review tokens are saved outright.
81//! **The honest caveat:** `local_fraction` is only as trustworthy as the
82//! threshold that produced it. A high `local_fraction` is a *saving claim*, not
83//! a *quality claim* — it should be widened past the conservative default only
84//! after a reviewer has measured, on a real corpus, that the accepted-local
85//! drafts hold up without the cloud pass (§13: *widen the local share only with
86//! measured evidence*).
87
88use serde::{Deserialize, Serialize};
89
90use crate::{CompletionRequest, Glossary, Message, ModelAdapter};
91
92/// The name [`crate::EchoAdapter::name`] reports. Used to detect the "no real
93/// local model" case so the round-trip signal can be honestly discounted.
94const ECHO_ADAPTER_NAME: &str = "echo";
95
96/// Stamped into a [`TwoPassPlan`] whenever drafting ran against the echo stub
97/// instead of a real local model: the "draft" is the source echoed back and the
98/// round-trip agreement is trivially perfect, so the round-trip signal carries
99/// no information and is folded in as neutral. Do not read a high
100/// `local_fraction` from an echo run as evidence the local model is good enough.
101pub const ECHO_PASSTHROUGH_NOTE: &str = "adapter is the echo stub (no local .gguf) — drafts are a \
102 pass-through of the source and the round-trip signal is meaningless; treat every AcceptLocal \
103 here as unproven and route to cloud in production.";
104
105/// The conservative default [`TwoPassConfig::accept_threshold`].
106///
107/// Set **high on purpose** (§13 Task III-6): a 0.5B model's technical-prose
108/// translation needs real review, so at the default most segments route to the
109/// cloud. Lower it — widening the local share — only once a reviewer has
110/// measured that accepted-local drafts hold up on a real corpus.
111pub const DEFAULT_ACCEPT_THRESHOLD: f32 = 0.85;
112
113/// What to do with one drafted segment.
114#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
115#[serde(rename_all = "snake_case")]
116pub enum Decision {
117 /// The local draft is confident enough that spending a cloud review on it is
118 /// not justified. **A routing decision, not a correctness guarantee** — see
119 /// the module docs and [`TwoPassPlan::AUTHORITATIVE`].
120 AcceptLocal,
121 /// The local draft is low-confidence; send this segment to the cloud model
122 /// for review. These are the only segments that cost cloud tokens.
123 SendToCloud,
124}
125
126/// Configuration for [`draft_and_route`]: the routing threshold, the length
127/// comfort band, and the source/target language hints used only to frame the
128/// draft prompt.
129///
130/// Serializable so a caller can load it from its own config format.
131#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
132pub struct TwoPassConfig {
133 /// A segment whose combined `confidence` is **greater than or equal to**
134 /// this is [`Decision::AcceptLocal`]; below it, [`Decision::SendToCloud`].
135 /// Defaults to the conservative [`DEFAULT_ACCEPT_THRESHOLD`]; raise toward
136 /// `1.0` to send even more to the cloud, lower it only with measured
137 /// evidence to widen the local share.
138 pub accept_threshold: f32,
139 /// The shortest segment (in `char`s) that still earns a full length score.
140 /// Below this the length score falls off linearly to zero — very short
141 /// segments (stray headings, garbage lines) are the least reliable.
142 pub min_comfortable_chars: usize,
143 /// The longest segment (in `char`s) that still earns a full length score.
144 /// Above this the length score decays — very long segments give a small
145 /// model the most room to drift.
146 pub max_comfortable_chars: usize,
147 /// Source-language hint for the draft prompt (e.g. `"Chinese"`). Framing
148 /// only; the deterministic stub adapter ignores it.
149 pub source_lang: String,
150 /// Target-language hint for the draft prompt (e.g. `"English"`).
151 pub target_lang: String,
152}
153
154impl Default for TwoPassConfig {
155 fn default() -> Self {
156 Self {
157 accept_threshold: DEFAULT_ACCEPT_THRESHOLD,
158 min_comfortable_chars: 24,
159 max_comfortable_chars: 320,
160 // The driving case is Chinese-language nuclear literature (§13).
161 source_lang: "Chinese".to_string(),
162 target_lang: "English".to_string(),
163 }
164 }
165}
166
167/// The composable evidence behind one segment's `confidence`, recorded so every
168/// decision is auditable (never a silent accept).
169///
170/// Each `*_score` is in `0..=1`; `confidence` is their documented weighted mean
171/// ([`ConfidenceSignals::combine`]). Raw inputs (`char_len`, `glossary_hits`,
172/// `round_trip_similarity`) are kept alongside the scores so a reviewer can see
173/// *why* a score landed where it did.
174#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
175pub struct ConfidenceSignals {
176 /// Length of the source segment in `char`s (the raw input to
177 /// [`length_score`]).
178 pub char_len: usize,
179 /// Length sub-score: high only within the comfortable band, falling off for
180 /// very short or very long segments.
181 pub length_score: f32,
182 /// Number of III-5 glossary occurrences the draft matched (0 when no
183 /// glossary was supplied).
184 pub glossary_hits: usize,
185 /// Glossary sub-score: neutral (0.5) with no hits, rising with diminishing
186 /// returns as more project-decided terms are pinned deterministically.
187 pub glossary_score: f32,
188 /// Raw round-trip agreement in `0..=1`: character-bigram Sørensen–Dice
189 /// similarity between the source and the draft translated *back* to the
190 /// source language (`1.0` = identical). Always recorded, even when not
191 /// trusted.
192 pub round_trip_similarity: f32,
193 /// Whether the round-trip signal is trustworthy. `false` under the echo stub
194 /// (the round trip is trivially perfect and meaningless); when `false`,
195 /// `round_trip_score` is neutralised to `0.5` regardless of
196 /// `round_trip_similarity`.
197 pub round_trip_trusted: bool,
198 /// Round-trip sub-score actually folded into `confidence`: equal to
199 /// `round_trip_similarity` when trusted, else the neutral `0.5`.
200 pub round_trip_score: f32,
201 /// The combined 0..1 confidence — the value compared against
202 /// [`TwoPassConfig::accept_threshold`].
203 pub confidence: f32,
204}
205
206impl ConfidenceSignals {
207 /// Weight of the length sub-score in the combined confidence.
208 pub const W_LENGTH: f32 = 0.35;
209 /// Weight of the round-trip sub-score in the combined confidence.
210 pub const W_ROUND_TRIP: f32 = 0.45;
211 /// Weight of the glossary sub-score in the combined confidence. The three
212 /// weights sum to `1.0`, so `confidence` stays in `0..=1` and no single
213 /// signal can carry a draft over a conservative threshold on its own.
214 pub const W_GLOSSARY: f32 = 0.20;
215
216 /// Combine the three sub-scores into the final 0..1 confidence: a fixed,
217 /// documented weighted mean (`0.35·length + 0.45·round_trip + 0.20·glossary`).
218 #[must_use]
219 pub fn combine(length_score: f32, round_trip_score: f32, glossary_score: f32) -> f32 {
220 (Self::W_LENGTH * length_score
221 + Self::W_ROUND_TRIP * round_trip_score
222 + Self::W_GLOSSARY * glossary_score)
223 .clamp(0.0, 1.0)
224 }
225}
226
227/// The plan for one segment: the local draft, the confidence, the routing
228/// decision, and the signals behind it.
229#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
230pub struct SegmentPlan {
231 /// The segment's index in the input slice (stable, for anchoring back to the
232 /// source — e.g. for the III-7 bilingual output).
233 pub index: usize,
234 /// The source segment, verbatim.
235 pub source: String,
236 /// The local model's draft translation, after the deterministic III-5
237 /// glossary post-pass.
238 pub local_draft: String,
239 /// The combined confidence (mirrors `signals.confidence`, surfaced here for
240 /// convenience).
241 pub confidence: f32,
242 /// Whether to keep the local draft or send this segment to the cloud.
243 pub decision: Decision,
244 /// The composable evidence behind `decision` — never omitted, so acceptance
245 /// is always auditable.
246 pub signals: ConfidenceSignals,
247}
248
249/// The result of [`draft_and_route`]: one [`SegmentPlan`] per input segment,
250/// plus any honesty caveats that apply to the whole plan.
251///
252/// The measurable split is [`TwoPassPlan::summary`].
253#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
254pub struct TwoPassPlan {
255 /// One plan per input segment, in input order.
256 pub segments: Vec<SegmentPlan>,
257 /// Plan-wide caveats: the [`ECHO_PASSTHROUGH_NOTE`] when drafted against the
258 /// stub, and the standing reminder that AcceptLocal is a routing decision,
259 /// not a correctness guarantee.
260 pub notes: Vec<String>,
261}
262
263impl TwoPassPlan {
264 /// A [`Decision::AcceptLocal`] is a **routing** decision, never the final
265 /// authority on whether a translation is correct (§13 Task III-6, mirroring
266 /// [`crate::preprocess`]). A compile-time constant so callers can encode
267 /// "do not gate correctness on this plan" in their own logic and tests.
268 pub const AUTHORITATIVE: bool = false;
269
270 /// The measurable split: totals and the local fraction (§13 — *report the
271 /// split so the saving is measurable*).
272 #[must_use]
273 pub fn summary(&self) -> TwoPassSummary {
274 let total = self.segments.len();
275 let accepted_local =
276 self.segments.iter().filter(|s| s.decision == Decision::AcceptLocal).count();
277 let sent_to_cloud = total - accepted_local;
278 let local_fraction =
279 if total == 0 { 0.0 } else { accepted_local as f32 / total as f32 };
280 TwoPassSummary { total, accepted_local, sent_to_cloud, local_fraction }
281 }
282}
283
284/// The measurable split produced by [`TwoPassPlan::summary`].
285///
286/// `local_fraction × total × (per-segment cloud review cost)` is the cloud
287/// tokens saved. Serializable so a machine consumer reports structure, not prose
288/// (§0.2).
289#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
290pub struct TwoPassSummary {
291 /// Total segments planned.
292 pub total: usize,
293 /// Segments kept locally (zero cloud tokens each).
294 pub accepted_local: usize,
295 /// Segments routed to the cloud for review (the only ones that cost cloud
296 /// tokens).
297 pub sent_to_cloud: usize,
298 /// `accepted_local / total` in `0..=1` (`0.0` for an empty plan) — the
299 /// fraction of the document handled entirely locally, and the saving figure.
300 /// A *saving* claim, not a *quality* claim: see the module docs' caveat.
301 pub local_fraction: f32,
302}
303
304/// Draft every segment with the **local** `adapter`, apply the III-5 `glossary`
305/// deterministically, score confidence from composable signals, and route each
306/// segment to `AcceptLocal` or `SendToCloud` against `cfg.accept_threshold`.
307///
308/// The `adapter` is injected — the caller is expected to hand in the **local**
309/// adapter it already resolved, so drafting costs **zero cloud tokens** by
310/// construction. Because the surface is `&dyn ModelAdapter`, tests drive it with
311/// the deterministic [`crate::EchoAdapter`] (no weights, no network).
312///
313/// For each segment the local model is called **twice**: once to draft the
314/// translation, once to translate the draft back for the round-trip signal. The
315/// result is a pure function of `(adapter, segments, glossary, cfg)` for a
316/// deterministic adapter — same input in, byte-identical plan out.
317#[must_use]
318pub fn draft_and_route(
319 adapter: &dyn ModelAdapter,
320 segments: &[String],
321 glossary: Option<&Glossary>,
322 cfg: &TwoPassConfig,
323) -> TwoPassPlan {
324 let echo_stub = adapter.name() == ECHO_ADAPTER_NAME;
325
326 let plans = segments
327 .iter()
328 .enumerate()
329 .map(|(index, source)| plan_segment(adapter, index, source, glossary, cfg, echo_stub))
330 .collect();
331
332 let mut notes = vec![
333 "AcceptLocal is a routing decision, not a correctness guarantee: it means only that the \
334 signals did not justify a cloud review, never that the draft is correct. Widen the local \
335 share past the conservative default only with measured evidence (§13 Task III-6)."
336 .to_string(),
337 ];
338 if echo_stub {
339 notes.push(ECHO_PASSTHROUGH_NOTE.to_string());
340 }
341
342 TwoPassPlan { segments: plans, notes }
343}
344
345/// Plan a single segment: draft (pass 1), glossary post-pass, round-trip
346/// (pass 2), score, decide.
347fn plan_segment(
348 adapter: &dyn ModelAdapter,
349 index: usize,
350 source: &str,
351 glossary: Option<&Glossary>,
352 cfg: &TwoPassConfig,
353 echo_stub: bool,
354) -> SegmentPlan {
355 // Pass 1: the local model drafts the translation.
356 let raw_draft = translate(adapter, source, &cfg.source_lang, &cfg.target_lang);
357
358 // III-5 glossary as a deterministic post-pass; the hit count folds into the
359 // glossary signal. No model tokens spent on terminology.
360 let (local_draft, glossary_hits) = match glossary {
361 Some(g) if !g.is_empty() => {
362 let applied = g.apply(&raw_draft);
363 let hits = applied.total_substitutions();
364 (applied.output, hits)
365 }
366 _ => (raw_draft, 0),
367 };
368
369 // Pass 2: translate the draft back to the source language and measure how
370 // far it diverged from the original source.
371 let back = translate(adapter, &local_draft, &cfg.target_lang, &cfg.source_lang);
372 let round_trip_similarity = dice_similarity(source, &back);
373
374 // Compose the three sub-scores.
375 let char_len = source.chars().count();
376 let length_score = length_score(char_len, cfg);
377 let glossary_score = glossary_score(glossary_hits, glossary.is_some());
378 // The echo stub's round trip is trivially perfect and carries no
379 // information; discount it to neutral so it cannot inflate confidence.
380 let round_trip_trusted = !echo_stub;
381 let round_trip_score = if round_trip_trusted { round_trip_similarity } else { 0.5 };
382
383 let confidence = ConfidenceSignals::combine(length_score, round_trip_score, glossary_score);
384 let decision = if confidence >= cfg.accept_threshold {
385 Decision::AcceptLocal
386 } else {
387 Decision::SendToCloud
388 };
389
390 SegmentPlan {
391 index,
392 source: source.to_string(),
393 local_draft,
394 confidence,
395 decision,
396 signals: ConfidenceSignals {
397 char_len,
398 length_score,
399 glossary_hits,
400 glossary_score,
401 round_trip_similarity,
402 round_trip_trusted,
403 round_trip_score,
404 confidence,
405 },
406 }
407}
408
409/// Route one translation request through the adapter and return the reply. This
410/// is the single point volume is handed to the *local* model — the caller having
411/// passed in the local adapter is what makes it cost zero cloud tokens.
412fn translate(adapter: &dyn ModelAdapter, text: &str, from: &str, to: &str) -> String {
413 let request = CompletionRequest::new([
414 Message::system(format!(
415 "Translate the {from} text the user sends into {to}. Output only the {to} \
416 translation, nothing else."
417 )),
418 Message::user(text),
419 ]);
420 // A drafting/round-trip step must never abort the whole plan: on adapter
421 // error, treat the draft as empty (which scores low and routes to cloud —
422 // the safe direction).
423 adapter.complete(&request).map(|r| r.content).unwrap_or_default()
424}
425
426/// Length sub-score in `0..=1`: `1.0` within the comfortable band
427/// `[min, max]`, falling off linearly to `0.0` at zero length and decaying
428/// toward a `0.3` floor for over-long segments.
429///
430/// Very short segments (a stray heading, a garbage line, an orphaned number)
431/// and very long ones give a 0.5B model the least reliable footing, so they
432/// score low and route to the cloud.
433#[must_use]
434pub fn length_score(char_len: usize, cfg: &TwoPassConfig) -> f32 {
435 let min = cfg.min_comfortable_chars.max(1);
436 let max = cfg.max_comfortable_chars.max(min);
437 if char_len == 0 {
438 0.0
439 } else if char_len < min {
440 // Linear 0 -> 1 across (0, min).
441 char_len as f32 / min as f32
442 } else if char_len <= max {
443 1.0
444 } else {
445 // Decay from 1.0 at `max` toward a 0.3 floor, reaching the floor at
446 // 2·max and below.
447 let over = (char_len - max) as f32;
448 let span = max as f32; // width over which we decay to the floor
449 (1.0 - 0.7 * (over / span)).max(0.3)
450 }
451}
452
453/// Glossary sub-score in `0..=1`. Neutral `0.5` when no glossary was supplied or
454/// nothing hit; rising with diminishing returns as more project-decided terms
455/// are pinned deterministically (`0.5 + 0.5·(1 − 1/(1+hits))`). Hits are
456/// positive evidence — never a penalty for a term-free segment.
457#[must_use]
458pub fn glossary_score(hits: usize, glossary_present: bool) -> f32 {
459 if !glossary_present {
460 return 0.5;
461 }
462 0.5 + 0.5 * (1.0 - 1.0 / (1.0 + hits as f32))
463}
464
465/// Character-bigram Sørensen–Dice similarity in `0..=1` (`1.0` = identical),
466/// the round-trip agreement metric. Deterministic, dependency-free, and
467/// character-based so it works on CJK source text.
468///
469/// `dice = 2·|A ∩ B| / (|A| + |B|)` over the two strings' character-bigram
470/// multisets. Strings too short to form a bigram fall back to exact equality.
471#[must_use]
472pub fn dice_similarity(a: &str, b: &str) -> f32 {
473 let ba = bigrams(a);
474 let bb = bigrams(b);
475 if ba.is_empty() || bb.is_empty() {
476 // No bigrams (one or both < 2 chars): fall back to exact equality.
477 return if a == b { 1.0 } else { 0.0 };
478 }
479 // Multiset intersection size.
480 let mut bb_counts: std::collections::HashMap<(char, char), i32> = std::collections::HashMap::new();
481 for g in &bb {
482 *bb_counts.entry(*g).or_insert(0) += 1;
483 }
484 let mut inter = 0usize;
485 for g in &ba {
486 let e = bb_counts.entry(*g).or_insert(0);
487 if *e > 0 {
488 *e -= 1;
489 inter += 1;
490 }
491 }
492 (2.0 * inter as f32) / (ba.len() as f32 + bb.len() as f32)
493}
494
495/// The character bigrams of `s`, in order (a multiset as a `Vec`).
496fn bigrams(s: &str) -> Vec<(char, char)> {
497 let chars: Vec<char> = s.chars().collect();
498 if chars.len() < 2 {
499 return Vec::new();
500 }
501 chars.windows(2).map(|w| (w[0], w[1])).collect()
502}
503
504#[cfg(test)]
505mod tests {
506 use super::*;
507 use crate::{CompletionResponse, EchoAdapter, Entry, Glossary};
508 use std::sync::atomic::{AtomicUsize, Ordering};
509
510 /// A non-echo adapter that returns a fixed reply and *counts* calls, so a
511 /// test can prove both the draft pass and the round-trip pass genuinely
512 /// routed through the adapter.
513 struct CountingAdapter {
514 reply: String,
515 calls: AtomicUsize,
516 }
517 impl CountingAdapter {
518 fn new(reply: &str) -> Self {
519 Self { reply: reply.to_string(), calls: AtomicUsize::new(0) }
520 }
521 }
522 impl ModelAdapter for CountingAdapter {
523 fn name(&self) -> &str {
524 "counting-test"
525 }
526 fn complete(&self, _request: &CompletionRequest) -> anyhow::Result<CompletionResponse> {
527 self.calls.fetch_add(1, Ordering::SeqCst);
528 Ok(CompletionResponse { content: self.reply.clone(), model: "counting-test".into() })
529 }
530 }
531
532 /// Compile-time guarantee that an AcceptLocal is never treated as the final
533 /// authority on correctness (§13 Task III-6).
534 const _: () = assert!(!TwoPassPlan::AUTHORITATIVE);
535
536 fn segs(v: &[&str]) -> Vec<String> {
537 v.iter().map(|s| (*s).to_string()).collect()
538 }
539
540 /// Drafting routes through the adapter, and both the draft **and** the
541 /// round-trip pass fire — proven by a call-counting adapter seeing exactly
542 /// two calls per segment.
543 #[test]
544 fn drafts_and_round_trips_route_through_the_adapter() {
545 let adapter = CountingAdapter::new("some translation");
546 let plan = draft_and_route(&adapter, &segs(&["源文本一", "源文本二"]), None, &TwoPassConfig::default());
547
548 assert_eq!(plan.segments.len(), 2);
549 // 2 segments × (1 draft + 1 round-trip) = 4 adapter calls.
550 assert_eq!(adapter.calls.load(Ordering::SeqCst), 4, "draft + round-trip must both route through the adapter");
551 // The draft is the adapter's reply.
552 assert_eq!(plan.segments[0].local_draft, "some translation");
553 }
554
555 /// A very-short / garbage segment scores low on length and routes to cloud,
556 /// even under the (otherwise generous) echo stub.
557 #[test]
558 fn very_short_segment_scores_low_and_goes_to_cloud() {
559 let plan = draft_and_route(&EchoAdapter, &segs(&["##"]), None, &TwoPassConfig::default());
560 let s = &plan.segments[0];
561 assert!(s.signals.length_score < 0.2, "a 2-char segment must score low on length: {}", s.signals.length_score);
562 assert_eq!(s.decision, Decision::SendToCloud);
563 }
564
565 /// The summary's `local_fraction` matches the decisions exactly.
566 #[test]
567 fn summary_local_fraction_matches_decisions() {
568 // A permissive threshold so a mix of accept/cloud is produced under echo.
569 let cfg = TwoPassConfig { accept_threshold: 0.4, ..TwoPassConfig::default() };
570 // One comfortable-length segment (accepts) + one 2-char garbage (cloud).
571 let long = "这是一段足够长的技术性中文文本用于测试本地优先的两遍翻译流程的置信度评分";
572 let plan = draft_and_route(&EchoAdapter, &segs(&[long, "##"]), None, &cfg);
573 let summary = plan.summary();
574
575 let expected_local =
576 plan.segments.iter().filter(|s| s.decision == Decision::AcceptLocal).count();
577 assert_eq!(summary.total, 2);
578 assert_eq!(summary.accepted_local, expected_local);
579 assert_eq!(summary.sent_to_cloud, 2 - expected_local);
580 assert!((summary.local_fraction - expected_local as f32 / 2.0).abs() < 1e-6);
581 // The mix we set up: the long one accepts, the garbage one goes to cloud.
582 assert_eq!(summary.accepted_local, 1);
583 assert_eq!(summary.sent_to_cloud, 1);
584 }
585
586 /// Glossary hits fold into the confidence signal: the same segment scores a
587 /// higher glossary sub-score (and thus higher confidence) with a matching
588 /// glossary than without one.
589 #[test]
590 fn glossary_hits_raise_the_confidence_signal() {
591 // Under echo the draft is the source echoed back, so a CJK glossary term
592 // in the source will hit in the draft.
593 let source = "华龙一号的数字孪生模型用于压水堆的乏燃料管理与安全评估研究工作";
594 let glossary = Glossary::from_entries([
595 Entry::new("华龙一号", "Hualong One"),
596 Entry::new("数字孪生", "digital twin"),
597 ])
598 .unwrap();
599 let cfg = TwoPassConfig::default();
600
601 let without = draft_and_route(&EchoAdapter, &segs(&[source]), None, &cfg);
602 let with = draft_and_route(&EchoAdapter, &segs(&[source]), Some(&glossary), &cfg);
603
604 assert_eq!(with.segments[0].signals.glossary_hits, 2, "both terms should hit");
605 assert!(
606 with.segments[0].signals.glossary_score > without.segments[0].signals.glossary_score,
607 "glossary hits must raise the glossary sub-score ({} !> {})",
608 with.segments[0].signals.glossary_score,
609 without.segments[0].signals.glossary_score
610 );
611 assert!(
612 with.segments[0].confidence > without.segments[0].confidence,
613 "glossary hits must raise overall confidence"
614 );
615 }
616
617 /// The conservative default threshold sends **most** of a mixed batch to the
618 /// cloud (§13: default conservative). Under the echo stub the round-trip
619 /// signal is discounted to neutral, so even comfortable-length segments do
620 /// not clear the high default.
621 #[test]
622 fn conservative_default_sends_most_to_cloud() {
623 let batch = segs(&[
624 "第一段中等长度的中文技术文本内容用于测试保守默认阈值的路由行为表现",
625 "第二段中等长度的中文技术文本内容用于测试保守默认阈值的路由行为表现",
626 "第三段中等长度的中文技术文本内容用于测试保守默认阈值的路由行为表现",
627 "短",
628 "##",
629 ]);
630 let plan = draft_and_route(&EchoAdapter, &batch, None, &TwoPassConfig::default());
631 let summary = plan.summary();
632 assert!(
633 summary.sent_to_cloud > summary.accepted_local,
634 "conservative default must send most to cloud: {summary:?}"
635 );
636 // Echo stub honesty note present.
637 assert!(plan.notes.iter().any(|n| n == ECHO_PASSTHROUGH_NOTE));
638 }
639
640 /// The echo stub discounts the round-trip signal: it is recorded but marked
641 /// untrusted and folded in as the neutral 0.5, never the trivially-perfect
642 /// 1.0 that the stub's pass-through would otherwise produce.
643 #[test]
644 fn echo_round_trip_is_recorded_but_not_trusted() {
645 let plan = draft_and_route(&EchoAdapter, &segs(&["一段用于测试往返信号折扣处理的中文文本内容"]), None, &TwoPassConfig::default());
646 let sig = &plan.segments[0].signals;
647 assert!(!sig.round_trip_trusted, "echo round-trip must not be trusted");
648 assert!((sig.round_trip_score - 0.5).abs() < 1e-6, "untrusted round-trip folds in as neutral 0.5");
649 }
650
651 /// A real (non-echo) adapter's round-trip *is* trusted and measured. Here
652 /// the reply never matches the CJK source, so similarity is ~0 → very low
653 /// confidence → cloud.
654 #[test]
655 fn non_echo_round_trip_is_trusted_and_measured() {
656 let adapter = CountingAdapter::new("a completely unrelated english reply");
657 let plan = draft_and_route(&adapter, &segs(&["一段足够长的中文源文本用于测试往返一致性信号"]), None, &TwoPassConfig::default());
658 let sig = &plan.segments[0].signals;
659 assert!(sig.round_trip_trusted);
660 assert!(sig.round_trip_similarity < 0.2, "unrelated back-translation must diverge: {}", sig.round_trip_similarity);
661 assert_eq!(plan.segments[0].decision, Decision::SendToCloud);
662 }
663
664 /// Same input ⇒ byte-identical plan (determinism).
665 #[test]
666 fn plan_is_deterministic() {
667 let glossary = Glossary::from_entries([Entry::new("压水堆", "PWR")]).unwrap();
668 let batch = segs(&["压水堆机组的数字孪生", "##", "another segment of moderate length here"]);
669 let cfg = TwoPassConfig::default();
670
671 let a = draft_and_route(&EchoAdapter, &batch, Some(&glossary), &cfg);
672 let b = draft_and_route(&EchoAdapter, &batch, Some(&glossary), &cfg);
673 assert_eq!(a, b, "same input must yield an identical plan");
674 }
675
676 /// An empty batch produces an empty plan with a well-defined summary.
677 #[test]
678 fn empty_batch_has_zero_local_fraction() {
679 let plan = draft_and_route(&EchoAdapter, &[], None, &TwoPassConfig::default());
680 let summary = plan.summary();
681 assert_eq!(summary.total, 0);
682 assert_eq!(summary.accepted_local, 0);
683 assert_eq!(summary.local_fraction, 0.0);
684 }
685
686 #[test]
687 fn length_score_shape() {
688 let cfg = TwoPassConfig::default();
689 assert_eq!(length_score(0, &cfg), 0.0);
690 assert!(length_score(2, &cfg) < 0.2); // very short
691 assert_eq!(length_score(cfg.min_comfortable_chars, &cfg), 1.0);
692 assert_eq!(length_score(cfg.max_comfortable_chars, &cfg), 1.0);
693 // Over-long decays but never below the 0.3 floor.
694 assert!(length_score(cfg.max_comfortable_chars * 10, &cfg) >= 0.3);
695 assert!(length_score(cfg.max_comfortable_chars + 1, &cfg) < 1.0);
696 }
697
698 #[test]
699 fn glossary_score_shape() {
700 // No glossary supplied -> neutral.
701 assert!((glossary_score(0, false) - 0.5).abs() < 1e-6);
702 // Glossary present, no hits -> still neutral (never a penalty).
703 assert!((glossary_score(0, true) - 0.5).abs() < 1e-6);
704 // Hits raise it, monotonically, with diminishing returns.
705 assert!(glossary_score(1, true) > glossary_score(0, true));
706 assert!(glossary_score(3, true) > glossary_score(1, true));
707 assert!(glossary_score(100, true) < 1.0);
708 }
709
710 #[test]
711 fn dice_similarity_bounds() {
712 assert_eq!(dice_similarity("华龙一号", "华龙一号"), 1.0);
713 assert_eq!(dice_similarity("abc", "abc"), 1.0);
714 assert!(dice_similarity("华龙一号", "完全不同的文本").abs() < 0.1);
715 // Sub-bigram strings fall back to exact equality.
716 assert_eq!(dice_similarity("a", "a"), 1.0);
717 assert_eq!(dice_similarity("a", "b"), 0.0);
718 assert_eq!(dice_similarity("", ""), 1.0);
719 }
720
721 /// Compile-time check that the reportable structs carry the serde derives
722 /// (§0.2 — structure, not prose). Kept as a bound assertion so no
723 /// `serde_json` dev-dependency is introduced.
724 #[test]
725 fn serde_derives_are_present() {
726 fn assert_serde<T: serde::Serialize + serde::de::DeserializeOwned>() {}
727 assert_serde::<TwoPassPlan>();
728 assert_serde::<SegmentPlan>();
729 assert_serde::<TwoPassSummary>();
730 assert_serde::<ConfidenceSignals>();
731 assert_serde::<TwoPassConfig>();
732 assert_serde::<Decision>();
733 }
734}