Skip to main content

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}