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.



106
107
108
109
110
111
# File 'lib/okf/bundle/graph.rb', line 106

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 (§11): 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

Blank, not just nil: §11 condition 2 makes a whitespace-only type as non-conformant as a missing one (the validator says so with the same OKF.blank?), so the index must not sort them into different buckets. Otherwise type: " " earns its own row, labelled with spaces, next to Untyped.



102
103
104
# File 'lib/okf/bundle/graph.rb', line 102

def self.default(value, fallback)
  OKF.blank?(value) ? 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. Body links and sources.resource entries feed the same resolver: §5.1 says a resource naming another concept is a derivation edge that "already exists in the bundle graph", and this is what makes that true — it is also what keeps a migrated bundle's graph equal to its v0.1 twin's, since a # Citations in-bundle link stops being a body link the moment it is lifted into frontmatter. URLs and scope descriptors resolve to nothing; an unresolvable path is broken_source's to report.



61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
# File 'lib/okf/bundle/graph.rb', line 61

def self.edges_for(concepts, id_by_path, root)
  seen = Set.new
  concepts.each_with_object([]) do |concept, edges|
    targets = Markdown::Links.extract(concept.body) +
              concept.sources.map { |source| source["resource"] }.compact
    targets.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).



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

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



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

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).



81
82
83
84
85
# File 'lib/okf/bundle/graph.rb', line 81

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



113
114
115
# File 'lib/okf/bundle/graph.rb', line 113

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.



122
123
124
125
126
127
128
# File 'lib/okf/bundle/graph.rb', line 122

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