kglite 0.17.4

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
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
//! Serde types for the blueprint JSON schema.
//!
//! See docs/python/guides/blueprints.md for the user-facing spec. These structs
//! are lenient: missing fields default to empty where sensible, matching the
//! behaviour of the old Python loader, and an unrecognised field never fails
//! the parse — blueprints in the wild carry stray keys and must keep building.
//!
//! Leniency is not silence, though. Each spec that a user hand-writes captures
//! its unrecognised keys in an `extra` map, and
//! [`super::validation::unknown_key_warnings`] turns them into build-report
//! warnings with a near-miss hint. A dropped `"lables"` otherwise costs every
//! label it carried and reports success. The `ACCEPTED_*_KEYS` lists below
//! feed only that hint — `extra` already knows the key is unrecognised — and
//! `accepted_key_lists_match_the_structs` keeps them in step with the fields.

use indexmap::IndexMap;
use serde::Deserialize;
use std::path::PathBuf;

#[derive(Debug, Deserialize, Default)]
pub struct Blueprint {
    #[serde(default)]
    pub settings: Settings,
    /// Inputs declared once by name, referenced from a spec's `file`.
    /// Declaration order is the order the "declared inputs" diagnostics list
    /// them in, so the author reads back what they wrote.
    #[serde(default)]
    pub files: IndexMap<String, FileSpec>,
    /// Node specs, in blueprint-JSON order. Iteration order matters because
    /// the FK-edge phase writes parallel edges on the *first* call per
    /// connection type (then dedupes on subsequent calls). Alphabetical
    /// order would produce different edge counts than the Python loader.
    #[serde(default)]
    pub nodes: IndexMap<String, NodeSpec>,
    /// Optional ordered pipeline of post-load compute primitives.
    /// 0.9.47+: each `ComputeOp` runs after the 5 existing load phases.
    /// Vec order = execution order; later ops can reference types
    /// produced by earlier ops.
    #[serde(default)]
    pub compute: Vec<ComputeOp>,
    /// Path to an ontology declaration document (JSON), resolved relative
    /// to the blueprint file (config sits with config; CSVs resolve against
    /// `input_root`). Installed and audited as a final build phase: warn-
    /// level violations land in the build report, error-level violations
    /// fail the build after the full report — no output file is written.
    #[serde(default)]
    pub ontology: Option<String>,
    /// Keys at the top level of the blueprint that this struct does not read.
    #[serde(flatten)]
    pub extra: IndexMap<String, serde_json::Value>,
}

#[derive(Debug, Deserialize, Default)]
pub struct Settings {
    #[serde(default, alias = "root")]
    pub input_root: Option<String>,
    #[serde(default)]
    pub output_path: Option<String>,
    #[serde(default, alias = "output")]
    pub output_file: Option<String>,
    /// Drop unpromoted provisional stub nodes (edges to a node that no
    /// CSV provided) at the end of the build. Default `false` — stubs
    /// are kept so no edge is lost; opt in to discard dangling refs.
    #[serde(default)]
    pub auto_purge: bool,
    /// Keys under `settings` that this struct does not read.
    #[serde(flatten)]
    pub extra: IndexMap<String, serde_json::Value>,
}

/// One declared input: where its rows come from and how to read them.
///
/// Which keys an entry may carry depends on its `format` — a delimited file's
/// `delimiter` is meaningless on a spreadsheet — so the accepted list is per
/// format and lives with the reader that owns it
/// ([`super::input::FormatSpec`]), not in the `ACCEPTED_*` lists below, which
/// are per struct.
#[derive(Debug, Deserialize, Default, Clone)]
pub struct FileSpec {
    /// The file this input reads, resolved against `settings.root`. Optional
    /// in the type because a later format supplies its rows another way; a
    /// `csv` entry without one is a validation error.
    #[serde(default)]
    pub path: Option<String>,
    #[serde(default = "default_input_format")]
    pub format: String,
    /// Keys on this `files` entry that this struct does not read.
    #[serde(flatten)]
    pub extra: IndexMap<String, serde_json::Value>,
}

fn default_input_format() -> String {
    "csv".to_string()
}

/// Keys the blueprint's top level reads. Hint source only — see the module
/// header.
pub const ACCEPTED_BLUEPRINT_KEYS: &[&str] = &["settings", "files", "nodes", "compute", "ontology"];

/// Keys `settings` reads, including the `root` / `output` aliases.
pub const ACCEPTED_SETTINGS_KEYS: &[&str] = &[
    "input_root",
    "root",
    "output_path",
    "output_file",
    "output",
    "auto_purge",
];

/// Keys a node spec (and a `sub_nodes` entry) reads.
pub const ACCEPTED_NODE_KEYS: &[&str] = &[
    "csv",
    "file",
    "pk",
    "title",
    "parent",
    "parent_fk",
    "properties",
    "labels",
    "skipped",
    "filter",
    "connections",
    "sub_nodes",
    "timeseries",
];

/// Keys an `fk_edges` entry reads.
pub const ACCEPTED_FK_EDGE_KEYS: &[&str] =
    &["target", "fk", "properties", "property_types", "rename"];

/// Keys a `junction_edges` entry reads.
pub const ACCEPTED_JUNCTION_EDGE_KEYS: &[&str] = &[
    "csv",
    "file",
    "source_fk",
    "target",
    "target_type_column",
    "target_fk",
    "properties",
    "property_types",
    "rename",
];

/// `"Disease"` or `["Disease", "Phenotype"]` — both land as a list, so the
/// loader has one shape to read.
fn string_or_string_list<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
where
    D: serde::Deserializer<'de>,
{
    #[derive(Deserialize)]
    #[serde(untagged)]
    enum OneOrMany {
        One(String),
        Many(Vec<String>),
    }
    Ok(match OneOrMany::deserialize(deserializer)? {
        OneOrMany::One(s) => vec![s],
        OneOrMany::Many(v) => v,
    })
}

impl Settings {
    /// Compute the absolute output path from `output_path` + `output_file`,
    /// falling back to `input_root / output_file`. Returns None if no output
    /// was configured.
    pub fn resolved_output(&self, input_root: &std::path::Path) -> Option<PathBuf> {
        let output_file = self.output_file.as_ref()?;
        let base = match &self.output_path {
            Some(p) => std::path::PathBuf::from(p),
            None => input_root.to_path_buf(),
        };
        Some(base.join(output_file))
    }
}

#[derive(Debug, Deserialize, Default, Clone)]
pub struct NodeSpec {
    /// Shorthand for a `files` entry named by the path itself. Mutually
    /// exclusive with `file`; see [`NodeSpec::input_name`].
    #[serde(default)]
    pub csv: Option<String>,
    /// Name of the `files` entry this spec's rows come from.
    #[serde(default)]
    pub file: Option<String>,
    #[serde(default)]
    pub pk: Option<String>,
    #[serde(default)]
    pub title: Option<String>,
    #[serde(default)]
    pub parent: Option<String>,
    #[serde(default)]
    pub parent_fk: Option<String>,
    #[serde(default)]
    pub properties: IndexMap<String, String>,
    /// Secondary labels stamped on every node of this type. The type name is
    /// the primary label and is never restamped, so listing it here is a
    /// no-op rather than a duplicate.
    #[serde(default)]
    pub labels: Vec<String>,
    #[serde(default)]
    pub skipped: Vec<String>,
    #[serde(default)]
    pub filter: IndexMap<String, serde_json::Value>,
    #[serde(default)]
    pub connections: Connections,
    #[serde(default)]
    pub sub_nodes: IndexMap<String, NodeSpec>,
    #[serde(default)]
    pub timeseries: Option<TimeseriesSpec>,
    /// Keys on this node spec that this struct does not read.
    #[serde(flatten)]
    pub extra: IndexMap<String, serde_json::Value>,
}

#[derive(Debug, Deserialize, Default, Clone)]
pub struct Connections {
    #[serde(default)]
    pub fk_edges: IndexMap<String, FkEdge>,
    #[serde(default)]
    pub junction_edges: IndexMap<String, JunctionEdge>,
}

#[derive(Debug, Deserialize, Clone)]
pub struct FkEdge {
    pub target: String,
    pub fk: String,
    /// Columns of the *source* node's CSV to attach to the edge, taken from
    /// the same row the FK value came from. Listing a column here does not
    /// keep it off the node — `skipped` is what does that.
    #[serde(default)]
    pub properties: Vec<String>,
    #[serde(default)]
    pub property_types: IndexMap<String, String>,
    /// CSV column → edge property name, with the same rules as
    /// [`JunctionEdge::rename`].
    #[serde(default)]
    pub rename: IndexMap<String, String>,
    /// Keys on this fk_edge that this struct does not read.
    #[serde(flatten)]
    pub extra: IndexMap<String, serde_json::Value>,
}

#[derive(Debug, Deserialize, Clone)]
pub struct JunctionEdge {
    /// Shorthand for a `files` entry named by the path itself. Mutually
    /// exclusive with `file`, and exactly one of the two must be set — a
    /// junction table is not synthesisable the way a manual node type is.
    #[serde(default)]
    pub csv: Option<String>,
    /// Name of the `files` entry this edge's rows come from.
    #[serde(default)]
    pub file: Option<String>,
    pub source_fk: String,
    /// The node type(s) this relationship points at. A JSON string is the
    /// one-type form; a list is the union form, for a relationship whose
    /// range is an abstract class — without it such a relation needs one
    /// relationship name per concrete type, which no query and no ontology
    /// `range` declaration can put back together.
    #[serde(deserialize_with = "string_or_string_list")]
    pub target: Vec<String>,
    pub target_fk: String,
    /// CSV column naming each row's target type, for the union form. Its
    /// values must be among `target`; a row naming anything else is skipped
    /// with a build warning rather than routed by guess. Without it, a union
    /// `target` is resolved by probing the declared types for the row's
    /// target id. Routing only — the column becomes an edge property just as
    /// any other does, by being listed in `properties`.
    #[serde(default)]
    pub target_type_column: Option<String>,
    #[serde(default)]
    pub properties: Vec<String>,
    #[serde(default)]
    pub property_types: IndexMap<String, String>,
    /// CSV column → edge property name. Keys must be listed in `properties`
    /// and refer to CSV columns (`property_types` stays keyed by the CSV
    /// name); fk columns are not renamable. This is the rename facility
    /// `property_types` was never — see `validation::
    /// unknown_property_type_warnings`.
    #[serde(default)]
    pub rename: IndexMap<String, String>,
    /// Keys on this junction_edge that this struct does not read.
    #[serde(flatten)]
    pub extra: IndexMap<String, serde_json::Value>,
}

impl NodeSpec {
    /// The registry name this spec's rows are read under: the `files` entry
    /// it names, or the `csv` shorthand, which is registered under the path
    /// string itself. `None` is a manual node type — one with no input at all.
    ///
    /// Validation has already refused a spec that sets both, so the order
    /// here only decides which of two rejected spellings wins.
    pub fn input_name(&self) -> Option<&str> {
        self.file.as_deref().or(self.csv.as_deref())
    }
}

impl JunctionEdge {
    /// The registry name this edge's rows are read under. See
    /// [`NodeSpec::input_name`]; validation refuses `None` here.
    pub fn input_name(&self) -> Option<&str> {
        self.file.as_deref().or(self.csv.as_deref())
    }
}

impl FkEdge {
    /// A `target` + `fk` edge with nothing else declared — the shape the
    /// loader synthesises for a node spec's implicit `parent` edge.
    pub fn plain(target: String, fk: String) -> Self {
        FkEdge {
            target,
            fk,
            properties: vec![],
            property_types: IndexMap::new(),
            rename: IndexMap::new(),
            extra: IndexMap::new(),
        }
    }
}

impl JunctionEdge {
    /// Property-less edge over a compute-pipeline output CSV.
    pub fn computed(csv: String, source_fk: String, target: String, target_fk: String) -> Self {
        JunctionEdge {
            csv: Some(csv),
            file: None,
            source_fk,
            target: vec![target],
            target_fk,
            target_type_column: None,
            properties: vec![],
            property_types: IndexMap::new(),
            rename: IndexMap::new(),
            extra: IndexMap::new(),
        }
    }
}

#[derive(Debug, Deserialize, Clone)]
#[serde(untagged)]
pub enum TimeKey {
    Single(String),
    Composite(IndexMap<String, String>),
}

#[derive(Debug, Deserialize, Clone)]
pub struct TimeseriesSpec {
    pub time_key: TimeKey,
    #[serde(default)]
    pub channels: IndexMap<String, String>,
    #[serde(default)]
    pub resolution: Option<String>,
    #[serde(default)]
    pub units: IndexMap<String, String>,
}

// ─── compute pipeline (0.9.47) ────────────────────────────────────────────

/// One operation in the blueprint's `compute:` pipeline. Each variant
/// is a named primitive with a fixed shape — no free-form DSL, no
/// user-defined functions, no graph traversal in expressions.
/// Cypher handles the post-build dynamic side; this layer handles
/// declarative graph shaping.
///
/// K2 ships the type + serde parsing + validation; per-variant
/// fields become "read" as K3-K6 wire each primitive's executor.
#[allow(dead_code)]
#[derive(Debug, Deserialize, Clone)]
#[serde(tag = "op", rename_all = "lowercase")]
pub enum ComputeOp {
    /// Add or overwrite properties on an existing node type via
    /// row-level expressions. Schema gains the new properties.
    Derive {
        from: String,
        set: IndexMap<String, String>,
    },
    /// Copy nodes matching a predicate from one type to another (or
    /// drop non-matching rows in place if `into` is omitted). The
    /// predicate is a row-level boolean expression.
    Filter {
        from: String,
        #[serde(rename = "where")]
        where_expr: String,
        #[serde(default)]
        into: Option<String>,
    },
    /// Synthesise a doubly-linked-list edge between consecutive nodes
    /// of a type, grouped by composite key and ordered by a property.
    /// Used for temporal walks (NEXT_TX per insider, NEXT_QUARTER
    /// per fund/security HOLDS series).
    Chain {
        from: String,
        group_by: Vec<String>,
        order_by: String,
        edge: String,
    },
    /// Synthesise `:Date` nodes for the closed range `[start, end]`
    /// plus chain + hierarchy edges, then link source-type date
    /// columns to the matching Date node.
    Calendar {
        #[serde(rename = "type", default = "default_calendar_type")]
        node_type: String,
        start: String,
        end: String,
        #[serde(default = "default_next_day_edge")]
        next_edge: String,
        #[serde(default)]
        in_month_edge: Option<String>,
        #[serde(default)]
        in_quarter_edge: Option<String>,
        #[serde(default)]
        in_year_edge: Option<String>,
        #[serde(default)]
        links: Vec<CalendarLink>,
    },
    /// Group source nodes by a composite key, evaluate per-group
    /// aggregate expressions, emit one summary node per group plus
    /// optional FK edges to the group-key target types.
    Aggregate {
        from: String,
        group_by: Vec<String>,
        into: String,
        agg: IndexMap<String, String>,
        #[serde(default)]
        edges: Vec<AggregateEdge>,
    },
}

#[derive(Debug, Deserialize, Clone)]
pub struct CalendarLink {
    pub from: String,
    pub date_col: String,
    pub edge: String,
}

#[derive(Debug, Deserialize, Clone)]
pub struct AggregateEdge {
    pub to: String,
    pub fk: String,
    pub edge: String,
}

fn default_calendar_type() -> String {
    "Date".to_string()
}
fn default_next_day_edge() -> String {
    "NEXT_DAY".to_string()
}

/// Load a blueprint from a file path.
pub fn load_blueprint_file(path: &std::path::Path) -> Result<Blueprint, String> {
    let bytes = std::fs::read(path)
        .map_err(|e| format!("Blueprint file not found: {}: {}", path.display(), e))?;
    let parsed: serde_json::Value =
        serde_json::from_slice(&bytes).map_err(|e| format!("Invalid blueprint JSON: {e}"))?;
    if contains_number_outside_f64(&parsed) {
        return Err("Invalid blueprint JSON: number out of range".to_string());
    }
    serde_json::from_slice(&bytes).map_err(|e| format!("Invalid blueprint JSON: {e}"))
}

fn contains_number_outside_f64(value: &serde_json::Value) -> bool {
    match value {
        serde_json::Value::Number(number) => number.as_f64().is_none(),
        serde_json::Value::Array(items) => items.iter().any(contains_number_outside_f64),
        serde_json::Value::Object(map) => map.values().any(contains_number_outside_f64),
        _ => false,
    }
}