Skip to content

Commit afe4d14

Browse files
authored
extend mcp (#28)
* extend mcp * fixes * transitive * bump time
1 parent 8f83a8e commit afe4d14

19 files changed

Lines changed: 888 additions & 33 deletions

File tree

crates/core/src/cache.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ pub struct ResultCacheKeyInput<'a> {
3737
pub include_stale: Option<bool>,
3838
pub include_invalidated: Option<bool>,
3939
pub time_range_hash: Option<u64>,
40+
/// Nanosecond resolution. The post-fetch filter compares full
41+
/// `DateTime<Utc>`, so sub-second `as_of` values would otherwise collide.
42+
pub as_of_nanos: Option<i64>,
4043
pub explain: bool,
4144
}
4245

@@ -53,6 +56,7 @@ impl ResultCacheKey {
5356
input.include_stale.hash(&mut hasher);
5457
input.include_invalidated.hash(&mut hasher);
5558
input.time_range_hash.hash(&mut hasher);
59+
input.as_of_nanos.hash(&mut hasher);
5660
input.explain.hash(&mut hasher);
5761
Self {
5862
hash: hasher.finish(),
@@ -217,6 +221,7 @@ mod tests {
217221
include_stale: None,
218222
include_invalidated: None,
219223
time_range_hash: None,
224+
as_of_nanos: None,
220225
explain: false,
221226
});
222227
cache.put_results(&filter, results, ns).await;
@@ -242,6 +247,7 @@ mod tests {
242247
include_stale: None,
243248
include_invalidated: None,
244249
time_range_hash: None,
250+
as_of_nanos: None,
245251
explain: false,
246252
});
247253
cache.put_results(&filter, vec![], ns).await;

crates/core/src/entity.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ impl<M: MetadataStore> EntityResolver<'_, M> {
148148
}
149149
}
150150

151-
fn normalize(name: &str) -> String {
151+
pub(crate) fn normalize(name: &str) -> String {
152152
name.trim()
153153
.to_lowercase()
154154
.replace(['-', '_', '/'], " ")

crates/core/src/service.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ mod recall;
44
mod reflect_op;
55
mod stats;
66
mod store;
7+
mod taxonomy;
8+
mod timeline;
79

810
use std::collections::HashMap;
911
use std::sync::Arc;

crates/core/src/service/recall.rs

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@ impl MemoryService {
2222
.limit
2323
.unwrap_or(DEFAULT_RECALL_LIMIT)
2424
.min(MAX_RECALL_LIMIT);
25-
let candidate_pool_size = if req.include_stale == Some(false) {
25+
// Widen when post-fetch filters will drop rows below `limit`.
26+
let needs_wider_pool = req.include_stale == Some(false) || req.as_of.is_some();
27+
let candidate_pool_size = if needs_wider_pool {
2628
limit.max(MIN_RERANK_POOL_SIZE) * 2
2729
} else {
2830
limit.max(MIN_RERANK_POOL_SIZE)
@@ -51,6 +53,7 @@ impl MemoryService {
5153
tr.end.map(|e| e.timestamp()).hash(&mut h);
5254
h.finish()
5355
});
56+
let as_of_nanos = req.as_of.and_then(|t| t.timestamp_nanos_opt());
5457
let cache_key = cache::ResultCacheKey::new(&cache::ResultCacheKeyInput {
5558
embedding: &embedding,
5659
types: type_strs.as_deref(),
@@ -60,6 +63,7 @@ impl MemoryService {
6063
include_stale: req.include_stale,
6164
include_invalidated: req.include_invalidated,
6265
time_range_hash: time_hash,
66+
as_of_nanos,
6367
explain: req.explain,
6468
});
6569

@@ -91,9 +95,23 @@ impl MemoryService {
9195
let ids: Vec<String> = results.iter().map(|(id, _)| id.clone()).collect();
9296
let memories = self.metadata_store.get_memories_by_ids(&ids).await?;
9397

98+
let as_of = req.as_of;
99+
let include_invalidated = req.include_invalidated.unwrap_or(false);
94100
let memory_map: HashMap<&str, &ferrex_store::Memory> = memories
95101
.iter()
96-
.filter(|m| req.include_invalidated.unwrap_or(false) || m.t_invalid.is_none())
102+
.filter(|m| {
103+
let Some(as_of) = as_of else {
104+
return include_invalidated || m.t_invalid.is_none();
105+
};
106+
let effective_start = m.t_valid.unwrap_or(m.created_at);
107+
if effective_start > as_of {
108+
return false;
109+
}
110+
if include_invalidated {
111+
return true;
112+
}
113+
m.t_invalid.is_none_or(|ti| ti > as_of)
114+
})
97115
.map(|m| (m.id.as_str(), m))
98116
.collect();
99117

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
use ferrex_store::MetadataStore;
2+
3+
use super::MemoryService;
4+
use crate::error::CoreError;
5+
use crate::types::{
6+
TaxonomyEntityEntry, TaxonomyPredicateEntry, TaxonomyRequest, TaxonomyResponse,
7+
};
8+
9+
const DEFAULT_TAXONOMY_LIMIT: usize = 10;
10+
const MAX_TAXONOMY_LIMIT: usize = 100;
11+
12+
impl MemoryService {
13+
#[tracing::instrument(name = "taxonomy", skip_all, fields(namespace))]
14+
pub async fn taxonomy(&self, req: TaxonomyRequest) -> Result<TaxonomyResponse, CoreError> {
15+
let scope_given = req.namespace.is_some();
16+
let namespace = req
17+
.namespace
18+
.unwrap_or_else(|| self.config.namespace.clone());
19+
tracing::Span::current().record("namespace", namespace.as_str());
20+
21+
let limit = req
22+
.limit
23+
.unwrap_or(DEFAULT_TAXONOMY_LIMIT)
24+
.min(MAX_TAXONOMY_LIMIT);
25+
#[allow(clippy::cast_possible_wrap)]
26+
let limit_i64 = limit as i64;
27+
28+
// Four reads, four reader-pool slots.
29+
let by_type_fut = self.metadata_store.memory_count_by_type(&namespace);
30+
let top_entities_fut = self.metadata_store.top_entities(&namespace, limit_i64);
31+
let top_predicates_fut = self.metadata_store.top_predicates(&namespace, limit_i64);
32+
let all_namespaces_fut = async {
33+
if scope_given {
34+
Ok(None)
35+
} else {
36+
self.metadata_store.list_namespaces().await.map(Some)
37+
}
38+
};
39+
let (by_type, top_entities_raw, top_predicates_raw, all_namespaces) = tokio::try_join!(
40+
by_type_fut,
41+
top_entities_fut,
42+
top_predicates_fut,
43+
all_namespaces_fut,
44+
)?;
45+
46+
let total_memories: u64 = by_type.values().sum();
47+
let top_entities = top_entities_raw
48+
.into_iter()
49+
.map(|(name, memory_count)| TaxonomyEntityEntry { name, memory_count })
50+
.collect();
51+
let top_predicates = top_predicates_raw
52+
.into_iter()
53+
.map(|(predicate, count)| TaxonomyPredicateEntry { predicate, count })
54+
.collect();
55+
56+
Ok(TaxonomyResponse {
57+
namespace,
58+
total_memories,
59+
by_type,
60+
top_entities,
61+
top_predicates,
62+
all_namespaces,
63+
})
64+
}
65+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
use ferrex_store::MetadataStore;
2+
3+
use super::{DEFAULT_RECALL_LIMIT, MAX_RECALL_LIMIT, MemoryService};
4+
use crate::entity::normalize;
5+
use crate::error::CoreError;
6+
use crate::types::{TimelineEntry, TimelineRequest, TimelineResponse};
7+
8+
impl MemoryService {
9+
#[tracing::instrument(name = "timeline", skip_all, fields(entity = %req.entity, namespace))]
10+
pub async fn timeline(&self, req: TimelineRequest) -> Result<TimelineResponse, CoreError> {
11+
let namespace = req
12+
.namespace
13+
.unwrap_or_else(|| self.config.namespace.clone());
14+
tracing::Span::current().record("namespace", namespace.as_str());
15+
16+
let normalized = normalize(&req.entity);
17+
if normalized.is_empty() {
18+
return Err(CoreError::Validation("empty entity name".into()));
19+
}
20+
21+
let limit = req
22+
.limit
23+
.unwrap_or(DEFAULT_RECALL_LIMIT)
24+
.min(MAX_RECALL_LIMIT);
25+
let include_invalidated = req.include_invalidated.unwrap_or(false);
26+
27+
let Some(entity) = self.metadata_store.get_entity_by_name(&normalized).await? else {
28+
return Ok(TimelineResponse {
29+
entity: req.entity,
30+
resolved_entity_id: None,
31+
entries: vec![],
32+
});
33+
};
34+
35+
#[allow(clippy::cast_possible_wrap)]
36+
let memories = self
37+
.metadata_store
38+
.timeline_by_entity(
39+
&entity.id,
40+
&namespace,
41+
limit as i64,
42+
include_invalidated,
43+
req.types.as_deref(),
44+
)
45+
.await?;
46+
47+
let entries = memories
48+
.into_iter()
49+
.map(|m| TimelineEntry {
50+
occurred_at: m.t_valid.unwrap_or(m.created_at),
51+
memory: m,
52+
})
53+
.collect();
54+
55+
Ok(TimelineResponse {
56+
entity: req.entity,
57+
resolved_entity_id: Some(entity.id),
58+
entries,
59+
})
60+
}
61+
}

crates/core/src/types.rs

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,14 @@ pub struct RecallRequest {
119119
pub include_stale: Option<bool>,
120120
pub include_invalidated: Option<bool>,
121121
pub time_range: Option<TimeRange>,
122+
/// Point-in-time filter. Keeps rows whose validity window (`t_valid` or
123+
/// `created_at` through `t_invalid`) covered this instant.
124+
/// `include_invalidated = true` drops the end check.
125+
///
126+
/// Scoring and `include_stale` still use the current clock, so a row that
127+
/// was fresh at `as_of` but is stale today still gets dropped. `as_of`
128+
/// asks "what did we know at T", it does not replay the whole pipeline at T.
129+
pub as_of: Option<DateTime<Utc>>,
122130
pub validate_ids: Option<Vec<String>>,
123131
pub explain: bool,
124132
}
@@ -151,6 +159,58 @@ pub struct StatsRequest {
151159
pub diagnostics: Option<bool>,
152160
}
153161

162+
#[derive(Debug)]
163+
pub struct TimelineRequest {
164+
pub entity: String,
165+
pub namespace: Option<String>,
166+
pub limit: Option<usize>,
167+
pub types: Option<Vec<MemoryType>>,
168+
pub include_invalidated: Option<bool>,
169+
}
170+
171+
#[derive(Debug, Serialize)]
172+
pub struct TimelineEntry {
173+
pub memory: Memory,
174+
pub occurred_at: DateTime<Utc>,
175+
}
176+
177+
#[derive(Debug, Serialize)]
178+
pub struct TimelineResponse {
179+
pub entity: String,
180+
pub resolved_entity_id: Option<String>,
181+
pub entries: Vec<TimelineEntry>,
182+
}
183+
184+
#[derive(Debug)]
185+
pub struct TaxonomyRequest {
186+
pub namespace: Option<String>,
187+
pub limit: Option<usize>,
188+
}
189+
190+
#[derive(Debug, Serialize)]
191+
pub struct TaxonomyEntityEntry {
192+
pub name: String,
193+
pub memory_count: u64,
194+
}
195+
196+
#[derive(Debug, Serialize)]
197+
pub struct TaxonomyPredicateEntry {
198+
pub predicate: String,
199+
pub count: u64,
200+
}
201+
202+
#[derive(Debug, Serialize)]
203+
pub struct TaxonomyResponse {
204+
pub namespace: String,
205+
pub total_memories: u64,
206+
pub by_type: HashMap<MemoryType, u64>,
207+
pub top_entities: Vec<TaxonomyEntityEntry>,
208+
pub top_predicates: Vec<TaxonomyPredicateEntry>,
209+
/// Distinct namespaces with live memories. `None` when the request was scoped.
210+
#[serde(skip_serializing_if = "Option::is_none")]
211+
pub all_namespaces: Option<Vec<String>>,
212+
}
213+
154214
#[derive(Debug, Serialize)]
155215
pub struct StoreResponse {
156216
pub id: String,

crates/core/tests/common/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,7 @@ pub fn recall_query(query: &str) -> RecallRequest {
110110
include_stale: None,
111111
include_invalidated: None,
112112
time_range: None,
113+
as_of: None,
113114
validate_ids: None,
114115
explain: false,
115116
}

crates/core/tests/diagnostics.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ async fn diagnostics_returns_nonzero_after_operations() {
2525
include_stale: None,
2626
include_invalidated: None,
2727
time_range: None,
28+
as_of: None,
2829
validate_ids: None,
2930
explain: false,
3031
})

crates/core/tests/golden_set.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,7 @@ async fn test_golden_set_recall_at_3() {
296296
include_stale: None,
297297
include_invalidated: None,
298298
time_range: None,
299+
as_of: None,
299300
validate_ids: None,
300301
explain: false,
301302
};
@@ -370,6 +371,7 @@ async fn test_golden_set_cache_returns_identical_results() {
370371
include_stale: None,
371372
include_invalidated: None,
372373
time_range: None,
374+
as_of: None,
373375
validate_ids: None,
374376
explain: false,
375377
};
@@ -389,6 +391,7 @@ async fn test_golden_set_cache_returns_identical_results() {
389391
include_stale: None,
390392
include_invalidated: None,
391393
time_range: None,
394+
as_of: None,
392395
validate_ids: None,
393396
explain: false,
394397
};

0 commit comments

Comments
 (0)