okf 1.11.0 → 1.13.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.
@@ -12,10 +12,17 @@ module OKF
12
12
  # cannot open at all — is retained as an unparseable entry (carrying the
13
13
  # ParseError message or the errno, so §9.1 can report it) rather than dropped
14
14
  # or raised. That tolerance is the whole §9 best-effort promise: one bad file
15
- # never breaks the rest, and this is the read every verb shares. Every read
16
- # goes through Path.join_under! so a symlinked or crafted path cannot escape
17
- # the bundle root — that guard still raises, because a path leaving the root
18
- # is not a bad file, it is a bundle lying about its shape.
15
+ # never breaks the rest, and this is the read every verb shares.
16
+ #
17
+ # Containment is enforced twice, because the two ways out of the root are
18
+ # different. A crafted *path* (`..`, an absolute string) is caught lexically
19
+ # by Path.join_under!. A *symlink* whose name sits inside the root but whose
20
+ # target does not cannot be seen lexically — File.expand_path does not
21
+ # resolve links — so each file is also realpath-resolved and its real
22
+ # location checked against the real root before a byte is read. An escaping
23
+ # file joins the unparseable bucket rather than raising: a planted symlink is
24
+ # one bad file, and letting it take down the whole bundle read would hand any
25
+ # writer of a served directory a denial of service. §9.1 then names it.
19
26
  class Reader
20
27
  def self.read(dir)
21
28
  new(dir).read
@@ -32,9 +39,22 @@ module OKF
32
39
  reserved = []
33
40
  unparseable = []
34
41
 
35
- markdown_paths.each do |path|
42
+ paths = markdown_paths
43
+ # Resolved once for the whole loop, but never at the cost of the
44
+ # best-effort promise: if the root itself has become unreadable since
45
+ # the glob, this stays nil and each file's own SafeRead call raises
46
+ # inside the per-file rescue below — one bad bundle degrades to
47
+ # unparseable entries, it does not crash the read every verb shares.
48
+ real_root = begin
49
+ File.realpath(@root) unless paths.empty?
50
+ rescue SystemCallError
51
+ nil
52
+ end
53
+
54
+ paths.each do |path|
36
55
  begin
37
- content = File.read(Path.join_under!(@root, path), encoding: "UTF-8")
56
+ absolute = Path.join_under!(@root, path)
57
+ content = SafeRead.read!(@root, absolute, real_root: real_root)
38
58
  if Concept.reserved?(path)
39
59
  reserved << Entry.new(path: path, content: content)
40
60
  else
@@ -43,18 +63,21 @@ module OKF
43
63
  end
44
64
  rescue Markdown::Frontmatter::ParseError => e
45
65
  unparseable << Entry.new(path: path, content: content, error: e.message)
46
- rescue SystemCallError => e
47
- # A file that cannot be opened is one unusable file, not a broken
48
- # bundle. Letting the errno out of here breaks "one bad file never
49
- # breaks the rest" for every verb at once — the read is the one path
50
- # they all share — and it breaks it in the worst way: a backtrace,
51
- # under an exit code that claims the bundle is non-conformant. So it
52
- # joins the same bucket a bad frontmatter block does, and §9.1 reports
53
- # it naming the file and the errno.
66
+ rescue Path::Error, SystemCallError => e
67
+ # A file we cannot safely read is one unusable file, not a broken
68
+ # bundle: an errno on open, or a path that leaves the root — lexically
69
+ # (`..`, an absolute string) or through a symlink whose target escapes
70
+ # it. Letting either out of here breaks "one bad file never breaks the
71
+ # rest" for every verb at once — the read is the one path they all
72
+ # share — and in the worst way: a backtrace under an exit code that
73
+ # claims non-conformance, or a served bundle taken down by one planted
74
+ # symlink. So it joins the same bucket a bad frontmatter block does,
75
+ # and §9.1 reports it naming the file and the reason.
54
76
  #
55
77
  # Its content is "" rather than nil: unknown, but every analyzer reads
56
- # it as text, and empty is the honest shape of a file we never saw —
57
- # no links to resolve, no encoding to be invalid, nothing claimed.
78
+ # it as text, and empty is the honest shape of a file we never read —
79
+ # no links to resolve, nothing claimed, and for a symlink escape, none
80
+ # of the target's bytes.
58
81
  unparseable << Entry.new(path: path, content: "", error: e.message)
59
82
  end
60
83
  end
@@ -279,7 +279,7 @@ module OKF
279
279
  end
280
280
 
281
281
  # Ranked match rows, catalog-style identity plus where the terms hit:
282
- # [{ slug:, id:, title:, type:, dir:, area:, tags:, matched: [field, …], score:, snippet: }, …]
282
+ # [{ slug:, id:, title:, type:, dir:, top_dir:, tags:, matched: [field, …], score:, snippet: }, …]
283
283
  # ordered by score descending, then slug, then id. `slug` is present only
284
284
  # when searching across bundles. No terms means no matches.
285
285
  def results
@@ -367,7 +367,7 @@ module OKF
367
367
  title: (concept.title || concept.id).to_s,
368
368
  type: concept.type.to_s,
369
369
  dir: OKF.dir_of(concept.id),
370
- area: area_of(concept.id),
370
+ top_dir: top_dir_of(concept.id),
371
371
  tags: Array(concept.tags).map(&:to_s),
372
372
  matched: matched,
373
373
  score: score.round(4),
@@ -421,9 +421,9 @@ module OKF
421
421
  end
422
422
  end
423
423
 
424
- # A concept's top-level area, mirroring the catalog's definition. Deprecated
425
- # in favour of OKF.dir_of, which keeps the levels this one throws away.
426
- def area_of(id)
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)
427
427
  id.include?("/") ? id.split("/").first : "(root)"
428
428
  end
429
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,
@@ -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
 
@@ -156,6 +160,18 @@ module OKF
156
160
  # `id` must not move a concept out of the directory it lives in. Pure: derived
157
161
  # from the concepts and the reserved index text, no disk. Shared by the
158
162
  # `okf index` view and the server's Index panel (/index).
163
+ # Every directory this bundle has — the same set #directory_index enumerates
164
+ # (concepts, an index.md or a log.md, plus every ancestor), without building
165
+ # the map. It is the answer to "does this bundle have a directory named X?",
166
+ # and the CLI needs exactly that to decide whether `--dir root` names a real
167
+ # directory or the bundle root. Reading it off #catalog instead is the same
168
+ # question asked of a smaller set, which is how the two views came to
169
+ # disagree about one bundle. Memoized: the model is immutable once read,
170
+ # and the resolvers above ask per invocation, not per bundle load.
171
+ def directories
172
+ @directories ||= directory_set(concepts.map { |concept| File.dirname(concept.path) }.uniq)
173
+ end
174
+
159
175
  def directory_index
160
176
  by_dir = concepts.group_by { |concept| File.dirname(concept.path) }
161
177
  dirs = directory_set(by_dir.keys)
@@ -189,17 +205,21 @@ module OKF
189
205
 
190
206
  private
191
207
 
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)
208
+ # A concept's top-level dir, derived from its id — the first path segment, the
209
+ # same derivation the catalog exposes, so every grouped view labels the bundle
210
+ # root "(root)". OKF.dir_of keeps the levels this one rolls up.
211
+ def top_dir_of(id)
195
212
  id.include?("/") ? id.split("/").first : "(root)"
196
213
  end
197
214
 
198
- # Every directory to show: those holding concepts or an index.md, plus each of
199
- # their ancestors up to the root, so the subdir tree stays connected even when
200
- # an intermediate directory holds nothing directly. Sorted with "." first.
215
+ # Every directory to show: those holding concepts, an index.md or a log.md,
216
+ # plus each of their ancestors up to the root, so the subdir tree stays
217
+ # connected even when an intermediate directory holds nothing directly. A
218
+ # scoped log counts because `okf log` reads it — a directory whose only file
219
+ # is its history still exists, and leaving it out is how the `root` alias
220
+ # beat a real `root/` for the second file kind in a row. Sorted "." first.
201
221
  def directory_set(concept_dirs)
202
- seed = concept_dirs + index_files.map { |path| File.dirname(path) }
222
+ seed = concept_dirs + (index_files + log_files).map { |path| File.dirname(path) }
203
223
  dirs = {}
204
224
  seed.each do |dir|
205
225
  current = dir
@@ -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
 
@@ -33,7 +33,7 @@ module OKF
33
33
  folder = OKF::Bundle::Folder.load(dir)
34
34
  report_skipped(folder)
35
35
  entries = folder.catalog
36
- selected = filter_entries(entries, options)
36
+ selected = filter_entries(entries, options, dir_scope(folder, options))
37
37
  return print_catalog_json(dir, selected, options) if options[:json]
38
38
 
39
39
  print_catalog(dir, selected, entries.size)
@@ -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(" · ")