diff --git a/TECHNICAL_REPORTS/1518-chat-template-cache-20260830.en.md b/TECHNICAL_REPORTS/1518-chat-template-cache-20260830.en.md new file mode 100644 index 000000000..89ea3c0c3 --- /dev/null +++ b/TECHNICAL_REPORTS/1518-chat-template-cache-20260830.en.md @@ -0,0 +1,63 @@ +# Technical Report: Chat Template Compile Cache + +## Summary + +PR #1518 resolves issue #1235 by caching the compiled MiniJinja chat template environment on each `ChatTemplateProcessor`. Before this change, every typed render, raw JSON render, history render and prompt-cache probe render rebuilt the environment, re-registered filters/functions/method callbacks and recompiled the immutable template source. + +The implementation moves compilation behind a processor-owned `OnceLock`, stores an owned `Environment<'static>` through MiniJinja's owned-template API, and keeps all request-specific values outside the cache. Messages, tools, kwargs, generation-prompt mode and thinking aliases are still rebuilt per render. + +## Problem + +The loaded template string is immutable for the processor lifetime, but `apply_inner` and `apply_raw_inner` both created a new MiniJinja environment and added the same `"chat"` template on every call. A single chat-completions request can call the renderer multiple times: once for the primary prompt, once for a prompt-cache history boundary, and again for next-turn warm-up probes. + +That made modern 5-20 KB chat templates pay parse/compile and filter registration cost repeatedly even though only the render context changes between calls. + +## Implementation + +- Added a `compiled_template` cache to `ChatTemplateProcessor`, shared across clones through `Arc>`. +- Added `compile_chat_template_environment`, which configures the MiniJinja environment once and inserts the template through `add_template_owned`. +- Replaced duplicated environment construction in both raw and typed render paths with `render_template`, so `apply_inner`, `apply_raw_inner`, history renders and probe renders share the same compiled template. +- Cached immutable parse-failure results as well, so a malformed immutable template is not reparsed on every fallback attempt. +- Preserved `TemplateRejection` behavior by leaving `raise_exception` as a render-time callable in the cached environment and rebuilding request contexts per render. + +## Correctness + +The cache owns only the operator/model-supplied template and MiniJinja environment configuration. It does not retain request data, tool declarations, kwargs or generation-prompt mode. This keeps request isolation intact while allowing the bytecode and callable registration to be reused. + +The test-only compile counter is stored per processor rather than globally, avoiding parallel test-order coupling. It verifies that typed and raw paths share one compile, clones reuse the same cache, malformed templates cache their parse failure, and concurrent render races converge to one successful compile. + +## Compatibility + +The focused byte-identity test compares cached renders against a fresh-environment oracle that mirrors the pre-change behavior. It covers typed render, typed history render, raw JSON render and raw JSON history render with tools and kwargs. The existing `server::chat_template::tests` suite also passed, preserving the broad established fixture coverage. + +The #1176 rejection/fallback split remains intact: template-side `raise_exception` still exposes the `TemplateRejection` sentinel, while engine failures and parse failures remain non-rejections and continue to be eligible for fallback behavior at the request layer. + +## Performance Evidence + +A bounded debug-profile smoke run over 400 renders reported: + +- Fresh environment/render loop: 27.248578 ms +- Cached environment/render loop: 7.99117 ms + +This is a local unit-level measurement, not a production throughput benchmark. It demonstrates the expected direction and approximate magnitude for the isolated compile/render boundary without claiming end-to-end serving performance. + +## Validation + +- `cargo fmt` passed. +- `cargo test --lib cached_template` passed: 6 passed, 1 ignored. +- `cargo test --lib server::chat_template::tests` passed: 90 passed, 2 ignored. +- `cargo test --lib server::reasoning_effort_tests` passed: 28 passed. +- `cargo test --lib history_render` passed: 5 passed. +- `cargo test --lib cached_template_performance_smoke_reports_delta -- --ignored --nocapture` passed and produced the timing evidence above. +- `cargo clippy --lib --tests -- -D warnings` passed. +- `git diff --check` passed. + +## Skipped Validation + +Broad cargo workspace tests, serial all-tests, workspace clippy and cold release builds were intentionally skipped under the wave-runner watchdog guard. Local model-template audit tests requiring a populated `models/` directory remained ignored. + +## Risk Notes + +The cached environment is shared across cloned processors. This is intentional because clones represent the same immutable template lifetime, but future code that introduces mutable template source on a processor must allocate a new cache with the new source. + +Malformed-template errors now surface through a cached internal error wrapper instead of a newly created MiniJinja error for each call. The user-visible parse context is preserved and the rejection discriminator remains false; no current tests or request behavior depend on downcasting parse errors to `minijinja::Error`. diff --git a/TECHNICAL_REPORTS/1518-chat-template-cache-20260830.ko.md b/TECHNICAL_REPORTS/1518-chat-template-cache-20260830.ko.md new file mode 100644 index 000000000..5a1f31c8b --- /dev/null +++ b/TECHNICAL_REPORTS/1518-chat-template-cache-20260830.ko.md @@ -0,0 +1,63 @@ +# 기술 보고서: 채팅 템플릿 컴파일 캐시 + +## 요약 + +PR #1518은 issue #1235를 해결하기 위해 각 `ChatTemplateProcessor`에 컴파일된 MiniJinja 채팅 템플릿 환경을 캐시한다. 변경 전에는 typed 렌더, raw JSON 렌더, history 렌더, 프롬프트 캐시 probe 렌더가 모두 매 호출마다 환경을 새로 만들고 필터, 함수, 메서드 콜백을 다시 등록한 뒤 불변 템플릿 소스를 다시 컴파일했다. + +이번 구현은 컴파일을 프로세서 소유 `OnceLock` 뒤로 옮기고, MiniJinja의 owned-template API를 사용해 `Environment<'static>`을 저장한다. 요청별 값은 캐시에 들어가지 않는다. 메시지, 도구, kwargs, generation-prompt 모드, thinking alias는 여전히 렌더마다 새로 구성된다. + +## 문제 + +로드된 템플릿 문자열은 프로세서 생명주기 동안 불변이지만, `apply_inner`와 `apply_raw_inner`는 매 호출마다 새 MiniJinja 환경을 만들고 같은 `"chat"` 템플릿을 추가했다. 하나의 chat-completions 요청도 primary prompt, prompt-cache history boundary, next-turn warm-up probe 때문에 렌더러를 여러 번 호출할 수 있다. + +그 결과 현대적인 5-20 KB 채팅 템플릿은 호출마다 parse/compile 비용과 필터 등록 비용을 반복해서 지불했다. 실제로 바뀌는 것은 렌더 컨텍스트뿐이었다. + +## 구현 + +- `ChatTemplateProcessor`에 `compiled_template` 캐시를 추가하고, `Arc>`로 clone 간에 공유했다. +- MiniJinja 환경을 한 번 설정하고 `add_template_owned`로 템플릿을 삽입하는 `compile_chat_template_environment`를 추가했다. +- raw와 typed 렌더 경로의 중복 환경 생성을 `render_template` 호출로 대체해 `apply_inner`, `apply_raw_inner`, history 렌더, probe 렌더가 같은 컴파일 결과를 공유하게 했다. +- 불변 템플릿의 parse failure도 캐시해 malformed template이 fallback 시도마다 재파싱되지 않게 했다. +- `raise_exception`은 캐시된 환경의 렌더 시점 callable로 유지하고 요청 컨텍스트는 매번 새로 만들기 때문에 `TemplateRejection` 동작을 보존했다. + +## 정확성 + +캐시는 운영자 또는 모델이 제공한 템플릿과 MiniJinja 환경 설정만 보관한다. 요청 데이터, 도구 선언, kwargs, generation-prompt 모드는 보관하지 않는다. 따라서 요청 격리는 유지하면서 bytecode와 callable 등록만 재사용한다. + +테스트 전용 compile counter는 전역이 아니라 프로세서별로 저장된다. 그래서 병렬 테스트 실행 순서에 의존하지 않고 typed/raw 경로가 하나의 compile을 공유하는지, clone이 같은 캐시를 재사용하는지, malformed template의 parse failure가 캐시되는지, 동시 렌더 race가 하나의 compile로 수렴하는지를 검증한다. + +## 호환성 + +집중 byte-identity 테스트는 변경 전 동작을 모사하는 fresh-environment oracle과 캐시 렌더 결과를 직접 비교한다. 도구와 kwargs가 있는 typed render, typed history render, raw JSON render, raw JSON history render를 모두 포함한다. 기존 `server::chat_template::tests`도 통과해 기존 fixture 기반 동작을 보존했다. + +#1176의 rejection/fallback 분기도 유지된다. 템플릿이 `raise_exception`으로 거부한 경우는 계속 `TemplateRejection` sentinel을 노출하고, engine failure와 parse failure는 rejection으로 분류되지 않아 요청 계층의 fallback 후보로 남는다. + +## 성능 근거 + +400회 렌더를 수행한 제한된 debug-profile smoke run 결과는 다음과 같다. + +- Fresh environment/render loop: 27.248578 ms +- Cached environment/render loop: 7.99117 ms + +이는 로컬 단위 측정이며 production throughput benchmark가 아니다. 컴파일/렌더 경계에서 기대한 방향과 대략적인 크기의 개선을 확인하기 위한 근거로만 사용한다. + +## 검증 + +- `cargo fmt` 통과. +- `cargo test --lib cached_template` 통과: 6 passed, 1 ignored. +- `cargo test --lib server::chat_template::tests` 통과: 90 passed, 2 ignored. +- `cargo test --lib server::reasoning_effort_tests` 통과: 28 passed. +- `cargo test --lib history_render` 통과: 5 passed. +- `cargo test --lib cached_template_performance_smoke_reports_delta -- --ignored --nocapture` 통과, 위 timing evidence 산출. +- `cargo clippy --lib --tests -- -D warnings` 통과. +- `git diff --check` 통과. + +## 생략한 검증 + +Wave-runner watchdog guard에 따라 broad cargo workspace tests, serial all-tests, workspace clippy, cold release builds는 의도적으로 실행하지 않았다. 로컬 `models/` 디렉터리가 필요한 model-template audit 테스트는 ignored 상태로 유지했다. + +## 위험 및 참고 사항 + +캐시된 환경은 clone된 프로세서 간에 공유된다. 이는 clone이 같은 불변 템플릿 생명주기를 나타낸다는 전제에서 의도한 동작이다. 향후 프로세서에서 템플릿 소스를 변경하는 코드가 생기면 새 소스와 함께 새 캐시를 할당해야 한다. + +Malformed-template 오류는 이제 매 호출마다 새 MiniJinja error를 만들지 않고 캐시된 내부 오류 wrapper를 통해 노출된다. 사용자에게 보이는 parse context는 유지되고 rejection discriminator는 계속 false다. 현재 테스트와 요청 동작 중 parse error를 `minijinja::Error`로 downcast해야 하는 경로는 없다. diff --git a/docs/supported-models.md b/docs/supported-models.md index 6fc9b5a86..47b37dadc 100644 --- a/docs/supported-models.md +++ b/docs/supported-models.md @@ -470,6 +470,8 @@ A template that refuses the value calls Jinja's `raise_exception`, and that now The `400` is specific to a deliberate refusal, not to render failures in general. mlxcel tells the two apart at the type level: `raise_exception` attaches a `TemplateRejection` sentinel as the error's source, so a template that mlxcel genuinely cannot render (an unimplemented filter, a malformed template) still degrades to the plain prompt exactly as it did before. The type is `pub`, so it is nameable outside the crate (`mlxcel::server::chat_template::TemplateRejection`), but its constructor and its field are both private, so nothing outside the render path can construct one and forge a false rejection. The rule applies to any template that validates a caller-supplied value, not just to `reasoning_effort`: templates that reject an `enable_thinking` value, a tool-choice value, or an unsupported role behave the same way. +The compiled MiniJinja environment is cached on the `ChatTemplateProcessor` for the lifetime of the loaded template. The typed-message renderer, raw JSON renderer, history renderer and probe renders all look up the same compiled `"chat"` template instead of rebuilding the environment, filters and bytecode on each call. The cache stores only the operator/model-supplied template and environment configuration; per-request messages, tools and kwargs are still rebuilt for every render, so request isolation and the `TemplateRejection` source-chain behavior above remain unchanged. + The Responses API's `reasoning.effort` feeds this same resolver and remains echoed unchanged on the response object. Its derived controls likewise override server-wide template defaults per key. ### `tojson` in chat templates behaves like Python `json.dumps` diff --git a/src/server/chat_template.rs b/src/server/chat_template.rs index 6c8856d8e..5cbd9004e 100644 --- a/src/server/chat_template.rs +++ b/src/server/chat_template.rs @@ -42,6 +42,7 @@ //! `.get()`, …) through minijinja's unknown-method callback. use std::path::Path; +use std::sync::{Arc, OnceLock}; use anyhow::{Context, Result}; use minijinja::{Environment, ErrorKind, Value}; @@ -147,6 +148,8 @@ pub fn template_rejection_message(err: &anyhow::Error) -> Option<&str> { #[derive(Clone)] pub struct ChatTemplateProcessor { template: String, + compiled_template: + Arc, ChatTemplateCompileError>>>, bos_token: String, eos_token: String, add_generation_prompt: bool, @@ -167,8 +170,45 @@ pub struct ChatTemplateProcessor { /// path that constructs a processor without a corresponding tokenizer /// (template-string overrides, `Default`, tests). default_enable_thinking: bool, + #[cfg(test)] + template_compile_count: Arc, } +#[derive(Debug, Clone)] +struct ChatTemplateCompileError { + kind: ErrorKind, + detail: String, + name: Option, + line: Option, +} + +impl From for ChatTemplateCompileError { + fn from(err: minijinja::Error) -> Self { + Self { + kind: err.kind(), + detail: err.to_string(), + name: err.name().map(str::to_string), + line: err.line(), + } + } +} + +impl std::fmt::Display for ChatTemplateCompileError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.detail)?; + write!(f, " ({:?}", self.kind)?; + if let Some(name) = &self.name { + write!(f, ", template {name}")?; + } + if let Some(line) = self.line { + write!(f, ", line {line}")?; + } + write!(f, ")") + } +} + +impl std::error::Error for ChatTemplateCompileError {} + impl ChatTemplateProcessor { /// Create a new processor by loading template from tokenizer_config.json or chat_template.jinja pub fn from_model_path(model_path: &Path) -> Result> { @@ -246,11 +286,14 @@ impl ChatTemplateProcessor { Ok(Some(Self { template: preprocess_template(template), + compiled_template: Arc::new(OnceLock::new()), bos_token, eos_token, add_generation_prompt: true, supports_tools_cached: None, default_enable_thinking: false, + #[cfg(test)] + template_compile_count: Arc::new(std::sync::atomic::AtomicUsize::new(0)), })) } @@ -258,11 +301,14 @@ impl ChatTemplateProcessor { pub fn with_template(template: String) -> Self { Self { template: preprocess_template(template), + compiled_template: Arc::new(OnceLock::new()), bos_token: String::new(), eos_token: String::new(), add_generation_prompt: true, supports_tools_cached: None, default_enable_thinking: false, + #[cfg(test)] + template_compile_count: Arc::new(std::sync::atomic::AtomicUsize::new(0)), } } @@ -308,6 +354,41 @@ impl ChatTemplateProcessor { &self.template } + /// Return how many times this processor has compiled its template. + /// + /// Test-only instrumentation for issue #1235. Keeping the counter on the + /// processor avoids global test-order coupling while proving both render + /// entry points share the same cached environment. + #[cfg(test)] + fn template_compile_count(&self) -> usize { + self.template_compile_count + .load(std::sync::atomic::Ordering::SeqCst) + } + + fn compiled_environment(&self) -> Result<&Environment<'static>> { + const CHAT_TEMPLATE_NAME: &str = "chat"; + + match self.compiled_template.get_or_init(|| { + #[cfg(test)] + self.template_compile_count + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + + compile_chat_template_environment(CHAT_TEMPLATE_NAME, &self.template) + .map_err(ChatTemplateCompileError::from) + }) { + Ok(env) => Ok(env), + Err(err) => { + Err(anyhow::Error::new(err.clone()).context("Failed to parse chat template")) + } + } + } + + fn render_template(&self, context: minijinja::Value) -> Result { + let tmpl = self.compiled_environment()?.get_template("chat")?; + tmpl.render(context) + .with_context(|| "Failed to render chat template") + } + /// Default tool-call output format implied by this template. /// /// Muse Glimmer's pinned template declares the ATEM grammar directly, so @@ -627,14 +708,6 @@ impl ChatTemplateProcessor { kwargs: &ChatTemplateKwargs, add_generation_prompt: bool, ) -> Result { - let mut env = Environment::new(); - configure_environment(&mut env); - - env.add_template("chat", &self.template) - .with_context(|| "Failed to parse chat template")?; - - let tmpl = env.get_template("chat")?; - // Convert serde_json::Value to minijinja::Value let messages_val = minijinja::Value::from_serialize(messages); @@ -663,8 +736,7 @@ impl ChatTemplateProcessor { // enable_thinking branches faithfully (issue #686), so no post-render // Gemma-4 patching is applied. The `enable_thinking` value reaches the // template through `build_template_context` above. - tmpl.render(context) - .with_context(|| "Failed to render chat template") + self.render_template(context) } /// Apply the chat template to messages. @@ -716,14 +788,6 @@ impl ChatTemplateProcessor { kwargs: &ChatTemplateKwargs, add_generation_prompt: bool, ) -> Result { - let mut env = Environment::new(); - configure_environment(&mut env); - - env.add_template("chat", &self.template) - .with_context(|| "Failed to parse chat template")?; - - let tmpl = env.get_template("chat")?; - // Many templates conditionally check variables like `tools`, // `enable_thinking`, etc. We must provide them with the right // concrete type to avoid undefined/None errors — in particular, @@ -752,11 +816,20 @@ impl ChatTemplateProcessor { // enable_thinking branches faithfully (issue #686), so no post-render // Gemma-4 patching is applied. The `enable_thinking` value reaches the // template through `build_template_context` above. - tmpl.render(context) - .with_context(|| "Failed to render chat template") + self.render_template(context) } } +fn compile_chat_template_environment( + template_name: &'static str, + template: &str, +) -> std::result::Result, minijinja::Error> { + let mut env = Environment::new(); + configure_environment(&mut env); + env.add_template_owned(template_name.to_string(), template.to_string())?; + Ok(env) +} + /// Build the minijinja template context merging the standard chat fields with /// caller-provided kwargs. /// @@ -1531,6 +1604,7 @@ impl std::fmt::Debug for ChatTemplateProcessor { .field("eos_token", &self.eos_token) .field("add_generation_prompt", &self.add_generation_prompt) .field("supports_tools_cached", &self.supports_tools_cached) + .field("compiled_template", &self.compiled_template.get().is_some()) .finish_non_exhaustive() } } @@ -1539,6 +1613,326 @@ impl std::fmt::Debug for ChatTemplateProcessor { mod tests { use super::*; + fn fresh_render_context( + processor: &ChatTemplateProcessor, + messages_val: minijinja::Value, + tools: Option<&[Tool]>, + kwargs: &ChatTemplateKwargs, + add_generation_prompt: bool, + ) -> Result { + let mut env = Environment::new(); + configure_environment(&mut env); + + env.add_template("chat", &processor.template) + .with_context(|| "Failed to parse chat template")?; + + let tmpl = env.get_template("chat")?; + let tools_val = match tools { + Some(t) => minijinja::Value::from_serialize(t), + None => minijinja::Value::from_serialize(Vec::::new()), + }; + let context = build_template_context( + messages_val, + &processor.bos_token, + &processor.eos_token, + add_generation_prompt, + tools_val, + kwargs, + processor.default_enable_thinking, + processor.wants_bare_thinking_alias(), + processor.wants_thinking_mode_alias(), + ); + + tmpl.render(context) + .with_context(|| "Failed to render chat template") + } + + fn fresh_typed_render( + processor: &ChatTemplateProcessor, + messages: &[ChatMessage], + tools: Option<&[Tool]>, + kwargs: &ChatTemplateKwargs, + add_generation_prompt: bool, + ) -> Result { + fresh_render_context( + processor, + minijinja::Value::from_serialize(messages), + tools, + kwargs, + add_generation_prompt, + ) + } + + fn fresh_raw_render( + processor: &ChatTemplateProcessor, + messages: &serde_json::Value, + tools: Option<&[Tool]>, + kwargs: &ChatTemplateKwargs, + add_generation_prompt: bool, + ) -> Result { + fresh_render_context( + processor, + minijinja::Value::from_serialize(messages), + tools, + kwargs, + add_generation_prompt, + ) + } + + #[test] + fn cached_template_is_shared_by_typed_and_raw_render_paths() { + let template = r#"{% for message in messages %}[{{ message.role }}:{{ message.content }}]{% endfor %}{% if add_generation_prompt %}[GEN]{% endif %}"#; + let processor = ChatTemplateProcessor::with_template(template.to_string()); + let typed = vec![ChatMessage { + role: "user".to_string(), + content: "hello".to_string(), + }]; + let raw = serde_json::json!([{"role": "user", "content": "hello"}]); + + assert_eq!(processor.template_compile_count(), 0); + + let typed_prompt = processor.apply(&typed, None).expect("typed render"); + let typed_history = processor + .apply_history_with_kwargs(&typed, None, &ChatTemplateKwargs::new()) + .expect("typed history render"); + let raw_prompt = processor.apply_raw(&raw, None).expect("raw render"); + let raw_history = processor + .apply_raw_history_with_kwargs(&raw, None, &ChatTemplateKwargs::new()) + .expect("raw history render"); + + assert_eq!(typed_prompt, "[user:hello][GEN]"); + assert_eq!(typed_history, "[user:hello]"); + assert_eq!(raw_prompt, typed_prompt); + assert_eq!(raw_history, typed_history); + assert_eq!( + processor.template_compile_count(), + 1, + "typed and raw render paths must reuse the same compiled template" + ); + } + + #[test] + fn cached_template_render_is_byte_identical_to_fresh_environment_render() { + let template = r#"{% if tools %}[TOOLS={{ tools | tojson(sort_keys=true) }}]{% endif %}{% if preserve_thinking %}[KEEP]{% endif %}{% for message in messages %}[{{ message.role }}={{ message.content }}]{% endfor %}{% if add_generation_prompt %}[ASSIST]{% endif %}"#; + let processor = ChatTemplateProcessor::with_template(template.to_string()); + let typed = vec![ + ChatMessage { + role: "system".to_string(), + content: "be exact".to_string(), + }, + ChatMessage { + role: "user".to_string(), + content: "hello".to_string(), + }, + ]; + let raw = serde_json::json!([ + {"role": "system", "content": "be exact"}, + {"role": "user", "content": "hello"} + ]); + let tools = vec![Tool { + tool_type: "function".to_string(), + function: crate::server::types::request::FunctionDefinition { + name: "lookup".to_string(), + description: Some("Look up facts".to_string()), + parameters: Some(serde_json::json!({"type": "object"})), + }, + }]; + let kwargs = ChatTemplateKwargs::from_json_str(r#"{"preserve_thinking": true}"#).unwrap(); + + assert_eq!( + processor + .apply_with_kwargs(&typed, Some(&tools), &kwargs) + .expect("cached typed render"), + fresh_typed_render(&processor, &typed, Some(&tools), &kwargs, true) + .expect("fresh typed render") + ); + assert_eq!( + processor + .apply_history_with_kwargs(&typed, Some(&tools), &kwargs) + .expect("cached typed history render"), + fresh_typed_render(&processor, &typed, Some(&tools), &kwargs, false) + .expect("fresh typed history render") + ); + assert_eq!( + processor + .apply_raw_with_kwargs(&raw, Some(&tools), &kwargs) + .expect("cached raw render"), + fresh_raw_render(&processor, &raw, Some(&tools), &kwargs, true) + .expect("fresh raw render") + ); + assert_eq!( + processor + .apply_raw_history_with_kwargs(&raw, Some(&tools), &kwargs) + .expect("cached raw history render"), + fresh_raw_render(&processor, &raw, Some(&tools), &kwargs, false) + .expect("fresh raw history render") + ); + assert_eq!(processor.template_compile_count(), 1); + } + + #[test] + fn cached_template_is_thread_safe_and_compiles_once_under_concurrent_renders() { + let processor = Arc::new(ChatTemplateProcessor::with_template( + r#"{% for message in messages %}{{ message.role }}:{{ message.content }};{% endfor %}"# + .to_string(), + )); + let expected = "user:hello;"; + + std::thread::scope(|scope| { + let mut handles = Vec::new(); + for _ in 0..16 { + let processor = Arc::clone(&processor); + handles.push(scope.spawn(move || { + processor + .apply( + &[ChatMessage { + role: "user".to_string(), + content: "hello".to_string(), + }], + None, + ) + .expect("render") + })); + } + for handle in handles { + assert_eq!(handle.join().expect("thread joined"), expected); + } + }); + + assert_eq!( + processor.template_compile_count(), + 1, + "concurrent renders must race through OnceLock into one compile" + ); + } + + #[test] + fn cached_template_preserves_rejection_sentinel_and_engine_failure_split() { + let reject = ChatTemplateProcessor::with_template( + "{{ raise_exception('refuse ' ~ messages[0].role) }}".to_string(), + ); + let typed = vec![ChatMessage { + role: "user".to_string(), + content: "hello".to_string(), + }]; + let raw = serde_json::json!([{"role": "user", "content": "hello"}]); + + let typed_err = reject + .apply(&typed, None) + .expect_err("typed render must reject"); + assert_eq!( + template_rejection_message(&typed_err), + Some("refuse user"), + "typed render must preserve the TemplateRejection source" + ); + let raw_err = reject + .apply_raw(&raw, None) + .expect_err("raw render must reject"); + assert_eq!( + template_rejection_message(&raw_err), + Some("refuse user"), + "raw render must preserve the TemplateRejection source" + ); + assert_eq!(reject.template_compile_count(), 1); + + let engine_failure = + ChatTemplateProcessor::with_template("{{ missing | no_such_filter }}".to_string()); + let err = engine_failure + .apply(&typed, None) + .expect_err("unknown filter is an engine failure"); + assert!( + template_rejection_message(&err).is_none(), + "engine-side failures must still be eligible for fallback" + ); + assert_eq!(engine_failure.template_compile_count(), 1); + } + + #[test] + fn cached_template_parse_failure_is_not_retried_or_misclassified() { + let processor = ChatTemplateProcessor::with_template("{% for m in messages %}".to_string()); + let raw = serde_json::json!([{"role": "user", "content": "hello"}]); + + for _ in 0..2 { + let err = processor + .apply_raw(&raw, None) + .expect_err("malformed template must fail to parse"); + assert!( + template_rejection_message(&err).is_none(), + "parse failures must remain engine failures, got: {err:#}" + ); + assert!( + format!("{err:#}").contains("Failed to parse chat template"), + "parse context must be preserved, got: {err:#}" + ); + } + + assert_eq!( + processor.template_compile_count(), + 1, + "a malformed immutable template should not be reparsed after the first failure" + ); + } + + #[test] + fn cloned_processor_reuses_the_same_cached_template() { + let processor = ChatTemplateProcessor::with_template("{{ messages[0].content }}".into()); + let cloned = processor.clone(); + let messages = vec![ChatMessage { + role: "user".to_string(), + content: "shared".to_string(), + }]; + + assert_eq!(processor.apply(&messages, None).unwrap(), "shared"); + assert_eq!(cloned.apply(&messages, None).unwrap(), "shared"); + assert_eq!( + processor.template_compile_count(), + 1, + "clone must share the per-template cache instead of starting a second one" + ); + assert_eq!(cloned.template_compile_count(), 1); + } + + #[test] + #[ignore = "micro-benchmark; run explicitly with --ignored --nocapture"] + fn cached_template_performance_smoke_reports_delta() { + let template = r#"{% for message in messages %}{% if message.role == 'system' %}<|system|>{{ message.content }}{% elif message.role == 'user' %}<|user|>{{ message.content }}{% else %}<|assistant|>{{ message.content }}{% endif %}{% endfor %}{% if add_generation_prompt %}<|assistant|>{% endif %}"#; + let processor = ChatTemplateProcessor::with_template(template.to_string()); + let messages = vec![ + ChatMessage { + role: "system".to_string(), + content: "be concise".to_string(), + }, + ChatMessage { + role: "user".to_string(), + content: "summarize the cache change".repeat(8), + }, + ]; + let kwargs = ChatTemplateKwargs::new(); + let iterations = 400usize; + + let fresh_start = std::time::Instant::now(); + let mut fresh_last = String::new(); + for _ in 0..iterations { + fresh_last = fresh_typed_render(&processor, &messages, None, &kwargs, true).unwrap(); + } + let fresh_elapsed = fresh_start.elapsed(); + + let cached_start = std::time::Instant::now(); + let mut cached_last = String::new(); + for _ in 0..iterations { + cached_last = processor + .apply_with_kwargs(&messages, None, &kwargs) + .expect("cached render"); + } + let cached_elapsed = cached_start.elapsed(); + + assert_eq!(cached_last, fresh_last); + assert_eq!(processor.template_compile_count(), 1); + eprintln!( + "chat template cache smoke: iterations={iterations} fresh={fresh_elapsed:?} cached={cached_elapsed:?}" + ); + } + /// A checkpoint that ships no template at all still has to render the /// prompt shape its model was trained on. #[test]