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/// The serialised evidence text for one state.
41///
42/// A batch of questions about the same state serialises it once and reuses the
43/// text; the bytes are exactly what [`direct_messages`] embeds, which a test
44/// pins.
45pub fn evidence_json(state: &Value) -> String {
46    to_python_json(state)
47}
48
49/// Build the chat messages for one decision from an already-serialised
50/// evidence text.
51///
52/// `json.dumps` serialises a nested value exactly as it would serialise that
53/// value alone, so assembling the payload from independently serialised parts
54/// is byte-identical to serialising the whole object — a test pins that, since
55/// the byte-for-byte alignment with the reference implementation rests on it.
56pub 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
88/// Build the chat messages for one decision.
89///
90/// The payload uses Python's JSON layout so the text matches the reference
91/// implementation byte for byte.
92pub fn direct_messages(state: &Value, question: &str, options: &[RowOption]) -> Vec<ChatMessage> {
93    direct_messages_reusing(&evidence_json(state), question, options)
94}
95
96/// SHA-256 of the rendered prompt.
97///
98/// This is the strongest available check that a bridged run used exactly the
99/// same prompt as a reference run: equal hashes mean equal bytes.
100pub 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
106/// Softmax over the selected option logits, matching fastjev's helper.
107pub 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/// The body of a `/apply-template` call.
115#[derive(Debug, Serialize)]
116struct ApplyTemplateRequest<'a> {
117    model: &'a str,
118    messages: &'a [ChatMessage],
119    /// Template variables such as `enable_thinking`, forwarded verbatim.
120    chat_template_kwargs: &'a Value,
121}
122
123#[derive(Debug, Deserialize)]
124struct ApplyTemplateResponse {
125    prompt: String,
126}
127
128/// The body of a `/tokenize` call.
129#[derive(Debug, Serialize)]
130struct TokenizeRequest<'a> {
131    model: &'a str,
132    content: &'a str,
133    /// The template already emits BOS, so tokenizing must not add another.
134    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
153/// HTTP access to the serving runtime.
154///
155/// Tokenization always goes through the runtime unless a local tokenizer is
156/// configured, so the bridge never needs a copy of the model's vocabulary.
157/// Rendering goes through it too unless a local renderer is configured.
158pub struct RuntimeClient {
159    client: reqwest::Client,
160    base: String,
161    model: String,
162    api_key: Option<String>,
163}
164
165impl RuntimeClient {
166    /// Point a client at the runtime's native base URL.
167    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    /// Render messages with the runtime's own chat template.
208    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    /// Tokenize without special tokens, matching fastjev's `encode_prompt`.
226    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    /// Decode tokens for the answer-slot round-trip check.
237    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/// The work the bridge can do without asking the serving runtime.
248///
249/// Both fields are optional and independent: a runtime may render templates but
250/// not tokenize, or the other way round. Anything left unset falls back to the
251/// runtime's own endpoints.
252#[derive(Default)]
253pub struct LocalComponents {
254    /// Renders the template in-process instead of calling `/apply-template`.
255    pub renderer: Option<LocalRenderer>,
256    #[cfg(feature = "local-tokenizer")]
257    /// Tokenizes in-process instead of calling `/tokenize`.
258    pub tokenizer: Option<LocalTokenizer>,
259}
260
261/// The prompt contract: rendering, tokenization, and the startup checks.
262pub struct ChatTemplate {
263    runtime: RuntimeClient,
264    local: LocalComponents,
265    chat_template_kwargs: Value,
266}
267
268impl ChatTemplate {
269    /// Combine a runtime with whichever in-process halves are configured.
270    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    /// Whether the prompt is rendered in this process rather than by the runtime.
279    pub fn renders_locally(&self) -> bool {
280        self.local.renderer.is_some()
281    }
282
283    /// Whether tokenization happens in this process rather than by the runtime.
284    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    /// Render the decision prompt for one set of messages.
296    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    /// Tokenize locally when configured, otherwise through the runtime.
308    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    /// Detokenize locally when configured, otherwise through the runtime.
317    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    /// Resolve the sixteen answer slots and prove they are exact single tokens.
326    ///
327    /// A slot that is not one round-tripping token would make the logit readout
328    /// meaningless, so this fails loudly instead of scoring the wrong ids.
329    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    /// Prove that appending an answer letter cannot change tokenization.
359    ///
360    /// Without this, the last prompt token could merge with the letter and the
361    /// scored logit would belong to a different token than the chosen option.
362    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    /// The template variables forwarded on every render.
382    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        // A batch serialises the shared state once and assembles each payload
437        // from parts; the result must be exactly what the whole-object path
438        // produces, or the byte-for-byte alignment with the reference
439        // implementation would quietly break for batched requests only.
440        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        // Measured on MiniCPM5-2B-Q8_0 for a billing question: the option
469        // logits were -12.32 (A), -2.1e-05 (B), -15.61 (C).
470        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        // No HTTP call happens, so the unreachable base URL never matters.
500        let rendered = template.render(&messages).await.unwrap();
501        assert!(rendered.starts_with("local:"), "{rendered}");
502        assert!(rendered.ends_with(":False"), "{rendered}");
503    }
504}