okf 1.11.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.
@@ -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,
@@ -119,28 +123,28 @@ module OKF
119
123
  status: concept.frontmatter["status"]&.to_s,
120
124
  backlog_ref: concept.frontmatter["backlog_ref"]&.to_s,
121
125
  dir: OKF.dir_of(id),
122
- area: area_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 areas its inbound
130
- # links come from — the evidence for "is this hub well-homed?": a hub whose
131
- # inbound majority is foreign to its own area is a move candidate, one with
132
- # a single dominant foreign area already names its better home. Only
133
- # concepts with at least one inbound link appear. Pure: derived from the
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))[area_of(edge[:source])] += 1
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
- by_area = sources.sort_by { |area, count| [ -count, area ] }.to_h
143
- { id: id, area: area_of(id), inbound: by_area.values.reduce(0, :+), by_area: by_area }
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 area, derived from its id — the same derivation the
193
- # catalog exposes, so every grouped view labels the bundle root "(root)".
194
- def area_of(id)
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
 
@@ -2,8 +2,8 @@
2
2
 
3
3
  module OKF
4
4
  class CLI
5
- # Every concept with its metadata, grouped by area. The widest of the read
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,7 +15,7 @@ module OKF
15
15
 
16
16
  def self.help_rows
17
17
  [
18
- [ "catalog <dir|@slug> [--json] [filters]", "list concepts with metadata, by area" ]
18
+ [ "catalog <dir|@slug> [--json] [filters]", "list concepts with metadata, by top-level dir" ]
19
19
  ]
20
20
  end
21
21
 
@@ -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[:area] }.sort_by(&:first).each do |area, group|
47
+ entries.group_by { |entry| entry[:top_dir] }.sort_by(&:first).each do |top_dir, group|
48
48
  @out.puts
49
- @out.puts " #{area == "(root)" ? "(root)" : "#{area}/"} (#{group.size})"
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(" · ")
@@ -193,7 +193,7 @@ module OKF
193
193
  def filter_entries(entries, options)
194
194
  entries.select do |entry|
195
195
  (options[:type].nil? || fold(entry[:type]) == fold(options[:type])) &&
196
- (options[:area].nil? || fold(entry[:area]) == fold_area(options[:area])) &&
196
+ (options[:area].nil? || fold(entry[:top_dir]) == fold_area(options[:area])) &&
197
197
  (options[:dir].nil? || under_dir?(entry[:dir], options[:dir])) &&
198
198
  (options[:tag].nil? || entry[:tags].any? { |tag| fold(tag) == fold(options[:tag]) })
199
199
  end
@@ -421,15 +421,58 @@ module OKF
421
421
  # Parse options, then take zero or more bundle positionals (the multi-bundle
422
422
  # server) — directories or @refs. Returns the resolved array (possibly
423
423
  # empty), or nil (after reporting) so the caller returns 2.
424
- def positional_dirs(parser, argv)
424
+ def positional_dirs(parser, argv, expand_groups: false)
425
425
  parser.parse!(argv)
426
- dirs = argv.map { |dir| resolve_ref(dir) }
426
+ dirs = if expand_groups
427
+ argv.flat_map { |arg| resolve_ref_expanding(arg) }
428
+ else
429
+ argv.map { |dir| resolve_ref(dir) }
430
+ end
427
431
  dirs.include?(nil) ? nil : dirs
428
432
  rescue OptionParser::ParseError => e
429
433
  @err.puts e.message
430
434
  nil
431
435
  end
432
436
 
437
+ # Like #resolve_ref, but a group @ref fans out to its member directories —
438
+ # the multi-bundle expansion only `server` wants (single-bundle verbs reject
439
+ # a group in #resolve_registered). Always returns an array of dirs so the
440
+ # caller can flat_map, or nil (reported) to fail the run.
441
+ def resolve_ref_expanding(arg)
442
+ return [ resolve_ref(arg) ] unless arg.start_with?("@")
443
+
444
+ registry = load_registry
445
+ return [ nil ] unless registry
446
+
447
+ slug = OKF::Registry.normalize(arg[1..-1])
448
+ return [ resolve_ref(arg) ] if slug.empty? || registry.group?(slug).nil?
449
+
450
+ group_member_dirs(registry, slug)
451
+ end
452
+
453
+ # A group's member directories, in order, skipping ones whose directory has
454
+ # vanished with the same note `@all` gives — and populating +ref_slugs+ so the
455
+ # hub mounts each under its registered slug. nil (reported) when nothing
456
+ # readable is left, or on a hand-edited cycle.
457
+ def group_member_dirs(registry, slug)
458
+ dirs = []
459
+ registry.expand(slug).each do |entry|
460
+ if File.directory?(entry.path)
461
+ ref_slugs[entry.path] = entry.slug
462
+ dirs << entry.path
463
+ else
464
+ skip_registered(entry)
465
+ end
466
+ end
467
+ return dirs unless dirs.empty?
468
+
469
+ @err.puts "error: @#{slug} resolves to no readable bundle (okf registry list)"
470
+ nil
471
+ rescue OKF::Error => e
472
+ @err.puts "error: #{e.message}"
473
+ nil
474
+ end
475
+
433
476
  # "@slug" — or bare "@", the registry's default — names a registered bundle
434
477
  # wherever a <dir> goes; anything else must be a directory on disk. A
435
478
  # leading @ always means the registry (a directory literally named that way
@@ -452,15 +495,24 @@ module OKF
452
495
  # only `server` and the `registry` verbs rescue one. Returns nil after
453
496
  # reporting, so every caller returns 2.
454
497
  def load_registry
455
- require "okf/registry"
456
- OKF::Registry.load
498
+ open_registry
457
499
  rescue OKF::Error => e
458
500
  @err.puts "error: #{e.message}"
459
501
  nil
460
502
  end
461
503
 
462
- # Resolve one @ref through the registry under $OKF_HOME (default ~/.okf).
463
- # The slug part is normalized
504
+ # The registry a verb resolves against — the single seam that opts the CLI
505
+ # into discovery. `cwd: Dir.pwd` is what makes OKF::Registry.load walk up for
506
+ # a project-local .okf-registry.json; a library caller passing no cwd stays
507
+ # global-only. The registry subcommands and `server` open through here too,
508
+ # so a bare `okf server` inside a repo serves that repo's bundles.
509
+ def open_registry
510
+ require "okf/registry"
511
+ OKF::Registry.load(cwd: Dir.pwd)
512
+ end
513
+
514
+ # Resolve one @ref through the active registry — a discovered project-local
515
+ # one, else the global $OKF_HOME (default ~/.okf). The slug part is normalized
464
516
  # exactly as registration normalized it, so @One finds the bundle
465
517
  # registered from dir One — but never through #slugify's mint-a-name
466
518
  # placeholder, so "@***" is a bad ref rather than whatever is slugged
@@ -486,6 +538,19 @@ module OKF
486
538
 
487
539
  asked = ref[1..-1]
488
540
  slug = OKF::Registry.normalize(asked)
541
+
542
+ # A group is a set, and this verb takes one bundle. Reading its first member
543
+ # would answer confidently about a bundle the user never singled out — the
544
+ # silent-wrong-answer shape the second-bundle rule already forbids — so it is
545
+ # exit 2, with the two verbs that *can* take a group named.
546
+ group = slug.empty? ? nil : registry.group?(slug)
547
+ if group
548
+ count = group.members.size
549
+ @err.puts "error: @#{slug} names a group of #{count} #{count == 1 ? "member" : "members"}; " \
550
+ "only `okf search` and `okf server` take a group"
551
+ return nil
552
+ end
553
+
489
554
  entry = if asked.empty?
490
555
  registry.default # bare "@"
491
556
  elsif slug.empty?
data/lib/okf/cli/graph.rb CHANGED
@@ -15,22 +15,26 @@ module OKF
15
15
 
16
16
  def self.help_rows
17
17
  [
18
- [ "graph <dir|@slug> [--json] [--minimal] [--hubs]", "print the knowledge graph" ]
18
+ [ "graph <dir|@slug> [--json] [--minimal] [--hubs]", "print the knowledge graph" ],
19
+ [ "graph <dir|@slug> --traffic [--cut N]", "directories and the link traffic between them" ]
19
20
  ]
20
21
  end
21
22
 
22
23
  def call(argv)
23
- options = { json: false, minimal: false, body: true, hubs: false }
24
+ options = { json: false, minimal: false, body: true, hubs: false, traffic: false, cut: nil }
24
25
  parser = OptionParser.new do |o|
25
- o.banner = "Usage: okf graph <dir|@slug> [--json] [--minimal] [--no-body] [--hubs]"
26
+ o.banner = "Usage: okf graph <dir|@slug> [--json] [--minimal] [--no-body] [--hubs] [--traffic]"
26
27
  json_flags(o, options, "emit nodes and edges as JSON")
27
28
  o.on("--minimal", "leanest nodes (id + title); adds type/tag indexes") { options[:minimal] = true }
28
29
  o.on("--[no-]body", "include each concept's body (default: yes)") { |v| options[:body] = v }
29
- o.on("--hubs", "rank concepts by inbound links, with the source areas") { options[:hubs] = true }
30
+ o.on("--hubs", "rank concepts by inbound links, with the source top-level dirs") { options[:hubs] = true }
31
+ o.on("--traffic", "collapse concepts into their dirs; count the links between them") { options[:traffic] = true }
32
+ o.on("--cut N", Integer, "least arc weight to draw (default: fitted to the bundle)") { |v| options[:cut] = v }
30
33
  help_flag(o)
31
34
  end
32
35
  dir = positional_dir(parser, argv) or return 2
33
36
 
37
+ return print_traffic(dir, options) if options[:traffic]
34
38
  return print_hubs(dir, options) if options[:hubs]
35
39
 
36
40
  folder = OKF::Bundle::Folder.load(dir)
@@ -52,16 +56,118 @@ module OKF
52
56
 
53
57
  private
54
58
 
59
+ # `graph --traffic`: concepts collapse into the directory they live in, and
60
+ # the links between two directories collapse into one weighted arc. It is
61
+ # the reduction that pays, and the measurement says why: on a typical
62
+ # bundle three quarters of all links cross a directory boundary, and those
63
+ # aggregate roughly ten-to-one — 227 links became 14 arcs on the bundle
64
+ # this was built for.
65
+ #
66
+ # The other half of the view is the cohesion column, which is the reason
67
+ # `refine` wants this. `--hubs` measures concepts; refine's step-3
68
+ # judgements ("does this directory prune?", "concern or container?") are
69
+ # about *directories*, and nothing measured those. Cohesion is a
70
+ # directory's internal traffic over its total, so it reads directly:
71
+ # near-zero with heavy inbound is a shared primitive, heavy outbound with
72
+ # nothing coming back is an index behaving like a container, and high is a
73
+ # directory that genuinely holds together.
74
+ def print_traffic(dir, options)
75
+ folder = OKF::Bundle::Folder.load(dir)
76
+ skeleton = folder.skeleton
77
+ cut = options[:cut] || skeleton.suggested_cut
78
+ return usage_error("--cut must be 1 or more, got #{cut}") if cut < 1
79
+
80
+ report_skipped(folder)
81
+ arcs = OKF::Bundle::Skeleton.arcs_above(skeleton.arcs, cut)
82
+ rows = dir_traffic(skeleton)
83
+ if options[:json]
84
+ emit_json(bundle_head(dir).merge(
85
+ "cut" => cut, "fitted" => options[:cut].nil?, "dirs" => stringify_rows(rows),
86
+ "arcs" => stringify_rows(arcs), "total_arcs" => skeleton.arcs.size
87
+ ))
88
+ else
89
+ @out.puts "Traffic — #{bundle_label(dir)} (#{skeleton.dirs.size} #{pluralize(skeleton.dirs.size, "dir")}, " \
90
+ "#{counted(arcs.size, skeleton.arcs.size, "arc")} at weight #{cut} or more)"
91
+ print_dir_rows(rows)
92
+ print_arc_rows(arcs)
93
+ end
94
+ 0
95
+ end
96
+
97
+ # Each directory's link traffic, split three ways. Counted over *every*
98
+ # arc, never the cut ones: the cut decides what is drawn, and a measurement
99
+ # that moved when the picture was tidied would be worthless as evidence.
100
+ def dir_traffic(skeleton)
101
+ out = Hash.new(0)
102
+ into = Hash.new(0)
103
+ skeleton.arcs.each do |arc|
104
+ out[arc[:source]] += arc[:weight]
105
+ into[arc[:target]] += arc[:weight]
106
+ end
107
+
108
+ skeleton.dirs.map do |row|
109
+ total = row[:internal] + out[row[:dir]] + into[row[:dir]]
110
+ row.merge(out: out[row[:dir]], in: into[row[:dir]],
111
+ cohesion: total.zero? ? nil : (100.0 * row[:internal] / total).round)
112
+ end
113
+ end
114
+
115
+ # An empty bundle gets no table at all — a column header over nothing is a
116
+ # heading that promises rows. The arc list is the opposite case and prints
117
+ # regardless (see print_arc_rows); the difference is that an empty arc list
118
+ # is a fact about the *cut*, and an empty dir table is a fact about the
119
+ # bundle the count line has already stated.
120
+ #
121
+ # Sorted by cohesion ascending, so the directories with a case to answer
122
+ # come first — a table that leads with the healthy ones buries its finding
123
+ # under the rows nobody needed to read. A directory with no traffic at all
124
+ # has no ratio to report and prints `—` rather than a 0% it did not earn.
125
+ def print_dir_rows(rows)
126
+ return if rows.empty?
127
+
128
+ ordered = rows.sort_by { |row| [ row[:cohesion] || 999, row[:dir] ] }
129
+ @out.puts
130
+ labels = ordered.map { |row| dir_label(row[:dir]) }
131
+ width = [ 3, *labels.map(&:length) ].max
132
+ @out.puts " #{"Dir".ljust(width)} Concepts Internal Out In Cohesion"
133
+ ordered.each_with_index do |row, i|
134
+ @out.puts " #{labels[i].ljust(width)} #{row[:count].to_s.rjust(8)} #{row[:internal].to_s.rjust(8)} " \
135
+ "#{row[:out].to_s.rjust(4)} #{row[:in].to_s.rjust(4)} #{(row[:cohesion] ? "#{row[:cohesion]}%" : "—").rjust(8)}"
136
+ end
137
+ end
138
+
139
+ # The arc list is the answer this mode exists for, so it prints even when
140
+ # the cut emptied it — a heading over nothing says "the cut was too tight",
141
+ # where silence reads as "the bundle has no cross-links".
142
+ def print_arc_rows(arcs)
143
+ @out.puts
144
+ @out.puts " Arcs"
145
+ return @out.puts " (none at this cut)" if arcs.empty?
146
+
147
+ width = arcs.map { |arc| dir_label(arc[:source]).length }.max
148
+ twidth = arcs.map { |arc| dir_label(arc[:target]).length }.max
149
+ arcs.each do |arc|
150
+ @out.puts " #{dir_label(arc[:source]).ljust(width)} → #{dir_label(arc[:target]).ljust(twidth)} ×#{arc[:weight]}"
151
+ end
152
+ end
153
+
154
+ # The skeleton is symbol-keyed (it is a pure model, not a payload), and every
155
+ # other --json view in this CLI answers in strings. Converted at the edge, so
156
+ # the model stays the model.
157
+ def stringify_rows(rows)
158
+ rows.map { |row| stringify(row) }
159
+ end
160
+
55
161
  # `graph --hubs`: the inbound ranking with each hub's links grouped by
56
- # source area — the "is this hub well-homed?" evidence. A hub whose
57
- # inbound majority comes from outside its own area is a move candidate;
58
- # --minimal/--no-body shape node payloads and change nothing here.
162
+ # source top-level dir — the "is this hub well-homed?" evidence. A hub whose
163
+ # inbound majority comes from outside its own top-level dir is a move
164
+ # candidate; --minimal/--no-body shape node payloads and change nothing here.
59
165
  def print_hubs(dir, options)
60
166
  folder = OKF::Bundle::Folder.load(dir)
61
167
  hubs = folder.hubs
62
168
  report_skipped(folder)
63
169
  if options[:json]
64
- rows = hubs.map { |row| { "id" => row[:id], "area" => row[:area], "inbound" => row[:inbound], "by_area" => row[:by_area] } }
170
+ rows = hubs.map { |row| { "id" => row[:id], "top_dir" => row[:top_dir], "inbound" => row[:inbound], "by_top_dir" => row[:by_top_dir] } }
65
171
  emit_json(bundle_head(dir).merge("count" => hubs.size, "hubs" => rows))
66
172
  else
67
173
  @out.puts "Hubs — #{bundle_label(dir)} (#{counted(hubs.size, folder.concepts.size, "concept")} with inbound links)"
@@ -69,7 +175,7 @@ module OKF
69
175
  width = hubs.map { |row| row[:id].length }.max || 0
70
176
  dwidth = hubs.map { |row| row[:inbound].to_s.length }.max || 0
71
177
  hubs.each do |row|
72
- sources = row[:by_area].map { |area, count| "#{area} #{count}" }.join(", ")
178
+ sources = row[:by_top_dir].map { |top_dir, count| "#{top_dir} #{count}" }.join(", ")
73
179
  @out.puts " #{row[:id].ljust(width)} ×#{row[:inbound].to_s.rjust(dwidth)} #{sources}"
74
180
  end
75
181
  end