gglib_core/domain/reasoning_effort.rs
1//! The `reasoning_effort` level — how hard the model is *asked* to think.
2//!
3//! Kept out of [`inference`](super::inference) deliberately. This is not a
4//! sampler and it does not behave like one: it is a string that a chat
5//! template may read at render time, its level vocabulary is per-template
6//! folklore ([ADR 0007] finding 3), and the whole reason it exists as a type
7//! rather than a `String` is a governance argument that needs room to be
8//! written down. `inference.rs` is already the largest file in this module and
9//! the ladder is the thing it should be about; [`template_caps`] (the other
10//! half of ADR 0007) set the same precedent one PR ago.
11//!
12//! [ADR 0007]: https://github.com/mmogr/gglib/blob/main/docs/adr/0007-ask-the-server-for-template-capabilities.md
13//! [`template_caps`]: super::template_caps
14
15use std::fmt;
16
17use serde::{Deserialize, Serialize};
18
19/// A reasoning-effort level gglib is willing to put on the wire.
20///
21/// # This enum is where gglib is deliberately stricter than upstream
22///
23/// [`InferenceConfig::extract_client_sampling`]'s coercion doctrine is "accept
24/// what upstream accepts and reject what upstream rejects, so gglib never
25/// becomes the stricter of the two". A closed enum breaks that rule for this
26/// one field, on purpose, and the exception is argued rather than assumed —
27/// see [`InferenceConfig::reasoning_effort`], which carries the argument.
28///
29/// # The levels
30///
31/// The six are llama-server's own documented set, taken from its
32/// `--reasoning-effort` help text on the pinned build. They are *offered*
33/// levels, not honoured ones: a template that never branches on the variable
34/// ignores every one of them, and even a template that reads it may only act
35/// on some (upstream's own tests show DeepSeek-V4 rendering something special
36/// for `"max"` and nothing at all for `"high"` or `"low"` — ADR 0007
37/// finding 3).
38///
39/// # `"none"` is not here, and its absence is the decision
40///
41/// llama-server accepts `reasoning_effort: "none"` and treats it specially: it
42/// **erases** the kwarg rather than passing it through. On `gpt-oss` the
43/// template's own `{%- set reasoning_effort = "medium" %}` fallback then
44/// fills the hole, so `"none"` yields **medium** thinking — confirmed live
45/// against the pinned binary, not inferred. Offering it as a level would ship
46/// a control whose most obvious value does the opposite of what it reads as.
47/// "Stop thinking" is
48/// [`reasoning_budget_tokens: 0`](super::InferenceConfig#structfield.reasoning_budget_tokens),
49/// which is sampler-enforced and range-validated upstream. See ADR 0007
50/// finding 4 and decision 4.
51///
52/// [ADR 0007]: https://github.com/mmogr/gglib/blob/main/docs/adr/0007-ask-the-server-for-template-capabilities.md
53/// [`InferenceConfig::extract_client_sampling`]: super::InferenceConfig::extract_client_sampling
54/// [`InferenceConfig::reasoning_effort`]: super::InferenceConfig#structfield.reasoning_effort
55/// [`InferenceConfig::reasoning_budget_tokens`]: super::InferenceConfig#structfield.reasoning_budget_tokens
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
57#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS), ts(export))]
58#[serde(rename_all = "lowercase")]
59pub enum ReasoningEffort {
60 /// The least thinking the template offers.
61 Minimal,
62 /// Below the template's own middle setting.
63 Low,
64 /// The middle setting, and the value `gpt-oss`'s template falls back to on
65 /// its own when no kwarg arrives.
66 Medium,
67 /// Above the middle setting.
68 High,
69 /// Serialises as `xhigh`, one word — *not* `x_high`. The wire spelling is
70 /// llama-server's, and `serde(rename_all = "lowercase")` lower-cases the
71 /// variant name whole rather than splitting on the case boundary the way
72 /// `snake_case` would. `the_wire_spelling_of_every_level_is_pinned` fails
73 /// if that ever changes.
74 XHigh,
75 /// The most thinking the template offers.
76 Max,
77}
78
79impl ReasoningEffort {
80 /// Every level, weakest first.
81 ///
82 /// The order is the `--reasoning-effort` help text's, which is also the
83 /// only ordering the levels have — nothing in llama.cpp compares them, and
84 /// a template is free to treat `low` and `high` identically.
85 pub const ALL: [Self; 6] = [
86 Self::Minimal,
87 Self::Low,
88 Self::Medium,
89 Self::High,
90 Self::XHigh,
91 Self::Max,
92 ];
93
94 /// The wire spelling, identical to what `serde` emits.
95 ///
96 /// Both spellings exist because both are needed — `serde` writes the
97 /// request body, and this reads a client's string without a round trip
98 /// through `serde_json` — and they are pinned equal by test rather than by
99 /// hope.
100 #[must_use]
101 pub const fn as_str(self) -> &'static str {
102 match self {
103 Self::Minimal => "minimal",
104 Self::Low => "low",
105 Self::Medium => "medium",
106 Self::High => "high",
107 Self::XHigh => "xhigh",
108 Self::Max => "max",
109 }
110 }
111
112 /// Read a level from a client's string, or `None` if it is not one.
113 ///
114 /// Case-insensitive, because the levels are a closed vocabulary and
115 /// `"HIGH"` can only have meant one thing. Note what is *not* here:
116 /// `"none"` is not a level (see the type docs), so it lands in the `None`
117 /// arm along with `"banana"` and is reported as a rejection by the caller.
118 #[must_use]
119 pub fn from_wire(s: &str) -> Option<Self> {
120 Self::ALL
121 .into_iter()
122 .find(|level| level.as_str().eq_ignore_ascii_case(s))
123 }
124
125 /// The accepted levels, rendered for an error message.
126 #[must_use]
127 pub fn wire_vocabulary() -> String {
128 Self::ALL
129 .iter()
130 .map(|level| level.as_str())
131 .collect::<Vec<_>>()
132 .join(", ")
133 }
134}
135
136impl fmt::Display for ReasoningEffort {
137 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
138 f.write_str(self.as_str())
139 }
140}
141
142#[cfg(test)]
143#[path = "reasoning_effort_tests.rs"]
144mod reasoning_effort_tests;