Skip to main content

gglib_core/domain/
loop_guard_log.rs

1//! The loop guard's log, which outlives the process.
2//!
3//! One row for every decision the guard took, and one row per day, model,
4//! gglib version and mode counting the requests it scanned. Unlike every
5//! counter in [`super::defects`], it survives a restart.
6//!
7//! It exists for one reader. ADR 0011's kill criterion asks whether the
8//! guard's trips reach zero across a denominator large enough that one would
9//! have been expected, and a denominator that large is more traffic than one
10//! daemon run is likely to see. The per-process ledger cannot answer that, and
11//! says why it should not try: a defect *rate* is a claim about recent traffic
12//! on this build of everything. This is not a rate kept for a loop to react
13//! to. Nothing reads it but a person, through `gglib proxy trips`, the
14//! daemon's `GET /api/proxy/loop-guard-trips` and the panel under the guard's
15//! own setting; every row is dated, so the reader chooses the window; and
16//! every row names the gglib version and the guard's mode, so a reading can be
17//! kept to one gglib release and one behaviour instead of averaged across
18//! them. `note` re-notes every later turn of a stuck conversation, while
19//! `refuse` refuses every one — which, for a client with no recovery path,
20//! ends the session — so their trip counts are different populations.
21//!
22//! That meets the objection only in part. The version is the workspace's, so
23//! development builds between two releases share it. Nothing here records the
24//! llama.cpp build or the model file, and the removed `defect_windows` scoped
25//! evidence by the first of those. A reading that spans a llama.cpp upgrade or
26//! a re-downloaded model has to be split by date, by hand.
27//!
28//! # What a trip row is, and is not
29//!
30//! A row records the guard's **decision** — noted or refused — not what
31//! reached the model. Under `note` the request goes on after the decision, and
32//! the note can still fail to arrive: a chat template with no branch for the
33//! `tool` role drops the last message and the note inside it; a conversation
34//! that also exceeds the context budget is refused `context_length_exceeded`;
35//! an embedding model, an unknown model or a failed admission refuses the
36//! request before it is sent; and an upstream that dies mid-request can fail
37//! the retry. Each of those is still a row here. The dashboard's
38//! `loop_guard_trips` is bumped only by a forwarded or refused request's
39//! snapshot, so under `note` this log can count more than the dashboard does
40//! for the same run.
41//!
42//! The log can also count *less* than happened. A decision the writer cannot
43//! queue — a full queue, or a writer already stopped — is dropped while its
44//! scan is still counted, so that day reads as fewer trips over the same
45//! denominator. A flush the database refuses loses its trips and scans
46//! together, and a forced exit loses whatever the writer had not yet flushed.
47//! How many were lost reaches only the daemon's log, as a warning; no reading
48//! shows it. A zero read from here rules out a trip only as far as those
49//! warnings are absent.
50//!
51//! Only the proxy's pre-dispatch scan writes here. The agent loop runs the same
52//! two detectors and records nothing (#1091).
53//!
54//! # What is stored
55//!
56//! No conversation text: no message, tool name, argument or tool result. The
57//! tool-call signature (`name:hash|…`) and the session id are each kept only as
58//! the first 16 hex digits of their SHA-256: stable keys, so a query on the
59//! table can tell "the same loop, seventeen times" from seventeen loops; the
60//! per-day summary every reader shows carries only the number of distinct
61//! sessions. They are correlation keys, not a privacy boundary — anyone
62//! holding the data directory can hash a guess and compare. The one
63//! client-chosen string kept as given is the model name, bounded to
64//! [`MODEL_NAME_LIMIT`] characters.
65
66use std::fmt::Write as _;
67
68use sha2::{Digest, Sha256};
69
70use super::defect_counts::LoopGuardTrip;
71use crate::settings::LoopGuardMode;
72
73/// How many days the log keeps, and so the widest window a reader can ask for.
74pub const LOOP_GUARD_LOG_RETENTION_DAYS: u32 = 90;
75
76/// The window a reader gets when it names none.
77pub const LOOP_GUARD_LOG_DEFAULT_DAYS: u32 = 30;
78
79/// The longest model name a row keeps. The client chooses the name, and the
80/// proxy sets no request-size limit of its own.
81pub const MODEL_NAME_LIMIT: usize = 256;
82
83/// The version every row is stamped with: the workspace's, which is gglib's.
84pub const GGLIB_VERSION: &str = env!("CARGO_PKG_VERSION");
85
86const SECS_PER_DAY: u64 = 86_400;
87
88/// The UTC day `secs` falls on, counted from the Unix epoch.
89///
90/// Trips and scans are both grouped by it, computed from the one timestamp
91/// the guard takes per request, so a request scanned a moment before midnight
92/// cannot have its trip counted against the next day.
93pub fn epoch_day(secs: u64) -> i64 {
94    i64::try_from(secs / SECS_PER_DAY).unwrap_or(i64::MAX)
95}
96
97/// The first day of a window of `days` days that ends with the day `now_secs`
98/// falls on, `days` clamped to 1..=[`LOOP_GUARD_LOG_RETENTION_DAYS`].
99pub fn first_day_of_window(now_secs: u64, days: u32) -> i64 {
100    let days = days.clamp(1, LOOP_GUARD_LOG_RETENTION_DAYS);
101    epoch_day(now_secs) - i64::from(days) + 1
102}
103
104/// The model name a row keeps: the client's, cut at [`MODEL_NAME_LIMIT`]
105/// characters.
106pub fn bounded_model_name(model_name: &str) -> String {
107    model_name.chars().take(MODEL_NAME_LIMIT).collect()
108}
109
110/// The first 16 hex digits of `text`'s SHA-256.
111fn short_hash(text: &str) -> String {
112    let digest = Sha256::digest(text.as_bytes());
113    let mut hex = String::with_capacity(16);
114    for byte in &digest[..8] {
115        // Writing to a `String` cannot fail.
116        let _ = write!(hex, "{byte:02x}");
117    }
118    hex
119}
120
121/// One decision the loop guard took about one request.
122///
123/// Its hashed fields have no setter that takes a hash: the only way in is the
124/// text itself, through [`Self::with_signature`] and [`Self::with_session`],
125/// which keep the hash and drop the text. A row therefore cannot hold a
126/// signature or a session id by construction, not by convention.
127#[derive(Debug, Clone, PartialEq, Eq)]
128pub struct LoopGuardTripEvent {
129    recorded_at_secs: u64,
130    model_name: String,
131    detector: LoopGuardTrip,
132    mode: LoopGuardMode,
133    signature_hash: Option<String>,
134    session_hash: Option<String>,
135    repeat_count: Option<u32>,
136    threshold: Option<u32>,
137}
138
139impl LoopGuardTripEvent {
140    /// A decision taken at `recorded_at_secs` by `detector` under `mode`:
141    /// [`LoopGuardMode::Note`] decided to forward the request with a note,
142    /// [`LoopGuardMode::Refuse`] refused it. Never [`LoopGuardMode::Off`],
143    /// which scans nothing and so decides nothing.
144    pub fn new(
145        recorded_at_secs: u64,
146        model_name: &str,
147        detector: LoopGuardTrip,
148        mode: LoopGuardMode,
149    ) -> Self {
150        debug_assert!(mode.scans(), "a guard that is off decides nothing");
151        Self {
152            recorded_at_secs,
153            model_name: bounded_model_name(model_name),
154            detector,
155            mode,
156            signature_hash: None,
157            session_hash: None,
158            repeat_count: None,
159            threshold: None,
160        }
161    }
162
163    /// The repeated tool-call batch's signature, kept only as its hash.
164    #[must_use]
165    pub fn with_signature(mut self, signature: &str) -> Self {
166        self.signature_hash = Some(short_hash(signature));
167        self
168    }
169
170    /// The request's session id, kept only as its hash.
171    #[must_use]
172    pub fn with_session(mut self, session_id: &str) -> Self {
173        self.session_hash = Some(short_hash(session_id));
174        self
175    }
176
177    /// How many times the reply repeated, and the threshold it crossed.
178    #[must_use]
179    pub const fn with_repeats(mut self, count: u32, threshold: u32) -> Self {
180        self.repeat_count = Some(count);
181        self.threshold = Some(threshold);
182        self
183    }
184
185    /// When the decision was taken, in seconds since the Unix epoch.
186    pub const fn recorded_at_secs(&self) -> u64 {
187        self.recorded_at_secs
188    }
189
190    /// The model the request named, bounded.
191    pub fn model_name(&self) -> &str {
192        &self.model_name
193    }
194
195    /// Which detector tripped.
196    pub const fn detector(&self) -> LoopGuardTrip {
197        self.detector
198    }
199
200    /// The mode the decision was taken under.
201    pub const fn mode(&self) -> LoopGuardMode {
202        self.mode
203    }
204
205    /// The signature's hash, for a loop.
206    pub fn signature_hash(&self) -> Option<&str> {
207        self.signature_hash.as_deref()
208    }
209
210    /// The session id's hash: the client's `x-gglib-session-id`, or the one
211    /// gglib derives from the conversation's opening when there is none.
212    pub fn session_hash(&self) -> Option<&str> {
213        self.session_hash.as_deref()
214    }
215
216    /// How many times the reply repeated, for stagnation.
217    pub const fn repeat_count(&self) -> Option<u32> {
218        self.repeat_count
219    }
220
221    /// The threshold that count crossed, for stagnation.
222    pub const fn threshold(&self) -> Option<u32> {
223        self.threshold
224    }
225}
226
227/// One day of the log for one model, gglib version and mode: how many
228/// requests the guard scanned, and how many it acted on — ordinarily some of
229/// those, though a trip whose scan was lost has none.
230///
231/// A day the guard scanned but never tripped is here with `trips` at zero.
232/// That row is the reading ADR 0011's criterion is about, so the log is never
233/// read from the trips alone. A trip whose scan was lost is here too, with
234/// `scanned` at zero.
235#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
236#[cfg_attr(feature = "ts-bindings", derive(ts_rs::TS), ts(export))]
237pub struct LoopGuardTripDay {
238    /// The UTC day, as `YYYY-MM-DD`.
239    pub day: String,
240    /// The model the requests named, bounded.
241    pub model_name: String,
242    /// The gglib version that scanned them.
243    pub gglib_version: String,
244    /// The mode they were scanned under: `note` or `refuse`.
245    pub mode: LoopGuardMode,
246    /// Requests the guard scanned — the denominator.
247    #[cfg_attr(feature = "ts-bindings", ts(type = "number"))]
248    pub scanned: u64,
249    /// The requests it acted on: noted under `note`, refused under
250    /// `refuse`. A decision, not a delivery: a noted request can still fail
251    /// to reach the model.
252    #[cfg_attr(feature = "ts-bindings", ts(type = "number"))]
253    pub trips: u64,
254    /// Of the trips, the ones [`LoopGuardTrip::Loop`] raised.
255    #[cfg_attr(feature = "ts-bindings", ts(type = "number"))]
256    pub loops: u64,
257    /// Of the trips, the ones [`LoopGuardTrip::Stagnation`] raised.
258    #[cfg_attr(feature = "ts-bindings", ts(type = "number"))]
259    pub stagnations: u64,
260    /// Distinct session ids among this row's trips — the client's
261    /// `x-gglib-session-id`, or the one gglib derives from the conversation's
262    /// opening when there is none. Per row: a session that trips on two days is
263    /// counted on each, so this does not add across rows.
264    #[cfg_attr(feature = "ts-bindings", ts(type = "number"))]
265    pub sessions: u64,
266}
267
268#[cfg(test)]
269#[path = "loop_guard_log_tests.rs"]
270mod tests;