Skip to main content

llm_transpile/
lib.rs

1//! # llm-transpiler
2//!
3//! A high-performance Rust library that converts raw documents (Markdown, HTML,
4//! Plain Text, Tables, etc.) into a structured bridge format so LLM agents can
5//! receive **maximum information with minimum tokens**.
6//!
7//! ## Quick Start
8//!
9//! ```rust
10//! use llm_transpile::{transpile, FidelityLevel, InputFormat};
11//!
12//! let md = "# Contract\n\nThis agreement was concluded in 2024.";
13//! let result = transpile(md, InputFormat::Markdown, FidelityLevel::Semantic, Some(4096))
14//!     .expect("transpile failed");
15//! println!("{}", result);
16//! ```
17//!
18//! ## Streaming Usage
19//!
20//! ```rust,no_run
21//! use llm_transpile::{transpile_stream, FidelityLevel, InputFormat};
22//! use futures::StreamExt;
23//!
24//! async fn example() {
25//!     let md = "# Document\n\nThis is a paragraph.";
26//!     let mut stream = transpile_stream(md, InputFormat::Markdown, FidelityLevel::Semantic, 4096).await;
27//!     while let Some(chunk) = stream.next().await {
28//!         let chunk = chunk.expect("stream error");
29//!         print!("{}", chunk.content);
30//!         if chunk.is_final { break; }
31//!     }
32//! }
33//! ```
34
35// ────────────────────────────────────────────────
36// Internal modules
37// ────────────────────────────────────────────────
38
39pub(crate) mod compressor;
40pub(crate) mod ir;
41pub(crate) mod renderer;
42pub(crate) mod stream;
43pub(crate) mod symbol;
44
45// Parser module (Markdown → IR)
46mod parser;
47
48// ────────────────────────────────────────────────
49// Public re-exports
50// ────────────────────────────────────────────────
51
52pub use compressor::{AdaptiveCompressor, CompressionConfig, CompressionStage};
53pub use ir::{DocNode, FidelityLevel, IRDocument};
54pub use renderer::{build_yaml_header, linearize_table, render_full, render_node};
55pub use stream::{StreamError, StreamingTranspiler, TranspileChunk};
56pub use symbol::SymbolDict;
57
58// ────────────────────────────────────────────────
59// Public enumerations
60// ────────────────────────────────────────────────
61
62/// Input document format.
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum InputFormat {
65    /// Plain text.
66    PlainText,
67    /// CommonMark-compatible Markdown.
68    Markdown,
69    /// HTML5.
70    Html,
71}
72
73// ────────────────────────────────────────────────
74// Top-level error type
75// ────────────────────────────────────────────────
76
77/// Transpile error.
78#[derive(Debug, thiserror::Error)]
79pub enum TranspileError {
80    #[error("parse failed: {0}")]
81    Parse(String),
82
83    #[error("symbol table overflow: {0}")]
84    SymbolOverflow(#[from] symbol::SymbolOverflowError),
85
86    #[error("stream error: {0}")]
87    Stream(#[from] stream::StreamError),
88
89    #[error("compression attempted in Lossless mode")]
90    LosslessModeViolation,
91
92    #[error("input exceeds maximum allowed size of {0} bytes")]
93    InputTooLarge(usize),
94}
95
96/// Maximum input size accepted by [`transpile`] and [`transpile_stream`].
97/// Inputs larger than this limit are rejected with [`TranspileError::InputTooLarge`]
98/// to prevent resource exhaustion on unbounded documents.
99pub const MAX_INPUT_BYTES: usize = 10 * 1024 * 1024; // 10 MiB
100
101// ────────────────────────────────────────────────
102// Internal helpers
103// ────────────────────────────────────────────────
104
105/// Strips Unicode PUA range (U+E000–U+F8FF) characters from the input string.
106/// Prevents external input from colliding with the internal symbol substitution scheme.
107fn strip_pua(input: &str) -> std::borrow::Cow<'_, str> {
108    if input
109        .chars()
110        .any(|c| ('\u{E000}'..='\u{F8FF}').contains(&c))
111    {
112        std::borrow::Cow::Owned(
113            input
114                .chars()
115                .filter(|c| !('\u{E000}'..='\u{F8FF}').contains(c))
116                .collect(),
117        )
118    } else {
119        std::borrow::Cow::Borrowed(input)
120    }
121}
122
123// ────────────────────────────────────────────────
124// Internal helpers: auto term discovery
125// ────────────────────────────────────────────────
126
127/// Automatically discovers frequently occurring terms in the document's body text
128/// and registers them in the SymbolDict for PUA substitution.
129///
130/// Only runs when fidelity allows compression. Terms must appear at least `min_freq` times
131/// across all body text nodes (Para, Header, List items). Short terms (< 3 chars for ASCII,
132/// < 2 chars for non-ASCII) are excluded because they don't save enough tokens to justify
133/// the dictionary entry overhead.
134////// ## ROI gate (P1a)
135///
136/// A term is only interned when the net token saving is positive. Both `term_tokens`
137/// and the PUA cost are measured by the **same active tokenizer** (see
138/// [`stream::pua_token_cost`]), so the gate is self-consistent under every build:
139///
140/// ```text
141/// body_saving  = count × (term_tokens - pua_cost)   // each occurrence
142/// dict_overhead = pua_cost + term_tokens + ENTRY_OVERHEAD
143///                // the "<PUA>=<term>\n" line in the <D> block
144/// intern  iff body_saving > dict_overhead
145/// ```
146///
147/// Under the `tiktoken` feature `pua_cost` = 3 (the real cl100k byte-fallback cost),
148/// so the gate is BPE-honest. Under the default heuristic build `pua_cost` = 1, which
149/// keeps the two sides in the same units — but note the heuristic itself is the
150/// self-referential estimate this crate's eval documents as inflated; install the
151/// `tiktoken` feature for honest numbers.
152///
153/// This prevents low-ROI substitutions (e.g. a 2-token word appearing 3 times) from
154/// inflating the `<D>` block more than they save in the body.
155fn auto_intern_frequent_terms(
156    doc: &IRDocument,
157    dict: &mut SymbolDict,
158    min_freq: usize,
159    max_terms: usize,
160) {
161    use std::collections::HashMap;
162
163    if !doc.fidelity.allows_compression() {
164        return;
165    }
166
167    // Count token frequencies across all body text nodes
168    let mut freq: HashMap<&str, usize> = HashMap::new();
169    for node in &doc.nodes {
170        let text: Option<&str> = match node {
171            DocNode::Para { text, .. } => Some(text.as_str()),
172            DocNode::Header { text, .. } => Some(text.as_str()),
173            DocNode::List { items, .. } => {
174                // Count tokens in list items
175                for item in items {
176                    for token in item.split_whitespace() {
177                        let min_len = if token.is_ascii() { 3 } else { 2 };
178                        if token.len() >= min_len {
179                            *freq.entry(token).or_insert(0) += 1;
180                        }
181                    }
182                }
183                None
184            }
185            _ => None,
186        };
187        if let Some(text) = text {
188            for token in text.split_whitespace() {
189                let min_len = if token.is_ascii() { 3 } else { 2 };
190                if token.len() >= min_len {
191                    *freq.entry(token).or_insert(0) += 1;
192                }
193            }
194        }
195    }
196
197    // Filter by min_freq, sort by frequency descending, take top max_terms
198    let mut candidates: Vec<(&str, usize)> = freq
199        .into_iter()
200        .filter(|(_, count)| *count >= min_freq)
201        .collect();
202    candidates.sort_by_key(|b| std::cmp::Reverse(b.1));
203
204    // ── ROI gate ─────────────────────────────────────────────────────────────
205    // A substituted occurrence replaces `term_tokens` with one PUA char. Both
206    // `term_tokens` and `pua_cost` come from the SAME active measurement
207    // (`stream::estimate_tokens` / `stream::pua_token_cost`), so the gate's two
208    // sides are always in the same units:
209    //   - tiktoken:  pua_cost = 3 (real cl100k byte-fallback ground truth) → BPE-honest
210    //   - heuristic: pua_cost = 1 (the chars-per-token heuristic itself; this is the
211    //     self-referential estimate the eval documents as inflated — gate is
212    //     self-consistent but not honest until the feature is enabled)
213    //
214    // Net saving across the document:
215    //   body_saving   = count × (term_tokens − pua_cost)   // each occurrence
216    //   dict_overhead = pua_cost + term_tokens + ENTRY_OVERHEAD
217    //                  // the "<PUA>=<term>\n" line in the <D> block
218    // Intern iff body_saving > dict_overhead (strictly positive net).
219    //
220    // `pua_cost` is a per-build constant (see `stream::pua_token_cost`), so it is
221    // hoisted out of the candidate loop rather than recomputed per term — under
222    // the `tiktoken` feature that would otherwise re-run a cl100k BPE encode for
223    // every candidate (up to `max_terms`).
224    let pua_cost = stream::pua_token_cost();
225
226    for (term, count) in candidates.into_iter().take(max_terms) {
227        let term_tokens = stream::estimate_tokens(term);
228        if term_tokens <= pua_cost {
229            // Break-even bar: a term must cost MORE than a PUA char for
230            // substitution to save anything at all per occurrence.
231            continue;
232        }
233        let per_occurrence_saving = term_tokens - pua_cost; // > 0 here
234        let body_saving = count.saturating_mul(per_occurrence_saving);
235        let dict_overhead = pua_cost + term_tokens + DICT_ENTRY_OVERHEAD;
236        if body_saving <= dict_overhead {
237            // Dictionary overhead cancels the body saving; skip.
238            continue;
239        }
240        // Ignore overflow — we just stop interning if we run out of PUA symbols
241        let _ = dict.intern(term);
242    }
243}
244
245/// Token cost of a single Unicode PUA character under the **real** cl100k BPE
246/// tokenizer.
247///
248/// Empirically measured: PUA codepoints (U+E000–U+F8FF) are absent from the cl100k
249/// merge table, so each encodes via byte-fallback to **3 tokens**. This constant
250/// replaces the old "PUA = 1 token" assumption that inflated reduction claims and
251/// caused ROI-negative substitutions.
252///
253/// Reported by the eval harness for transparency and used as a build-independent
254/// ground-truth reference. The ROI gate does **not** use this constant directly —
255/// it calls [`stream::pua_token_cost`], which returns this value under the
256/// `tiktoken` feature and the heuristic's `1` under the default build, so the
257/// gate's two sides always share the same unit.
258///
259/// **Caveat:** this value is only realized when the `tiktoken` feature is enabled.
260/// Without it, the heuristic estimates PUA at 1 token — the same self-referential
261/// assumption the eval flags as inflated. For honest interning decisions, build
262/// with `--features tiktoken`.
263pub const PUA_TOKEN_COST: usize = 3;
264
265/// Approximate extra token overhead of a `<D>` dictionary entry beyond the PUA
266/// char and the term text itself — the `=`, the `\n`, and BPE boundary effects.
267///
268/// Measured entries: `"PUA=foo\n"` = 5 tokens (PUA 3 + "=foo"+newline ≈ 2),
269/// `"PUA=transformer\n"` = 7 tokens. The fixed non-term portion hovers around
270/// 3–4; we use a conservative 4 so the ROI bar errs toward *not* interning
271/// (the empirically safer default given PUA's high base cost).
272const DICT_ENTRY_OVERHEAD: usize = 4;
273
274// ────────────────────────────────────────────────
275// Public API
276// ────────────────────────────────────────────────
277
278/// Converts a document **synchronously** into the bridge format.
279///
280/// # Arguments
281/// - `input`    — source document text
282/// - `format`   — input format (Markdown / HTML / PlainText)
283/// - `fidelity` — semantic preservation level
284/// - `budget`   — maximum token count (`None` = unlimited)
285///
286/// # Returns
287/// Bridge-format string (`<D>?<H><B>...</B>`)
288///
289/// # Errors
290/// Returns `TranspileError` on parse failure or symbol table overflow.
291pub fn transpile(
292    input: &str,
293    format: InputFormat,
294    fidelity: FidelityLevel,
295    budget: Option<usize>,
296) -> Result<String, TranspileError> {
297    if input.len() > MAX_INPUT_BYTES {
298        return Err(TranspileError::InputTooLarge(input.len()));
299    }
300    let input = strip_pua(input);
301    let input = input.as_ref();
302
303    // 1. Parse → IR
304    let mut doc = parser::parse(input, format, fidelity, budget).map_err(TranspileError::Parse)?;
305
306    // 2. Compress + hard-cap re-compression loop (only when a budget is provided)
307    if let Some(b) = budget
308        && fidelity != FidelityLevel::Lossless
309    {
310        doc.nodes = compress_to_budget(std::mem::take(&mut doc.nodes), b, fidelity, input);
311    }
312
313    // 3. Auto-discover frequent terms for symbol substitution
314    let mut dict = SymbolDict::new();
315    auto_intern_frequent_terms(&doc, &mut dict, 3, 50);
316
317    // 4. Render
318    let output = render_full(&doc, &mut dict);
319    Ok(output)
320}
321
322/// Compresses `nodes` until the rendered output fits within `budget` tokens,
323/// or until further compression yields no improvement.
324///
325/// Strategy:
326/// 1. First pass uses `current_tokens` estimated from the raw input.
327/// 2. After rendering, if the output still exceeds `budget`, the actual
328///    token count is fed back as `current_tokens` and compression is retried
329///    at the next higher stage.
330/// 3. The loop terminates when either:
331///    - output fits within `budget`, or
332///    - two consecutive passes produce the same node count (compression
333///      saturated — further iterations would be identical).
334///
335/// Maximum iterations: 4 (one per `CompressionStage`).
336fn compress_to_budget(
337    nodes: Vec<DocNode>,
338    budget: usize,
339    fidelity: FidelityLevel,
340    raw_input: &str,
341) -> Vec<DocNode> {
342    use compressor::CompressionStage;
343
344    let compressor = AdaptiveCompressor::new();
345
346    // Stages in ascending order — we walk up from the initial estimate.
347    const STAGES: &[CompressionStage] = &[
348        CompressionStage::StopwordOnly,
349        CompressionStage::PruneLowImportance,
350        CompressionStage::DeduplicateAndLinearize,
351        CompressionStage::MaxCompression,
352    ];
353
354    // Initial compression: use raw-input token estimate (same as before).
355    let initial_tokens = stream::estimate_tokens(raw_input);
356    let cfg = CompressionConfig {
357        budget,
358        current_tokens: initial_tokens,
359        fidelity,
360    };
361    let mut current_nodes = compressor.compress(nodes, &cfg);
362    let mut prev_node_count = usize::MAX;
363
364    for &stage in STAGES {
365        // Render to measure actual output tokens.
366        // We use a temporary empty dict here — symbol substitution happens later
367        // in the main flow and only saves ~1% tokens, so it does not affect the
368        // hard-cap decision materially.
369        let tmp_output = {
370            let mut tmp_dict = SymbolDict::new();
371            let mut tmp_doc = ir::IRDocument::new(fidelity, Some(budget));
372            tmp_doc.nodes = current_nodes.clone();
373            renderer::render_full(&tmp_doc, &mut tmp_dict)
374        };
375        let actual_tokens = stream::estimate_tokens(&tmp_output);
376
377        // Within budget — done.
378        if actual_tokens <= budget {
379            break;
380        }
381
382        // Saturated — further compression would be a no-op.
383        if current_nodes.len() == prev_node_count {
384            break;
385        }
386        prev_node_count = current_nodes.len();
387
388        // Skip stages that are at or below what the compressor already applied.
389        let effective_stage = {
390            let ratio = actual_tokens as f64 / budget as f64;
391            let auto_stage = match ratio {
392                r if r < 0.60 => CompressionStage::StopwordOnly,
393                r if r < 0.80 => CompressionStage::PruneLowImportance,
394                r if r < 0.95 => CompressionStage::DeduplicateAndLinearize,
395                _ => CompressionStage::MaxCompression,
396            };
397            auto_stage.max(stage)
398        };
399
400        if effective_stage < stage {
401            continue;
402        }
403
404        // Re-compress at the actual measured token count.
405        let retry_cfg = CompressionConfig {
406            budget,
407            current_tokens: actual_tokens,
408            fidelity,
409        };
410        let retry_nodes = compressor.compress(current_nodes.clone(), &retry_cfg);
411        current_nodes = retry_nodes;
412    }
413
414    current_nodes
415}
416
417/// Converts a document into a **Tokio stream**.
418///
419/// The first chunk is delivered immediately, minimizing TTFT.
420///
421/// # Arguments
422/// - `input`    — source document text
423/// - `format`   — input format (Markdown / HTML / PlainText)
424/// - `fidelity` — semantic preservation level
425/// - `budget`   — maximum allowed token count. Passing `0` is treated as
426///   "unlimited" and immediately switches to `Compressed` mode during
427///   budget-usage calculations. Use a positive non-zero value to enforce a token limit.
428///
429/// # Errors
430/// On parse failure, `Err(StreamError::Parse(...))` is sent as the first stream item
431/// and the stream is then closed. Use [`transpile`] if you prefer a single `Result`.
432pub async fn transpile_stream(
433    input: &str,
434    format: InputFormat,
435    fidelity: FidelityLevel,
436    budget: usize,
437) -> std::pin::Pin<Box<dyn futures::Stream<Item = Result<TranspileChunk, StreamError>> + Send>> {
438    if input.len() > MAX_INPUT_BYTES {
439        return Box::pin(futures::stream::once(futures::future::ready(Err(
440            StreamError::InputTooLarge(input.len()),
441        ))));
442    }
443    let sanitized = strip_pua(input);
444    let input_ref = sanitized.as_ref();
445
446    let doc = match parser::parse(input_ref, format, fidelity, Some(budget)) {
447        Ok(doc) => doc,
448        Err(msg) => {
449            // Parse failure: immediately return a stream containing a single Err chunk.
450            // futures::future::ready() is Unpin, so it can be safely used with stream::once.
451            return Box::pin(futures::stream::once(futures::future::ready(Err(
452                StreamError::Parse(msg),
453            ))));
454        }
455    };
456
457    let transpiler = StreamingTranspiler::new(budget, fidelity);
458    Box::pin(transpiler.transpile(doc))
459}
460
461/// Returns the approximate token count for the given text.
462///
463/// Uses a character-count-based heuristic without a real model tokenizer.
464/// For higher accuracy, use `tiktoken-rs` or the `tokenizers` crate directly.
465pub fn token_count(text: &str) -> usize {
466    stream::estimate_tokens(text)
467}
468
469/// Token-count measurement methodology.
470///
471/// Used to make explicit *which* counting method produced a number, so that
472/// downstream reports cannot silently mix the fast heuristic with an accurate
473/// BPE tokenizer.
474#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
475#[serde(rename_all = "snake_case")]
476#[non_exhaustive]
477pub enum TokenMethod {
478    /// Character-count heuristic (`chars_per_token`). Fast, dependency-free,
479    /// but **self-referential** when used to evaluate this crate's own output:
480    /// it bakes in the same assumptions (e.g. PUA = 1 token) that the
481    /// compressor optimizes for, inflating the apparent reduction.
482    Heuristic,
483    /// Real BPE tokenizer (OpenAI `cl100k_base` via `tiktoken-rs`). Slower and
484    /// requires the `tiktoken` feature, but independent of this crate's
485    /// compression assumptions — the honest basis for reduction claims.
486    Bpe,
487}
488
489/// A token-count measurement pairing a numeric result with its methodology.
490///
491/// Construct via [`measure_tokens`] (BPE when the `tiktoken` feature is
492/// enabled, heuristic otherwise) or explicitly via [`TokenMeasurement::bpe`] /
493/// [`TokenMeasurement::heuristic`].
494#[derive(Debug, Clone, serde::Serialize)]
495pub struct TokenMeasurement {
496    /// The counted token count.
497    pub tokens: usize,
498    /// How it was counted.
499    pub method: TokenMethod,
500}
501
502impl TokenMeasurement {
503    /// A heuristic measurement.
504    ///
505    /// Uses `estimate_tokens_heuristic` directly (not `token_count`, which
506    /// dispatches to BPE under the `tiktoken` feature). This keeps the
507    /// heuristic value stable and comparable regardless of features.
508    pub fn heuristic(text: &str) -> Self {
509        Self {
510            tokens: stream::estimate_tokens_heuristic(text),
511            method: TokenMethod::Heuristic,
512        }
513    }
514
515    /// A BPE measurement using `cl100k_base`. Only available with the
516    /// `tiktoken` feature.
517    #[cfg(feature = "tiktoken")]
518    pub fn bpe(text: &str) -> Self {
519        Self {
520            tokens: bpe_token_count(text),
521            method: TokenMethod::Bpe,
522        }
523    }
524}
525
526/// Counts tokens using the real `cl100k_base` BPE tokenizer.
527///
528/// Requires the `tiktoken` feature. Returns the heuristic estimate as a
529/// fallback if the tokenizer failed to initialize (should not happen with the
530/// bundled merge table).
531#[cfg(feature = "tiktoken")]
532pub fn bpe_token_count(text: &str) -> usize {
533    use std::sync::OnceLock;
534    static BPE: OnceLock<Option<tiktoken_rs::CoreBPE>> = OnceLock::new();
535    let bpe = BPE.get_or_init(|| tiktoken_rs::cl100k_base().ok());
536    match bpe {
537        Some(b) => b.encode_ordinary(text).len().max(1),
538        None => token_count(text),
539    }
540}
541
542/// Measures `text` with the most accurate method available.
543///
544/// With the `tiktoken` feature this returns a BPE measurement; otherwise a
545/// heuristic measurement. Use [`measure_tokens_dual`] when you need both
546/// side-by-side for an honest comparison.
547pub fn measure_tokens(text: &str) -> TokenMeasurement {
548    #[cfg(feature = "tiktoken")]
549    {
550        TokenMeasurement::bpe(text)
551    }
552    #[cfg(not(feature = "tiktoken"))]
553    {
554        TokenMeasurement::heuristic(text)
555    }
556}
557
558/// Measures `text` with both methodologies when `tiktoken` is enabled.
559///
560/// Without the `tiktoken` feature, the `bpe` field is `None` and only the
561/// heuristic number is reported. This struct is the foundation for
562/// non-self-referential reduction reporting: compare the BPE numbers, not the
563/// heuristic ones, when making token-saving claims.
564#[derive(Debug, Clone, serde::Serialize)]
565pub struct DualTokenMeasurement {
566    pub heuristic: usize,
567    #[serde(skip_serializing_if = "Option::is_none")]
568    pub bpe: Option<usize>,
569}
570
571/// Counts `text` with both the heuristic and (if available) the BPE tokenizer.
572///
573/// The heuristic value comes from `estimate_tokens_heuristic` (always the
574/// character-count estimate, never the BPE path), so under the `tiktoken`
575/// feature the two fields genuinely differ — enabling an honest comparison
576/// of the self-referential heuristic against the real tokenizer.
577pub fn measure_tokens_dual(text: &str) -> DualTokenMeasurement {
578    let heuristic = stream::estimate_tokens_heuristic(text);
579    #[cfg(feature = "tiktoken")]
580    {
581        DualTokenMeasurement {
582            heuristic,
583            bpe: Some(bpe_token_count(text)),
584        }
585    }
586    #[cfg(not(feature = "tiktoken"))]
587    {
588        DualTokenMeasurement {
589            heuristic,
590            bpe: None,
591        }
592    }
593}
594
595// ────────────────────────────────────────────────
596// Integration tests
597// ────────────────────────────────────────────────
598
599#[cfg(test)]
600mod tests {
601    use super::*;
602
603    const SAMPLE_MD: &str = r#"
604# 소프트웨어 라이선스 계약
605
606## 계약 당사자
607
608본 계약은 갑(라이선서)과 을(라이선시) 사이에 체결됩니다.
609
610## 주요 조항
611
612- 소스 코드 배포 금지
613- 역설계 금지
614- 연간 라이선스 비용: 1,000,000원
615
616| 항목 | 금액 |
617|------|------|
618| 기본료 | 800,000원 |
619| 유지보수 | 200,000원 |
620"#;
621
622    #[test]
623    fn transpile_markdown_produces_bridge_format() {
624        let result = transpile(
625            SAMPLE_MD,
626            InputFormat::Markdown,
627            FidelityLevel::Semantic,
628            Some(2048),
629        );
630        assert!(
631            result.is_ok(),
632            "transpile should succeed: {:?}",
633            result.err()
634        );
635        let output = result.unwrap();
636        assert!(output.contains("<B>"), "output must contain <B> tag");
637        assert!(
638            output.contains("</B>"),
639            "output must contain </B> closing tag"
640        );
641    }
642
643    #[test]
644    fn transpile_lossless_preserves_content() {
645        let result = transpile(
646            "중요한 법적 내용입니다.",
647            InputFormat::PlainText,
648            FidelityLevel::Lossless,
649            None,
650        );
651        let output = result.unwrap();
652        assert!(output.contains("중요한 법적 내용입니다."));
653    }
654
655    #[test]
656    fn token_count_is_positive() {
657        assert!(token_count("hello world") > 0);
658    }
659
660    #[test]
661    fn measure_tokens_dual_returns_heuristic_always() {
662        let m = measure_tokens_dual("hello world transformer");
663        // Heuristic is always available regardless of features.
664        assert!(m.heuristic > 0);
665    }
666
667    #[test]
668    fn measure_tokens_dual_bpe_present_with_feature() {
669        let m = measure_tokens_dual("hello world transformer");
670        #[cfg(feature = "tiktoken")]
671        {
672            assert!(
673                m.bpe.is_some(),
674                "BPE measurement must be present with tiktoken feature"
675            );
676            assert!(m.bpe.unwrap() > 0);
677        }
678        #[cfg(not(feature = "tiktoken"))]
679        {
680            assert!(m.bpe.is_none(), "BPE must be None without tiktoken feature");
681        }
682    }
683
684    /// AC (dual-measurement independence): under the `tiktoken` feature the two
685    /// measurements must genuinely differ — the heuristic and cl100k must NOT
686    /// produce identical counts for text where their assumptions diverge. A PUA
687    /// codepoint is the canonical divergence: the heuristic counts it as 1 token
688    /// (its built-in assumption), while cl100k byte-fallback counts 3. Without
689    /// this test a regression that compiles out the heuristic under `tiktoken`
690    /// would make "dual" report the same number twice and go unnoticed.
691    #[cfg(feature = "tiktoken")]
692    #[test]
693    fn measure_tokens_dual_heuristic_and_bpe_genuinely_differ() {
694        // A single PUA char: heuristic = 1 (assumption), cl100k = 3 (byte-fallback).
695        let m = measure_tokens_dual("\u{E000}");
696        let bpe = m.bpe.expect("BPE present under tiktoken");
697        assert_eq!(
698            m.heuristic, 1,
699            "heuristic must count a PUA char as 1 token (its baked-in assumption)"
700        );
701        assert_eq!(
702            bpe, 3,
703            "cl100k must count a PUA char as 3 tokens (byte-fallback ground truth)"
704        );
705        assert_ne!(
706            m.heuristic, bpe,
707            "dual measurement must genuinely differ — this is the whole point"
708        );
709    }
710
711    /// AC1: documents the heuristic tokenizer's PUA assumption (tiktoken OFF).
712    ///
713    /// Without the `tiktoken` feature, `token_count` uses the
714    /// `chars_per_token` heuristic which **assumes PUA = 1 token per char**
715    /// (`stream.rs` PUA branch). This is the self-referential assumption the
716    /// compressor optimizes for. See `pua_real_token_cost_*` (tiktoken ON)
717    /// for the ground truth that disproves it.
718    #[cfg(not(feature = "tiktoken"))]
719    #[test]
720    fn pua_heuristic_assumes_one_token_per_char() {
721        let one = "\u{E000}";
722        let eight = "\u{E000}\u{E001}\u{E002}\u{E003}\u{E004}\u{E005}\u{E006}\u{E007}";
723        // The heuristic's baked-in (incorrect) assumption.
724        assert_eq!(token_count(one), 1);
725        assert_eq!(token_count(eight), 8);
726    }
727
728    /// AC1: empirically measures the *real* cl100k token cost of PUA (tiktoken ON).
729    ///
730    /// Ground truth: PUA codepoints are absent from cl100k's merge table, so
731    /// each encodes via byte-fallback to **3 tokens**, and distinct PUA chars
732    /// never merge. Measured (cl100k_base):
733    ///
734    /// | text                 | heuristic (off) | real (on) |
735    /// |----------------------|-----------------|-----------|
736    /// | 1 PUA char           | 1               | 3         |
737    /// | 8 distinct PUA chars | 8               | 24        |
738    ///
739    /// **Implication**: the heuristic *undercounts* PUA cost by 3×, so any
740    /// reduction figure computed with the heuristic on PUA-heavy output is
741    /// inflated. Honest reports must use BPE numbers.
742    #[cfg(feature = "tiktoken")]
743    #[test]
744    fn pua_real_token_cost_is_documented() {
745        let one_pua = "\u{E000}";
746        let eight_pua = "\u{E000}\u{E001}\u{E002}\u{E003}\u{E004}\u{E005}\u{E006}\u{E007}";
747
748        // With tiktoken ON, token_count dispatches to the real BPE tokenizer.
749        assert_eq!(
750            token_count(one_pua),
751            3,
752            "single PUA char = 3 cl100k tokens (byte-fallback)"
753        );
754        assert_eq!(
755            token_count(eight_pua),
756            24,
757            "8 distinct PUA chars = 24 tokens (3 each, no merge). \
758             If this drifts, update the token-honesty docs."
759        );
760        // bpe_token_count agrees with token_count under the feature.
761        assert_eq!(bpe_token_count(one_pua), token_count(one_pua));
762    }
763
764    /// AC1: demonstrates the PUA substitution break-even empirically.
765    ///
766    /// Ground truth (cl100k_base):
767    /// - PUA char = 3 tokens (byte-fallback)
768    /// - "large language model" = 3 tokens (each common word is 1 token)
769    /// - "retrieval augmented generation" = 3 tokens (also < 4)
770    ///
771    /// **Break-even**: a term only pays to PUA-substitute if it costs **more
772    /// than 3 tokens**. Common multi-word phrases like "large language model"
773    /// are *already* 3 tokens — substituting with a PUA char (3 tokens) saves
774    /// nothing. This empirically refutes the premise that PUA substitution
775    /// broadly reduces tokens; under a real tokenizer it rarely does, and the
776    /// heuristic (which counts PUA as 1) massively overstates any saving.
777    #[cfg(feature = "tiktoken")]
778    #[test]
779    fn pua_substitution_break_even_is_empirically_honest() {
780        let pua_tok = bpe_token_count("\u{E000}");
781        assert_eq!(pua_tok, 3, "PUA char costs 3 tokens");
782
783        // A 3-token phrase substitutes to a 3-token PUA → zero saving.
784        let phrase = "large language model";
785        assert_eq!(bpe_token_count(phrase), 3);
786        assert!(
787            bpe_token_count(phrase) <= pua_tok,
788            "3-token phrase does not benefit from PUA substitution (tie)"
789        );
790
791        // Only a 4+ token term would genuinely save (1 token) — verify such
792        // terms exist and cross the bar, proving the break-even claim.
793        let long_term = "transformer-based language model fine-tuning pipeline";
794        let long_tok = bpe_token_count(long_term);
795        assert!(
796            long_tok > pua_tok,
797            "long term ({long_tok}) must exceed PUA cost ({pua_tok}) to save tokens"
798        );
799    }
800
801    /// AC1: a plain Latin sentence must have a heuristic that is *consistent*
802    /// with (but not necessarily equal to) the real BPE count, within a sane
803    /// band. Catches gross regressions in `chars_per_token`.
804    #[cfg(feature = "tiktoken")]
805    #[test]
806    fn heuristic_tracks_bpe_within_band() {
807        let text = "The quick brown fox jumps over the lazy dog near the riverbank.";
808        let h = token_count(text);
809        let b = bpe_token_count(text);
810        // Heuristic should be within 2× of BPE for plain Latin prose.
811        // (CJK-heavy text is excluded — heuristic diverges more there, which
812        // is exactly why BPE measurement exists.)
813        let ratio = h as f64 / b as f64;
814        assert!(
815            (0.5..=2.0).contains(&ratio),
816            "heuristic/bpe ratio {ratio:.2} outside [0.5, 2.0] for Latin prose \
817             (heuristic={h}, bpe={b})"
818        );
819    }
820
821    #[test]
822    fn pua_chars_stripped_from_input() {
823        let input_with_pua = "hello \u{E000}world\u{F8FF}";
824        let output = transpile(
825            input_with_pua,
826            InputFormat::PlainText,
827            FidelityLevel::Lossless,
828            None,
829        )
830        .unwrap();
831        assert!(
832            !output.contains('\u{E000}'),
833            "PUA characters must not appear in output"
834        );
835        assert!(output.contains("hello"), "plain text must be preserved");
836        assert!(
837            output.contains("world"),
838            "adjacent text after PUA removal must be preserved"
839        );
840    }
841
842    #[tokio::test]
843    async fn stream_error_variant_is_send_and_stream_works() {
844        use futures::StreamExt;
845        use stream::StreamError;
846
847        // Compile-time check for StreamError::Parse variant
848        fn _assert_send<T: Send>(_: T) {}
849        _assert_send(StreamError::Parse("test".to_string()));
850
851        // Verify normal streaming behavior
852        let mut stream = transpile_stream(
853            SAMPLE_MD,
854            InputFormat::Markdown,
855            FidelityLevel::Semantic,
856            8192,
857        )
858        .await;
859        let first = stream.next().await.expect("at least one chunk must exist");
860        assert!(
861            first.is_ok(),
862            "valid input must yield an Ok chunk: {:?}",
863            first.err()
864        );
865    }
866
867    #[test]
868    fn transpile_rejects_oversized_input() {
869        let huge = "a".repeat(MAX_INPUT_BYTES + 1);
870        let result = transpile(&huge, InputFormat::PlainText, FidelityLevel::Lossless, None);
871        assert!(
872            matches!(result, Err(TranspileError::InputTooLarge(_))),
873            "expected InputTooLarge, got: {:?}",
874            result
875        );
876    }
877
878    #[tokio::test]
879    async fn stream_rejects_oversized_input() {
880        use futures::StreamExt;
881        let huge = "a".repeat(MAX_INPUT_BYTES + 1);
882        let mut stream =
883            transpile_stream(&huge, InputFormat::PlainText, FidelityLevel::Lossless, 0).await;
884        let first = stream.next().await.expect("must yield an error item");
885        assert!(
886            matches!(first, Err(stream::StreamError::InputTooLarge(_))),
887            "oversized stream input must yield InputTooLarge, got: {:?}",
888            first
889        );
890    }
891
892    #[test]
893    fn transpile_auto_interns_frequent_terms() {
894        // The ROI gate compares a candidate term's token count against the *measured*
895        // PUA cost (`stream::pua_token_cost()` — 1 under the heuristic, 3 under
896        // cl100k). "API endpoint" is well under the bar under BOTH tokenizers (a
897        // short, common phrase), so it must NOT be interned — substitution would add
898        // tokens. Because both sides of the gate now share one unit, this holds
899        // regardless of the `tiktoken` feature.
900        let md = "# Test\n\nAPI endpoint API endpoint API endpoint API endpoint API endpoint.";
901        let result = transpile(
902            md,
903            InputFormat::Markdown,
904            FidelityLevel::Semantic,
905            Some(4096),
906        );
907        let output = result.unwrap();
908        assert!(
909            !output.contains("<D>"),
910            "short common term 'API endpoint' (≤ pua_cost) must not be PUA-substituted: {output}"
911        );
912    }
913
914    /// AC (gate unit-consistency): in the DEFAULT (heuristic) build, the gate's
915    /// `pua_cost` is 1 (same unit as the heuristic term_tokens). A long
916    /// Latin term whose *heuristic* token count clears the bar is interned — and
917    /// because the units match, this is a self-consistent decision (not a unit
918    /// mismatch that could smuggle in ROI-negative substitutions). This test pins
919    /// that the default build's gate no longer mixes the old `PUA_TOKEN_COST = 3`
920    /// constant against a heuristic `term_tokens`.
921    #[test]
922    fn transpile_default_build_gate_uses_consistent_units() {
923        // "transformer-architecture" previously over-interned ROI-negative in the
924        // default build because the gate mixed heuristic term_tokens (6) with the
925        // real-cl100k PUA_TOKEN_COST (3). With `pua_token_cost()` the default build
926        // uses pua_cost = 1, so this term (heuristic 6) clears the per-occurrence
927        // bar — but it is now a *consistent* decision. We assert only the guarantee
928        // common to both tokenizers: the output is well-formed and the gate ran
929        // without panicking on the unit mismatch.
930        let body = "transformer-architecture ".repeat(8);
931        let result = transpile(
932            &body,
933            InputFormat::Markdown,
934            FidelityLevel::Semantic,
935            Some(4096),
936        );
937        let output = result.unwrap();
938        assert!(output.contains("<B>"));
939    }
940
941    /// A genuinely long, high-frequency term CAN cross the ROI bar. This test uses
942    /// a term long enough that even after the honest PUA cost (3) and dictionary
943    /// overhead, repeated occurrences save tokens. The intent is to verify the gate
944    /// *permits* substitution when it is truly profitable, not that it always blocks.
945    #[test]
946    fn transpile_interns_long_high_freq_term_under_heuristic() {
947        // "internationalization-localization-pipeline" (40 chars). Repeated 8×, it
948        // clears the ROI bar under BOTH tokenizers:
949        //   - heuristic: term ≈ 11 tokens, pua_cost = 1 → per-occ saving 10 × 8 ≫ overhead
950        //   - cl100k:     term = 6 tokens, pua_cost = 3 → per-occ saving 3 × 8 = 24 > 13 overhead
951        // So we assert the *positive* (permits) half of the gate under both builds,
952        // not just the heuristic one.
953        let term = "internationalization-localization-pipeline";
954        let md = format!("# Doc\n\n{term} {term} {term} {term} {term} {term} {term} {term}.");
955        let result = transpile(
956            &md,
957            InputFormat::Markdown,
958            FidelityLevel::Semantic,
959            Some(4096),
960        );
961        let output = result.unwrap();
962        assert!(output.contains("<B>"));
963        assert!(
964            output.contains("<D>"),
965            "long high-freq term should clear the ROI bar (heuristic AND cl100k): {output}"
966        );
967    }
968
969    #[test]
970    fn transpile_no_auto_intern_in_lossless() {
971        // Lossless mode should still work (no auto-intern doesn't break anything)
972        let md = "API API API API API API.";
973        let result = transpile(md, InputFormat::PlainText, FidelityLevel::Lossless, None);
974        let output = result.unwrap();
975        // Lossless may or may not have <D> — just verify it doesn't crash
976        assert!(output.contains("<B>"));
977    }
978
979    #[test]
980    fn transpile_no_intern_for_rare_terms() {
981        // A term appearing only once should NOT be interned
982        let md = "This document mentions API once.";
983        let result = transpile(
984            md,
985            InputFormat::PlainText,
986            FidelityLevel::Semantic,
987            Some(4096),
988        );
989        let output = result.unwrap();
990        // Rare term should not trigger a <D> block (saves dictionary overhead)
991        // This test verifies min_freq threshold works
992        assert!(output.contains("<B>"));
993    }
994
995    #[test]
996    fn html_pua_entity_stripped_after_tag_removal() {
997        // &#xE000; decoded by ammonia becomes a PUA char — must be stripped
998        let html = "<p>hello &#xE000; world</p>";
999        let output = transpile(html, InputFormat::Html, FidelityLevel::Lossless, None).unwrap();
1000        assert!(
1001            !output.contains('\u{E000}'),
1002            "PUA from HTML entity decoding must be stripped"
1003        );
1004        assert!(
1005            output.contains("hello"),
1006            "surrounding text must be preserved"
1007        );
1008    }
1009}