kglite 0.17.8

Pure-Rust embedded Cypher knowledge graph engine with in-memory, mmap, and disk storage, and agent-facing schema introspection
Documentation
//! Graph builder: turn parsed [`ConceptDoc`]s into a [`DirGraph`].
//!
//! Mirrors the code-graph loader pattern: build columnar [`DataFrame`]s and hand them to
//! the bulk `maintain::add_nodes` / `add_connections` mutators (interning, type
//! schema, id-index, and dedup come for free). Nodes are grouped by label; edges
//! by `(source_label, target_label, conn_type)` so each `add_connections` call
//! has correctly-typed endpoints. Dangling link targets vivify as `_provisional`
//! stub nodes (the mutator's built-in behaviour).
//!
//! Structured frontmatter values (`tags` lists, nested maps surfaced inside
//! lists) are JSON-encoded into String columns for OKF bundles — the same
//! convention code-graph builders use for `parameters`/`fields`. The vault
//! profile turns that off (`Profile::native_collections`) and stores them as
//! `Value::List` / `Value::Map` columns instead.
//!
//! This module holds the pipeline — [`build`] itself, the group map the
//! builders fill, and [`emit_groups`] that drains it. Each stage lives beside
//! its own concern: [`nodes`], [`folders`], [`hubs`], [`attachments`],
//! [`resolver`] and [`edges`].

mod attachments;
mod edges;
mod folders;
mod hubs;
mod nodes;
mod resolver;

use crate::datatypes::values::{DataFrame, Value};
use crate::graph::mutation::maintain;
use crate::graph::DirGraph;
use crate::okf::model::{BuildOptions, BuildReport, ConceptDoc};
use attachments::build_attachments;
use edges::build_edges;
use folders::build_folders;
use hubs::{build_aux_nodes, build_hubs};
use nodes::build_nodes;
use std::collections::{BTreeMap, BTreeSet, HashSet};
use std::path::Path;
use std::sync::Arc;

/// One connection row: the endpoints plus whatever properties the edge itself
/// carries (VAULT.md §5.4 `section`/`anchor`, §6's `alt`/`ordinal`). Structural
/// edges carry none, which keeps their frames two columns wide.
type EdgeRow = (String, String, Vec<(String, Value)>);
/// `(conn_type, source_label, target_label)` → the rows to emit for it. A
/// `BTreeMap` keyed with the connection type first, because [`emit_groups`]
/// needs every group of one type together and in a fixed order — see the
/// initial-load note there.
type EdgeGroups = BTreeMap<(String, String, String), Vec<EdgeRow>>;

/// A finished build: the graph, and what the builder saw producing it.
/// No `Debug` — `DirGraph` has none, and a graph is not a thing to format.
#[derive(Clone)]
pub struct BuildOutput {
    pub graph: Arc<DirGraph>,
    pub report: BuildReport,
}

/// Build a knowledge graph from an OKF bundle directory.
///
/// Under the `obsidian` dialect the vault's own `.kglite/vault.yaml` is read
/// first and overrides the dialect profile (VAULT.md §7), and its
/// `.kglite/skills/` + `.kglite/recipes/` are imported into the finished graph
/// (§8). A `vault.yaml` that does not parse fails the build rather than being
/// ignored — see [`crate::okf::vault_config`].
pub fn build(root: &Path, opts: &BuildOptions) -> Result<BuildOutput, String> {
    let mut config_warnings: Vec<String> = Vec::new();
    let (effective, config) = effective_options(root, opts, &mut config_warnings)?;
    let opts = &effective;

    let walked = super::walk::discover(root, opts)?;
    let (docs, findings) = super::parse_concepts_reported(&walked.concepts, opts);
    let mut report = BuildReport {
        files_scanned: walked.concepts.len(),
        concepts: docs.len(),
        errors: findings.errors,
        warnings: findings.warnings,
        ..BuildReport::default()
    };
    report.warnings.extend(config_warnings);
    let mut graph = DirGraph::new();
    if docs.is_empty() {
        // An empty vault still carries its skills and its declarations; the
        // config is what a rebuild re-applies, and reporting it only when a
        // note happened to parse would make the report depend on the content
        // it is describing.
        finish_vault(root, opts, config.as_ref(), &mut graph, &mut report);
        stamp_provenance(&mut graph, root, &walked, opts);
        return Ok(BuildOutput {
            graph: Arc::new(graph),
            report,
        });
    }
    let declared_types = config.as_ref().map(|c| &c.types);
    build_nodes(&mut graph, &docs, opts, declared_types, &mut report)?;
    build_aux_nodes(&mut graph, &docs, &mut report)?;
    // Hub and folder edges are collected rather than emitted, because they
    // meet the link edges in one group map: a note's `parent:` and the folder
    // layout can name the same relationship, and two `emit_groups` calls
    // cannot see each other's rows to fold them into one edge.
    let mut groups = build_hubs(&mut graph, &docs, &opts.profile, &mut report)?;
    merge_groups(
        &mut groups,
        build_folders(&mut graph, &docs, &walked.index_files, opts, &mut report)?,
    );
    merge_groups(
        &mut groups,
        build_attachments(
            &mut graph,
            &docs,
            &walked.attachments,
            &opts.profile,
            &mut report,
        )?,
    );
    build_edges(&mut graph, &docs, opts, groups, &mut report)?;
    finish_vault(root, opts, config.as_ref(), &mut graph, &mut report);
    stamp_provenance(&mut graph, root, &walked, opts);
    Ok(BuildOutput {
        graph: Arc::new(graph),
        report,
    })
}

/// Record where this graph came from and what that directory looked like
/// (VAULT.md §12), so a later process can ask whether a rebuild would read
/// anything new without being told the path again.
///
/// The fingerprint is taken from the walk the build already did, not from a
/// second one: two walks of a directory being edited would disagree, and the
/// stamp has to describe the files this graph was made of.
///
/// The root is stored absolute where the filesystem will say so — a relative
/// path is only meaningful from the working directory the build happened to
/// run in, and the graph outlives it.
fn stamp_provenance(
    graph: &mut DirGraph,
    root: &Path,
    walked: &crate::okf::walk::WalkResult,
    opts: &BuildOptions,
) {
    let absolute = root.canonicalize().unwrap_or_else(|_| root.to_path_buf());
    graph.source_root = Some(absolute.to_string_lossy().into_owned());
    graph.source_fingerprint = Some(crate::okf::fingerprint::fingerprint_of(root, walked, opts));
}

/// The options a build of `root` actually runs with: the caller's, with the
/// vault's own `.kglite/vault.yaml` applied over their profile.
///
/// Shared with [`crate::okf::fingerprint`], which has to see the same
/// `skip_dirs` the build saw or the two would describe different file sets and
/// a rebuild check would report a change on every call.
pub(crate) fn effective_options(
    root: &Path,
    opts: &BuildOptions,
    warnings: &mut Vec<String>,
) -> Result<(BuildOptions, Option<crate::okf::vault_config::VaultConfig>), String> {
    let config = load_vault_config(root, opts, warnings)?;
    // The overrides reach discovery and parsing, so `skip_dirs`, `hubs` and
    // the label ladder are already the vault's before the first file is read.
    let mut effective = opts.clone();
    if let Some(cfg) = &config {
        cfg.apply_to_profile(&mut effective.profile);
    }
    Ok((effective, config))
}

/// Read `.kglite/vault.yaml` when the dialect is one that has vaults.
///
/// The file is a *vault* construct, so `okf` and `loose` ignore it — with a
/// warning, never silently: a bundle carrying one was almost certainly meant
/// to be built as a vault, and a config that does nothing and says nothing is
/// the reassuring-direction failure.
fn load_vault_config(
    root: &Path,
    opts: &BuildOptions,
    warnings: &mut Vec<String>,
) -> Result<Option<crate::okf::vault_config::VaultConfig>, String> {
    if opts.dialect == crate::okf::Dialect::Obsidian {
        return crate::okf::vault_config::load(root);
    }
    if crate::okf::vault_config::config_path(root).is_file() {
        warnings.push(format!(
            "`.kglite/vault.yaml` is a vault declaration and is ignored under the `{}` \
             dialect; build with dialect=\"obsidian\" to apply it",
            match opts.dialect {
                crate::okf::Dialect::Loose => "loose",
                _ => "okf",
            }
        ));
    }
    Ok(None)
}

/// Everything a vault's `.kglite/` directory adds to a finished graph: the
/// config's post-build declarations (§7) and the carried skills and recipes
/// (§8). A non-vault build passes `None` and reaches neither.
fn finish_vault(
    root: &Path,
    opts: &BuildOptions,
    config: Option<&crate::okf::vault_config::VaultConfig>,
    graph: &mut DirGraph,
    report: &mut BuildReport,
) {
    if let Some(cfg) = config {
        cfg.apply_post_build(graph, report);
    }
    if opts.dialect == crate::okf::Dialect::Obsidian {
        crate::okf::vault_config::import_carried(root, graph, report);
    }
}

/// A doc's file path minus `.md` — the directory hierarchy and the path-link
/// namespace both live here. Equal to `concept_id` under the path id scheme,
/// and deliberately *not* under the vault's, where the id is a bare stem: a
/// folder derived from the id would leave every vault note at the root.
fn doc_path(d: &ConceptDoc) -> &str {
    d.file_path.strip_suffix(".md").unwrap_or(&d.file_path)
}

/// Record `count` nodes of `label` in the report.
fn count_nodes(report: &mut BuildReport, label: &str, count: usize) {
    if count > 0 {
        *report.nodes_by_label.entry(label.to_string()).or_default() += count;
    }
}

/// Coerce a property value for columnar storage. With `native` set (the vault
/// profile) every value passes through as itself, so a frontmatter sequence
/// reaches the graph as a `Value::List` column. Without it, structured values
/// JSON-encode to a String — the OKF/Loose convention codingest's docs pass
/// shares, kept because those graphs' consumers parse the JSON today.
pub(crate) fn column_value(v: &Value, native: bool) -> Value {
    match v {
        Value::List(_) | Value::Map(_) if !native => Value::String(
            serde_json::to_string(&crate::param::kglite_value_to_json(v)).unwrap_or_default(),
        ),
        other => other.clone(),
    }
}

/// Emit grouped edges: one `add_connections` per `(src_label, tgt_label, conn)`
/// so every call has correctly-typed endpoints.
fn emit_groups(
    graph: &mut DirGraph,
    groups: EdgeGroups,
    report: &mut BuildReport,
) -> Result<(), String> {
    // The initial-load regime belongs to the connection *type*, decided once
    // before the first group of it is emitted. Letting each call re-detect it
    // made the first group of a type keep its parallel edges while every later
    // group folded duplicate endpoint pairs onto one — so two body links that
    // differ only in `section` became two edges or one depending on hash
    // order, and the same vault built two different graphs.
    let fresh: BTreeSet<&str> = groups
        .keys()
        .map(|(conn, _, _)| conn.as_str())
        .filter(|conn| !graph.connection_type_metadata.contains_key(*conn))
        .collect();
    let fresh: BTreeSet<String> = fresh.into_iter().map(str::to_string).collect();
    for ((conn, src_label, tgt_label), edges) in groups {
        // The same relationship can be written twice — a `parent:` naming the
        // folder note the layout already joined this note to (VAULT.md §2.3,
        // §4.3). Identical rows are one edge; rows differing in an edge
        // property are not identical and stay two (§5.4).
        let mut seen: HashSet<EdgeRow> = HashSet::new();
        let edges: Vec<EdgeRow> = edges
            .into_iter()
            .filter(|r| seen.insert(r.clone()))
            .collect();
        *report.edges_by_type.entry(conn.clone()).or_default() += edges.len();
        // One frame per group, so its columns are the union of the property
        // keys any row in it carries; a row missing one gets Null, which
        // `add_connections` drops rather than storing.
        let prop_keys: Vec<String> = edges
            .iter()
            .flat_map(|(_, _, props)| props.iter().map(|(k, _)| k.clone()))
            .collect::<BTreeSet<String>>()
            .into_iter()
            .collect();
        let rows: Vec<Vec<Value>> = edges
            .into_iter()
            .map(|(s, t, props)| {
                let mut row = Vec::with_capacity(2 + prop_keys.len());
                row.push(Value::String(s));
                row.push(Value::String(t));
                for key in &prop_keys {
                    row.push(
                        props
                            .iter()
                            .find(|(k, _)| k == key)
                            .map(|(_, v)| v.clone())
                            .unwrap_or(Value::Null),
                    );
                }
                row
            })
            .collect();
        let mut columns = vec!["source_id".to_string(), "target_id".to_string()];
        columns.extend(prop_keys);
        let df = DataFrame::from_cypher_rows(columns, rows)?;
        let initial = maintain::InitialLoad::Preset(fresh.contains(&conn));
        maintain::add_connections_with_initial_load(
            graph,
            df,
            conn,
            src_label,
            "source_id".to_string(),
            tgt_label,
            "target_id".to_string(),
            None,
            None,
            Some("update".to_string()),
            initial,
        )?;
    }
    Ok(())
}

/// Fold one group map into another, concatenating the rows of shared keys.
fn merge_groups(into: &mut EdgeGroups, from: EdgeGroups) {
    for (key, rows) in from {
        into.entry(key).or_default().extend(rows);
    }
}

#[cfg(test)]
mod build_tests;
#[cfg(test)]
mod tests_support;