gglib_core/request_pipeline/effort_gate.rs
1//! Stage 5b: suppress a resolved `reasoning_effort` when the model's observed
2//! template never reads it — and say so.
3//!
4//! [ADR 0007] decision 3: *the resolved `reasoning_effort` is suppressed when —
5//! and only when — the observed caps positively say the template does not read
6//! the variable*. This module is that sentence.
7//!
8//! # Why it must be reported rather than performed quietly
9//!
10//! A template that does not read the variable ignores it in perfect silence:
11//! HTTP 200, prompt byte-identical, no warning, no status change (ADR 0007
12//! finding 7c, confirmed live). Neither reasoning control is echoed anywhere —
13//! not in `/slots.params`, not in `/props` (finding 7a) — so no readback will
14//! ever notice that a level went nowhere. If gglib deletes the key without a
15//! record, the fact is unrecoverable from every surface at once.
16//!
17//! So the suppression writes itself down twice.
18//! [`ParamSource::SuppressedByTemplate`] replaces the rung in the decision's
19//! provenance — no surface can print `reasoning_effort=profile` for a value
20//! that was never sent — and [`SuppressedEffort`] carries the level and the
21//! rung that supplied it, so a surface can say *which* value was dropped and
22//! *who* asked for it.
23//!
24//! Both records are read downstream: `gglib model explain` renders the
25//! provenance, and the proxy hands [`SuppressedEffort`] to its sampling audit so
26//! the dashboard can name the level and the rung.
27//!
28//! This module's own `debug!` stays, and is not redundant with the pipeline's
29//! `"sampling resolved"` line. That line is rendered by
30//! [`sampling_log`](super::sampling_log) *after* this stage precisely so it
31//! describes what was sent — which means that on a suppression it reads
32//! `reasoning_effort=None … reasoning_effort=suppressed-by-template`, and the
33//! level and rung this stage threw away appear nowhere in it.
34//!
35//! # Unknown never gates
36//!
37//! The predicate is deliberately conservative in the same shape
38//! [`strip_unsupported_tools`](super::tools::strip_unsupported_tools) uses
39//! (`tools.rs:29-40`): it acts only on a
40//! [`catalog_resolved`](super::ModelContext::catalog_resolved) context, and
41//! only on a positive [`Support::No`]. A passthrough model, a model nobody has
42//! launched yet, a `/props` read that failed, and a caps object that did not
43//! carry the field all mean *nobody knows*, and all keep their effort.
44//!
45//! The precedent is a shape, not an equivalence, and the difference is worth
46//! stating: tool stripping reads **gglib's own catalog row** — a fact this
47//! system recorded about the model — while this reads **llama-server's
48//! self-report**, a fact another process stated about itself. ADR 0007 names
49//! that posture (*a runtime self-report used as a policy input*) precisely
50//! because it is not the same thing as a stored capability, and it carries the
51//! extra rule that a report which failed to arrive must never be read as one
52//! that arrived negative.
53//!
54//! # Scope: the top-level key, and only the effort
55//!
56//! This gate governs the top-level `reasoning_effort` key alone. A client's
57//! `chat_template_kwargs` remains a **verbatim passthrough** — gglib neither
58//! reads nor edits it, and a caller who puts an effort level in there is
59//! addressing the template directly, over gglib's head, which is a different
60//! (and unmodelled) act from setting the field the ladder resolves.
61//!
62//! [`reasoning_budget_tokens`](crate::domain::InferenceConfig::reasoning_budget_tokens)
63//! is **never** suppressed here. It is not a template variable at all: it is
64//! enforced by llama.cpp's own sampler-side budget
65//! (`common/reasoning-budget.{h,cpp}`) and range-validated upstream, so a
66//! template that ignores `reasoning_effort` still honours the budget. Gating it
67//! on a caps bit that describes a *template* would be a category error, and
68//! `the_budget_survives_a_model_that_cannot_honour_effort` pins it.
69//!
70//! [ADR 0007]: https://github.com/mmogr/gglib/blob/main/docs/adr/0007-ask-the-server-for-template-capabilities.md
71
72use serde::Serialize;
73use serde_json::Value;
74use tracing::debug;
75
76use super::ModelContext;
77use super::sampling::SamplingDecision;
78use crate::domain::inference::REASONING_EFFORT_KEY;
79use crate::domain::{
80 FieldSources, InferenceConfig, ParamSource, ReasoningEffort, Support, TemplateCaps,
81 reasoning_effort_support,
82};
83
84/// A resolved effort level this stage threw away, and where it came from.
85///
86/// The provenance entry alone says *that* a value was suppressed; this says
87/// **which** and **whose**. Both halves are needed for the sentence an operator
88/// has to be able to read — "the `:high` profile asked for `high`; this model's
89/// template does not read `reasoning_effort`, so nothing was sent" — and the
90/// rung is the half that would otherwise be destroyed, because
91/// [`ParamSource::SuppressedByTemplate`] overwrites it in
92/// [`FieldSources`].
93#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
94pub struct SuppressedEffort {
95 /// The level the ladder resolved, which llama-server never saw.
96 pub level: ReasoningEffort,
97 /// The rung that supplied it — a [`ParamSource::Layer`] index into
98 /// [`SamplingDecision::layer_names`], since no floor ever names an effort
99 /// (`no_floor_names_a_reasoning_control`).
100 pub source: ParamSource,
101}
102
103/// Remove a resolved `reasoning_effort` the observed template cannot read, and
104/// return what was removed.
105///
106/// Returns `None` — leaving `body` and `decision` untouched — for every model
107/// that is not a positive "this template does not read it": unresolved
108/// contexts, never-observed models, unreadable caps, caps that omit the field,
109/// and templates that do read it. Also `None` when the ladder resolved no
110/// effort at all, which is the ordinary case: nothing was going to be sent, so
111/// nothing is suppressed and there is nothing to report.
112///
113/// On a suppression it does three things, and the second and third are the
114/// point:
115///
116/// 1. deletes the top-level key from `body`;
117/// 2. clears `decision.resolved.reasoning_effort`, because that field is
118/// documented as *the values written into the body* and this one no longer
119/// is;
120/// 3. rewrites `decision.sources.reasoning_effort` to
121/// [`ParamSource::SuppressedByTemplate`], so every provenance surface
122/// reports the suppression instead of naming the rung whose value did not
123/// survive.
124pub fn suppress_unsupported_effort(
125 body: &mut Value,
126 ctx: &ModelContext,
127 decision: &mut SamplingDecision,
128) -> Option<SuppressedEffort> {
129 // `catalog_resolved` is stated here rather than folded into the shared
130 // predicate below because it is this caller's question, not the rule's: a
131 // passthrough request is one gglib knows nothing about, and the caps field
132 // on such a context is `None` for want of a lookup rather than for want of
133 // an observation. The explain surfaces have no equivalent doubt — they are
134 // holding the catalog row.
135 if !ctx.catalog_resolved {
136 return None;
137 }
138 // Checked before anything is mutated: a body that is not a JSON object is
139 // left alone everywhere in this pipeline, and a decision recording a
140 // suppression that did not happen to a body is worse than no record.
141 if !body.is_object() {
142 return None;
143 }
144
145 let suppressed = suppress_stored_effort(
146 &mut decision.resolved,
147 &mut decision.sources,
148 &ctx.template_caps,
149 )?;
150
151 if let Some(obj) = body.as_object_mut() {
152 obj.remove(REASONING_EFFORT_KEY);
153 }
154
155 // `debug!`, not `warn!`: on a model whose template ignores the variable
156 // this fires on every request that resolves a level, and the condition is
157 // a property of the model, not a fault. It is logged at all because the
158 // wire will never show it — see the module docs.
159 debug!(
160 level = %suppressed.level,
161 from = %describe_rung(suppressed.source, &decision.layer_names),
162 "reasoning_effort suppressed: this model's template does not read it"
163 );
164 Some(suppressed)
165}
166
167/// Stage 5b's rule, applied to a resolution with no request in hand.
168///
169/// The predicate and both record-keeping edits, minus everything that needs a
170/// body: [`suppress_unsupported_effort`] is this plus the key deletion and the
171/// log line. Split out because `gglib model explain` and
172/// `GET /api/models/:id/explain` have to answer the same question about the
173/// same model and must not answer it differently. An explain surface that
174/// re-implemented the condition could only ever *disagree* with the gate it is
175/// describing — the same argument ADR 0007 makes for reading llama-server's
176/// self-report instead of building a detector, one level in.
177///
178/// Note what an explain surface is doing when it calls this: it is reporting a
179/// **conditional** fact. The stored configuration resolves a level, and on any
180/// real request against this model that level would be deleted before sending.
181/// Nothing has been sent, and nothing here pretends otherwise — which is why
182/// the surfaces render it as *would not be sent*, not as *was not sent*.
183///
184/// `catalog_resolved` has no analogue here and is not needed: a caller holding
185/// a model row has, by construction, resolved the catalog. `caps` being `None`
186/// still means "never observed" and still answers [`Support::Unknown`], so an
187/// unlaunched model keeps its level exactly as an unresolved request does.
188///
189/// Returns `None` — leaving both arguments untouched — unless the caps
190/// positively say the template does not read the variable *and* something
191/// resolved a level to suppress.
192#[must_use]
193pub fn suppress_stored_effort(
194 resolved: &mut InferenceConfig,
195 sources: &mut FieldSources,
196 caps: &Option<TemplateCaps>,
197) -> Option<SuppressedEffort> {
198 let level = resolved.reasoning_effort?;
199
200 // `is_some` is stated even though `reasoning_effort_support` already
201 // answers `Unknown` for absent caps: this is the one predicate in the arc
202 // allowed to delete a value, and it should read as the conjunction ADR 0007
203 // decision 3 writes rather than lean on a helper's behaviour at a distance.
204 if caps.is_none() || reasoning_effort_support(caps) != Support::No {
205 return None;
206 }
207
208 let suppressed = SuppressedEffort {
209 level,
210 source: sources.reasoning_effort,
211 };
212 resolved.reasoning_effort = None;
213 sources.reasoning_effort = ParamSource::SuppressedByTemplate;
214 Some(suppressed)
215}
216
217/// Name the rung a suppressed level came from, for the debug line.
218///
219/// A floor label is unreachable — no floor names an effort — but the arm is
220/// spelled out rather than guessed at, and a value already suppressed cannot
221/// be suppressed twice.
222fn describe_rung(source: ParamSource, names: &[&'static str]) -> &'static str {
223 match source {
224 ParamSource::Layer(i) => names.get(i).copied().unwrap_or("?"),
225 ParamSource::Floor | ParamSource::FloorCoupled => "floor",
226 ParamSource::Unset => "unset",
227 ParamSource::SuppressedByTemplate => "suppressed-by-template",
228 }
229}
230
231#[cfg(test)]
232#[path = "effort_gate_tests.rs"]
233mod effort_gate_tests;
234
235#[cfg(test)]
236#[path = "effort_gate_stored_tests.rs"]
237mod effort_gate_stored_tests;