lutaml-model 0.8.23 → 0.8.28

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 (73) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/performance.yml +1 -1
  3. data/Gemfile +6 -0
  4. data/README.adoc +16 -7
  5. data/docs/_guides/index.adoc +4 -0
  6. data/docs/_guides/native-engines.adoc +61 -0
  7. data/docs/_pages/configuration.adoc +11 -7
  8. data/docs/_pages/serialization_adapters.adoc +14 -5
  9. data/lib/compat/opal/lutaml_model_boot.rb +5 -0
  10. data/lib/lutaml/json/adapter/multi_json_adapter.rb +12 -1
  11. data/lib/lutaml/json/adapter/yeptris_adapter.rb +21 -0
  12. data/lib/lutaml/json/adapter.rb +1 -0
  13. data/lib/lutaml/json/format.rb +1 -1
  14. data/lib/lutaml/key_value/adapter/json/multi_json_adapter.rb +16 -1
  15. data/lib/lutaml/key_value/adapter/json/yeptris_adapter.rb +15 -0
  16. data/lib/lutaml/key_value/adapter/toml/teptris_adapter.rb +42 -0
  17. data/lib/lutaml/key_value/adapter/toml.rb +1 -0
  18. data/lib/lutaml/key_value/adapter/yaml/yeptris_adapter.rb +15 -0
  19. data/lib/lutaml/key_value/transform.rb +82 -0
  20. data/lib/lutaml/key_value/transformation/value_serializer.rb +25 -0
  21. data/lib/lutaml/key_value/transformation.rb +25 -1
  22. data/lib/lutaml/model/adapter_resolver.rb +23 -6
  23. data/lib/lutaml/model/attribute.rb +31 -6
  24. data/lib/lutaml/model/compiled_rule.rb +16 -0
  25. data/lib/lutaml/model/configuration.rb +5 -0
  26. data/lib/lutaml/model/error/unknown_adapter_type_error.rb +4 -4
  27. data/lib/lutaml/model/serialize/format_conversion.rb +65 -0
  28. data/lib/lutaml/model/toml.rb +10 -6
  29. data/lib/lutaml/model/type/date_time.rb +2 -2
  30. data/lib/lutaml/model/type/value.rb +24 -2
  31. data/lib/lutaml/model/utils.rb +6 -5
  32. data/lib/lutaml/model/version.rb +1 -1
  33. data/lib/lutaml/toml/adapter/teptris_adapter.rb +14 -0
  34. data/lib/lutaml/toml/adapter.rb +1 -0
  35. data/lib/lutaml/toml/format.rb +1 -1
  36. data/lib/lutaml/xml/adapter/leptris_adapter.rb +15 -0
  37. data/lib/lutaml/xml/adapter_element.rb +111 -3
  38. data/lib/lutaml/xml/builder/base.rb +3 -1
  39. data/lib/lutaml/xml/builder/leptris.rb +13 -0
  40. data/lib/lutaml/xml/builder/nokogiri.rb +3 -0
  41. data/lib/lutaml/xml/builder.rb +1 -0
  42. data/lib/lutaml/xml/declaration_planner.rb +8 -2
  43. data/lib/lutaml/xml/format.rb +7 -1
  44. data/lib/lutaml/xml/leptris/element.rb +34 -0
  45. data/lib/lutaml/xml/namespace_collector.rb +4 -3
  46. data/lib/lutaml/xml/plan_compiler.rb +253 -0
  47. data/lib/lutaml/xml/plan_hydrator.rb +341 -0
  48. data/lib/lutaml/xml/plan_order.rb +50 -0
  49. data/lib/lutaml/xml/plan_serializer.rb +241 -0
  50. data/lib/lutaml/xml/plan_walk.rb +30 -0
  51. data/lib/lutaml/xml/transformation/custom_method_wrapper.rb +8 -0
  52. data/lib/lutaml/xml/xml_element.rb +6 -2
  53. data/lib/lutaml/yaml/adapter/yeptris_adapter.rb +46 -0
  54. data/lib/lutaml/yaml/adapter.rb +5 -0
  55. data/lib/lutaml/yaml/format.rb +1 -1
  56. data/lib/tasks/performance.rake +5 -0
  57. data/lib/tasks/performance_comparator.rb +19 -3
  58. data/lib/tasks/performance_probe.rake +33 -0
  59. data/lib/tasks/performance_probe.rb +106 -0
  60. data/lib/tasks/performance_probe_plan_walk.rb +58 -0
  61. data/lib/tasks/performance_probe_xml.rb +54 -0
  62. data/lutaml-model.gemspec +1 -1
  63. data/spec/lutaml/json/yeptris_adapter_spec.rb +63 -0
  64. data/spec/lutaml/key_value/adapter/toml/teptris_adapter_spec.rb +71 -0
  65. data/spec/lutaml/model/json_adapter_spec.rb +6 -0
  66. data/spec/lutaml/model/toml_adapter_spec.rb +8 -0
  67. data/spec/lutaml/model/toml_spec.rb +36 -1
  68. data/spec/lutaml/xml/plan_fast_path_spec.rb +547 -0
  69. data/spec/lutaml/xml/plan_walk_spec.rb +102 -0
  70. data/spec/lutaml/xml/xml_spec.rb +13 -0
  71. data/spec/lutaml/yaml/yeptris_adapter_spec.rb +63 -0
  72. data/spec/spec_helper.rb +7 -1
  73. metadata +28 -4
@@ -0,0 +1,241 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Xml
5
+ # Serialize-side mirror of the plan fast path: builds the output
6
+ # tree directly through leptris's fused create_child chain — no
7
+ # XmlElement tree, no DeclarationPlanner pass, no builder context
8
+ # stack, no moxml wrapper minting. Serialized shapes must be the
9
+ # plain ones (attributes, scalars, collections, nested models,
10
+ # raw fragments, content runs); custom-method, polymorphic,
11
+ # union, and multi-spelling rows keep the interpretive serializer
12
+ # (their output flows through hash-shaped custom APIs).
13
+ #
14
+ # Ordered/mixed models serialize from element_order when the
15
+ # instance carries one (parsed models): text runs, comments, and
16
+ # element entries in document order, mirroring the interpretive
17
+ # order applier — including its content-attr-first text reads
18
+ # (mutations to the model show up) and CDATA flattening. Models
19
+ # without element_order fall back interpretively (return nil).
20
+ module PlanSerializer
21
+ SERIALIZABLE_KINDS = %i[scalar collection_native collection_cb
22
+ nested ordered_deferred raw content
23
+ content_deferred].freeze
24
+ CONTENT_KINDS = %i[content content_deferred].freeze
25
+
26
+ class << self
27
+ # plan: the compiler's entry for instance's class
28
+ def call(instance, plan)
29
+ return nil if plan[:ordered] && instance.element_order.nil?
30
+
31
+ doc = ::Leptris::XML::Document.create
32
+ root = doc.create_element(plan[:tree][:name])
33
+ doc.root = root
34
+ if plan[:ordered]
35
+ build_ordered(root, instance, plan, doc)
36
+ else
37
+ build(root, instance, plan, doc)
38
+ end
39
+ doc.to_xml(indent: 2, no_decl: true)
40
+ end
41
+
42
+ # Whether the compiled plan is serialize-shaped. Ordered/mixed
43
+ # models serialize through build_ordered from element_order;
44
+ # plain models through plan rows.
45
+ def serializable?(plan)
46
+ plan[:rows].all? do |_rule, _attr, kind, _sp, _del|
47
+ SERIALIZABLE_KINDS.include?(kind)
48
+ end
49
+ end
50
+
51
+ private
52
+
53
+ def register
54
+ Lutaml::Model::Config.default_register
55
+ end
56
+
57
+ def build(element, instance, plan, doc)
58
+ write_attributes(element, instance, plan)
59
+ plan[:rows].each do |rule, attr, kind, spelling, delegate|
60
+ value = value_of(instance, attr, delegate)
61
+ next unless rule.render?(value, instance)
62
+
63
+ case kind
64
+ when :scalar
65
+ add_leaf(element, row_name(rule, spelling),
66
+ attr.serialize(value, :xml, register), doc)
67
+ when :collection_native, :collection_cb
68
+ Array(value).each do |item|
69
+ add_leaf(element, rule.name.to_s,
70
+ attr.serialize(item, :xml, register), doc)
71
+ end
72
+ when :nested, :ordered_deferred
73
+ Array(value).each do |item|
74
+ child_plan = PlanCompiler.compile(item.class, register)
75
+ child = element.create_child(child_plan[:tree][:name])
76
+ if child_plan[:ordered]
77
+ return nil unless build_ordered(child, item, child_plan, doc)
78
+
79
+ else
80
+ build(child, item, child_plan, doc)
81
+ end
82
+ end
83
+ when :raw
84
+ Array(value).each { |raw| element.add_child(raw.to_s) }
85
+ when :content, :content_deferred
86
+ Array(value).each { |run| element.add_child(doc.create_text_node(run.to_s)) }
87
+ end
88
+ end
89
+ end
90
+
91
+ # Element_order-driven emission (the interpretive order
92
+ # applier's contract): text runs and comments verbatim, each
93
+ # element entry consuming the next item of its collection
94
+ # (document order). Text reads prefer the content attribute
95
+ # when it lines up with the text-node count, so mutations to
96
+ # the model are reflected; element_order text is the fallback.
97
+ # PIs drop (interpretive parity). Returns nil when a nested
98
+ # ordered child lacks element_order (caller falls back).
99
+ def build_ordered(element, instance, plan, doc)
100
+ write_attributes(element, instance, plan)
101
+ rows_by_name = {}
102
+ plan[:rows].each do |rule, attr, kind, spelling, delegate|
103
+ next unless rule.name
104
+
105
+ rows_by_name[rule.name.to_s] ||= [rule, attr, kind, spelling,
106
+ delegate]
107
+ end
108
+ content_entry = plan[:rows].find do |rule, _attr, kind, _sp, _del|
109
+ CONTENT_KINDS.include?(kind) && rule.name.nil?
110
+ end
111
+ order = instance.element_order
112
+ element_indices = ::Hash.new(0)
113
+ content_attr = content_entry && content_entry[1]
114
+ content_value = content_attr &&
115
+ value_of(instance, content_attr, content_entry[4])
116
+ text_node_count = order.count { |o| o.type == "Text" }
117
+ use_content_index = content_value.is_a?(Array) &&
118
+ content_value.length == text_node_count
119
+ content_cdata = content_entry && content_entry[0].cdata
120
+ text_node_index = 0
121
+
122
+ order.each do |object|
123
+ case object.type
124
+ when "Text"
125
+ text = if content_attr && !content_value.nil?
126
+ if use_content_index
127
+ content_value[text_node_index].to_s
128
+ elsif !content_value.is_a?(Array) && text_node_count <= 1
129
+ content_value.to_s
130
+ else
131
+ object.text_content || object.name
132
+ end
133
+ else
134
+ object.text_content || object.name
135
+ end
136
+ text_node_index += 1
137
+ next if text.nil?
138
+ # Mixed content keeps whitespace; ordered-only skips it
139
+ next if content_attr.nil? && text.strip.empty?
140
+
141
+ element.add_child(content_cdata ? doc.create_cdata(text) : doc.create_text_node(text))
142
+ when "Comment"
143
+ element.add_child(doc.create_comment(object.text_content))
144
+ when "Element"
145
+ entry = rows_by_name[object.name]
146
+ next unless entry
147
+
148
+ _, attr, kind, _spelling, delegate = entry
149
+ value = value_of(instance, attr, delegate)
150
+ case kind
151
+ when :collection_native, :collection_cb
152
+ index = element_indices[object.name]
153
+ items = Array(value)
154
+ next unless index < items.length
155
+
156
+ element_indices[object.name] += 1
157
+ add_leaf(element, object.name,
158
+ attr.serialize(items[index], :xml, register), doc)
159
+ when :nested, :ordered_deferred
160
+ item = if attr.collection?
161
+ index = element_indices[object.name]
162
+ element_indices[object.name] += 1
163
+ Array(value)[index]
164
+ else
165
+ value
166
+ end
167
+ next unless item
168
+
169
+ child_plan = PlanCompiler.compile(item.class, register)
170
+ child = element.create_child(child_plan[:tree][:name])
171
+ if child_plan[:ordered]
172
+ return nil unless build_ordered(child, item, child_plan, doc)
173
+ else
174
+ build(child, item, child_plan, doc)
175
+ end
176
+ when :raw
177
+ element.add_child(value.to_s) unless value.nil?
178
+ when :scalar
179
+ add_leaf(element, object.name,
180
+ attr.serialize(value, :xml, register), doc)
181
+ end
182
+ end
183
+ end
184
+ true
185
+ end
186
+
187
+ # Attributes in document order when the instance recorded it
188
+ # (parsed models), else plan row order.
189
+ def write_attributes(element, instance, plan)
190
+ recorded = instance.attribute_order
191
+ if recorded && !recorded.empty?
192
+ by_name = {}
193
+ plan[:attr_rows].each do |rule, attr, _sp, delegate|
194
+ by_name[rule.name.to_s] = [rule, attr, delegate]
195
+ end
196
+ names = recorded |
197
+ plan[:attr_rows].map { |rule, _a, _s, _d| rule.name.to_s }
198
+ names.each do |name|
199
+ write_attribute(element, instance, name, by_name[name])
200
+ end
201
+ else
202
+ plan[:attr_rows].each do |rule, attr, _sp, delegate|
203
+ write_attribute(element, instance, rule.name.to_s,
204
+ [rule, attr, delegate])
205
+ end
206
+ end
207
+ end
208
+
209
+ def write_attribute(element, instance, name, entry)
210
+ return unless entry
211
+
212
+ _rule, attr, delegate = entry
213
+ value = value_of(instance, attr, delegate)
214
+ return if value.nil?
215
+
216
+ element.set_attribute(name,
217
+ attr.serialize(value, :xml, register).to_s)
218
+ end
219
+
220
+ def add_leaf(element, name, value, doc)
221
+ child = element.create_child(name)
222
+ child.add_child(doc.create_text_node(value.to_s)) unless value.nil?
223
+ child
224
+ end
225
+
226
+ def value_of(instance, attr, delegate)
227
+ if delegate
228
+ target = instance.public_send(delegate)
229
+ target&.public_send(attr.name)
230
+ else
231
+ instance.public_send(attr.name)
232
+ end
233
+ end
234
+
235
+ def row_name(rule, spelling)
236
+ (spelling || rule.name).to_s
237
+ end
238
+ end
239
+ end
240
+ end
241
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Xml
5
+ # Public consumer surface for the plan fast path: one engine
6
+ # parse + descriptor walk and direct model hydration, bypassing
7
+ # the wrapper-tree construction that dominates large-document
8
+ # consumer paths. The returned object is the mapped model root;
9
+ # collection attributes remain collections on that model.
10
+ module PlanWalk
11
+ class << self
12
+ # Build the plan, walk the document, and hydrate the mapped
13
+ # root. Returns nil when the model is not plan-compilable.
14
+ def call(model_class, xml, _options = {})
15
+ register = Lutaml::Model::Config.default_register
16
+ plan = PlanCompiler.compile(model_class, register)
17
+ return nil unless plan
18
+
19
+ document = ::Leptris::XML.parse(xml)
20
+ root = document.root
21
+ return nil unless root && root.name == plan[:tree][:name]
22
+
23
+ PlanHydrator.call(model_class, plan,
24
+ plan[:descriptor].walk(root),
25
+ node: root)
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -80,6 +80,14 @@ module Lutaml
80
80
  require "moxml" unless defined?(Moxml)
81
81
  fragment_doc = Moxml.new.parse(fragment_string, fragment: true)
82
82
  add_fragment_children_to_parent(fragment_doc, parent)
83
+ rescue Moxml::ParseError
84
+ # Engines without a fragment mode (leptris today, lutaml/moxml#188)
85
+ # reject multiple top-level nodes; re-parse under a synthetic root
86
+ # and lift its children.
87
+ wrapped = Moxml.new.parse(
88
+ "<lutaml-fragment>#{fragment_string}</lutaml-fragment>",
89
+ )
90
+ add_fragment_children_to_parent(wrapped.root, parent)
83
91
  rescue LoadError
84
92
  append_raw_content(parent, fragment_string)
85
93
  end
@@ -299,7 +299,7 @@ module Lutaml
299
299
  end
300
300
 
301
301
  def text
302
- return @text if children.empty?
302
+ return @text if @children.empty?
303
303
  return @computed_text if defined?(@computed_text)
304
304
 
305
305
  @computed_text = if content_bearing_children_count > 1
@@ -393,7 +393,11 @@ module Lutaml
393
393
 
394
394
  @children_index = {}
395
395
  @children.each do |child|
396
- next if child.is_a?(XmlElement) && child.processing_instruction?
396
+ # Index element children only: the index serves by-name element
397
+ # lookup, and AdapterElement keeps non-element children raw
398
+ # (lazily wrapped) until #children is read.
399
+ next unless child.is_a?(XmlElement)
400
+ next if child.processing_instruction?
397
401
 
398
402
  key = child.namespaced_name
399
403
  @children_index[key] ||= []
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "standard_adapter"
4
+
5
+ module Lutaml
6
+ module Yaml
7
+ module Adapter
8
+ # YAML over the yeptris engine (libyeptris C11). Uses the native
9
+ # Yeptris::YAML surface, which does not rebind or interact with the
10
+ # Psych constant, so it coexists with the standard adapter in one
11
+ # process. Inherits the document/generation contract; only the
12
+ # engine calls differ.
13
+ #
14
+ # Semantic notes vs the standard adapter's Psych.safe_load:
15
+ # implicit typing is parity-verified (compat_11 schema); anchors
16
+ # and aliases always resolve; a tagged value without a core type
17
+ # materializes as plain data instead of raising DisallowedClass.
18
+ class YeptrisAdapter < StandardAdapter
19
+ # yeptris is required lazily: the ruby-platform variant installs
20
+ # everywhere but only loads where libyeptris exists (no Windows
21
+ # prebuilts) — a broken install must degrade to detection
22
+ # fallback, never crash loads.
23
+ def self.parse(yaml, _options = {})
24
+ require "yeptris"
25
+ require "yeptris/yaml"
26
+ # rubocop:disable-next Naming/VariableNumber -- upstream schema literal
27
+ Yeptris::YAML.load(yaml, schema: :compat_11)
28
+ end
29
+
30
+ def to_yaml(_options = {})
31
+ attributes_to_serialize = if @attributes.is_a?(Lutaml::KeyValue::DataModel::Element)
32
+ # Unwrap __root__ wrapper to get actual content
33
+ @attributes.to_hash["__root__"]
34
+ else
35
+ # Legacy Hash input (backward compatibility)
36
+ @attributes
37
+ end
38
+
39
+ require "yeptris"
40
+ require "yeptris/yaml"
41
+ Yeptris::YAML.dump(attributes_to_serialize)
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -8,6 +8,11 @@ module Lutaml
8
8
  autoload :MappingRule, "#{__dir__}/adapter/mapping_rule"
9
9
  autoload :Transform, "#{__dir__}/adapter/transform"
10
10
  autoload :StandardAdapter, "#{__dir__}/adapter/standard_adapter"
11
+ # MRI-only native engine adapter (libyeptris FFI)
12
+ Lutaml::Model::RuntimeCompatibility.autoload_native(
13
+ self,
14
+ YeptrisAdapter: "#{__dir__}/adapter/yeptris_adapter",
15
+ )
11
16
  end
12
17
  end
13
18
  end
@@ -21,7 +21,7 @@ Lutaml::Model::FormatRegistry.register(
21
21
  transformer: Lutaml::Yaml::Adapter::Transform,
22
22
  key_value: true,
23
23
  adapter_options: {
24
- available: %i[standard standard_yaml],
24
+ available: %i[standard standard_yaml yeptris],
25
25
  default: :standard,
26
26
  },
27
27
  )
@@ -63,6 +63,11 @@ namespace :performance do
63
63
  run_bench("#{BENCH_DIR}/bench_unitsml.rb")
64
64
  end
65
65
 
66
+ desc "XML model-pipeline performance probe (from_xml/to_xml i/s + allocs)"
67
+ task :probe_xml do
68
+ require_relative "performance_probe_xml"
69
+ end
70
+
66
71
  # --- Gate comparison ---
67
72
 
68
73
  desc "Compare downstream benchmark JSON results (gate enforcement)"
@@ -12,9 +12,9 @@ class PerformanceComparator
12
12
 
13
13
  BENCHMARK_MATRIX = {
14
14
  xml: %i[nokogiri ox oga rexml],
15
- json: %i[standard_json multi_json oj],
16
- yaml: %i[standard_yaml],
17
- toml: %i[toml_rb tomlib],
15
+ json: %i[standard_json multi_json oj yeptris],
16
+ yaml: %i[standard_yaml yeptris],
17
+ toml: %i[toml_rb tomlib teptris],
18
18
  }.freeze
19
19
 
20
20
  DIRECTIONS = %i[from to].freeze
@@ -107,8 +107,24 @@ class PerformanceComparator
107
107
  in [:toml, :tomlib]
108
108
  # Skip tomlib on Windows due to segmentation fault issues
109
109
  Gem.win_platform?
110
+ in [:yaml | :json, :yeptris]
111
+ # yeptris ships prebuilt platform gems for linux/darwin only
112
+ Gem.win_platform?
113
+ in [:toml, :teptris]
114
+ teptris_loadable?
110
115
  else
111
116
  false
112
117
  end
113
118
  end
119
+
120
+ # A teptris platform gem whose native library cannot load (the 0.2.17/
121
+ # 0.2.18 x86_64-linux/x64-mingw-ucrt soname gap) must skip its cell,
122
+ # not crash the comparator run.
123
+ def teptris_loadable?
124
+ require "teptris"
125
+ false
126
+ rescue LoadError, StandardError
127
+ warn "performance comparator: teptris not loadable here — skipping its cell"
128
+ true
129
+ end
114
130
  end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ # One-command performance probe for the model pipeline (TODO.perf/01).
4
+ #
5
+ # rake performance:probe # steady-state i/s + allocations/op
6
+ # rake performance:profile # ruby-prof flat profiles (parse + dump)
7
+ # rake performance:oneshot # fresh-process boot+parse-once medians
8
+ desc "Performance probes for the model pipeline"
9
+ namespace :performance do
10
+ desc "steady-state i/s and allocations per op (nested 200-item model)"
11
+ task :probe do
12
+ system({ "BENCH_DIR" => nil }, Gem.ruby, File.expand_path("performance_probe.rb", __dir__), "probe") || raise("probe failed")
13
+ end
14
+
15
+ desc "ruby-prof flat profiles for from_yaml and to_yaml"
16
+ task :profile do
17
+ system(Gem.ruby, File.expand_path("performance_probe.rb", __dir__), "profile") || raise("profile failed")
18
+ end
19
+
20
+ desc "one-shot (fresh process) boot+define and parse-once medians"
21
+ task :oneshot do
22
+ boots = []
23
+ parses = []
24
+ 5.times do
25
+ out = `#{Gem.ruby} #{File.expand_path("performance_probe.rb", __dir__)} oneshot`
26
+ boots << Regexp.last_match(1).to_f if out =~ /boot\+define: ([\d.]+)ms/
27
+ parses << Regexp.last_match(1).to_f if out =~ /parse-once: ([\d.]+)ms/
28
+ end
29
+ med = ->(a) { a.sort[a.length / 2] }
30
+ puts format("boot+define median: %<boot>.1fms parse-once median: %<parse>.1fms",
31
+ boot: med.call(boots), parse: med.call(parses)) + " (#{boots.length} runs)"
32
+ end
33
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Model-pipeline performance probe (TODO.perf/01). Run via
4
+ # rake performance:probe / :profile / :oneshot, or directly:
5
+ # ruby lib/tasks/performance_probe.rb probe|profile|oneshot
6
+
7
+ MODE = ARGV[0] || "probe"
8
+
9
+ # One-shot mode times the require itself; every other mode needs it up front.
10
+ require "lutaml/model" unless MODE == "oneshot"
11
+
12
+ def define_models
13
+ item = Class.new(Lutaml::Model::Serializable) do
14
+ attribute :id, :integer
15
+ attribute :name, :string
16
+ attribute :tags, :string, collection: true
17
+
18
+ key_value do
19
+ map "id", to: :id
20
+ map "name", to: :name
21
+ map "tags", to: :tags
22
+ end
23
+ end
24
+ root = Class.new(Lutaml::Model::Serializable) do
25
+ attribute :item, item, collection: true
26
+
27
+ key_value { map "item", to: :item }
28
+ end
29
+ [item, root]
30
+ end
31
+
32
+ def build_root(item, root, count)
33
+ root.new(item: (0...count).map { |i| item.new(id: i, name: "Test #{i}", tags: %w[a b c]) })
34
+ end
35
+
36
+ def ips_of(seconds = 3)
37
+ require "benchmark/ips"
38
+ lambda do |label, &blk|
39
+ r = Benchmark.ips(time: seconds, quiet: true) { |x| x.report(label, &blk) }
40
+ r.entries.max_by(&:ips).ips.round(1)
41
+ end
42
+ end
43
+
44
+ def allocs_of(&blk)
45
+ GC.start
46
+ before = GC.stat(:total_allocated_objects)
47
+ 50.times(&blk)
48
+ (GC.stat(:total_allocated_objects) - before) / 50.0
49
+ end
50
+
51
+ case MODE
52
+ when "probe"
53
+ item, root = define_models
54
+ model = build_root(item, root, 200)
55
+ yaml_doc = model.to_yaml
56
+ json_doc = model.to_json
57
+ root.from_yaml(yaml_doc)
58
+
59
+ ips = ips_of
60
+ puts format("%<label>s: %<ips>8.1f i/s allocs/op: %<allocs>8.1f",
61
+ label: "from_yaml",
62
+ ips: ips.call("fy") { root.from_yaml(yaml_doc) },
63
+ allocs: allocs_of { root.from_yaml(yaml_doc) })
64
+ puts format("%<label>s: %<ips>8.1f i/s allocs/op: %<allocs>8.1f",
65
+ label: "to_yaml ",
66
+ ips: ips.call("ty") { model.to_yaml },
67
+ allocs: allocs_of { model.to_yaml })
68
+ puts format("%<label>s: %<ips>8.1f i/s allocs/op: %<allocs>8.1f",
69
+ label: "from_json",
70
+ ips: ips.call("fj") { root.from_json(json_doc) },
71
+ allocs: allocs_of { root.from_json(json_doc) })
72
+ puts format("%<label>s: %<ips>8.1f i/s allocs/op: %<allocs>8.1f",
73
+ label: "to_json ",
74
+ ips: ips.call("tj") { model.to_json },
75
+ allocs: allocs_of { model.to_json })
76
+ when "profile"
77
+ item, root = define_models
78
+ model = build_root(item, root, 200)
79
+ yaml_doc = model.to_yaml
80
+ root.from_yaml(yaml_doc)
81
+
82
+ require "ruby-prof"
83
+ def print_profile(profile, label)
84
+ puts "===== #{label} (20 iterations, self-time) ====="
85
+ printer = RubyProf::FlatPrinter.new(profile)
86
+ begin
87
+ printer.print($stdout, {})
88
+ rescue ArgumentError
89
+ printer.print($stdout)
90
+ end
91
+ end
92
+
93
+ print_profile(RubyProf::Profile.profile { 20.times { root.from_yaml(yaml_doc) } }, "from_yaml")
94
+ print_profile(RubyProf::Profile.profile { 20.times { model.to_yaml } }, "to_yaml")
95
+ when "oneshot"
96
+ # Fresh-process mode: this script IS the one-shot body; the rake task
97
+ # re-invokes it N times and reports medians.
98
+ t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
99
+ require "lutaml/model"
100
+ item, root = define_models
101
+ t1 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
102
+ yaml_doc = build_root(item, root, 200).to_yaml
103
+ root.from_yaml(yaml_doc)
104
+ t2 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
105
+ puts format("boot+define: %<boot>.1fms parse-once: %<parse>.1fms", boot: (t1 - t0) * 1000, parse: (t2 - t1) * 1000)
106
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "lutaml/model"
4
+ require "leptris"
5
+ require "leptris/xml/descriptor"
6
+ require "benchmark"
7
+
8
+ module PlanWalkProbe
9
+ class BulkItem < Lutaml::Model::Serializable
10
+ attribute :id, :integer
11
+ attribute :name, :string
12
+ attribute :tags, :string, collection: true
13
+
14
+ xml do
15
+ element "row"
16
+ map_attribute "id", to: :id
17
+ map_element "name", to: :name
18
+ map_element "tags", to: :tags
19
+ end
20
+ end
21
+
22
+ class BulkRoot < Lutaml::Model::Serializable
23
+ attribute :item, BulkItem, collection: true
24
+
25
+ xml do
26
+ element "iso"
27
+ map_element "row", to: :item
28
+ end
29
+ end
30
+ end
31
+
32
+ xml = File.read("/tmp/iso.xml")
33
+ Lutaml::Model::Config.adapter_for(:xml) # resolve adapter before benchmarking
34
+
35
+ measure = lambda do |label, &block|
36
+ elapsed = Benchmark.measure { 3.times(&block) }.real * 1000
37
+ puts "#{label}: %.2f ms total / %.2f ms each" % [elapsed, elapsed / 3]
38
+ end
39
+
40
+ measure.call("raw parse + descriptor walk") do
41
+ descriptor = Leptris::XML::Descriptor.build(
42
+ name: "iso",
43
+ children: [{ name: "row", kind: :collection }],
44
+ )
45
+ descriptor.walk(Leptris::XML.parse(xml).root)
46
+ end
47
+
48
+ Lutaml::Model::Config.instance.xml_plan_fast_path = true
49
+ measure.call("PlanWalk parse + 5000 model.new") do
50
+ result = Lutaml::Xml::PlanWalk.call(PlanWalkProbe::BulkRoot, xml)
51
+ raise "PlanWalk returned nil" unless result&.item&.size == 5000
52
+ end
53
+
54
+ Lutaml::Model::Config.instance.xml_plan_fast_path = false
55
+ measure.call("interpretive from_xml") do
56
+ result = PlanWalkProbe::BulkRoot.from_xml(xml)
57
+ raise "interpretive returned wrong size" unless result.item.size == 5000
58
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ # XML pipeline probe (mirrors performance_probe.rb's KV shape): quantifies
4
+ # the lutaml-model XML layer over the moxml runtime-default engine.
5
+ # ruby lib/tasks/performance_probe.rb xml
6
+
7
+ require "lutaml/model"
8
+
9
+ item = Class.new(Lutaml::Model::Serializable) do
10
+ attribute :id, :integer
11
+ attribute :name, :string
12
+ attribute :tags, :string, collection: true
13
+
14
+ xml do
15
+ element "item"
16
+ map_attribute "id", to: :id
17
+ map_element "name", to: :name
18
+ map_element "tag", to: :tags
19
+ end
20
+ end
21
+ root = Class.new(Lutaml::Model::Serializable) do
22
+ attribute :item, item, collection: true
23
+
24
+ xml do
25
+ element "root"
26
+ map_element "item", to: :item
27
+ end
28
+ end
29
+
30
+ model = root.new(item: (0...200).map { |i| item.new(id: i, name: "Test #{i}", tags: %w[a b c]) })
31
+ xml_doc = model.to_xml
32
+ root.from_xml(xml_doc)
33
+
34
+ ips = lambda do |label, &blk|
35
+ require "benchmark/ips"
36
+ r = Benchmark.ips(time: 3, quiet: true) { |x| x.report(label, &blk) }
37
+ r.entries.max_by(&:ips).ips.round(1)
38
+ end
39
+
40
+ allocs = lambda do |&blk|
41
+ GC.start
42
+ before = GC.stat(:total_allocated_objects)
43
+ 50.times(&blk)
44
+ (GC.stat(:total_allocated_objects) - before) / 50.0
45
+ end
46
+
47
+ fy = ips.call("fx") { root.from_xml(xml_doc) }
48
+ fa = allocs.call { root.from_xml(xml_doc) }
49
+ ty = ips.call("tx") { model.to_xml }
50
+ ta = allocs.call { model.to_xml }
51
+
52
+ puts format("from_xml: %<ips>8.1f i/s allocs/op: %<allocs>8.1f", ips: fy, allocs: fa)
53
+ puts format("to_xml : %<ips>8.1f i/s allocs/op: %<allocs>8.1f", ips: ty, allocs: ta)
54
+ puts "engine: #{Moxml::VERSION} / #{Leptris::VERSION}"