Skip to content

Commit 9acc4aa

Browse files
BonoJoviclaude
andcommitted
test(engine): fold exploratory probes into the data-driven suite
The verb_kanji_probe and kana_fixation_probe #[ignore] probes are now subsumed: the verb-kanji and きょう→今日 phenomena are covered end-to-end by the conversion-quality cases (case_0027/0028/0030–0036), and the kana-neutralization invariant is held by the hermetic guard rerank_neutralizes_plain_kana_llm_score. Delete both. Replace learning_curve — which hand-rolled the rerank combine (a partial pipeline, the false-positive hazard) and needed a live server — with learning_promotes_surface_to_top1: a hermetic test that drives the real ConversionEngine and asserts recorded user selections promote a non-default surface (はし→橋, きしゃ→汽車, あめ→飴, each at N=1) to top-1 and keep it there. Deterministic under MockScorer, runs in CI. Engine #[ignore] probes drop from 3 to 0; 129 lib tests pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent ac17313 commit 9acc4aa

1 file changed

Lines changed: 49 additions & 280 deletions

File tree

src/engine/mod.rs

Lines changed: 49 additions & 280 deletions
Original file line numberDiff line numberDiff line change
@@ -1326,206 +1326,62 @@ pub struct ConversionCandidate {
13261326
mod tests {
13271327
use super::*;
13281328

1329-
/// Learning-curve experiment: for each homophone case, how many user
1330-
/// selections N does it take before the final (dict + user + LLM) ranking
1331-
/// puts the correct surface on top?
1332-
///
1333-
/// Ignored by default (needs a running llama-server). Run against either
1334-
/// model:
1335-
/// cargo test --lib engine -- --ignored --nocapture learning_curve
1336-
/// BONOLITH_LLM_ENDPOINT=http://127.0.0.1:8081 cargo test --lib engine \
1337-
/// -- --ignored --nocapture learning_curve
1338-
///
1339-
/// The combine mirrors production: candidates are ordered by
1340-
/// effective_score (freq + user*2 + surface_adj), then the top
1341-
/// LLM_RERANK_TOP_N are reranked by `rank_base*0.4 + llm*0.6`. The LLM
1342-
/// score is independent of N, so it's fetched once per surface and cached;
1343-
/// only the effective-score ordering (hence rank_base) moves as N grows.
1329+
/// Learning regression (hermetic): recording user selections must be able
1330+
/// to promote a non-default but valid surface to top-1 through the *full*
1331+
/// pipeline (build_segment_states ordering + background rerank), and must
1332+
/// never demote it once learned. Deterministic under MockScorer, so it
1333+
/// guards the user-learning weight in the rerank combine without a server.
1334+
/// Replaces the old `learning_curve` probe, which hand-rolled the combine
1335+
/// (a partial pipeline) instead of driving the real engine.
13441336
#[test]
1345-
#[ignore]
1346-
fn learning_curve() {
1347-
use crate::core::llm::{HttpLlamaScorer, LlmScorer};
1348-
1349-
let scorer = match HttpLlamaScorer::from_default_endpoint() {
1350-
Some(s) => s,
1351-
None => {
1352-
eprintln!("no llama-server reachable; skipping");
1353-
return;
1354-
}
1355-
};
1356-
let dict = Dictionary::new();
1357-
1358-
// (preceding context, reading, correct surface)
1359-
let cases = [
1360-
("ご飯を食べるための", "はし", "箸"),
1361-
("川にかかった", "はし", "橋"),
1362-
("工場の最新の", "きかい", "機械"),
1363-
("またとない", "きかい", "機会"),
1364-
("空から降ってくる", "あめ", "雨"),
1365-
("甘くておいしい", "あめ", "飴"),
1366-
("神社にお参りして", "かみ", "神"),
1367-
("夏はとても", "あつい", "暑い"),
1368-
("やかんのお湯が", "あつい", "熱い"),
1369-
("時間どおり", "せいかく", "正確"),
1370-
];
1371-
1337+
fn learning_promotes_surface_to_top1() {
1338+
// (reading, target) where `target` is a valid homophone that is *not*
1339+
// the cold-start default (e.g. 飴 sits below 雨) — so any flip to it can
1340+
// only come from the recorded user selections.
1341+
let cases = [("はし", "橋"), ("きしゃ", "汽車"), ("あめ", "飴")];
13721342
const MAX_N: u32 = 20;
1373-
let endpoint =
1374-
std::env::var("BONOLITH_LLM_ENDPOINT").unwrap_or_else(|_| "default(8080)".into());
1375-
println!("\n=== learning curve (endpoint={endpoint}) ===");
1376-
1377-
for (ctx, reading, correct) in cases {
1378-
let entries = dict.lookup(reading);
1379-
if !entries.iter().any(|e| e.surface == correct) {
1380-
println!(" {reading} -> {correct}: NOT IN DICT (skipped)");
1381-
continue;
1382-
}
13831343

1384-
// Cache the (N-independent) LLM score per surface once.
1385-
let llm: std::collections::HashMap<String, f64> = entries
1386-
.iter()
1387-
.map(|e| (e.surface.clone(), scorer.score(ctx, &e.surface)))
1388-
.collect();
1389-
1390-
// Winner of the production-style combine for a given learned count.
1391-
let winner_at = |n: u32| -> String {
1392-
let mut user = UserScorer::new();
1393-
for _ in 0..n {
1394-
user.record(reading, correct);
1344+
for (reading, target) in cases {
1345+
let top1_after = |n: u32| -> String {
1346+
let shared = SharedCore::new_hermetic();
1347+
{
1348+
let mut user = shared.user_scorer.lock().unwrap();
1349+
for _ in 0..n {
1350+
user.record(reading, target);
1351+
}
13951352
}
1396-
let mut ranked: Vec<&&DictionaryEntry> = entries.iter().collect();
1397-
ranked.sort_by(|a, b| {
1398-
let sa = ConversionEngine::effective_score_with(&user, reading, a);
1399-
let sb = ConversionEngine::effective_score_with(&user, reading, b);
1400-
sb.partial_cmp(&sa).unwrap_or(std::cmp::Ordering::Equal)
1401-
});
1402-
let rerank_count = ranked.len().min(5);
1403-
ranked[..rerank_count]
1404-
.iter()
1405-
.enumerate()
1406-
.map(|(i, e)| {
1407-
// Mirrors trigger_llm_rerank's combine, including the
1408-
// user-learning magnitude term (USER_LEARNING_WEIGHT).
1409-
let rank_base = 1.0 - (i as f64 / rerank_count as f64) * 0.3;
1410-
let combined = rank_base * 0.4
1411-
+ llm[&e.surface] * 0.6
1412-
+ user.score(reading, &e.surface) * 0.5;
1413-
(e.surface.clone(), combined)
1414-
})
1415-
.max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))
1416-
.map(|(s, _)| s)
1353+
let mut engine = ConversionEngine::with_shared(shared);
1354+
engine.append_raw(reading);
1355+
if engine.start_conversion().is_none() {
1356+
return String::new();
1357+
}
1358+
// Drain the deterministic background rerank.
1359+
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
1360+
while !engine.has_llm_rerank_result() && std::time::Instant::now() < deadline {
1361+
std::thread::sleep(std::time::Duration::from_millis(1));
1362+
}
1363+
engine.apply_llm_rerank();
1364+
engine
1365+
.conversion_state()
1366+
.map(|s| s.composed_text())
14171367
.unwrap_or_default()
14181368
};
14191369

1420-
let flip = (0..=MAX_N).find(|&n| winner_at(n) == correct);
1421-
let n0 = winner_at(0);
1422-
match flip {
1423-
Some(0) => println!(" {reading} -> {correct}: ✓ correct at N=0 (no learning needed)"),
1424-
Some(n) => println!(
1425-
" {reading} -> {correct}: flips at N={n} (N=0 winner was {n0})"
1426-
),
1427-
None => println!(
1428-
" {reading} -> {correct}: never wins within N={MAX_N} (stuck on {n0})"
1429-
),
1430-
}
1431-
}
1432-
}
1433-
1434-
/// Hiragana-fixation probe: for readings whose raw kana competes with a
1435-
/// high-frequency kanji homophone (きょう→今日, はし→箸/橋), build the *full*
1436-
/// candidate list exactly as `build_segment_states` does — including the
1437-
/// inserted raw-reading kana — then run the production rerank combine with
1438-
/// the real LLM. Reports the winner and the kana form's rank so we can see
1439-
/// whether the LLM's high rating of a plain-kana continuation pulls the
1440-
/// kana above the kanji (the "ひらがな固着" failure).
1441-
///
1442-
/// Ignored by default (needs a running llama-server):
1443-
/// cargo test --lib engine -- --ignored --nocapture kana_fixation
1444-
#[test]
1445-
#[ignore]
1446-
fn kana_fixation_probe() {
1447-
use crate::core::llm::{HttpLlamaScorer, LlmScorer};
1448-
1449-
let scorer = match HttpLlamaScorer::from_default_endpoint() {
1450-
Some(s) => s,
1451-
None => {
1452-
eprintln!("no llama-server reachable; skipping");
1453-
return;
1454-
}
1455-
};
1456-
let dict = Dictionary::new();
1457-
let user = UserScorer::new(); // no learning: probe the cold-start path
1458-
1459-
// (preceding context, reading, expected kanji)
1460-
let cases = [
1461-
("", "きょう", "今日"),
1462-
("また", "きょう", "今日"),
1463-
("あしたではなく", "きょう", "今日"),
1464-
("", "はし", "箸"),
1465-
("ご飯を食べるための", "はし", "箸"),
1466-
("川にかかった", "はし", "橋"),
1467-
];
1468-
1469-
println!("\n=== hiragana-fixation probe ===");
1470-
for (ctx, reading, expected) in cases {
1471-
// Replicate build_segment_states' ordering + raw-kana insertion.
1472-
let mut entries = dict.lookup(reading);
1473-
entries.sort_by(|a, b| {
1474-
let sa = ConversionEngine::effective_score_with(&user, reading, a);
1475-
let sb = ConversionEngine::effective_score_with(&user, reading, b);
1476-
sb.partial_cmp(&sa).unwrap_or(std::cmp::Ordering::Equal)
1477-
});
1478-
let mut candidates: Vec<String> =
1479-
entries.iter().map(|e| e.surface.clone()).collect();
1480-
if candidates.is_empty() || !candidates.contains(&reading.to_string()) {
1481-
let kana_score = user.score(reading, reading) * 2.0 + 0.1;
1482-
let pos = entries
1483-
.iter()
1484-
.position(|e| {
1485-
ConversionEngine::effective_score_with(&user, reading, e) < kana_score
1486-
})
1487-
.unwrap_or(candidates.len());
1488-
candidates.insert(pos, reading.to_string());
1489-
}
1490-
1491-
// Production rerank combine over the top-N.
1492-
let rerank_count = candidates.len().min(5);
1493-
let scored: Vec<(String, f64, f64)> = candidates[..rerank_count]
1494-
.iter()
1495-
.enumerate()
1496-
.map(|(i, surface)| {
1497-
// Mirror trigger_llm_rerank: neutralize the LLM term for the
1498-
// plain-kana form so the model's kana bias can't promote it.
1499-
let llm = ConversionEngine::rerank_llm_score(reading, surface, || {
1500-
scorer.score(ctx, surface)
1501-
});
1502-
let rank_base = 1.0 - (i as f64 / rerank_count as f64) * 0.3;
1503-
let combined = rank_base * 0.4
1504-
+ llm * 0.6
1505-
+ user.score(reading, surface) * 0.5;
1506-
(surface.clone(), llm, combined)
1507-
})
1508-
.collect();
1509-
1510-
let mut ranked = scored.clone();
1511-
ranked.sort_by(|a, b| b.2.partial_cmp(&a.2).unwrap_or(std::cmp::Ordering::Equal));
1512-
let winner = &ranked[0].0;
1513-
let kana_rank = ranked.iter().position(|(s, ..)| s == reading);
1514-
let kana_llm = scored
1515-
.iter()
1516-
.find(|(s, ..)| s == reading)
1517-
.map(|(_, l, _)| *l);
1518-
let exp_llm = scored
1519-
.iter()
1520-
.find(|(s, ..)| s == expected)
1521-
.map(|(_, l, _)| *l);
1522-
println!(
1523-
" ctx={ctx:?} {reading}: winner={winner} (want {expected}) | \
1524-
kana@top5={} rank={:?} llm(kana)={:?} llm({expected})={:?}",
1525-
candidates[..rerank_count].contains(&reading.to_string()),
1526-
kana_rank,
1527-
kana_llm.map(|v| format!("{v:.3}")),
1528-
exp_llm.map(|v| format!("{v:.3}")),
1370+
let flip = (0..=MAX_N).find(|&n| top1_after(n) == target);
1371+
assert!(
1372+
flip.is_some(),
1373+
"{reading} -> {target}: learning never promotes it to top-1 within N={MAX_N} (got {:?})",
1374+
top1_after(MAX_N),
1375+
);
1376+
// Once learned, it must stay on top at higher N (no demotion).
1377+
assert_eq!(
1378+
top1_after(MAX_N),
1379+
target,
1380+
"{reading} -> {target}: not stable at N={MAX_N}",
1381+
);
1382+
eprintln!(
1383+
"learning: {reading} -> {target} reaches top-1 at N={}",
1384+
flip.unwrap(),
15291385
);
15301386
}
15311387
}
@@ -1548,93 +1404,6 @@ mod tests {
15481404
assert_eq!(kanji, 0.9);
15491405
}
15501406

1551-
/// Verb-kanji probe (residual ①): the production pipeline is now just the
1552-
/// per-segment rerank (stage 1; N-best was reverted as inert). This builds
1553-
/// each segment's candidates like build_segment_states, runs the stage-1
1554-
/// rerank left-to-right with left context, and reports the chosen sentence.
1555-
/// Separates the two failure modes: (A) frequency bug — the everyday verb
1556-
/// buried below a rare variant (fixed by PRIORITY_OVERRIDES); (B) LLM flip —
1557-
/// the common verb is already freq-top yet the model flips it (渡る→亙る),
1558-
/// which the dictionary cannot fix.
1559-
///
1560-
/// cargo test --lib engine -- --ignored --nocapture verb_kanji
1561-
#[test]
1562-
#[ignore]
1563-
fn verb_kanji_probe() {
1564-
use crate::core::llm::{HttpLlamaScorer, LlmScorer};
1565-
1566-
let scorer = match HttpLlamaScorer::from_default_endpoint() {
1567-
Some(s) => s,
1568-
None => {
1569-
eprintln!("no llama-server reachable; skipping");
1570-
return;
1571-
}
1572-
};
1573-
let dict = Dictionary::new();
1574-
let user = UserScorer::new();
1575-
1576-
// (kana input, expected verb surface) — the noun half is unambiguous.
1577-
let cases = [
1578-
("かみにいのる", "祈る"),
1579-
("プールでおよぐ", "泳ぐ"),
1580-
("ドアをとじる", "閉じる"),
1581-
("えいがをみる", "見る"),
1582-
("りょうりをつくる", "作る"),
1583-
("ともだちにあう", "会う"),
1584-
("かぎをさがす", "探す"),
1585-
("はしをわたる", "渡る"), // (B): 渡る already freq-top; expect LLM may still flip
1586-
];
1587-
1588-
println!("\n=== verb-kanji probe ===");
1589-
let mut ok = 0;
1590-
for (kana, expected) in cases {
1591-
let segs = dict.segment(kana);
1592-
let mut preceding = String::new();
1593-
let mut picked: Vec<String> = Vec::new();
1594-
for seg in &segs {
1595-
let mut entries: Vec<&DictionaryEntry> = seg.candidates.iter().collect();
1596-
entries.sort_by(|a, b| {
1597-
let sa = ConversionEngine::effective_score_with(&user, &seg.reading, a);
1598-
let sb = ConversionEngine::effective_score_with(&user, &seg.reading, b);
1599-
sb.partial_cmp(&sa).unwrap_or(std::cmp::Ordering::Equal)
1600-
});
1601-
let mut cands: Vec<String> = entries.iter().map(|e| e.surface.clone()).collect();
1602-
if cands.is_empty() {
1603-
cands.push(seg.reading.clone());
1604-
}
1605-
if cands.len() > 1 && seg.reading.chars().count() >= 2 {
1606-
let n = cands.len().min(5);
1607-
let best = (0..n)
1608-
.map(|i| {
1609-
let llm = ConversionEngine::rerank_llm_score(
1610-
&seg.reading,
1611-
&cands[i],
1612-
|| scorer.score(&preceding, &cands[i]),
1613-
);
1614-
let rank_base = 1.0 - (i as f64 / n as f64) * 0.3;
1615-
(cands[i].clone(), rank_base * 0.4 + llm * 0.6)
1616-
})
1617-
.max_by(|a, b| a.1.partial_cmp(&b.1).unwrap())
1618-
.map(|(s, _)| s)
1619-
.unwrap_or_else(|| cands[0].clone());
1620-
preceding.push_str(&best);
1621-
picked.push(best);
1622-
} else {
1623-
preceding.push_str(&cands[0]);
1624-
picked.push(cands[0].clone());
1625-
}
1626-
}
1627-
let sentence: String = picked.concat();
1628-
let got_verb = picked.iter().any(|p| p == expected);
1629-
ok += got_verb as usize;
1630-
println!(
1631-
" {kana}: {sentence} | verb {expected} {}",
1632-
if got_verb { "OK" } else { "MISS" },
1633-
);
1634-
}
1635-
println!(" ---\n verb correct {ok}/{}", cases.len());
1636-
}
1637-
16381407
#[test]
16391408
fn process_key_buffering() {
16401409
let mut engine = ConversionEngine::new();

0 commit comments

Comments
 (0)