Class: OKF::Bundle::Graph

Inherits:
Object
  • Object
show all
Defined in:
lib/okf/bundle/graph.rb

Overview

An in-memory knowledge graph of a bundle: concepts become nodes and bundle-relative markdown links become directed edges. Pure — built from an OKF::Bundle (already in memory), does no I/O, and carries no presentation concerns (sizing/colour belong to a renderer).

Node fidelity is a build option, so the graph can ship only what a consumer needs and let a server serve the rest on demand:

* default (minimal: false, body: true) — full: id, type, title, description,
tags, body.
* body: false — everything but the body.
* minimal: true — just id and title (the leanest payload to draw the graph).

Regardless of node fidelity, #type_index and #tag_index expose compact inverted indexes ({ value => [id, …] }) computed from every concept, so a minimal client can still colour by type and filter by tag.

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(nodes:, edges:, type_index: {}, tag_index: {}) ⇒ Graph

Returns a new instance of Graph.



93
94
95
96
97
98
# File 'lib/okf/bundle/graph.rb', line 93

def initialize(nodes:, edges:, type_index: {}, tag_index: {})
  @nodes = nodes
  @edges = edges
  @type_index = type_index
  @tag_index = tag_index
end

Instance Attribute Details

#edgesObject (readonly)

Returns the value of attribute edges.



20
21
22
# File 'lib/okf/bundle/graph.rb', line 20

def edges
  @edges
end

#nodesObject (readonly)

Returns the value of attribute nodes.



20
21
22
# File 'lib/okf/bundle/graph.rb', line 20

def nodes
  @nodes
end

#tag_indexObject (readonly)

Returns the value of attribute tag_index.



20
21
22
# File 'lib/okf/bundle/graph.rb', line 20

def tag_index
  @tag_index
end

#type_indexObject (readonly)

Returns the value of attribute type_index.



20
21
22
# File 'lib/okf/bundle/graph.rb', line 20

def type_index
  @type_index
end

Class Method Details

.build(bundle, minimal: false, body: true) ⇒ Object



22
23
24
25
26
27
28
29
30
31
32
33
34
# File 'lib/okf/bundle/graph.rb', line 22

def self.build(bundle, minimal: false, body: true)
  # Best-effort (§9): a malformed concept never reaches here — the reader keeps
  # it in bundle.unparseable — so the rest of the bundle still renders. Inspect
  # bundle.unparseable to detect skips.
  concepts = bundle.concepts
  id_by_path = concepts.map { |concept| [ concept.path, concept.id ] }.to_h
  new(
    nodes: concepts.map { |concept| node_for(concept, minimal: minimal, body: body) },
    edges: edges_for(concepts, id_by_path, bundle.root),
    type_index: type_index_for(concepts),
    tag_index: tag_index_for(concepts)
  )
end

.default(value, fallback) ⇒ Object



89
90
91
# File 'lib/okf/bundle/graph.rb', line 89

def self.default(value, fallback)
  value.nil? ? fallback : value.to_s
end

.edges_for(concepts, id_by_path, root) ⇒ Object

Edges resolve by path — a markdown link is a file path — then map that path to the concept living there and use its id, so a frontmatter id that differs from the path still lands the edge on the right node.



54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
# File 'lib/okf/bundle/graph.rb', line 54

def self.edges_for(concepts, id_by_path, root)
  seen = Set.new
  concepts.each_with_object([]) do |concept, edges|
    Markdown::Links.extract(concept.body).each do |raw|
      resolved = Markdown::Links.resolve(raw, from: concept.path, bundle: root)
      next if resolved.nil?

      target_id = id_by_path[resolved]
      next if target_id.nil?
      next if target_id == concept.id
      next unless seen.add?([ concept.id, target_id ])

      edges << { source: concept.id, target: target_id }
    end
  end
end

.node_for(concept, minimal: false, body: true) ⇒ Object



36
37
38
39
40
41
42
43
44
45
46
47
48
49
# File 'lib/okf/bundle/graph.rb', line 36

def self.node_for(concept, minimal: false, body: true)
  title = default(concept.title, File.basename(concept.id))
  return { id: concept.id, title: title } if minimal

  node = {
    id: concept.id,
    type: default(concept.type, "Untyped"),
    title: title,
    description: concept.description.to_s,
    tags: tags_of(concept)
  }
  node[:body] = concept.body.to_s.strip if body
  node
end

.tag_index_for(concepts) ⇒ Object

{ tag => [id, …] } over every concept (a concept contributes 0..n tags).



79
80
81
82
83
# File 'lib/okf/bundle/graph.rb', line 79

def self.tag_index_for(concepts)
  concepts.each_with_object({}) do |concept, index|
    tags_of(concept).each { |tag| (index[tag.to_s] ||= []) << concept.id }
  end
end

.tags_of(concept) ⇒ Object



85
86
87
# File 'lib/okf/bundle/graph.rb', line 85

def self.tags_of(concept)
  concept.tags.is_a?(Array) ? concept.tags : []
end

.type_index_for(concepts) ⇒ Object

{ type => [id, …] } over every concept (one type each, "Untyped" if blank).



72
73
74
75
76
# File 'lib/okf/bundle/graph.rb', line 72

def self.type_index_for(concepts)
  concepts.each_with_object({}) do |concept, index|
    (index[default(concept.type, "Untyped")] ||= []) << concept.id
  end
end

Instance Method Details

#to_hObject



100
101
102
# File 'lib/okf/bundle/graph.rb', line 100

def to_h
  { nodes: nodes, edges: edges }
end

#unlinked_idsObject

Ids of nodes with graph degree 0 — no cross-links in or out. These are the "loose" files: they float in a rendered graph, reachable (if at all) only via an index.md listing, which is not an edge. Distinct from an orphan, a reachability notion that an index listing satisfies — a concept can be indexed (not an orphan) yet still be unlinked here. Preserves node order.



109
110
111
112
113
114
115
# File 'lib/okf/bundle/graph.rb', line 109

def unlinked_ids
  @unlinked_ids ||= begin
    linked = Set.new
    edges.each { |edge| linked << edge[:source] << edge[:target] }
    nodes.map { |node| node[:id] }.reject { |id| linked.include?(id) }
  end
end