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}