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}