moxml 0.5.149 → 0.5.150

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.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/dependent-repos.json +7 -2
  3. data/.github/workflows/dependent-tests.yml +4 -1
  4. data/.github/workflows/performance-release.yml +79 -0
  5. data/.github/workflows/performance.yml +59 -0
  6. data/Rakefile +23 -0
  7. data/benchmark/README.md +106 -0
  8. data/benchmark/scoreboard.json +115 -0
  9. data/benchmark/scoreboard.rb +556 -0
  10. data/bin/generate-model-coverage +21 -0
  11. data/docs/_pages/adapter-compatibility.adoc +3 -3
  12. data/docs/_pages/divergences.adoc +32 -41
  13. data/docs/_pages/ecosystem.adoc +62 -0
  14. data/docs/_pages/headed-ox-limitations.adoc +1 -1
  15. data/docs/_pages/headed-ox.adoc +2 -2
  16. data/docs/_pages/index.adoc +4 -0
  17. data/docs/_pages/model-coverage.adoc +57 -0
  18. data/docs/_pages/performance.adoc +29 -0
  19. data/docs/_pages/why-moxml.adoc +87 -0
  20. data/docs/_references/processing-model.adoc +42 -3
  21. data/lib/moxml/adapter/base.rb +73 -0
  22. data/lib/moxml/adapter/leptris.rb +183 -4
  23. data/lib/moxml/adapter/libxml.rb +34 -2
  24. data/lib/moxml/adapter/nokogiri.rb +10 -0
  25. data/lib/moxml/adapter/oga.rb +39 -7
  26. data/lib/moxml/adapter/ox.rb +8 -0
  27. data/lib/moxml/adapter/rexml.rb +17 -2
  28. data/lib/moxml/c14n/data_model.rb +8 -0
  29. data/lib/moxml/c14n/processor.rb +17 -8
  30. data/lib/moxml/context.rb +28 -0
  31. data/lib/moxml/document.rb +17 -3
  32. data/lib/moxml/node.rb +56 -5
  33. data/lib/moxml/node_set.rb +31 -1
  34. data/lib/moxml/relaxng.rb +62 -0
  35. data/lib/moxml/sax/block_handler.rb +7 -0
  36. data/lib/moxml/sax/handler.rb +20 -0
  37. data/lib/moxml/version.rb +1 -1
  38. data/lib/moxml/xpath.rb +28 -0
  39. data/lib/moxml.rb +1 -0
  40. data/spec/consistency/adapter_parity_spec.rb +22 -0
  41. data/spec/consistency/citation_hygiene_spec.rb +30 -0
  42. data/spec/consistency/model_coverage_audit_spec.rb +61 -86
  43. data/spec/consistency/published_docs_spec.rb +49 -0
  44. data/spec/consistency/xpath_idioms_spec.rb +23 -5
  45. data/spec/integration/shared_examples/edge_cases.rb +0 -13
  46. data/spec/moxml/adapter/leptris_spec.rb +23 -3
  47. data/spec/moxml/adapter/shared_examples/adapter_contract.rb +0 -8
  48. data/spec/moxml/attribute_resolver_spec.rb +33 -0
  49. data/spec/moxml/c14n/adapter_delegation_spec.rb +33 -6
  50. data/spec/moxml/c14n/character_encoding_spec.rb +7 -13
  51. data/spec/moxml/c14n/comments_pis_spec.rb +35 -8
  52. data/spec/moxml/c14n/subset_spec.rb +2 -12
  53. data/spec/moxml/c14n/w3c_examples_spec.rb +2 -5
  54. data/spec/moxml/element_indent_spec.rb +34 -0
  55. data/spec/moxml/node_spec.rb +0 -3
  56. data/spec/moxml/relax_ng_spec.rb +67 -0
  57. data/spec/moxml/sax_spec.rb +45 -74
  58. data/spec/moxml/xpath_capabilities_spec.rb +11 -38
  59. data/spec/support/input_taxonomy.rb +7 -0
  60. data/spec/support/model_coverage.rb +159 -0
  61. metadata +17 -2
@@ -0,0 +1,556 @@
1
+ # frozen_string_literal: true
2
+
3
+ # The published performance scoreboard (TODO.sota/04).
4
+ #
5
+ # Protocol: CPU-time medians of an interleaved A/B run. For every
6
+ # repetition the baseline (raw nokogiri) and the candidate (moxml on
7
+ # the fastest engine lane) alternate in the same thermal window; the
8
+ # reported ratio is median(baseline) / median(candidate), so > 1.0
9
+ # means the facade is faster than the raw library.
10
+ #
11
+ # Honesty gates:
12
+ # - CPU time (CLOCK_PROCESS_CPUTIME_ID), not wall time, so scheduler
13
+ # noise does not become a performance claim;
14
+ # - the harness refuses to report when the 1-minute load exceeds
15
+ # half the CPU count (override with SCORE_FORCE=1 for smoke runs);
16
+ # - warmup runs are discarded; GC is started between samples;
17
+ # - every row names the exact operation measured.
18
+ #
19
+ # Usage:
20
+ # bundle exec ruby benchmark/scoreboard.rb # all rows
21
+ # SCORE_ROWS=parse,serialize bundle exec ruby benchmark/scoreboard.rb
22
+ # SCORE_ADAPTER=oga bundle exec ruby benchmark/scoreboard.rb
23
+ # SCORE_JSON=benchmark/scoreboard.json bundle exec ruby benchmark/scoreboard.rb
24
+ # SCORE_LOCK=1 bundle exec ruby benchmark/scoreboard.rb # parity gate
25
+ #
26
+ # SCORE_LOCK=1 exits non-zero when a parity row is below 1.0x or when
27
+ # a row regresses more than 10% against SCORE_BASELINE.
28
+ require "moxml"
29
+ require "nokogiri"
30
+ require "json"
31
+ require "time"
32
+
33
+ module Scoreboard
34
+ ADAPTER = (ENV["SCORE_ADAPTER"] || "leptris").to_sym
35
+ REPS = (ENV["SCORE_REPS"] || 9).to_i
36
+ WARMUP = (ENV["SCORE_WARMUP"] || 2).to_i
37
+ ROW_FILTER = ENV["SCORE_ROWS"]&.split(",")&.map(&:strip)
38
+ FORCE = ENV["SCORE_FORCE"] == "1"
39
+ LOCK = ENV["SCORE_LOCK"] == "1"
40
+ BASELINE_PATH = ENV.fetch("SCORE_BASELINE", nil)
41
+ JSON_PATH = ENV.fetch("SCORE_JSON", nil)
42
+ MARKDOWN_PATH = ENV.fetch("SCORE_MARKDOWN", nil)
43
+ PUBLISH = ENV["SCORE_PUBLISH"] == "1"
44
+ REGRESSION_TOLERANCE = 0.10
45
+ # Measurement tolerance for the absolute parity gate: ratios move a
46
+ # few percent run to run; a row must stay at or above this floor.
47
+ PARITY_FLOOR = 0.95
48
+
49
+ Field = Struct.new(:name, :unit, :value)
50
+ Record = Struct.new(:id, :kind, :fields)
51
+ Catalog = Struct.new(:records)
52
+
53
+ PLAN = Moxml::Plan.new do
54
+ on("record") { |attrs, _text, fields| Record.new(attrs["id"], attrs["kind"], fields) }
55
+ on("field") { |attrs, text, _kids| Field.new(attrs["name"], attrs["unit"], text) }
56
+ end
57
+
58
+ module_function
59
+
60
+ # A consumer-shaped catalog: mixed text, attributes, nesting.
61
+ def xml
62
+ @xml ||= begin
63
+ parts = [%(<?xml version="1.0"?><catalog>)]
64
+ 300.times do |i|
65
+ parts << %(<record id="r#{i}" kind="k#{i % 3}">)
66
+ 4.times do |j|
67
+ parts << %(<field name="f#{j}" unit="u#{j}">value #{i}.#{j}</field>)
68
+ end
69
+ parts << %(</record>)
70
+ end
71
+ parts << %(</catalog>)
72
+ parts.join
73
+ end
74
+ end
75
+
76
+ def cpu_now
77
+ Process.clock_gettime(Process::CLOCK_PROCESS_CPUTIME_ID)
78
+ end
79
+
80
+ def load_average
81
+ if File.exist?("/proc/loadavg")
82
+ File.read("/proc/loadavg").split[0].to_f
83
+ else
84
+ `sysctl -n vm.loadavg 2>/dev/null`.split[1].to_f
85
+ end
86
+ end
87
+
88
+ def ncpu
89
+ require "etc"
90
+ Etc.nprocessors
91
+ rescue StandardError
92
+ 1
93
+ end
94
+
95
+ def check_load!
96
+ return if FORCE
97
+
98
+ load = load_average
99
+ budget = ncpu * 0.5
100
+ return if load <= budget
101
+
102
+ warn format(
103
+ "scoreboard: refusing to measure — 1-minute load %<load>.1f exceeds " \
104
+ "%<budget>.1f (half of %<ncpu>d CPUs). Stop background work or set " \
105
+ "SCORE_FORCE=1 for a smoke run (numbers are not publishable).",
106
+ load: load, budget: budget, ncpu: ncpu,
107
+ )
108
+ exit 2
109
+ end
110
+
111
+ def median(values)
112
+ sorted = values.sort
113
+ sorted[sorted.size / 2]
114
+ end
115
+
116
+ # Interleaved A/B sampling. Yields :baseline / :candidate; returns
117
+ # [median_baseline, median_candidate, allocations_baseline,
118
+ # allocations_candidate] over the requested repetition count.
119
+ def measure
120
+ WARMUP.times { yield :baseline }
121
+ WARMUP.times { yield :candidate }
122
+ baseline = []
123
+ candidate = []
124
+ base_allocs = nil
125
+ cand_allocs = nil
126
+ REPS.times do
127
+ [[:baseline, baseline], [:candidate, candidate]].each do |side, bucket|
128
+ GC.start
129
+ a0 = GC.stat(:total_allocated_objects)
130
+ t0 = cpu_now
131
+ yield side
132
+ dt = cpu_now - t0
133
+ bucket << (dt * 1e6)
134
+ allocs = GC.stat(:total_allocated_objects) - a0
135
+ if side == :baseline
136
+ base_allocs = allocs
137
+ else
138
+ cand_allocs = allocs
139
+ end
140
+ end
141
+ end
142
+ [median(baseline), median(candidate), base_allocs, cand_allocs]
143
+ end
144
+
145
+ # --- shared fixtures ------------------------------------------------
146
+
147
+ def baseline_doc
148
+ @baseline_doc ||= Nokogiri::XML(xml)
149
+ end
150
+
151
+ def moxml_doc
152
+ @moxml_doc ||= moxml_ctx.parse(xml)
153
+ end
154
+
155
+ def moxml_ctx
156
+ @moxml_ctx ||= Moxml.new(ADAPTER)
157
+ end
158
+
159
+ def baseline_records
160
+ @baseline_records ||= baseline_doc.xpath("//record")
161
+ end
162
+
163
+ def moxml_records
164
+ @moxml_records ||= moxml_doc.xpath("//record").to_a
165
+ end
166
+
167
+ # Row registry. Each entry: name, kind (:parity / :structural /
168
+ # :data), and the operation in both worlds.
169
+ def rows
170
+ @rows ||= begin
171
+ definition = {
172
+ "parse" => [:parity,
173
+ -> { Nokogiri::XML(xml) },
174
+ -> { moxml_ctx.parse(xml) }],
175
+ "serialize" => [:parity,
176
+ -> { baseline_doc.to_xml },
177
+ -> { moxml_doc.to_xml }],
178
+ # Count only: the node-set cardinality without materializing
179
+ # wrapper objects on either side.
180
+ "xpath //field (count)" => [:parity,
181
+ -> { baseline_doc.xpath("//field").size },
182
+ -> { moxml_doc.xpath("//field").size }],
183
+ # Materialized results: what a consumer iterating the set pays.
184
+ # Structural (tracked, not parity): per-result wrapper mint.
185
+ "xpath //field (each)" => [:structural,
186
+ -> { baseline_doc.xpath("//field").each(&:name) },
187
+ -> { moxml_doc.xpath("//field").each(&:name) }],
188
+ "attr pairs listing" => [:parity,
189
+ -> do
190
+ baseline_records.each do |e|
191
+ e.attributes.map { |k, v| [k, v.value] }
192
+ end
193
+ end,
194
+ -> { moxml_records.each(&:attribute_pairs) }],
195
+ # Structural (tracked, not parity): engine mint + GC.
196
+ "build add_element" => [:structural,
197
+ -> { build_nokogiri },
198
+ -> { build_moxml(moxml_ctx) }],
199
+ # Warm traversal is near parity but not reliably above it on
200
+ # every machine: tracked, not parity-gated.
201
+ "walk+read (warm)" => [:structural,
202
+ -> { walk_nokogiri(baseline_doc) },
203
+ -> { walk_moxml(moxml_doc) }],
204
+ # Structural (tracked, not parity): per-node wrapper mint.
205
+ "walk+read (cold)" => [:structural,
206
+ -> { walk_nokogiri(Nokogiri::XML(xml)) },
207
+ -> { walk_moxml(moxml_ctx.parse(xml)) }],
208
+ # Structural row: recurse through `children` and filter
209
+ # elements — the NodeSet machinery cost, cold.
210
+ "children recursion (cold)" => [:structural,
211
+ -> { children_nokogiri(Nokogiri::XML(xml)) },
212
+ -> { children_moxml(moxml_ctx.parse(xml)) }],
213
+ # The data-API row: the compiled plan against the typed
214
+ # Nokogiri walk consumers write by hand.
215
+ "plan records (typed)" => [:data,
216
+ -> { typed_nokogiri(Nokogiri::XML(xml)) },
217
+ -> { PLAN.parse(xml, moxml_ctx) }],
218
+ }
219
+ definition.select { |name, _| ROW_FILTER.nil? || ROW_FILTER.include?(name) }
220
+ end
221
+ end
222
+
223
+ # --- row implementations -------------------------------------------
224
+
225
+ def build_nokogiri
226
+ doc = Nokogiri::XML::Document.new
227
+ root = Nokogiri::XML::Node.new("catalog", doc)
228
+ doc.root = root
229
+ 300.times do |i|
230
+ record = Nokogiri::XML::Node.new("record", doc)
231
+ record["id"] = "r#{i}"
232
+ record["kind"] = "k#{i % 3}"
233
+ 4.times do |j|
234
+ field = Nokogiri::XML::Node.new("field", doc)
235
+ field["name"] = "f#{j}"
236
+ field["unit"] = "u#{j}"
237
+ field.content = "value #{i}.#{j}"
238
+ record.add_child(field)
239
+ end
240
+ root.add_child(record)
241
+ end
242
+ doc
243
+ end
244
+
245
+ def build_moxml(ctx)
246
+ doc = ctx.create_document
247
+ root = doc.add_element("catalog")
248
+ 300.times do |i|
249
+ record = root.add_element("record", "id" => "r#{i}", "kind" => "k#{i % 3}")
250
+ 4.times do |j|
251
+ field = record.add_element("field", "name" => "f#{j}", "unit" => "u#{j}")
252
+ field.content = "value #{i}.#{j}"
253
+ end
254
+ end
255
+ doc
256
+ end
257
+
258
+ def walk_nokogiri(doc)
259
+ walk = lambda do |node|
260
+ node.element_children.each do |child|
261
+ child.name
262
+ child.text
263
+ walk.call(child)
264
+ end
265
+ end
266
+ walk.call(doc.root)
267
+ end
268
+
269
+ def walk_moxml(doc)
270
+ walk = lambda do |node|
271
+ node.children.each do |child|
272
+ next unless child.is_a?(Moxml::Element)
273
+
274
+ child.name
275
+ child.text
276
+ walk.call(child)
277
+ end
278
+ end
279
+ walk.call(doc.root)
280
+ end
281
+
282
+ def children_nokogiri(doc)
283
+ walk = lambda do |node|
284
+ node.children.each do |child|
285
+ next unless child.element?
286
+
287
+ child.name
288
+ walk.call(child)
289
+ end
290
+ end
291
+ walk.call(doc.root)
292
+ end
293
+
294
+ def children_moxml(doc)
295
+ walk = lambda do |node|
296
+ node.children.each do |child|
297
+ next unless child.is_a?(Moxml::Element)
298
+
299
+ child.name
300
+ walk.call(child)
301
+ end
302
+ end
303
+ walk.call(doc.root)
304
+ end
305
+
306
+ def typed_nokogiri(doc)
307
+ records = doc.root.element_children.map do |record|
308
+ fields = record.element_children.map do |field|
309
+ Field.new(field["name"], field["unit"], field.text)
310
+ end
311
+ Record.new(record["id"], record["kind"], fields)
312
+ end
313
+ Catalog.new(records)
314
+ end
315
+
316
+ # --- run ------------------------------------------------------------
317
+
318
+ def validate_fixtures!
319
+ records = PLAN.parse(xml, moxml_ctx)
320
+ raise "plan shape" unless records.size == 300
321
+
322
+ typed = typed_nokogiri(baseline_doc)
323
+ raise "typed shape" unless typed.records.size == 300
324
+ end
325
+
326
+ def run
327
+ check_load!
328
+ validate_fixtures!
329
+ results = []
330
+ rows.each do |name, (kind, baseline_op, candidate_op)|
331
+ b, c, ba, ca = measure do |side|
332
+ side == :baseline ? baseline_op.call : candidate_op.call
333
+ end
334
+ ratio = b / c
335
+ results << {
336
+ "row" => name,
337
+ "kind" => kind.to_s,
338
+ "parity" => kind == :parity,
339
+ "baseline_us" => b.round(1),
340
+ "candidate_us" => c.round(1),
341
+ "ratio" => ratio.round(3),
342
+ "baseline_allocs" => ba,
343
+ "candidate_allocs" => ca,
344
+ }
345
+ end
346
+
347
+ report(results)
348
+ write_json(results) if JSON_PATH
349
+ write_markdown(results) if MARKDOWN_PATH
350
+ write_published(results) if PUBLISH
351
+ enforce_lock(results) if LOCK
352
+ results
353
+ end
354
+
355
+ def report(results)
356
+ puts format(
357
+ "%-28<row>s %12<nk>s %12<mx>s %8<ratio>s %10<na>s %10<ma>s",
358
+ row: "row", nk: "nokogiri us", mx: "#{ADAPTER} us", ratio: "ratio",
359
+ na: "nk allocs", ma: "mx allocs"
360
+ )
361
+ results.each do |row|
362
+ puts format(
363
+ "%-28<row>s %12.1<base>f %12.1<cand>f %7.2<ratio>fx " \
364
+ "%10<ba>d %10<ca>d%<tag>s",
365
+ row: row["row"], base: row["baseline_us"],
366
+ cand: row["candidate_us"], ratio: row["ratio"],
367
+ ba: row["baseline_allocs"], ca: row["candidate_allocs"],
368
+ tag: row["parity"] ? "" : " (#{row['kind']})"
369
+ )
370
+ end
371
+ puts format(
372
+ "(ruby %<ruby>s; %<adapter>s %<version>s; load %<load>.2f; " \
373
+ "reps %<reps>d; CPU medians, interleaved)",
374
+ ruby: RUBY_VERSION,
375
+ adapter: ADAPTER,
376
+ version: defined?(Leptris::VERSION) && ADAPTER == :leptris ? Leptris::VERSION : "",
377
+ load: load_average,
378
+ reps: REPS,
379
+ )
380
+ end
381
+
382
+ ADOC_PATH = "docs/_pages/performance.adoc"
383
+ ADOC_BEGIN = "// SCOREBOARD:BEGIN"
384
+ ADOC_END = "// SCOREBOARD:END"
385
+ README_PATH = "benchmark/README.md"
386
+ PUBLISHED_JSON = "benchmark/scoreboard.json"
387
+ SCOREBOARD_BEGIN = "<!-- SCOREBOARD:BEGIN -->"
388
+ SCOREBOARD_END = "<!-- SCOREBOARD:END -->"
389
+
390
+ def write_published(results)
391
+ write_json(results, PUBLISHED_JSON)
392
+ write_adoc(results)
393
+ write_readme(results)
394
+ warn "scoreboard: published #{ADOC_PATH} and #{README_PATH}"
395
+ end
396
+
397
+ def kind_of(row)
398
+ row["kind"]
399
+ end
400
+
401
+ def status_of(row)
402
+ case row["kind"]
403
+ when "parity"
404
+ row["ratio"] >= PARITY_FLOOR ? "met" : "open (below parity)"
405
+ when "structural"
406
+ "tracked (below parity)"
407
+ else
408
+ "informational"
409
+ end
410
+ end
411
+
412
+ def markdown_table(results)
413
+ lines = [
414
+ "| Row | Ratio | Kind | Status |",
415
+ "| --- | ----- | ---- | ------ |",
416
+ ]
417
+ results.each do |row|
418
+ lines << format("| `%<row>s` | %<ratio>.2fx | %<kind>s | %<status>s |",
419
+ row: row["row"], ratio: row["ratio"],
420
+ kind: kind_of(row), status: status_of(row))
421
+ end
422
+ lines.join("\n")
423
+ end
424
+
425
+ def adoc_table(results)
426
+ lines = [
427
+ '[cols="2,1,1,1",options="header"]',
428
+ "|===",
429
+ "| Row | Ratio | Kind | Status",
430
+ ]
431
+ results.each do |row|
432
+ lines << format("| `%<row>s` | %<ratio>.2fx | %<kind>s | %<status>s",
433
+ row: row["row"], ratio: row["ratio"],
434
+ kind: kind_of(row), status: status_of(row))
435
+ end
436
+ lines << "|==="
437
+ lines.join("\n")
438
+ end
439
+
440
+ def write_readme(results)
441
+ text = File.read(README_PATH)
442
+ pattern = /#{Regexp.escape(SCOREBOARD_BEGIN)}.*?#{Regexp.escape(SCOREBOARD_END)}/mo
443
+ return unless text.match?(pattern)
444
+
445
+ block = "#{SCOREBOARD_BEGIN}\n#{markdown_table(results)}\n" \
446
+ "#{SCOREBOARD_END}"
447
+ File.write(README_PATH, text.sub(pattern, block))
448
+ end
449
+
450
+ def write_adoc(results)
451
+ text = File.read(ADOC_PATH)
452
+ pattern = /#{Regexp.escape(ADOC_BEGIN)}.*?#{Regexp.escape(ADOC_END)}/mo
453
+ unless text.match?(pattern)
454
+ warn "scoreboard: #{ADOC_PATH} has no scoreboard markers; skipping " \
455
+ "(add #{ADOC_BEGIN} / #{ADOC_END})"
456
+ return
457
+ end
458
+
459
+ lines = []
460
+ lines << "Generated by `SCORE_PUBLISH=1 bundle exec ruby " \
461
+ "benchmark/scoreboard.rb`. Protocol: CPU-time medians of " \
462
+ "an interleaved A/B run (raw nokogiri vs moxml on " \
463
+ "#{ADAPTER}); the ratio is baseline / candidate, so > 1.0 " \
464
+ "means the facade is faster. Warmups discarded, GC between " \
465
+ "samples."
466
+ lines << ""
467
+ lines << "Measurement: Ruby #{RUBY_VERSION}, #{ADAPTER} " \
468
+ "#{Leptris::VERSION if defined?(Leptris::VERSION) && ADAPTER == :leptris}, " \
469
+ "nokogiri #{Nokogiri::VERSION}, #{Time.now.utc.iso8601}, " \
470
+ "1-minute load #{load_average.round(2)}, #{REPS} repetitions."
471
+ lines << ""
472
+ lines << adoc_table(results)
473
+ lines << ""
474
+ lines << "*Parity rows* must stay at or above the 0.95 measurement " \
475
+ "floor. *Structural rows* are measured and trend-locked " \
476
+ "(10% regression tolerance) while their gaps remain open: " \
477
+ "per-node wrapper minting costs (#312 endgame) and engine " \
478
+ "construction. *Data API* rows are informational."
479
+ lines << ""
480
+ block = "#{ADOC_BEGIN}\n#{lines.join("\n")}#{ADOC_END}"
481
+ File.write(ADOC_PATH, text.sub(pattern, block))
482
+ end
483
+
484
+ def write_markdown(results)
485
+ lines = [
486
+ "## Performance scoreboard",
487
+ "",
488
+ "CPU-time medians of an interleaved A/B run against raw nokogiri " \
489
+ "on the same document (ratio > 1.0 means moxml is faster). " \
490
+ "Pair: moxml + #{ADAPTER} " \
491
+ "#{Leptris::VERSION if defined?(Leptris::VERSION) && ADAPTER == :leptris} " \
492
+ "on Ruby #{RUBY_VERSION}; #{REPS} repetitions.",
493
+ "",
494
+ markdown_table(results),
495
+ "",
496
+ "Parity rows are the commitment (0.95 measurement floor); " \
497
+ "structural rows are tracked (10% regression tolerance) with " \
498
+ "their gaps documented in `benchmark/README.md`.",
499
+ ]
500
+ File.write(MARKDOWN_PATH, "#{lines.join("\n")}\n")
501
+ warn "scoreboard: wrote #{MARKDOWN_PATH}"
502
+ end
503
+
504
+ def write_json(results, path = JSON_PATH)
505
+ payload = {
506
+ "protocol" => {
507
+ "measured" => Time.now.utc.iso8601,
508
+ "ruby" => RUBY_VERSION,
509
+ "adapter" => ADAPTER.to_s,
510
+ "adapter_version" => (defined?(Leptris::VERSION) ? Leptris::VERSION : nil),
511
+ "nokogiri" => Nokogiri::VERSION,
512
+ "reps" => REPS,
513
+ "warmup" => WARMUP,
514
+ "clock" => "CLOCK_PROCESS_CPUTIME_ID",
515
+ "load" => load_average.round(2),
516
+ },
517
+ "rows" => results,
518
+ }
519
+ File.write(path, "#{JSON.pretty_generate(payload)}\n")
520
+ warn "scoreboard: wrote #{path}"
521
+ end
522
+
523
+ def enforce_lock(results)
524
+ failures = results.select do |row|
525
+ row["kind"] == "parity" && row["ratio"] < PARITY_FLOOR
526
+ end
527
+ if BASELINE_PATH && File.exist?(BASELINE_PATH)
528
+ baseline = JSON.parse(File.read(BASELINE_PATH))
529
+ .fetch("rows").to_h { |row| [row["row"], row] }
530
+ results.each do |row|
531
+ old = baseline[row["row"]]
532
+ next unless old && old["ratio"].to_f.positive?
533
+
534
+ floor = old["ratio"].to_f * (1.0 - REGRESSION_TOLERANCE)
535
+ next if row["ratio"] >= floor
536
+
537
+ failures << row.merge(
538
+ "regression" => "#{format('%.2f', row['ratio'])}x < " \
539
+ "#{format('%.2f', floor)}x (10% below baseline " \
540
+ "#{format('%.2f', old['ratio'])}x)",
541
+ )
542
+ end
543
+ end
544
+ return if failures.empty?
545
+
546
+ warn "scoreboard lock FAILED:"
547
+ failures.each do |row|
548
+ warn format(" %-28<row>s %6.2<ratio>fx %<note>s",
549
+ row: row["row"], ratio: row["ratio"],
550
+ note: row["regression"] || "below parity")
551
+ end
552
+ exit 1
553
+ end
554
+ end
555
+
556
+ Scoreboard.run
@@ -0,0 +1,21 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Regenerate the published model-coverage page block from the coverage
5
+ # matrix (spec/support/model_coverage.rb). Deterministic: no clock, no
6
+ # engine probes — the page is a view of committed data.
7
+ require "support/model_coverage"
8
+
9
+ PAGE = "docs/_pages/model-coverage.adoc"
10
+ BEGIN_MARK = "// MODEL-COVERAGE:BEGIN"
11
+ END_MARK = "// MODEL-COVERAGE:END"
12
+
13
+ text = File.read(PAGE)
14
+ pattern = /#{Regexp.escape(BEGIN_MARK)}.*?#{Regexp.escape(END_MARK)}/mo
15
+ unless text.match?(pattern)
16
+ abort "#{PAGE} has no coverage markers (#{BEGIN_MARK} / #{END_MARK})"
17
+ end
18
+
19
+ block = "#{BEGIN_MARK}\n#{ModelCoverage.render_page_block}\n#{END_MARK}"
20
+ File.write(PAGE, text.sub(pattern, block))
21
+ warn "model coverage: published #{PAGE}"
@@ -264,7 +264,7 @@ All adapters now use expanded format (`<empty></empty>`) for consistency, contro
264
264
 
265
265
  === Whitespace handling
266
266
 
267
- Minor differences in whitespace preservation between adapters are documented and expected.
267
+ Minor differences in whitespace preservation between adapters are registered where they are observable — see link:divergences.html[the divergence register].
268
268
 
269
269
  == Pending tests summary
270
270
 
@@ -337,7 +337,7 @@ Minor differences in whitespace preservation between adapters are documented and
337
337
  * Prefer pure Ruby XPath implementation for debugging
338
338
  * Need more XPath capabilities than standard Ox provides
339
339
  * Memory efficiency important but XPath features required
340
- * Can work within documented limitations (99.20% compatibility)
340
+ * Can work within the known limitations (99.20% compatibility)
341
341
 
342
342
  == Test strategy
343
343
 
@@ -370,7 +370,7 @@ This ensures consistent behavior regardless of underlying adapter's validation s
370
370
  The pending tests represent either:
371
371
 
372
372
  * Optional features (benchmarks)
373
- * Known adapter limitations (documented)
373
+ * Known adapter limitations (see link:divergences.html[the register])
374
374
  * Platform-specific behavior differences (acceptable)
375
375
 
376
376
  **No action required - library is in excellent production-ready state.**