Skip to main content

jev_bridge/
prompt.rs

1//! The direct decision prompt contract and the single-token answer-slot check.
2//!
3//! This mirrors fastjev's `direct-options-v1` contract: one system instruction,
4//! one JSON payload of evidence / criterion / lettered options, rendered
5//! through the model's own chat template with thinking disabled, then scored
6//! by reading the next-token logits of the fixed uppercase answer letters.
7//!
8//! Rendering can come from two places. By default the serving runtime renders
9//! it (`/apply-template` on llama.cpp), which guarantees the prompt is exactly
10//! what that runtime feeds the model. When the runtime exposes no such endpoint
11//! — vLLM, for instance — [`crate::render::LocalRenderer`] renders the same
12//! template in-process instead.
13
14use 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
24/// The uppercase answer letters, in option order.
25pub const LETTERS: &str = "ABCDEFGHIJKLMNOP";
26/// The system instruction the direct contract freezes.
27pub 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.";
28/// The prompt contract version recorded with every result.
29pub const PROMPT_VERSION: &str = "direct-options-v1";
30
31/// One chat message, in the shape every chat template expects.
32#[derive(Debug, Clone, Serialize)]
33pub struct ChatMessage {
34    /// Who speaks: `system` or `user`.
35    pub role: &'static str,
36    /// The message text, already serialised by the caller.
37    pub content: String,
38}
39
40/// Build the chat messages for one decision.
41///
42/// The payload uses Python's JSON layout so the text matches the reference
43/// implementation byte for byte.
44pub fn direct_messages(state: &Value, question: &str, options: &[RowOption]) -> Vec<ChatMessage> {
45    let entries: Vec<Value> = options
46        .iter()
47        .enumerate()
48        .map(|(index, option)| {
49            json!({
50                "letter": &LETTERS[index..index + 1],
51                "description": option.description,
52            })
53        })
54        .collect();
55    let payload = json!({
56        "evidence": state,
57        "criterion": question,
58        "options": entries,
59    });
60    vec![
61        ChatMessage {
62            role: "system",
63            content: DIRECT_SYSTEM.to_string(),
64        },
65        ChatMessage {
66            role: "user",
67            content: to_python_json(&payload),
68        },
69    ]
70}
71
72/// SHA-256 of the rendered prompt.
73///
74/// This is the strongest available check that a bridged run used exactly the
75/// same prompt as a reference run: equal hashes mean equal bytes.
76pub fn prompt_sha256(prompt: &str) -> String {
77    let mut hasher = Sha256::new();
78    hasher.update(prompt.as_bytes());
79    format!("{:x}", hasher.finalize())
80}
81
82/// Softmax over the selected option logits, matching fastjev's helper.
83pub fn softmax(values: &[f64]) -> Vec<f64> {
84    let maximum = values.iter().copied().fold(f64::NEG_INFINITY, f64::max);
85    let weights: Vec<f64> = values.iter().map(|value| (value - maximum).exp()).collect();
86    let total: f64 = weights.iter().sum();
87    weights.iter().map(|weight| weight / total).collect()
88}
89
90/// The body of a `/apply-template` call.
91#[derive(Debug, Serialize)]
92struct ApplyTemplateRequest<'a> {
93    model: &'a str,
94    messages: &'a [ChatMessage],
95    /// Template variables such as `enable_thinking`, forwarded verbatim.
96    chat_template_kwargs: &'a Value,
97}
98
99#[derive(Debug, Deserialize)]
100struct ApplyTemplateResponse {
101    prompt: String,
102}
103
104/// The body of a `/tokenize` call.
105#[derive(Debug, Serialize)]
106struct TokenizeRequest<'a> {
107    model: &'a str,
108    content: &'a str,
109    /// The template already emits BOS, so tokenizing must not add another.
110    add_special: bool,
111}
112
113#[derive(Debug, Deserialize)]
114struct TokenizeResponse {
115    tokens: Vec<u32>,
116}
117
118#[derive(Debug, Serialize)]
119struct DetokenizeRequest<'a> {
120    model: &'a str,
121    tokens: &'a [u32],
122}
123
124#[derive(Debug, Deserialize)]
125struct DetokenizeResponse {
126    content: String,
127}
128
129/// HTTP access to the serving runtime.
130///
131/// Tokenization always goes through the runtime unless a local tokenizer is
132/// configured, so the bridge never needs a copy of the model's vocabulary.
133/// Rendering goes through it too unless a local renderer is configured.
134pub struct RuntimeClient {
135    client: reqwest::Client,
136    base: String,
137    model: String,
138    api_key: Option<String>,
139}
140
141impl RuntimeClient {
142    /// Point a client at the runtime's native base URL.
143    pub fn new(
144        client: reqwest::Client,
145        base: impl Into<String>,
146        model: impl Into<String>,
147        api_key: Option<String>,
148    ) -> Self {
149        Self {
150            client,
151            base: base.into().trim_end_matches('/').to_string(),
152            model: model.into(),
153            api_key,
154        }
155    }
156
157    async fn post<T: serde::de::DeserializeOwned>(
158        &self,
159        path: &str,
160        body: &(impl serde::Serialize + ?Sized),
161    ) -> Result<T> {
162        let url = format!("{}{path}", self.base);
163        let mut request = self.client.post(&url).json(body);
164        if let Some(key) = &self.api_key {
165            request = request.bearer_auth(key);
166        }
167        let response = request
168            .send()
169            .await
170            .with_context(|| format!("POST {url} failed"))?;
171        let status = response.status();
172        let text = response
173            .text()
174            .await
175            .with_context(|| format!("reading {url} response failed"))?;
176        if !status.is_success() {
177            bail!("POST {url} returned HTTP {status}: {}", truncate(&text));
178        }
179        serde_json::from_str(&text)
180            .with_context(|| format!("POST {url} returned an unexpected body: {}", truncate(&text)))
181    }
182
183    /// Render messages with the runtime's own chat template.
184    pub async fn apply_template(
185        &self,
186        messages: &[ChatMessage],
187        chat_template_kwargs: &Value,
188    ) -> Result<String> {
189        let body = ApplyTemplateRequest {
190            model: &self.model,
191            messages,
192            chat_template_kwargs,
193        };
194        let response: ApplyTemplateResponse = self
195            .post("/apply-template", &body)
196            .await
197            .context("rendering the chat template on the serving runtime failed")?;
198        Ok(response.prompt)
199    }
200
201    /// Tokenize without special tokens, matching fastjev's `encode_prompt`.
202    pub async fn tokenize(&self, content: &str) -> Result<Vec<u32>> {
203        let body = TokenizeRequest {
204            model: &self.model,
205            content,
206            add_special: false,
207        };
208        let response: TokenizeResponse = self.post("/tokenize", &body).await?;
209        Ok(response.tokens)
210    }
211
212    /// Decode tokens for the answer-slot round-trip check.
213    pub async fn detokenize(&self, tokens: &[u32]) -> Result<String> {
214        let body = DetokenizeRequest {
215            model: &self.model,
216            tokens,
217        };
218        let response: DetokenizeResponse = self.post("/detokenize", &body).await?;
219        Ok(response.content)
220    }
221}
222
223/// The work the bridge can do without asking the serving runtime.
224///
225/// Both fields are optional and independent: a runtime may render templates but
226/// not tokenize, or the other way round. Anything left unset falls back to the
227/// runtime's own endpoints.
228#[derive(Default)]
229pub struct LocalComponents {
230    /// Renders the template in-process instead of calling `/apply-template`.
231    pub renderer: Option<LocalRenderer>,
232    #[cfg(feature = "local-tokenizer")]
233    /// Tokenizes in-process instead of calling `/tokenize`.
234    pub tokenizer: Option<LocalTokenizer>,
235}
236
237/// The prompt contract: rendering, tokenization, and the startup checks.
238pub struct ChatTemplate {
239    runtime: RuntimeClient,
240    local: LocalComponents,
241    chat_template_kwargs: Value,
242}
243
244impl ChatTemplate {
245    /// Combine a runtime with whichever in-process halves are configured.
246    pub fn new(runtime: RuntimeClient, local: LocalComponents, chat_template_kwargs: Value) -> Self {
247        Self {
248            runtime,
249            local,
250            chat_template_kwargs,
251        }
252    }
253
254    /// Whether the prompt is rendered in this process rather than by the runtime.
255    pub fn renders_locally(&self) -> bool {
256        self.local.renderer.is_some()
257    }
258
259    /// Whether tokenization happens in this process rather than by the runtime.
260    pub fn tokenizes_locally(&self) -> bool {
261        #[cfg(feature = "local-tokenizer")]
262        {
263            self.local.tokenizer.is_some()
264        }
265        #[cfg(not(feature = "local-tokenizer"))]
266        {
267            false
268        }
269    }
270
271    /// Render the decision prompt for one set of messages.
272    pub async fn render(&self, messages: &[ChatMessage]) -> Result<String> {
273        match &self.local.renderer {
274            Some(local) => local.render(messages, &self.chat_template_kwargs),
275            None => {
276                self.runtime
277                    .apply_template(messages, &self.chat_template_kwargs)
278                    .await
279            }
280        }
281    }
282
283    /// Tokenize locally when configured, otherwise through the runtime.
284    pub async fn tokenize(&self, content: &str) -> Result<Vec<u32>> {
285        #[cfg(feature = "local-tokenizer")]
286        if let Some(local) = &self.local.tokenizer {
287            return local.tokenize(content);
288        }
289        self.runtime.tokenize(content).await
290    }
291
292    /// Detokenize locally when configured, otherwise through the runtime.
293    pub async fn detokenize(&self, tokens: &[u32]) -> Result<String> {
294        #[cfg(feature = "local-tokenizer")]
295        if let Some(local) = &self.local.tokenizer {
296            return local.detokenize(tokens);
297        }
298        self.runtime.detokenize(tokens).await
299    }
300
301    /// Resolve the sixteen answer slots and prove they are exact single tokens.
302    ///
303    /// A slot that is not one round-tripping token would make the logit readout
304    /// meaningless, so this fails loudly instead of scoring the wrong ids.
305    pub async fn resolve_slots(&self) -> Result<Vec<u32>> {
306        let mut slots = Vec::with_capacity(LETTERS.len());
307        for letter in LETTERS.chars() {
308            let ids = self.tokenize(&letter.to_string()).await?;
309            if ids.len() != 1 {
310                bail!(
311                    "answer slot {letter:?} is not a single token ({} tokens); this model cannot \
312                     serve the direct option-logit contract",
313                    ids.len()
314                );
315            }
316            slots.push(ids[0]);
317        }
318        let mut unique = slots.clone();
319        unique.sort_unstable();
320        unique.dedup();
321        if unique.len() != slots.len() {
322            bail!("answer-slot tokens collide");
323        }
324        let decoded = self
325            .detokenize(&slots)
326            .await
327            .context("verifying answer-slot round trip failed")?;
328        if decoded != LETTERS {
329            bail!("answer slots decode to {decoded:?} instead of {LETTERS:?}");
330        }
331        Ok(slots)
332    }
333
334    /// Prove that appending an answer letter cannot change tokenization.
335    ///
336    /// Without this, the last prompt token could merge with the letter and the
337    /// scored logit would belong to a different token than the chosen option.
338    pub async fn verify_boundary(&self, prompt: &str, slots: &[u32]) -> Result<()> {
339        let base = self.tokenize(prompt).await?;
340        if base.is_empty() {
341            bail!("rendered prompt is empty");
342        }
343        for (index, letter) in LETTERS.chars().enumerate() {
344            let combined = self.tokenize(&format!("{prompt}{letter}")).await?;
345            let mut expected = base.clone();
346            expected.push(slots[index]);
347            if combined != expected {
348                bail!(
349                    "answer boundary changes tokenization for slot {letter:?}: the prompt does \
350                     not end in a position where {letter:?} stays one token"
351                );
352            }
353        }
354        Ok(())
355    }
356
357    /// The template variables forwarded on every render.
358    pub fn chat_template_kwargs(&self) -> Value {
359        self.chat_template_kwargs.clone()
360    }
361}
362
363fn truncate(text: &str) -> String {
364    let mut owned = text.chars().take(400).collect::<String>();
365    if text.chars().count() > 400 {
366        owned.push('…');
367    }
368    owned
369}
370
371#[cfg(test)]
372mod tests {
373    use super::*;
374    use serde_json::Map;
375
376    fn options() -> Vec<RowOption> {
377        vec![
378            RowOption {
379                id: "billing".into(),
380                description: "Billing and refunds.".into(),
381            },
382            RowOption {
383                id: "sales".into(),
384                description: "Pricing and contracts.".into(),
385            },
386        ]
387    }
388
389    #[test]
390    fn payload_matches_python_json_layout() {
391        let messages = direct_messages(&json!("charged twice"), "Which queue?", &options());
392        let user = messages[1].content.as_str();
393        assert_eq!(
394            user,
395            r#"{"evidence": "charged twice", "criterion": "Which queue?", "options": [{"letter": "A", "description": "Billing and refunds."}, {"letter": "B", "description": "Pricing and contracts."}]}"#
396        );
397        assert_eq!(messages[0].content, DIRECT_SYSTEM);
398    }
399
400    #[test]
401    fn structured_state_is_embedded_as_json() {
402        let messages = direct_messages(&json!({"message": "hi", "count": 2}), "q", &options());
403        let user = messages[1].content.as_str();
404        assert!(
405            user.contains(r#""evidence": {"message": "hi", "count": 2}"#),
406            "{user}"
407        );
408    }
409
410    #[test]
411    fn softmax_is_stable_for_large_magnitudes() {
412        let probabilities = softmax(&[-1000.0, -1001.0]);
413        assert!((probabilities[0] - 0.7310585786300049).abs() < 1e-12);
414        assert!((probabilities.iter().sum::<f64>() - 1.0).abs() < 1e-12);
415    }
416
417    #[test]
418    fn prompt_hash_is_stable_and_distinguishing() {
419        assert_eq!(prompt_sha256("abc").len(), 64);
420        assert_ne!(prompt_sha256("abc"), prompt_sha256("abd"));
421    }
422
423    #[test]
424    fn softmax_matches_the_measured_llamacpp_distribution() {
425        // Measured on MiniCPM5-2B-Q8_0 for a billing question: the option
426        // logits were -12.32 (A), -2.1e-05 (B), -15.61 (C).
427        let probabilities = softmax(&[-12.320333, -0.0000213, -15.617624]);
428        assert!(probabilities[1] > 0.9999, "{probabilities:?}");
429        assert!(probabilities[0] < 1e-5);
430    }
431
432    #[tokio::test]
433    async fn a_local_renderer_takes_precedence_over_the_runtime() {
434        let runtime = RuntimeClient::new(
435            reqwest::Client::new(),
436            "http://127.0.0.1:1".to_string(),
437            "unreachable".to_string(),
438            None,
439        );
440        let local = LocalRenderer::new(
441            "local:{{ messages[0].content }}:{{ enable_thinking }}".to_string(),
442            Map::new(),
443        )
444        .unwrap();
445        let template = ChatTemplate::new(
446            runtime,
447            LocalComponents {
448                renderer: Some(local),
449                ..Default::default()
450            },
451            json!({"enable_thinking": false}),
452        );
453
454        assert!(template.renders_locally());
455        let messages = direct_messages(&json!("state"), "question", &options());
456        // No HTTP call happens, so the unreachable base URL never matters.
457        let rendered = template.render(&messages).await.unwrap();
458        assert!(rendered.starts_with("local:"), "{rendered}");
459        assert!(rendered.ends_with(":False"), "{rendered}");
460    }
461}