Skip to main content

laya_core/
prompt.rs

1//! Prompt construction.
2//!
3//! Layout, matching upstream exactly:
4//!
5//! ```text
6//! [CLS] <type> question: <instructions> [SEP] [MASK] opt0 [MASK] opt1 ... [SEP] <state> [SEP]
7//! ```
8//!
9//! The `[MASK]` before each option is the marker whose encoder output the
10//! scoring head reads, so marker positions and truncation have to agree with
11//! upstream token for token.
12
13use crate::error::Result;
14use crate::json_compat::dumps_unicode;
15use crate::question::Question;
16use crate::tokenizer::Tokenizer;
17use serde_json::Value;
18
19/// Longest option, in tokens, before truncation.
20const MAX_OPTION_TOKENS: usize = 48;
21/// Minimum token budget the instructions keep when options are long.
22const MIN_HEAD_TOKENS: usize = 8;
23/// Below this remaining budget, options are trimmed to make room.
24const MIN_OPTION_BUDGET: usize = 16;
25
26/// A tokenized question prefix: ids plus the marker offset of each option.
27#[derive(Clone, Debug, PartialEq, Eq)]
28pub struct Prefix {
29    pub ids: Vec<u32>,
30    pub markers: Vec<usize>,
31}
32
33/// A fully built sequence ready for collation.
34#[derive(Clone, Debug, PartialEq, Eq)]
35pub struct Sequence {
36    pub ids: Vec<u32>,
37    pub markers: Vec<usize>,
38    /// True when the state did not fit in `max_len` and its tail was dropped.
39    ///
40    /// Truncation is silent otherwise: the answers stay well formed and the
41    /// token count looks ordinary, so a caller that must not send a cut state
42    /// has nothing else to check.
43    pub truncated: bool,
44}
45
46/// Upstream's `serialize_state`: strings pass through, everything else becomes
47/// `json.dumps(..., ensure_ascii=False)`.
48pub fn serialize_state(state: &Value) -> String {
49    match state {
50        Value::String(text) => text.clone(),
51        other => dumps_unicode(other),
52    }
53}
54
55/// Build the question-only prefix, before state tokens and final truncation.
56pub fn build_prefix(
57    tokenizer: &Tokenizer,
58    question: &Question,
59    head_max_len: usize,
60) -> Result<Prefix> {
61    let mask = tokenizer.mask_token.as_str();
62    let options = question.render_options();
63
64    // Literal mask tokens in user text would create phantom markers.
65    let instructions = question.instructions.replace(mask, " ");
66    let head_text = format!("{} question: {}", question.kind.name(), instructions);
67    let mut head_ids = tokenizer.encode(&head_text)?;
68
69    let mut option_ids: Vec<Vec<u32>> = Vec::with_capacity(options.len());
70    for option in &options {
71        let text = format!(" {}", option.replace(mask, " "));
72        let mut ids = tokenizer.encode(&text)?;
73        ids.truncate(MAX_OPTION_TOKENS);
74        let mut with_marker = Vec::with_capacity(ids.len() + 1);
75        with_marker.push(tokenizer.mask_token_id);
76        with_marker.extend_from_slice(&ids);
77        option_ids.push(with_marker);
78    }
79
80    let used: usize = option_ids.iter().map(Vec::len).sum();
81    // `head_max_len - used` is saturating here; upstream lets it go negative and
82    // then clamps with `max(8, ...)`, which lands on the same slice length.
83    let mut option_budget = head_max_len.saturating_sub(used);
84    if option_budget < MIN_OPTION_BUDGET {
85        let per = std::cmp::max(
86            4,
87            head_max_len.saturating_sub(MIN_OPTION_BUDGET) / std::cmp::max(1, option_ids.len()),
88        );
89        for ids in &mut option_ids {
90            ids.truncate(per);
91        }
92        let used: usize = option_ids.iter().map(Vec::len).sum();
93        option_budget = head_max_len.saturating_sub(used);
94    }
95    head_ids.truncate(std::cmp::max(MIN_HEAD_TOKENS, option_budget));
96
97    let mut ids = Vec::with_capacity(head_ids.len() + used + 3);
98    ids.push(tokenizer.cls_token_id);
99    ids.extend_from_slice(&head_ids);
100    ids.push(tokenizer.sep_token_id);
101
102    let mut markers = Vec::with_capacity(option_ids.len());
103    for option in &option_ids {
104        markers.push(ids.len());
105        ids.extend_from_slice(option);
106    }
107    ids.push(tokenizer.sep_token_id);
108
109    Ok(Prefix { ids, markers })
110}
111
112/// Append state tokens to a prefix and apply the final length cap.
113pub fn extend_with_state(
114    tokenizer: &Tokenizer,
115    prefix: &Prefix,
116    state_ids: &[u32],
117    max_len: usize,
118    truncate_left: bool,
119) -> Sequence {
120    let room = max_len.saturating_sub(prefix.ids.len() + 1);
121    let state = if truncate_left {
122        // Upstream writes `st[-room:]`, and in Python `st[-0:]` is the whole
123        // list rather than an empty one. Reproduced deliberately; `truncate_left`
124        // is not reachable from the public API, so this only matters for parity.
125        if room == 0 {
126            state_ids
127        } else {
128            &state_ids[state_ids.len().saturating_sub(room)..]
129        }
130    } else {
131        &state_ids[..std::cmp::min(room, state_ids.len())]
132    };
133
134    let mut ids = Vec::with_capacity(prefix.ids.len() + state.len() + 1);
135    ids.extend_from_slice(&prefix.ids);
136    ids.extend_from_slice(state);
137    ids.push(tokenizer.sep_token_id);
138
139    // Either the state was clipped to fit the budget, or the prefix alone
140    // already overran it and the final truncate is about to drop real tokens.
141    let truncated = state.len() < state_ids.len() || ids.len() > max_len;
142    ids.truncate(max_len);
143
144    let markers = prefix
145        .markers
146        .iter()
147        .copied()
148        .filter(|marker| *marker < max_len)
149        .collect();
150
151    Sequence {
152        ids,
153        markers,
154        truncated,
155    }
156}
157
158/// Build one complete sequence for a question and state.
159pub fn build_sequence(
160    tokenizer: &Tokenizer,
161    state: &Value,
162    question: &Question,
163    max_len: usize,
164    head_max_len: usize,
165) -> Result<Sequence> {
166    let prefix = build_prefix(tokenizer, question, head_max_len)?;
167    let state_text = serialize_state(state).replace(tokenizer.mask_token.as_str(), " ");
168    let state_ids = tokenizer.encode(&state_text)?;
169    Ok(extend_with_state(
170        tokenizer, &prefix, &state_ids, max_len, false,
171    ))
172}