@@ -1326,206 +1326,62 @@ pub struct ConversionCandidate {
13261326mod 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