kglite 0.16.7

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
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
//! The per-conflict-mode matrix for the bulk relationship-constraint gate.
//!
//! Every mode merges a row into an existing edge differently, so "does this
//! row violate the constraint?" has a different answer in each — and two of
//! them are counter-intuitive: `Preserve` must **accept** a bad row value
//! (it is discarded), and `Sum` must **refuse** a pair of values that are each
//! individually fine (their sum is not).

use crate::datatypes::{DataFrame, Value};
use crate::graph::algorithms::Interrupt;
use crate::graph::dir_graph::DirGraph;
use crate::graph::mutation::maintain::{add_connections, add_nodes};
use crate::graph::property_types::DeclaredType;
use crate::graph::storage::interner::InternedKey;
use crate::graph::storage::GraphRead;

fn nodes(graph: &mut DirGraph, node_type: &str, ids: Vec<Value>) {
    let frame = DataFrame::from_cypher_rows(
        vec!["id".to_string()],
        ids.into_iter().map(|id| vec![id]).collect(),
    )
    .unwrap();
    add_nodes(
        graph,
        frame,
        node_type.to_string(),
        "id".to_string(),
        Some("id".to_string()),
        None,
    )
    .unwrap();
}

/// A frame of `(source, target, property)` rows. `None` writes a null cell,
/// which the loader treats as an absent property — the same collapse the node
/// gate makes.
fn edges_df(column: &str, rows: &[(i64, &str, Option<Value>)]) -> DataFrame {
    DataFrame::from_cypher_rows(
        vec!["s".to_string(), "t".to_string(), column.to_string()],
        rows.iter()
            .map(|(source, target, value)| {
                vec![
                    Value::Int64(*source),
                    Value::String((*target).into()),
                    value.clone().unwrap_or(Value::Null),
                ]
            })
            .collect(),
    )
    .unwrap()
}

/// A frame carrying no property column at all — the partial-update shape.
fn bare_df(rows: &[(i64, &str)]) -> DataFrame {
    DataFrame::from_cypher_rows(
        vec!["s".to_string(), "t".to_string()],
        rows.iter()
            .map(|(source, target)| vec![Value::Int64(*source), Value::String((*target).into())])
            .collect(),
    )
    .unwrap()
}

fn mentions(graph: &mut DirGraph, frame: DataFrame, mode: Option<&str>) -> Result<(), String> {
    add_connections(
        graph,
        frame,
        "MENTIONS".to_string(),
        "Doc".to_string(),
        "s".to_string(),
        "Entity".to_string(),
        "t".to_string(),
        None,
        None,
        mode.map(str::to_string),
    )
    .map(|_| ())
}

/// Two `Doc`s, three `Entity`s, and one stored `MENTIONS` edge `(1, A)`
/// carrying `weight = 10` and `since = 2020` — installed *before* the
/// constraint, so the declaration scan vouches for it.
fn seeded_graph(constrain: impl Fn(&mut DirGraph)) -> DirGraph {
    let mut graph = DirGraph::new();
    nodes(&mut graph, "Doc", vec![Value::Int64(1), Value::Int64(2)]);
    nodes(
        &mut graph,
        "Entity",
        vec![
            Value::String("A".into()),
            Value::String("B".into()),
            Value::String("C".into()),
        ],
    );
    let seed = DataFrame::from_cypher_rows(
        vec![
            "s".to_string(),
            "t".to_string(),
            "weight".to_string(),
            "since".to_string(),
        ],
        vec![vec![
            Value::Int64(1),
            Value::String("A".into()),
            Value::Int64(10),
            Value::Int64(2020),
        ]],
    )
    .unwrap();
    mentions(&mut graph, seed, None).expect("seed edge");
    constrain(&mut graph);
    graph
}

fn typed_graph() -> DirGraph {
    seeded_graph(|graph| {
        graph
            .create_rel_property_type_constraint(
                "MENTIONS",
                "weight",
                DeclaredType::Integer,
                &Interrupt::default(),
            )
            .expect("declaration must install over clean data");
    })
}

fn required_graph() -> DirGraph {
    seeded_graph(|graph| {
        graph
            .create_rel_not_null_constraint("MENTIONS", "since", &Interrupt::default())
            .expect("declaration must install over clean data");
    })
}

fn edge_count(graph: &DirGraph, source: i64) -> usize {
    let idx = graph
        .lookup_by_id_readonly("Doc", &Value::Int64(source))
        .unwrap();
    let key = InternedKey::from_str("MENTIONS");
    graph
        .graph
        .edges_directed(idx, petgraph::Direction::Outgoing)
        .filter(|edge| edge.connection_type() == key)
        .count()
}

fn stored_weight(graph: &DirGraph, source: i64) -> Option<Value> {
    let idx = graph
        .lookup_by_id_readonly("Doc", &Value::Int64(source))
        .unwrap();
    let key = InternedKey::from_str("MENTIONS");
    let weight = InternedKey::from_str("weight");
    graph
        .graph
        .edges_directed(idx, petgraph::Direction::Outgoing)
        .find(|edge| edge.connection_type() == key)
        .and_then(|edge| {
            edge.weight()
                .properties
                .iter()
                .find(|(k, _)| *k == weight)
                .map(|(_, v)| v.clone())
        })
}

// ── property type × conflict mode ────────────────────────────────────

/// Update: the row's value wins, so a bad one lands — and is refused.
#[test]
fn update_refuses_a_bad_row_value_over_a_good_stored_one() {
    let mut graph = typed_graph();
    let error = mentions(
        &mut graph,
        edges_df("weight", &[(1, "A", Some(Value::String("heavy".into())))]),
        Some("update"),
    )
    .expect_err("the row's value wins under update, so it must be judged");
    assert!(error.contains("STRING"), "{error}");
    assert!(error.contains("MENTIONS.weight"), "{error}");
    assert_eq!(stored_weight(&graph, 1), Some(Value::Int64(10)));
}

/// Preserve: the stored value wins, so the row's bad value is *discarded*.
/// Refusing it would reject a write the engine never performs.
#[test]
fn preserve_accepts_a_bad_row_value_the_engine_will_discard() {
    let mut graph = typed_graph();
    mentions(
        &mut graph,
        edges_df("weight", &[(1, "A", Some(Value::String("heavy".into())))]),
        Some("preserve"),
    )
    .expect("preserve keeps the stored value, so the row cannot violate anything");
    assert_eq!(stored_weight(&graph, 1), Some(Value::Int64(10)));
}

/// Preserve on a pair that does *not* exist yet is a create, and then the row
/// is the whole state — the same value must now be refused.
#[test]
fn preserve_refuses_the_same_bad_value_when_the_pair_is_new() {
    let mut graph = typed_graph();
    let error = mentions(
        &mut graph,
        edges_df("weight", &[(1, "B", Some(Value::String("heavy".into())))]),
        Some("preserve"),
    )
    .expect_err("nothing is stored for (1, B), so the row is the whole edge");
    assert!(error.contains("STRING"), "{error}");
    assert_eq!(edge_count(&graph, 1), 1, "the refused frame wrote nothing");
}

/// Replace drops the stored properties and rebuilds from the row.
#[test]
fn replace_refuses_a_bad_row_value() {
    let mut graph = typed_graph();
    let error = mentions(
        &mut graph,
        edges_df("weight", &[(1, "A", Some(Value::String("heavy".into())))]),
        Some("replace"),
    )
    .expect_err("replace rebuilds the edge from the row alone");
    assert!(error.contains("STRING"), "{error}");
    assert_eq!(stored_weight(&graph, 1), Some(Value::Int64(10)));
}

/// Skip leaves an existing edge exactly as it was, so the row is judged
/// against nothing.
#[test]
fn skip_accepts_a_bad_row_value_for_an_existing_edge() {
    let mut graph = typed_graph();
    mentions(
        &mut graph,
        edges_df("weight", &[(1, "A", Some(Value::String("heavy".into())))]),
        Some("skip"),
    )
    .expect("skip touches nothing, so nothing can violate a constraint");
    assert_eq!(stored_weight(&graph, 1), Some(Value::Int64(10)));
}

/// The mode that produces a value neither side wrote. `10` and `1.5` each
/// satisfy nothing and everything on their own; their sum is a FLOAT, and an
/// INTEGER declaration has to catch it. A gate that judged the row in
/// isolation would pass this.
#[test]
fn sum_refuses_a_row_whose_addition_changes_the_type() {
    let mut graph = typed_graph();
    let error = mentions(
        &mut graph,
        edges_df("weight", &[(1, "A", Some(Value::Float64(1.5)))]),
        Some("sum"),
    )
    .expect_err("10 + 1.5 is a FLOAT, which the INTEGER declaration refuses");
    assert!(error.contains("FLOAT"), "{error}");
    assert_eq!(stored_weight(&graph, 1), Some(Value::Int64(10)));
}

/// The same mode must not refuse an addition that stays in type.
#[test]
fn sum_accepts_an_addition_that_stays_an_integer() {
    let mut graph = typed_graph();
    mentions(
        &mut graph,
        edges_df("weight", &[(1, "A", Some(Value::Int64(5)))]),
        Some("sum"),
    )
    .expect("10 + 5 is an INTEGER");
    assert_eq!(stored_weight(&graph, 1), Some(Value::Int64(15)));
}

// ── presence × conflict mode ─────────────────────────────────────────

/// The partial-update contract: a frame that does not carry the required
/// column leaves the stored value alone, so it must not be refused. This is
/// the assertion that stops the gate from being written as "every row must
/// supply every required property".
#[test]
fn update_accepts_a_frame_that_does_not_carry_the_required_column() {
    let mut graph = required_graph();
    mentions(&mut graph, bare_df(&[(1, "A")]), Some("update"))
        .expect("the stored `since` survives an update that never mentions it");
}

/// The same frame against a pair with no stored edge creates one, and a
/// created edge has to satisfy the requirement itself.
#[test]
fn update_refuses_a_new_pair_with_no_value_for_the_required_property() {
    let mut graph = required_graph();
    let error = mentions(&mut graph, bare_df(&[(1, "B")]), Some("update"))
        .expect_err("a created edge must satisfy the requirement");
    assert!(error.contains("'since'"), "{error}");
    assert!(error.contains("relationship"), "{error}");
    assert_eq!(edge_count(&graph, 1), 1, "the refused frame wrote nothing");
}

/// Replace drops the stored value, so the row has to carry the required one.
#[test]
fn replace_refuses_a_row_without_the_required_property() {
    let mut graph = required_graph();
    let error = mentions(&mut graph, bare_df(&[(1, "A")]), Some("replace"))
        .expect_err("replace rebuilds from the row, which has no `since`");
    assert!(error.contains("'since'"), "{error}");
}

/// Preserve and Skip both keep the stored value, so neither can lose it.
#[test]
fn preserve_and_skip_keep_the_required_value() {
    for mode in ["preserve", "skip"] {
        let mut graph = required_graph();
        mentions(&mut graph, bare_df(&[(1, "A")]), Some(mode))
            .unwrap_or_else(|e| panic!("{mode}: {e}"));
    }
}

/// Within-frame consolidation: two rows for the same *new* pair land on one
/// edge, so the second row merges into the first's result rather than into
/// nothing. Judging each row alone would refuse the second one.
#[test]
fn a_second_row_for_the_same_new_pair_merges_into_the_first() {
    let mut graph = required_graph();
    mentions(
        &mut graph,
        edges_df(
            "since",
            &[(1, "B", Some(Value::Int64(2021))), (1, "B", None)],
        ),
        Some("update"),
    )
    .expect("the first row supplies `since`; the second merges onto that edge");
    assert_eq!(edge_count(&graph, 1), 2);
}

// ── the frame contract ───────────────────────────────────────────────

/// A refused frame is refused whole: no edge, no title write, and no
/// connection-type metadata for a type the frame failed to load. The loaders
/// validate-then-write, and a per-row skip here would fork that contract.
#[test]
fn a_refused_frame_writes_nothing_at_all() {
    let mut graph = required_graph();
    let before: Vec<String> = {
        let mut types: Vec<String> = graph.connection_type_metadata.keys().cloned().collect();
        types.sort();
        types
    };
    let error = mentions(
        &mut graph,
        edges_df("weight", &[(1, "A", Some(Value::Int64(3))), (1, "C", None)]),
        Some("update"),
    )
    .expect_err("the second row creates an edge with no `since`");
    assert!(error.contains("'since'"), "{error}");
    assert_eq!(edge_count(&graph, 1), 1, "no edge from the good row either");
    let after: Vec<String> = {
        let mut types: Vec<String> = graph.connection_type_metadata.keys().cloned().collect();
        types.sort();
        types
    };
    assert_eq!(
        after, before,
        "a refused frame must not teach the schema anything"
    );
}

/// An unconstrained connection type pays the fast-out and nothing else: the
/// same frame that a constrained type refuses loads without complaint.
#[test]
fn an_unconstrained_connection_type_is_not_gated() {
    let mut graph = DirGraph::new();
    nodes(&mut graph, "Doc", vec![Value::Int64(1)]);
    nodes(&mut graph, "Entity", vec![Value::String("A".into())]);
    mentions(
        &mut graph,
        edges_df("weight", &[(1, "A", Some(Value::String("heavy".into())))]),
        Some("update"),
    )
    .expect("nothing is declared, so nothing is judged");
    assert_eq!(edge_count(&graph, 1), 1);
}

// ── the loader's two row-folding regimes ─────────────────────────────

/// **The regression this file exists to prevent.**
///
/// On an initial load of a new connection type the loader creates a
/// relationship *per row* — no lookup, no merge, no consolidation between two
/// rows naming the same pair. A gate that models rows as merging into one edge
/// per pair therefore judges a post-state the loader will never produce: it
/// sees `{since: 2020}` where the loader will store two relationships, the
/// second of them carrying no `since` at all.
///
/// That admits a stored relationship violating its own constraint, which
/// breaks the invariant `preserve` and `skip` lean on — that every stored edge
/// of a constrained type is already legal.
#[test]
fn an_initial_load_judges_each_row_as_its_own_relationship() {
    let mut graph = DirGraph::new();
    nodes(&mut graph, "Doc", vec![Value::Int64(1)]);
    nodes(&mut graph, "Entity", vec![Value::String("A".into())]);
    // Declared before any MENTIONS edge exists, so the type is absent from the
    // connection metadata and the load below takes the initial-load path.
    graph
        .create_rel_not_null_constraint("MENTIONS", "since", &Interrupt::default())
        .expect("an empty type is vacuously clean");

    let error = mentions(
        &mut graph,
        edges_df(
            "since",
            &[(1, "A", Some(Value::Int64(2020))), (1, "A", None)],
        ),
        Some("update"),
    )
    .expect_err("the second row becomes its own relationship, with no `since`");
    assert!(error.contains("'since'"), "{error}");
    assert_eq!(
        edge_count(&graph, 1),
        0,
        "the refused frame must store neither relationship"
    );
}

/// The same shape one load later. The type is now known, so the loader looks
/// edges up and the two rows consolidate onto one relationship — which the
/// first row made legal. Judging them independently here would refuse a write
/// the loader performs correctly.
#[test]
fn a_known_type_still_consolidates_two_rows_onto_one_relationship() {
    let mut graph = required_graph();
    mentions(
        &mut graph,
        edges_df(
            "since",
            &[(1, "B", Some(Value::Int64(2021))), (1, "B", None)],
        ),
        Some("update"),
    )
    .expect("the first row supplies `since`; the second merges onto that edge");
    assert_eq!(edge_count(&graph, 1), 2);
}