use std::sync::Arc;
use trusty_common::memory_core::store::kg::Triple;
use trusty_common::memory_core::PalaceHandle;
use crate::AppState;
#[derive(Debug, thiserror::Error)]
pub enum KgWriteError {
#[error(transparent)]
Admission(anyhow::Error),
#[error("kg assert: {0:#}")]
Assert(anyhow::Error),
#[error("triple written but prompt cache refresh failed: {0:#}")]
CacheRefresh(anyhow::Error),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CachePolicy {
Inline,
Batch,
}
#[derive(Debug, Clone, Copy)]
pub struct AssertReceipt {
pub hot: bool,
}
pub async fn assert_triple(
state: &AppState,
handle: &Arc<PalaceHandle>,
triple: Triple,
policy: CachePolicy,
) -> Result<AssertReceipt, KgWriteError> {
let _admission = crate::prompt_facts::check_tier_s_admission(
state,
handle,
&triple.subject,
&triple.predicate,
&triple.object,
)
.await
.map_err(KgWriteError::Admission)?;
let hot = crate::prompt_facts::is_hot_predicate(&triple.predicate);
handle
.kg
.assert(triple)
.await
.map_err(KgWriteError::Assert)?;
if hot && policy == CachePolicy::Inline {
refresh(state).await?;
}
Ok(AssertReceipt { hot })
}
pub async fn refresh_after_batch(state: &AppState, any_hot: bool) -> Result<(), KgWriteError> {
if any_hot {
refresh(state).await
} else {
Ok(())
}
}
async fn refresh(state: &AppState) -> Result<(), KgWriteError> {
crate::prompt_facts::rebuild_prompt_cache(state)
.await
.map_err(KgWriteError::CacheRefresh)
}
#[cfg(test)]
mod tests {
use super::*;
use trusty_common::memory_core::palace::PalaceId;
fn state_with_palace(name: &str) -> (AppState, Arc<PalaceHandle>) {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path().to_path_buf();
std::mem::forget(tmp);
let state = AppState::new(root).with_default_palace(Some(name.to_string()));
let palace = trusty_common::memory_core::Palace {
id: PalaceId::new(name),
name: name.to_string(),
description: None,
created_at: chrono::Utc::now(),
data_dir: state.data_root.join(name),
};
let handle = state
.registry
.create_palace(&state.data_root, palace)
.expect("create palace");
(state, handle)
}
fn triple(subject: &str, predicate: &str, object: &str) -> Triple {
Triple {
subject: subject.to_string(),
predicate: predicate.to_string(),
object: object.to_string(),
valid_from: chrono::Utc::now(),
valid_to: None,
confidence: 1.0,
provenance: Some("test".to_string()),
}
}
#[tokio::test]
async fn kg_write_refreshes_cache_for_hot_predicate() {
let (state, handle) = state_with_palace("hotwrite");
let receipt = assert_triple(
&state,
&handle,
triple("rust", "has_convention", "no unwrap in library code"),
CachePolicy::Inline,
)
.await
.expect("assert");
assert!(receipt.hot);
let guard = state.prompt_context_cache.read().await;
assert!(
guard.formatted.contains("no unwrap in library code"),
"hot write missing from cache; got: {}",
guard.formatted
);
}
#[tokio::test]
async fn kg_write_skips_refresh_for_cold_predicate() {
let (state, handle) = state_with_palace("coldwrite");
let receipt = assert_triple(
&state,
&handle,
triple("alice", "works_at", "Acme"),
CachePolicy::Inline,
)
.await
.expect("assert");
assert!(!receipt.hot);
let guard = state.prompt_context_cache.read().await;
assert!(
guard.triples.is_empty(),
"cold write should not populate the cache; got: {:?}",
guard.triples
);
}
#[tokio::test]
async fn kg_write_admission_refusal_leaves_storage_and_cache_untouched() {
let (state, handle) = state_with_palace("refusal");
let over_long = "x".repeat(crate::prompt_facts::TIER_S_MAX_OBJECT_CHARS + 1);
let err = assert_triple(
&state,
&handle,
triple("subj", "has_convention", &over_long),
CachePolicy::Inline,
)
.await
.expect_err("over-long object must be refused");
assert!(
matches!(err, KgWriteError::Admission(_)),
"expected Admission, got: {err:?}"
);
let stored = handle.kg.query_active("subj").await.expect("query");
assert!(
stored.is_empty(),
"refused write reached storage: {stored:?}"
);
let guard = state.prompt_context_cache.read().await;
assert!(guard.triples.is_empty(), "refused write reached the cache");
}
#[tokio::test]
async fn kg_write_batch_policy_defers_refresh() {
let (state, handle) = state_with_palace("batchwrite");
let receipt = assert_triple(
&state,
&handle,
triple("tm", "is_alias_for", "trusty-memory"),
CachePolicy::Batch,
)
.await
.expect("assert");
assert!(receipt.hot);
assert!(
state.prompt_context_cache.read().await.triples.is_empty(),
"Batch must not refresh inline"
);
refresh_after_batch(&state, receipt.hot)
.await
.expect("batch refresh");
let guard = state.prompt_context_cache.read().await;
assert!(
guard.formatted.contains("tm → trusty-memory"),
"batch refresh missed the write; got: {}",
guard.formatted
);
}
#[tokio::test]
async fn kg_write_batch_refresh_is_a_noop_when_nothing_was_hot() {
let (state, handle) = state_with_palace("batchcold");
assert_triple(
&state,
&handle,
triple("bob", "lives_in", "Paris"),
CachePolicy::Batch,
)
.await
.expect("assert");
refresh_after_batch(&state, false).await.expect("noop");
assert!(state.prompt_context_cache.read().await.triples.is_empty());
}
}