pub(crate) mod compressor;
pub(crate) mod ir;
pub(crate) mod renderer;
pub(crate) mod stream;
pub(crate) mod symbol;
mod parser;
pub use compressor::{AdaptiveCompressor, CompressionConfig, CompressionStage};
pub use ir::{DocNode, FidelityLevel, IRDocument};
pub use renderer::{build_yaml_header, linearize_table, render_full, render_node};
pub use stream::{StreamError, StreamingTranspiler, TranspileChunk};
pub use symbol::SymbolDict;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InputFormat {
PlainText,
Markdown,
Html,
}
#[derive(Debug, thiserror::Error)]
pub enum TranspileError {
#[error("parse failed: {0}")]
Parse(String),
#[error("symbol table overflow: {0}")]
SymbolOverflow(#[from] symbol::SymbolOverflowError),
#[error("stream error: {0}")]
Stream(#[from] stream::StreamError),
#[error("compression attempted in Lossless mode")]
LosslessModeViolation,
#[error("input exceeds maximum allowed size of {0} bytes")]
InputTooLarge(usize),
}
pub const MAX_INPUT_BYTES: usize = 10 * 1024 * 1024;
fn strip_pua(input: &str) -> std::borrow::Cow<'_, str> {
if input
.chars()
.any(|c| ('\u{E000}'..='\u{F8FF}').contains(&c))
{
std::borrow::Cow::Owned(
input
.chars()
.filter(|c| !('\u{E000}'..='\u{F8FF}').contains(c))
.collect(),
)
} else {
std::borrow::Cow::Borrowed(input)
}
}
fn auto_intern_frequent_terms(
doc: &IRDocument,
dict: &mut SymbolDict,
min_freq: usize,
max_terms: usize,
) {
use std::collections::HashMap;
if !doc.fidelity.allows_compression() {
return;
}
let mut freq: HashMap<&str, usize> = HashMap::new();
for node in &doc.nodes {
let text: Option<&str> = match node {
DocNode::Para { text, .. } => Some(text.as_str()),
DocNode::Header { text, .. } => Some(text.as_str()),
DocNode::List { items, .. } => {
for item in items {
for token in item.split_whitespace() {
let min_len = if token.is_ascii() { 3 } else { 2 };
if token.len() >= min_len {
*freq.entry(token).or_insert(0) += 1;
}
}
}
None
}
_ => None,
};
if let Some(text) = text {
for token in text.split_whitespace() {
let min_len = if token.is_ascii() { 3 } else { 2 };
if token.len() >= min_len {
*freq.entry(token).or_insert(0) += 1;
}
}
}
}
let mut candidates: Vec<(&str, usize)> = freq
.into_iter()
.filter(|(_, count)| *count >= min_freq)
.collect();
candidates.sort_by_key(|b| std::cmp::Reverse(b.1));
let pua_cost = stream::pua_token_cost();
for (term, count) in candidates.into_iter().take(max_terms) {
let term_tokens = stream::estimate_tokens(term);
if term_tokens <= pua_cost {
continue;
}
let per_occurrence_saving = term_tokens - pua_cost; let body_saving = count.saturating_mul(per_occurrence_saving);
let dict_overhead = pua_cost + term_tokens + DICT_ENTRY_OVERHEAD;
if body_saving <= dict_overhead {
continue;
}
let _ = dict.intern(term);
}
}
pub const PUA_TOKEN_COST: usize = 3;
const DICT_ENTRY_OVERHEAD: usize = 4;
pub fn transpile(
input: &str,
format: InputFormat,
fidelity: FidelityLevel,
budget: Option<usize>,
) -> Result<String, TranspileError> {
if input.len() > MAX_INPUT_BYTES {
return Err(TranspileError::InputTooLarge(input.len()));
}
let input = strip_pua(input);
let input = input.as_ref();
let mut doc = parser::parse(input, format, fidelity, budget).map_err(TranspileError::Parse)?;
if let Some(b) = budget
&& fidelity != FidelityLevel::Lossless
{
doc.nodes = compress_to_budget(std::mem::take(&mut doc.nodes), b, fidelity, input);
}
let mut dict = SymbolDict::new();
auto_intern_frequent_terms(&doc, &mut dict, 3, 50);
let output = render_full(&doc, &mut dict);
Ok(output)
}
fn compress_to_budget(
nodes: Vec<DocNode>,
budget: usize,
fidelity: FidelityLevel,
raw_input: &str,
) -> Vec<DocNode> {
use compressor::CompressionStage;
let compressor = AdaptiveCompressor::new();
const STAGES: &[CompressionStage] = &[
CompressionStage::StopwordOnly,
CompressionStage::PruneLowImportance,
CompressionStage::DeduplicateAndLinearize,
CompressionStage::MaxCompression,
];
let initial_tokens = stream::estimate_tokens(raw_input);
let cfg = CompressionConfig {
budget,
current_tokens: initial_tokens,
fidelity,
};
let mut current_nodes = compressor.compress(nodes, &cfg);
let mut prev_node_count = usize::MAX;
for &stage in STAGES {
let tmp_output = {
let mut tmp_dict = SymbolDict::new();
let mut tmp_doc = ir::IRDocument::new(fidelity, Some(budget));
tmp_doc.nodes = current_nodes.clone();
renderer::render_full(&tmp_doc, &mut tmp_dict)
};
let actual_tokens = stream::estimate_tokens(&tmp_output);
if actual_tokens <= budget {
break;
}
if current_nodes.len() == prev_node_count {
break;
}
prev_node_count = current_nodes.len();
let effective_stage = {
let ratio = actual_tokens as f64 / budget as f64;
let auto_stage = match ratio {
r if r < 0.60 => CompressionStage::StopwordOnly,
r if r < 0.80 => CompressionStage::PruneLowImportance,
r if r < 0.95 => CompressionStage::DeduplicateAndLinearize,
_ => CompressionStage::MaxCompression,
};
auto_stage.max(stage)
};
if effective_stage < stage {
continue;
}
let retry_cfg = CompressionConfig {
budget,
current_tokens: actual_tokens,
fidelity,
};
let retry_nodes = compressor.compress(current_nodes.clone(), &retry_cfg);
current_nodes = retry_nodes;
}
current_nodes
}
pub async fn transpile_stream(
input: &str,
format: InputFormat,
fidelity: FidelityLevel,
budget: usize,
) -> std::pin::Pin<Box<dyn futures::Stream<Item = Result<TranspileChunk, StreamError>> + Send>> {
if input.len() > MAX_INPUT_BYTES {
return Box::pin(futures::stream::once(futures::future::ready(Err(
StreamError::InputTooLarge(input.len()),
))));
}
let sanitized = strip_pua(input);
let input_ref = sanitized.as_ref();
let doc = match parser::parse(input_ref, format, fidelity, Some(budget)) {
Ok(doc) => doc,
Err(msg) => {
return Box::pin(futures::stream::once(futures::future::ready(Err(
StreamError::Parse(msg),
))));
}
};
let transpiler = StreamingTranspiler::new(budget, fidelity);
Box::pin(transpiler.transpile(doc))
}
pub fn token_count(text: &str) -> usize {
stream::estimate_tokens(text)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum TokenMethod {
Heuristic,
Bpe,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct TokenMeasurement {
pub tokens: usize,
pub method: TokenMethod,
}
impl TokenMeasurement {
pub fn heuristic(text: &str) -> Self {
Self {
tokens: stream::estimate_tokens_heuristic(text),
method: TokenMethod::Heuristic,
}
}
#[cfg(feature = "tiktoken")]
pub fn bpe(text: &str) -> Self {
Self {
tokens: bpe_token_count(text),
method: TokenMethod::Bpe,
}
}
}
#[cfg(feature = "tiktoken")]
pub fn bpe_token_count(text: &str) -> usize {
use std::sync::OnceLock;
static BPE: OnceLock<Option<tiktoken_rs::CoreBPE>> = OnceLock::new();
let bpe = BPE.get_or_init(|| tiktoken_rs::cl100k_base().ok());
match bpe {
Some(b) => b.encode_ordinary(text).len().max(1),
None => token_count(text),
}
}
pub fn measure_tokens(text: &str) -> TokenMeasurement {
#[cfg(feature = "tiktoken")]
{
TokenMeasurement::bpe(text)
}
#[cfg(not(feature = "tiktoken"))]
{
TokenMeasurement::heuristic(text)
}
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct DualTokenMeasurement {
pub heuristic: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub bpe: Option<usize>,
}
pub fn measure_tokens_dual(text: &str) -> DualTokenMeasurement {
let heuristic = stream::estimate_tokens_heuristic(text);
#[cfg(feature = "tiktoken")]
{
DualTokenMeasurement {
heuristic,
bpe: Some(bpe_token_count(text)),
}
}
#[cfg(not(feature = "tiktoken"))]
{
DualTokenMeasurement {
heuristic,
bpe: None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const SAMPLE_MD: &str = r#"
# 소프트웨어 라이선스 계약
## 계약 당사자
본 계약은 갑(라이선서)과 을(라이선시) 사이에 체결됩니다.
## 주요 조항
- 소스 코드 배포 금지
- 역설계 금지
- 연간 라이선스 비용: 1,000,000원
| 항목 | 금액 |
|------|------|
| 기본료 | 800,000원 |
| 유지보수 | 200,000원 |
"#;
#[test]
fn transpile_markdown_produces_bridge_format() {
let result = transpile(
SAMPLE_MD,
InputFormat::Markdown,
FidelityLevel::Semantic,
Some(2048),
);
assert!(
result.is_ok(),
"transpile should succeed: {:?}",
result.err()
);
let output = result.unwrap();
assert!(output.contains("<B>"), "output must contain <B> tag");
assert!(
output.contains("</B>"),
"output must contain </B> closing tag"
);
}
#[test]
fn transpile_lossless_preserves_content() {
let result = transpile(
"중요한 법적 내용입니다.",
InputFormat::PlainText,
FidelityLevel::Lossless,
None,
);
let output = result.unwrap();
assert!(output.contains("중요한 법적 내용입니다."));
}
#[test]
fn token_count_is_positive() {
assert!(token_count("hello world") > 0);
}
#[test]
fn measure_tokens_dual_returns_heuristic_always() {
let m = measure_tokens_dual("hello world transformer");
assert!(m.heuristic > 0);
}
#[test]
fn measure_tokens_dual_bpe_present_with_feature() {
let m = measure_tokens_dual("hello world transformer");
#[cfg(feature = "tiktoken")]
{
assert!(
m.bpe.is_some(),
"BPE measurement must be present with tiktoken feature"
);
assert!(m.bpe.unwrap() > 0);
}
#[cfg(not(feature = "tiktoken"))]
{
assert!(m.bpe.is_none(), "BPE must be None without tiktoken feature");
}
}
#[cfg(feature = "tiktoken")]
#[test]
fn measure_tokens_dual_heuristic_and_bpe_genuinely_differ() {
let m = measure_tokens_dual("\u{E000}");
let bpe = m.bpe.expect("BPE present under tiktoken");
assert_eq!(
m.heuristic, 1,
"heuristic must count a PUA char as 1 token (its baked-in assumption)"
);
assert_eq!(
bpe, 3,
"cl100k must count a PUA char as 3 tokens (byte-fallback ground truth)"
);
assert_ne!(
m.heuristic, bpe,
"dual measurement must genuinely differ — this is the whole point"
);
}
#[cfg(not(feature = "tiktoken"))]
#[test]
fn pua_heuristic_assumes_one_token_per_char() {
let one = "\u{E000}";
let eight = "\u{E000}\u{E001}\u{E002}\u{E003}\u{E004}\u{E005}\u{E006}\u{E007}";
assert_eq!(token_count(one), 1);
assert_eq!(token_count(eight), 8);
}
#[cfg(feature = "tiktoken")]
#[test]
fn pua_real_token_cost_is_documented() {
let one_pua = "\u{E000}";
let eight_pua = "\u{E000}\u{E001}\u{E002}\u{E003}\u{E004}\u{E005}\u{E006}\u{E007}";
assert_eq!(
token_count(one_pua),
3,
"single PUA char = 3 cl100k tokens (byte-fallback)"
);
assert_eq!(
token_count(eight_pua),
24,
"8 distinct PUA chars = 24 tokens (3 each, no merge). \
If this drifts, update the token-honesty docs."
);
assert_eq!(bpe_token_count(one_pua), token_count(one_pua));
}
#[cfg(feature = "tiktoken")]
#[test]
fn pua_substitution_break_even_is_empirically_honest() {
let pua_tok = bpe_token_count("\u{E000}");
assert_eq!(pua_tok, 3, "PUA char costs 3 tokens");
let phrase = "large language model";
assert_eq!(bpe_token_count(phrase), 3);
assert!(
bpe_token_count(phrase) <= pua_tok,
"3-token phrase does not benefit from PUA substitution (tie)"
);
let long_term = "transformer-based language model fine-tuning pipeline";
let long_tok = bpe_token_count(long_term);
assert!(
long_tok > pua_tok,
"long term ({long_tok}) must exceed PUA cost ({pua_tok}) to save tokens"
);
}
#[cfg(feature = "tiktoken")]
#[test]
fn heuristic_tracks_bpe_within_band() {
let text = "The quick brown fox jumps over the lazy dog near the riverbank.";
let h = token_count(text);
let b = bpe_token_count(text);
let ratio = h as f64 / b as f64;
assert!(
(0.5..=2.0).contains(&ratio),
"heuristic/bpe ratio {ratio:.2} outside [0.5, 2.0] for Latin prose \
(heuristic={h}, bpe={b})"
);
}
#[test]
fn pua_chars_stripped_from_input() {
let input_with_pua = "hello \u{E000}world\u{F8FF}";
let output = transpile(
input_with_pua,
InputFormat::PlainText,
FidelityLevel::Lossless,
None,
)
.unwrap();
assert!(
!output.contains('\u{E000}'),
"PUA characters must not appear in output"
);
assert!(output.contains("hello"), "plain text must be preserved");
assert!(
output.contains("world"),
"adjacent text after PUA removal must be preserved"
);
}
#[tokio::test]
async fn stream_error_variant_is_send_and_stream_works() {
use futures::StreamExt;
use stream::StreamError;
fn _assert_send<T: Send>(_: T) {}
_assert_send(StreamError::Parse("test".to_string()));
let mut stream = transpile_stream(
SAMPLE_MD,
InputFormat::Markdown,
FidelityLevel::Semantic,
8192,
)
.await;
let first = stream.next().await.expect("at least one chunk must exist");
assert!(
first.is_ok(),
"valid input must yield an Ok chunk: {:?}",
first.err()
);
}
#[test]
fn transpile_rejects_oversized_input() {
let huge = "a".repeat(MAX_INPUT_BYTES + 1);
let result = transpile(&huge, InputFormat::PlainText, FidelityLevel::Lossless, None);
assert!(
matches!(result, Err(TranspileError::InputTooLarge(_))),
"expected InputTooLarge, got: {:?}",
result
);
}
#[tokio::test]
async fn stream_rejects_oversized_input() {
use futures::StreamExt;
let huge = "a".repeat(MAX_INPUT_BYTES + 1);
let mut stream =
transpile_stream(&huge, InputFormat::PlainText, FidelityLevel::Lossless, 0).await;
let first = stream.next().await.expect("must yield an error item");
assert!(
matches!(first, Err(stream::StreamError::InputTooLarge(_))),
"oversized stream input must yield InputTooLarge, got: {:?}",
first
);
}
#[test]
fn transpile_auto_interns_frequent_terms() {
let md = "# Test\n\nAPI endpoint API endpoint API endpoint API endpoint API endpoint.";
let result = transpile(
md,
InputFormat::Markdown,
FidelityLevel::Semantic,
Some(4096),
);
let output = result.unwrap();
assert!(
!output.contains("<D>"),
"short common term 'API endpoint' (≤ pua_cost) must not be PUA-substituted: {output}"
);
}
#[test]
fn transpile_default_build_gate_uses_consistent_units() {
let body = "transformer-architecture ".repeat(8);
let result = transpile(
&body,
InputFormat::Markdown,
FidelityLevel::Semantic,
Some(4096),
);
let output = result.unwrap();
assert!(output.contains("<B>"));
}
#[test]
fn transpile_interns_long_high_freq_term_under_heuristic() {
let term = "internationalization-localization-pipeline";
let md = format!("# Doc\n\n{term} {term} {term} {term} {term} {term} {term} {term}.");
let result = transpile(
&md,
InputFormat::Markdown,
FidelityLevel::Semantic,
Some(4096),
);
let output = result.unwrap();
assert!(output.contains("<B>"));
assert!(
output.contains("<D>"),
"long high-freq term should clear the ROI bar (heuristic AND cl100k): {output}"
);
}
#[test]
fn transpile_no_auto_intern_in_lossless() {
let md = "API API API API API API.";
let result = transpile(md, InputFormat::PlainText, FidelityLevel::Lossless, None);
let output = result.unwrap();
assert!(output.contains("<B>"));
}
#[test]
fn transpile_no_intern_for_rare_terms() {
let md = "This document mentions API once.";
let result = transpile(
md,
InputFormat::PlainText,
FidelityLevel::Semantic,
Some(4096),
);
let output = result.unwrap();
assert!(output.contains("<B>"));
}
#[test]
fn html_pua_entity_stripped_after_tag_removal() {
let html = "<p>hello  world</p>";
let output = transpile(html, InputFormat::Html, FidelityLevel::Lossless, None).unwrap();
assert!(
!output.contains('\u{E000}'),
"PUA from HTML entity decoding must be stripped"
);
assert!(
output.contains("hello"),
"surrounding text must be preserved"
);
}
}