1use anyhow::{bail, Context, Result};
15use serde::{Deserialize, Serialize};
16use serde_json::{json, Value};
17use sha2::{Digest, Sha256};
18
19use crate::render::LocalRenderer;
20use crate::wire::{to_python_json, RowOption};
21#[cfg(feature = "local-tokenizer")]
22use crate::tokenizer::LocalTokenizer;
23
24pub const LETTERS: &str = "ABCDEFGHIJKLMNOP";
26pub const DIRECT_SYSTEM: &str = "Apply the supplied criterion to the supplied evidence. Choose exactly one listed option. Respond with only its uppercase letter, with no explanation or reasoning.";
28pub const PROMPT_VERSION: &str = "direct-options-v1";
30
31#[derive(Debug, Clone, Serialize)]
33pub struct ChatMessage {
34 pub role: &'static str,
36 pub content: String,
38}
39
40pub fn evidence_json(state: &Value) -> String {
46 to_python_json(state)
47}
48
49pub fn direct_messages_reusing(
57 evidence: &str,
58 question: &str,
59 options: &[RowOption],
60) -> Vec<ChatMessage> {
61 let entries: Vec<Value> = options
62 .iter()
63 .enumerate()
64 .map(|(index, option)| {
65 json!({
66 "letter": &LETTERS[index..index + 1],
67 "description": option.description,
68 })
69 })
70 .collect();
71 let payload = format!(
72 "{{\"evidence\": {evidence}, \"criterion\": {}, \"options\": {}}}",
73 to_python_json(&Value::String(question.to_string())),
74 to_python_json(&Value::Array(entries)),
75 );
76 vec![
77 ChatMessage {
78 role: "system",
79 content: DIRECT_SYSTEM.to_string(),
80 },
81 ChatMessage {
82 role: "user",
83 content: payload,
84 },
85 ]
86}
87
88pub fn direct_messages(state: &Value, question: &str, options: &[RowOption]) -> Vec<ChatMessage> {
93 direct_messages_reusing(&evidence_json(state), question, options)
94}
95
96pub fn prompt_sha256(prompt: &str) -> String {
101 let mut hasher = Sha256::new();
102 hasher.update(prompt.as_bytes());
103 format!("{:x}", hasher.finalize())
104}
105
106pub fn softmax(values: &[f64]) -> Vec<f64> {
108 let maximum = values.iter().copied().fold(f64::NEG_INFINITY, f64::max);
109 let weights: Vec<f64> = values.iter().map(|value| (value - maximum).exp()).collect();
110 let total: f64 = weights.iter().sum();
111 weights.iter().map(|weight| weight / total).collect()
112}
113
114#[derive(Debug, Serialize)]
116struct ApplyTemplateRequest<'a> {
117 model: &'a str,
118 messages: &'a [ChatMessage],
119 chat_template_kwargs: &'a Value,
121}
122
123#[derive(Debug, Deserialize)]
124struct ApplyTemplateResponse {
125 prompt: String,
126}
127
128#[derive(Debug, Serialize)]
130struct TokenizeRequest<'a> {
131 model: &'a str,
132 content: &'a str,
133 add_special: bool,
135}
136
137#[derive(Debug, Deserialize)]
138struct TokenizeResponse {
139 tokens: Vec<u32>,
140}
141
142#[derive(Debug, Serialize)]
143struct DetokenizeRequest<'a> {
144 model: &'a str,
145 tokens: &'a [u32],
146}
147
148#[derive(Debug, Deserialize)]
149struct DetokenizeResponse {
150 content: String,
151}
152
153pub struct RuntimeClient {
159 client: reqwest::Client,
160 base: String,
161 model: String,
162 api_key: Option<String>,
163}
164
165impl RuntimeClient {
166 pub fn new(
168 client: reqwest::Client,
169 base: impl Into<String>,
170 model: impl Into<String>,
171 api_key: Option<String>,
172 ) -> Self {
173 Self {
174 client,
175 base: base.into().trim_end_matches('/').to_string(),
176 model: model.into(),
177 api_key,
178 }
179 }
180
181 async fn post<T: serde::de::DeserializeOwned>(
182 &self,
183 path: &str,
184 body: &(impl serde::Serialize + ?Sized),
185 ) -> Result<T> {
186 let url = format!("{}{path}", self.base);
187 let mut request = self.client.post(&url).json(body);
188 if let Some(key) = &self.api_key {
189 request = request.bearer_auth(key);
190 }
191 let response = request
192 .send()
193 .await
194 .with_context(|| format!("POST {url} failed"))?;
195 let status = response.status();
196 let text = response
197 .text()
198 .await
199 .with_context(|| format!("reading {url} response failed"))?;
200 if !status.is_success() {
201 bail!("POST {url} returned HTTP {status}: {}", truncate(&text));
202 }
203 serde_json::from_str(&text)
204 .with_context(|| format!("POST {url} returned an unexpected body: {}", truncate(&text)))
205 }
206
207 pub async fn apply_template(
209 &self,
210 messages: &[ChatMessage],
211 chat_template_kwargs: &Value,
212 ) -> Result<String> {
213 let body = ApplyTemplateRequest {
214 model: &self.model,
215 messages,
216 chat_template_kwargs,
217 };
218 let response: ApplyTemplateResponse = self
219 .post("/apply-template", &body)
220 .await
221 .context("rendering the chat template on the serving runtime failed")?;
222 Ok(response.prompt)
223 }
224
225 pub async fn tokenize(&self, content: &str) -> Result<Vec<u32>> {
227 let body = TokenizeRequest {
228 model: &self.model,
229 content,
230 add_special: false,
231 };
232 let response: TokenizeResponse = self.post("/tokenize", &body).await?;
233 Ok(response.tokens)
234 }
235
236 pub async fn detokenize(&self, tokens: &[u32]) -> Result<String> {
238 let body = DetokenizeRequest {
239 model: &self.model,
240 tokens,
241 };
242 let response: DetokenizeResponse = self.post("/detokenize", &body).await?;
243 Ok(response.content)
244 }
245}
246
247#[derive(Default)]
253pub struct LocalComponents {
254 pub renderer: Option<LocalRenderer>,
256 #[cfg(feature = "local-tokenizer")]
257 pub tokenizer: Option<LocalTokenizer>,
259}
260
261pub struct ChatTemplate {
263 runtime: RuntimeClient,
264 local: LocalComponents,
265 chat_template_kwargs: Value,
266}
267
268impl ChatTemplate {
269 pub fn new(runtime: RuntimeClient, local: LocalComponents, chat_template_kwargs: Value) -> Self {
271 Self {
272 runtime,
273 local,
274 chat_template_kwargs,
275 }
276 }
277
278 pub fn renders_locally(&self) -> bool {
280 self.local.renderer.is_some()
281 }
282
283 pub fn tokenizes_locally(&self) -> bool {
285 #[cfg(feature = "local-tokenizer")]
286 {
287 self.local.tokenizer.is_some()
288 }
289 #[cfg(not(feature = "local-tokenizer"))]
290 {
291 false
292 }
293 }
294
295 pub async fn render(&self, messages: &[ChatMessage]) -> Result<String> {
297 match &self.local.renderer {
298 Some(local) => local.render(messages, &self.chat_template_kwargs),
299 None => {
300 self.runtime
301 .apply_template(messages, &self.chat_template_kwargs)
302 .await
303 }
304 }
305 }
306
307 pub async fn tokenize(&self, content: &str) -> Result<Vec<u32>> {
309 #[cfg(feature = "local-tokenizer")]
310 if let Some(local) = &self.local.tokenizer {
311 return local.tokenize(content);
312 }
313 self.runtime.tokenize(content).await
314 }
315
316 pub async fn detokenize(&self, tokens: &[u32]) -> Result<String> {
318 #[cfg(feature = "local-tokenizer")]
319 if let Some(local) = &self.local.tokenizer {
320 return local.detokenize(tokens);
321 }
322 self.runtime.detokenize(tokens).await
323 }
324
325 pub async fn resolve_slots(&self) -> Result<Vec<u32>> {
330 let mut slots = Vec::with_capacity(LETTERS.len());
331 for letter in LETTERS.chars() {
332 let ids = self.tokenize(&letter.to_string()).await?;
333 if ids.len() != 1 {
334 bail!(
335 "answer slot {letter:?} is not a single token ({} tokens); this model cannot \
336 serve the direct option-logit contract",
337 ids.len()
338 );
339 }
340 slots.push(ids[0]);
341 }
342 let mut unique = slots.clone();
343 unique.sort_unstable();
344 unique.dedup();
345 if unique.len() != slots.len() {
346 bail!("answer-slot tokens collide");
347 }
348 let decoded = self
349 .detokenize(&slots)
350 .await
351 .context("verifying answer-slot round trip failed")?;
352 if decoded != LETTERS {
353 bail!("answer slots decode to {decoded:?} instead of {LETTERS:?}");
354 }
355 Ok(slots)
356 }
357
358 pub async fn verify_boundary(&self, prompt: &str, slots: &[u32]) -> Result<()> {
363 let base = self.tokenize(prompt).await?;
364 if base.is_empty() {
365 bail!("rendered prompt is empty");
366 }
367 for (index, letter) in LETTERS.chars().enumerate() {
368 let combined = self.tokenize(&format!("{prompt}{letter}")).await?;
369 let mut expected = base.clone();
370 expected.push(slots[index]);
371 if combined != expected {
372 bail!(
373 "answer boundary changes tokenization for slot {letter:?}: the prompt does \
374 not end in a position where {letter:?} stays one token"
375 );
376 }
377 }
378 Ok(())
379 }
380
381 pub fn chat_template_kwargs(&self) -> Value {
383 self.chat_template_kwargs.clone()
384 }
385}
386
387fn truncate(text: &str) -> String {
388 let mut owned = text.chars().take(400).collect::<String>();
389 if text.chars().count() > 400 {
390 owned.push('…');
391 }
392 owned
393}
394
395#[cfg(test)]
396mod tests {
397 use super::*;
398 use serde_json::Map;
399
400 fn options() -> Vec<RowOption> {
401 vec![
402 RowOption {
403 id: "billing".into(),
404 description: "Billing and refunds.".into(),
405 },
406 RowOption {
407 id: "sales".into(),
408 description: "Pricing and contracts.".into(),
409 },
410 ]
411 }
412
413 #[test]
414 fn payload_matches_python_json_layout() {
415 let messages = direct_messages(&json!("charged twice"), "Which queue?", &options());
416 let user = messages[1].content.as_str();
417 assert_eq!(
418 user,
419 r#"{"evidence": "charged twice", "criterion": "Which queue?", "options": [{"letter": "A", "description": "Billing and refunds."}, {"letter": "B", "description": "Pricing and contracts."}]}"#
420 );
421 assert_eq!(messages[0].content, DIRECT_SYSTEM);
422 }
423
424 #[test]
425 fn structured_state_is_embedded_as_json() {
426 let messages = direct_messages(&json!({"message": "hi", "count": 2}), "q", &options());
427 let user = messages[1].content.as_str();
428 assert!(
429 user.contains(r#""evidence": {"message": "hi", "count": 2}"#),
430 "{user}"
431 );
432 }
433
434 #[test]
435 fn reusing_the_serialised_evidence_is_byte_identical() {
436 let state = json!({
441 "message": "已扣款两次 charged twice",
442 "count": 2,
443 "nested": {"tags": ["a", "b"], "ok": true, "missing": null, "ratio": 0.5},
444 });
445 for question in ["Which queue?", "哪一个队列?", "quotes \" and \\ backslash"] {
446 let whole = direct_messages(&state, question, &options());
447 let reused = direct_messages_reusing(&evidence_json(&state), question, &options());
448 assert_eq!(whole[0].content, reused[0].content);
449 assert_eq!(whole[1].content, reused[1].content, "question {question:?}");
450 }
451 }
452
453 #[test]
454 fn softmax_is_stable_for_large_magnitudes() {
455 let probabilities = softmax(&[-1000.0, -1001.0]);
456 assert!((probabilities[0] - 0.7310585786300049).abs() < 1e-12);
457 assert!((probabilities.iter().sum::<f64>() - 1.0).abs() < 1e-12);
458 }
459
460 #[test]
461 fn prompt_hash_is_stable_and_distinguishing() {
462 assert_eq!(prompt_sha256("abc").len(), 64);
463 assert_ne!(prompt_sha256("abc"), prompt_sha256("abd"));
464 }
465
466 #[test]
467 fn softmax_matches_the_measured_llamacpp_distribution() {
468 let probabilities = softmax(&[-12.320333, -0.0000213, -15.617624]);
471 assert!(probabilities[1] > 0.9999, "{probabilities:?}");
472 assert!(probabilities[0] < 1e-5);
473 }
474
475 #[tokio::test]
476 async fn a_local_renderer_takes_precedence_over_the_runtime() {
477 let runtime = RuntimeClient::new(
478 reqwest::Client::new(),
479 "http://127.0.0.1:1".to_string(),
480 "unreachable".to_string(),
481 None,
482 );
483 let local = LocalRenderer::new(
484 "local:{{ messages[0].content }}:{{ enable_thinking }}".to_string(),
485 Map::new(),
486 )
487 .unwrap();
488 let template = ChatTemplate::new(
489 runtime,
490 LocalComponents {
491 renderer: Some(local),
492 ..Default::default()
493 },
494 json!({"enable_thinking": false}),
495 );
496
497 assert!(template.renders_locally());
498 let messages = direct_messages(&json!("state"), "question", &options());
499 let rendered = template.render(&messages).await.unwrap();
501 assert!(rendered.starts_with("local:"), "{rendered}");
502 assert!(rendered.ends_with(":False"), "{rendered}");
503 }
504}