forge_dictate/normalize/prompt.rs
1//! The exact input format S1-mini was trained on.
2
3/// Required verbatim. The model card states that changing this wording,
4/// or dropping the control line below, can produce garbled output.
5pub const SYSTEM: &str = "You are a text normalizer for speech-to-text transcripts. The input begins with a control line specifying the styling, structure, and context settings; clean the transcript to match those settings and output only the cleaned text.";
6
7/// What the Qwen3 template emits for `enable_thinking=false`. Omit it and
8/// the model answers with this fragment and stops, which reads as a broken
9/// pipeline rather than a malformed prompt.
10pub const ASSISTANT_PREFIX: &str = "<|im_start|>assistant\n<think>\n\n</think>\n\n";
11
12/// The register the transcript is rewritten into.
13///
14/// A closed set: the card documents a value outside the trained sets as one
15/// of the two ways to get garbled output, so the set is enforced here rather
16/// than trusted to callers.
17///
18/// The four are not a gradient. Measured, `Formal` declines comma insertions
19/// and a grammar fix that `SemiFormal` applies, so the card's "`formal` is
20/// `semi-formal` with contractions expanded" is wrong and there is no
21/// dialling this a little further in either direction.
22#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
23#[serde(rename_all = "snake_case")]
24pub enum Styling {
25 /// Lowercase throughout, apostrophes stripped, colloquialisms kept.
26 /// Measured to mangle technical terms, rendering MMAP as "mmapped here".
27 Casual,
28 /// Keeps the speaker's phrasing; sentence starts stay lowercase.
29 SemiCasual,
30 /// Standard written English, colloquialisms smoothed. The only value
31 /// measured to insert commas the transcript did not have.
32 #[default]
33 SemiFormal,
34 /// As `SemiFormal`, with contractions expanded.
35 Formal,
36}
37
38impl Styling {
39 /// The card's spelling. These strings are the trained vocabulary, not a
40 /// display format.
41 fn as_str(self) -> &'static str {
42 match self {
43 Self::Casual => "casual",
44 Self::SemiCasual => "semi-casual",
45 Self::SemiFormal => "semi-formal",
46 Self::Formal => "formal",
47 }
48 }
49}
50
51/// Whether the model may turn enumerable content into a bulleted list.
52#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
53#[serde(rename_all = "snake_case")]
54pub enum Structure {
55 /// Sentences and paragraphs throughout.
56 #[default]
57 Prose,
58 /// Permits Markdown bullets. **Returns multi-line text** when it forms
59 /// a list, so a host that assumes one line gets a surprise. Measured
60 /// more conservative than the card documents: the card's own three-item
61 /// example comes back as prose here, while a seven-item input bullets.
62 Lists,
63}
64
65impl Structure {
66 fn as_str(self) -> &'static str {
67 match self {
68 Self::Prose => "prose",
69 Self::Lists => "lists",
70 }
71 }
72}
73
74/// Destination conventions. Not a domain or vocabulary hint: it will not
75/// protect technical terms, which are a [`Styling`] concern.
76#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
77#[serde(rename_all = "snake_case")]
78pub enum Context {
79 /// Flowing text.
80 #[default]
81 General,
82 /// Email layout: a greeting line, the body and a sign-off block,
83 /// separated by blank lines. **Reliably returns multi-line text**, so a
84 /// host that assumes one line gets a surprise. Measured token-neutral
85 /// rather than longer, because the newlines replace connective prose.
86 Email,
87}
88
89impl Context {
90 fn as_str(self) -> &'static str {
91 match self {
92 Self::General => "general",
93 Self::Email => "email",
94 }
95 }
96}
97
98/// Build the prompt from a whole
99/// [`NormalizeOptions`](crate::normalize::NormalizeOptions).
100///
101/// The seam that lets a test assert every field reaches the control
102/// line without a model. `normalize_with` calls this rather than
103/// unpacking the struct itself, so a field dropped on the way to the
104/// prompt is dropped here where it is checked.
105pub fn for_options(text: &str, opts: crate::normalize::NormalizeOptions) -> String {
106 build(text, opts.styling, opts.structure, opts.context)
107}
108
109pub fn build(text: &str, styling: Styling, structure: Structure, context: Context) -> String {
110 let styling = styling.as_str();
111 let structure = structure.as_str();
112 let context = context.as_str();
113 format!(
114 "<|im_start|>system\n{SYSTEM}<|im_end|>\n\
115 <|im_start|>user\n[Styling: {styling}] [Structure: {structure}] [Context: {context}]\n\
116 {text}<|im_end|>\n\
117 {ASSISTANT_PREFIX}"
118 )
119}
120
121#[cfg(test)]
122mod tests {
123 use super::*;
124
125 /// Transcribed from the model card's hand-built-prompt section, not
126 /// from `build`, so the two agree only if `build` is right.
127 const CARD_EXAMPLE: &str = "<|im_start|>system\nYou are a text normalizer for speech-to-text transcripts. The input begins with a control line specifying the styling, structure, and context settings; clean the transcript to match those settings and output only the cleaned text.<|im_end|>\n<|im_start|>user\n[Styling: semi-formal] [Structure: prose] [Context: general]\nso um send the report by uh friday<|im_end|>\n<|im_start|>assistant\n<think>\n\n</think>\n\n";
128
129 /// Every control-line option must reach the prompt.
130 ///
131 /// The literal is exhaustive rather than `..default()`, so a newly
132 /// added field is a compile error here and whoever adds one has to
133 /// decide whether it belongs on the control line.
134 ///
135 /// `k` and `ngram` are listed for that reason alone - they are
136 /// decoder settings, do not appear in the control line, and are not
137 /// checked by the assertion below.
138 #[test]
139 fn every_control_line_option_reaches_the_prompt() {
140 let p = for_options(
141 "anything",
142 crate::normalize::NormalizeOptions {
143 styling: Styling::Formal,
144 structure: Structure::Lists,
145 context: Context::Email,
146 k: 64,
147 ngram: 2,
148 },
149 );
150 assert!(
151 p.contains("[Styling: formal] [Structure: lists] [Context: email]"),
152 "a field dropped between the options and the prompt silently reverts a user's setting: {p}"
153 );
154 }
155
156 /// The three shipped defaults, pinned in one assertion.
157 ///
158 /// Each enum's `#[default]` is otherwise checked by nothing, so
159 /// moving one while tuning would silently change every user's output.
160 #[test]
161 fn the_default_control_line_is_the_shipped_one() {
162 let p = for_options("anything", crate::normalize::NormalizeOptions::default());
163 assert!(
164 p.contains("[Styling: semi-formal] [Structure: prose] [Context: general]"),
165 "the shipped defaults must stay semi-formal / prose / general, got: {p}"
166 );
167 }
168
169 #[test]
170 fn matches_the_trained_input_format() {
171 assert_eq!(
172 build(
173 "so um send the report by uh friday",
174 Styling::SemiFormal,
175 Structure::Prose,
176 Context::General,
177 ),
178 CARD_EXAMPLE,
179 "prompt diverged from the format S1-mini was trained on"
180 );
181 }
182
183 /// The control line has to carry the caller's axes in the card's own
184 /// spelling. A stringly-typed axis would let a plausible-looking
185 /// `"semiformal"` through to the model, which the card lists as a cause
186 /// of garbled output.
187 #[test]
188 fn the_axes_reach_the_control_line_in_the_cards_spelling() {
189 for (styling, spelling) in [
190 (Styling::Casual, "casual"),
191 (Styling::SemiCasual, "semi-casual"),
192 (Styling::SemiFormal, "semi-formal"),
193 (Styling::Formal, "formal"),
194 ] {
195 let p = build("anything", styling, Structure::Prose, Context::General);
196 assert!(
197 p.contains(&format!("[Styling: {spelling}]")),
198 "{styling:?} did not reach the control line as {spelling:?}"
199 );
200 }
201 for (structure, spelling) in [(Structure::Prose, "prose"), (Structure::Lists, "lists")] {
202 let p = build("anything", Styling::SemiFormal, structure, Context::General);
203 assert!(
204 p.contains(&format!("[Structure: {spelling}]")),
205 "{structure:?} did not reach the control line as {spelling:?}"
206 );
207 }
208 for (context, spelling) in [(Context::General, "general"), (Context::Email, "email")] {
209 let p = build("anything", Styling::SemiFormal, Structure::Prose, context);
210 assert!(
211 p.contains(&format!("[Context: {spelling}]")),
212 "{context:?} did not reach the control line as {spelling:?}"
213 );
214 }
215 }
216
217 /// Insurance, not coverage: `CARD_EXAMPLE` already carries this
218 /// prefix verbatim, so this cannot fail on its own. Kept for the
219 /// failure message, which names the one omission that makes the
220 /// model answer `<think>` and stop.
221 ///
222 /// Spelled out rather than compared against [`ASSISTANT_PREFIX`]: the
223 /// constant is what builds the prompt, so checking one against the
224 /// other passes for any value they happen to share.
225 #[test]
226 fn carries_the_empty_think_block() {
227 assert!(
228 build("anything", Styling::default(), Structure::default(), Context::default())
229 .ends_with("<|im_start|>assistant\n<think>\n\n</think>\n\n"),
230 "prompt lost the empty think block; the model will answer \
231 `<think>` and stop"
232 );
233 }
234}