Class: OKF::Bundle::Graph
- Inherits:
-
Object
- Object
- OKF::Bundle::Graph
- 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
-
#edges ⇒ Object
readonly
Returns the value of attribute edges.
-
#nodes ⇒ Object
readonly
Returns the value of attribute nodes.
-
#tag_index ⇒ Object
readonly
Returns the value of attribute tag_index.
-
#type_index ⇒ Object
readonly
Returns the value of attribute type_index.
Class Method Summary collapse
- .build(bundle, minimal: false, body: true) ⇒ Object
-
.default(value, fallback) ⇒ Object
Blank, not just nil: §11 condition 2 makes a whitespace-only
typeas 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. -
.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
idthat differs from the path still lands the edge on the right node. - .node_for(concept, minimal: false, body: true) ⇒ Object
-
.tag_index_for(concepts) ⇒ Object
{ tag => [id, …] } over every concept (a concept contributes 0..n tags).
- .tags_of(concept) ⇒ Object
-
.type_index_for(concepts) ⇒ Object
{ type => [id, …] } over every concept (one type each, "Untyped" if blank).
Instance Method Summary collapse
-
#initialize(nodes:, edges:, type_index: {}, tag_index: {}) ⇒ Graph
constructor
A new instance of Graph.
- #to_h ⇒ Object
-
#unlinked_ids ⇒ Object
Ids of nodes with graph degree 0 — no cross-links in or out.
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
#edges ⇒ Object (readonly)
Returns the value of attribute edges.
20 21 22 |
# File 'lib/okf/bundle/graph.rb', line 20 def edges @edges end |
#nodes ⇒ Object (readonly)
Returns the value of attribute nodes.
20 21 22 |
# File 'lib/okf/bundle/graph.rb', line 20 def nodes @nodes end |
#tag_index ⇒ Object (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_index ⇒ Object (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: (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| (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.(concept) concept..is_a?(Array) ? concept. : [] 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_h ⇒ Object
113 114 115 |
# File 'lib/okf/bundle/graph.rb', line 113 def to_h { nodes: nodes, edges: edges } end |
#unlinked_ids ⇒ Object
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 |