use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
use std::sync::{RwLock, RwLockReadGuard};
use petgraph::graph::NodeIndex;
use crate::graph::algorithms::text_index::bm25::{PreparedQuery, ScoredDoc};
use crate::graph::algorithms::text_index::TextIndex;
use crate::graph::dir_graph::DirGraph;
use crate::graph::index_freshness::{FreshnessDelta, IndexFreshness};
use crate::graph::schema::InternedKey;
use crate::graph::storage::{GraphRead, StrField};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TextIndexReport {
pub indexed: usize,
pub skipped: usize,
pub terms: usize,
}
pub fn index_key(node_type: &str, property: &str) -> (String, String) {
(node_type.to_string(), property.to_string())
}
#[derive(Debug)]
pub struct TextIndexStore {
index: RwLock<TextIndex>,
generation: AtomicU64,
freshness: IndexFreshness,
resolved_field: String,
skipped: AtomicUsize,
}
impl Clone for TextIndexStore {
fn clone(&self) -> Self {
Self {
index: RwLock::new(self.index().clone()),
generation: AtomicU64::new(self.generation()),
freshness: self.freshness.clone(),
resolved_field: self.resolved_field.clone(),
skipped: AtomicUsize::new(self.skipped()),
}
}
}
pub struct TextIndexRead<'a>(RwLockReadGuard<'a, TextIndex>);
impl TextIndexRead<'_> {
pub(crate) fn index(&self) -> &TextIndex {
&self.0
}
pub fn prepare_query(&self, query: &str) -> PreparedQuery {
self.0.prepare_query(query)
}
pub fn score(&self, node: NodeIndex, query: &PreparedQuery) -> Option<f64> {
let slot = TextIndexStore::slot(node);
self.0.contains_doc(slot).then(|| self.0.score(slot, query))
}
pub fn top_k(&self, query: &PreparedQuery, k: usize) -> Vec<(NodeIndex, f64)> {
self.0
.top_k(query, k)
.into_iter()
.map(|ScoredDoc { slot, score }| (NodeIndex::new(slot as usize), score))
.collect()
}
pub fn contains_node(&self, node: NodeIndex) -> bool {
self.0.contains_doc(TextIndexStore::slot(node))
}
pub fn documents(&self) -> usize {
self.0.total_docs()
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
}
impl TextIndexStore {
#[inline]
fn slot(node: NodeIndex) -> u32 {
node.index() as u32
}
fn index(&self) -> RwLockReadGuard<'_, TextIndex> {
self.index.read().unwrap_or_else(|e| e.into_inner())
}
pub fn read(&self) -> TextIndexRead<'_> {
TextIndexRead(self.index())
}
pub fn generation(&self) -> u64 {
self.generation.load(Ordering::Acquire)
}
pub fn resolved_field(&self) -> &str {
&self.resolved_field
}
pub(crate) fn freshness_state(&self) -> &IndexFreshness {
&self.freshness
}
pub fn documents(&self) -> usize {
self.index().total_docs()
}
pub fn skipped(&self) -> usize {
self.skipped.load(Ordering::Relaxed)
}
pub fn terms(&self) -> usize {
self.index().vocabulary_len()
}
pub fn estimated_bytes(&self) -> usize {
self.index().estimated_bytes()
}
pub fn contains_node(&self, node: NodeIndex) -> bool {
self.read().contains_node(node)
}
pub fn delta_size(&self, graph: &DirGraph) -> usize {
self.freshness.delta_size(node_bound(graph))
}
pub fn is_stale(&self, graph: &DirGraph) -> bool {
self.freshness.is_stale(node_bound(graph))
}
pub fn auto_refresh_limit(&self) -> usize {
self.freshness.limit()
}
pub fn can_auto_refresh(&self, graph: &DirGraph) -> bool {
self.freshness.within_limit(node_bound(graph))
}
pub fn remove_node(&mut self, node: NodeIndex) -> bool {
self.index
.get_mut()
.unwrap_or_else(|e| e.into_inner())
.remove_doc(Self::slot(node))
}
pub(crate) fn note_slot_changed(&self, node: NodeIndex) {
self.freshness.note_changed(Self::slot(node));
}
pub fn prepare_query(&self, query: &str) -> PreparedQuery {
self.read().prepare_query(query)
}
pub fn score(&self, node: NodeIndex, query: &PreparedQuery) -> Option<f64> {
self.read().score(node, query)
}
pub fn top_k(&self, query: &PreparedQuery, k: usize) -> Vec<(NodeIndex, f64)> {
self.read().top_k(query, k)
}
pub fn validate(&self) -> Result<(), String> {
self.index().validate()
}
pub fn refresh(&self, graph: &DirGraph, node_type: &str) -> usize {
if graph.read_only {
return 0;
}
let mut index = self.index.write().unwrap_or_else(|e| e.into_inner());
let Some(delta) = self.freshness.take_delta(node_bound(graph)) else {
return 0;
};
let field_key = InternedKey::from_str(&self.resolved_field);
let type_key = InternedKey::from_str(node_type);
let splices = delta
.slots()
.filter(|slot| {
graph.graph.node_type_of(NodeIndex::new(*slot as usize)) == Some(type_key)
|| index.contains_doc(*slot)
})
.count();
let seen = if rebuild_beats_folding(splices) {
self.rebuild(graph, node_type, &mut index, field_key)
} else {
self.fold(graph, node_type, &delta, &mut index, type_key, field_key)
};
self.generation.fetch_add(1, Ordering::Release);
debug_assert!(
index.validate().is_ok(),
"a refreshed text index must satisfy its own invariants: {:?}",
index.validate()
);
seen
}
fn fold(
&self,
graph: &DirGraph,
node_type: &str,
delta: &FreshnessDelta,
index: &mut TextIndex,
type_key: InternedKey,
field_key: InternedKey,
) -> usize {
let mut seen = 0usize;
for slot in delta.slots() {
seen += 1;
let node = NodeIndex::new(slot as usize);
let indexed = match graph.graph.node_type_of(node) {
Some(key) if key == type_key => graph
.graph
.node_view(node)
.map(|view| {
match view.resolved_field_str(node_type, &self.resolved_field, field_key) {
StrField::Str(text) => {
index.add_doc(slot, text.as_ref());
true
}
StrField::NotString | StrField::Absent => false,
}
})
.unwrap_or(false),
_ => false,
};
if !indexed {
index.remove_doc(slot);
}
}
seen
}
fn rebuild(
&self,
graph: &DirGraph,
node_type: &str,
index: &mut TextIndex,
field_key: InternedKey,
) -> usize {
let members = graph
.type_indices
.get(node_type)
.map(|nodes| nodes.to_vec())
.unwrap_or_default();
let mut skipped = 0usize;
*index = TextIndex::build(members.iter().filter_map(|node| {
let view = graph.graph.node_view(*node)?;
match view.resolved_field_str(node_type, &self.resolved_field, field_key) {
StrField::Str(text) => Some((Self::slot(*node), text)),
StrField::NotString | StrField::Absent => {
skipped += 1;
None
}
}
}));
self.skipped.store(skipped, Ordering::Relaxed);
members.len()
}
}
pub(crate) const FOLD_SLOTS_PER_REBUILD: usize = 1500;
fn rebuild_beats_folding(splices: usize) -> bool {
splices > FOLD_SLOTS_PER_REBUILD
}
#[inline]
fn node_bound(graph: &DirGraph) -> u32 {
GraphRead::node_bound(&graph.graph) as u32
}
pub(crate) fn note_node_created(graph: &DirGraph, node: NodeIndex, node_type: &str) {
let slot = TextIndexStore::slot(node);
for ((indexed_type, _), store) in &graph.text_indexes {
store
.freshness
.note_created(slot, indexed_type == node_type);
}
}
pub(crate) fn note_property_written(
graph: &DirGraph,
node: NodeIndex,
node_type: &str,
field: Option<&str>,
) {
let slot = TextIndexStore::slot(node);
for ((indexed_type, _), store) in &graph.text_indexes {
if indexed_type != node_type {
continue;
}
if field.is_none_or(|written| written == store.resolved_field) {
store.freshness.note_changed(slot);
}
}
}
pub fn build_text_index(
graph: &mut DirGraph,
node_type: &str,
property: &str,
auto_refresh_limit: Option<usize>,
) -> Result<TextIndexReport, String> {
if GraphRead::is_disk(&graph.graph) {
return Err(format!(
"build_text_index('{node_type}', '{property}') is not supported on a disk-backed \
graph: the BM25 index is heap-resident, and building one over a graph sized for \
the disk backend is the memory cliff that backend exists to avoid. Use the \
default (in-memory) or 'mapped' storage mode."
));
}
if !graph.has_node_type(node_type) {
return Err(format!(
"Unknown node type '{node_type}'. build_text_index() indexes one node type's \
property; list the graph's node types to see what exists."
));
}
let field = graph.resolve_alias(node_type, property).to_string();
let key = InternedKey::from_str(&field);
let nodes = graph
.type_indices
.get(node_type)
.map(|members| members.to_vec())
.unwrap_or_default();
let mut skipped = 0usize;
let index = TextIndex::build(nodes.iter().filter_map(|node_idx| {
let view = graph.graph.node_view(*node_idx)?;
match view.resolved_field_str(node_type, &field, key) {
StrField::Str(text) => Some((TextIndexStore::slot(*node_idx), text)),
StrField::NotString | StrField::Absent => {
skipped += 1;
None
}
}
}));
if index.total_docs() == 0 && !nodes.is_empty() {
return Err(format!(
"No '{node_type}' node carries a string value for '{property}' — all {} were \
absent or non-string, so there is nothing to index. Check the spelling, and note \
that BM25 indexes text: a numeric or list-valued property is not indexable.",
nodes.len()
));
}
debug_assert!(
index.validate().is_ok(),
"a freshly built text index must satisfy its own invariants: {:?}",
index.validate()
);
let key_pair = index_key(node_type, property);
let limit = auto_refresh_limit.or_else(|| {
graph
.text_indexes
.get(&key_pair)
.map(|existing| existing.auto_refresh_limit())
});
let store = TextIndexStore {
index: RwLock::new(index),
generation: AtomicU64::new(0),
freshness: IndexFreshness::covering(node_bound(graph), limit),
resolved_field: field,
skipped: AtomicUsize::new(skipped),
};
let report = TextIndexReport {
indexed: store.documents(),
skipped,
terms: store.terms(),
};
graph.text_indexes.insert(key_pair, store);
graph.bump_version();
Ok(report)
}
pub(crate) fn attach_persisted_text_index(
graph: &mut DirGraph,
node_type: &str,
property: &str,
index: TextIndex,
freshness: IndexFreshness,
resolved_field: String,
skipped: usize,
) {
graph.text_indexes.insert(
index_key(node_type, property),
TextIndexStore {
index: RwLock::new(index),
generation: AtomicU64::new(0),
freshness,
resolved_field,
skipped: AtomicUsize::new(skipped),
},
);
}
pub fn refresh_text_index(graph: &DirGraph, node_type: &str, property: &str) -> Option<usize> {
let store = graph.text_indexes.get(&index_key(node_type, property))?;
Some(store.refresh(graph, node_type))
}
pub fn drop_text_index(graph: &mut DirGraph, node_type: &str, property: &str) -> bool {
let removed = graph
.text_indexes
.remove(&index_key(node_type, property))
.is_some();
if removed {
graph.bump_version();
}
removed
}
pub fn text_index_store<'a>(
graph: &'a DirGraph,
node_type: &str,
property: &str,
) -> Option<&'a TextIndexStore> {
graph
.text_indexes
.iter()
.find(|((indexed_type, indexed_property), _)| {
indexed_type == node_type && indexed_property == property
})
.map(|(_, store)| store)
}
pub fn has_text_index(graph: &DirGraph, node_type: &str, property: &str) -> bool {
graph
.text_indexes
.contains_key(&index_key(node_type, property))
}
pub fn list_text_indexes(graph: &DirGraph) -> Vec<(&str, &str, &TextIndexStore)> {
let mut out: Vec<(&str, &str, &TextIndexStore)> = graph
.text_indexes
.iter()
.map(|((node_type, property), store)| (node_type.as_str(), property.as_str(), store))
.collect();
out.sort_unstable_by_key(|(node_type, property, _)| (*node_type, *property));
out
}
#[cfg(test)]
#[path = "text_indexes_tests.rs"]
mod tests;
#[cfg(test)]
#[path = "text_indexes_freshness_tests.rs"]
mod freshness_tests;