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 //  decoded by ammonia becomes a PUA char — must be stripped
998 let html = "<p>hello  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}