kglite 0.18.0

Pure-Rust embedded Cypher knowledge graph engine with in-memory, mmap, and disk storage, and agent-facing schema introspection
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
//! Relationship BM25 text indexes — the node lane's twin, keyed by edge slot.
//!
//! A child of [`crate::graph::text_indexes`] so it reuses [`TextIndexStore`]
//! whole: the same inverted index, the same generation counter, the same
//! [`IndexFreshness`] tracker. What differs is only what a slot *is* and how a
//! document is read, so those are the only things this module adds:
//!
//! - **Slot = `EdgeIndex::index()`.** The same identity convention as nodes,
//!   and the same reason: no second mapping to fall out of step. Freshness
//!   runs over the graph's *edge* bound.
//! - **Document = the relationship's own property**, read by interned key with
//!   no alias resolution (relationships have no id/title alias). A string, or a
//!   list of strings/nulls joined by the shared node rule.
//! - **Deletion prunes at the one edge-removal choke point**
//!   (`edge_embeddings::remove_edge_with_embeddings`), and journals
//!   `UndoEntry::EdgeTextDocPruned` so a rolled-back delete re-marks the slot,
//!   exactly as `prune_doomed_text_docs` does for nodes.
//! - **Lifecycle runs inside Cypher** (`db.relationship_text_index.*`), so unlike the
//!   node lane a build or drop can be followed by a failing clause:
//!   both journal `UndoEntry::EdgeTextIndexReplaced`, moving (not cloning) the
//!   prior store into the journal.
//!
//! Memory and mapped only; disk refuses with the node lane's reason. Not
//! WAL-recorded, like the node text index: on a durable graph an index built
//! after the last checkpoint is absent after reopen until rebuilt.

use std::borrow::Cow;
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
use std::sync::RwLock;

use petgraph::graph::EdgeIndex;

use super::{
    index_key, join_text_list, rebuild_beats_folding, TextIndexRead, TextIndexReport,
    TextIndexStore, BATCH_MIN_CHANGES,
};
use crate::datatypes::values::Value;
use crate::graph::algorithms::text_index::bm25::PreparedQuery;
use crate::graph::algorithms::text_index::TextIndex;
use crate::graph::dir_graph::DirGraph;
use crate::graph::index_freshness::IndexFreshness;
use crate::graph::schema::{EdgeData, InternedKey};
use crate::graph::storage::GraphRead;

#[inline]
fn edge_slot(edge: EdgeIndex) -> u32 {
    edge.index() as u32
}

/// The graph's edge-slot bound, as the document slot space sees it.
#[inline]
fn edge_bound(graph: &DirGraph) -> u32 {
    GraphRead::edge_bound(&graph.graph) as u32
}

fn edge_document(edge: &EdgeData, field: InternedKey) -> Option<Cow<'_, str>> {
    let value = edge
        .properties
        .iter()
        .find(|(key, _)| *key == field)
        .map(|(_, value)| value)?;
    match value {
        Value::String(text) => Some(Cow::Borrowed(text.as_str())),
        Value::List(items) => join_text_list(items).map(Cow::Owned),
        _ => None,
    }
}

/// The document `slot` holds for an index over `(type_key, field)`: `None`
/// when the slot is vacant, holds another type, or carries no text.
fn slot_document(
    graph: &DirGraph,
    slot: u32,
    type_key: InternedKey,
    field: InternedKey,
) -> Option<Cow<'_, str>> {
    let edge = graph.graph.edge_weight(EdgeIndex::new(slot as usize))?;
    if edge.connection_type != type_key {
        return None;
    }
    edge_document(edge, field)
}

/// Build a fresh index over every relationship of `type_key`. Returns the
/// index, how many relationships of the type exist, and how many yielded no
/// document.
fn build_over_type(
    graph: &DirGraph,
    type_key: InternedKey,
    field: InternedKey,
) -> (TextIndex, usize, usize) {
    let mut members = 0usize;
    let mut skipped = 0usize;
    let index = TextIndex::build(graph.graph.edge_indices().filter_map(|edge| {
        let data = graph.graph.edge_weight(edge)?;
        if data.connection_type != type_key {
            return None;
        }
        members += 1;
        match edge_document(data, field) {
            Some(text) => Some((edge_slot(edge), text)),
            None => {
                skipped += 1;
                None
            }
        }
    }));
    (index, members, skipped)
}

impl TextIndexRead<'_> {
    /// BM25 score of one relationship, or `None` when it has no document.
    pub fn score_relationship(&self, edge: EdgeIndex, query: &PreparedQuery) -> Option<f64> {
        let slot = edge_slot(edge);
        self.0.contains_doc(slot).then(|| self.0.score(slot, query))
    }
}

impl TextIndexStore {
    /// Documents the next [`Self::refresh_relationships`] would re-read — the edge
    /// twin of [`Self::delta_size`].
    pub fn relationship_delta_size(&self, graph: &DirGraph) -> usize {
        self.freshness.delta_size(edge_bound(graph))
    }

    /// Whether the graph's relationships have moved past what this index
    /// covers.
    pub fn relationship_is_stale(&self, graph: &DirGraph) -> bool {
        self.freshness.is_stale(edge_bound(graph))
    }

    /// Whether the outstanding relationship delta is within the inline-refresh
    /// ceiling.
    pub fn relationship_can_auto_refresh(&self, graph: &DirGraph) -> bool {
        self.freshness.within_limit(edge_bound(graph))
    }

    /// Drop a relationship's document. See [`Self::remove_node`] for why every
    /// deletion must reach this.
    pub(crate) fn remove_edge(&mut self, edge: EdgeIndex) -> bool {
        self.index
            .get_mut()
            .unwrap_or_else(|e| e.into_inner())
            .remove_doc(edge_slot(edge))
    }

    /// Mark a relationship slot for re-reading — the undo of a pruning delete.
    pub(crate) fn note_edge_slot_changed(&self, edge: EdgeIndex) {
        self.freshness.note_changed(edge_slot(edge));
    }

    /// Fold every outstanding relationship change into the index. The edge
    /// twin of [`Self::refresh`]: the same lock order, the same fold / batch /
    /// rebuild arms and crossover, reading each slot's relationship instead of
    /// its node.
    pub fn refresh_relationships(&self, graph: &DirGraph, rel_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(edge_bound(graph)) else {
            return 0;
        };
        let field = InternedKey::from_str(&self.resolved_field);
        let type_key = InternedKey::from_str(rel_type);
        let changes = delta
            .slots()
            .filter(|slot| {
                graph
                    .graph
                    .edge_weight(EdgeIndex::new(*slot as usize))
                    .is_some_and(|edge| edge.connection_type == type_key)
                    || index.contains_doc(*slot)
            })
            .count();
        let seen = if rebuild_beats_folding(changes, index.total_docs()) {
            let (rebuilt, members, skipped) = build_over_type(graph, type_key, field);
            *index = rebuilt;
            self.skipped.store(skipped, Ordering::Relaxed);
            members
        } else if changes < BATCH_MIN_CHANGES {
            let mut seen = 0usize;
            for slot in delta.slots() {
                seen += 1;
                match slot_document(graph, slot, type_key, field) {
                    Some(text) => index.add_doc(slot, text.as_ref()),
                    None => {
                        index.remove_doc(slot);
                    }
                }
            }
            seen
        } else {
            index.replace_batch(
                delta
                    .slots()
                    .map(|slot| (slot, slot_document(graph, slot, type_key, field))),
            )
        };
        // Bumped for every claimed delta; see `refresh` for why.
        self.generation.fetch_add(1, Ordering::Release);
        debug_assert!(
            index.validate().is_ok(),
            "a refreshed relationship text index must satisfy its own invariants: {:?}",
            index.validate()
        );
        seen
    }
}

fn store_of(
    index: TextIndex,
    freshness: IndexFreshness,
    property: &str,
    skipped: usize,
) -> TextIndexStore {
    TextIndexStore {
        index: RwLock::new(index),
        generation: AtomicU64::new(0),
        freshness,
        resolved_field: property.to_string(),
        skipped: AtomicUsize::new(skipped),
    }
}

/// Install `store` under `key` (or remove the key when `None`), journalling
/// the store it displaces so a failed statement puts it back.
fn replace_store(graph: &mut DirGraph, key: (String, String), store: Option<TextIndexStore>) {
    let prior = match store {
        Some(store) => graph.edge_text_indexes.insert(key.clone(), store),
        None => graph.edge_text_indexes.remove(&key),
    };
    if let Some(journal) = graph.graph.undo_journal_mut() {
        journal.note_edge_text_index_replaced(key, prior);
    }
}

/// Build (or rebuild) a BM25 index over `property` for every relationship of
/// `rel_type`. The relationship twin of
/// [`build_text_index`](super::build_text_index): same document rule, same
/// "a type whose every member yields nothing is a misspelling" error, same
/// kept-unless-given `auto_refresh_limit`.
pub(crate) fn build_edge_text_index(
    graph: &mut DirGraph,
    rel_type: &str,
    property: &str,
    auto_refresh_limit: Option<usize>,
) -> Result<TextIndexReport, String> {
    if GraphRead::is_disk(&graph.graph) {
        return Err(format!(
            "db.relationship_text_index.build({{type: '{rel_type}', text_column: '{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_connection_type(rel_type) {
        return Err(format!(
            "Unknown relationship type '{rel_type}'. db.relationship_text_index.build indexes one \
             relationship type's property; CALL db.relationshipTypes() lists the types that exist."
        ));
    }
    let field = InternedKey::from_str(property);
    let type_key = InternedKey::from_str(rel_type);
    let (index, members, skipped) = build_over_type(graph, type_key, field);
    if index.total_docs() == 0 && members > 0 {
        return Err(format!(
            "No '{rel_type}' relationship carries text or a string/null list for '{property}' — \
             all {members} were absent or not text documents, so there is nothing to index. \
             Check the spelling, and note that numbers and lists containing non-text members \
             are not indexable."
        ));
    }
    let key = index_key(rel_type, property);
    let limit = auto_refresh_limit.or_else(|| {
        graph
            .edge_text_indexes
            .get(&key)
            .map(TextIndexStore::auto_refresh_limit)
    });
    let store = store_of(
        index,
        IndexFreshness::covering(edge_bound(graph), limit),
        property,
        skipped,
    );
    let report = TextIndexReport {
        indexed: store.documents(),
        skipped,
        terms: store.terms(),
    };
    replace_store(graph, key, Some(store));
    graph.bump_version();
    Ok(report)
}

/// Fold every outstanding change into the relationship index over
/// `(rel_type, property)`. `None` when no such index exists.
pub(crate) fn refresh_edge_text_index(
    graph: &DirGraph,
    rel_type: &str,
    property: &str,
) -> Option<usize> {
    let store = edge_text_index_store(graph, rel_type, property)?;
    Some(store.refresh_relationships(graph, rel_type))
}

/// Drop the relationship text index over `(rel_type, property)`. Returns
/// whether one existed.
pub(crate) fn drop_edge_text_index(graph: &mut DirGraph, rel_type: &str, property: &str) -> bool {
    let key = index_key(rel_type, property);
    if !graph.edge_text_indexes.contains_key(&key) {
        return false;
    }
    replace_store(graph, key, None);
    graph.bump_version();
    true
}

/// The relationship text index over `(rel_type, property)`, if built. Scans
/// rather than hashing, for the reason
/// [`text_index_store`](super::text_index_store) gives.
pub(crate) fn edge_text_index_store<'a>(
    graph: &'a DirGraph,
    rel_type: &str,
    property: &str,
) -> Option<&'a TextIndexStore> {
    graph
        .edge_text_indexes
        .iter()
        .find(|((indexed_type, indexed_property), _)| {
            indexed_type == rel_type && indexed_property == property
        })
        .map(|(_, store)| store)
}

/// Every relationship text index, sorted by `(rel_type, property)`.
pub(crate) fn list_edge_text_indexes(graph: &DirGraph) -> Vec<(&str, &str, &TextIndexStore)> {
    let mut out: Vec<(&str, &str, &TextIndexStore)> = graph
        .edge_text_indexes
        .iter()
        .map(|((rel_type, property), store)| (rel_type.as_str(), property.as_str(), store))
        .collect();
    out.sort_unstable_by_key(|(rel_type, property, _)| (*rel_type, *property));
    out
}

/// Install an index restored from a `.kgl` section. Persistence-only; the
/// decoder validates the payload first.
pub(crate) fn attach_persisted_edge_text_index(
    graph: &mut DirGraph,
    rel_type: &str,
    property: &str,
    index: TextIndex,
    freshness: IndexFreshness,
    skipped: usize,
) {
    graph.edge_text_indexes.insert(
        index_key(rel_type, property),
        store_of(index, freshness, property, skipped),
    );
}

/// A relationship of `rel_type` was created at `edge` — the recycled-slot
/// check. Reached only through `index_freshness::write_hooks`, past its gate.
pub(crate) fn note_edge_created(graph: &DirGraph, edge: EdgeIndex, rel_type: InternedKey) {
    let slot = edge_slot(edge);
    for ((indexed_type, _), store) in &graph.edge_text_indexes {
        store
            .freshness
            .note_created(slot, InternedKey::from_str(indexed_type) == rel_type);
    }
}

/// A property of the relationship at `edge` was written. `field: None` is a
/// caller that replaced several properties at once.
pub(crate) fn note_edge_property_written(
    graph: &DirGraph,
    edge: EdgeIndex,
    rel_type: InternedKey,
    field: Option<InternedKey>,
) {
    let slot = edge_slot(edge);
    for ((indexed_type, property), store) in &graph.edge_text_indexes {
        if InternedKey::from_str(indexed_type) != rel_type {
            continue;
        }
        if field.is_none_or(|written| written == InternedKey::from_str(property)) {
            store.freshness.note_changed(slot);
        }
    }
}

/// Prune `edge`'s documents before its slot is freed, journalling each prune
/// so a rollback re-marks the slot (the relationship twin of
/// `delete_state::prune_doomed_text_docs`).
pub(crate) fn prune_edge_text_docs(graph: &mut DirGraph, edge: EdgeIndex) {
    if graph.edge_text_indexes.is_empty() {
        return;
    }
    let pruned: Vec<(String, String)> = graph
        .edge_text_indexes
        .iter_mut()
        .filter_map(|(key, store)| store.remove_edge(edge).then(|| key.clone()))
        .collect();
    let Some(journal) = graph.graph.undo_journal_mut() else {
        return;
    };
    for store_key in pruned {
        journal.note_edge_text_doc_pruned(store_key, edge.index());
    }
}

#[cfg(test)]
#[path = "edge_text_indexes_tests.rs"]
mod tests;