moxml 0.5.61 → 0.5.62

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 432ffb6c040c2b5689552a050b01835b792cde27ddc816981fd186fd83b9df7d
4
- data.tar.gz: 917f7712e1799209f6d1c3f832fb5a3de492da5e570959a9d8bf2a8b4902d537
3
+ metadata.gz: 430e3a7e8c18cfa2c083ae9b263e9fedbaed03c109811980f0ca51de36507be7
4
+ data.tar.gz: 9644f7689ef2126884a6b11ecfcf9c532e0b2cae9cb44005b5b6b3434d96c7b4
5
5
  SHA512:
6
- metadata.gz: b6f0a69c52df888bf9afbc2706e755d9844d8d65247720d887cc6fb34c8d855964f278d798b947bfb028ce58f32253ff6ed92101fcc2cf45fe28695170c23405
7
- data.tar.gz: 90b841bd8eb58119a38dce5333a53f9b37abc70755e69a887c52ab4565d1101727a2dcc40903a4671b92a10d4ba1bd67ab712ac97d37bdd04861359dd4b09bba
6
+ metadata.gz: 9f89223d0bc12427916bb46220f79d957df704b878ea02dc11387c9096c6829f5309ed857a3faa4c1b9aee0697b3dfe2f7fa9f73fc5e083b6c1c0963bea11501
7
+ data.tar.gz: 5120f5700dd76a688576501a90ee2e6ffc443558cb89478dd23f2ea5a424d29dac41dd4df4cb9acc764451b81f84c5a03eed3d4ab734adfa276c7a1043bf54ce
@@ -59,3 +59,8 @@ jobs:
59
59
  RUN_PERFORMANCE: "1"
60
60
  RUBY_YJIT_ENABLE: "1"
61
61
  run: bundle exec rake spec:performance
62
+
63
+ - name: Plan parity lock (plan vs raw-nokogiri CPU)
64
+ env:
65
+ LEPTRIS_BENCH_LOCK: "1"
66
+ run: bundle exec rake benchmark:plan
data/Rakefile CHANGED
@@ -253,6 +253,16 @@ namespace :benchmark do
253
253
  sh "ruby", "benchmark/pipeline_bench.rb"
254
254
  end
255
255
 
256
+ # Plan-bench regression lock (#249 follow-up): Moxml::Plan vs the
257
+ # wrapper walk vs raw nokogiri on the consumer shape. With
258
+ # LEPTRIS_BENCH_LOCK=1 (CI) it exits 1 when plan time exceeds
259
+ # 1.5x raw nokogiri — the "raw-nokogiri CPU parity" regression
260
+ # tripwire.
261
+ desc "Plan materialization benchmark with raw-nokogiri parity lock"
262
+ task :plan do
263
+ sh "bundle exec ruby benchmark/plan_bench.rb"
264
+ end
265
+
256
266
  desc "Generate adapter benchmark report"
257
267
  task :report do
258
268
  ruby "benchmarks/generate_report.rb"
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Plan-bench regression lock (leptris follow-up to #249): the plan
4
+ # path must stay at or near raw-nokogiri CPU parity on the consumer
5
+ # shape. Run via `rake benchmark:plan` or with LEPTRIS_BENCH_LOCK=1
6
+ # to enforce the ceiling (CI): plan <= nokogiri * 1.5 — failures
7
+ # mean an adapter or plan change regressed the compiled walk.
8
+ require "moxml"
9
+ require "nokogiri"
10
+
11
+ Field = Struct.new(:name, :unit, :value)
12
+ Record = Struct.new(:id, :kind, :fields)
13
+ Catalog = Struct.new(:records)
14
+
15
+ parts = [%(<?xml version="1.0"?><catalog>)]
16
+ 150.times do |i|
17
+ parts << %(<record id="r#{i}" kind="k#{i % 3}">)
18
+ 6.times { |j| parts << %(<field name="f#{j}" unit="u#{j}">value #{i}.#{j}</field>) }
19
+ parts << %(</record>)
20
+ end
21
+ parts << %(</catalog>)
22
+ XML = parts.join
23
+
24
+ CTX = Moxml.new(:leptris)
25
+
26
+ PLAN = Moxml::Plan.new do
27
+ on("record") { |attrs, _t, fields| Record.new(attrs["id"], attrs["kind"], fields) }
28
+ on("field") { |attrs, text, _k| Field.new(attrs["name"], attrs["unit"], text) }
29
+ end
30
+
31
+ def plan_run
32
+ records = PLAN.parse(XML, CTX)
33
+ raise "shape" unless records.size == 150 && records[7].fields[3].value == "value 7.3"
34
+
35
+ records
36
+ end
37
+
38
+ def wrapper_run
39
+ doc = CTX.parse(XML)
40
+ catalog = Catalog.new([])
41
+ doc.root.children.each do |rec|
42
+ next unless rec.is_a?(Moxml::Element)
43
+
44
+ record = Record.new(rec["id"], rec["kind"], [])
45
+ rec.children.each do |f|
46
+ next unless f.is_a?(Moxml::Element)
47
+
48
+ record.fields << Field.new(f["name"], f["unit"], f.text)
49
+ end
50
+ catalog.records << record
51
+ end
52
+ raise "shape" unless catalog.records.size == 150
53
+
54
+ catalog
55
+ end
56
+
57
+ def nokogiri_run
58
+ doc = Nokogiri::XML(XML)
59
+ catalog = Catalog.new([])
60
+ doc.root.children.each do |rec|
61
+ next unless rec.is_a?(Nokogiri::XML::Element)
62
+
63
+ record = Record.new(rec["id"], rec["kind"], [])
64
+ rec.children.each do |f|
65
+ next unless f.is_a?(Nokogiri::XML::Element)
66
+
67
+ record.fields << Field.new(f["name"], f["unit"], f.text)
68
+ end
69
+ catalog.records << record
70
+ end
71
+ raise "shape" unless catalog.records.size == 150
72
+
73
+ catalog
74
+ end
75
+
76
+ REPS = 200
77
+
78
+ def bench(&block)
79
+ times = []
80
+ allocs = []
81
+ REPS.times do
82
+ GC.start
83
+ a0 = GC.stat(:total_allocated_objects)
84
+ t0 = Process.clock_gettime(Process::CLOCK_PROCESS_CPUTIME_ID)
85
+ yield
86
+ times << (Process.clock_gettime(Process::CLOCK_PROCESS_CPUTIME_ID) - t0)
87
+ allocs << (GC.stat(:total_allocated_objects) - a0)
88
+ end
89
+ [times.sort[times.size / 2] * 1e6, allocs.min]
90
+ end
91
+
92
+ # The parity lock needs the lean plan rows (binding 1.9.194.2+);
93
+ # on older bindings the plan runs the generic fallback and the
94
+ # ceiling is meaningless — report and exit clean.
95
+ if Gem::Version.new(Leptris::VERSION) < Gem::Version.new("1.9.194.2")
96
+ puts "SKIP parity lock: leptris #{Leptris::VERSION} lacks snapshot_rows (needs 1.9.194.2)"
97
+ exit 0
98
+ end
99
+
100
+ # warm
101
+ plan_run
102
+ wrapper_run
103
+ nokogiri_run
104
+
105
+ plan_us, plan_allocs = bench { plan_run }
106
+ wrap_us, wrap_allocs = bench { wrapper_run }
107
+ nk_us, nk_allocs = bench { nokogiri_run }
108
+
109
+ puts format("plan (parse+materialize) %<t>8.0f us %<a>7d allocs",
110
+ t: plan_us, a: plan_allocs)
111
+ puts format("wrapper (parse+walk) %<t>8.0f us %<a>7d allocs",
112
+ t: wrap_us, a: wrap_allocs)
113
+ puts format("nokogiri (parse+walk) %<t>8.0f us %<a>7d allocs",
114
+ t: nk_us, a: nk_allocs)
115
+ puts format("plan/nokogiri %<r>.2fx (ceiling 1.50x for the CI lock)",
116
+ r: plan_us / nk_us)
117
+
118
+ exit(1) if ENV["LEPTRIS_BENCH_LOCK"] && plan_us > nk_us * 1.5
@@ -358,11 +358,15 @@ namespace_validation_mode: :strict)
358
358
  nil
359
359
  end
360
360
 
361
- # Plan row stream (Moxml::Plan): yields |name, attrs_pairs
362
- # (flat [k, v, ...]), first-text, depth| per element in
363
- # document order (pre-order). Returns true when the adapter
364
- # provided the stream; nil/false lets the plan run its
365
- # generic wrapper walk.
361
+ # Plan row stream (Moxml::Plan) ADAPTER CONTRACT. Yields
362
+ # |name, attrs_pairs (flat [k, v, ...]), first-text, depth|
363
+ # per element in document order (pre-order); first-text is
364
+ # the element's first text child or nil. Adapters implement
365
+ # this natively off their engine nodes (nokogiri, ox, oga,
366
+ # rexml, libxml, leptris do) — Moxml::Plan executes entirely
367
+ # on these rows with no wrapper materialization. This default
368
+ # returns nil, which makes Moxml::Plan fall back to the
369
+ # generic wrapper walk — correct but slow; last resort only.
366
370
  def plan_rows(_native)
367
371
  nil
368
372
  end
@@ -1312,6 +1312,38 @@ module Moxml
1312
1312
  dst[attr_name] = attr.value
1313
1313
  end
1314
1314
  end
1315
+
1316
+ # Plan row stream (Moxml::Plan contract): pre-order element
1317
+ # rows straight off the LibXML nodes — no wrapper minting.
1318
+ def plan_rows(native)
1319
+ root = if native.is_a?(::LibXML::XML::Document)
1320
+ r = native.root
1321
+ return nil unless r
1322
+
1323
+ r
1324
+ else
1325
+ native
1326
+ end
1327
+
1328
+ walk = lambda do |el, depth|
1329
+ attrs = []
1330
+ el.attributes.each do |attr|
1331
+ attrs << attr.name << attr.value
1332
+ end
1333
+ first_text = nil
1334
+ el.children.each do |c|
1335
+ first_text ||= c.content if c.text?
1336
+ end
1337
+ yield(el.name, attrs, first_text, depth)
1338
+ el.children.each do |c|
1339
+ walk.call(c, depth + 1) if c.element?
1340
+ end
1341
+ end
1342
+ walk.call(root, 0)
1343
+ true
1344
+ end
1345
+
1346
+ public :plan_rows
1315
1347
  end
1316
1348
 
1317
1349
  # Bridge between LibXML SAX and Moxml SAX
@@ -516,6 +516,25 @@ module Moxml
516
516
  attachments.set(native_doc, :xml_decl, nil)
517
517
  end
518
518
 
519
+ # Plan row stream (Moxml::Plan contract): pre-order element
520
+ # rows |name, attrs_pairs, first-text, depth| straight off
521
+ # the libxml2-backed nodes — no wrapper minting.
522
+ def plan_rows(native)
523
+ root = native.is_a?(::Nokogiri::XML::Document) ? native.root : native
524
+ return nil unless root
525
+
526
+ walk = lambda do |el, depth|
527
+ attrs = []
528
+ el.attribute_nodes.each { |a| attrs << a.name << a.value }
529
+ first_text = nil
530
+ el.children.each { |c| first_text ||= c.content if c.text? }
531
+ yield(el.name, attrs, first_text, depth)
532
+ el.element_children.each { |c| walk.call(c, depth + 1) }
533
+ end
534
+ walk.call(root, 0)
535
+ true
536
+ end
537
+
519
538
  private
520
539
 
521
540
  def build_declaration_attrs(version, encoding, standalone)
@@ -533,6 +552,10 @@ module Moxml
533
552
  hsh.merge(attr_name => value)
534
553
  end
535
554
  end
555
+
556
+ # Plan row stream (Moxml::Plan contract): pre-order element
557
+ # rows |name, attrs_pairs, first-text, depth| straight off
558
+ # the libxml2-backed nodes — no wrapper minting.
536
559
  end
537
560
 
538
561
  # Bridge between Nokogiri SAX and Moxml SAX
@@ -643,6 +643,34 @@ module Moxml
643
643
 
644
644
  ::Moxml::Adapter::CustomizedOga::XmlGenerator.new(node).to_xml
645
645
  end
646
+
647
+ # Plan row stream (Moxml::Plan contract): pre-order element
648
+ # rows over Oga's Ruby node objects.
649
+ def plan_rows(native)
650
+ root = if native.is_a?(::Oga::XML::Document)
651
+ native.children.find { |c| c.is_a?(::Oga::XML::Element) }
652
+ else
653
+ native
654
+ end
655
+ return nil unless root.is_a?(::Oga::XML::Element)
656
+
657
+ walk = lambda do |el, depth|
658
+ attrs = []
659
+ el.attributes.each { |a| attrs << a.name << a.value }
660
+ first_text = nil
661
+ el.children.each do |c|
662
+ first_text ||= c.text if c.is_a?(::Oga::XML::Text)
663
+ end
664
+ yield(el.name, attrs, first_text, depth)
665
+ el.children.each do |c|
666
+ walk.call(c, depth + 1) if c.is_a?(::Oga::XML::Element)
667
+ end
668
+ end
669
+ walk.call(root, 0)
670
+ true
671
+ end
672
+
673
+ public :plan_rows
646
674
  end
647
675
 
648
676
  # Bridge between a parsed Oga DOM and Moxml SAX events.
@@ -955,6 +955,35 @@ module Moxml
955
955
  output << "</#{elem.name}>"
956
956
  output
957
957
  end
958
+
959
+ # Plan row stream (Moxml::Plan contract): pre-order element
960
+ # rows over Ox's Ruby node objects.
961
+ def plan_rows(native)
962
+ root = if native.is_a?(::Ox::Document)
963
+ nodes = native.nodes.grep(::Ox::Element)
964
+ nodes.first
965
+ else
966
+ native
967
+ end
968
+ return nil unless root.is_a?(::Ox::Element)
969
+
970
+ walk = lambda do |el, depth|
971
+ attrs = []
972
+ (el.attributes || {}).each { |k, v| attrs << k.to_s << v.to_s }
973
+ first_text = nil
974
+ (el.nodes || []).each do |c|
975
+ first_text ||= c if c.is_a?(::String)
976
+ end
977
+ yield(el.name.to_s, attrs, first_text, depth)
978
+ (el.nodes || []).each do |c|
979
+ walk.call(c, depth + 1) if c.is_a?(::Ox::Element)
980
+ end
981
+ end
982
+ walk.call(root, 0)
983
+ true
984
+ end
985
+
986
+ public :plan_rows
958
987
  end
959
988
  end
960
989
 
@@ -694,6 +694,24 @@ module Moxml
694
694
  )
695
695
  formatter.write(node, output)
696
696
  end
697
+
698
+ # Plan row stream (Moxml::Plan contract): pre-order element
699
+ # rows over REXML's Ruby node objects.
700
+ def plan_rows(native)
701
+ root = native.is_a?(::REXML::Document) ? native.root : native
702
+ return nil unless root
703
+
704
+ walk = lambda do |el, depth|
705
+ attrs = []
706
+ el.attributes.each { |k, v| attrs << k << v }
707
+ yield(el.name, attrs, el.text, depth)
708
+ el.elements.each { |c| walk.call(c, depth + 1) }
709
+ end
710
+ walk.call(root, 0)
711
+ true
712
+ end
713
+
714
+ public :plan_rows
697
715
  end
698
716
  end
699
717
 
data/lib/moxml/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Moxml
4
- VERSION = "0.5.61"
4
+ VERSION = "0.5.62"
5
5
  end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+
5
+ # Moxml::Plan adapter-contract conformance (leptris follow-up to
6
+ # #249): every adapter must produce IDENTICAL plan results on the
7
+ # same document, whether its plan_rows is a bulk C stream (leptris,
8
+ # nokogiri, libxml) or a Ruby-native walk (ox, oga, rexml).
9
+ PlanRow = Struct.new(:id, :kind, :fields)
10
+ PlanCell = Struct.new(:name, :unit, :value)
11
+
12
+ RSpec.describe "Moxml::Plan adapter conformance" do
13
+ # Single-line fixture: no inter-element whitespace text, so
14
+ # engines that differ on insignificant text still agree.
15
+ let(:xml) do
16
+ '<catalog><record id="r0" kind="k0"><field name="f0" unit="u0">v 0.0</field><field name="f1" unit="u1">v 0.1</field></record><record id="r1" kind="k1"><field name="f0" unit="u0">v 1.0</field></record></catalog>'
17
+ end
18
+
19
+ let(:plan) do
20
+ Moxml::Plan.new do
21
+ on("record") do |attrs, _text, fields|
22
+ PlanRow.new(attrs["id"], attrs["kind"], fields)
23
+ end
24
+ on("field") do |attrs, text, _kids|
25
+ PlanCell.new(attrs["name"], attrs["unit"], text)
26
+ end
27
+ end
28
+ end
29
+
30
+ ADAPTERS = %i[leptris nokogiri ox oga rexml libxml].freeze # rubocop:disable Lint/ConstantDefinitionInBlock, RSpec/LeakyConstantDeclaration
31
+
32
+ it "executes on every available adapter" do
33
+ available = ADAPTERS.select do |a|
34
+ ctx = Moxml.new(a)
35
+ ctx.parse(xml)
36
+ true
37
+ rescue StandardError
38
+ false
39
+ end
40
+ expect(available).not_to be_empty
41
+
42
+ results = available.to_h { |a| [a, plan.parse(xml, Moxml.new(a))] }
43
+
44
+ baseline = results[available.first]
45
+ results.each do |adapter, rows|
46
+ expect(rows).to eq(baseline), "adapter #{adapter} diverged"
47
+ end
48
+
49
+ expect(baseline.size).to eq(2)
50
+ expect(baseline[0].fields.map(&:value)).to eq(["v 0.0", "v 0.1"])
51
+ end
52
+
53
+ it "skips no element and preserves order" do
54
+ rows = plan.parse(xml, Moxml.new(:nokogiri))
55
+
56
+ expect(rows.map(&:id)).to eq(%w[r0 r1])
57
+ end
58
+
59
+ it "handles depth-3 nesting identically" do
60
+ deep = Moxml::Plan.new do
61
+ on("catalog") { |_a, _t, records| records }
62
+ on("record") { |attrs, _t, fields| [attrs["id"], fields] }
63
+ on("field") { |attrs, text, _| [attrs["name"], text] }
64
+ end
65
+
66
+ ADAPTERS.each do |a|
67
+ begin
68
+ ctx = Moxml.new(a)
69
+ ctx.parse(xml)
70
+ rescue StandardError
71
+ next
72
+ end
73
+ out = deep.parse(xml, ctx)
74
+ expect(out[0]).to eq(
75
+ [["r0", [["f0", "v 0.0"], ["f1", "v 0.1"]]],
76
+ ["r1", [["f0", "v 1.0"]]]],
77
+ )
78
+ end
79
+ end
80
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: moxml
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.61
4
+ version: 0.5.62
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
@@ -39,6 +39,7 @@ files:
39
39
  - README.adoc
40
40
  - Rakefile
41
41
  - benchmark/pipeline_bench.rb
42
+ - benchmark/plan_bench.rb
42
43
  - benchmarks/.gitignore
43
44
  - benchmarks/generate_report.rb
44
45
  - bin/console
@@ -488,6 +489,7 @@ files:
488
489
  - spec/moxml/opal_oga_smoke_spec.rb
489
490
  - spec/moxml/opal_rexml_adapter_spec.rb
490
491
  - spec/moxml/opal_smoke_spec.rb
492
+ - spec/moxml/plan_conformance_spec.rb
491
493
  - spec/moxml/plan_spec.rb
492
494
  - spec/moxml/processing_instruction_spec.rb
493
495
  - spec/moxml/readonly_parse_spec.rb