use std::collections::HashMap;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::uri::AxiomUri;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RelationSummary {
pub uri: String,
pub reason: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub relation_type: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source_object_type: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target_object_type: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct RelationLink {
pub id: String,
pub uris: Vec<String>,
pub reason: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContextHit {
pub uri: String,
pub score: f32,
#[serde(rename = "abstract")]
pub abstract_text: String,
pub context_type: String,
pub relations: Vec<RelationSummary>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub snippet: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub matched_heading: Option<String>,
#[serde(default)]
pub score_components: ScoreComponents,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ScoreComponents {
pub exact: f32,
pub dense: f32,
pub sparse: f32,
pub path: f32,
pub recency: f32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FindResult {
pub query_plan: QueryPlan,
pub query_results: Vec<ContextHit>,
#[serde(default, skip_serializing_if = "HitBuckets::is_empty")]
pub hit_buckets: HitBuckets,
#[serde(default)]
pub memories: Vec<ContextHit>,
#[serde(default)]
pub resources: Vec<ContextHit>,
#[serde(default)]
pub skills: Vec<ContextHit>,
#[serde(skip_serializing_if = "Option::is_none")]
pub trace: Option<RetrievalTrace>,
#[serde(skip_serializing_if = "Option::is_none")]
pub trace_uri: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
pub struct HitBuckets {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub memories: Vec<usize>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub resources: Vec<usize>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub skills: Vec<usize>,
}
impl HitBuckets {
#[must_use]
pub fn is_empty(&self) -> bool {
self.memories.is_empty() && self.resources.is_empty() && self.skills.is_empty()
}
}
impl FindResult {
pub fn rebuild_hit_buckets(&mut self) {
self.hit_buckets = classify_hit_buckets(&self.query_results);
self.rebuild_legacy_views();
}
pub fn rebuild_legacy_views(&mut self) {
let (memories, resources, skills) =
collect_legacy_hit_views(&self.query_results, &self.hit_buckets);
self.memories = memories;
self.resources = resources;
self.skills = skills;
}
pub fn memories(&self) -> impl Iterator<Item = &ContextHit> {
self.hit_buckets
.memories
.iter()
.filter_map(|&index| self.query_results.get(index))
}
pub fn resources(&self) -> impl Iterator<Item = &ContextHit> {
self.hit_buckets
.resources
.iter()
.filter_map(|&index| self.query_results.get(index))
}
pub fn skills(&self) -> impl Iterator<Item = &ContextHit> {
self.hit_buckets
.skills
.iter()
.filter_map(|&index| self.query_results.get(index))
}
}
pub fn classify_hit_buckets(hits: &[ContextHit]) -> HitBuckets {
let mut buckets = HitBuckets::default();
for (index, hit) in hits.iter().enumerate() {
if hit.uri.starts_with("axiom://user/memories")
|| hit.uri.starts_with("axiom://agent/memories")
{
buckets.memories.push(index);
} else if hit.uri.starts_with("axiom://agent/skills") {
buckets.skills.push(index);
} else {
buckets.resources.push(index);
}
}
buckets
}
fn collect_legacy_hit_views(
hits: &[ContextHit],
buckets: &HitBuckets,
) -> (Vec<ContextHit>, Vec<ContextHit>, Vec<ContextHit>) {
let memories = buckets
.memories
.iter()
.filter_map(|&index| hits.get(index).cloned())
.collect::<Vec<_>>();
let resources = buckets
.resources
.iter()
.filter_map(|&index| hits.get(index).cloned())
.collect::<Vec<_>>();
let skills = buckets
.skills
.iter()
.filter_map(|&index| hits.get(index).cloned())
.collect::<Vec<_>>();
(memories, resources, skills)
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SearchFilter {
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub mime: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RetrievalStep {
pub round: u32,
pub current_uri: String,
pub children_examined: usize,
pub children_selected: usize,
pub queue_size_after: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TraceStats {
pub latency_ms: u128,
pub explored_nodes: usize,
pub convergence_rounds: u32,
#[serde(default)]
pub typed_query_count: usize,
#[serde(default)]
pub relation_enriched_hits: usize,
#[serde(default)]
pub relation_enriched_links: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RetrievalTrace {
pub trace_id: String,
pub request_type: String,
pub query: String,
pub target_uri: Option<String>,
pub start_points: Vec<TracePoint>,
pub steps: Vec<RetrievalStep>,
pub final_topk: Vec<TracePoint>,
pub stop_reason: String,
pub metrics: TraceStats,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TracePoint {
pub uri: String,
pub score: f32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IndexRecord {
pub id: String,
pub uri: String,
pub parent_uri: Option<String>,
pub is_leaf: bool,
pub context_type: String,
pub name: String,
pub abstract_text: String,
pub content: String,
pub tags: Vec<String>,
pub updated_at: DateTime<Utc>,
pub depth: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchOptions {
pub query: String,
pub target_uri: Option<AxiomUri>,
pub session: Option<String>,
#[serde(default)]
pub session_hints: Vec<String>,
#[serde(default)]
pub budget: Option<SearchBudget>,
pub limit: usize,
pub score_threshold: Option<f32>,
pub min_match_tokens: Option<usize>,
pub filter: Option<SearchFilter>,
pub request_type: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SearchBudget {
pub max_ms: Option<u64>,
pub max_nodes: Option<usize>,
pub max_depth: Option<usize>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct QueryPlan {
#[serde(default)]
pub scopes: Vec<String>,
#[serde(default)]
pub keywords: Vec<String>,
#[serde(default)]
pub typed_queries: Vec<TypedQueryPlan>,
#[serde(default)]
pub notes: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TypedQueryPlan {
pub kind: String,
pub query: String,
pub scopes: Vec<String>,
pub priority: u8,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MetadataFilter {
pub fields: HashMap<String, serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchRequest {
pub query: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target_uri: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limit: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub score_threshold: Option<f32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub min_match_tokens: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub filter: Option<MetadataFilter>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub budget: Option<SearchBudget>,
#[serde(default)]
pub runtime_hints: Vec<RuntimeHint>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum RuntimeHintKind {
Observation,
CurrentTask,
SuggestedResponse,
External,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct RuntimeHint {
pub kind: RuntimeHintKind,
pub text: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EmbeddingBackendStatus {
pub provider: String,
pub vector_version: String,
pub dim: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BackendStatus {
pub local_records: usize,
pub retrieval_backend: String,
pub retrieval_backend_policy: String,
pub embedding: EmbeddingBackendStatus,
}
#[cfg(test)]
mod tests {
use super::{
ContextHit, FindResult, QueryPlan, ScoreComponents, SearchRequest, TypedQueryPlan,
classify_hit_buckets,
};
#[test]
fn query_plan_serialization_snapshot_is_stable() {
let plan = QueryPlan {
scopes: vec!["resources".to_string()],
keywords: vec!["oauth".to_string()],
typed_queries: vec![
TypedQueryPlan {
kind: "primary".to_string(),
query: "oauth".to_string(),
scopes: vec!["resources".to_string()],
priority: 1,
},
TypedQueryPlan {
kind: "session_recent".to_string(),
query: "oauth hint".to_string(),
scopes: vec!["session".to_string()],
priority: 2,
},
],
notes: vec!["backend:memory".to_string(), "budget_nodes:10".to_string()],
};
let encoded = serde_json::to_value(&plan).expect("serialize query plan");
assert_eq!(
encoded,
serde_json::json!({
"scopes": ["resources"],
"keywords": ["oauth"],
"typed_queries": [
{
"kind": "primary",
"query": "oauth",
"scopes": ["resources"],
"priority": 1
},
{
"kind": "session_recent",
"query": "oauth hint",
"scopes": ["session"],
"priority": 2
}
],
"notes": ["backend:memory", "budget_nodes:10"]
})
);
}
#[test]
fn runtime_hint_serde_backward_compat() {
let payload = serde_json::json!({
"query": "oauth",
"target_uri": "axiom://resources",
"session": "s-1",
"limit": 5
});
let decoded: SearchRequest =
serde_json::from_value(payload).expect("deserialize search request");
assert!(decoded.runtime_hints.is_empty());
}
fn hit(uri: &str) -> ContextHit {
ContextHit {
uri: uri.to_string(),
score: 0.5,
abstract_text: String::new(),
context_type: "resource".to_string(),
relations: Vec::new(),
snippet: None,
matched_heading: None,
score_components: ScoreComponents::default(),
}
}
#[test]
fn classify_hit_buckets_assigns_stable_indices() {
let hits = vec![
hit("axiom://resources/docs/a.md"),
hit("axiom://user/memories/preferences/pref.md"),
hit("axiom://agent/skills/rust.md"),
hit("axiom://agent/memories/patterns/pat.md"),
];
let buckets = classify_hit_buckets(&hits);
assert_eq!(buckets.resources, vec![0]);
assert_eq!(buckets.memories, vec![1, 3]);
assert_eq!(buckets.skills, vec![2]);
}
#[test]
fn find_result_bucket_accessors_read_from_query_results() {
let query_results = vec![
hit("axiom://resources/docs/a.md"),
hit("axiom://user/memories/preferences/pref.md"),
hit("axiom://agent/skills/rust.md"),
];
let hit_buckets = classify_hit_buckets(&query_results);
let mut result = FindResult {
query_plan: QueryPlan::default(),
hit_buckets,
query_results,
memories: Vec::new(),
resources: Vec::new(),
skills: Vec::new(),
trace: None,
trace_uri: None,
};
result.rebuild_legacy_views();
let memories = result
.memories()
.map(|item| item.uri.clone())
.collect::<Vec<_>>();
let resources = result
.resources()
.map(|item| item.uri.clone())
.collect::<Vec<_>>();
let skills = result
.skills()
.map(|item| item.uri.clone())
.collect::<Vec<_>>();
assert_eq!(
memories,
vec!["axiom://user/memories/preferences/pref.md".to_string()]
);
assert_eq!(resources, vec!["axiom://resources/docs/a.md".to_string()]);
assert_eq!(skills, vec!["axiom://agent/skills/rust.md".to_string()]);
}
#[test]
fn find_result_legacy_views_are_derived_from_hit_buckets() {
let query_results = vec![
hit("axiom://resources/docs/a.md"),
hit("axiom://user/memories/preferences/pref.md"),
hit("axiom://agent/skills/rust.md"),
];
let hit_buckets = classify_hit_buckets(&query_results);
let mut result = FindResult {
query_plan: QueryPlan::default(),
query_results,
hit_buckets,
memories: Vec::new(),
resources: Vec::new(),
skills: Vec::new(),
trace: None,
trace_uri: None,
};
result.rebuild_legacy_views();
assert_eq!(result.memories.len(), 1);
assert_eq!(result.resources.len(), 1);
assert_eq!(result.skills.len(), 1);
}
}