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.
- checksums.yaml +4 -4
- data/.github/workflows/dependent-repos.json +7 -2
- data/.github/workflows/dependent-tests.yml +4 -1
- data/.github/workflows/performance-release.yml +79 -0
- data/.github/workflows/performance.yml +59 -0
- data/Rakefile +23 -0
- data/benchmark/README.md +106 -0
- data/benchmark/scoreboard.json +115 -0
- data/benchmark/scoreboard.rb +556 -0
- data/bin/generate-model-coverage +21 -0
- data/docs/_pages/adapter-compatibility.adoc +3 -3
- data/docs/_pages/divergences.adoc +32 -41
- data/docs/_pages/ecosystem.adoc +62 -0
- data/docs/_pages/headed-ox-limitations.adoc +1 -1
- data/docs/_pages/headed-ox.adoc +2 -2
- data/docs/_pages/index.adoc +4 -0
- data/docs/_pages/model-coverage.adoc +57 -0
- data/docs/_pages/performance.adoc +29 -0
- data/docs/_pages/why-moxml.adoc +87 -0
- data/docs/_references/processing-model.adoc +42 -3
- data/lib/moxml/adapter/base.rb +73 -0
- data/lib/moxml/adapter/leptris.rb +183 -4
- data/lib/moxml/adapter/libxml.rb +34 -2
- data/lib/moxml/adapter/nokogiri.rb +10 -0
- data/lib/moxml/adapter/oga.rb +39 -7
- data/lib/moxml/adapter/ox.rb +8 -0
- data/lib/moxml/adapter/rexml.rb +17 -2
- data/lib/moxml/c14n/data_model.rb +8 -0
- data/lib/moxml/c14n/processor.rb +17 -8
- data/lib/moxml/context.rb +28 -0
- data/lib/moxml/document.rb +17 -3
- data/lib/moxml/node.rb +56 -5
- data/lib/moxml/node_set.rb +31 -1
- data/lib/moxml/relaxng.rb +62 -0
- data/lib/moxml/sax/block_handler.rb +7 -0
- data/lib/moxml/sax/handler.rb +20 -0
- data/lib/moxml/version.rb +1 -1
- data/lib/moxml/xpath.rb +28 -0
- data/lib/moxml.rb +1 -0
- data/spec/consistency/adapter_parity_spec.rb +22 -0
- data/spec/consistency/citation_hygiene_spec.rb +30 -0
- data/spec/consistency/model_coverage_audit_spec.rb +61 -86
- data/spec/consistency/published_docs_spec.rb +49 -0
- data/spec/consistency/xpath_idioms_spec.rb +23 -5
- data/spec/integration/shared_examples/edge_cases.rb +0 -13
- data/spec/moxml/adapter/leptris_spec.rb +23 -3
- data/spec/moxml/adapter/shared_examples/adapter_contract.rb +0 -8
- data/spec/moxml/attribute_resolver_spec.rb +33 -0
- data/spec/moxml/c14n/adapter_delegation_spec.rb +33 -6
- data/spec/moxml/c14n/character_encoding_spec.rb +7 -13
- data/spec/moxml/c14n/comments_pis_spec.rb +35 -8
- data/spec/moxml/c14n/subset_spec.rb +2 -12
- data/spec/moxml/c14n/w3c_examples_spec.rb +2 -5
- data/spec/moxml/element_indent_spec.rb +34 -0
- data/spec/moxml/node_spec.rb +0 -3
- data/spec/moxml/relax_ng_spec.rb +67 -0
- data/spec/moxml/sax_spec.rb +45 -74
- data/spec/moxml/xpath_capabilities_spec.rb +11 -38
- data/spec/support/input_taxonomy.rb +7 -0
- data/spec/support/model_coverage.rb +159 -0
- 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
|
|
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
|
|
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 (
|
|
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.**
|