romaji2ja / code /infer_fast.py
limoXD's picture
Publish accepted A75 checkpoint with bound evaluation evidence
03b56f8 verified
Raw
History Blame Contribute Delete
112 kB
import argparse
import hashlib
import json
import sqlite3
import sys
import time
from collections import Counter
from functools import lru_cache
from pathlib import Path
from normalization import NORMALIZATION_VERSION, normalize_input
from romaji_kana import (
GENERIC_FALLBACK_VERSION,
generic_romaji_fallback,
load_general_lexicon,
prepare_generic_lexicon,
)
from general_phrase import (
GENERAL_PHRASE_VERSION,
build_general_phrase_index,
canonicalize_romaji_variants,
general_phrase_rescue,
)
try:
from rapidfuzz.distance import Levenshtein as _RapidLevenshtein
except Exception:
_RapidLevenshtein = None
BOS_IN = "\uEE00"
BOS_OUT = "\uEE01"
DEFAULT_LEXICON_CANDIDATES = (
"artifacts/lexicon/romaji2ja_typo_95.json",
"artifacts/lexicon/romaji2ja_feedback_95.json",
"artifacts/lexicon/romaji2ja.json",
)
DEFAULT_CANDIDATE_FEEDBACK = "artifacts/lexicon/candidate_feedback.jsonl"
DEFAULT_GENERAL_LEXICON = "artifacts/lexicon/general_reading_lexicon.json"
DEFAULT_AUX_LEXICON = "artifacts/lexicon/adversarial_piece_aliases.json"
FUZZY_CANDIDATE_LIMIT = 160
WEIGHTED_FUZZY_CANDIDATE_LIMIT = 128
WEIGHTED_FUZZY_FAST_CANDIDATE_LIMIT = 16
WEIGHTED_FUZZY_FAST_ACCEPT_SCORE = 0.26
FUZZY_SEGMENT_MAX_SPLITS = 4
FUZZY_TRIPLE_MAX_SPLITS = 48
MULTI_SEGMENT_FUZZY_PIECE_PROBE_LIMIT = 12
ANCHORED_FUZZY_MIN_ANCHOR_LEN = 12
DEEP_MULTI_SEGMENT_MIN_LEN = 56
SANDWICH_FUZZY_MIN_MIDDLE_LEN = 5
FAST_PATH_VERSION = "fastpath-v46-general-phrase-prefuzzy-exact-20260614"
LONG_FUZZY_SEGMENT_MAX_SCORE = 0.42
MULTI_SEGMENT_FUZZY_MAX_SCORE = 0.26
RELAXED_MULTI_SEGMENT_FUZZY_MAX_SCORE = 0.28
EXTENDED_MULTI_SEGMENT_MIN_LEN = 100
LONG_SEGMENT_MIN_LEN = 100
LONG_SEGMENT_MAX_SEGMENTS = 20
WIDE_MULTI_SEGMENT_MIN_LEN = 120
WIDE_MULTI_SEGMENT_MAX_FUZZY_SEGMENTS = 8
WIDE_MULTI_SEGMENT_MAX_TRANSITIONS = 8
WIDE_MULTI_SEGMENT_BEAM_WIDTH = 16
WIDE_MULTI_SEGMENT_PIECE_PROBE_LIMIT = 8
WIDE_MULTI_SEGMENT_MIN_OUTPUT_RATIO = 0.25
WIDE_MULTI_SEGMENT_WEIGHTED_CANDIDATE_LIMIT = 32
WIDE_MULTI_SEGMENT_WIDE_PLAIN_ACCEPT_SCORE = 0.28
WIDE_MULTI_SEGMENT_BOUNDARY_LENGTH_DELTA = 1
WIDE_MULTI_SEGMENT_RESCUE_MIN_LEN = 260
WIDE_MULTI_SEGMENT_RESCUE_MAX_FUZZY_SEGMENTS = 12
WIDE_MULTI_SEGMENT_RESCUE_BEAM_WIDTH = 24
WIDE_MULTI_SEGMENT_RESCUE_PIECE_PROBE_LIMIT = 12
WIDE_MULTI_SEGMENT_RESCUE_WEIGHTED_CANDIDATE_LIMIT = 16
WIDE_MULTI_SEGMENT_RESCUE_PLAIN_ACCEPT_SCORE = 0.28
WIDE_MULTI_SEGMENT_PLAIN_ACCEPT_SCORE = 0.22
LONG_SINGLE_FUZZY_WEIGHTED_CANDIDATE_LIMIT = 16
LONG_SINGLE_FUZZY_PLAIN_ACCEPT_SCORE = 0.28
LONG_FUZZY_BOUNDARY_LENGTH_DELTA = 3
DENSE_OVERFLOW_MIN_LEN = 240
DENSE_OVERFLOW_DIRECT_MIN_LEN = 400
DENSE_OVERFLOW_MIN_SEGMENTS = LONG_SEGMENT_MAX_SEGMENTS + 1
DENSE_OVERFLOW_MAX_SEGMENTS = 64
DENSE_OVERFLOW_MAX_FUZZY_SEGMENTS = 32
DENSE_OVERFLOW_BEAM_WIDTH = 4
DENSE_OVERFLOW_MAX_SCORE = 0.24
DENSE_OVERFLOW_MAX_DISTANCE_RATIO = 0.30
DENSE_OVERFLOW_FUZZY_COST = 0.03
DENSE_COMPACT_FUZZY_KEEP_PER_OUTPUT_LENGTH = 2
SHORT_VITERBI_COMPACT_FUZZY_KEEP_PER_OUTPUT_LENGTH = 4
DENSE_OVERFLOW_RESCUE_MAX_SCORE = 0.27
DENSE_OVERFLOW_RESCUE_BEAM_WIDTH = 2
DENSE_OVERFLOW_WEIGHTED_RESCUE_BEAM_WIDTH = 1
DENSE_OVERFLOW_FAST_WEIGHTED_RESCUE_BEAM_WIDTH = 2
DENSE_OVERFLOW_MAX_FULL_RESCUE_SEGMENTS = 1
DENSE_OVERFLOW_FULL_RESCUE_COST = 0.02
DENSE_OVERFLOW_WEIGHTED_CANDIDATE_LIMIT = 32
DENSE_OVERFLOW_FAST_WEIGHTED_CANDIDATE_LIMIT = 4
DENSE_OVERFLOW_FAST_WEIGHTED_VALIDATE_SCORE = 0.24
DENSE_OVERFLOW_FAST_WEIGHTED_VALIDATE_MARGIN = 0.02
DENSE_OVERFLOW_FAST_WEIGHTED_VALIDATE_CANDIDATE_LIMIT = 32
DENSE_OVERFLOW_RELAXED_WEIGHTED_RESCUE_MAX_SCORE = 0.29
DENSE_OVERFLOW_RELAXED_WEIGHTED_CANDIDATE_LIMIT = 8
DENSE_OVERFLOW_SHORT_VITERBI_MIN_LEN = 800
DENSE_OVERFLOW_SHORT_VITERBI_MIN_SEGMENTS = 50
DENSE_OVERFLOW_SHORT_VITERBI_MAX_SEGMENTS = 96
DENSE_OVERFLOW_SHORT_VITERBI_MIN_PIECE_LEN = 5
DENSE_OVERFLOW_SHORT_VITERBI_MAX_PIECE_LEN = 28
DENSE_OVERFLOW_SHORT_VITERBI_MAX_SCORE = 0.32
DENSE_OVERFLOW_SHORT_VITERBI_MAX_DISTANCE_RATIO = 0.32
DENSE_OVERFLOW_SHORT_VITERBI_WEIGHTED_CANDIDATE_LIMIT = 3
DENSE_OVERFLOW_SHORT_VITERBI_POSITION_BEAM = 3
DENSE_OVERFLOW_SHORT_VITERBI_ULTRA_WEIGHTED_CANDIDATE_LIMIT = 32
DENSE_OVERFLOW_SHORT_VITERBI_ULTRA_MAX_SCORE = 0.42
DENSE_OVERFLOW_SHORT_VITERBI_MAX_FUZZY_SEGMENTS = 64
DENSE_OVERFLOW_SHORT_VITERBI_MAX_COST_PER_SEGMENT = 0.20
DENSE_OVERFLOW_SHORT_VITERBI_MAX_FUZZY_RATIO = 0.75
DENSE_OVERFLOW_SHORT_VITERBI_FULL_RERANK_SCORE = 0.31
DENSE_OVERFLOW_SHORT_VITERBI_FULL_RERANK_MARGIN = 0.02
DENSE_OVERFLOW_SHORT_VITERBI_FULL_RERANK_CANDIDATE_LIMIT = 32
DENSE_OVERFLOW_SHORT_VITERBI_FULL_RERANK_MIN_DISTANCE = 5
DENSE_OVERFLOW_SHORT_VITERBI_FULL_RERANK_MIN_LEN_DELTA = 2
QWERTY_ROWS = (
("1234567890", 0.0),
("qwertyuiop", 0.25),
("asdfghjkl", 0.75),
("zxcvbnm", 1.25),
)
KEY_POS = {c: (x + i, float(y)) for y, (row, x) in enumerate(QWERTY_ROWS) for i, c in enumerate(row)}
VOWEL_NEIGHBORS = {
"a": "ieo",
"i": "aeu",
"u": "ioe",
"e": "iau",
"o": "aue",
}
def resolve_lexicon_path(path: str | None) -> str | None:
if not path:
return None
if path == "auto":
for candidate in DEFAULT_LEXICON_CANDIDATES:
if Path(candidate).exists():
return candidate
return None
return path
def load_lexicon(path: str | None, *, include_aux: bool = True) -> dict:
resolved = resolve_lexicon_path(path)
if not resolved:
return {}
p = Path(resolved)
if not p.exists():
return {}
lexicon = json.loads(p.read_text(encoding="utf-8"))
aux_path = Path(DEFAULT_AUX_LEXICON)
if include_aux and aux_path.exists() and aux_path.resolve() != p.resolve():
aux = json.loads(aux_path.read_text(encoding="utf-8"))
for key, value in aux.items():
lexicon.setdefault(normalize_input(key), value)
return lexicon
def load_choice_feedback(path: str | None) -> dict[str, str]:
if not path:
return {}
p = Path(path)
if not p.exists():
return {}
choices = {}
with p.open(encoding="utf-8") as f:
for line in f:
if not line.strip():
continue
row = json.loads(line)
inp = normalize_input(row.get("input", ""))
out = row.get("output")
if inp and out:
choices[inp] = out
return choices
def file_fingerprint(label: str, path: str | None) -> str:
if not path:
return f"{label}:none"
p = Path(path)
if not p.exists():
return f"{label}:{path}:missing"
st = p.stat()
return f"{label}:{p.resolve()}:{st.st_size}:{int(st.st_mtime)}"
def build_lexicon_index(lexicon: dict) -> dict[int, list[tuple[str, str]]]:
by_len = {}
for key, value in lexicon.items():
by_len.setdefault(len(key), []).append((key, value))
return by_len
def build_lexicon_lengths(lexicon: dict) -> list[int]:
return sorted({len(key) for key in lexicon}, reverse=True)
def boundary_fuzzy_lengths(
fuzzy_lengths: list[int],
exact_lengths: list[int],
max_len: int,
*,
min_len: int = 5,
delta: int = LONG_FUZZY_BOUNDARY_LENGTH_DELTA,
) -> list[int]:
lengths = {length for length in fuzzy_lengths if min_len <= length <= max_len}
for base_len in exact_lengths:
for offset in range(-delta, delta + 1):
length = base_len + offset
if min_len <= length <= max_len:
lengths.add(length)
return sorted(lengths, reverse=True)
def key_quality(key: str, median_len: float) -> tuple[float, int, str]:
noisy = sum(1 for ch in key if ch.isdigit() or not ch.isalpha())
rare = sum(1 for ch in key if ch in "qxz")
return (abs(len(key) - median_len) + noisy * 8 + rare * 0.25, len(key), key)
def build_compact_fuzzy_lexicon(lexicon: dict, keep_per_output: int = 8) -> dict:
by_output = {}
for key, value in lexicon.items():
by_output.setdefault(value, []).append(key)
compact = {}
for value, keys in by_output.items():
lengths = sorted(len(key) for key in keys)
mid = len(lengths) // 2
median_len = lengths[mid] if len(lengths) % 2 else (lengths[mid - 1] + lengths[mid]) / 2
for key in sorted(keys, key=lambda item: key_quality(item, median_len))[:keep_per_output]:
compact[key] = value
return compact
def build_dense_compact_fuzzy_lexicon(
lexicon: dict,
keep_per_output: int = 8,
keep_per_output_length: int = DENSE_COMPACT_FUZZY_KEEP_PER_OUTPUT_LENGTH,
) -> dict:
compact = build_compact_fuzzy_lexicon(lexicon, keep_per_output=keep_per_output)
by_output_length = {}
for key, value in lexicon.items():
by_output_length.setdefault((value, len(key)), []).append(key)
for (value, length), keys in by_output_length.items():
for key in sorted(keys, key=lambda item: key_quality(item, length))[:keep_per_output_length]:
compact[key] = value
return compact
def char_grams(text: str) -> set[str]:
if len(text) <= 3:
return {text}
width = 2 if len(text) <= 10 else 3
return {text[i:i + width] for i in range(0, len(text) - width + 1)}
def build_lexicon_gram_index(lexicon: dict) -> dict[str, list[tuple[str, str]]]:
index = {}
for key, value in lexicon.items():
for gram in char_grams(key):
index.setdefault(gram, []).append((key, value))
return index
def edit_distance(a: str, b: str, max_dist: int | None = None) -> int:
if a == b:
return 0
if max_dist is not None and abs(len(a) - len(b)) > max_dist:
return max_dist + 1
if _RapidLevenshtein is not None:
if max_dist is None:
return int(_RapidLevenshtein.distance(a, b))
return int(_RapidLevenshtein.distance(a, b, score_cutoff=max_dist))
prev = list(range(len(b) + 1))
for i, ca in enumerate(a, 1):
cur = [i] + [0] * len(b)
row_min = cur[0]
for j, cb in enumerate(b, 1):
cur[j] = min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (ca != cb))
row_min = min(row_min, cur[j])
if max_dist is not None and row_min > max_dist:
return max_dist + 1
prev = cur
return prev[-1]
def keyboard_substitution_cost(a: str, b: str) -> float:
if a == b:
return 0.0
if a in VOWEL_NEIGHBORS and b in VOWEL_NEIGHBORS[a]:
return 0.45
if a in KEY_POS and b in KEY_POS:
ax, ay = KEY_POS[a]
bx, by = KEY_POS[b]
dist = ((ax - bx) ** 2 + (ay - by) ** 2) ** 0.5
if dist <= 1.15:
return 0.35
if dist <= 1.7:
return 0.65
return 1.0
KEYBOARD_SUBSTITUTION_COSTS = {
ca: {cb: keyboard_substitution_cost(ca, cb) for cb in "abcdefghijklmnopqrstuvwxyz0123456789"}
for ca in "abcdefghijklmnopqrstuvwxyz0123456789"
}
def weighted_edit_distance(a: str, b: str, max_dist: float | None = None) -> float:
if a == b:
return 0.0
if max_dist is not None and abs(len(a) - len(b)) * 0.9 > max_dist:
return max_dist + 1.0
prev = [i * 0.9 for i in range(len(b) + 1)]
for i, ca in enumerate(a, 1):
cur = [i * 0.9] + [0.0] * len(b)
row_min = cur[0]
substitution_costs = KEYBOARD_SUBSTITUTION_COSTS.get(ca)
for j, cb in enumerate(b, 1):
substitution_cost = substitution_costs.get(cb, 1.0) if substitution_costs is not None else keyboard_substitution_cost(ca, cb)
delete_cost = prev[j] + 0.9
insert_cost = cur[j - 1] + 0.9
replace_cost = prev[j - 1] + substitution_cost
best_cost = delete_cost if delete_cost < insert_cost else insert_cost
if replace_cost < best_cost:
best_cost = replace_cost
cur[j] = best_cost
if best_cost < row_min:
row_min = best_cost
if max_dist is not None and row_min > max_dist:
return max_dist + 1.0
prev = cur
return prev[-1]
def fuzzy_lexicon_lookup(
inp: str,
lexicon: dict,
lexicon_by_len: dict | None = None,
lexicon_gram_index: dict | None = None,
):
match = fuzzy_lexicon_match(inp, lexicon, lexicon_by_len, lexicon_gram_index)
return match["value"] if match else None
def fuzzy_lexicon_match(
inp: str,
lexicon: dict,
lexicon_by_len: dict | None = None,
lexicon_gram_index: dict | None = None,
):
if not lexicon:
return None
max_dist = 2 if len(inp) <= 8 else 3 if len(inp) <= 12 else 5 if len(inp) <= 18 else 6 if len(inp) <= 32 else 7
best = None
tied_values = set()
allowed_lengths = set(range(len(inp) - max_dist, len(inp) + max_dist + 1))
if lexicon_gram_index is not None:
counts = Counter()
values = {}
for gram in char_grams(inp):
for key, value in lexicon_gram_index.get(gram, ()):
if len(key) in allowed_lengths:
counts[key] += 1
values[key] = value
if counts:
candidates = [
(key, values[key])
for key, _ in counts.most_common(FUZZY_CANDIDATE_LIMIT)
]
elif lexicon_by_len is not None:
candidates = []
for length in allowed_lengths:
candidates.extend(lexicon_by_len.get(length, ()))
else:
candidates = lexicon.items()
elif lexicon_by_len is not None:
candidates = []
for length in allowed_lengths:
candidates.extend(lexicon_by_len.get(length, ()))
else:
candidates = lexicon.items()
for key, value in candidates:
dist = edit_distance(inp, key, max_dist=max_dist)
if dist > max_dist:
continue
score = dist / max(1, max(len(inp), len(key)))
cand = (dist, score, key, value)
if best is None or cand[:2] < best[:2]:
best = cand
tied_values = {value}
elif cand[:2] == best[:2]:
tied_values.add(value)
if best is None:
return None
if best[1] > 0.36:
return None
if len(tied_values) > 1:
return None
return {
"key": best[2],
"value": best[3],
"distance": best[0],
"score": best[1],
}
def weighted_fuzzy_lexicon_match(
inp: str,
lexicon: dict,
lexicon_by_len: dict | None = None,
lexicon_gram_index: dict | None = None,
*,
candidate_limit: int | None = None,
):
if not lexicon:
return None
threshold = 0.46
max_dist = threshold * max(1, len(inp))
allowed_lengths = set(range(len(inp) - 7, len(inp) + 8))
if lexicon_gram_index is not None:
counts = Counter()
values = {}
for gram in char_grams(inp):
for key, value in lexicon_gram_index.get(gram, ()):
if len(key) in allowed_lengths:
counts[key] += 1
values[key] = value
limit = candidate_limit or WEIGHTED_FUZZY_CANDIDATE_LIMIT
candidates = [(key, values[key]) for key, _ in counts.most_common(limit)]
elif lexicon_by_len is not None:
candidates = []
for length in allowed_lengths:
candidates.extend(lexicon_by_len.get(length, ()))
else:
candidates = list(lexicon.items())
def scan(candidate_slice, best=None, tied_values=None):
if tied_values is None:
tied_values = set()
for key, value in candidate_slice:
dist = weighted_edit_distance(inp, key, max_dist=max_dist)
if dist > max_dist:
continue
score = dist / max(1, max(len(inp), len(key)))
cand = (dist, score, key, value)
if best is None or cand[:2] < best[:2]:
best = cand
tied_values = {value}
elif cand[:2] == best[:2]:
tied_values.add(value)
return best, tied_values
fast_limit = min(WEIGHTED_FUZZY_FAST_CANDIDATE_LIMIT, len(candidates))
best, tied_values = scan(candidates[:fast_limit])
if (
best is None
or best[1] > WEIGHTED_FUZZY_FAST_ACCEPT_SCORE
or len(tied_values) > 1
) and fast_limit < len(candidates):
best, tied_values = scan(candidates[fast_limit:], best, tied_values)
if best is None or best[1] > threshold or len(tied_values) > 1:
return None
return {
"key": best[2],
"value": best[3],
"distance": best[0],
"score": best[1],
}
def segment_lexicon_lookup(
inp: str,
lexicon: dict,
lexicon_lengths: list[int] | None = None,
*,
min_segments: int = 2,
max_segments: int = 8,
):
if not lexicon or len(inp) < 16:
return None
lengths = lexicon_lengths or build_lexicon_lengths(lexicon)
if not lengths:
return None
n = len(inp)
dp = [None] * (n + 1)
dp[n] = (0, {""})
for i in range(n - 1, -1, -1):
best_score = None
best_outputs = set()
for length in lengths:
j = i + length
if j > n:
continue
piece = inp[i:j]
value = lexicon.get(piece)
if value is None or dp[j] is None:
continue
tail_segments, tail_outputs = dp[j]
segments = tail_segments + 1
if segments > max_segments:
continue
score = (segments, -length)
outputs = {value + tail for tail in tail_outputs}
if best_score is None or score < best_score:
best_score = score
best_outputs = outputs
elif score == best_score:
best_outputs.update(outputs)
if len(best_outputs) > 1:
best_outputs = set(list(best_outputs)[:2])
if best_score is not None:
dp[i] = (best_score[0], best_outputs)
if dp[0] is None:
return None
segments, outputs = dp[0]
if segments < min_segments or len(outputs) != 1:
return None
return next(iter(outputs))
def has_exact_subpiece(inp: str, lexicon: dict, lexicon_lengths: list[int] | None = None, min_len: int = 10) -> bool:
if not lexicon or len(inp) < min_len:
return False
lengths = lexicon_lengths or build_lexicon_lengths(lexicon)
n = len(inp)
for i in range(n - min_len + 1):
for length in lengths:
if length < min_len:
continue
j = i + length
if j > n:
continue
if inp[i:j] in lexicon:
return True
return False
def fuzzy_segment_lexicon_lookup(
inp: str,
lexicon: dict,
lexicon_by_len: dict | None = None,
lexicon_gram_index: dict | None = None,
lexicon_lengths: list[int] | None = None,
*,
max_delta: int = 7,
max_splits: int = FUZZY_SEGMENT_MAX_SPLITS,
):
if not lexicon or len(inp) < 16:
return None
lengths = lexicon_lengths or build_lexicon_lengths(lexicon)
if not lengths:
return None
n = len(inp)
split_candidates = set()
min_len = min(lengths)
max_len = max(lengths)
for length in lengths:
for delta in range(-max_delta, max_delta + 1):
split = length + delta
if min_len <= split <= n - min_len:
right_len = n - split
if min_len - max_delta <= right_len <= max_len + max_delta:
split_candidates.add(split)
def overlap_score(piece: str) -> float:
if piece in lexicon:
return 10.0
if lexicon_gram_index is None:
return 0.0
grams = char_grams(piece)
if not grams:
return 0.0
counts = Counter()
allowed = set(range(len(piece) - max_delta, len(piece) + max_delta + 1))
for gram in grams:
for key, _ in lexicon_gram_index.get(gram, ()):
if len(key) in allowed:
counts[key] += 1
if not counts:
return 0.0
return counts.most_common(1)[0][1] / max(1, len(grams))
if len(split_candidates) > max_splits:
split_candidates = {
split
for split, _ in sorted(
(
(split, overlap_score(inp[:split]) + overlap_score(inp[split:]))
for split in split_candidates
),
key=lambda item: (-item[1], abs(item[0] - n / 2), item[0]),
)[:max_splits]
}
best_score = None
best_outputs = set()
match_cache = {}
def piece_match(piece: str):
if piece in lexicon:
return {"key": piece, "value": lexicon[piece], "distance": 0, "score": 0.0, "exact": True}
if piece not in match_cache:
match = fuzzy_lexicon_match(piece, lexicon, lexicon_by_len, lexicon_gram_index)
if match is None:
match = weighted_fuzzy_lexicon_match(piece, lexicon, lexicon_by_len, lexicon_gram_index)
match_cache[piece] = match
match = match_cache[piece]
if match is None:
return None
return {**match, "exact": False}
for split in sorted(split_candidates):
left = piece_match(inp[:split])
if left is None:
continue
right = piece_match(inp[split:])
if right is None:
continue
fuzzy_count = int(not left["exact"]) + int(not right["exact"])
if fuzzy_count == 0:
continue
dist = left["distance"] + right["distance"]
score = left["score"] + right["score"]
# Prefer exact+fuzzy repairs, then lower edit cost, then cleaner scores.
cand_score = (fuzzy_count, dist, round(score, 6), abs(split - n / 2))
output = left["value"] + right["value"]
if best_score is None or cand_score < best_score:
best_score = cand_score
best_outputs = {output}
elif cand_score == best_score:
best_outputs.add(output)
if len(best_outputs) > 1:
best_outputs = set(list(best_outputs)[:2])
if best_score is None or len(best_outputs) != 1:
return None
# Avoid very loose two-sided repairs; whole-model fallback is better there.
if best_score[0] == 2 and best_score[1] > 10:
return None
# Long two-piece repairs can accidentally swallow multiple intended phrases
# into one noisy lexicon alias. Prefer multi-segment repair or model fallback.
if len(inp) >= 40 and best_score[0] == 1 and best_score[2] > LONG_FUZZY_SEGMENT_MAX_SCORE:
return None
return next(iter(best_outputs))
def anchored_fuzzy_segment_lexicon_lookup(
inp: str,
lexicon: dict,
lexicon_lengths: list[int] | None,
fuzzy_lexicon: dict,
fuzzy_lexicon_by_len: dict | None = None,
fuzzy_lexicon_gram_index: dict | None = None,
*,
min_anchor_len: int = ANCHORED_FUZZY_MIN_ANCHOR_LEN,
):
if not lexicon or not fuzzy_lexicon or len(inp) < 32:
return None
lengths = lexicon_lengths or build_lexicon_lengths(lexicon)
if not lengths:
return None
def acceptable(match, piece: str):
if match is None:
return None
if match["score"] > 0.32 or match["distance"] > max(5, len(piece) * 0.28):
return None
return match
def fuzzy_piece_match(piece: str):
if len(piece) < 5:
return None
match = acceptable(fuzzy_lexicon_match(piece, fuzzy_lexicon, fuzzy_lexicon_by_len, fuzzy_lexicon_gram_index), piece)
if match is None:
match = acceptable(weighted_fuzzy_lexicon_match(piece, fuzzy_lexicon, fuzzy_lexicon_by_len, fuzzy_lexicon_gram_index), piece)
return match
candidates = []
for length in lengths:
if length < min_anchor_len or length >= len(inp) - 5:
continue
prefix = inp[:length]
prefix_value = lexicon.get(prefix)
if prefix_value is not None:
rem = inp[length:]
match = fuzzy_piece_match(rem)
if match is not None:
candidates.append((match["distance"], match["score"], -length, prefix_value + match["value"]))
suffix = inp[-length:]
suffix_value = lexicon.get(suffix)
if suffix_value is not None:
rem = inp[:-length]
match = fuzzy_piece_match(rem)
if match is not None:
candidates.append((match["distance"], match["score"], -length, match["value"] + suffix_value))
if not candidates:
return None
ranked = sorted(candidates)
if len(ranked) > 1 and ranked[0][:3] == ranked[1][:3] and ranked[0][3] != ranked[1][3]:
return None
return ranked[0][3]
def sandwich_fuzzy_segment_lexicon_lookup(
inp: str,
lexicon: dict,
lexicon_lengths: list[int] | None,
fuzzy_lexicon: dict,
fuzzy_lexicon_by_len: dict | None = None,
fuzzy_lexicon_gram_index: dict | None = None,
full_lexicon_by_len: dict | None = None,
full_lexicon_gram_index: dict | None = None,
*,
min_anchor_len: int = ANCHORED_FUZZY_MIN_ANCHOR_LEN,
min_middle_len: int = SANDWICH_FUZZY_MIN_MIDDLE_LEN,
):
if not lexicon or not fuzzy_lexicon or len(inp) < 40:
return None
lengths = lexicon_lengths or build_lexicon_lengths(lexicon)
if not lengths:
return None
prefix_hits = []
suffix_hits = []
n = len(inp)
for length in lengths:
if length < min_anchor_len or length > n - min_middle_len:
continue
prefix_value = lexicon.get(inp[:length])
if prefix_value is not None:
prefix_hits.append((length, prefix_value))
suffix_value = lexicon.get(inp[n - length :])
if suffix_value is not None:
suffix_hits.append((n - length, suffix_value, length))
if not prefix_hits or not suffix_hits:
return None
match_cache = {}
def acceptable(match, piece: str):
if match is None:
return None
if match["score"] > 0.36 or match["distance"] > max(7, len(piece) * 0.34):
return None
return match
def middle_match(piece: str):
if piece in match_cache:
return match_cache[piece]
match = acceptable(fuzzy_lexicon_match(piece, fuzzy_lexicon, fuzzy_lexicon_by_len, fuzzy_lexicon_gram_index), piece)
if match is None:
match = acceptable(weighted_fuzzy_lexicon_match(piece, fuzzy_lexicon, fuzzy_lexicon_by_len, fuzzy_lexicon_gram_index), piece)
if match is None:
match = acceptable(fuzzy_lexicon_match(piece, lexicon, full_lexicon_by_len, full_lexicon_gram_index), piece)
if match is None:
match = acceptable(weighted_fuzzy_lexicon_match(piece, lexicon, full_lexicon_by_len, full_lexicon_gram_index), piece)
match_cache[piece] = match
return match
candidates = []
for prefix_end, prefix_value in prefix_hits:
for suffix_start, suffix_value, suffix_len in suffix_hits:
if suffix_start - prefix_end < min_middle_len:
continue
middle = inp[prefix_end:suffix_start]
if middle in lexicon:
continue
match = middle_match(middle)
if match is None:
continue
anchor_len = prefix_end + suffix_len
candidates.append((
match["distance"],
round(match["score"], 6),
-anchor_len,
abs((suffix_start - prefix_end) - n / 3),
prefix_value + match["value"] + suffix_value,
))
if not candidates:
return None
ranked = sorted(candidates)
if len(ranked) > 1 and ranked[0][:4] == ranked[1][:4] and ranked[0][4] != ranked[1][4]:
return None
return ranked[0][4]
def fuzzy_multi_segment_lexicon_lookup(
inp: str,
lexicon: dict,
lexicon_lengths: list[int] | None,
fuzzy_lexicon: dict,
fuzzy_lexicon_by_len: dict | None = None,
fuzzy_lexicon_gram_index: dict | None = None,
fuzzy_lexicon_lengths: list[int] | None = None,
full_lexicon_by_len: dict | None = None,
full_lexicon_gram_index: dict | None = None,
*,
min_segments: int = 3,
max_segments: int = 8,
max_fuzzy_segments: int = 2,
max_fuzzy_transitions: int = 6,
beam_width: int = 12,
max_fuzzy_score: float = MULTI_SEGMENT_FUZZY_MAX_SCORE,
piece_probe_limit: int = MULTI_SEGMENT_FUZZY_PIECE_PROBE_LIMIT,
prefer_short_pieces: bool = False,
):
if not lexicon or not fuzzy_lexicon or len(inp) < 24:
return None
exact_lengths = lexicon_lengths or build_lexicon_lengths(lexicon)
fuzzy_lengths = fuzzy_lexicon_lengths or build_lexicon_lengths(fuzzy_lexicon)
if not exact_lengths or not fuzzy_lengths:
return None
n = len(inp)
match_cache = {}
overlap_cache = {}
def fuzzy_piece_overlap(piece: str) -> float:
if piece in overlap_cache:
return overlap_cache[piece]
if fuzzy_lexicon_gram_index is None:
overlap_cache[piece] = 0.0
return 0.0
grams = char_grams(piece)
if not grams:
overlap_cache[piece] = 0.0
return 0.0
counts = Counter()
allowed = set(range(len(piece) - 7, len(piece) + 8))
for gram in grams:
for key, _ in fuzzy_lexicon_gram_index.get(gram, ()):
if len(key) in allowed:
counts[key] += 1
score = counts.most_common(1)[0][1] / max(1, len(grams)) if counts else 0.0
overlap_cache[piece] = score
return score
def fuzzy_piece_match(piece: str):
if piece in match_cache:
return match_cache[piece]
def shared_prefix_len(a: str, b: str) -> int:
count = 0
for ca, cb in zip(a, b):
if ca != cb:
break
count += 1
return count
def acceptable(match):
if match is None:
return None
# Multi-segment repair is powerful; keep it conservative so a loose
# fuzzy chunk does not swallow multiple intended phrases.
if match["score"] > max_fuzzy_score or match["distance"] > max(5, len(piece) * 0.28):
return None
return match
compact_plain = acceptable(fuzzy_lexicon_match(piece, fuzzy_lexicon, fuzzy_lexicon_by_len, fuzzy_lexicon_gram_index))
full_plain = acceptable(fuzzy_lexicon_match(piece, lexicon, full_lexicon_by_len, full_lexicon_gram_index))
if (
full_plain is not None
and full_plain["distance"] <= 2
and full_plain["score"] <= 0.16
and shared_prefix_len(piece, full_plain["key"]) >= min(2, len(piece), len(full_plain["key"]))
):
preferred = dict(full_plain)
preferred["score"] = min(preferred["score"], preferred["distance"] * 0.02)
match_cache[piece] = preferred
return preferred
candidates = [match for match in (compact_plain, full_plain) if match is not None]
compact_weighted = acceptable(weighted_fuzzy_lexicon_match(piece, fuzzy_lexicon, fuzzy_lexicon_by_len, fuzzy_lexicon_gram_index))
full_weighted = acceptable(weighted_fuzzy_lexicon_match(piece, lexicon, full_lexicon_by_len, full_lexicon_gram_index))
candidates.extend(match for match in (compact_weighted, full_weighted) if match is not None)
match = None
if candidates:
candidates.sort(key=lambda item: (round(item["score"], 6), item["distance"], len(item["key"])))
match = candidates[0]
match_cache[piece] = match
return match
min_fuzzy_len = max(5, min(fuzzy_lengths))
def prune(items):
best_by_output = {}
for item in items:
key = item[3]
score = item[:3]
if key not in best_by_output or score < best_by_output[key][:3]:
best_by_output[key] = item
return sorted(best_by_output.values(), key=lambda item: (item[0], item[1], item[2], len(item[3])))[:beam_width]
@lru_cache(maxsize=None)
def solve_exact(pos: int):
if pos == n:
return ((0, 0.0, 0, ""),)
exact_results = []
for length in exact_lengths:
j = pos + length
if j > n:
continue
value = lexicon.get(inp[pos:j])
if value is None:
continue
for rest_fuzzy, rest_cost, rest_segments, rest_output in solve_exact(j):
segments = rest_segments + 1
if segments <= max_segments:
exact_results.append((rest_fuzzy, rest_cost, segments, value + rest_output))
return tuple(prune(exact_results))
@lru_cache(maxsize=None)
def solve(pos: int, fuzzy_left: int):
if pos == n:
return ((0, 0.0, 0, ""),)
exact_results = []
for length in exact_lengths:
j = pos + length
if j > n:
continue
value = lexicon.get(inp[pos:j])
if value is None:
continue
for rest_fuzzy, rest_cost, rest_segments, rest_output in solve(j, fuzzy_left):
segments = rest_segments + 1
if segments <= max_segments:
exact_results.append((rest_fuzzy, rest_cost, segments, value + rest_output))
# If exact segmentation can continue to the end, keep it. This makes the
# expensive fuzzy branch run only at the first position where exact
# segmentation gets stuck, which is the common long-input typo shape.
if exact_results:
return tuple(prune(exact_results))
if fuzzy_left <= 0:
return ()
candidate_pieces = []
for length in fuzzy_lengths:
if length < min_fuzzy_len:
continue
j = pos + length
if j > n:
continue
piece = inp[pos:j]
if piece in lexicon:
continue
overlap = fuzzy_piece_overlap(piece)
if overlap <= 0.0:
continue
if prefer_short_pieces:
tail_exact_penalty = 0 if solve_exact(j) else 1
candidate_pieces.append((
tail_exact_penalty,
-overlap,
length,
abs((n - j) - (n / 2)),
j,
piece,
))
else:
candidate_pieces.append((-overlap, abs((n - j) - (n / 2)), length, j, piece))
candidate_pieces = sorted(candidate_pieces)[:piece_probe_limit]
fuzzy_transitions = []
for candidate in candidate_pieces:
j, piece = candidate[-2], candidate[-1]
tail_states = solve_exact(j)
if not tail_states and fuzzy_left > 1:
tail_states = solve(j, fuzzy_left - 1)
if not tail_states:
continue
match = fuzzy_piece_match(piece)
if match is None:
continue
cost = match["score"] + match["distance"] * 0.01
fuzzy_transitions.append((cost, j, match, tail_states))
fuzzy_results = []
for cost, j, match, tail_states in sorted(
fuzzy_transitions,
key=lambda item: (item[0], abs((n - item[1]) - (n / 2))),
)[:max_fuzzy_transitions]:
for rest_fuzzy, rest_cost, rest_segments, rest_output in tail_states:
segments = rest_segments + 1
if segments <= max_segments:
fuzzy_results.append((
rest_fuzzy + 1,
rest_cost + cost,
segments,
match["value"] + rest_output,
))
return tuple(prune(fuzzy_results))
finals = [state for state in solve(0, max_fuzzy_segments) if state[2] >= min_segments and state[0] > 0]
if not finals:
return None
best_by_output = {}
for item in finals:
key = item[3]
score = item[:3]
if key not in best_by_output or score < best_by_output[key][:3]:
best_by_output[key] = item
ranked = sorted(best_by_output.values(), key=lambda item: (item[0], item[1], item[2], len(item[3])))
if not ranked:
return None
if len(ranked) > 1 and ranked[0][:3] == ranked[1][:3] and ranked[0][3] != ranked[1][3]:
return None
return ranked[0][3]
def wide_beam_multi_segment_lexicon_lookup(
inp: str,
lexicon: dict,
lexicon_lengths: list[int] | None,
fuzzy_lexicon: dict,
fuzzy_lexicon_by_len: dict | None = None,
fuzzy_lexicon_gram_index: dict | None = None,
fuzzy_lexicon_lengths: list[int] | None = None,
full_lexicon_by_len: dict | None = None,
full_lexicon_gram_index: dict | None = None,
*,
min_segments: int = 3,
max_segments: int = LONG_SEGMENT_MAX_SEGMENTS,
max_fuzzy_segments: int = WIDE_MULTI_SEGMENT_MAX_FUZZY_SEGMENTS,
beam_width: int = 32,
piece_probe_limit: int = 18,
max_fuzzy_score: float = RELAXED_MULTI_SEGMENT_FUZZY_MAX_SCORE,
plain_accept_score: float | None = WIDE_MULTI_SEGMENT_PLAIN_ACCEPT_SCORE,
use_compact_weighted: bool = True,
weighted_candidate_limit: int | None = None,
):
if not lexicon or not fuzzy_lexicon or len(inp) < WIDE_MULTI_SEGMENT_MIN_LEN:
return None
exact_lengths = lexicon_lengths or build_lexicon_lengths(lexicon)
fuzzy_lengths = fuzzy_lexicon_lengths or build_lexicon_lengths(fuzzy_lexicon)
if not exact_lengths or not fuzzy_lengths:
return None
n = len(inp)
min_fuzzy_len = max(5, min(fuzzy_lengths))
match_cache = {}
overlap_cache = {}
def shared_prefix_len(a: str, b: str) -> int:
count = 0
for ca, cb in zip(a, b):
if ca != cb:
break
count += 1
return count
def fuzzy_piece_overlap(piece: str) -> float:
if piece in overlap_cache:
return overlap_cache[piece]
if fuzzy_lexicon_gram_index is None:
overlap_cache[piece] = 0.0
return 0.0
grams = char_grams(piece)
if not grams:
overlap_cache[piece] = 0.0
return 0.0
counts = Counter()
allowed = set(range(len(piece) - 7, len(piece) + 8))
for gram in grams:
for key, _ in fuzzy_lexicon_gram_index.get(gram, ()):
if len(key) in allowed:
counts[key] += 1
score = counts.most_common(1)[0][1] / max(1, len(grams)) if counts else 0.0
overlap_cache[piece] = score
return score
def fuzzy_piece_match(piece: str):
if piece in match_cache:
return match_cache[piece]
def acceptable(match):
if match is None:
return None
if match["score"] > max_fuzzy_score or match["distance"] > max(5, len(piece) * 0.28):
return None
if len(match["value"]) < max(5, int(len(piece) * WIDE_MULTI_SEGMENT_MIN_OUTPUT_RATIO)):
return None
return match
compact_plain = acceptable(fuzzy_lexicon_match(piece, fuzzy_lexicon, fuzzy_lexicon_by_len, fuzzy_lexicon_gram_index))
full_plain = acceptable(fuzzy_lexicon_match(piece, lexicon, full_lexicon_by_len, full_lexicon_gram_index))
if (
full_plain is not None
and full_plain["distance"] <= 2
and full_plain["score"] <= 0.16
and shared_prefix_len(piece, full_plain["key"]) >= min(2, len(piece), len(full_plain["key"]))
):
preferred = dict(full_plain)
preferred["score"] = min(preferred["score"], preferred["distance"] * 0.02)
match_cache[piece] = preferred
return preferred
candidates = [match for match in (compact_plain, full_plain) if match is not None]
if candidates:
candidates.sort(key=lambda item: (round(item["score"], 6), item["distance"], len(item["key"])))
if plain_accept_score is not None and candidates[0]["score"] <= plain_accept_score:
match_cache[piece] = candidates[0]
return candidates[0]
compact_weighted = None
if use_compact_weighted:
compact_weighted = acceptable(weighted_fuzzy_lexicon_match(
piece,
fuzzy_lexicon,
fuzzy_lexicon_by_len,
fuzzy_lexicon_gram_index,
candidate_limit=weighted_candidate_limit,
))
full_weighted = acceptable(weighted_fuzzy_lexicon_match(
piece,
lexicon,
full_lexicon_by_len,
full_lexicon_gram_index,
candidate_limit=weighted_candidate_limit,
))
candidates.extend(match for match in (compact_weighted, full_weighted) if match is not None)
match = None
if candidates:
candidates.sort(key=lambda item: (round(item["score"], 6), item["distance"], len(item["key"])))
match = candidates[0]
match_cache[piece] = match
return match
def prune_states(items):
best_by_position_output = {}
for item in items:
fuzzy_count, cost, segments, pos, output = item
key = (pos, output)
score = (fuzzy_count, -segments, round(cost, 6))
if key not in best_by_position_output or score < best_by_position_output[key][0]:
best_by_position_output[key] = (score, (fuzzy_count, cost, segments, pos, output))
return sorted(
(item for _, item in best_by_position_output.values()),
key=lambda item: (item[0], -item[2], round(item[1], 6), -item[3], len(item[4])),
)[:beam_width]
states = [(0, 0.0, 0, 0, "")]
finals = []
for _ in range(max_segments):
next_states = []
for fuzzy_count, cost, segments, pos, output in states:
if pos == n:
if segments >= min_segments and fuzzy_count > 0:
finals.append((fuzzy_count, cost, segments, output))
continue
exact_transitions = []
for length in exact_lengths:
j = pos + length
if j > n:
continue
value = lexicon.get(inp[pos:j])
if value is None:
continue
exact_transitions.append((fuzzy_count, cost, segments + 1, j, output + value))
next_states.extend(exact_transitions)
if fuzzy_count >= max_fuzzy_segments:
continue
piece_candidates = []
for length in boundary_fuzzy_lengths(
fuzzy_lengths,
exact_lengths,
n - pos,
min_len=min_fuzzy_len,
delta=WIDE_MULTI_SEGMENT_BOUNDARY_LENGTH_DELTA,
):
if length < min_fuzzy_len:
continue
j = pos + length
if j > n:
continue
piece = inp[pos:j]
if piece in lexicon:
continue
overlap = fuzzy_piece_overlap(piece)
if overlap <= 0.0:
continue
piece_candidates.append((-overlap, length, abs((n - j) - (n / 2)), j, piece))
for _, _, _, j, piece in sorted(piece_candidates)[:piece_probe_limit]:
match = fuzzy_piece_match(piece)
if match is None:
continue
match_cost = match["score"] + match["distance"] * 0.01
next_states.append((
fuzzy_count + 1,
cost + match_cost,
segments + 1,
j,
output + match["value"],
))
states = prune_states(next_states)
if not states:
break
for fuzzy_count, cost, segments, pos, output in states:
if pos == n and segments >= min_segments and fuzzy_count > 0:
finals.append((fuzzy_count, cost, segments, output))
if not finals:
return None
best_by_output = {}
for item in finals:
key = item[3]
score = item[:3]
if key not in best_by_output or score < best_by_output[key][:3]:
best_by_output[key] = item
ranked = sorted(best_by_output.values(), key=lambda item: (item[0], -item[2], round(item[1], 6), len(item[3])))
if len(ranked) > 1 and ranked[0][:3] == ranked[1][:3] and ranked[0][3] != ranked[1][3]:
return None
return ranked[0][3]
def single_fuzzy_long_segment_lexicon_lookup(
inp: str,
lexicon: dict,
lexicon_lengths: list[int] | None,
fuzzy_lexicon: dict,
fuzzy_lexicon_by_len: dict | None = None,
fuzzy_lexicon_gram_index: dict | None = None,
fuzzy_lexicon_lengths: list[int] | None = None,
full_lexicon_by_len: dict | None = None,
full_lexicon_gram_index: dict | None = None,
*,
min_segments: int = 3,
max_segments: int = LONG_SEGMENT_MAX_SEGMENTS,
max_fuzzy_score: float = RELAXED_MULTI_SEGMENT_FUZZY_MAX_SCORE,
plain_accept_score: float | None = None,
use_compact_weighted: bool = True,
weighted_candidate_limit: int | None = None,
):
if not lexicon or not fuzzy_lexicon or len(inp) < WIDE_MULTI_SEGMENT_MIN_LEN:
return None
exact_lengths = lexicon_lengths or build_lexicon_lengths(lexicon)
fuzzy_lengths = fuzzy_lexicon_lengths or build_lexicon_lengths(fuzzy_lexicon)
if not exact_lengths or not fuzzy_lengths:
return None
n = len(inp)
min_fuzzy_len = max(5, min(fuzzy_lengths))
match_cache = {}
def shared_prefix_len(a: str, b: str) -> int:
count = 0
for ca, cb in zip(a, b):
if ca != cb:
break
count += 1
return count
def acceptable(match, piece: str):
if match is None:
return None
if match["score"] > max_fuzzy_score or match["distance"] > max(5, len(piece) * 0.28):
return None
if len(match["value"]) < max(5, int(len(piece) * WIDE_MULTI_SEGMENT_MIN_OUTPUT_RATIO)):
return None
return match
def fuzzy_piece_match(piece: str):
if piece in match_cache:
return match_cache[piece]
compact_plain = acceptable(fuzzy_lexicon_match(piece, fuzzy_lexicon, fuzzy_lexicon_by_len, fuzzy_lexicon_gram_index), piece)
full_plain = acceptable(fuzzy_lexicon_match(piece, lexicon, full_lexicon_by_len, full_lexicon_gram_index), piece)
if (
full_plain is not None
and full_plain["distance"] <= 2
and full_plain["score"] <= 0.16
and shared_prefix_len(piece, full_plain["key"]) >= min(2, len(piece), len(full_plain["key"]))
):
preferred = dict(full_plain)
preferred["score"] = min(preferred["score"], preferred["distance"] * 0.02)
match_cache[piece] = preferred
return preferred
candidates = [match for match in (compact_plain, full_plain) if match is not None]
if plain_accept_score is not None and candidates:
candidates.sort(key=lambda item: (round(item["score"], 6), item["distance"], len(item["key"])))
if candidates[0]["score"] <= plain_accept_score:
match_cache[piece] = candidates[0]
return candidates[0]
compact_weighted = None
if use_compact_weighted:
compact_weighted = acceptable(weighted_fuzzy_lexicon_match(
piece,
fuzzy_lexicon,
fuzzy_lexicon_by_len,
fuzzy_lexicon_gram_index,
candidate_limit=weighted_candidate_limit,
), piece)
full_weighted = acceptable(weighted_fuzzy_lexicon_match(
piece,
lexicon,
full_lexicon_by_len,
full_lexicon_gram_index,
candidate_limit=weighted_candidate_limit,
), piece)
candidates.extend(match for match in (compact_weighted, full_weighted) if match is not None)
match = None
if candidates:
candidates.sort(key=lambda item: (round(item["score"], 6), item["distance"], len(item["key"])))
match = candidates[0]
match_cache[piece] = match
return match
@lru_cache(maxsize=None)
def exact_suffix(pos: int):
if pos == n:
return ((0, ""),)
results = []
for length in exact_lengths:
j = pos + length
if j > n:
continue
value = lexicon.get(inp[pos:j])
if value is None:
continue
for rest_segments, rest_output in exact_suffix(j):
segments = rest_segments + 1
if segments <= max_segments:
results.append((segments, value + rest_output))
if not results:
return ()
best_by_output = {}
for item in results:
output = item[1]
if output not in best_by_output or item[0] > best_by_output[output][0]:
best_by_output[output] = item
return tuple(sorted(best_by_output.values(), key=lambda item: (-item[0], len(item[1])))[:8])
prefix_states = [(0, 0, "")]
seen_prefix = {(0, 0, "")}
candidates = []
for _ in range(max_segments):
next_prefix = []
for pos, prefix_segments, prefix_output in prefix_states:
if pos >= n:
continue
for length in boundary_fuzzy_lengths(fuzzy_lengths, exact_lengths, n - pos, min_len=min_fuzzy_len):
if length < min_fuzzy_len:
continue
j = pos + length
if j > n:
continue
piece = inp[pos:j]
if piece in lexicon:
continue
suffixes = exact_suffix(j)
if not suffixes:
continue
match = fuzzy_piece_match(piece)
if match is None:
continue
for suffix_segments, suffix_output in suffixes:
segments = prefix_segments + 1 + suffix_segments
if segments < min_segments or segments > max_segments:
continue
cost = match["score"] + match["distance"] * 0.01
candidates.append((cost, -segments, prefix_output + match["value"] + suffix_output))
for length in exact_lengths:
j = pos + length
if j > n:
continue
value = lexicon.get(inp[pos:j])
if value is None:
continue
state = (j, prefix_segments + 1, prefix_output + value)
if state[1] <= max_segments and state not in seen_prefix:
seen_prefix.add(state)
next_prefix.append(state)
if candidates:
break
prefix_states = sorted(next_prefix, key=lambda item: (-item[0], -item[1], len(item[2])))[:32]
if not prefix_states:
break
if not candidates:
return None
best_by_output = {}
for item in candidates:
output = item[2]
if output not in best_by_output or item[:2] < best_by_output[output][:2]:
best_by_output[output] = item
ranked = sorted(best_by_output.values())
if len(ranked) > 1 and ranked[0][:2] == ranked[1][:2] and ranked[0][2] != ranked[1][2]:
return None
return ranked[0][2]
def dense_overflow_segment_lexicon_lookup(
inp: str,
lexicon: dict,
lexicon_lengths: list[int] | None,
fuzzy_lexicon: dict,
fuzzy_lexicon_by_len: dict | None = None,
fuzzy_lexicon_gram_index: dict | None = None,
full_lexicon_by_len: dict | None = None,
full_lexicon_gram_index: dict | None = None,
*,
min_segments: int = DENSE_OVERFLOW_MIN_SEGMENTS,
max_segments: int = DENSE_OVERFLOW_MAX_SEGMENTS,
max_fuzzy_segments: int = DENSE_OVERFLOW_MAX_FUZZY_SEGMENTS,
beam_width: int = DENSE_OVERFLOW_BEAM_WIDTH,
max_fuzzy_score: float = DENSE_OVERFLOW_MAX_SCORE,
allow_full_rescue: bool = False,
max_full_rescue_segments: int = DENSE_OVERFLOW_MAX_FULL_RESCUE_SEGMENTS,
use_weighted_piece: bool = False,
weighted_candidate_limit: int = DENSE_OVERFLOW_WEIGHTED_CANDIDATE_LIMIT,
validate_weighted_with_full: bool = False,
weighted_validation_score: float = DENSE_OVERFLOW_FAST_WEIGHTED_VALIDATE_SCORE,
weighted_validation_margin: float = DENSE_OVERFLOW_FAST_WEIGHTED_VALIDATE_MARGIN,
weighted_validation_candidate_limit: int = DENSE_OVERFLOW_FAST_WEIGHTED_VALIDATE_CANDIDATE_LIMIT,
):
if not lexicon or not fuzzy_lexicon or len(inp) < DENSE_OVERFLOW_MIN_LEN:
return None
exact_lengths = lexicon_lengths or build_lexicon_lengths(lexicon)
if not exact_lengths:
return None
n = len(inp)
match_cache = {}
full_match_cache = {}
def acceptable(match, piece: str):
if match is None:
return None
if match["score"] > max_fuzzy_score:
return None
if match["distance"] > max(5, len(piece) * DENSE_OVERFLOW_MAX_DISTANCE_RATIO):
return None
if len(match["value"]) < max(2, int(len(piece) * WIDE_MULTI_SEGMENT_MIN_OUTPUT_RATIO)):
return None
return match
def fuzzy_piece_match(piece: str):
if piece in match_cache:
return match_cache[piece]
match = acceptable(
fuzzy_lexicon_match(piece, fuzzy_lexicon, fuzzy_lexicon_by_len, fuzzy_lexicon_gram_index),
piece,
)
if match is None and use_weighted_piece:
match = acceptable(
weighted_fuzzy_lexicon_match(
piece,
fuzzy_lexicon,
fuzzy_lexicon_by_len,
fuzzy_lexicon_gram_index,
candidate_limit=weighted_candidate_limit,
),
piece,
)
if (
validate_weighted_with_full
and match is not None
and match["score"] >= weighted_validation_score
and full_lexicon_by_len is not None
and full_lexicon_gram_index is not None
):
full_match = acceptable(
weighted_fuzzy_lexicon_match(
piece,
lexicon,
full_lexicon_by_len,
full_lexicon_gram_index,
candidate_limit=weighted_validation_candidate_limit,
),
piece,
)
if full_match is not None and full_match["score"] + weighted_validation_margin < match["score"]:
match = full_match
match_cache[piece] = match
return match
def full_piece_match(piece: str):
if piece in full_match_cache:
return full_match_cache[piece]
match = acceptable(
fuzzy_lexicon_match(piece, lexicon, full_lexicon_by_len, full_lexicon_gram_index),
piece,
)
if match is None and use_weighted_piece:
match = acceptable(
weighted_fuzzy_lexicon_match(
piece,
lexicon,
full_lexicon_by_len,
full_lexicon_gram_index,
candidate_limit=weighted_candidate_limit,
),
piece,
)
full_match_cache[piece] = match
return match
states = [(0, 0.0, 0, 0, 0, "")]
finals = []
for _ in range(max_segments):
next_states = []
for pos, cost, fuzzy_count, full_count, segments, output in states:
if pos == n:
finals.append((cost, fuzzy_count, full_count, segments, output))
continue
remaining = n - pos
local_states = []
full_rescue_pieces = []
for length in exact_lengths:
if length > remaining:
continue
j = pos + length
piece = inp[pos:j]
value = lexicon.get(piece)
if value is not None:
local_states.append((j, cost, fuzzy_count, full_count, segments + 1, output + value))
continue
if fuzzy_count >= max_fuzzy_segments:
continue
match = fuzzy_piece_match(piece)
if match is not None:
piece_cost = match["score"] + match["distance"] * 0.01 + DENSE_OVERFLOW_FUZZY_COST
local_states.append((
j,
cost + piece_cost,
fuzzy_count + 1,
full_count,
segments + 1,
output + match["value"],
))
elif allow_full_rescue and full_count < max_full_rescue_segments:
full_rescue_pieces.append((j, piece))
if local_states:
next_states.extend(local_states)
continue
if not allow_full_rescue or full_count >= max_full_rescue_segments:
continue
for j, piece in full_rescue_pieces:
match = full_piece_match(piece)
if match is None:
continue
piece_cost = match["score"] + match["distance"] * 0.01 + DENSE_OVERFLOW_FUZZY_COST
next_states.append((
j,
cost + piece_cost + DENSE_OVERFLOW_FULL_RESCUE_COST,
fuzzy_count + 1,
full_count + 1,
segments + 1,
output + match["value"],
))
if not next_states:
break
best_by_position_output = {}
for item in next_states:
key = (item[0], item[5])
score = (round(item[1], 6), item[2], item[3], item[4])
if key not in best_by_position_output or score < best_by_position_output[key][0]:
best_by_position_output[key] = (score, item)
states = sorted(
(item for _, item in best_by_position_output.values()),
key=lambda item: (round(item[1], 6), item[3], item[2], -item[4], -item[0], len(item[5])),
)[:beam_width]
for item in states:
if item[0] == n:
finals.append((item[1], item[2], item[3], item[4], item[5]))
finals = [item for item in finals if item[3] >= min_segments]
if not finals:
return None
best_by_output = {}
for item in finals:
key = item[4]
score = item[:4]
if key not in best_by_output or score < best_by_output[key][:4]:
best_by_output[key] = item
ranked = sorted(
best_by_output.values(),
key=lambda item: (round(item[0], 6), item[2], item[1], -item[3], len(item[4])),
)
if len(ranked) > 1:
first = (round(ranked[0][0], 6), ranked[0][1], ranked[0][2], ranked[0][3])
second = (round(ranked[1][0], 6), ranked[1][1], ranked[1][2], ranked[1][3])
if first == second and ranked[0][4] != ranked[1][4]:
return None
return ranked[0][4]
def dense_overflow_short_viterbi_rescue_lookup(
inp: str,
lexicon: dict,
lexicon_lengths: list[int] | None,
fuzzy_lexicon: dict,
fuzzy_lexicon_by_len: dict | None = None,
fuzzy_lexicon_gram_index: dict | None = None,
full_lexicon_by_len: dict | None = None,
full_lexicon_gram_index: dict | None = None,
*,
min_segments: int = DENSE_OVERFLOW_MIN_SEGMENTS,
max_segments: int = DENSE_OVERFLOW_SHORT_VITERBI_MAX_SEGMENTS,
max_score: float = DENSE_OVERFLOW_SHORT_VITERBI_MAX_SCORE,
max_distance_ratio: float = DENSE_OVERFLOW_SHORT_VITERBI_MAX_DISTANCE_RATIO,
weighted_candidate_limit: int = DENSE_OVERFLOW_SHORT_VITERBI_WEIGHTED_CANDIDATE_LIMIT,
position_beam: int = DENSE_OVERFLOW_SHORT_VITERBI_POSITION_BEAM,
max_fuzzy_segments: int = DENSE_OVERFLOW_SHORT_VITERBI_MAX_FUZZY_SEGMENTS,
max_cost_per_segment: float = DENSE_OVERFLOW_SHORT_VITERBI_MAX_COST_PER_SEGMENT,
max_fuzzy_ratio: float = DENSE_OVERFLOW_SHORT_VITERBI_MAX_FUZZY_RATIO,
validate_with_full: bool = False,
full_rerank_score: float = DENSE_OVERFLOW_SHORT_VITERBI_FULL_RERANK_SCORE,
full_rerank_margin: float = DENSE_OVERFLOW_SHORT_VITERBI_FULL_RERANK_MARGIN,
full_rerank_candidate_limit: int = DENSE_OVERFLOW_SHORT_VITERBI_FULL_RERANK_CANDIDATE_LIMIT,
full_rerank_min_distance: int = DENSE_OVERFLOW_SHORT_VITERBI_FULL_RERANK_MIN_DISTANCE,
full_rerank_min_len_delta: int = DENSE_OVERFLOW_SHORT_VITERBI_FULL_RERANK_MIN_LEN_DELTA,
):
if not lexicon or not fuzzy_lexicon or len(inp) < DENSE_OVERFLOW_SHORT_VITERBI_MIN_LEN:
return None
exact_lengths = lexicon_lengths or build_lexicon_lengths(lexicon)
lengths = [
length for length in exact_lengths
if DENSE_OVERFLOW_SHORT_VITERBI_MIN_PIECE_LEN <= length <= DENSE_OVERFLOW_SHORT_VITERBI_MAX_PIECE_LEN
]
if not lengths:
return None
n = len(inp)
match_cache = {}
def acceptable(match, piece: str):
if match is None:
return None
if match["score"] > max_score:
return None
if match["distance"] > max(5, len(piece) * max_distance_ratio):
return None
if len(match["value"]) < max(2, int(len(piece) * WIDE_MULTI_SEGMENT_MIN_OUTPUT_RATIO)):
return None
return match
def fuzzy_piece_match(piece: str):
if piece in match_cache:
return match_cache[piece]
match = acceptable(
fuzzy_lexicon_match(piece, fuzzy_lexicon, fuzzy_lexicon_by_len, fuzzy_lexicon_gram_index),
piece,
)
if match is None:
match = acceptable(
weighted_fuzzy_lexicon_match(
piece,
fuzzy_lexicon,
fuzzy_lexicon_by_len,
fuzzy_lexicon_gram_index,
candidate_limit=weighted_candidate_limit,
),
piece,
)
if (
validate_with_full
and match is not None
and match["score"] >= full_rerank_score
and (
match["distance"] >= full_rerank_min_distance
or abs(len(piece) - len(match["key"])) >= full_rerank_min_len_delta
)
and full_lexicon_by_len is not None
and full_lexicon_gram_index is not None
):
full_match = acceptable(
weighted_fuzzy_lexicon_match(
piece,
lexicon,
full_lexicon_by_len,
full_lexicon_gram_index,
candidate_limit=full_rerank_candidate_limit,
),
piece,
)
if full_match is not None and full_match["score"] + full_rerank_margin < match["score"]:
match = full_match
match_cache[piece] = match
return match
def state_score(state):
return (round(state[0], 6), state[1], -state[2], len(state[3]))
def add_state(position: int, candidate):
bucket = best_by_position.setdefault(position, [])
candidate_key = candidate[3]
candidate_score = state_score(candidate)
kept = []
for existing in bucket:
if existing[3] == candidate_key:
if state_score(existing) <= candidate_score:
return
continue
kept.append(existing)
kept.append(candidate)
kept.sort(key=state_score)
best_by_position[position] = kept[:position_beam]
# Keep a tiny per-position beam. A single state is fast, but very long
# typo-heavy concatenations can temporarily prefer a locally cheaper
# ambiguous phrase and prune the globally correct path.
best_by_position = {0: [(0.0, 0, 0, "")]}
for pos in range(n + 1):
states = best_by_position.get(pos)
if states is None:
continue
for state in states:
cost, fuzzy_count, segments, output = state
if segments >= max_segments:
continue
remaining = n - pos
for length in lengths:
if length > remaining:
continue
j = pos + length
piece = inp[pos:j]
value = lexicon.get(piece)
next_cost = cost
next_fuzzy_count = fuzzy_count
if value is None:
if fuzzy_count >= max_fuzzy_segments:
continue
match = fuzzy_piece_match(piece)
if match is None:
continue
value = match["value"]
next_fuzzy_count += 1
next_cost += match["score"] + match["distance"] * 0.01 + DENSE_OVERFLOW_FUZZY_COST
next_segments = segments + 1
add_state(j, (next_cost, next_fuzzy_count, next_segments, output + value))
finals = best_by_position.get(n)
if not finals:
return None
valid_finals = []
for final in finals:
cost, fuzzy_count, segments, output = final
if segments < max(min_segments, DENSE_OVERFLOW_SHORT_VITERBI_MIN_SEGMENTS):
continue
if cost > segments * max_cost_per_segment:
continue
if fuzzy_count > max(1, int(segments * max_fuzzy_ratio)):
continue
valid_finals.append(final)
if not valid_finals:
return None
return sorted(valid_finals, key=state_score)[0][3]
def fuzzy_triple_segment_lexicon_lookup(
inp: str,
lexicon: dict,
fuzzy_lexicon: dict,
fuzzy_lexicon_by_len: dict | None = None,
fuzzy_lexicon_gram_index: dict | None = None,
fuzzy_lexicon_lengths: list[int] | None = None,
full_lexicon_by_len: dict | None = None,
full_lexicon_gram_index: dict | None = None,
*,
max_delta: int = 7,
max_split_pairs: int = FUZZY_TRIPLE_MAX_SPLITS,
):
if not lexicon or not fuzzy_lexicon or len(inp) < 36:
return None
fuzzy_lengths = fuzzy_lexicon_lengths or build_lexicon_lengths(fuzzy_lexicon)
if not fuzzy_lengths:
return None
n = len(inp)
min_len = max(5, min(fuzzy_lengths))
max_len = max(fuzzy_lengths)
split_positions = set()
for length in fuzzy_lengths:
for delta in range(-max_delta, max_delta + 1):
split = length + delta
if min_len <= split <= n - min_len:
split_positions.add(split)
overlap_cache = {}
def overlap_score(piece: str) -> float:
if piece in lexicon:
return 10.0
if piece in overlap_cache:
return overlap_cache[piece]
if fuzzy_lexicon_gram_index is None:
overlap_cache[piece] = 0.0
return 0.0
grams = char_grams(piece)
if not grams:
overlap_cache[piece] = 0.0
return 0.0
counts = Counter()
allowed = set(range(len(piece) - max_delta, len(piece) + max_delta + 1))
for gram in grams:
for key, _ in fuzzy_lexicon_gram_index.get(gram, ()):
if len(key) in allowed:
counts[key] += 1
score = counts.most_common(1)[0][1] / max(1, len(grams)) if counts else 0.0
overlap_cache[piece] = score
return score
split_pairs = []
for first in split_positions:
for second in split_positions:
if second <= first:
continue
lengths = (first, second - first, n - second)
if any(length < min_len - max_delta or length > max_len + max_delta for length in lengths):
continue
pieces = (inp[:first], inp[first:second], inp[second:])
score = sum(overlap_score(piece) for piece in pieces)
if score <= 0:
continue
balance = sum(abs(length - n / 3) for length in lengths)
split_pairs.append((-score, balance, first, second))
if not split_pairs:
return None
match_cache = {}
def acceptable(match, piece: str):
if match is None:
return None
if match["score"] > MULTI_SEGMENT_FUZZY_MAX_SCORE or match["distance"] > max(5, len(piece) * 0.28):
return None
return match
def piece_match(piece: str):
if piece in match_cache:
return match_cache[piece]
value = lexicon.get(piece)
if value is not None:
match_cache[piece] = {"key": piece, "value": value, "distance": 0, "score": 0.0, "exact": True}
return match_cache[piece]
match = acceptable(fuzzy_lexicon_match(piece, fuzzy_lexicon, fuzzy_lexicon_by_len, fuzzy_lexicon_gram_index), piece)
if match is None:
match = acceptable(weighted_fuzzy_lexicon_match(piece, fuzzy_lexicon, fuzzy_lexicon_by_len, fuzzy_lexicon_gram_index), piece)
if match is None:
match = acceptable(fuzzy_lexicon_match(piece, lexicon, full_lexicon_by_len, full_lexicon_gram_index), piece)
if match is None:
match = acceptable(weighted_fuzzy_lexicon_match(piece, lexicon, full_lexicon_by_len, full_lexicon_gram_index), piece)
if match is not None:
match = {**match, "exact": False}
match_cache[piece] = match
return match
best_score = None
best_outputs = set()
for _, balance, first, second in sorted(split_pairs)[:max_split_pairs]:
pieces = (inp[:first], inp[first:second], inp[second:])
matches = [piece_match(piece) for piece in pieces]
if any(match is None for match in matches):
continue
fuzzy_count = sum(1 for match in matches if not match["exact"])
if fuzzy_count == 0:
continue
distance = sum(match["distance"] for match in matches)
score = sum(match["score"] for match in matches)
cand_score = (fuzzy_count, distance, round(score, 6), balance)
output = "".join(match["value"] for match in matches)
if best_score is None or cand_score < best_score:
best_score = cand_score
best_outputs = {output}
elif cand_score == best_score:
best_outputs.add(output)
if len(best_outputs) > 1:
best_outputs = set(list(best_outputs)[:2])
if best_score is None or len(best_outputs) != 1:
return None
return next(iter(best_outputs))
def model_key(model: str) -> str:
p = Path(model)
if not p.exists():
return hashlib.sha256(model.encode("utf-8")).hexdigest()[:16]
parts = [str(p.resolve())]
for name in ("config.json", "model.safetensors", "pytorch_model.bin"):
f = p / name
if f.exists():
st = f.stat()
parts.append(f"{name}:{st.st_size}:{int(st.st_mtime)}")
return hashlib.sha256("|".join(parts).encode("utf-8")).hexdigest()[:16]
def runtime_key(
model: str,
lexicon_path: str | None,
choice_feedback_path: str | None = None,
general_lexicon_path: str | None = None,
extra: str = "",
) -> str:
parts = [model_key(model), FAST_PATH_VERSION, NORMALIZATION_VERSION, GENERIC_FALLBACK_VERSION]
parts.append(GENERAL_PHRASE_VERSION)
parts.append(file_fingerprint("lexicon", lexicon_path))
parts.append(file_fingerprint("aux_lexicon", DEFAULT_AUX_LEXICON))
parts.append(file_fingerprint("choice_feedback", choice_feedback_path))
parts.append(file_fingerprint("general_lexicon", general_lexicon_path))
if extra:
parts.append(extra)
return hashlib.sha256("|".join(parts).encode("utf-8")).hexdigest()[:20]
def cache_connect(path: str | None):
if not path:
return None
p = Path(path)
p.parent.mkdir(parents=True, exist_ok=True)
con = sqlite3.connect(str(p), timeout=30.0)
con.execute("PRAGMA journal_mode=WAL")
con.execute("PRAGMA synchronous=NORMAL")
con.execute("PRAGMA cache_size=-64000")
con.execute("PRAGMA temp_store=MEMORY")
con.execute("PRAGMA mmap_size=268435456")
con.execute("PRAGMA busy_timeout=5000")
con.execute(
"CREATE TABLE IF NOT EXISTS infer_cache ("
"model_key TEXT NOT NULL, input TEXT NOT NULL, output TEXT NOT NULL, "
"created_at INTEGER NOT NULL, PRIMARY KEY(model_key, input))"
)
return con
def cache_get(con, key: str, inp: str):
if con is None:
return None
row = con.execute(
"SELECT output FROM infer_cache WHERE model_key=? AND input=?",
(key, inp),
).fetchone()
return row[0] if row else None
def cache_put(con, key: str, inp: str, out: str):
if con is None:
return
con.execute(
"INSERT OR REPLACE INTO infer_cache(model_key,input,output,created_at) "
"VALUES(?,?,?,strftime('%s','now'))",
(key, inp, out),
)
con.commit()
def resolve_device(name: str):
import torch
if name == "auto":
if torch.cuda.is_available():
return "cuda"
try:
import torch_directml
return torch_directml.device()
except Exception:
return "cpu"
if name == "dml":
import torch_directml
return torch_directml.device()
return name
def load_model(model_path: str, device):
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
use_cuda = str(device) == "cuda"
tok = AutoTokenizer.from_pretrained(model_path)
model = AutoModelForCausalLM.from_pretrained(
model_path,
dtype=torch.bfloat16 if use_cuda else torch.float32,
attn_implementation="eager",
).to(device).eval()
return model, tok
def generate(model, tok, inp: str, device):
import torch
prompt = BOS_IN + inp + BOS_OUT
enc = tok(prompt, return_tensors="pt", add_special_tokens=False).to(device)
max_new = min(max(16, int(len(inp) * 0.8) + 8), 96)
with torch.no_grad():
out = model.generate(
enc.input_ids,
attention_mask=enc.attention_mask,
max_new_tokens=max_new,
do_sample=False,
use_cache=True,
eos_token_id=tok.eos_token_id,
pad_token_id=tok.pad_token_id,
)
return tok.decode(out[0][enc.input_ids.shape[1]:], skip_special_tokens=True)
class FastConverter:
def __init__(
self,
model_path,
device_name="auto",
lexicon_path=None,
cache_path=None,
fuzzy=True,
segment=True,
choice_feedback_path: str | None = DEFAULT_CANDIDATE_FEEDBACK,
general_lexicon_path: str | None = DEFAULT_GENERAL_LEXICON,
general_phrase: bool = True,
general_phrase_aggressive: bool = False,
):
self.model_path = model_path
self.device_name = device_name
self.lexicon_path = resolve_lexicon_path(lexicon_path)
self.choice_feedback_path = choice_feedback_path
self.general_lexicon_path = general_lexicon_path
self.choice_feedback = load_choice_feedback(choice_feedback_path)
self.general_lexicon = load_general_lexicon(general_lexicon_path)
self.generic_phrase_prepared = prepare_generic_lexicon({})
self.generic_prepared = prepare_generic_lexicon(self.general_lexicon)
self.lexicon = load_lexicon(self.lexicon_path)
self.lexicon_by_len = build_lexicon_index(self.lexicon)
self.lexicon_lengths = build_lexicon_lengths(self.lexicon)
self.lexicon_gram_index = build_lexicon_gram_index(self.lexicon)
self.fuzzy_lexicon = build_compact_fuzzy_lexicon(self.lexicon)
self.fuzzy_lexicon_by_len = build_lexicon_index(self.fuzzy_lexicon)
self.fuzzy_lexicon_lengths = build_lexicon_lengths(self.fuzzy_lexicon)
self.fuzzy_lexicon_gram_index = build_lexicon_gram_index(self.fuzzy_lexicon)
self.dense_fuzzy_lexicon = build_dense_compact_fuzzy_lexicon(self.lexicon)
self.dense_fuzzy_lexicon_by_len = build_lexicon_index(self.dense_fuzzy_lexicon)
self.dense_fuzzy_lexicon_gram_index = build_lexicon_gram_index(self.dense_fuzzy_lexicon)
self.short_viterbi_fuzzy_lexicon = build_dense_compact_fuzzy_lexicon(
self.lexicon,
keep_per_output_length=SHORT_VITERBI_COMPACT_FUZZY_KEEP_PER_OUTPUT_LENGTH,
)
self.short_viterbi_fuzzy_lexicon_by_len = build_lexicon_index(self.short_viterbi_fuzzy_lexicon)
self.short_viterbi_fuzzy_lexicon_gram_index = build_lexicon_gram_index(self.short_viterbi_fuzzy_lexicon)
self.general_phrase = general_phrase
self.general_phrase_aggressive = general_phrase_aggressive
self._general_phrase_index = None
self.cache = cache_connect(cache_path)
self.key = runtime_key(
model_path,
self.lexicon_path,
choice_feedback_path,
general_lexicon_path,
extra=f"gp:{int(general_phrase)}:{int(general_phrase_aggressive)}",
)
self.fuzzy = fuzzy
self.segment = segment
self.device = None
self.model = None
self.tok = None
def _ensure_general_phrase_index(self):
if self._general_phrase_index is None:
self._general_phrase_index = build_general_phrase_index(self.general_lexicon)
return self._general_phrase_index
def _try_general_phrase_rescue(self, inp, *, exact_only: bool = False):
"""Generic noisy-romaji rescue: canonical re-lookup (Alt A) then
general-lexicon fuzzy Viterbi anchor-and-fill (recommended). Returns
(output, source) or None. Runs only after earlier routes decline."""
if not self.general_phrase:
return None
# Alt A: canonical-variant re-lookup through high-confidence routes.
for variant in canonicalize_romaji_variants(inp):
if variant == inp:
continue
exact = self.lexicon.get(variant)
if exact is not None:
return exact, "canonical_exact"
if self.segment:
seg = segment_lexicon_lookup(variant, self.lexicon, self.lexicon_lengths)
if seg is not None:
return seg, "canonical_segment"
# Recommended: general-lexicon fuzzy Viterbi anchor-and-fill.
index = self._ensure_general_phrase_index()
hit = general_phrase_rescue(
inp,
index,
aggressive=self.general_phrase_aggressive,
exact_only=exact_only,
)
if hit is not None:
return hit[0], "general_phrase_viterbi"
return None
def ensure_model(self):
if self.model is None:
self.device = resolve_device(self.device_name)
self.model, self.tok = load_model(self.model_path, self.device)
def convert(self, text):
inp = normalize_input(text)
t0 = time.perf_counter()
choice_hit = self.choice_feedback.get(inp)
if choice_hit is not None:
cache_put(self.cache, self.key, inp, choice_hit)
return choice_hit, "choice_feedback", (time.perf_counter() - t0) * 1000
if inp in self.lexicon:
return self.lexicon[inp], "lexicon", (time.perf_counter() - t0) * 1000
cached = cache_get(self.cache, self.key, inp)
if cached is not None:
return cached, "cache", (time.perf_counter() - t0) * 1000
if self.segment:
segment_hit = segment_lexicon_lookup(inp, self.lexicon, self.lexicon_lengths)
if segment_hit is not None:
cache_put(self.cache, self.key, inp, segment_hit)
return segment_hit, "lexicon_segment", (time.perf_counter() - t0) * 1000
if len(inp) >= LONG_SEGMENT_MIN_LEN:
segment_hit = segment_lexicon_lookup(
inp,
self.lexicon,
self.lexicon_lengths,
max_segments=LONG_SEGMENT_MAX_SEGMENTS,
)
if segment_hit is not None:
cache_put(self.cache, self.key, inp, segment_hit)
return segment_hit, "lexicon_segment_long", (time.perf_counter() - t0) * 1000
generic_hit = generic_romaji_fallback(inp, prepared=self.generic_phrase_prepared, min_coverage=0.45)
if generic_hit is not None:
cache_put(self.cache, self.key, inp, generic_hit)
return generic_hit, "romaji_kana_fallback_early", (time.perf_counter() - t0) * 1000
has_known_piece = self.segment and has_exact_subpiece(inp, self.lexicon, self.lexicon_lengths)
if len(inp) >= 32 and not any(ch.isdigit() for ch in inp) and not has_known_piece:
generic_hit = generic_romaji_fallback(inp, prepared=self.generic_prepared, min_coverage=0.75)
if generic_hit is not None:
cache_put(self.cache, self.key, inp, generic_hit)
return generic_hit, "romaji_kana_general_fallback_early", (time.perf_counter() - t0) * 1000
# Cheap default prefilter: if deterministic romaji canonicalization
# actually changed the input, try only the exact/no-fill general phrase
# lattice before the heavy fuzzy beams. Full fuzzy general_phrase remains
# post-fallback (or opt-in aggressive), so ambiguous cases still abstain.
if (
self.general_phrase
and not self.general_phrase_aggressive
and len(inp) >= 24
and not any(ch.isdigit() for ch in inp)
and any(v != inp for v in canonicalize_romaji_variants(inp))
):
rescue = self._try_general_phrase_rescue(inp, exact_only=True)
if rescue is not None:
rescue_out, rescue_src = rescue
cache_put(self.cache, self.key, inp, rescue_out)
return rescue_out, f"{rescue_src}_prefuzzy_exact", (time.perf_counter() - t0) * 1000
# Alt B (explicit): let the full general-lexicon phrase route
# participate earlier, before the heavy fuzzy beams, so colloquial
# phrases can short-circuit. This changes ordering, hence opt-in.
if self.general_phrase_aggressive:
rescue = self._try_general_phrase_rescue(inp)
if rescue is not None:
rescue_out, rescue_src = rescue
cache_put(self.cache, self.key, inp, rescue_out)
return rescue_out, f"{rescue_src}_aggressive", (time.perf_counter() - t0) * 1000
if self.fuzzy:
fuzzy_hit = fuzzy_lexicon_lookup(inp, self.lexicon, self.lexicon_by_len, self.lexicon_gram_index)
if fuzzy_hit is not None:
cache_put(self.cache, self.key, inp, fuzzy_hit)
return fuzzy_hit, "lexicon_fuzzy", (time.perf_counter() - t0) * 1000
if self.segment:
def try_fuzzy_multi_segment(**kwargs):
return fuzzy_multi_segment_lexicon_lookup(
inp,
self.lexicon,
self.lexicon_lengths,
self.fuzzy_lexicon,
self.fuzzy_lexicon_by_len,
self.fuzzy_lexicon_gram_index,
self.fuzzy_lexicon_lengths,
self.lexicon_by_len,
self.lexicon_gram_index,
**kwargs,
)
def try_fuzzy_triple_segment():
return fuzzy_triple_segment_lexicon_lookup(
inp,
self.lexicon,
self.fuzzy_lexicon,
self.fuzzy_lexicon_by_len,
self.fuzzy_lexicon_gram_index,
self.fuzzy_lexicon_lengths,
self.lexicon_by_len,
self.lexicon_gram_index,
)
def try_single_fuzzy_long_segment():
return single_fuzzy_long_segment_lexicon_lookup(
inp,
self.lexicon,
self.lexicon_lengths,
self.fuzzy_lexicon,
self.fuzzy_lexicon_by_len,
self.fuzzy_lexicon_gram_index,
self.fuzzy_lexicon_lengths,
self.lexicon_by_len,
self.lexicon_gram_index,
max_segments=LONG_SEGMENT_MAX_SEGMENTS,
max_fuzzy_score=RELAXED_MULTI_SEGMENT_FUZZY_MAX_SCORE,
plain_accept_score=LONG_SINGLE_FUZZY_PLAIN_ACCEPT_SCORE,
use_compact_weighted=False,
weighted_candidate_limit=LONG_SINGLE_FUZZY_WEIGHTED_CANDIDATE_LIMIT,
)
tried_fuzzy_multi = False
tried_fuzzy_triple = False
fuzzy_multi_hit = None
exact_subpiece = has_known_piece
prefer_triple = len(inp) >= 40 and not exact_subpiece
if prefer_triple:
fuzzy_triple_hit = try_fuzzy_triple_segment()
tried_fuzzy_triple = True
if fuzzy_triple_hit is not None:
cache_put(self.cache, self.key, inp, fuzzy_triple_hit)
return fuzzy_triple_hit, "lexicon_triple_segment_fuzzy", (time.perf_counter() - t0) * 1000
if len(inp) >= 40 and exact_subpiece:
anchored_hit = anchored_fuzzy_segment_lexicon_lookup(
inp,
self.lexicon,
self.lexicon_lengths,
self.fuzzy_lexicon,
self.fuzzy_lexicon_by_len,
self.fuzzy_lexicon_gram_index,
)
if anchored_hit is not None:
cache_put(self.cache, self.key, inp, anchored_hit)
return anchored_hit, "lexicon_anchor_fuzzy", (time.perf_counter() - t0) * 1000
sandwich_hit = sandwich_fuzzy_segment_lexicon_lookup(
inp,
self.lexicon,
self.lexicon_lengths,
self.fuzzy_lexicon,
self.fuzzy_lexicon_by_len,
self.fuzzy_lexicon_gram_index,
self.lexicon_by_len,
self.lexicon_gram_index,
)
if sandwich_hit is not None:
cache_put(self.cache, self.key, inp, sandwich_hit)
return sandwich_hit, "lexicon_sandwich_fuzzy", (time.perf_counter() - t0) * 1000
if len(inp) >= DENSE_OVERFLOW_DIRECT_MIN_LEN:
dense_overflow_hit = dense_overflow_segment_lexicon_lookup(
inp,
self.lexicon,
self.lexicon_lengths,
self.dense_fuzzy_lexicon,
self.dense_fuzzy_lexicon_by_len,
self.dense_fuzzy_lexicon_gram_index,
)
if dense_overflow_hit is not None:
cache_put(self.cache, self.key, inp, dense_overflow_hit)
return dense_overflow_hit, "lexicon_dense_overflow", (time.perf_counter() - t0) * 1000
dense_overflow_hit = dense_overflow_segment_lexicon_lookup(
inp,
self.lexicon,
self.lexicon_lengths,
self.dense_fuzzy_lexicon,
self.dense_fuzzy_lexicon_by_len,
self.dense_fuzzy_lexicon_gram_index,
self.lexicon_by_len,
self.lexicon_gram_index,
beam_width=DENSE_OVERFLOW_RESCUE_BEAM_WIDTH,
max_fuzzy_score=DENSE_OVERFLOW_RESCUE_MAX_SCORE,
allow_full_rescue=True,
)
if dense_overflow_hit is not None:
cache_put(self.cache, self.key, inp, dense_overflow_hit)
return dense_overflow_hit, "lexicon_dense_overflow_rescue", (time.perf_counter() - t0) * 1000
dense_overflow_hit = dense_overflow_segment_lexicon_lookup(
inp,
self.lexicon,
self.lexicon_lengths,
self.dense_fuzzy_lexicon,
self.dense_fuzzy_lexicon_by_len,
self.dense_fuzzy_lexicon_gram_index,
self.lexicon_by_len,
self.lexicon_gram_index,
beam_width=DENSE_OVERFLOW_FAST_WEIGHTED_RESCUE_BEAM_WIDTH,
max_fuzzy_score=DENSE_OVERFLOW_RESCUE_MAX_SCORE,
use_weighted_piece=True,
weighted_candidate_limit=DENSE_OVERFLOW_FAST_WEIGHTED_CANDIDATE_LIMIT,
validate_weighted_with_full=True,
)
if dense_overflow_hit is not None:
cache_put(self.cache, self.key, inp, dense_overflow_hit)
return dense_overflow_hit, "lexicon_dense_overflow_fast_weighted_rescue", (time.perf_counter() - t0) * 1000
dense_overflow_hit = dense_overflow_segment_lexicon_lookup(
inp,
self.lexicon,
self.lexicon_lengths,
self.dense_fuzzy_lexicon,
self.dense_fuzzy_lexicon_by_len,
self.dense_fuzzy_lexicon_gram_index,
self.lexicon_by_len,
self.lexicon_gram_index,
beam_width=DENSE_OVERFLOW_FAST_WEIGHTED_RESCUE_BEAM_WIDTH,
max_fuzzy_score=DENSE_OVERFLOW_RELAXED_WEIGHTED_RESCUE_MAX_SCORE,
use_weighted_piece=True,
weighted_candidate_limit=DENSE_OVERFLOW_RELAXED_WEIGHTED_CANDIDATE_LIMIT,
validate_weighted_with_full=True,
)
if dense_overflow_hit is not None:
cache_put(self.cache, self.key, inp, dense_overflow_hit)
return dense_overflow_hit, "lexicon_dense_overflow_relaxed_weighted_rescue", (time.perf_counter() - t0) * 1000
dense_overflow_hit = dense_overflow_short_viterbi_rescue_lookup(
inp,
self.lexicon,
self.lexicon_lengths,
self.short_viterbi_fuzzy_lexicon,
self.short_viterbi_fuzzy_lexicon_by_len,
self.short_viterbi_fuzzy_lexicon_gram_index,
self.lexicon_by_len,
self.lexicon_gram_index,
validate_with_full=True,
)
if dense_overflow_hit is not None:
cache_put(self.cache, self.key, inp, dense_overflow_hit)
return dense_overflow_hit, "lexicon_dense_overflow_short_viterbi_rescue", (time.perf_counter() - t0) * 1000
dense_overflow_hit = dense_overflow_short_viterbi_rescue_lookup(
inp,
self.lexicon,
self.lexicon_lengths,
self.short_viterbi_fuzzy_lexicon,
self.short_viterbi_fuzzy_lexicon_by_len,
self.short_viterbi_fuzzy_lexicon_gram_index,
self.lexicon_by_len,
self.lexicon_gram_index,
max_score=DENSE_OVERFLOW_SHORT_VITERBI_ULTRA_MAX_SCORE,
weighted_candidate_limit=DENSE_OVERFLOW_SHORT_VITERBI_ULTRA_WEIGHTED_CANDIDATE_LIMIT,
validate_with_full=True,
)
if dense_overflow_hit is not None:
cache_put(self.cache, self.key, inp, dense_overflow_hit)
return dense_overflow_hit, "lexicon_dense_overflow_short_viterbi_ultra_rescue", (time.perf_counter() - t0) * 1000
dense_overflow_hit = dense_overflow_segment_lexicon_lookup(
inp,
self.lexicon,
self.lexicon_lengths,
self.dense_fuzzy_lexicon,
self.dense_fuzzy_lexicon_by_len,
self.dense_fuzzy_lexicon_gram_index,
self.lexicon_by_len,
self.lexicon_gram_index,
beam_width=DENSE_OVERFLOW_WEIGHTED_RESCUE_BEAM_WIDTH,
max_fuzzy_score=DENSE_OVERFLOW_RESCUE_MAX_SCORE,
allow_full_rescue=True,
use_weighted_piece=True,
)
if dense_overflow_hit is not None:
cache_put(self.cache, self.key, inp, dense_overflow_hit)
return dense_overflow_hit, "lexicon_dense_overflow_weighted_rescue", (time.perf_counter() - t0) * 1000
if len(inp) >= WIDE_MULTI_SEGMENT_MIN_LEN:
single_fuzzy_hit = try_single_fuzzy_long_segment()
if single_fuzzy_hit is not None:
cache_put(self.cache, self.key, inp, single_fuzzy_hit)
return single_fuzzy_hit, "lexicon_single_fuzzy_long_segment", (time.perf_counter() - t0) * 1000
if DENSE_OVERFLOW_MIN_LEN <= len(inp) < DENSE_OVERFLOW_DIRECT_MIN_LEN:
dense_overflow_hit = dense_overflow_segment_lexicon_lookup(
inp,
self.lexicon,
self.lexicon_lengths,
self.dense_fuzzy_lexicon,
self.dense_fuzzy_lexicon_by_len,
self.dense_fuzzy_lexicon_gram_index,
)
if dense_overflow_hit is not None:
cache_put(self.cache, self.key, inp, dense_overflow_hit)
return dense_overflow_hit, "lexicon_dense_overflow", (time.perf_counter() - t0) * 1000
dense_overflow_hit = dense_overflow_segment_lexicon_lookup(
inp,
self.lexicon,
self.lexicon_lengths,
self.dense_fuzzy_lexicon,
self.dense_fuzzy_lexicon_by_len,
self.dense_fuzzy_lexicon_gram_index,
self.lexicon_by_len,
self.lexicon_gram_index,
beam_width=DENSE_OVERFLOW_RESCUE_BEAM_WIDTH,
max_fuzzy_score=DENSE_OVERFLOW_RESCUE_MAX_SCORE,
allow_full_rescue=True,
)
if dense_overflow_hit is not None:
cache_put(self.cache, self.key, inp, dense_overflow_hit)
return dense_overflow_hit, "lexicon_dense_overflow_rescue", (time.perf_counter() - t0) * 1000
dense_overflow_hit = dense_overflow_segment_lexicon_lookup(
inp,
self.lexicon,
self.lexicon_lengths,
self.dense_fuzzy_lexicon,
self.dense_fuzzy_lexicon_by_len,
self.dense_fuzzy_lexicon_gram_index,
self.lexicon_by_len,
self.lexicon_gram_index,
beam_width=DENSE_OVERFLOW_FAST_WEIGHTED_RESCUE_BEAM_WIDTH,
max_fuzzy_score=DENSE_OVERFLOW_RESCUE_MAX_SCORE,
use_weighted_piece=True,
weighted_candidate_limit=DENSE_OVERFLOW_FAST_WEIGHTED_CANDIDATE_LIMIT,
validate_weighted_with_full=True,
)
if dense_overflow_hit is not None:
cache_put(self.cache, self.key, inp, dense_overflow_hit)
return dense_overflow_hit, "lexicon_dense_overflow_fast_weighted_rescue", (time.perf_counter() - t0) * 1000
dense_overflow_hit = dense_overflow_segment_lexicon_lookup(
inp,
self.lexicon,
self.lexicon_lengths,
self.dense_fuzzy_lexicon,
self.dense_fuzzy_lexicon_by_len,
self.dense_fuzzy_lexicon_gram_index,
self.lexicon_by_len,
self.lexicon_gram_index,
beam_width=DENSE_OVERFLOW_FAST_WEIGHTED_RESCUE_BEAM_WIDTH,
max_fuzzy_score=DENSE_OVERFLOW_RELAXED_WEIGHTED_RESCUE_MAX_SCORE,
use_weighted_piece=True,
weighted_candidate_limit=DENSE_OVERFLOW_RELAXED_WEIGHTED_CANDIDATE_LIMIT,
validate_weighted_with_full=True,
)
if dense_overflow_hit is not None:
cache_put(self.cache, self.key, inp, dense_overflow_hit)
return dense_overflow_hit, "lexicon_dense_overflow_relaxed_weighted_rescue", (time.perf_counter() - t0) * 1000
dense_overflow_hit = dense_overflow_segment_lexicon_lookup(
inp,
self.lexicon,
self.lexicon_lengths,
self.dense_fuzzy_lexicon,
self.dense_fuzzy_lexicon_by_len,
self.dense_fuzzy_lexicon_gram_index,
self.lexicon_by_len,
self.lexicon_gram_index,
beam_width=DENSE_OVERFLOW_WEIGHTED_RESCUE_BEAM_WIDTH,
max_fuzzy_score=DENSE_OVERFLOW_RESCUE_MAX_SCORE,
allow_full_rescue=True,
use_weighted_piece=True,
)
if dense_overflow_hit is not None:
cache_put(self.cache, self.key, inp, dense_overflow_hit)
return dense_overflow_hit, "lexicon_dense_overflow_weighted_rescue", (time.perf_counter() - t0) * 1000
dense_overflow_hit = dense_overflow_short_viterbi_rescue_lookup(
inp,
self.lexicon,
self.lexicon_lengths,
self.short_viterbi_fuzzy_lexicon,
self.short_viterbi_fuzzy_lexicon_by_len,
self.short_viterbi_fuzzy_lexicon_gram_index,
self.lexicon_by_len,
self.lexicon_gram_index,
validate_with_full=True,
)
if dense_overflow_hit is not None:
cache_put(self.cache, self.key, inp, dense_overflow_hit)
return dense_overflow_hit, "lexicon_dense_overflow_short_viterbi_rescue", (time.perf_counter() - t0) * 1000
dense_overflow_hit = dense_overflow_short_viterbi_rescue_lookup(
inp,
self.lexicon,
self.lexicon_lengths,
self.short_viterbi_fuzzy_lexicon,
self.short_viterbi_fuzzy_lexicon_by_len,
self.short_viterbi_fuzzy_lexicon_gram_index,
self.lexicon_by_len,
self.lexicon_gram_index,
max_score=DENSE_OVERFLOW_SHORT_VITERBI_ULTRA_MAX_SCORE,
weighted_candidate_limit=DENSE_OVERFLOW_SHORT_VITERBI_ULTRA_WEIGHTED_CANDIDATE_LIMIT,
validate_with_full=True,
)
if dense_overflow_hit is not None:
cache_put(self.cache, self.key, inp, dense_overflow_hit)
return dense_overflow_hit, "lexicon_dense_overflow_short_viterbi_ultra_rescue", (time.perf_counter() - t0) * 1000
if len(inp) >= 40:
fuzzy_multi_hit = try_fuzzy_multi_segment()
tried_fuzzy_multi = True
if fuzzy_multi_hit is not None:
cache_put(self.cache, self.key, inp, fuzzy_multi_hit)
return fuzzy_multi_hit, "lexicon_multi_segment_fuzzy", (time.perf_counter() - t0) * 1000
if len(inp) >= DEEP_MULTI_SEGMENT_MIN_LEN:
fuzzy_multi_hit = try_fuzzy_multi_segment(
max_fuzzy_segments=3,
max_fuzzy_transitions=4,
beam_width=8,
)
if fuzzy_multi_hit is not None:
cache_put(self.cache, self.key, inp, fuzzy_multi_hit)
return fuzzy_multi_hit, "lexicon_multi_segment_fuzzy_deep", (time.perf_counter() - t0) * 1000
if len(inp) >= DEEP_MULTI_SEGMENT_MIN_LEN:
fuzzy_multi_hit = try_fuzzy_multi_segment(
max_fuzzy_segments=3,
max_fuzzy_transitions=6,
beam_width=12,
max_fuzzy_score=RELAXED_MULTI_SEGMENT_FUZZY_MAX_SCORE,
)
if fuzzy_multi_hit is not None:
cache_put(self.cache, self.key, inp, fuzzy_multi_hit)
return fuzzy_multi_hit, "lexicon_multi_segment_fuzzy_relaxed", (time.perf_counter() - t0) * 1000
if len(inp) >= WIDE_MULTI_SEGMENT_MIN_LEN:
wide_beam_hit = wide_beam_multi_segment_lexicon_lookup(
inp,
self.lexicon,
self.lexicon_lengths,
self.fuzzy_lexicon,
self.fuzzy_lexicon_by_len,
self.fuzzy_lexicon_gram_index,
self.fuzzy_lexicon_lengths,
self.lexicon_by_len,
self.lexicon_gram_index,
max_segments=LONG_SEGMENT_MAX_SEGMENTS,
max_fuzzy_segments=WIDE_MULTI_SEGMENT_MAX_FUZZY_SEGMENTS,
beam_width=WIDE_MULTI_SEGMENT_BEAM_WIDTH,
piece_probe_limit=WIDE_MULTI_SEGMENT_PIECE_PROBE_LIMIT,
max_fuzzy_score=RELAXED_MULTI_SEGMENT_FUZZY_MAX_SCORE,
plain_accept_score=WIDE_MULTI_SEGMENT_WIDE_PLAIN_ACCEPT_SCORE,
use_compact_weighted=False,
weighted_candidate_limit=WIDE_MULTI_SEGMENT_WEIGHTED_CANDIDATE_LIMIT,
)
if wide_beam_hit is not None:
cache_put(self.cache, self.key, inp, wide_beam_hit)
return wide_beam_hit, "lexicon_multi_segment_fuzzy_wide_beam", (time.perf_counter() - t0) * 1000
if len(inp) >= WIDE_MULTI_SEGMENT_RESCUE_MIN_LEN:
wide_beam_hit = wide_beam_multi_segment_lexicon_lookup(
inp,
self.lexicon,
self.lexicon_lengths,
self.fuzzy_lexicon,
self.fuzzy_lexicon_by_len,
self.fuzzy_lexicon_gram_index,
self.fuzzy_lexicon_lengths,
self.lexicon_by_len,
self.lexicon_gram_index,
max_segments=LONG_SEGMENT_MAX_SEGMENTS,
max_fuzzy_segments=WIDE_MULTI_SEGMENT_RESCUE_MAX_FUZZY_SEGMENTS,
beam_width=WIDE_MULTI_SEGMENT_RESCUE_BEAM_WIDTH,
piece_probe_limit=WIDE_MULTI_SEGMENT_RESCUE_PIECE_PROBE_LIMIT,
max_fuzzy_score=RELAXED_MULTI_SEGMENT_FUZZY_MAX_SCORE,
plain_accept_score=WIDE_MULTI_SEGMENT_RESCUE_PLAIN_ACCEPT_SCORE,
use_compact_weighted=False,
weighted_candidate_limit=WIDE_MULTI_SEGMENT_RESCUE_WEIGHTED_CANDIDATE_LIMIT,
)
if wide_beam_hit is not None:
cache_put(self.cache, self.key, inp, wide_beam_hit)
return wide_beam_hit, "lexicon_multi_segment_fuzzy_wide_rescue", (time.perf_counter() - t0) * 1000
if len(inp) >= EXTENDED_MULTI_SEGMENT_MIN_LEN:
fuzzy_multi_hit = try_fuzzy_multi_segment(
max_segments=LONG_SEGMENT_MAX_SEGMENTS,
max_fuzzy_segments=4,
max_fuzzy_transitions=6,
beam_width=12,
max_fuzzy_score=RELAXED_MULTI_SEGMENT_FUZZY_MAX_SCORE,
)
if fuzzy_multi_hit is not None:
cache_put(self.cache, self.key, inp, fuzzy_multi_hit)
return fuzzy_multi_hit, "lexicon_multi_segment_fuzzy_extended", (time.perf_counter() - t0) * 1000
if len(inp) >= 40 and not tried_fuzzy_triple and not exact_subpiece:
fuzzy_triple_hit = try_fuzzy_triple_segment()
tried_fuzzy_triple = True
if fuzzy_triple_hit is not None:
cache_put(self.cache, self.key, inp, fuzzy_triple_hit)
return fuzzy_triple_hit, "lexicon_triple_segment_fuzzy", (time.perf_counter() - t0) * 1000
fuzzy_segment_hit = fuzzy_segment_lexicon_lookup(
inp,
self.fuzzy_lexicon,
self.fuzzy_lexicon_by_len,
self.fuzzy_lexicon_gram_index,
self.fuzzy_lexicon_lengths,
)
if fuzzy_segment_hit is None:
fuzzy_segment_hit = fuzzy_segment_lexicon_lookup(
inp,
self.lexicon,
self.lexicon_by_len,
self.lexicon_gram_index,
self.lexicon_lengths,
)
if fuzzy_segment_hit is not None:
cache_put(self.cache, self.key, inp, fuzzy_segment_hit)
return fuzzy_segment_hit, "lexicon_segment_fuzzy", (time.perf_counter() - t0) * 1000
if len(inp) >= 40 and not tried_fuzzy_triple:
fuzzy_triple_hit = try_fuzzy_triple_segment()
tried_fuzzy_triple = True
if fuzzy_triple_hit is not None:
cache_put(self.cache, self.key, inp, fuzzy_triple_hit)
return fuzzy_triple_hit, "lexicon_triple_segment_fuzzy", (time.perf_counter() - t0) * 1000
if not tried_fuzzy_multi:
fuzzy_multi_hit = try_fuzzy_multi_segment()
if fuzzy_multi_hit is not None:
cache_put(self.cache, self.key, inp, fuzzy_multi_hit)
return fuzzy_multi_hit, "lexicon_multi_segment_fuzzy", (time.perf_counter() - t0) * 1000
generic_hit = generic_romaji_fallback(inp, prepared=self.generic_prepared)
if generic_hit is not None:
cache_put(self.cache, self.key, inp, generic_hit)
return generic_hit, "romaji_kana_fallback", (time.perf_counter() - t0) * 1000
# Generic noisy-romaji rescue: only fires here, after every earlier
# route declined and before the neural model. On the acceptance gates
# (model count 0) this stage is never reached, so it cannot change a
# passing gate row; it only converts neural-fallback cases.
rescue = self._try_general_phrase_rescue(inp)
if rescue is not None:
rescue_out, rescue_src = rescue
cache_put(self.cache, self.key, inp, rescue_out)
return rescue_out, rescue_src, (time.perf_counter() - t0) * 1000
self.ensure_model()
out = generate(self.model, self.tok, inp, self.device)
cache_put(self.cache, self.key, inp, out)
return out, f"model:{self.device}", (time.perf_counter() - t0) * 1000
def convert(
text,
model_path,
device_name="auto",
lexicon_path=None,
cache_path=None,
fuzzy=True,
segment=True,
choice_feedback_path: str | None = DEFAULT_CANDIDATE_FEEDBACK,
general_lexicon_path: str | None = DEFAULT_GENERAL_LEXICON,
general_phrase: bool = True,
general_phrase_aggressive: bool = False,
):
return FastConverter(
model_path,
device_name,
lexicon_path,
cache_path,
fuzzy,
segment,
choice_feedback_path,
general_lexicon_path,
general_phrase,
general_phrase_aggressive,
).convert(text)
def main():
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
ap = argparse.ArgumentParser()
ap.add_argument("--model", required=True)
ap.add_argument("--device", default="auto", choices=["auto", "cpu", "cuda", "dml"])
ap.add_argument(
"--lexicon",
default="auto",
help=(
"Path to lexicon JSON. 'auto' prefers romaji2ja_typo_95.json, "
"then romaji2ja_feedback_95.json, then romaji2ja.json."
),
)
ap.add_argument("--cache", default="artifacts/cache/infer_cache.sqlite")
ap.add_argument("--choice-feedback", default=DEFAULT_CANDIDATE_FEEDBACK)
ap.add_argument("--general-lexicon", default=DEFAULT_GENERAL_LEXICON)
ap.add_argument("--no-choice-feedback", action="store_true")
ap.add_argument("--no-segment", action="store_true")
ap.add_argument("--no-fuzzy", action="store_true")
ap.add_argument("--no-general-phrase", action="store_true")
ap.add_argument("--general-phrase-aggressive", action="store_true")
ap.add_argument("--json", action="store_true")
ap.add_argument("text")
args = ap.parse_args()
out, source, ms = convert(
args.text,
args.model,
args.device,
args.lexicon,
args.cache,
not args.no_fuzzy,
not args.no_segment,
None if args.no_choice_feedback else args.choice_feedback,
args.general_lexicon,
not args.no_general_phrase,
args.general_phrase_aggressive,
)
if args.json:
print(json.dumps({"output": out, "source": source, "latency_ms": round(ms, 2)}, ensure_ascii=False))
else:
print(out)
print(f"({source}, {ms:.0f} ms)")
if __name__ == "__main__":
main()