Skip to main content

kopitiam_ai/
bilingual.rs

1//! Bilingual aligned output (token-max card **III-7**).
2//!
3//! Emit source + target **aligned**, with a stable per-segment anchor, so a
4//! reviewer — human or model — jumps straight to a specific segment and checks
5//! it without reading the whole document. `kopitiam_token_max.md` §13 Task
6//! III-7: *"Turns review from a full-document read into a targeted one."*
7//!
8//! The saving is the same shape as III-6's local/cloud split, but on the
9//! *review* side: [`review_targets`] returns only the anchors of the segments
10//! that actually need a look, so a reviewer reads `M` flagged of `N` total
11//! instead of all `N`. [`review_coverage`] reports that `M / N` fraction as a
12//! serializable figure (§0.2 — structure, not prose).
13//!
14//! # Decoupled by construction
15//!
16//! This module deliberately does **not** depend on `kopitiam-document`. It
17//! accepts the identity/text it needs as plain inputs ([`BilingualSegment`]),
18//! mirroring III-4's `TmSegment`-style decoupling: the anchor `id`, the stable
19//! `index`, the `source`, and the `target` are handed in. The bridge from the
20//! III-6 two-pass plan is the optional [`from_two_pass`] convenience, which
21//! reads only the public fields of [`crate::SegmentPlan`] and a parallel slice
22//! of finished targets — it introduces no new dependency and preserves
23//! [`crate::SegmentPlan::index`] as the anchor.
24//!
25//! # Anchors are the whole point
26//!
27//! Every rendered segment carries a stable `<!-- seg <id> -->` HTML comment
28//! anchor (invisible in rendered Markdown, greppable in the raw text). A
29//! reviewer — or an automated check — resolves an anchor to exactly one segment
30//! and inspects its source/target pair in isolation. Flagged segments
31//! (explicitly `needs_review`, or below the confidence threshold) additionally
32//! carry a visible [`REVIEW_MARKER`] so a human eye scans straight to them.
33//!
34//! Output is **deterministic**: segments are emitted in ascending `index`
35//! order regardless of input order, so the same input renders byte-identically.
36
37use serde::{Deserialize, Serialize};
38
39/// The visible marker stamped on a segment that needs review, so a human
40/// scanning the aligned output lands on it immediately. Kept as a stable
41/// literal substring so tooling (and [`review_targets`] consumers) can grep for
42/// it.
43pub const REVIEW_MARKER: &str = "⚠ review";
44
45/// The default confidence below which a segment is treated as needing review.
46///
47/// Tied to III-6's [`crate::DEFAULT_ACCEPT_THRESHOLD`]: a segment whose
48/// confidence would not have cleared the two-pass *accept-local* threshold is,
49/// by the same measure, one a reviewer should look at. Conservative on purpose
50/// — it is better to flag a segment the reviewer skips than to hide one they
51/// needed to see.
52pub const DEFAULT_REVIEW_THRESHOLD: f32 = crate::DEFAULT_ACCEPT_THRESHOLD;
53
54/// One aligned source/target pair with a stable anchor.
55///
56/// The inputs are decoupled from any document model: `id` is the anchor a
57/// reviewer resolves, `index` fixes the deterministic emission order, and
58/// `source`/`target` are the aligned text. `confidence` and `needs_review` drive
59/// the review marker and [`review_targets`]; either is enough to flag a segment.
60#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
61pub struct BilingualSegment {
62    /// Stable anchor for this segment, emitted as `<!-- seg <id> -->`. When
63    /// bridged from a III-6 plan via [`from_two_pass`] this is the plan's
64    /// `index` rendered as a string, so the anchor *is* the segment index.
65    pub id: String,
66    /// The segment's stable position; output is sorted ascending by this, so
67    /// rendering is deterministic and independent of input order.
68    pub index: usize,
69    /// The source-language text, verbatim.
70    pub source: String,
71    /// The target-language text, verbatim.
72    pub target: String,
73    /// Optional confidence in `0..=1` (e.g. III-6's
74    /// [`crate::SegmentPlan::confidence`]). When present and below the review
75    /// threshold, the segment is flagged even if `needs_review` is `false`.
76    pub confidence: Option<f32>,
77    /// An explicit "a reviewer must look at this" flag, independent of
78    /// `confidence`. Set directly, or derived (e.g. by [`from_two_pass`] from a
79    /// [`crate::Decision::SendToCloud`] routing decision).
80    pub needs_review: bool,
81}
82
83impl BilingualSegment {
84    /// A plain constructor for the common case: no confidence recorded and not
85    /// (yet) flagged for review. Fields are public, so a caller can also build
86    /// the struct literally or set `confidence`/`needs_review` afterwards.
87    #[must_use]
88    pub fn new(
89        id: impl Into<String>,
90        index: usize,
91        source: impl Into<String>,
92        target: impl Into<String>,
93    ) -> Self {
94        Self {
95            id: id.into(),
96            index,
97            source: source.into(),
98            target: target.into(),
99            confidence: None,
100            needs_review: false,
101        }
102    }
103
104    /// Whether this segment should be surfaced to a reviewer: explicitly
105    /// `needs_review`, or a recorded `confidence` strictly below `threshold`.
106    #[must_use]
107    pub fn is_flagged(&self, threshold: f32) -> bool {
108        self.needs_review || self.confidence.is_some_and(|c| c < threshold)
109    }
110}
111
112/// How to lay the aligned pairs out.
113#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
114#[serde(rename_all = "snake_case")]
115pub enum Layout {
116    /// Per segment: the anchor, the source line(s), then the target line(s) as a
117    /// Markdown blockquote so the two are visually distinguished. Best for prose
118    /// review where segments are multi-line.
119    Interleaved,
120    /// A single Markdown table with `seg | source | target | review` columns.
121    /// Best for short, dense segments a reviewer skims as rows.
122    SideBySideTable,
123}
124
125/// Options for [`render_bilingual`].
126///
127/// Serializable so a caller can load layout choices from its own config (§0.2).
128#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
129pub struct BilingualOptions {
130    /// Interleaved or side-by-side table.
131    pub layout: Layout,
132    /// Confidence below which a segment carries the [`REVIEW_MARKER`] in the
133    /// rendered output. Defaults to [`DEFAULT_REVIEW_THRESHOLD`]; a segment with
134    /// no recorded confidence is flagged only by its `needs_review` bool.
135    pub low_confidence_threshold: f32,
136}
137
138impl Default for BilingualOptions {
139    fn default() -> Self {
140        Self { layout: Layout::Interleaved, low_confidence_threshold: DEFAULT_REVIEW_THRESHOLD }
141    }
142}
143
144impl BilingualOptions {
145    /// Interleaved layout with the default review threshold.
146    #[must_use]
147    pub fn interleaved() -> Self {
148        Self { layout: Layout::Interleaved, ..Self::default() }
149    }
150
151    /// Side-by-side table layout with the default review threshold.
152    #[must_use]
153    pub fn side_by_side() -> Self {
154        Self { layout: Layout::SideBySideTable, ..Self::default() }
155    }
156}
157
158/// Render aligned source/target output with stable per-segment anchors.
159///
160/// Segments are emitted in ascending `index` order (input order is irrelevant),
161/// so the output is deterministic — the same input renders byte-identically.
162/// Empty input renders the empty string.
163#[must_use]
164pub fn render_bilingual(segments: &[BilingualSegment], options: &BilingualOptions) -> String {
165    if segments.is_empty() {
166        return String::new();
167    }
168    let ordered = ordered_refs(segments);
169    match options.layout {
170        Layout::Interleaved => render_interleaved(&ordered, options.low_confidence_threshold),
171        Layout::SideBySideTable => render_table(&ordered, options.low_confidence_threshold),
172    }
173}
174
175/// The anchors of the segments a reviewer must actually check — the "read only
176/// these" list (§0.1). A segment is included when it is `needs_review` or its
177/// recorded confidence is below [`DEFAULT_REVIEW_THRESHOLD`]. Returned in
178/// ascending `index` order to match the rendered output.
179///
180/// This is the review-side saving: a reviewer reads these `M` anchors instead of
181/// all `N` segments. See [`review_coverage`] for the quantified `M / N`.
182#[must_use]
183pub fn review_targets(segments: &[BilingualSegment]) -> Vec<&str> {
184    ordered_refs(segments)
185        .into_iter()
186        .filter(|s| s.is_flagged(DEFAULT_REVIEW_THRESHOLD))
187        .map(|s| s.id.as_str())
188        .collect()
189}
190
191/// The quantified review saving: of `total` segments, `flagged` need a look, so
192/// a reviewer reads `review_fraction` of the document (§13 measurement).
193///
194/// Serializable so a machine consumer reports the figure as structure, not prose
195/// (§0.2). `review_fraction` is `flagged / total`, or `0.0` for empty input.
196#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
197pub struct ReviewCoverage {
198    /// Total aligned segments in the document.
199    pub total: usize,
200    /// Segments flagged for review (`needs_review` or below-threshold
201    /// confidence) — the only ones [`review_targets`] surfaces.
202    pub flagged: usize,
203    /// `flagged / total` in `0..=1` (`0.0` when `total == 0`): the fraction of
204    /// the document a reviewer actually reads. `1 - review_fraction` is the read
205    /// that anchoring saved.
206    pub review_fraction: f32,
207}
208
209/// Compute the [`ReviewCoverage`] for a set of segments using the default
210/// review threshold — the measurement backing the card: a reviewer reads
211/// `flagged` of `total`.
212#[must_use]
213pub fn review_coverage(segments: &[BilingualSegment]) -> ReviewCoverage {
214    let total = segments.len();
215    let flagged =
216        segments.iter().filter(|s| s.is_flagged(DEFAULT_REVIEW_THRESHOLD)).count();
217    let review_fraction = if total == 0 { 0.0 } else { flagged as f32 / total as f32 };
218    ReviewCoverage { total, flagged, review_fraction }
219}
220
221/// Bridge from III-6: build aligned segments from a slice of two-pass
222/// [`crate::SegmentPlan`]s and a parallel slice of finished `targets` (the
223/// reviewed/final target text for each plan, in the same order).
224///
225/// Each plan's `index` becomes the anchor `id` and the emission-ordering
226/// `index`, so the III-7 anchor is exactly III-6's segment index. A plan routed
227/// to the cloud ([`crate::Decision::SendToCloud`]) is marked `needs_review`, and
228/// the plan's `confidence` is carried through so [`render_bilingual`] and
229/// [`review_targets`] can flag on it too.
230///
231/// Pairs are zipped, so if the slices differ in length the extra tail is
232/// ignored — the caller is expected to pass one target per plan.
233#[must_use]
234pub fn from_two_pass(
235    plans: &[crate::SegmentPlan],
236    targets: &[String],
237) -> Vec<BilingualSegment> {
238    plans
239        .iter()
240        .zip(targets.iter())
241        .map(|(plan, target)| BilingualSegment {
242            id: plan.index.to_string(),
243            index: plan.index,
244            source: plan.source.clone(),
245            target: target.clone(),
246            confidence: Some(plan.confidence),
247            needs_review: plan.decision == crate::Decision::SendToCloud,
248        })
249        .collect()
250}
251
252/// Segments as references, sorted ascending by `index` (stable on ties), so
253/// every emission path shares one deterministic order.
254fn ordered_refs(segments: &[BilingualSegment]) -> Vec<&BilingualSegment> {
255    let mut refs: Vec<&BilingualSegment> = segments.iter().collect();
256    refs.sort_by_key(|s| s.index);
257    refs
258}
259
260/// The `<!-- seg <id> -->` anchor comment for a segment.
261fn anchor(id: &str) -> String {
262    format!("<!-- seg {id} -->")
263}
264
265/// Interleaved rendering: anchor, source, target-as-blockquote, per segment.
266fn render_interleaved(ordered: &[&BilingualSegment], threshold: f32) -> String {
267    let mut blocks: Vec<String> = Vec::with_capacity(ordered.len());
268    for seg in ordered {
269        let mut lines: Vec<String> = Vec::new();
270        // Anchor line, with the visible review marker appended when flagged so a
271        // reviewer scans straight to it.
272        if seg.is_flagged(threshold) {
273            lines.push(format!("{} {}", anchor(&seg.id), review_marker(seg.confidence)));
274        } else {
275            lines.push(anchor(&seg.id));
276        }
277        // Source verbatim.
278        lines.push(seg.source.clone());
279        // A blank line, then the target as a blockquote so source and target are
280        // visually distinguished even when both are multi-line.
281        lines.push(String::new());
282        for line in blockquote(&seg.target) {
283            lines.push(line);
284        }
285        blocks.push(lines.join("\n"));
286    }
287    // One trailing newline so the output is a well-formed block of text.
288    format!("{}\n", blocks.join("\n\n"))
289}
290
291/// Side-by-side rendering: one Markdown table, one row per segment.
292fn render_table(ordered: &[&BilingualSegment], threshold: f32) -> String {
293    let mut out = String::new();
294    out.push_str("| seg | source | target | review |\n");
295    out.push_str("| --- | --- | --- | --- |\n");
296    for seg in ordered {
297        let review = if seg.is_flagged(threshold) { review_marker(seg.confidence) } else { String::new() };
298        // The anchor lives in the `seg` cell alongside the visible id, so the
299        // row is both greppable (comment) and readable (id).
300        let seg_cell = format!("{} {}", anchor(&seg.id), escape_table_cell(&seg.id));
301        out.push_str(&format!(
302            "| {} | {} | {} | {} |\n",
303            seg_cell,
304            escape_table_cell(&seg.source),
305            escape_table_cell(&seg.target),
306            escape_table_cell(&review),
307        ));
308    }
309    out
310}
311
312/// The review marker, enriched with the confidence when one is recorded, while
313/// always containing the stable [`REVIEW_MARKER`] substring.
314fn review_marker(confidence: Option<f32>) -> String {
315    match confidence {
316        Some(c) => format!("{REVIEW_MARKER} (confidence {c:.2})"),
317        None => REVIEW_MARKER.to_string(),
318    }
319}
320
321/// Prefix each line of `text` with `> ` so it renders as a Markdown blockquote.
322/// An empty target yields a single empty blockquote line so the segment still
323/// has a visible target slot.
324fn blockquote(text: &str) -> Vec<String> {
325    if text.is_empty() {
326        return vec!["> ".to_string()];
327    }
328    text.lines().map(|l| format!("> {l}")).collect()
329}
330
331/// Escape a string for use inside a single Markdown table cell: literal pipes
332/// become `\|` (so they do not split the cell) and newlines become `<br>` (so a
333/// multi-line value stays on one row), mirroring the constraint the document
334/// table renderer works under.
335fn escape_table_cell(text: &str) -> String {
336    let piped = text.replace('|', "\\|");
337    piped.replace("\r\n", "\n").replace('\r', "\n").replace('\n', "<br>")
338}
339
340#[cfg(test)]
341mod tests {
342    use super::*;
343    use crate::{Decision, TwoPassConfig, draft_and_route, EchoAdapter};
344
345    fn seg(id: &str, index: usize, source: &str, target: &str) -> BilingualSegment {
346        BilingualSegment::new(id, index, source, target)
347    }
348
349    /// Interleaved layout emits exactly one anchor per segment, in ascending
350    /// index order, each with its source and target.
351    #[test]
352    fn interleaved_emits_one_anchor_per_segment_in_index_order() {
353        // Deliberately out of order to prove the sort.
354        let segs = vec![
355            seg("b", 1, "源二", "target two"),
356            seg("a", 0, "源一", "target one"),
357            seg("c", 2, "源三", "target three"),
358        ];
359        let out = render_bilingual(&segs, &BilingualOptions::interleaved());
360
361        assert_eq!(out.matches("<!-- seg ").count(), 3, "one anchor per segment");
362        // Anchors appear in index order (a=0, b=1, c=2).
363        let pa = out.find("<!-- seg a -->").unwrap();
364        let pb = out.find("<!-- seg b -->").unwrap();
365        let pc = out.find("<!-- seg c -->").unwrap();
366        assert!(pa < pb && pb < pc, "anchors must be in ascending index order");
367        // Source + target both present, target as a blockquote.
368        assert!(out.contains("源一"));
369        assert!(out.contains("> target one"));
370    }
371
372    /// A low-confidence segment carries the visible review marker in the output
373    /// and appears in `review_targets`; a confident one does not.
374    #[test]
375    fn low_confidence_segment_is_marked_and_targeted() {
376        let mut low = seg("lo", 0, "questionable source", "questionable target");
377        low.confidence = Some(0.20); // below the default threshold
378        let mut high = seg("hi", 1, "solid source", "solid target");
379        high.confidence = Some(0.99); // above the default threshold
380
381        let segs = vec![low, high];
382        let out = render_bilingual(&segs, &BilingualOptions::interleaved());
383
384        // The marker sits on the flagged segment.
385        assert!(out.contains(REVIEW_MARKER), "flagged segment must carry the review marker");
386        // Only the flagged anchor is a review target.
387        let targets = review_targets(&segs);
388        assert_eq!(targets, vec!["lo"]);
389    }
390
391    /// An explicit `needs_review` flag (no confidence) also flags a segment.
392    #[test]
393    fn explicit_needs_review_flags_without_confidence() {
394        let mut s = seg("x", 0, "src", "tgt");
395        s.needs_review = true;
396        assert!(s.confidence.is_none());
397        assert_eq!(review_targets(&[s]), vec!["x"]);
398    }
399
400    /// The side-by-side table escapes pipes, folds newlines, and carries the
401    /// anchor in the row.
402    #[test]
403    fn side_by_side_table_escapes_pipes_and_has_anchor() {
404        let s = seg("p", 0, "a | b\nc", "x | y");
405        let out = render_bilingual(&[s], &BilingualOptions::side_by_side());
406
407        assert!(out.contains("| seg | source | target | review |"), "header row present");
408        assert!(out.contains("<!-- seg p -->"), "anchor present in the row");
409        // The literal pipe in the source is escaped, not left to split the cell.
410        assert!(out.contains("a \\| b"), "pipe must be escaped: {out}");
411        assert!(!out.contains("a | b"), "raw unescaped pipe must not survive");
412        // The newline in the source is folded to <br>.
413        assert!(out.contains("<br>c"), "newline must fold to <br>: {out}");
414    }
415
416    /// The `from_two_pass` bridge builds output from III-6 plans + targets and
417    /// preserves each plan's `index` as the anchor; a cloud-routed plan is
418    /// flagged for review.
419    #[test]
420    fn from_two_pass_bridges_iii6_and_preserves_index_anchor() {
421        // A mixed batch under the echo stub: a comfortable segment and a garbage
422        // one, with a permissive threshold so one accepts and one routes to cloud.
423        let cfg = TwoPassConfig { accept_threshold: 0.4, ..TwoPassConfig::default() };
424        let long = "这是一段足够长的技术性中文文本用于测试双语对齐输出的锚点桥接";
425        let batch = vec![long.to_string(), "##".to_string()];
426        let plan = draft_and_route(&EchoAdapter, &batch, None, &cfg);
427
428        // Sanity: exactly the mix the bridge test relies on.
429        assert_eq!(plan.segments[0].decision, Decision::AcceptLocal);
430        assert_eq!(plan.segments[1].decision, Decision::SendToCloud);
431
432        let targets = vec!["accepted target".to_string(), "cloud target".to_string()];
433        let bi = from_two_pass(&plan.segments, &targets);
434
435        assert_eq!(bi.len(), 2);
436        // index preserved as both the ordering index and the anchor id.
437        assert_eq!(bi[0].index, 0);
438        assert_eq!(bi[0].id, "0");
439        assert_eq!(bi[1].index, 1);
440        assert_eq!(bi[1].id, "1");
441        // Confidence carried through from the plan.
442        assert_eq!(bi[0].confidence, Some(plan.segments[0].confidence));
443        // The `needs_review` bool maps the routing decision: cloud-routed is
444        // flagged, accepted-local is not.
445        assert!(!bi[0].needs_review);
446        assert!(bi[1].needs_review);
447
448        // The cloud-routed segment's anchor is always a review target. Under the
449        // echo stub no confidence clears the conservative default review
450        // threshold, so the accepted-local segment's *carried confidence* also
451        // flags it — the intended "confidence can flag too" behaviour, and the
452        // honest III-6 stance that a 0.5B draft warrants review.
453        let targets = review_targets(&bi);
454        assert!(targets.contains(&"1"), "cloud-routed anchor must be a review target: {targets:?}");
455        assert!(bi[0].confidence.unwrap() < DEFAULT_REVIEW_THRESHOLD);
456        assert!(targets.contains(&"0"), "a below-threshold accepted draft is still flagged by confidence");
457
458        // The rendered output anchors on the plan index.
459        let out = render_bilingual(&bi, &BilingualOptions::interleaved());
460        assert!(out.contains("<!-- seg 1 -->"), "anchor must be the plan index");
461    }
462
463    /// The measurement: of N segments, only the flagged M are read.
464    #[test]
465    fn review_coverage_quantifies_the_saving() {
466        let mut a = seg("0", 0, "s", "t");
467        a.confidence = Some(0.9); // clears threshold
468        let mut b = seg("1", 1, "s", "t");
469        b.confidence = Some(0.1); // flagged
470        let mut c = seg("2", 2, "s", "t");
471        c.needs_review = true; // flagged
472        let cov = review_coverage(&[a, b, c]);
473        assert_eq!(cov.total, 3);
474        assert_eq!(cov.flagged, 2);
475        assert!((cov.review_fraction - 2.0 / 3.0).abs() < 1e-6);
476    }
477
478    /// Empty input renders the empty string, in both layouts, with a zeroed
479    /// coverage.
480    #[test]
481    fn empty_input_is_empty_output() {
482        assert_eq!(render_bilingual(&[], &BilingualOptions::interleaved()), "");
483        assert_eq!(render_bilingual(&[], &BilingualOptions::side_by_side()), "");
484        assert!(review_targets(&[]).is_empty());
485        let cov = review_coverage(&[]);
486        assert_eq!(cov.total, 0);
487        assert_eq!(cov.flagged, 0);
488        assert_eq!(cov.review_fraction, 0.0);
489    }
490
491    /// Same input ⇒ byte-identical output (determinism), independent of input
492    /// order.
493    #[test]
494    fn render_is_deterministic() {
495        let ordered = vec![seg("0", 0, "一", "one"), seg("1", 1, "二", "two"), seg("2", 2, "三", "three")];
496        let shuffled = vec![seg("2", 2, "三", "three"), seg("0", 0, "一", "one"), seg("1", 1, "二", "two")];
497        let opts = BilingualOptions::interleaved();
498        assert_eq!(render_bilingual(&ordered, &opts), render_bilingual(&shuffled, &opts));
499
500        let table = BilingualOptions::side_by_side();
501        assert_eq!(render_bilingual(&ordered, &table), render_bilingual(&shuffled, &table));
502
503        // Re-rendering the exact same input is byte-identical.
504        let a = render_bilingual(&ordered, &opts);
505        let b = render_bilingual(&ordered, &opts);
506        assert_eq!(a, b);
507    }
508
509    /// Reportable structs carry the serde derives (§0.2) — a bound assertion, so
510    /// no `serde_json` dev-dependency is introduced (mirrors `translate.rs`).
511    #[test]
512    fn serde_derives_are_present() {
513        fn assert_serde<T: serde::Serialize + serde::de::DeserializeOwned>() {}
514        assert_serde::<BilingualSegment>();
515        assert_serde::<BilingualOptions>();
516        assert_serde::<Layout>();
517        assert_serde::<ReviewCoverage>();
518    }
519}