okf 1.8.0 → 1.9.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.
@@ -2,24 +2,97 @@
2
2
 
3
3
  module OKF
4
4
  class Bundle
5
- # Deterministic text retrieval over an in-memory bundle the browser page's
6
- # search brought server-side and extended to bodies. Terms are ANDed: every
7
- # term must hit at least one searched field, though not necessarily the same
8
- # one. A term is a case-insensitive substring, or a Ruby regular expression
9
- # with `regexp: true`. Matches rank by where they hit (a title hit outranks a
10
- # body hit) and carry one bounded context snippet, so answering "which concept
11
- # covers X?" costs a row, not a body read.
5
+ # Ranked text retrieval over one or more in-memory bundles. Terms are ANDed:
6
+ # every term must hit at least one searched field, though not necessarily the
7
+ # same one. Rows carry the fields each term hit, so a result stays explainable
8
+ # rather than being a bare relevance number.
12
9
  #
13
- # Deliberately not fuzzy: the consuming agent is the fuzzy layer synonyms
14
- # and vocabulary drift are judgment over the index map, not string distance.
10
+ # This class is a *facade*. It owns everything that defines what a result is
11
+ # the documents, the row and its key order, the snippet window, the final sort
12
+ # — and delegates only "which documents match, how well, and where" to an
13
+ # engine (Search::Index by default, Search::Scan for regexp). An engine that
14
+ # built its own rows could disagree about what a match is; this split makes
15
+ # that unrepresentable.
15
16
  #
16
- # Pure — no disk, no stdio. The CLI's `okf search` and any embedding app share
17
- # it: OKF::Bundle::Search.call(bundle, [ "dedup", "key" ]).
17
+ # Pure — no disk, no stdio. The CLI's `okf search` and any embedding app
18
+ # share it: OKF::Bundle::Search.call(bundle, [ "dedup", "key" ]).
18
19
  class Search
20
+ # Raised when the query needs something the engine cannot do — either the
21
+ # one that was named, or any that is available. Carries structured data
22
+ # rather than a finished sentence, because the shell says "--regexp" where
23
+ # the core says ":regexp"; the CLI formats it and exits 2.
24
+ class UnsupportedQuery < OKF::Error
25
+ attr_reader :missing, :engine
26
+
27
+ def initialize(missing, engine: nil)
28
+ @missing = missing
29
+ @engine = engine
30
+ super(build_message(missing, engine))
31
+ end
32
+
33
+ private
34
+
35
+ def build_message(missing, engine)
36
+ return "no search engine is available" if missing.empty?
37
+
38
+ offered = missing.map { |name| ":#{name}" }.join(", ")
39
+ engine.nil? ? "no available search engine offers #{offered}" : "engine #{engine} does not offer #{offered}"
40
+ end
41
+ end
42
+
43
+ # Raised when `--engine` names something that is not on offer. An engine
44
+ # registered but reporting `available? == false` is absent from the list for
45
+ # the same reason it is absent from routing: it cannot answer. A future
46
+ # addon whose native build failed will want a kinder message than this one.
47
+ class UnknownEngine < OKF::Error
48
+ attr_reader :name, :available
49
+
50
+ def initialize(name, available)
51
+ @name = name
52
+ @available = available
53
+ super("unknown search engine: #{name} (available: #{available.join(", ")})")
54
+ end
55
+ end
56
+
57
+ # The **declarable** vocabulary: what an engine may claim about itself.
58
+ # Frozen so an engine declaring `:regex` is refused at registration rather
59
+ # than silently never selected — a typo in an addon would otherwise present
60
+ # as "my engine is ignored".
61
+ #
62
+ # `:prefix` lives here and *not* in ROUTABLE on purpose. Nothing asks for
63
+ # its absence, so it selects nothing; what it does is document that this
64
+ # engine grows a term to the tokens it prefixes, which an FTS5 engine may
65
+ # not do by default. Declarative, and honest about being declarative.
66
+ CAPABILITIES = %i[regexp fuzzy prefix].freeze
67
+
68
+ # The **routable** subset: the capabilities a query can actually require,
69
+ # and therefore the only ones that pick an engine. Kept distinct from
70
+ # CAPABILITIES because a capability nothing selects on, filed among the ones
71
+ # that do, is documentation posing as code.
72
+ #
73
+ # Each entry is also the option name the facade hands an engine that
74
+ # declares it — see #engine_options, which is what keeps a meaningful
75
+ # option from reaching an engine that would quietly drop it.
76
+ ROUTABLE = %i[regexp fuzzy].freeze
77
+
78
+ # Chosen when the query requires nothing in particular, which is the
79
+ # overwhelming majority of searches.
80
+ #
81
+ # The scan, not the index, because a one-shot CLI builds an index, asks one
82
+ # question and exits — a build with a single query to amortize it over.
83
+ # Measured end to end: 3.00s vs 0.24s at 1,000 concepts, 0.83s vs 0.18s at
84
+ # 250, and the gap widens with the bundle. Raw-text matching also carries no
85
+ # tokenizer, so the terms that are glued to symbols and therefore
86
+ # unreachable by token (`minifts`, $OKF_HOME) stay findable by default.
87
+ #
88
+ # What it gives up is BM25+ ranking, reachable with `--engine index` — and
89
+ # that is also the engine the browser page runs, so the two rank alike only
90
+ # when the index is named. See .okf/design/search-engines.md.
91
+ DEFAULT_ENGINE = :scan
92
+
19
93
  # The searchable fields with their rank weight, strongest signal first.
20
- # A concept's score sums the weights of the fields that matched; hitting a
21
- # field twice does not stack. Tags match against the space-joined list,
22
- # mirroring the server page's haystack.
94
+ # In the index engine these ride as MiniFTS per-field `boost`; the scan
95
+ # sums the weights of the fields that matched instead.
23
96
  WEIGHTS = {
24
97
  "title" => 5,
25
98
  "id" => 4,
@@ -38,53 +111,165 @@ module OKF
38
111
  # Characters of context kept on each side of the first matched term.
39
112
  SNIPPET_RADIUS = 44
40
113
 
41
- def self.call(bundle, terms, fields: nil, regexp: false)
42
- new(bundle, terms, fields: fields, regexp: regexp).results
114
+ # Edit distance as a fraction of term length, under `fuzzy: true` — the
115
+ # same 0.2 the browser page passes, so both forgive the same typos.
116
+ FUZZY_DISTANCE = 0.2
117
+
118
+ # The unique document key is "<slug>\0<id>": ids are only unique *within* a
119
+ # bundle, and a merge that collided two bundles' same-named concepts would
120
+ # silently drop one.
121
+ KEY_SEPARATOR = "\0"
122
+
123
+ # Append-only and idempotent by id: a second registration of an id already
124
+ # present is a no-op, so a double `require` cannot double the registry and
125
+ # an addon cannot quietly displace a built-in. Deliberately the same shape
126
+ # as the Linter's planned register hook — two extension points, one idiom.
127
+ def self.register(engine)
128
+ rogue = engine.capabilities - CAPABILITIES
129
+ raise ArgumentError, "unknown search capability: #{rogue.join(", ")}" unless rogue.empty?
130
+
131
+ @engines ||= []
132
+ @engines << engine unless @engines.any? { |registered| registered.id == engine.id }
133
+ engine
134
+ end
135
+
136
+ # A frozen snapshot in registration order. Frozen because the registry is
137
+ # only meant to grow through .register, where the vocabulary is checked.
138
+ def self.engines
139
+ (@engines ||= []).dup.freeze
140
+ end
141
+
142
+ # The router. Naming an engine is an override, not a hint: it is how a
143
+ # caller reaches semantics no capability flag asks for — `--engine scan`
144
+ # means "match raw text", which the flags cannot express because there is
145
+ # nothing to *require*. A named engine that cannot do what was also asked
146
+ # is an error rather than a silent fallback, since falling back would answer
147
+ # a different question than the one that was posed.
148
+ #
149
+ # Unnamed, the default engine leads, then registration order; the first
150
+ # available engine offering *every* required capability answers. Partition
151
+ # rather than sort_by, because sort_by is not stable and registration order
152
+ # is the tie-break.
153
+ def self.engine_for(required, engines: self.engines, name: nil)
154
+ available = engines.select(&:available?)
155
+ return named_engine(name, required, available) unless OKF.blank?(name)
156
+
157
+ default, rest = available.partition { |engine| engine.id == DEFAULT_ENGINE }
158
+ found = (default + rest).find { |engine| (required - engine.capabilities).empty? }
159
+ return found if found
160
+
161
+ raise UnsupportedQuery, required
162
+ end
163
+
164
+ def self.named_engine(name, required, available)
165
+ wanted = name.to_s.downcase
166
+ found = available.find { |engine| engine.id.to_s == wanted }
167
+ raise UnknownEngine.new(name, available.map(&:id)) if found.nil?
168
+
169
+ missing = required - found.capabilities
170
+ raise UnsupportedQuery.new(missing, engine: found.id) unless missing.empty?
171
+
172
+ found
173
+ end
174
+ private_class_method :named_engine
175
+
176
+ def self.call(bundle, terms, fields: nil, regexp: false, fuzzy: false, engine: nil, engines: nil)
177
+ new([ [ nil, bundle ] ], terms, fields: fields, regexp: regexp, fuzzy: fuzzy, engine: engine, engines: engines).results
178
+ end
179
+
180
+ # Several bundles as [ slug, bundle ] pairs, ranked into one list with every
181
+ # row labeled by its slug. They share **one** index on purpose: BM25 weighs a
182
+ # term by how rare it is in the corpus, so per-bundle indexes would score the
183
+ # same match differently depending on which bundle it came from. One index
184
+ # makes one corpus, and the merged ranking is comparable by construction.
185
+ def self.across(bundles, terms, fields: nil, regexp: false, fuzzy: false, engine: nil, engines: nil)
186
+ new(bundles, terms, fields: fields, regexp: regexp, fuzzy: fuzzy, engine: engine, engines: engines).results
43
187
  end
44
188
 
45
- # Raises RegexpError on an invalid pattern with `regexp: true` — the caller
46
- # owns turning that into a usage error.
47
- def initialize(bundle, terms, fields: nil, regexp: false)
48
- @bundle = bundle
49
- raw = Array(terms).reject { |term| OKF.blank?(term) }
50
- @matchers = raw.map { |term| regexp ? Regexp.new(term.to_s, Regexp::IGNORECASE) : term.to_s.downcase }
189
+ # Raises RegexpError on an invalid pattern with `regexp: true`, and
190
+ # UnsupportedQuery when no engine can answer — the caller owns turning
191
+ # either into a usage error. `engines:` overrides the registry, which is how
192
+ # 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)
194
+ @bundles = bundles
195
+ @terms = Array(terms).reject { |term| OKF.blank?(term) }.map(&:to_s)
51
196
  @fields = fields.nil? || fields.empty? ? FIELDS : fields
197
+ @regexp = regexp
198
+ @fuzzy = fuzzy
199
+ @engine = engine
200
+ @engines = engines
201
+ @sources = {}
52
202
  end
53
203
 
54
204
  # Ranked match rows, catalog-style identity plus where the terms hit:
55
- # [{ id:, title:, type:, area:, tags:, matched: [field, …], score:, snippet: }, …]
56
- # ordered by score descending, then id. No terms means no matches.
205
+ # [{ slug:, id:, title:, type:, area:, tags:, matched: [field, …], score:, snippet: }, …]
206
+ # ordered by score descending, then slug, then id. `slug` is present only
207
+ # when searching across bundles. No terms means no matches.
57
208
  def results
58
- return [] if @matchers.empty?
209
+ return [] if @terms.empty?
59
210
 
60
- @bundle.concepts
61
- .map { |concept| match(concept) }
62
- .compact
63
- .sort_by { |row| [ -row[:score], row[:id] ] }
211
+ chosen = engine
212
+ rows = chosen.call(documents, @terms, **engine_options(chosen)).map do |hit|
213
+ slug, concept = @sources[hit[:key]]
214
+ row(slug, concept, hit[:matched], hit[:score], hit[:terms])
215
+ end
216
+ rows.sort_by { |row| [ -row[:score], row[:slug].to_s, row[:id] ] }
64
217
  end
65
218
 
66
219
  private
67
220
 
68
- def match(concept)
69
- texts = searchable_texts(concept)
70
- matched = matched_fields(texts)
71
- return nil if matched.nil?
221
+ # The engine is chosen by what the query needs, not by a flag naming one.
222
+ # `-e` unambiguously means "regexp semantics", so it routes on its own and
223
+ # says nothing about it — there is no --engine flag to reconcile with.
224
+ def engine
225
+ Search.engine_for(required_capabilities, engines: @engines || Search.engines, name: @engine)
226
+ end
72
227
 
73
- {
74
- id: concept.id,
75
- title: (concept.title || concept.id).to_s,
76
- type: concept.type.to_s,
77
- area: area_of(concept.id),
78
- tags: Array(concept.tags).map(&:to_s),
79
- matched: matched,
80
- score: matched.map { |field| WEIGHTS[field] }.reduce(0, :+),
81
- snippet: snippet(texts, matched)
82
- }
228
+ # What the query requires, in the routable vocabulary. `:prefix` never
229
+ # appears: it is declarable, not routable — nothing asks for its absence.
230
+ def required_capabilities
231
+ ROUTABLE.select { |capability| requested[capability] }
232
+ end
233
+
234
+ # `fields:` always, plus exactly the routable options the chosen engine
235
+ # declared it understands.
236
+ #
237
+ # The facade used to hand every engine every option and trust it to ignore
238
+ # what it could not use. Routing makes that harmless in practice — a fuzzy
239
+ # query only ever reaches a :fuzzy engine — but "harmless because something
240
+ # else prevents it" is precisely how an option comes to be dropped in
241
+ # silence the day that something else changes. An engine now receives only
242
+ # what it can act on, so there is nothing left for it to ignore.
243
+ def engine_options(chosen)
244
+ options = { fields: @fields }
245
+ ROUTABLE.each do |capability|
246
+ options[capability] = requested[capability] if chosen.capabilities.include?(capability)
247
+ end
248
+ options
249
+ end
250
+
251
+ def requested
252
+ @requested ||= { regexp: @regexp, fuzzy: @fuzzy }
253
+ end
254
+
255
+ # Every concept as an indexable document, keyed uniquely across bundles.
256
+ # @sources keeps the way back, so the index stores no fields of its own.
257
+ def documents
258
+ docs = []
259
+ @bundles.each do |slug, bundle|
260
+ bundle.concepts.each do |concept|
261
+ key = "#{slug}#{KEY_SEPARATOR}#{concept.id}"
262
+ @sources[key] = [ slug, concept ]
263
+ docs << field_texts(concept).merge("key" => key)
264
+ end
265
+ end
266
+ docs
83
267
  end
84
268
 
85
- # { field => original-case text } for the fields this search reads.
86
- def searchable_texts(concept)
87
- texts = {
269
+ # { field => original-case text } for every searchable field. The index reads
270
+ # all of them; `fields:` narrows the search, not the document.
271
+ def field_texts(concept)
272
+ {
88
273
  "id" => concept.id,
89
274
  "title" => concept.title.to_s,
90
275
  "type" => concept.type.to_s,
@@ -92,35 +277,46 @@ module OKF
92
277
  "tags" => Array(concept.tags).join(" "),
93
278
  "body" => concept.body
94
279
  }
95
- texts.each_with_object({}) do |(field, text), acc|
96
- acc[field] = text if @fields.include?(field)
97
- end
98
- end
99
-
100
- # The union of fields any term hit, in WEIGHTS order — or nil when some term
101
- # hit nothing (terms are ANDed).
102
- def matched_fields(texts)
103
- hits = @matchers.map do |matcher|
104
- fields = texts.keys.select { |field| hit?(matcher, texts[field]) }
105
- return nil if fields.empty?
106
-
107
- fields
108
- end
109
- FIELDS.select { |field| hits.flatten.include?(field) }
110
280
  end
111
281
 
112
- def hit?(matcher, text)
113
- matcher.is_a?(Regexp) ? matcher.match?(text) : text.downcase.include?(matcher)
282
+ # `slug` leads the row so a merged result reads bundle-first, and drops
283
+ # entirely for a single bundle, which has no slug to carry.
284
+ def row(slug, concept, matched, score, terms)
285
+ texts = field_texts(concept)
286
+ built = {
287
+ slug: slug,
288
+ id: concept.id,
289
+ title: (concept.title || concept.id).to_s,
290
+ type: concept.type.to_s,
291
+ area: area_of(concept.id),
292
+ tags: Array(concept.tags).map(&:to_s),
293
+ matched: matched,
294
+ score: score.round(4),
295
+ snippet: snippet(texts, matched, terms)
296
+ }
297
+ built.delete(:slug) if slug.nil?
298
+ built
114
299
  end
115
300
 
116
301
  # One bounded context window around the first term that hit the strongest
117
302
  # snippet-worthy field; "" when the match needs no context (id/title/type/tags).
118
- def snippet(texts, matched)
303
+ def snippet(texts, matched, terms)
119
304
  field = SNIPPET_FIELDS.find { |candidate| matched.include?(candidate) }
120
305
  return "" if field.nil?
121
306
 
122
- matcher = @matchers.find { |candidate| hit?(candidate, texts[field]) }
123
- context(texts[field], matcher)
307
+ matcher = snippet_matcher(texts[field], terms)
308
+ matcher.nil? ? "" : context(texts[field], matcher)
309
+ end
310
+
311
+ # What to point the window at: the first of the engine's reported matchers
312
+ # that this text actually contains. Engine-agnostic on purpose — the scan
313
+ # reports compiled patterns, the index reports lowercased document terms,
314
+ # and both are things `locate` can find again in the flattened text.
315
+ def snippet_matcher(text, terms)
316
+ down = text.downcase
317
+ Array(terms).find do |term|
318
+ term.is_a?(Regexp) ? term.match?(text) : down.include?(term)
319
+ end
124
320
  end
125
321
 
126
322
  def context(text, matcher)
data/lib/okf/bundle.rb CHANGED
@@ -111,7 +111,7 @@ module OKF
111
111
  id = concept.id
112
112
  {
113
113
  id: id,
114
- title: (concept.title || id).to_s,
114
+ title: OKF.blank?(concept.title) ? File.basename(id) : concept.title.to_s,
115
115
  type: concept.type.to_s,
116
116
  description: concept.description.to_s,
117
117
  tags: Array(concept.tags).map(&:to_s),
@@ -159,7 +159,7 @@ module OKF
159
159
  listing: here.map do |concept|
160
160
  {
161
161
  id: concept.id,
162
- title: (concept.title || concept.id).to_s,
162
+ title: OKF.blank?(concept.title) ? File.basename(concept.id) : concept.title.to_s,
163
163
  description: concept.description.to_s,
164
164
  type: concept.type.to_s,
165
165
  tags: Array(concept.tags).map(&:to_s)
data/lib/okf/cli.rb CHANGED
@@ -92,6 +92,12 @@ module OKF
92
92
  # together.
93
93
  ALL_REF = "@all"
94
94
 
95
+ # The core raises `:regexp`; a user typed `--regexp`. Translating here keeps
96
+ # the flag vocabulary in the shell, where it belongs, and lets the message end
97
+ # with the fix rather than only the complaint: an engine that *can* do what was
98
+ # asked is named, so the next command is obvious.
99
+ CAPABILITY_FLAGS = { regexp: "--regexp", fuzzy: "--fuzzy" }.freeze
100
+
95
101
  # The row shape each list view emits, so `--fields`/`--except` can be checked
96
102
  # against a name even when the result is empty. Without it the typo guard
97
103
  # keyed off the data: `--fields bogus` was a usage error against a bundle
@@ -183,20 +189,27 @@ module OKF
183
189
  0
184
190
  end
185
191
 
186
- # Deterministic text retrieval — the browser page's search brought to the CLI
187
- # and extended to bodies. Terms after the directory are ANDed case-insensitive
188
- # substrings (Ruby regexps with --regexp); rows rank by where they hit (title >
189
- # id > tags > type/description > body) and carry one bounded context snippet,
190
- # so "which concept covers X?" costs a few rows, not a body read. Advisory
191
- # read: exit 0 even with no matches. Deliberately not fuzzy the consuming
192
- # agent is the fuzzy layer.
192
+ # Ranked text retrieval — the browser page's search brought to the CLI on the
193
+ # same engine (a MiniFTS index) and extended to bodies. Terms after the
194
+ # directory are ANDed tokens, matched whole or by prefix (Ruby regexps with
195
+ # --regexp, typo tolerance with --fuzzy); rows rank by BM25+ weighted toward
196
+ # where they hit (title > id > tags > type/description > body) and carry one
197
+ # bounded context snippet, so "which concept covers X?" costs a few rows, not
198
+ # a body read. Advisory read: exit 0 even with no matches. Exact by default —
199
+ # the consuming agent is the fuzzy layer, until it asks not to be.
193
200
  def search(argv)
194
- options = { json: false, regexp: false }
201
+ options = { json: false, regexp: false, fuzzy: false, engine: nil }
195
202
  parser = OptionParser.new do |o|
196
- o.banner = "Usage: okf search <dir|@slug…|@all> <term> [term ...] [--regexp] [--in FIELDS] [--type T] [--area A] [--tag T] [--json]"
203
+ o.banner = "Usage: okf search <dir|@slug…|@all> <term…> [--engine NAME] [--regexp|--fuzzy] [--in FIELDS] [--type T] [--area A] [--tag T] [--json]"
204
+ search_engine_note(o)
197
205
  json_flags(o, options, "emit the matches as JSON")
198
206
  projection_flags(o, options)
199
- o.on("-e", "--regexp", "treat each term as a Ruby regular expression (case-insensitive)") { options[:regexp] = true }
207
+ o.on("-e", "--regexp", "read each term as a Ruby regular expression rather",
208
+ "than literal text — case-insensitive (scan engine)") { options[:regexp] = true }
209
+ o.on("--fuzzy",
210
+ "tolerate typos, edit distance #{OKF::Bundle::Search::FUZZY_DISTANCE} × term length (index engine)") { options[:fuzzy] = true }
211
+ o.on("--engine NAME", "match with this engine instead of the default",
212
+ "(#{engine_names}) — index is BM25+ ranked, token-based") { |v| options[:engine] = v }
200
213
  o.on("--in LIST", Array, "search only these fields (#{OKF::Bundle::Search::FIELDS.join(", ")})") { |v| options[:in] = v.map(&:downcase) }
201
214
  filter_flags(o, options, :type, :area, :tag)
202
215
  help_flag(o)
@@ -237,11 +250,19 @@ module OKF
237
250
  unknown = Array(options[:in]) - OKF::Bundle::Search::FIELDS
238
251
  return usage_error("unknown field(s): #{unknown.join(", ")} (searchable: #{OKF::Bundle::Search::FIELDS.join(", ")})") unless unknown.empty?
239
252
 
253
+ # Two query languages, not two dials on one: a regexp is matched against raw
254
+ # text, --fuzzy is an edit distance over indexed tokens. Silently honouring
255
+ # one and dropping the other would answer a question nobody asked.
256
+ if options[:regexp] && options[:fuzzy]
257
+ return usage_error("--regexp and --fuzzy are mutually exclusive (a pattern is matched literally, not by edit distance)")
258
+ end
259
+
240
260
  return multi_search(pairs, terms, options) if pairs
241
261
 
242
262
  folder = OKF::Bundle::Folder.load(dir)
243
263
  report_skipped(folder)
244
- rows = OKF::Bundle::Search.call(folder.bundle, terms, fields: options[:in], regexp: options[:regexp])
264
+ rows = OKF::Bundle::Search.call(folder.bundle, terms, fields: options[:in], regexp: options[:regexp],
265
+ fuzzy: options[:fuzzy], engine: options[:engine])
245
266
  keep = filter_ids(folder, options)
246
267
  rows = rows.select { |row| keep.include?(row[:id]) } unless keep.nil?
247
268
  return print_search_json(dir, terms, rows, options) if options[:json]
@@ -250,6 +271,10 @@ module OKF
250
271
  0
251
272
  rescue RegexpError => e
252
273
  usage_error("invalid pattern: #{e.message}")
274
+ rescue OKF::Bundle::Search::UnknownEngine => e
275
+ usage_error(e.message)
276
+ rescue OKF::Bundle::Search::UnsupportedQuery => e
277
+ usage_error(unsupported_query_message(e))
253
278
  end
254
279
 
255
280
  # Every registered bundle, as [slug, dir] pairs — what @all expands to.
@@ -324,22 +349,29 @@ module OKF
324
349
  [ [ ref_slugs[path], path ] ]
325
350
  end
326
351
 
327
- # Search each bundle with the same terms and merge the rankings scores are
328
- # absolute term weights, so they compare across bundles every row labeled
329
- # with its bundle's slug and ties broken deterministically.
352
+ # Search every bundle at once and merge the rankings, each row labeled with
353
+ # its bundle's slug. The bundles go in as *one* corpus rather than one search
354
+ # each: BM25 weighs a term by how rare it is, so ranking each bundle on its own
355
+ # statistics and then interleaving the lists would let the same match score
356
+ # differently for no reason a reader could see. One index, one ranking.
357
+ #
358
+ # Filters stay per-bundle — they are per-folder questions — so they apply to
359
+ # the merged rows by (slug, id) afterwards.
330
360
  def multi_search(pairs, terms, options)
331
- rows = []
361
+ bundles = []
362
+ keeps = {}
332
363
  total = 0
333
364
  pairs.each do |slug, dir|
334
365
  folder = OKF::Bundle::Folder.load(dir)
335
366
  report_skipped(folder)
336
367
  total += folder.bundle.concepts.size
337
- found = OKF::Bundle::Search.call(folder.bundle, terms, fields: options[:in], regexp: options[:regexp])
368
+ bundles << [ slug, folder.bundle ]
338
369
  keep = filter_ids(folder, options)
339
- found = found.select { |row| keep.include?(row[:id]) } unless keep.nil?
340
- found.each { |row| rows << { slug: slug }.merge(row) }
370
+ keeps[slug] = keep unless keep.nil?
341
371
  end
342
- rows.sort_by! { |row| [ -row[:score], row[:slug], row[:id] ] }
372
+ rows = OKF::Bundle::Search.across(bundles, terms, fields: options[:in], regexp: options[:regexp],
373
+ fuzzy: options[:fuzzy], engine: options[:engine])
374
+ rows = rows.select { |row| !keeps.key?(row[:slug]) || keeps[row[:slug]].include?(row[:id]) }
343
375
  return print_multi_search_json(pairs, terms, rows, options) if options[:json]
344
376
 
345
377
  print_multi_search(pairs, terms, rows, total)
@@ -400,7 +432,7 @@ module OKF
400
432
  o.on("--bind ADDR", "address to bind (default #{options[:bind]})") { |v| options[:bind] = v }
401
433
  o.on("-t", "--title TITLE", "graph title, single bundle only (default: parent/bundle dir name)") { |v| options[:title] = v }
402
434
  o.on("-l", "--link URL", "source URL shown in the header, single bundle only") { |v| options[:link] = v }
403
- o.on("--layout NAME", OKF::Server::Graph::LAYOUTS, "initial layout (#{OKF::Server::Graph::LAYOUTS.join(", ")})") { |v| options[:layout] = v }
435
+ o.on("--layout NAME", OKF::Render::Graph::LAYOUTS, "initial layout (#{OKF::Render::Graph::LAYOUTS.join(", ")})") { |v| options[:layout] = v }
404
436
  help_flag(o)
405
437
  end
406
438
  dirs = positional_dirs(parser, argv) or return 2
@@ -531,7 +563,7 @@ module OKF
531
563
  # self-contained HTML file (bodies, catalog, index, logs baked in, no server
532
564
  # needed — e.g. hosting on GitHub Pages). Prints to stdout unless -o is given.
533
565
  def render(argv)
534
- require "okf/server/app"
566
+ require "okf/render/graph"
535
567
 
536
568
  options = { output: nil, title: nil, link: nil, layout: "cose" }
537
569
  parser = OptionParser.new do |o|
@@ -539,14 +571,14 @@ module OKF
539
571
  o.on("-o", "--output FILE", "write to FILE instead of stdout") { |v| options[:output] = v }
540
572
  o.on("-t", "--title TITLE", "graph title (default: parent/bundle dir name)") { |v| options[:title] = v }
541
573
  o.on("-l", "--link URL", "source URL shown in the header") { |v| options[:link] = v }
542
- o.on("--layout NAME", OKF::Server::Graph::LAYOUTS, "initial layout (#{OKF::Server::Graph::LAYOUTS.join(", ")})") { |v| options[:layout] = v }
574
+ o.on("--layout NAME", OKF::Render::Graph::LAYOUTS, "initial layout (#{OKF::Render::Graph::LAYOUTS.join(", ")})") { |v| options[:layout] = v }
543
575
  help_flag(o)
544
576
  end
545
577
  dir = positional_dir(parser, argv) or return 2
546
578
 
547
579
  folder = OKF::Bundle::Folder.load(dir)
548
580
  report_skipped(folder)
549
- html = OKF::Server::App.new(folder, title: options[:title] || folder.name, link: options[:link], layout: options[:layout]).render_static
581
+ html = OKF::Render::Graph.static(folder, title: options[:title], link: options[:link], layout: options[:layout])
550
582
  if options[:output]
551
583
  # A bad -o path (a missing directory, a permission denial) is a bad
552
584
  # *argument*: exit 2 with the reason, never a backtrace and an exit code
@@ -1117,6 +1149,43 @@ module OKF
1117
1149
  end
1118
1150
  end
1119
1151
 
1152
+ # The registered engines, read at parse time so an addon that registers one
1153
+ # shows up in `--help` without the CLI knowing it exists.
1154
+ def engine_names
1155
+ OKF::Bundle::Search.engines.map(&:id).join(" | ")
1156
+ end
1157
+
1158
+ def unsupported_query_message(error)
1159
+ wanted = error.missing.map { |name| CAPABILITY_FLAGS.fetch(name, ":#{name}") }.join(", ")
1160
+ return "no available search engine offers #{wanted}" if error.engine.nil?
1161
+
1162
+ able = OKF::Bundle::Search.engines.select { |engine| (error.missing - engine.capabilities).empty? }
1163
+ message = "--engine #{error.engine} does not support #{wanted}"
1164
+ message += " (try --engine #{able.map(&:id).join(" or ")})" unless able.empty?
1165
+ message
1166
+ end
1167
+
1168
+ # The engine story, told once, in the only place there is to tell it. `search`
1169
+ # routes on what the query needs — a pattern needs the scan, --fuzzy needs the
1170
+ # index — and says nothing about it at runtime: no note on stderr, nothing in
1171
+ # the header, and deliberately no --engine flag. So this is where a user learns
1172
+ # that the exactness a token index gives up is still reachable, and that -e is
1173
+ # how. Without it that capability is present but undiscoverable.
1174
+ #
1175
+ # It leads rather than trails because #help_flag registers -h with `on_tail`,
1176
+ # which OptionParser renders after every separator: a closing paragraph would
1177
+ # print *above* the -h line and split the option list in half. Stating the
1178
+ # matching model before the flags reads better anyway.
1179
+ def search_engine_note(parser)
1180
+ parser.separator ""
1181
+ parser.separator "Terms match raw text, so a phrase (\"dedup key\"), a dotted identifier (7.2.0,"
1182
+ parser.separator "customer_id) and a word inside `backticks` all match literally — the scan engine."
1183
+ parser.separator "--engine index matches whole tokens and the tokens they prefix, ranked by BM25+:"
1184
+ parser.separator "better ranking and the engine the browser page runs, at the cost of that"
1185
+ parser.separator "exactness. --fuzzy implies it. Add -e to read the terms as regular expressions."
1186
+ parser.separator ""
1187
+ end
1188
+
1120
1189
  # --fields/--except project the JSON down to the properties an agent wants, so it
1121
1190
  # never pays tokens for fields it will not read. --fields is an allowlist,
1122
1191
  # --except a denylist (mutually exclusive); both imply --json and apply per item
@@ -1281,7 +1350,8 @@ module OKF
1281
1350
  return resolve_registered(arg) if arg.start_with?("@")
1282
1351
 
1283
1352
  unless File.directory?(arg)
1284
- @err.puts "error: #{arg} is not a directory"
1353
+ @err.puts "error: #{arg} is not a directory or a registry ref " \
1354
+ "(@slug names a registered bundle, @ the default; okf registry list)"
1285
1355
  return nil
1286
1356
  end
1287
1357
  arg
@@ -1692,7 +1762,7 @@ module OKF
1692
1762
  loose <dir|@slug> [--json] list files with no graph links, by folder
1693
1763
  validate <dir|@slug> [--json] check OKF v0.1 conformance
1694
1764
 
1695
- search <dir|@slug…|@all> <term…> [-e] [...] find concepts by text or regexp, ranked (@all: every bundle)
1765
+ search <dir|@slug…|@all> <term…> [-e|--fuzzy] [...] find concepts by text or regexp, ranked (@all: every bundle)
1696
1766
  index <dir|@slug> [--json] [--area A] [--no-body] the index map: dirs, their listings and rollups
1697
1767
  stats <dir|@slug> [--json] bundle rollups (concepts, types, areas, links, tags)
1698
1768
  types <dir|@slug> [--json] [filters] list types with their concepts, by count