lutaml-model 0.8.100 → 0.8.114

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 (38) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +6 -5
  3. data/lib/compat/opal/lutaml_model_boot.rb +1 -0
  4. data/lib/lutaml/json/adapter/yeptris_adapter.rb +11 -0
  5. data/lib/lutaml/key_value/transform.rb +4 -0
  6. data/lib/lutaml/key_value/transformation.rb +10 -0
  7. data/lib/lutaml/model/attribute.rb +43 -3
  8. data/lib/lutaml/model/cached_type_resolver.rb +13 -6
  9. data/lib/lutaml/model/configuration.rb +13 -0
  10. data/lib/lutaml/model/serialize/builder.rb +22 -0
  11. data/lib/lutaml/model/serialize/format_conversion.rb +21 -3
  12. data/lib/lutaml/model/serialize/initialization.rb +14 -1
  13. data/lib/lutaml/model/type_resolver.rb +7 -5
  14. data/lib/lutaml/model/version.rb +1 -1
  15. data/lib/lutaml/xml/adapter/leptris_adapter.rb +1 -0
  16. data/lib/lutaml/xml/adapter/xml_parser.rb +5 -45
  17. data/lib/lutaml/xml/leptris/native_emission.rb +187 -0
  18. data/lib/lutaml/xml/leptris/plan_compiler.rb +161 -102
  19. data/lib/lutaml/xml/leptris/plan_hydrator.rb +276 -58
  20. data/lib/lutaml/xml/leptris/plan_serializer.rb +298 -45
  21. data/lib/lutaml/xml/leptris/plan_walk.rb +6 -3
  22. data/lib/lutaml/xml/leptris.rb +25 -84
  23. data/lib/lutaml/xml/model_transform.rb +5 -0
  24. data/lib/lutaml/xml/transformation/custom_method_wrapper.rb +15 -6
  25. data/lib/lutaml/yaml/adapter/yeptris_adapter.rb +11 -0
  26. data/lutaml-model.gemspec +3 -3
  27. data/spec/lutaml/json/yeptris_adapter_spec.rb +3 -10
  28. data/spec/lutaml/model/adapter_resolver_configured_spec.rb +32 -8
  29. data/spec/lutaml/model/collection_strict_assignment_spec.rb +55 -0
  30. data/spec/lutaml/model/singular_tolerance_spec.rb +159 -0
  31. data/spec/lutaml/model/unregistered_context_resolution_spec.rb +34 -0
  32. data/spec/lutaml/xml/adapter/empty_document_diagnostics_spec.rb +50 -0
  33. data/spec/lutaml/xml/plan_fast_path_spec.rb +12 -6
  34. data/spec/lutaml/xml/plan_type_ns_attribute_spec.rb +0 -11
  35. data/spec/lutaml/xml/plan_uri_exact_dispatch_spec.rb +287 -0
  36. data/spec/lutaml/xml/plan_wildcard_any_spec.rb +0 -9
  37. data/spec/lutaml/yaml/yeptris_adapter_spec.rb +3 -10
  38. metadata +16 -10
@@ -15,14 +15,23 @@ module Lutaml
15
15
  # (their values echo name and type_tag; native collection values
16
16
  # echo neither — leptris-ruby#220).
17
17
  module PlanHydrator
18
+ # Row kinds whose child type/plan resolve once per plan (see
19
+ # child_row_plans).
20
+ CHILD_ROW_KINDS = %i[nested ordered_deferred].freeze
21
+
18
22
  class << self
19
- # plan: the compiler's entry for model_class
23
+ # plan: the compiled plan entry for model_class
20
24
  # value: the walk root PlanValue (element)
21
25
  # node: the parsed source element (leptris); ordered/mixed
22
26
  # plans rebuild element_order from it and ordered children
23
27
  # hydrate natively against their own nodes
28
+ # declaration_plan_mode: mirrors from_xml's
29
+ # :import_declaration_plan option (:skip records nothing;
30
+ # :lazy is honored as :eager — the lazy variant pins the
31
+ # raw engine DOM, the retention the eager default exists
32
+ # to avoid)
24
33
  def call(model_class, plan, value, parent: nil, node: nil,
25
- register: nil)
34
+ register: nil, declaration_plan_mode: :eager)
26
35
  # The child model's declared lutaml_default_register takes
27
36
  # precedence over the ambient register — the same
28
37
  # resolve_for_child contract the interpretive path applies.
@@ -32,9 +41,11 @@ module Lutaml
32
41
  register = Lutaml::Model::Register.resolve_for_child(
33
42
  model_class, register || Lutaml::Model::Config.default_register
34
43
  )
35
- buckets = node && plan[:needs_nodes] ? element_buckets(node) : nil
44
+ buckets = if node && plan[:needs_nodes]
45
+ element_buckets(node, plan[:contested_names])
46
+ end
36
47
  attr_kwargs = attributes_kwargs(plan, value, node)
37
- child_kwargs, children, delegates =
48
+ child_kwargs, children, delegates, run_children =
38
49
  children_kwargs(model_class, plan, value, buckets, register)
39
50
  plan[:collection_defaults].each do |name|
40
51
  next if attr_kwargs.key?(name) || child_kwargs.key?(name)
@@ -44,27 +55,92 @@ module Lutaml
44
55
  # TODO.max-perf/33: the bulk constructor — allocate +
45
56
  # compiled writers, from_hash semantics — measured ~30x on
46
57
  # hydration-heavy corpora versus the generic constructor.
47
- instance = model_class.instantiate(attr_kwargs.merge(child_kwargs),
48
- register)
58
+ attr_kwargs.update(child_kwargs)
59
+ instance = model_class.instantiate(attr_kwargs, register)
49
60
  instance.lutaml_parent = parent if parent
50
61
  instance.lutaml_root ||= parent&.lutaml_root || parent
62
+ # Root prefix fidelity (the interpretive transform records
63
+ # it from the source element): prefixed sources keep their
64
+ # prefixes through the serialize lane's format
65
+ # preservation.
66
+ if parent.nil? && node.respond_to?(:prefix) && node.prefix &&
67
+ !node.prefix.empty?
68
+ instance.xml_namespace_prefix = node.prefix
69
+ end
51
70
  # #909: order summaries are per-instance pinned records —
52
71
  # read-only consumers skip them via the config.
53
72
  if node && plan[:ordered] &&
54
73
  Lutaml::Model::Config.instance.element_order_tracking
55
74
  instance.element_order = PlanOrder.build(node)
56
75
  end
76
+ # Root declaration-plan parity (the interpretive
77
+ # transform's eager mode): consumers and the interpretive
78
+ # serialize fallback read import_declaration_plan state —
79
+ # plan-hydrated roots must carry the same in-scope xmlns
80
+ # declarations or round-trips drop them (#872). The root's
81
+ # xsi:schemaLocation rides the same state (the
82
+ # interpretive set_schema_location contract).
83
+ if parent.nil? && node
84
+ record_declaration_plan(instance, node, model_class) if declaration_plan_mode != :skip
85
+ record_schema_location(instance, node)
86
+ end
57
87
  children.each do |child|
58
88
  child.lutaml_parent = instance
59
89
  child.lutaml_root ||= instance.lutaml_root || instance
60
90
  end
61
- interpret_deferred(model_class, plan, value, instance, node, buckets)
91
+ interpret_deferred(model_class, plan, value, instance, node,
92
+ buckets, run_children)
62
93
  route_delegates(delegates, instance)
63
94
  instance
64
95
  end
65
96
 
66
97
  private
67
98
 
99
+ def record_declaration_plan(instance, node, model_class)
100
+ ns_data = collect_native_namespaces(node)
101
+ return if ns_data.empty?
102
+
103
+ plan = Lutaml::Xml::DeclarationPlan.from_input_with_locations(
104
+ ns_data, model_class.mappings_for(:xml)
105
+ )
106
+ instance.import_declaration_plan = plan if plan
107
+ end
108
+
109
+ # The interpretive parse stores the root's xsi:schemaLocation
110
+ # verbatim (set_schema_location); the writer re-emits it.
111
+ def record_schema_location(instance, node)
112
+ schema_loc = node.attributes.each_value.find do |a|
113
+ a.name.to_s.split(":", 2).last == "schemaLocation"
114
+ end
115
+ instance.raw_schema_location = schema_loc.value if schema_loc
116
+ end
117
+
118
+ # Same ns_data shape as ModelTransform.collect_element_namespaces,
119
+ # collected straight off the engine nodes — no wrapper tree.
120
+ # Iterates children with an inline element filter:
121
+ # element_children materializes a filtered array per node,
122
+ # which the niso allocation gate caught at scale.
123
+ def collect_native_namespaces(element, path = [], result = {})
124
+ own = element.namespace_definitions
125
+ unless own.empty?
126
+ namespaces = {}
127
+ own.each do |ns|
128
+ key = ns.prefix || :default
129
+ namespaces[key] = { uri: ns.href, prefix: ns.prefix,
130
+ format: ns.prefix ? :prefix : :default }
131
+ end
132
+ result[path.dup] = namespaces
133
+ end
134
+ element.children.each do |child|
135
+ next unless child.is_a?(::Leptris::XML::Element)
136
+
137
+ path.push(child.name)
138
+ collect_native_namespaces(child, path, result)
139
+ path.pop
140
+ end
141
+ result
142
+ end
143
+
68
144
  def register
69
145
  Lutaml::Model::Config.default_register
70
146
  end
@@ -73,8 +149,12 @@ module Lutaml
73
149
  kwargs = {}
74
150
  sole_claimants = sole_claimant_names(plan)
75
151
  ns_attr_names = plan[:ns_attr_names]
76
- plan_attr_rows(plan).each do |rule, attr, name|
77
- v = value.attribute(name)
152
+ rows = plan_attr_rows(plan)
153
+ # #1670 batch face (leptris 1.9.351.1): one FFI crossing
154
+ # per node instead of one per (node x attribute name).
155
+ bound = value.attributes_snapshot
156
+ rows.each do |rule, attr, name|
157
+ v = bound[name]
78
158
  if v.nil? && node && sole_claimants.include?(name)
79
159
  # #754/#790 parity: a sole-claimant attribute rule binds
80
160
  # any qualification (the interpretive matcher's lenient
@@ -152,8 +232,8 @@ module Lutaml
152
232
  # hydration allocated two or three strings per attribute row
153
233
  # per instance.
154
234
  def plan_attr_rows(plan)
155
- plan[:attr_row_names] ||= plan[:attr_rows].map do |rule, attr|
156
- [rule, attr, rule.name.to_s]
235
+ plan[:attr_row_names] ||= plan[:attr_rows].map do |rule, attr, row_name|
236
+ [rule, attr, row_name || rule.name.to_s]
157
237
  end
158
238
  end
159
239
 
@@ -164,7 +244,8 @@ module Lutaml
164
244
  def children_kwargs(_model_class, plan, value, buckets = nil,
165
245
  register = nil)
166
246
  register ||= Lutaml::Model::Config.default_register
167
- grouped, tagged = group_children(value, plan[:row_tags])
247
+ grouped, tagged, run_children = group_children(value,
248
+ plan[:row_tags])
168
249
  buckets = nil unless buckets && plan[:needs_nodes]
169
250
  kwargs = {}
170
251
  children = []
@@ -173,8 +254,21 @@ module Lutaml
173
254
  # the block-backed Hash is a fresh object per hydrated row.
174
255
  spellings = nil
175
256
  row_tags = plan[:row_tags]
176
- plan[:rows].each_with_index do |(rule, attr, kind, spelling, delegate), idx|
257
+ wire_names = row_wire_names(plan)
258
+ row_memo = child_row_plans(plan, register)
259
+ # Array#each with a top-of-loop counter: each_with_index
260
+ # mints an enumerator per hydrated node — 14.7K allocations
261
+ # per parse on the pnas guardrail — and next must keep its
262
+ # continue semantics.
263
+ rows = plan[:rows]
264
+ idx = -1
265
+ # rubocop:disable Metrics/BlockLength -- one row-kind
266
+ # dispatch table; splitting it would scatter the contract
267
+ rows.each do |row|
268
+ idx += 1
269
+ rule, attr, kind, spelling, delegate, _row_type = row
177
270
  tag = row_tags&.[](idx)
271
+ name = wire_names[idx]
178
272
  case kind
179
273
  when :scalar
180
274
  # Raw passthrough: the model constructor is the single
@@ -192,7 +286,7 @@ module Lutaml
192
286
  first = if tag
193
287
  tagged[tag]&.first
194
288
  else
195
- grouped.dig(rule.name.to_s, 0)
289
+ grouped.dig(name, 0)
196
290
  end
197
291
  v = first&.string_value
198
292
  v = mapped_empty_value(rule) if v.nil? && first
@@ -204,7 +298,7 @@ module Lutaml
204
298
  when :wildcard_any
205
299
  # interpreted post-instance (interpret_deferred)
206
300
  when :content
207
- runs = content_runs(value)
301
+ runs = content_runs(value, run_children)
208
302
  if runs.empty? && !attr.collection?
209
303
  # An empty element's content binds through the rule's
210
304
  # value map for scalar content attributes: "" for the
@@ -220,13 +314,13 @@ module Lutaml
220
314
  # walk against its own node + element_order from the
221
315
  # node's children. Without one (direct PlanHydrator
222
316
  # use), the subtree fragment-parses interpretively.
223
- if buckets
224
- child_type = attr.type(register)
225
- child_plan = PlanCompiler.compile(child_type, register)
226
- items = buckets.fetch(rule.name.to_s, []).map do |n|
317
+ if buckets && row_memo[idx]
318
+ child_type, child_plan, child_register, contested = row_memo[idx]
319
+ selected = child_selection(buckets, name, child_plan, contested)
320
+ items = (selected || []).map do |n|
227
321
  call(child_type, child_plan,
228
322
  child_plan[:descriptor].walk(n), node: n,
229
- register: register)
323
+ register: child_register)
230
324
  end
231
325
  unless items.empty?
232
326
  children.concat(items)
@@ -234,8 +328,28 @@ module Lutaml
234
328
  attr.collection? ? items : items.first)
235
329
  end
236
330
  end
331
+ when :child_deferred
332
+ # Uncompilable child (cycles, opted-out shapes): each
333
+ # source node casts through the attribute — the full
334
+ # interpretive machinery on the bridged island,
335
+ # identical semantics to the interpretive path.
336
+ contested_uri = if plan[:contested_names]&.include?(name)
337
+ child_type_uri(attr, register)
338
+ end
339
+ elements = deferred_elements(value, name, buckets,
340
+ contested_uri)
341
+ next if elements.empty?
342
+
343
+ results = elements.map do |element|
344
+ attr.cast(element, :xml, register)
345
+ end
346
+ unless results.empty?
347
+ children.concat(results)
348
+ assign(kwargs, delegates, delegate, rule, attr,
349
+ attr.collection? ? results : results.first)
350
+ end
237
351
  when :collection_cb
238
- values = grouped[rule.name.to_s].to_a.map do |item|
352
+ values = grouped[name].to_a.map do |item|
239
353
  item&.string_value
240
354
  end
241
355
  values = mapped_capture_values(values, rule)
@@ -249,7 +363,7 @@ module Lutaml
249
363
  values = if tag
250
364
  tagged[tag].to_a.map(&:string_value)
251
365
  else
252
- native_collection(value, rule.name.to_s)
366
+ native_collection(value, name)
253
367
  end
254
368
  values = mapped_capture_values(values, rule) unless values.nil?
255
369
  unless values.nil?
@@ -259,16 +373,34 @@ module Lutaml
259
373
  spellings ||= Hash.new { |h, k| h[k] = [] }
260
374
  spellings[[rule, attr]] << grouped[spelling.to_s].to_a
261
375
  when :nested
262
- child_type = attr.type(register)
263
- child_plan = PlanCompiler.compile(child_type, register)
264
- cursor = if child_plan[:needs_nodes] && buckets
265
- buckets.fetch(rule.name.to_s, [])
266
- end
267
- items = grouped.fetch(rule.name.to_s, []).each_with_index
268
- .map do |v, i|
269
- call(child_type, child_plan, v,
270
- node: cursor && cursor[i], register: register)
376
+ child_type, child_plan, child_register, contested = row_memo[idx]
377
+ selected = child_selection(buckets, name, child_plan, contested)
378
+ if selected && contested
379
+ # Contested name: the walk's grouped values cross-echo
380
+ # every same-name element into every row — dispatch
381
+ # per source node instead (the walk is row-scoped).
382
+ items = selected.map do |n|
383
+ call(child_type, child_plan,
384
+ child_plan[:descriptor].walk(n), node: n,
385
+ register: child_register)
271
386
  end
387
+ else
388
+ values = grouped.fetch(name, [])
389
+ cursor = if child_plan[:needs_nodes] && buckets
390
+ selected
391
+ end
392
+ # Indexed loop: each_with_index.map mints an
393
+ # enumerator per node with nested rows.
394
+ count = values.size
395
+ items = Array.new(count)
396
+ pos = 0
397
+ while pos < count
398
+ items[pos] = call(child_type, child_plan, values[pos],
399
+ node: cursor && cursor[pos],
400
+ register: child_register)
401
+ pos += 1
402
+ end
403
+ end
272
404
  next if items.empty?
273
405
 
274
406
  children.concat(items)
@@ -276,6 +408,7 @@ module Lutaml
276
408
  attr.collection? ? items : items.first)
277
409
  end
278
410
  end
411
+ # rubocop:enable Metrics/BlockLength
279
412
  unless !spellings || spellings.empty?
280
413
  spellings.each do |(rule, attr), groups|
281
414
  # Interpretive order for shared-attribute groups is
@@ -289,7 +422,7 @@ module Lutaml
289
422
  attr.collection? ? values : values.first)
290
423
  end
291
424
  end
292
- [kwargs, children, delegates]
425
+ [kwargs, children, delegates, run_children]
293
426
  end
294
427
 
295
428
  # Deferred islands: raw subtrees become wrapper elements via a
@@ -300,11 +433,20 @@ module Lutaml
300
433
  # node (no fragment parse — the reparse dominated deferred
301
434
  # hydration). Ordered children only land here without a node.
302
435
  def interpret_deferred(model_class, plan, value, instance, node = nil,
303
- buckets = nil)
304
- plan[:rows].each do |rule, attr, kind, _spelling, _delegate|
436
+ buckets = nil, run_children = nil)
437
+ # Array#each + top counter: no per-node enumerator (see
438
+ # children_kwargs).
439
+ rows = plan[:rows]
440
+ idx = -1
441
+ rows.each do |row|
442
+ idx += 1
443
+ rule = row[0]
444
+ attr = row[1]
445
+ kind = row[2]
446
+ wire_name = row_wire_names(plan)[idx]
305
447
  case kind
306
448
  when :content_deferred
307
- runs = content_runs(value)
449
+ runs = content_runs(value, run_children)
308
450
  if runs.empty?
309
451
  # The interpretive pipeline classifies the raw ""
310
452
  # through the rule's value map: the :empty directive
@@ -327,13 +469,13 @@ module Lutaml
327
469
  # concatenated).
328
470
  instance.public_send(:"#{attr.name}=", runs.join)
329
471
  when :raw
330
- raws = raw_strings(value, rule)
472
+ raws = raw_strings(value, wire_name)
331
473
  next if raws.empty?
332
474
 
333
475
  instance.public_send(:"#{attr.name}=",
334
476
  attr.collection? ? raws : raws.first)
335
477
  when :custom_method
336
- elements = deferred_elements(value, rule, buckets)
478
+ elements = deferred_elements(value, wire_name, buckets)
337
479
  next if elements.empty?
338
480
 
339
481
  args = attr.collection? ? elements : elements.first
@@ -343,7 +485,7 @@ module Lutaml
343
485
  when :ordered_deferred
344
486
  next if node # hydrated natively in children_kwargs
345
487
 
346
- results = deferred_elements(value, rule, buckets).map do |element|
488
+ results = deferred_elements(value, wire_name, buckets).map do |element|
347
489
  attr.cast(element, :xml, register,
348
490
  lutaml_parent: instance,
349
491
  lutaml_root: instance.lutaml_root || instance)
@@ -386,7 +528,7 @@ module Lutaml
386
528
  attr.collection? ? results : results.first,
387
529
  )
388
530
  when :polymorphic
389
- results = deferred_elements(value, rule, buckets).map do |element|
531
+ results = deferred_elements(value, wire_name, buckets).map do |element|
390
532
  attr.cast(element, :xml, register,
391
533
  polymorphic: rule.polymorphic,
392
534
  lutaml_parent: instance,
@@ -405,11 +547,30 @@ module Lutaml
405
547
  # construction only, no parse), falling back to a fragment
406
548
  # parse of the captured subtree when no node is available.
407
549
  # Both lists are document-ordered for the row's name.
408
- def deferred_elements(value, rule, buckets)
409
- nodes = buckets && buckets[rule.name.to_s]
410
- return nodes.map { |n| bridge_element(n) } if nodes
550
+ def deferred_elements(value, name, buckets, contested_uri = nil)
551
+ return raw_strings(value, name).map { |raw| fragment_element(raw) } unless buckets
552
+
553
+ nodes = buckets[name]
554
+ return [] unless nodes
555
+
556
+ if contested_uri
557
+ # Contested name: bind only the (URI, local) matches —
558
+ # the type's own mapping namespace is the row identity.
559
+ nodes = buckets[[contested_uri, name]] || []
560
+ end
561
+ nodes.map { |n| bridge_element(n) }
562
+ end
563
+
564
+ def child_type_uri(attr, register)
565
+ type = attr.type(register)
566
+ return nil unless type.is_a?(Class) &&
567
+ type.include?(::Lutaml::Model::Serialize)
411
568
 
412
- raw_strings(value, rule).map { |raw| fragment_element(raw) }
569
+ type.mappings_for(:xml, register)&.namespace_uri
570
+ end
571
+
572
+ def contested_name?(plan, rule)
573
+ plan[:contested_names]&.include?(rule.name.to_s) || false
413
574
  end
414
575
 
415
576
  def bridge_element(node)
@@ -451,15 +612,14 @@ module Lutaml
451
612
  end
452
613
  end
453
614
 
454
- def content_runs(value)
455
- out = []
456
- value.count.times do |i|
457
- c = value.at(i)
458
- next unless c.name.nil? && c.kind == :collection
459
-
460
- out.concat(Array.new(c.count) { |j| c.at(j).string_value })
615
+ # Content runs ride the children snapshot: the nil-name
616
+ # entries ARE the :collection run groups (one crossing per
617
+ # node via group_children, none here). The per-group
618
+ # at/count crossings are the drain itself.
619
+ def content_runs(_value, run_children)
620
+ (run_children || []).flat_map do |group|
621
+ Array.new(group.count) { |j| group.at(j).string_value }
461
622
  end
462
- out
463
623
  end
464
624
 
465
625
  # Native collection rows echo their producing row's name
@@ -474,11 +634,11 @@ module Lutaml
474
634
  nil
475
635
  end
476
636
 
477
- def raw_strings(value, rule)
637
+ def raw_strings(value, name)
478
638
  out = []
479
639
  value.count.times do |idx|
480
640
  child = value.at(idx)
481
- if child.name == rule.name.to_s && child.kind == :raw
641
+ if child.name == name && child.kind == :raw
482
642
  out << child.string_value
483
643
  end
484
644
  end
@@ -499,7 +659,7 @@ module Lutaml
499
659
  # Frozen with the plan: recomputing the tally per hydration
500
660
  # showed in the #876-era profile as Enumerable#find/tally churn.
501
661
  plan[:sole_claimants] ||= plan[:attr_rows]
502
- .map { |rule, _attr| rule.name.to_s }.tally
662
+ .map { |rule, _attr, row_name| row_name || rule.name.to_s }.tally
503
663
  .select { |_n, c| c == 1 }.keys
504
664
  end
505
665
 
@@ -527,8 +687,14 @@ module Lutaml
527
687
  grouped = {}
528
688
  want_tags = !row_tags.nil?
529
689
  tagged = want_tags ? {} : nil
690
+ run_children = nil
530
691
  names.each_with_index do |name, i|
531
- next if name.nil? # content runs, read separately
692
+ if name.nil?
693
+ # Content runs: same snapshot pass — at()/count() per
694
+ # child cost a crossing and a wrapper each.
695
+ (run_children ||= []) << children[i]
696
+ next
697
+ end
532
698
 
533
699
  child = children[i]
534
700
  (grouped[name] ||= []) << child
@@ -536,7 +702,7 @@ module Lutaml
536
702
  (tagged[tags[i]] ||= []) << child
537
703
  end
538
704
  end
539
- [grouped, tagged]
705
+ [grouped, tagged, run_children]
540
706
  end
541
707
 
542
708
  # Element children bucketed by local name, document order
@@ -544,15 +710,67 @@ module Lutaml
544
710
  # ordered-child hydration. Only built when the plan's subtree
545
711
  # needs source nodes; namespace-qualified models never get
546
712
  # here (the compiler keeps them interpretive).
547
- def element_buckets(node)
713
+ # Contested names need (URI, local) node buckets; every other
714
+ # plan hydrates from the name view alone — the qualified keys
715
+ # are one array + hash write per child and measured as an
716
+ # allocation regression on element-heavy corpora (niso gate).
717
+ def element_buckets(node, contested_names = nil)
548
718
  buckets = {}
719
+ qualified = contested_names && !contested_names.empty?
549
720
  node.children.each do |child|
550
721
  next unless child.is_a?(::Leptris::XML::Element)
551
722
 
552
- (buckets[child.name] ||= []) << child
723
+ name = child.name
724
+ (buckets[name] ||= []) << child
725
+ if qualified
726
+ uri = child.namespace&.href
727
+ (buckets[[uri, name]] ||= []) << child if uri
728
+ end
553
729
  end
554
730
  buckets
555
731
  end
732
+
733
+ # Selected source nodes for a child row: contested names
734
+ # dispatch by (URI, local); every other row binds the whole
735
+ # name bucket leniently (any qualification). The pair form
736
+ # this replaces allocated a fresh array per row per node on
737
+ # the ordered walk (niso gate).
738
+ def child_selection(buckets, name, child_plan, contested)
739
+ all = buckets && buckets[name]
740
+ return all unless buckets && all && contested
741
+
742
+ tree_ns = child_plan[:tree][:ns]
743
+ exact_uri = tree_ns.is_a?(Hash) ? tree_ns[:exact] : tree_ns
744
+ exact_uri ? (buckets[[exact_uri, name]] || []) : all
745
+ end
746
+
747
+ # Per-(plan, register) child-row tuples, computed once and
748
+ # reused across every node the walk hands this plan: the
749
+ # per-node type lookup, compile hit, child register
750
+ # resolution, and contested flag were each a measurable slice
751
+ # of the ordered-lane allocation profile (niso gate).
752
+ def child_row_plans(plan, register)
753
+ plan[:child_row_memo] ||= {}
754
+ plan[:child_row_memo][register] ||= plan[:rows].map do |rule, attr, kind, _spelling, _delegate, row_type|
755
+ next nil unless CHILD_ROW_KINDS.include?(kind)
756
+
757
+ child_type = row_type || attr.type(register)
758
+ child_plan = PlanCompiler.compile(child_type, register)
759
+ child_register = Lutaml::Model::Register.resolve_for_child(
760
+ child_type, register
761
+ )
762
+ [child_type, child_plan, child_register,
763
+ contested_name?(plan, rule)]
764
+ end
765
+ end
766
+
767
+ # Plan-frozen wire names: rule.name.to_s minted a fresh string
768
+ # per row per node in the hot loop.
769
+ def row_wire_names(plan)
770
+ plan[:row_wire_names] ||= plan[:rows].map do |rule, _attr, _kind, _spelling, _delegate, _row_type|
771
+ rule.name.to_s
772
+ end
773
+ end
556
774
  end
557
775
  end
558
776
  end