lutaml-model 0.8.22 → 0.8.24

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 (88) 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/_guides/xml-mapping.adoc +31 -0
  8. data/docs/_pages/configuration.adoc +11 -7
  9. data/docs/_pages/serialization_adapters.adoc +14 -5
  10. data/lib/compat/opal/lutaml_model_boot.rb +2 -0
  11. data/lib/lutaml/json/adapter/multi_json_adapter.rb +12 -1
  12. data/lib/lutaml/json/adapter/oj_adapter.rb +7 -0
  13. data/lib/lutaml/json/adapter/standard_adapter.rb +17 -5
  14. data/lib/lutaml/json/adapter/yeptris_adapter.rb +21 -0
  15. data/lib/lutaml/json/adapter.rb +1 -0
  16. data/lib/lutaml/json/format.rb +1 -1
  17. data/lib/lutaml/json/generator_options.rb +62 -0
  18. data/lib/lutaml/jsonl/adapter/standard_adapter.rb +1 -1
  19. data/lib/lutaml/jsonld/adapter.rb +12 -4
  20. data/lib/lutaml/key_value/adapter/json/multi_json_adapter.rb +16 -1
  21. data/lib/lutaml/key_value/adapter/json/oj_adapter.rb +7 -0
  22. data/lib/lutaml/key_value/adapter/json/standard_adapter.rb +17 -5
  23. data/lib/lutaml/key_value/adapter/json/yeptris_adapter.rb +15 -0
  24. data/lib/lutaml/key_value/adapter/jsonl/standard_adapter.rb +1 -1
  25. data/lib/lutaml/key_value/adapter/toml/teptris_adapter.rb +42 -0
  26. data/lib/lutaml/key_value/adapter/toml.rb +1 -0
  27. data/lib/lutaml/key_value/adapter/yaml/yeptris_adapter.rb +15 -0
  28. data/lib/lutaml/key_value/transform.rb +82 -0
  29. data/lib/lutaml/key_value/transformation/value_serializer.rb +25 -0
  30. data/lib/lutaml/key_value/transformation.rb +25 -1
  31. data/lib/lutaml/model/adapter_resolver.rb +23 -6
  32. data/lib/lutaml/model/attribute.rb +31 -6
  33. data/lib/lutaml/model/collection.rb +5 -0
  34. data/lib/lutaml/model/configuration.rb +5 -0
  35. data/lib/lutaml/model/error/unknown_adapter_type_error.rb +4 -4
  36. data/lib/lutaml/model/serialization_adapter.rb +1 -1
  37. data/lib/lutaml/model/serialize/format_conversion.rb +75 -7
  38. data/lib/lutaml/model/serialize.rb +18 -0
  39. data/lib/lutaml/model/toml.rb +10 -6
  40. data/lib/lutaml/model/transformation.rb +5 -0
  41. data/lib/lutaml/model/type/date_time.rb +2 -2
  42. data/lib/lutaml/model/type/value.rb +24 -2
  43. data/lib/lutaml/model/utils.rb +6 -5
  44. data/lib/lutaml/model/version.rb +1 -1
  45. data/lib/lutaml/toml/adapter/teptris_adapter.rb +14 -0
  46. data/lib/lutaml/toml/adapter.rb +1 -0
  47. data/lib/lutaml/toml/format.rb +1 -1
  48. data/lib/lutaml/xml/adapter/leptris_adapter.rb +15 -0
  49. data/lib/lutaml/xml/adapter_element.rb +111 -3
  50. data/lib/lutaml/xml/builder/base.rb +3 -1
  51. data/lib/lutaml/xml/builder/leptris.rb +13 -0
  52. data/lib/lutaml/xml/builder/nokogiri.rb +3 -0
  53. data/lib/lutaml/xml/builder.rb +1 -0
  54. data/lib/lutaml/xml/format.rb +4 -1
  55. data/lib/lutaml/xml/leptris/element.rb +34 -0
  56. data/lib/lutaml/xml/mapping.rb +24 -0
  57. data/lib/lutaml/xml/model_transform.rb +52 -1
  58. data/lib/lutaml/xml/plan_compiler.rb +139 -0
  59. data/lib/lutaml/xml/plan_hydrator.rb +80 -0
  60. data/lib/lutaml/xml/transformation/custom_method_wrapper.rb +8 -0
  61. data/lib/lutaml/xml/transformation.rb +29 -0
  62. data/lib/lutaml/xml/xml_element.rb +6 -2
  63. data/lib/lutaml/yaml/adapter/yeptris_adapter.rb +40 -0
  64. data/lib/lutaml/yaml/adapter.rb +5 -0
  65. data/lib/lutaml/yaml/format.rb +1 -1
  66. data/lib/tasks/performance.rake +5 -0
  67. data/lib/tasks/performance_comparator.rb +19 -3
  68. data/lib/tasks/performance_probe.rake +33 -0
  69. data/lib/tasks/performance_probe.rb +106 -0
  70. data/lib/tasks/performance_probe_xml.rb +54 -0
  71. data/lutaml-model.gemspec +1 -1
  72. data/spec/lutaml/json/adapter/standard_adapter_spec.rb +159 -0
  73. data/spec/lutaml/json/yeptris_adapter_spec.rb +63 -0
  74. data/spec/lutaml/jsonld/adapter_spec.rb +19 -0
  75. data/spec/lutaml/key_value/adapter/toml/teptris_adapter_spec.rb +71 -0
  76. data/spec/lutaml/model/collection_spec.rb +1 -1
  77. data/spec/lutaml/model/collection_state_nesting_spec.rb +44 -0
  78. data/spec/lutaml/model/generator_defaults_spec.rb +101 -0
  79. data/spec/lutaml/model/json_adapter_spec.rb +6 -0
  80. data/spec/lutaml/model/json_state_nesting_spec.rb +82 -0
  81. data/spec/lutaml/model/toml_adapter_spec.rb +8 -0
  82. data/spec/lutaml/model/toml_spec.rb +36 -1
  83. data/spec/lutaml/xml/plan_fast_path_spec.rb +96 -0
  84. data/spec/lutaml/xml/shared_element_rules_spec.rb +64 -0
  85. data/spec/lutaml/xml/xml_spec.rb +13 -0
  86. data/spec/lutaml/yaml/yeptris_adapter_spec.rb +63 -0
  87. data/spec/spec_helper.rb +7 -1
  88. metadata +29 -4
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Xml
5
+ # Phase 5 slice: compile a model's XML mapping into a
6
+ # Leptris::XML::Descriptor plan and materialize whole documents in
7
+ # one native pass (leptris_plan_walk) — no moxml wrapper tree, no
8
+ # per-element Ruby dispatch. Measured on the 200-item probe:
9
+ # walk+hydrate 8.7x faster, 82% fewer allocations than the
10
+ # interpretive path, hydration-equal output.
11
+ #
12
+ # A model compiles only when EVERY rule is plan-shaped; anything
13
+ # richer keeps the interpretive path (all-or-nothing per model):
14
+ # - map_attribute / map_element rows only
15
+ # - element rows: Value-scalar types or nested Serializables
16
+ # - no content/raw mappings, custom methods, delegates,
17
+ # polymorphism, transforms, multiple spellings, namespaces,
18
+ # ordered mappings, hash_mappings, root_mappings
19
+ # - attributes not derived/union/polymorphic
20
+ #
21
+ # The fast path is opt-in (Config.xml_plan_fast_path) while the
22
+ # full semantics audit (parent links, consolidation, ordering
23
+ # metadata) completes.
24
+ module PlanCompiler
25
+ # Isolated holder: suites freeze model classes; a cache on a
26
+ # frozen constant would be immutable (TypeProbeCache precedent).
27
+ PLAN_CACHE = ::Class.new do
28
+ class << self
29
+ def cache
30
+ @cache ||= {}
31
+ end
32
+ end
33
+ end.cache
34
+
35
+ class << self
36
+ def compile(model_class, register)
37
+ key = [model_class, register]
38
+ return PLAN_CACHE[key] if PLAN_CACHE.key?(key)
39
+
40
+ PLAN_CACHE[key] = build(model_class, register)
41
+ end
42
+
43
+ private
44
+
45
+ def build(model_class, register)
46
+ return nil unless model_class.is_a?(Class) &&
47
+ model_class.include?(::Lutaml::Model::Serialize)
48
+
49
+ mapping = model_class.mappings_for(:xml, register)
50
+ return nil unless compilable_mapping?(mapping)
51
+
52
+ rows = []
53
+ attr_rows = [] # [[rule, attr]] hydration metadata
54
+ plan_attrs = [] # [{name:, kind:}] rows for the engine plan
55
+ compiled = [] # [rule, attr, kind] in children order
56
+ mapping.mappings(register).each do |rule|
57
+ attr = model_class.attributes(register)[rule.to]
58
+ return nil if attr.nil?
59
+ return nil if attr.derived? || attr.union? || attr.polymorphic?
60
+
61
+ if rule.attribute?
62
+ return nil unless scalar_type?(attr, register)
63
+
64
+ attr_rows << [rule, attr]
65
+ plan_attrs << { name: rule.name.to_s }
66
+ else
67
+ return nil unless plan_element_row?(rule)
68
+
69
+ type = attr.type(register)
70
+ if serializable_type?(type)
71
+ child = compile(type, register)
72
+ return nil unless child
73
+
74
+ compiled << [rule, attr, :nested]
75
+ rows << { name: rule.name.to_s, kind: :nested,
76
+ plan: child[:tree] }
77
+ else
78
+ return nil unless scalar_type?(attr, register)
79
+
80
+ kind = attr.collection? ? :collection : :scalar
81
+ # Collection row values carry neither name nor type_tag
82
+ # echo (leptris 1.9.174): hydration can only attribute
83
+ # them unambiguously with a single collection row per
84
+ # element.
85
+ if kind == :collection && rows.count { |r| r[:kind] == :collection } >= 1
86
+ return nil
87
+ end
88
+
89
+ compiled << [rule, attr, kind]
90
+ rows << { name: rule.name.to_s, kind: kind }
91
+ end
92
+ end
93
+ end
94
+
95
+ tree = { name: mapping.root_element.to_s, attributes: plan_attrs,
96
+ children: rows }
97
+ begin
98
+ # Lazy: the Opal boot loads this file, and leptris is a
99
+ # native gem there — the require only belongs on the
100
+ # engines-enabled path.
101
+ require "leptris/xml/descriptor"
102
+ descriptor = ::Leptris::XML::Descriptor.build(**tree)
103
+ rescue StandardError
104
+ return nil
105
+ end
106
+
107
+ { descriptor: descriptor, tree: tree, rows: compiled,
108
+ attr_rows: attr_rows, mapping: mapping }
109
+ end
110
+
111
+ def compilable_mapping?(mapping)
112
+ !mapping.ordered? && mapping.root_element &&
113
+ !(mapping.respond_to?(:root_mappings) && mapping.root_mappings)
114
+ end
115
+
116
+ def plan_element_row?(rule)
117
+ !rule.raw_mapping? && !rule.content_mapping? &&
118
+ !rule.delegate && !rule.multiple_mappings? &&
119
+ !rule.namespace_set? && !rule.cdata &&
120
+ !rule.mixed_content && !rule.as_list && !rule.delimiter &&
121
+ !rule.has_custom_method_for_deserialization? &&
122
+ !rule.polymorphic_mapping? &&
123
+ !(rule.transform.is_a?(Hash) && !rule.transform.empty?) &&
124
+ !rule.transform.is_a?(Class)
125
+ end
126
+
127
+ def scalar_type?(attr, register)
128
+ type = attr.type(register)
129
+ type.is_a?(Class) && type < ::Lutaml::Model::Type::Value &&
130
+ !attr.custom_collection?
131
+ end
132
+
133
+ def serializable_type?(type)
134
+ type.is_a?(Class) && type.include?(::Lutaml::Model::Serialize)
135
+ end
136
+ end
137
+ end
138
+ end
139
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Xml
5
+ # Hydrates model instances from a Descriptor#walk PlanValue tree,
6
+ # keyed by each value's producing row name (never position — rows
7
+ # for missing elements are simply absent). Builds constructor
8
+ # kwargs recursively and instantiates through .new, so typing,
9
+ # defaults, and collection semantics all ride the constructor.
10
+ module PlanHydrator
11
+ class << self
12
+ # plan: the compiler's entry for model_class
13
+ # value: the walk root PlanValue (element)
14
+ def call(model_class, plan, value)
15
+ kwargs = attributes_kwargs(plan, value)
16
+ kwargs.merge!(children_kwargs(model_class, plan, value))
17
+ model_class.new(**kwargs)
18
+ end
19
+
20
+ private
21
+
22
+ def attributes_kwargs(plan, value)
23
+ kwargs = {}
24
+ plan[:attr_rows].each do |rule, attr|
25
+ v = value.attribute(rule.name.to_s)
26
+ kwargs[attr.name.to_sym] = v unless v.nil?
27
+ end
28
+ kwargs
29
+ end
30
+
31
+ def children_kwargs(_model_class, plan, value)
32
+ register = Lutaml::Model::Config.default_register
33
+ grouped = group_children_by_name(value)
34
+
35
+ kwargs = {}
36
+ plan[:rows].each do |rule, attr, kind|
37
+ values = grouped[kind == :collection ? :__collection : rule.name.to_s]
38
+ next if values.nil? || values.empty?
39
+
40
+ kwargs[attr.name.to_sym] =
41
+ case kind
42
+ when :scalar
43
+ values.first.string_value
44
+ when :collection
45
+ # One collection-row value per element; its items are
46
+ # the individual scalar matches.
47
+ values.flat_map do |cv|
48
+ Array.new(cv.count) { |i| cv.at(i).string_value }
49
+ end
50
+ when :nested
51
+ child_plan = PlanCompiler.compile(attr.type(register),
52
+ register)
53
+ items = values.map do |v|
54
+ call(attr.type(register), child_plan, v)
55
+ end
56
+ attr.collection? ? items : items.first
57
+ end
58
+ end
59
+ kwargs
60
+ end
61
+
62
+ # Scalar and nested values echo their producing row's name;
63
+ # collection values echo neither name nor type_tag, so with the
64
+ # compiler's single-collection-row guard they are attributed by
65
+ # kind.
66
+ def group_children_by_name(value)
67
+ grouped = {}
68
+ value.count.times do |i|
69
+ child = value.at(i)
70
+ key = child.name || (child.kind == :collection ? :__collection : nil)
71
+ next if key.nil?
72
+
73
+ (grouped[key] ||= []) << child
74
+ end
75
+ grouped
76
+ end
77
+ end
78
+ end
79
+ end
80
+ 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
@@ -264,6 +264,8 @@ module Lutaml
264
264
  root, model_instance, options,
265
265
  compiled_rules, model_class, register_id
266
266
  ) do |action, rule, value, set_xsi_nil|
267
+ next if action == :apply_rule && duplicate_element_rules[rule]
268
+
267
269
  rule_options = options.merge(current_model: model_instance)
268
270
  case action
269
271
  when :apply_rule
@@ -291,6 +293,32 @@ module Lutaml
291
293
  # @param root [XmlElement] Root element
292
294
  # @param model_instance [Object] The model instance
293
295
  # @param options [Hash] Options
296
+ # Reader for the precomputed skip set (#765).
297
+ def duplicate_element_rules
298
+ @duplicate_element_rules
299
+ end
300
+
301
+ # lutaml-model#765: several element rules may target one attribute
302
+ # (spelling tolerance, e.g. editorial-group vs editorialgroup). The
303
+ # extra spellings exist for parse tolerance only — serialization
304
+ # emits the value once, under the first declared spelling. Derived
305
+ # once at compile time (transformations are frozen and shared).
306
+ def after_compile
307
+ seen = {}
308
+ @duplicate_element_rules = {}
309
+ compiled_rules.each do |rule|
310
+ next unless rule.option(:mapping_type) == :element
311
+
312
+ key = rule.attribute_name.to_s
313
+ if seen.key?(key)
314
+ @duplicate_element_rules[rule] = true
315
+ else
316
+ seen[key] = true
317
+ end
318
+ end
319
+ @duplicate_element_rules.freeze
320
+ end
321
+
294
322
  def apply_standard_rules(root, model_instance, options)
295
323
  attr_order = model_instance.is_a?(Lutaml::Model::Serialize) &&
296
324
  model_instance.attribute_order
@@ -303,6 +331,7 @@ module Lutaml
303
331
 
304
332
  rules.each do |rule|
305
333
  next unless valid_mapping?(rule, options)
334
+ next if duplicate_element_rules[rule]
306
335
 
307
336
  rule_options = options.merge(current_model: model_instance)
308
337
  apply_rule(root, rule, model_instance, rule_options, model_class,
@@ -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,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yeptris"
4
+ require "yeptris/yaml"
5
+ require_relative "standard_adapter"
6
+
7
+ module Lutaml
8
+ module Yaml
9
+ module Adapter
10
+ # YAML over the yeptris engine (libyeptris C11). Uses the native
11
+ # Yeptris::YAML surface, which does not rebind or interact with the
12
+ # Psych constant, so it coexists with the standard adapter in one
13
+ # process. Inherits the document/generation contract; only the
14
+ # engine calls differ.
15
+ #
16
+ # Semantic notes vs the standard adapter's Psych.safe_load:
17
+ # implicit typing is parity-verified (compat_11 schema); anchors
18
+ # and aliases always resolve; a tagged value without a core type
19
+ # materializes as plain data instead of raising DisallowedClass.
20
+ class YeptrisAdapter < StandardAdapter
21
+ def self.parse(yaml, _options = {})
22
+ # rubocop:disable-next Naming/VariableNumber -- upstream schema literal
23
+ Yeptris::YAML.load(yaml, schema: :compat_11)
24
+ end
25
+
26
+ def to_yaml(_options = {})
27
+ attributes_to_serialize = if @attributes.is_a?(Lutaml::KeyValue::DataModel::Element)
28
+ # Unwrap __root__ wrapper to get actual content
29
+ @attributes.to_hash["__root__"]
30
+ else
31
+ # Legacy Hash input (backward compatibility)
32
+ @attributes
33
+ end
34
+
35
+ Yeptris::YAML.dump(attributes_to_serialize)
36
+ end
37
+ end
38
+ end
39
+ end
40
+ 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,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}"
data/lutaml-model.gemspec CHANGED
@@ -38,7 +38,7 @@ Gem::Specification.new do |spec|
38
38
  spec.add_dependency "canon"
39
39
  spec.add_dependency "concurrent-ruby"
40
40
  spec.add_dependency "liquid", ">= 4.0", "< 6.0"
41
- spec.add_dependency "moxml", "~> 0.5.0"
41
+ spec.add_dependency "moxml", "~> 0.5.34"
42
42
  spec.add_dependency "ostruct"
43
43
  spec.add_dependency "rubyzip", "~> 2.3"
44
44
  spec.add_dependency "thor"