|
20 | 20 | //! cargo test --test conversion_quality -- --nocapture |
21 | 21 |
|
22 | 22 | use bonolith::core::dictionary::Dictionary; |
23 | | -use bonolith::engine::{ConversionEngine, SharedCore}; |
| 23 | +use bonolith::engine::{ConversionEngine, ConversionState, SharedCore}; |
24 | 24 | use std::fs; |
25 | 25 | use std::time::{Duration, Instant}; |
26 | 26 |
|
@@ -480,3 +480,180 @@ fn evaluate_top1_accuracy_live() { |
480 | 480 | } |
481 | 481 | } |
482 | 482 | } |
| 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