okf 1.10.0 → 1.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +294 -0
- data/README.md +310 -445
- data/lib/okf/bundle/folder.rb +24 -5
- data/lib/okf/bundle/linter.rb +1 -1
- data/lib/okf/bundle/search/index.rb +13 -3
- data/lib/okf/bundle/search.rb +93 -13
- data/lib/okf/bundle/skeleton.rb +241 -0
- data/lib/okf/bundle.rb +19 -14
- data/lib/okf/cli/catalog.rb +6 -6
- data/lib/okf/cli/command.rb +241 -14
- data/lib/okf/cli/dirs.rb +118 -0
- data/lib/okf/cli/files.rb +2 -2
- data/lib/okf/cli/graph.rb +115 -9
- data/lib/okf/cli/index.rb +67 -25
- data/lib/okf/cli/loose.rb +2 -2
- data/lib/okf/cli/registry.rb +152 -15
- data/lib/okf/cli/render.rb +3 -2
- data/lib/okf/cli/search.rb +33 -2
- data/lib/okf/cli/server.rb +16 -5
- data/lib/okf/cli/stats.rb +36 -11
- data/lib/okf/cli/tags.rb +29 -7
- data/lib/okf/cli/types.rb +1 -1
- data/lib/okf/cli.rb +10 -6
- data/lib/okf/registry.rb +351 -20
- data/lib/okf/render/graph/template.html.erb +504 -103
- data/lib/okf/render/graph.rb +27 -3
- data/lib/okf/server/app.rb +74 -3
- data/lib/okf/server/hub.rb +40 -30
- data/lib/okf/skill/SKILL.md +17 -10
- data/lib/okf/skill/playbooks/consume.md +3 -3
- data/lib/okf/skill/playbooks/maintain.md +4 -3
- data/lib/okf/skill/playbooks/menu.md +3 -2
- data/lib/okf/skill/playbooks/refine.md +30 -3
- data/lib/okf/skill/playbooks/search.md +7 -7
- data/lib/okf/skill/reference/cli.md +171 -32
- data/lib/okf/version.rb +1 -1
- data/lib/okf.rb +10 -0
- metadata +21 -8
data/lib/okf/bundle/folder.rb
CHANGED
|
@@ -42,6 +42,10 @@ module OKF
|
|
|
42
42
|
@bundle.graph(minimal: minimal, body: body)
|
|
43
43
|
end
|
|
44
44
|
|
|
45
|
+
def skeleton
|
|
46
|
+
@bundle.skeleton
|
|
47
|
+
end
|
|
48
|
+
|
|
45
49
|
def catalog
|
|
46
50
|
@bundle.catalog
|
|
47
51
|
end
|
|
@@ -64,13 +68,28 @@ module OKF
|
|
|
64
68
|
end
|
|
65
69
|
end
|
|
66
70
|
|
|
67
|
-
#
|
|
68
|
-
#
|
|
69
|
-
#
|
|
70
|
-
#
|
|
71
|
+
# The bundle's display label — path arithmetic, no disk. It is a class
|
|
72
|
+
# method so a caller that only wants the label (the registry naming an
|
|
73
|
+
# entry) can have it without a Reader.read of every file.
|
|
74
|
+
#
|
|
75
|
+
# "parent/dir", because a bundle directory's own name is rarely unique
|
|
76
|
+
# enough to name it by — except when that name is `.okf`, the conventional
|
|
77
|
+
# container, and then the parent carries the whole answer on its own. A
|
|
78
|
+
# registry of eight projects is eight rows reading `…/.okf`, which is the
|
|
79
|
+
# one word that tells none of them apart; `repo/.okf` is read as "repo" by
|
|
80
|
+
# anyone looking at it anyway.
|
|
81
|
+
#
|
|
82
|
+
# A directory with no parent to borrow (`/.okf`) keeps its own name: the
|
|
83
|
+
# parent is `/`, which names nothing. That case used to compose into
|
|
84
|
+
# `//.okf`.
|
|
71
85
|
def self.label(root)
|
|
72
86
|
pathname = Pathname.new(root)
|
|
73
|
-
|
|
87
|
+
parent = pathname.parent.basename.to_s
|
|
88
|
+
base = pathname.basename.to_s
|
|
89
|
+
return base if [ "/", "." ].include?(parent)
|
|
90
|
+
return parent if base == ".okf"
|
|
91
|
+
|
|
92
|
+
"#{parent}/#{base}"
|
|
74
93
|
end
|
|
75
94
|
|
|
76
95
|
def name
|
data/lib/okf/bundle/linter.rb
CHANGED
|
@@ -12,8 +12,9 @@ module OKF
|
|
|
12
12
|
#
|
|
13
13
|
# Matching is by *token*: a term matches a whole word or a word it prefixes
|
|
14
14
|
# ("dedup" reaches "deduplication"), and `fuzzy:` opts into typo tolerance.
|
|
15
|
-
# The index
|
|
16
|
-
#
|
|
15
|
+
# The index builds per call unless the caller holds a Search::Corpus, which
|
|
16
|
+
# builds it once through .prepare and reuses it — the server does, the
|
|
17
|
+
# one-shot CLI cannot. See .okf/capabilities/search.md.
|
|
17
18
|
module Index
|
|
18
19
|
CAPABILITIES = %i[fuzzy prefix].freeze
|
|
19
20
|
|
|
@@ -37,9 +38,18 @@ module OKF
|
|
|
37
38
|
# can neither match nor be credited. The hit's `terms` are MiniFTS's
|
|
38
39
|
# matched *document* terms — already lowercased, and present in the text
|
|
39
40
|
# verbatim even when the query only prefixed them.
|
|
40
|
-
|
|
41
|
+
# The expensive half, separated so a long-lived caller can hold it. A
|
|
42
|
+
# Corpus calls this once; a one-shot call still goes through #call and
|
|
43
|
+
# builds inline, which is why this is an addition and not a new
|
|
44
|
+
# requirement on the engine contract.
|
|
45
|
+
def prepare(documents)
|
|
41
46
|
index = MiniFTS.new(fields: FIELDS, id_field: "key")
|
|
42
47
|
index.add_all(documents)
|
|
48
|
+
index
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def call(documents, terms, fields:, fuzzy: false, prepared: nil, **_options)
|
|
52
|
+
index = prepared || prepare(documents)
|
|
43
53
|
|
|
44
54
|
options = { combine_with: "AND", prefix: true, boost: WEIGHTS, fields: fields }
|
|
45
55
|
options[:fuzzy] = FUZZY_DISTANCE if fuzzy
|
data/lib/okf/bundle/search.rb
CHANGED
|
@@ -186,12 +186,89 @@ module OKF
|
|
|
186
186
|
new(bundles, terms, fields: fields, regexp: regexp, fuzzy: fuzzy, engine: engine, engines: engines).results
|
|
187
187
|
end
|
|
188
188
|
|
|
189
|
+
# The searchable text of one concept, by field. Here rather than on an
|
|
190
|
+
# instance because a Corpus builds documents with no query in hand.
|
|
191
|
+
def self.field_texts(concept)
|
|
192
|
+
{
|
|
193
|
+
"id" => concept.id,
|
|
194
|
+
"title" => concept.title.to_s,
|
|
195
|
+
"type" => concept.type.to_s,
|
|
196
|
+
"description" => concept.description.to_s,
|
|
197
|
+
"tags" => Array(concept.tags).join(" "),
|
|
198
|
+
"body" => concept.body
|
|
199
|
+
}
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# A corpus prepared once and queried many times: the documents, the key →
|
|
203
|
+
# concept map, and each engine's built index.
|
|
204
|
+
#
|
|
205
|
+
# This is the asymmetry the engine choice was always argued from. A CLI
|
|
206
|
+
# process loads a bundle, asks one question and exits, so an index build has
|
|
207
|
+
# exactly one query to amortize over and the scan wins. A server is the
|
|
208
|
+
# other case — the build is ~95% of the index path's cost, and paying it per
|
|
209
|
+
# request made every search re-read the whole corpus. Held once, it is paid
|
|
210
|
+
# once.
|
|
211
|
+
#
|
|
212
|
+
# Pure: it holds concepts, never disk. Which is also the cost — the corpus
|
|
213
|
+
# is a snapshot, so a body edited after it was built is searchable only
|
|
214
|
+
# after the holder drops it. That matches the graph, which is memoized the
|
|
215
|
+
# same way and for the same reason.
|
|
216
|
+
class Corpus
|
|
217
|
+
attr_reader :bundles, :documents, :sources
|
|
218
|
+
|
|
219
|
+
def initialize(bundles)
|
|
220
|
+
@bundles = bundles
|
|
221
|
+
@documents = []
|
|
222
|
+
@sources = {}
|
|
223
|
+
@indexes = {}
|
|
224
|
+
bundles.each do |slug, bundle|
|
|
225
|
+
bundle.concepts.each do |concept|
|
|
226
|
+
key = "#{slug}#{KEY_SEPARATOR}#{concept.id}"
|
|
227
|
+
@sources[key] = [ slug, concept ]
|
|
228
|
+
@documents << Search.field_texts(concept).merge("key" => key)
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# nil for an engine with nothing to prebuild — the scan reads raw text and
|
|
234
|
+
# has no index to hold — so the option only ever reaches one that declared
|
|
235
|
+
# it can. Memoized per engine id: two engines over one corpus is legal.
|
|
236
|
+
def index_for(engine)
|
|
237
|
+
return nil unless engine.respond_to?(:prepare)
|
|
238
|
+
|
|
239
|
+
@indexes[engine.id] ||= engine.prepare(@documents)
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
# Prepare a corpus for a long-lived caller. Hand the result back to .with
|
|
244
|
+
# for every query.
|
|
245
|
+
#
|
|
246
|
+
# `engine:` builds that engine's index *now* rather than on the first query.
|
|
247
|
+
# Without it the corpus holds only the documents, and the expensive half —
|
|
248
|
+
# the index — is still built lazily, which puts the whole cost on whoever
|
|
249
|
+
# searches first. A server knows its engine at boot, so it can pay there.
|
|
250
|
+
def self.prepare(bundles, engine: nil, engines: nil)
|
|
251
|
+
corpus = Corpus.new(bundles)
|
|
252
|
+
return corpus if OKF.blank?(engine)
|
|
253
|
+
|
|
254
|
+
corpus.index_for(engine_for([], engines: engines || self.engines, name: engine))
|
|
255
|
+
corpus
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Query a prepared corpus. Same rows as .across, without rebuilding what the
|
|
259
|
+
# corpus already holds.
|
|
260
|
+
def self.with(corpus, terms, fields: nil, regexp: false, fuzzy: false, engine: nil, engines: nil)
|
|
261
|
+
new(corpus.bundles, terms, fields: fields, regexp: regexp, fuzzy: fuzzy,
|
|
262
|
+
engine: engine, engines: engines, corpus: corpus).results
|
|
263
|
+
end
|
|
264
|
+
|
|
189
265
|
# Raises RegexpError on an invalid pattern with `regexp: true`, and
|
|
190
266
|
# UnsupportedQuery when no engine can answer — the caller owns turning
|
|
191
267
|
# either into a usage error. `engines:` overrides the registry, which is how
|
|
192
268
|
# the "nothing qualifies" path stays reachable without an addon installed.
|
|
193
|
-
def initialize(bundles, terms, fields: nil, regexp: false, fuzzy: false, engine: nil, engines: nil)
|
|
269
|
+
def initialize(bundles, terms, fields: nil, regexp: false, fuzzy: false, engine: nil, engines: nil, corpus: nil)
|
|
194
270
|
@bundles = bundles
|
|
271
|
+
@corpus = corpus
|
|
195
272
|
@terms = Array(terms).reject { |term| OKF.blank?(term) }.map(&:to_s)
|
|
196
273
|
@fields = fields.nil? || fields.empty? ? FIELDS : fields
|
|
197
274
|
@regexp = regexp
|
|
@@ -202,7 +279,7 @@ module OKF
|
|
|
202
279
|
end
|
|
203
280
|
|
|
204
281
|
# Ranked match rows, catalog-style identity plus where the terms hit:
|
|
205
|
-
# [{ slug:, id:, title:, type:,
|
|
282
|
+
# [{ slug:, id:, title:, type:, dir:, top_dir:, tags:, matched: [field, …], score:, snippet: }, …]
|
|
206
283
|
# ordered by score descending, then slug, then id. `slug` is present only
|
|
207
284
|
# when searching across bundles. No terms means no matches.
|
|
208
285
|
def results
|
|
@@ -242,6 +319,7 @@ module OKF
|
|
|
242
319
|
# what it can act on, so there is nothing left for it to ignore.
|
|
243
320
|
def engine_options(chosen)
|
|
244
321
|
options = { fields: @fields }
|
|
322
|
+
options[:prepared] = @corpus.index_for(chosen) if @corpus
|
|
245
323
|
ROUTABLE.each do |capability|
|
|
246
324
|
options[capability] = requested[capability] if chosen.capabilities.include?(capability)
|
|
247
325
|
end
|
|
@@ -255,6 +333,13 @@ module OKF
|
|
|
255
333
|
# Every concept as an indexable document, keyed uniquely across bundles.
|
|
256
334
|
# @sources keeps the way back, so the index stores no fields of its own.
|
|
257
335
|
def documents
|
|
336
|
+
# The corpus already walked every concept and kept the map that turns a
|
|
337
|
+
# hit back into a row; taking its sources is what makes that reuse whole.
|
|
338
|
+
if @corpus
|
|
339
|
+
@sources = @corpus.sources
|
|
340
|
+
return @corpus.documents
|
|
341
|
+
end
|
|
342
|
+
|
|
258
343
|
docs = []
|
|
259
344
|
@bundles.each do |slug, bundle|
|
|
260
345
|
bundle.concepts.each do |concept|
|
|
@@ -269,14 +354,7 @@ module OKF
|
|
|
269
354
|
# { field => original-case text } for every searchable field. The index reads
|
|
270
355
|
# all of them; `fields:` narrows the search, not the document.
|
|
271
356
|
def field_texts(concept)
|
|
272
|
-
|
|
273
|
-
"id" => concept.id,
|
|
274
|
-
"title" => concept.title.to_s,
|
|
275
|
-
"type" => concept.type.to_s,
|
|
276
|
-
"description" => concept.description.to_s,
|
|
277
|
-
"tags" => Array(concept.tags).join(" "),
|
|
278
|
-
"body" => concept.body
|
|
279
|
-
}
|
|
357
|
+
Search.field_texts(concept)
|
|
280
358
|
end
|
|
281
359
|
|
|
282
360
|
# `slug` leads the row so a merged result reads bundle-first, and drops
|
|
@@ -288,7 +366,8 @@ module OKF
|
|
|
288
366
|
id: concept.id,
|
|
289
367
|
title: (concept.title || concept.id).to_s,
|
|
290
368
|
type: concept.type.to_s,
|
|
291
|
-
|
|
369
|
+
dir: OKF.dir_of(concept.id),
|
|
370
|
+
top_dir: top_dir_of(concept.id),
|
|
292
371
|
tags: Array(concept.tags).map(&:to_s),
|
|
293
372
|
matched: matched,
|
|
294
373
|
score: score.round(4),
|
|
@@ -342,8 +421,9 @@ module OKF
|
|
|
342
421
|
end
|
|
343
422
|
end
|
|
344
423
|
|
|
345
|
-
# A concept's top-level
|
|
346
|
-
|
|
424
|
+
# A concept's top-level dir, mirroring the catalog's definition — the first
|
|
425
|
+
# path segment. OKF.dir_of keeps the levels this one rolls up.
|
|
426
|
+
def top_dir_of(id)
|
|
347
427
|
id.include?("/") ? id.split("/").first : "(root)"
|
|
348
428
|
end
|
|
349
429
|
end
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OKF
|
|
4
|
+
class Bundle
|
|
5
|
+
# The graph reduced to what a reader can hold in their head. Pure — built from
|
|
6
|
+
# an OKF::Bundle::Graph's nodes and edges, does no I/O, and decides nothing
|
|
7
|
+
# about how any of it is drawn.
|
|
8
|
+
#
|
|
9
|
+
# A dense bundle is not dense in the way a hub-and-spoke picture is. Measured
|
|
10
|
+
# on a 47-concept bundle with 227 links: the top hub takes 13 inbound and the
|
|
11
|
+
# median takes 4, so there is no 80/20 to exploit — dropping two thirds of the
|
|
12
|
+
# concepts still leaves 53 edges. The density lives *between directories*:
|
|
13
|
+
# 173 of those 227 links (76%) cross a directory boundary, and they collapse
|
|
14
|
+
# into 50 directory-to-directory arcs of which the top ten carry half the mass.
|
|
15
|
+
# That is the reduction worth drawing, and it is why #arcs exists at all.
|
|
16
|
+
#
|
|
17
|
+
# ── the two things it produces, and who reads them ──
|
|
18
|
+
#
|
|
19
|
+
# dirs + arcs the reduction, as counts: one row per directory, one
|
|
20
|
+
# weighted arc per ordered pair. Printed by
|
|
21
|
+
# `okf graph --traffic`, with cohesion derived from them.
|
|
22
|
+
# edges every link with the cut it survives (`keep_at`). Nothing
|
|
23
|
+
# prints these — they are what lets the graph page lay a
|
|
24
|
+
# large bundle out on its strongest links first.
|
|
25
|
+
#
|
|
26
|
+
# Both are emitted *unthresholded*, and #suggested_cut names where to cut
|
|
27
|
+
# rather than cutting, so a caller narrows the picture without this class
|
|
28
|
+
# having to know what a picture is.
|
|
29
|
+
#
|
|
30
|
+
# Directories come off the concept *id* (OKF.dir_of), not the file path, so
|
|
31
|
+
# this agrees with #catalog, #hubs and the `--dir` filter. Bundle#directory_index
|
|
32
|
+
# groups by path instead — deliberately, since an index.md is a physical
|
|
33
|
+
# listing — so the two disagree for a concept whose frontmatter `id` moves it.
|
|
34
|
+
# This side follows the id because the edges do.
|
|
35
|
+
class Skeleton
|
|
36
|
+
attr_reader :dirs, :arcs, :edges
|
|
37
|
+
|
|
38
|
+
def self.build(bundle)
|
|
39
|
+
graph = bundle.graph(minimal: true)
|
|
40
|
+
ids = graph.nodes.map { |node| node[:id] }
|
|
41
|
+
pairs = graph.edges.map { |edge| [ edge[:source], edge[:target] ] }
|
|
42
|
+
dir_by_id = ids.map { |id| [ id, OKF.dir_of(id) ] }.to_h
|
|
43
|
+
|
|
44
|
+
new(
|
|
45
|
+
dirs: dirs_for(dir_by_id, pairs),
|
|
46
|
+
arcs: arcs_for(dir_by_id, pairs),
|
|
47
|
+
edges: edges_for(pairs, neighbours_for(ids, pairs))
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# ── the cuts (see above) ──
|
|
52
|
+
|
|
53
|
+
def self.arcs_above(arcs, weight)
|
|
54
|
+
arcs.select { |arc| arc[:weight] >= weight }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def self.edges_within(edges, keep_at)
|
|
58
|
+
edges.select { |edge| edge[:keep_at] <= keep_at }
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# ── the directories ──
|
|
62
|
+
|
|
63
|
+
# One row per directory that holds a concept, plus every ancestor up to the
|
|
64
|
+
# root, so the tree stays connected through a directory that holds nothing
|
|
65
|
+
# directly. `count` is direct and `subtree` is at-or-below — the same pair
|
|
66
|
+
# `okf dirs` prints, and for the same reason: a direct count alone cannot
|
|
67
|
+
# say where the mass is once the listing is cut off at a depth.
|
|
68
|
+
def self.dirs_for(dir_by_id, pairs)
|
|
69
|
+
direct = Hash.new(0)
|
|
70
|
+
dir_by_id.each_value { |dir| direct[dir] += 1 }
|
|
71
|
+
internal = Hash.new(0)
|
|
72
|
+
pairs.each do |source, target|
|
|
73
|
+
from = dir_by_id[source]
|
|
74
|
+
internal[from] += 1 if from == dir_by_id[target]
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
every_dir(direct.keys).map do |dir|
|
|
78
|
+
{ dir: dir, parent: parent_of(dir), count: direct[dir],
|
|
79
|
+
subtree: direct.reduce(0) { |sum, (other, n)| under?(other, dir) ? sum + n : sum },
|
|
80
|
+
internal: internal[dir] }
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# The cross-directory link mass, aggregated. Directed and never a self-arc:
|
|
85
|
+
# a directory's internal links are carried on its own row (`internal`),
|
|
86
|
+
# because an arc from a box to itself is a loop the eye has to untangle to
|
|
87
|
+
# learn a number the box could simply have carried.
|
|
88
|
+
def self.arcs_for(dir_by_id, pairs)
|
|
89
|
+
weights = Hash.new(0)
|
|
90
|
+
pairs.each do |source, target|
|
|
91
|
+
from = dir_by_id[source]
|
|
92
|
+
to = dir_by_id[target]
|
|
93
|
+
weights[[ from, to ]] += 1 unless from == to
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
weights.map { |(from, to), weight| { source: from, target: to, weight: weight } }
|
|
97
|
+
.sort_by { |arc| [ -arc[:weight], arc[:source], arc[:target] ] }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# ── where to cut ──
|
|
101
|
+
|
|
102
|
+
# The arc cut that leaves a readable picture, chosen from the *shape* of
|
|
103
|
+
# the bundle rather than fixed. A fixed cut cannot work: measured at
|
|
104
|
+
# weight 3 across ten bundles it left 2 arcs on one and 136 on another —
|
|
105
|
+
# too tight to be a picture at one end, no reduction at all at the other.
|
|
106
|
+
#
|
|
107
|
+
# What stays constant when a bundle grows is not the arc count but the
|
|
108
|
+
# arcs *per box*: a node-link diagram reads at roughly one to two edges
|
|
109
|
+
# per node regardless of size. So the target is 1.5 arcs per directory,
|
|
110
|
+
# and the cut is whatever weight delivers it — 22 arcs over 13 directories
|
|
111
|
+
# on one bundle, 191 over 95 on another, both about the same density.
|
|
112
|
+
#
|
|
113
|
+
# The floor of 8 is for the small end, where 1.5-per-box would cut a
|
|
114
|
+
# ten-arc bundle down to something that no longer shows how it is joined
|
|
115
|
+
# up. Ties are kept rather than broken, so the result is *at least* the
|
|
116
|
+
# target — the alternative is dropping one of two arcs that weigh the
|
|
117
|
+
# same, which is an arbitrary choice presented as a threshold.
|
|
118
|
+
def self.suggested_cut(arcs, dir_count)
|
|
119
|
+
target = [ (dir_count * 1.5).ceil, 8 ].max
|
|
120
|
+
return 1 if arcs.empty? || arcs.size <= target
|
|
121
|
+
|
|
122
|
+
arcs[target - 1][:weight]
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# ── the edges, each with the cut it survives ──
|
|
126
|
+
|
|
127
|
+
# The local-degree sparsifier (Lindner et al.): every concept keeps its own
|
|
128
|
+
# most-connected neighbours, and an edge survives if *either* end kept it.
|
|
129
|
+
# The union is the point — it is what stops a sparsifier from stranding the
|
|
130
|
+
# quiet half of the bundle, which a global "drop the weakest edges" rule
|
|
131
|
+
# does immediately.
|
|
132
|
+
#
|
|
133
|
+
# Not the disparity filter, the usual name in this territory: that one reads
|
|
134
|
+
# an edge's weight against its endpoint's total, and every link here weighs
|
|
135
|
+
# exactly 1, which makes every proportion identical and the filter a coin
|
|
136
|
+
# toss. Weighted-graph tools do not transfer to an unweighted graph just
|
|
137
|
+
# because both are graphs.
|
|
138
|
+
#
|
|
139
|
+
# `keep_at` is the smallest cut (0–100) at which the edge appears, so the
|
|
140
|
+
# consumer's whole job is `keep_at <= n`. An edge to a node's single most
|
|
141
|
+
# connected neighbour keeps at 0 and is therefore never cut away: the
|
|
142
|
+
# skeleton always spans every linked concept.
|
|
143
|
+
def self.edges_for(pairs, neighbours)
|
|
144
|
+
order = ordered_neighbours(neighbours)
|
|
145
|
+
pairs.map do |source, target|
|
|
146
|
+
{ source: source, target: target,
|
|
147
|
+
keep_at: [ keep_at(order, neighbours, source, target),
|
|
148
|
+
keep_at(order, neighbours, target, source) ].min }
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Where `other` sits in `id`'s neighbours (1 = most connected), turned into
|
|
153
|
+
# the smallest cut that reaches it. Position 1 needs no budget at all; past
|
|
154
|
+
# that, a cut of n keeps ceil(degree ** n/100) neighbours, so the answer is
|
|
155
|
+
# the least n satisfying that — solved directly rather than searched, and
|
|
156
|
+
# pinned against the definition it came from in the unit test.
|
|
157
|
+
def self.keep_at(order, neighbours, id, other)
|
|
158
|
+
position = order[id].index(other) + 1
|
|
159
|
+
return 0 if position == 1
|
|
160
|
+
|
|
161
|
+
degree = neighbours[id].size
|
|
162
|
+
(100 * Math.log(position - 1) / Math.log(degree)).floor + 1
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def self.ordered_neighbours(neighbours)
|
|
166
|
+
neighbours.each_with_object({}) do |(id, set), out|
|
|
167
|
+
out[id] = set.sort_by { |other| [ -neighbours[other].size, other ] }
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Undirected neighbour sets. A link is structure regardless of which end
|
|
172
|
+
# authored it, and a concept every page cites but that cites nothing back
|
|
173
|
+
# is as central as one that does the citing.
|
|
174
|
+
def self.neighbours_for(ids, pairs)
|
|
175
|
+
sets = ids.map { |id| [ id, Set.new ] }.to_h
|
|
176
|
+
pairs.each do |source, target|
|
|
177
|
+
sets[source] << target if sets.key?(source) && sets.key?(target)
|
|
178
|
+
sets[target] << source if sets.key?(source) && sets.key?(target)
|
|
179
|
+
end
|
|
180
|
+
sets
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# ── path arithmetic (pure; the bundle root is ".") ──
|
|
184
|
+
|
|
185
|
+
def self.every_dir(dirs)
|
|
186
|
+
seen = {}
|
|
187
|
+
dirs.each do |dir|
|
|
188
|
+
current = dir
|
|
189
|
+
loop do
|
|
190
|
+
seen[current] = true
|
|
191
|
+
break if current == "."
|
|
192
|
+
|
|
193
|
+
current = parent_of(current)
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
seen.keys.sort_by { |dir| dir == "." ? "" : dir }
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def self.parent_of(dir)
|
|
200
|
+
return nil if dir == "."
|
|
201
|
+
|
|
202
|
+
File.dirname(dir)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# At or below — the same rule `--dir` is answered against, so a row's
|
|
206
|
+
# `subtree` and what `--dir <that row>` returns can never disagree.
|
|
207
|
+
def self.under?(dir, ancestor)
|
|
208
|
+
return true if ancestor == "."
|
|
209
|
+
|
|
210
|
+
dir == ancestor || dir.start_with?("#{ancestor}/")
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def initialize(dirs:, arcs:, edges:)
|
|
214
|
+
@dirs = dirs
|
|
215
|
+
@arcs = arcs
|
|
216
|
+
@edges = edges
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def suggested_cut
|
|
220
|
+
@suggested_cut ||= self.class.suggested_cut(arcs, dirs.size)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# The `keep_at` of each edge in +edges+, in *its* order — so a caller
|
|
224
|
+
# holding an OKF::Bundle::Graph can line the two up. Matched on the pair
|
|
225
|
+
# rather than by index: both lists derive from the same graph and so are
|
|
226
|
+
# already parallel today, and an index-aligned read would go wrong in
|
|
227
|
+
# silence on the day one of them stops being built that way.
|
|
228
|
+
def cuts_for(graph_edges)
|
|
229
|
+
by_pair = edges.each_with_object({}) { |edge, out| out[[ edge[:source], edge[:target] ]] = edge[:keep_at] }
|
|
230
|
+
graph_edges.map { |edge| by_pair[[ edge[:source], edge[:target] ]] || 0 }
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
# What the drawn views read. `edges` stays out: it is per-link data whose
|
|
234
|
+
# only consumer needs it at boot, before any fetch could answer — so it
|
|
235
|
+
# rides inline with the graph instead (see OKF::Render::Graph#edge_cuts_json).
|
|
236
|
+
def to_h
|
|
237
|
+
{ dirs: dirs, arcs: arcs, suggested_cut: suggested_cut }
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
data/lib/okf/bundle.rb
CHANGED
|
@@ -95,6 +95,10 @@ module OKF
|
|
|
95
95
|
Graph.build(self, minimal: minimal, body: body)
|
|
96
96
|
end
|
|
97
97
|
|
|
98
|
+
def skeleton
|
|
99
|
+
Skeleton.build(self)
|
|
100
|
+
end
|
|
101
|
+
|
|
98
102
|
# Rich per-concept metadata the catalog / files / stats consumers want but the
|
|
99
103
|
# lean graph omits — the descriptive frontmatter fields plus in/out link degree
|
|
100
104
|
# taken from the graph edges. Pure: derived from the concepts and their links,
|
|
@@ -118,29 +122,29 @@ module OKF
|
|
|
118
122
|
timestamp: concept.timestamp&.to_s,
|
|
119
123
|
status: concept.frontmatter["status"]&.to_s,
|
|
120
124
|
backlog_ref: concept.frontmatter["backlog_ref"]&.to_s,
|
|
121
|
-
dir:
|
|
122
|
-
|
|
125
|
+
dir: OKF.dir_of(id),
|
|
126
|
+
top_dir: top_dir_of(id),
|
|
123
127
|
links_out: out_degree[id],
|
|
124
128
|
links_in: in_degree[id]
|
|
125
129
|
}
|
|
126
130
|
end.sort_by { |entry| entry[:id] }
|
|
127
131
|
end
|
|
128
132
|
|
|
129
|
-
# Concepts ranked by inbound link degree, each with the
|
|
130
|
-
# links come from — the evidence for "is this hub well-homed?": a hub
|
|
131
|
-
# inbound majority is foreign to its own
|
|
132
|
-
# a single dominant foreign
|
|
133
|
-
# concepts with at least one inbound link appear. Pure: derived
|
|
134
|
-
# graph edges. Shared by the `okf graph --hubs` view.
|
|
133
|
+
# Concepts ranked by inbound link degree, each with the top-level dirs its
|
|
134
|
+
# inbound links come from — the evidence for "is this hub well-homed?": a hub
|
|
135
|
+
# whose inbound majority is foreign to its own top-level dir is a move
|
|
136
|
+
# candidate, one with a single dominant foreign dir already names its better
|
|
137
|
+
# home. Only concepts with at least one inbound link appear. Pure: derived
|
|
138
|
+
# from the graph edges. Shared by the `okf graph --hubs` view.
|
|
135
139
|
def hubs
|
|
136
140
|
inbound = {}
|
|
137
141
|
graph(minimal: true).edges.each do |edge|
|
|
138
|
-
(inbound[edge[:target]] ||= Hash.new(0))[
|
|
142
|
+
(inbound[edge[:target]] ||= Hash.new(0))[top_dir_of(edge[:source])] += 1
|
|
139
143
|
end
|
|
140
144
|
|
|
141
145
|
inbound.map do |id, sources|
|
|
142
|
-
|
|
143
|
-
{ id: id,
|
|
146
|
+
by_top_dir = sources.sort_by { |top_dir, count| [ -count, top_dir ] }.to_h
|
|
147
|
+
{ id: id, top_dir: top_dir_of(id), inbound: by_top_dir.values.reduce(0, :+), by_top_dir: by_top_dir }
|
|
144
148
|
end.sort_by { |row| [ -row[:inbound], row[:id] ] }
|
|
145
149
|
end
|
|
146
150
|
|
|
@@ -189,9 +193,10 @@ module OKF
|
|
|
189
193
|
|
|
190
194
|
private
|
|
191
195
|
|
|
192
|
-
# A concept's top-level
|
|
193
|
-
# catalog exposes, so every grouped view labels the bundle
|
|
194
|
-
|
|
196
|
+
# A concept's top-level dir, derived from its id — the first path segment, the
|
|
197
|
+
# same derivation the catalog exposes, so every grouped view labels the bundle
|
|
198
|
+
# root "(root)". OKF.dir_of keeps the levels this one rolls up.
|
|
199
|
+
def top_dir_of(id)
|
|
195
200
|
id.include?("/") ? id.split("/").first : "(root)"
|
|
196
201
|
end
|
|
197
202
|
|
data/lib/okf/cli/catalog.rb
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
module OKF
|
|
4
4
|
class CLI
|
|
5
|
-
# Every concept with its metadata, grouped by
|
|
6
|
-
# views, and the one the others narrow down from.
|
|
5
|
+
# Every concept with its metadata, grouped by top-level dir. The widest of the
|
|
6
|
+
# read views, and the one the others narrow down from.
|
|
7
7
|
class Catalog < Command
|
|
8
8
|
def self.id
|
|
9
9
|
:catalog
|
|
@@ -15,14 +15,14 @@ module OKF
|
|
|
15
15
|
|
|
16
16
|
def self.help_rows
|
|
17
17
|
[
|
|
18
|
-
[ "catalog <dir|@slug> [--json] [filters]", "list concepts with metadata, by
|
|
18
|
+
[ "catalog <dir|@slug> [--json] [filters]", "list concepts with metadata, by top-level dir" ]
|
|
19
19
|
]
|
|
20
20
|
end
|
|
21
21
|
|
|
22
22
|
def call(argv)
|
|
23
23
|
options = { json: false }
|
|
24
24
|
parser = OptionParser.new do |o|
|
|
25
|
-
o.banner = "Usage: okf catalog <dir|@slug> [--type T] [--
|
|
25
|
+
o.banner = "Usage: okf catalog <dir|@slug> [--type T] [--dir D] [--tag T] [--json]"
|
|
26
26
|
json_flags(o, options, "emit the catalog as JSON")
|
|
27
27
|
projection_flags(o, options)
|
|
28
28
|
filter_flags(o, options, :type, :area, :tag)
|
|
@@ -44,9 +44,9 @@ module OKF
|
|
|
44
44
|
|
|
45
45
|
def print_catalog(dir, entries, total)
|
|
46
46
|
@out.puts "Catalog — #{bundle_label(dir)} (#{counted(entries.size, total, "concept")})"
|
|
47
|
-
entries.group_by { |entry| entry[:
|
|
47
|
+
entries.group_by { |entry| entry[:top_dir] }.sort_by(&:first).each do |top_dir, group|
|
|
48
48
|
@out.puts
|
|
49
|
-
@out.puts " #{
|
|
49
|
+
@out.puts " #{top_dir == "(root)" ? "(root)" : "#{top_dir}/"} (#{group.size})"
|
|
50
50
|
group.each do |entry|
|
|
51
51
|
links = entry[:links_out] + entry[:links_in]
|
|
52
52
|
meta = [ entry[:type], (links.positive? ? "↳#{links}" : nil), entry[:status] ].compact.join(" · ")
|