Skip to main content

gglib_core/domain/
defects.rs

1//! Per-model defect counters — the Tier C signals the closed loop steers by.
2//!
3//! The proxy records defect *events* (a loop-guard trip, a tool-call repair,
4//! a turn that died mid-stream) as they happen; a reader turns those into
5//! *rates* over whatever window it cares about. Writers never interpret and
6//! readers never guess: a trip is a fact about one request, a rate is a
7//! claim about a model, and the split keeps both honest.
8//!
9//! Counters are cumulative and process-lifetime (they live on the proxy
10//! supervisor, like the agent cache metrics, so a proxy restart does not
11//! zero them). There is no windowing here, and no `delta` helper: that pair
12//! existed for the tune scheduler, which kept per-model baselines and rated
13//! the difference. Since ADR 0006 nothing acts on these automatically, and the
14//! one reader left — `gglib proxy dashboard` — shows the run's totals, which
15//! is the honest shape for a counter that resets with the process.
16//!
17//! They are diagnosis: what actually fails, per model, for a person to read
18//! and act on.
19//!
20//! Deliberately not persisted: a defect rate is a claim about recent traffic
21//! on this build of everything, and yesterday's rate answering today's
22//! question is exactly the staleness ADR 0001 warns about. The loop reacts
23//! to what is happening, not to what once happened.
24//!
25//! That was tried the other way and reverted, so it does not need trying
26//! again. Persistence — a `defect_windows` table, exponential decay by
27//! wall-clock age, and outright discard of evidence recorded against a
28//! different llama.cpp release — was built to let the idle-time tune
29//! scheduler carry evidence across restarts. Decay and build scoping existed
30//! *only* to answer the staleness objection above; they were the price of
31//! persisting at all, not features in their own right.
32//!
33//! With the scheduler removed, nothing acts on these counts automatically,
34//! and sampling defaults now come from the model's own metadata rather than
35//! from measured rates. Nobody was left who needed yesterday's numbers, so
36//! the whole apparatus went rather than sit dormant. These counters are
37//! diagnostic, per-process, and reset on restart — which is the correct
38//! lifetime for a claim about what is happening now.
39//!
40//! One reading has since needed yesterday's numbers: ADR 0011's kill
41//! criterion asks whether the guard's trips reach zero across more traffic
42//! than one run sees. So the loop guard's decisions — not these counters — are
43//! also written to a log that outlives the process,
44//! [`super::loop_guard_log`]. It answers the objection above in part: every
45//! row is dated, so a person chooses the window; nothing automatic reads it;
46//! and every row carries the gglib version and the guard's mode. It records
47//! neither the llama.cpp build nor the model file, which `defect_windows`
48//! scoped by, so a reading that spans either has to be split by date.
49
50use std::collections::HashMap;
51use std::sync::Mutex;
52
53pub use super::defect_counts::{LoopGuardTrip, ModelDefectCounts};
54
55/// Process-lifetime per-model defect counters.
56///
57/// A synchronous mutex over a small map: every operation is a couple of
58/// integer bumps under the lock, on paths that already do far heavier work.
59#[derive(Debug, Default)]
60pub struct ModelDefectLedger {
61    counts: Mutex<HashMap<String, ModelDefectCounts>>,
62}
63
64impl ModelDefectLedger {
65    /// Create an empty ledger.
66    #[must_use]
67    pub fn new() -> Self {
68        Self::default()
69    }
70
71    /// Count one request for `model`.
72    pub fn record_request(&self, model: &str) {
73        self.with(model, |c| c.requests += 1);
74    }
75
76    /// Count one loop-guard intervention for `model`, under the detector that
77    /// raised it.
78    ///
79    /// Since #1052 an intervention is a note *or* a refusal — the default
80    /// forwards the request with a note rather than rejecting it.
81    ///
82    /// Bumps the detector's own count and `loop_guard_trips`, which stays the
83    /// sum of the two. Also counts the request itself: a trip outside its own
84    /// denominator would overstate every rate computed from these numbers.
85    ///
86    /// Scoped to the **snapshot**, not the client request. Exactly one of this
87    /// and [`Self::record_request`] runs per snapshot recorded, because the
88    /// caller branches on whether the snapshot names a detector. A client
89    /// request that is noted and then retried after an upstream death records
90    /// two snapshots — the second deliberately carries no trip — so it bumps
91    /// `requests` twice and `loop_guard_trips` once. That double count of
92    /// `requests` predates this and is the retry path's, not the guard's.
93    pub fn record_loop_guard_trip(&self, model: &str, which: LoopGuardTrip) {
94        self.with(model, |c| {
95            c.requests += 1;
96            c.loop_guard_trips += 1;
97            match which {
98                LoopGuardTrip::Loop => c.loop_guard_loops += 1,
99                LoopGuardTrip::Stagnation => c.loop_guard_stagnations += 1,
100            }
101        });
102    }
103
104    /// Count one tool-call repair attempt for `model`.
105    pub fn record_repair(&self, model: &str, succeeded: bool) {
106        self.with(model, |c| {
107            c.repairs_attempted += 1;
108            if succeeded {
109                c.repairs_succeeded += 1;
110            }
111        });
112    }
113
114    /// Count one upstream mid-stream failure for `model`.
115    ///
116    /// Deliberately does *not* bump `requests`, unlike
117    /// [`Self::record_loop_guard_trip`], which counts a request the guard
118    /// acted on — refused instead of forwarding, or forwarded with a note —
119    /// and so has to count its own denominator either way. A stream error
120    /// happens after the request was forwarded and already counted; bumping
121    /// here would count the same request twice and deflate every rate.
122    pub fn record_stream_error(&self, model: &str) {
123        self.with(model, |c| c.stream_errors += 1);
124    }
125
126    /// Count one generation cut off at the token ceiling for `model`.
127    pub fn record_truncated_generation(&self, model: &str) {
128        self.with(model, |c| c.truncated_generations += 1);
129    }
130
131    /// Count one turn that produced nothing client-renderable for `model`.
132    ///
133    /// `reasoning_only` says whether the model produced reasoning and nothing
134    /// else. It is counted *within* the empty total, not beside it — the turn
135    /// was empty either way, and this records why.
136    pub fn record_empty_response(&self, model: &str, reasoning_only: bool) {
137        self.with(model, |c| {
138            c.empty_responses += 1;
139            if reasoning_only {
140                c.reasoning_only += 1;
141            }
142        });
143    }
144
145    /// Count one turn where dialect markup reached client-visible output.
146    pub fn record_dialect_residue(&self, model: &str) {
147        self.with(model, |c| c.dialect_residue += 1);
148    }
149
150    /// Count one turn whose tool call could not be validated at all.
151    pub fn record_unvalidatable_schema(&self, model: &str) {
152        self.with(model, |c| c.unvalidatable_schemas += 1);
153    }
154
155    /// Count one turn whose normalization discarded a malformed tool call.
156    pub fn record_normalization_error(&self, model: &str) {
157        self.with(model, |c| c.normalization_errors += 1);
158    }
159
160    /// Count one turn that repeated the call before it and got an equal
161    /// result back.
162    pub fn record_identical_result_repeat(&self, model: &str) {
163        self.with(model, |c| c.identical_result_repeats += 1);
164    }
165
166    /// Count one turn the guard would have acted on for repeating and did not,
167    /// because the answer had moved. A repeat still inside the allowance is not.
168    pub fn record_repeat_rescued(&self, model: &str) {
169        self.with(model, |c| c.repeats_rescued += 1);
170    }
171
172    /// Record that one turn repeated a batch whose results could not be
173    /// compared.
174    pub fn record_repeat_not_evaluated(&self, model: &str) {
175        self.with(model, |c| c.repeats_not_evaluated += 1);
176    }
177
178    /// The current counts for every model that has any.
179    #[must_use]
180    pub fn snapshot(&self) -> HashMap<String, ModelDefectCounts> {
181        self.counts
182            .lock()
183            .unwrap_or_else(std::sync::PoisonError::into_inner)
184            .clone()
185    }
186
187    fn with(&self, model: &str, update: impl FnOnce(&mut ModelDefectCounts)) {
188        let mut counts = self
189            .counts
190            .lock()
191            .unwrap_or_else(std::sync::PoisonError::into_inner);
192        update(counts.entry(model.to_owned()).or_default());
193    }
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199
200    #[test]
201    fn events_accumulate_per_model() {
202        let ledger = ModelDefectLedger::new();
203        ledger.record_request("a");
204        ledger.record_request("a");
205        ledger.record_loop_guard_trip("a", LoopGuardTrip::Loop);
206        ledger.record_repair("b", true);
207        ledger.record_repair("b", false);
208
209        let snap = ledger.snapshot();
210        assert_eq!(snap["a"].requests, 3); // a trip counts its own request
211        assert_eq!(snap["a"].loop_guard_trips, 1);
212        assert_eq!(snap["b"].repairs_attempted, 2);
213        assert_eq!(snap["b"].repairs_succeeded, 1);
214    }
215
216    /// A trip is counted under the detector that raised it and in the sum, and
217    /// counts its own request once. ADR 0011's first criterion asks about
218    /// stagnation alone, which one tally over both detectors could not answer.
219    #[test]
220    fn a_trip_is_counted_under_its_detector_and_in_the_sum() {
221        let ledger = ModelDefectLedger::new();
222        ledger.record_loop_guard_trip("a", LoopGuardTrip::Loop);
223        ledger.record_loop_guard_trip("a", LoopGuardTrip::Stagnation);
224        ledger.record_loop_guard_trip("a", LoopGuardTrip::Stagnation);
225
226        let snap = ledger.snapshot()["a"];
227        assert_eq!(snap.loop_guard_loops, 1, "one loop trip");
228        assert_eq!(snap.loop_guard_stagnations, 2, "two stagnation trips");
229        assert_eq!(snap.loop_guard_trips, 3, "the sum of the two");
230        assert_eq!(snap.requests, 3, "each trip counts its own request, once");
231    }
232
233    /// A stream error marks an already-forwarded request as having died; it
234    /// must not also count a request, or the rate it feeds is deflated by
235    /// its own denominator.
236    #[test]
237    fn a_stream_error_does_not_bump_its_own_denominator() {
238        let ledger = ModelDefectLedger::new();
239        ledger.record_request("a");
240        ledger.record_stream_error("a");
241
242        let snap = ledger.snapshot()["a"];
243        assert_eq!(snap.requests, 1, "the turn was counted when forwarded");
244        assert_eq!(snap.stream_errors, 1);
245    }
246
247    /// `reasoning_only` is a subset of `empty_responses`, not a sibling. A
248    /// reader wanting "empty but not reasoning-only" subtracts; one wanting
249    /// the empty rate uses the total without having to add two fields.
250    #[test]
251    fn reasoning_only_turns_are_counted_within_the_empty_total() {
252        let ledger = ModelDefectLedger::new();
253        ledger.record_empty_response("a", true);
254        ledger.record_empty_response("a", false);
255
256        let snap = ledger.snapshot()["a"];
257        assert_eq!(snap.empty_responses, 2, "both turns were empty");
258        assert_eq!(snap.reasoning_only, 1, "one of them had reasoning");
259    }
260
261    /// None of the counted-only instruments touch `requests`. They describe
262    /// turns that were already counted when forwarded, so bumping the
263    /// denominator here would deflate every rate computed from it.
264    #[test]
265    fn counted_only_instruments_leave_the_denominator_alone() {
266        let ledger = ModelDefectLedger::new();
267        ledger.record_request("a");
268        ledger.record_truncated_generation("a");
269        ledger.record_empty_response("a", false);
270        ledger.record_dialect_residue("a");
271        ledger.record_unvalidatable_schema("a");
272        ledger.record_normalization_error("a");
273        ledger.record_stream_error("a");
274
275        let snap = ledger.snapshot()["a"];
276        assert_eq!(snap.requests, 1, "one request, however many faults it had");
277        assert_eq!(snap.truncated_generations, 1);
278        assert_eq!(snap.dialect_residue, 1);
279        assert_eq!(snap.unvalidatable_schemas, 1);
280        assert_eq!(snap.normalization_errors, 1);
281    }
282}