use chrono::{DateTime, SecondsFormat, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use super::error::AuditError;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PerQueryAudit {
pub query_id: Uuid,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub tenant_id: Option<String>,
pub model_id: String,
pub model_version: String,
pub query_lineage: serde_json::Value,
pub top_k_result_ids: Vec<String>,
pub retrieval_scores: Vec<f32>,
pub executed_at: DateTime<Utc>,
#[serde(default)]
pub signature: String,
}
impl PerQueryAudit {
pub fn new(
query_id: Uuid,
model_id: impl Into<String>,
model_version: impl Into<String>,
query_lineage: serde_json::Value,
top_k_result_ids: Vec<String>,
retrieval_scores: Vec<f32>,
) -> Result<Self, AuditError> {
if top_k_result_ids.len() != retrieval_scores.len() {
return Err(AuditError::LengthMismatch {
ids: top_k_result_ids.len(),
scores: retrieval_scores.len(),
});
}
Ok(Self {
query_id,
tenant_id: None,
model_id: model_id.into(),
model_version: model_version.into(),
query_lineage,
top_k_result_ids,
retrieval_scores,
executed_at: Utc::now(),
signature: String::new(),
})
}
pub fn executed_at_micros(&self) -> i64 {
self.executed_at.timestamp_micros()
}
}
pub fn canonical_serialize(record: &PerQueryAudit) -> Result<Vec<u8>, AuditError> {
let tenant = record
.tenant_id
.as_deref()
.ok_or(AuditError::NoTenantBinding)?;
let mut out = Vec::new();
out.push(b'{');
write_field(
&mut out,
"query_id",
&serde_json::json!(record.query_id),
true,
)?;
write_field(&mut out, "tenant_id", &serde_json::json!(tenant), false)?;
write_field(
&mut out,
"model_id",
&serde_json::json!(record.model_id),
false,
)?;
write_field(
&mut out,
"model_version",
&serde_json::json!(record.model_version),
false,
)?;
write_field(
&mut out,
"query_lineage",
&canonicalize_value(&record.query_lineage),
false,
)?;
write_field(
&mut out,
"top_k_result_ids",
&serde_json::json!(record.top_k_result_ids),
false,
)?;
write_field(
&mut out,
"retrieval_scores",
&serde_json::json!(record.retrieval_scores),
false,
)?;
let ts = record
.executed_at
.to_rfc3339_opts(SecondsFormat::Secs, true);
write_field(&mut out, "executed_at", &serde_json::json!(ts), false)?;
out.push(b'}');
Ok(out)
}
fn write_field(
out: &mut Vec<u8>,
key: &str,
value: &serde_json::Value,
first: bool,
) -> Result<(), AuditError> {
if !first {
out.push(b',');
}
out.extend_from_slice(serde_json::to_string(key)?.as_bytes());
out.push(b':');
out.extend_from_slice(serde_json::to_vec(value)?.as_slice());
Ok(())
}
fn canonicalize_value(value: &serde_json::Value) -> serde_json::Value {
match value {
serde_json::Value::Object(map) => {
let mut entries: Vec<(&String, &serde_json::Value)> = map.iter().collect();
entries.sort_by(|a, b| a.0.cmp(b.0));
let mut sorted = serde_json::Map::new();
for (k, v) in entries {
sorted.insert(k.clone(), canonicalize_value(v));
}
serde_json::Value::Object(sorted)
}
serde_json::Value::Array(items) => {
serde_json::Value::Array(items.iter().map(canonicalize_value).collect())
}
other => other.clone(),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn scoped(lineage: serde_json::Value) -> PerQueryAudit {
let mut r = PerQueryAudit::new(
Uuid::nil(),
"m",
"v",
lineage,
vec!["a".into(), "b".into()],
vec![0.1, 0.2],
)
.unwrap();
r.tenant_id = Some("t1".into());
r
}
#[test]
fn new_rejects_length_mismatch() {
let err = PerQueryAudit::new(
Uuid::nil(),
"m",
"v",
serde_json::json!({}),
vec!["a".into()],
vec![0.1, 0.2],
)
.unwrap_err();
assert!(matches!(
err,
AuditError::LengthMismatch { ids: 1, scores: 2 }
));
}
#[test]
fn canonical_requires_tenant() {
let r = PerQueryAudit::new(Uuid::nil(), "m", "v", serde_json::json!({}), vec![], vec![])
.unwrap();
assert!(matches!(
canonical_serialize(&r),
Err(AuditError::NoTenantBinding)
));
}
#[test]
fn canonical_is_key_order_independent() {
let a = scoped(serde_json::json!({ "examiner_id": "42", "image_hashes": ["x"] }));
let b = scoped(serde_json::json!({ "image_hashes": ["x"], "examiner_id": "42" }));
assert_eq!(
canonical_serialize(&a).unwrap(),
canonical_serialize(&b).unwrap(),
"canonical form must be independent of lineage key insertion order"
);
}
#[test]
fn canonical_is_deterministic() {
let r = scoped(serde_json::json!({ "k": [1, 2, 3], "nested": { "z": 1, "a": 2 } }));
assert_eq!(
canonical_serialize(&r).unwrap(),
canonical_serialize(&r).unwrap()
);
}
#[test]
fn canonical_changes_when_a_field_changes() {
let a = scoped(serde_json::json!({ "k": 1 }));
let mut b = a.clone();
b.model_id = "different".into();
assert_ne!(
canonical_serialize(&a).unwrap(),
canonical_serialize(&b).unwrap()
);
}
}