Skip to content

Commit 92fb3ef

Browse files
BonoJoviclaude
andcommitted
test(quality): measure oracle-prefix ceiling for incremental rerank
Adds measure_oracle_prefix_ceiling_live (#[ignore]) to quantify the upside of re-ranking the rest of the sentence each time the user confirms a word. Drives the real engine confirm-and-continue, comparing downstream bunsetsu top-1 with the system's own left context (self) vs the correct one (oracle). MockScorer ignores context, so it is live-only. Result (1.5B): downstream 51.2% (self) -> 53.5% (oracle), +1 net position (+2.3 pts). The cascade hypothesis holds in the specific cases — 神に fixes 祈る (case_0030), 鶏が (case_0008) — but the ceiling is small: most downstream errors are segmentation or right-context/world-knowledge lexical errors that a correct left prefix cannot fix. Like the reverted sentence N-best rerank (660250f), the upside does not justify the latency/UX cost, so the interactive re-rank is shelved. The test stays as the gate to revisit on a stronger model. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent dcc3727 commit 92fb3ef

1 file changed

Lines changed: 178 additions & 1 deletion

File tree

tests/conversion_quality.rs

Lines changed: 178 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
//! cargo test --test conversion_quality -- --nocapture
2121
2222
use bonolith::core::dictionary::Dictionary;
23-
use bonolith::engine::{ConversionEngine, SharedCore};
23+
use bonolith::engine::{ConversionEngine, ConversionState, SharedCore};
2424
use std::fs;
2525
use std::time::{Duration, Instant};
2626

@@ -480,3 +480,180 @@ fn evaluate_top1_accuracy_live() {
480480
}
481481
}
482482
}
483+
484+
// ---------------------------------------------------------------------------
485+
// Oracle-prefix ceiling (live, informational).
486+
//
487+
// Quantifies the upside of incremental re-ranking — re-evaluating the rest of
488+
// the sentence each time the user confirms a word. We drive the REAL engine
489+
// confirm-and-continue: at each expected bunsetsu we convert the *remaining*
490+
// reading with the LLM context built from prior confirmations, then commit a
491+
// confirmation surface so it becomes left context for the next round. The two
492+
// modes differ only in that surface:
493+
// - oracle: the correct expected surface (a perfect user confirmation)
494+
// - self: the system's own leading output (today's single-pass behaviour)
495+
// The gap on downstream bunsetsu (j>=1, where left context exists) is the
496+
// ceiling of the feature. MockScorer ignores context, so this is live-only and
497+
// never gates — it informs whether the feature is worth building.
498+
// ---------------------------------------------------------------------------
499+
500+
/// Surface produced for the first `target_chars` of reading, or None if a
501+
/// segment boundary straddles that offset (then we can't isolate the bunsetsu).
502+
fn leading_surface(state: &ConversionState, target_chars: usize) -> Option<String> {
503+
let mut acc = 0usize;
504+
let mut surf = String::new();
505+
for seg in &state.segments {
506+
acc += seg.reading.chars().count();
507+
surf.push_str(&seg.candidates[seg.selected]);
508+
if acc == target_chars {
509+
return Some(surf);
510+
}
511+
if acc > target_chars {
512+
return None;
513+
}
514+
}
515+
None
516+
}
517+
518+
/// Confirm-and-continue over one case. Returns, per bunsetsu, `Some(hit)` when
519+
/// the leading output is alignable (so measurable), else `None`.
520+
fn confirm_and_continue(case: &Case, oracle: bool) -> Vec<Option<bool>> {
521+
let scorer = bonolith::core::llm::HttpLlamaScorer::from_default_endpoint()
522+
.expect("llama-server checked reachable by caller");
523+
let shared = SharedCore::new_eval(Box::new(scorer));
524+
let mut engine = ConversionEngine::with_shared(shared);
525+
526+
let mut out = Vec::with_capacity(case.expected.len());
527+
for j in 0..case.expected.len() {
528+
let remaining: String = case.expected_readings[j..].concat();
529+
engine.append_raw(&remaining);
530+
if engine.start_conversion().is_none() {
531+
engine.reset();
532+
out.push(None);
533+
continue;
534+
}
535+
let deadline = Instant::now() + Duration::from_secs(2);
536+
while !engine.has_llm_rerank_result() && Instant::now() < deadline {
537+
std::thread::sleep(Duration::from_millis(1));
538+
}
539+
engine.apply_llm_rerank();
540+
541+
let target = case.expected_readings[j].chars().count();
542+
let got = engine
543+
.conversion_state()
544+
.and_then(|s| leading_surface(s, target));
545+
out.push(got.as_ref().map(|g| g == &case.expected[j]));
546+
547+
// Confirm a surface so it becomes left context: the correct one in
548+
// oracle mode, the system's own leading output otherwise (falling back
549+
// to correct when unalignable, so context stays sane either way).
550+
let confirm = if oracle {
551+
case.expected[j].clone()
552+
} else {
553+
got.unwrap_or_else(|| case.expected[j].clone())
554+
};
555+
engine.clear_conversion();
556+
engine.commit(&confirm);
557+
}
558+
out
559+
}
560+
561+
#[derive(Default, Clone, Copy)]
562+
struct CeilStats {
563+
aligned: u32,
564+
oracle_hit: u32,
565+
self_hit: u32,
566+
}
567+
568+
#[test]
569+
#[ignore]
570+
fn measure_oracle_prefix_ceiling_live() {
571+
if bonolith::core::llm::HttpLlamaScorer::from_default_endpoint().is_none() {
572+
eprintln!("no llama-server reachable; skipping oracle-prefix ceiling");
573+
return;
574+
}
575+
let cases = load_cases();
576+
577+
let mut overall = CeilStats::default();
578+
let mut by_pos: std::collections::BTreeMap<String, CeilStats> = std::collections::BTreeMap::new();
579+
let mut wins: Vec<String> = Vec::new();
580+
581+
eprintln!("\n=== Oracle-prefix ceiling (downstream bunsetsu, j>=1) ===");
582+
for case in &cases {
583+
if case.expected.len() < 2 {
584+
continue; // no downstream position to measure
585+
}
586+
let oracle = confirm_and_continue(case, true);
587+
let zelf = confirm_and_continue(case, false);
588+
589+
for j in 1..case.expected.len() {
590+
// Segmentation of the remaining reading is context-independent, so
591+
// alignment matches across modes; require both measurable.
592+
if let (Some(oh), Some(sh)) = (oracle[j], zelf[j]) {
593+
let b = by_pos.entry(case.pos_solvable.clone()).or_default();
594+
overall.aligned += 1;
595+
b.aligned += 1;
596+
if oh {
597+
overall.oracle_hit += 1;
598+
b.oracle_hit += 1;
599+
}
600+
if sh {
601+
overall.self_hit += 1;
602+
b.self_hit += 1;
603+
}
604+
if oh && !sh {
605+
wins.push(format!(
606+
"{} bunsetsu[{}] {:?} fixed by correct left context",
607+
case.id, j, case.expected[j]
608+
));
609+
}
610+
}
611+
}
612+
}
613+
614+
let pct = |n: u32, d: u32| if d == 0 { 0.0 } else { 100.0 * n as f64 / d as f64 };
615+
eprintln!(
616+
"\nDownstream aligned positions: {}",
617+
overall.aligned
618+
);
619+
eprintln!(
620+
" self (current single-pass): {}/{} ({:.1}%)",
621+
overall.self_hit,
622+
overall.aligned,
623+
pct(overall.self_hit, overall.aligned),
624+
);
625+
eprintln!(
626+
" oracle (perfect left context): {}/{} ({:.1}%) <- ceiling",
627+
overall.oracle_hit,
628+
overall.aligned,
629+
pct(overall.oracle_hit, overall.aligned),
630+
);
631+
eprintln!(
632+
" ceiling gain: +{} positions (+{:.1} pts)",
633+
overall.oracle_hit.saturating_sub(overall.self_hit),
634+
pct(overall.oracle_hit, overall.aligned) - pct(overall.self_hit, overall.aligned),
635+
);
636+
637+
eprintln!("\nBy pos_solvable (self -> oracle):");
638+
for (k, s) in &by_pos {
639+
eprintln!(
640+
" {:<8} {}/{} ({:.1}%) -> {}/{} ({:.1}%)",
641+
k,
642+
s.self_hit,
643+
s.aligned,
644+
pct(s.self_hit, s.aligned),
645+
s.oracle_hit,
646+
s.aligned,
647+
pct(s.oracle_hit, s.aligned),
648+
);
649+
}
650+
651+
if wins.is_empty() {
652+
eprintln!("\nNo downstream position flips on correct left context — feature looks inert.");
653+
} else {
654+
eprintln!("\nPositions fixed purely by correct left context ({}):", wins.len());
655+
for w in &wins {
656+
eprintln!(" {}", w);
657+
}
658+
}
659+
}

0 commit comments

Comments
 (0)