klieo-runlog 3.3.1

Tier 2 observability — RunLog aggregate + replay engine for klieo agents.
Documentation
//! Stable request fingerprint for replay-keying (ADR-048).
//!
//! A capture records the fingerprint of each LLM request; the re-drive double
//! (slice 2b) answers the agent's outgoing request by matching this same
//! fingerprint. Record and replay build the request via the same agent code,
//! so the fingerprint matches — the determinism the replay engine already
//! assumes. A request with no recorded fingerprint is the divergence signal.
//!
//! The canonical form is built from `serde_json` of the request's
//! `Serialize` fields (`Message`/`ToolDef`/sampling params) plus a hand-written
//! tag for `ResponseFormat` (which is not `Serialize`). serde encodings are a
//! stable contract — unlike `Debug`, a derive/field change is a deliberate,
//! reviewable break rather than a silent fingerprint shift that would
//! invalidate persisted captures across code versions.

use klieo_core::llm::{ChatRequest, ResponseFormat};

const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;

/// Stable hex fingerprint over a request's replay-identity fields (FNV-1a 64).
/// `timeout` is excluded — it is operational, not semantic, so it must not
/// perturb the fingerprint.
pub fn request_fingerprint(req: &ChatRequest) -> String {
    let mut canonical = String::new();
    push_json(&mut canonical, "messages", &req.messages);
    push_json(&mut canonical, "tools", &req.tools);
    push_json(&mut canonical, "temperature", &req.temperature);
    push_json(&mut canonical, "max_tokens", &req.max_tokens);
    push_json(&mut canonical, "stop", &req.stop);
    canonical.push_str("response_format=");
    canonical.push_str(&response_format_tag(&req.response_format));

    let mut hash = FNV_OFFSET_BASIS;
    for byte in canonical.as_bytes() {
        hash ^= u64::from(*byte);
        hash = hash.wrapping_mul(FNV_PRIME);
    }
    format!("{hash:016x}")
}

/// The request fields serialize to a byte buffer infallibly (JSON objects
/// always have string keys), so a serialization failure is a bug, not a runtime
/// condition — hence the `expect`.
fn push_json<T: serde::Serialize>(buf: &mut String, name: &str, value: &T) {
    buf.push_str(name);
    buf.push('=');
    buf.push_str(&serde_json::to_string(value).expect("request field is JSON-serializable"));
    buf.push('\n');
}

/// Stable discriminant tag for `ResponseFormat`, which is not `Serialize`. The
/// `#[non_exhaustive]` wildcard collapses any future variant to `other` — a
/// known limitation that a new variant must revisit.
fn response_format_tag(format: &ResponseFormat) -> String {
    match format {
        ResponseFormat::Text => "text".into(),
        ResponseFormat::Json { schema } => format!("json:{schema}"),
        ResponseFormat::StructuredOutput { schema } => format!("structured:{schema}"),
        _ => "other".into(),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use klieo_core::llm::{ChatRequest, Message, ResponseFormat, Role};

    fn user_request(content: &str) -> ChatRequest {
        ChatRequest {
            messages: vec![Message {
                role: Role::User,
                content: content.into(),
                tool_calls: vec![],
                tool_call_id: None,
            }],
            tools: vec![],
            temperature: None,
            max_tokens: None,
            response_format: ResponseFormat::Text,
            stop: vec![],
            timeout: None,
        }
    }

    #[test]
    fn same_request_same_fingerprint() {
        assert_eq!(
            request_fingerprint(&user_request("hello")),
            request_fingerprint(&user_request("hello"))
        );
    }

    #[test]
    fn differing_message_changes_fingerprint() {
        assert_ne!(
            request_fingerprint(&user_request("hello")),
            request_fingerprint(&user_request("goodbye"))
        );
    }

    #[test]
    fn timeout_does_not_affect_fingerprint() {
        let mut with_timeout = user_request("hello");
        with_timeout.timeout = Some(std::time::Duration::from_secs(30));
        assert_eq!(
            request_fingerprint(&user_request("hello")),
            request_fingerprint(&with_timeout),
            "timeout is operational, not part of replay identity"
        );
    }

    #[test]
    fn sampling_params_change_fingerprint() {
        let mut hot = user_request("hello");
        hot.temperature = Some(0.9);
        assert_ne!(
            request_fingerprint(&user_request("hello")),
            request_fingerprint(&hot)
        );
    }

    #[test]
    fn fingerprint_is_16_lowercase_hex_chars() {
        let fp = request_fingerprint(&user_request("hello"));
        assert_eq!(fp.len(), 16, "FNV-1a 64 renders as 16 hex chars");
        assert!(
            fp.chars()
                .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()),
            "fingerprint must be lowercase hex: {fp}"
        );
    }

    #[test]
    fn empty_message_request_is_well_formed_and_distinct() {
        let empty = ChatRequest {
            messages: vec![],
            tools: vec![],
            temperature: None,
            max_tokens: None,
            response_format: ResponseFormat::Text,
            stop: vec![],
            timeout: None,
        };
        assert_eq!(request_fingerprint(&empty).len(), 16);
        assert_ne!(
            request_fingerprint(&empty),
            request_fingerprint(&user_request("hello")),
            "an empty request must not collide with a non-empty one"
        );
    }

    #[test]
    fn response_format_changes_fingerprint() {
        let text = user_request("hello");
        let mut json = user_request("hello");
        json.response_format = ResponseFormat::Json {
            schema: serde_json::json!({"type": "object"}),
        };
        let mut structured = user_request("hello");
        structured.response_format = ResponseFormat::StructuredOutput {
            schema: serde_json::json!({"type": "object"}),
        };
        let (ft, fj, fs) = (
            request_fingerprint(&text),
            request_fingerprint(&json),
            request_fingerprint(&structured),
        );
        assert_ne!(ft, fj, "Text vs Json must differ");
        assert_ne!(ft, fs, "Text vs StructuredOutput must differ");
        assert_ne!(fj, fs, "Json vs StructuredOutput must differ");
    }
}