lutaml-model 0.8.100 → 0.8.119

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 (42) 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 +28 -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/adapter_resolver.rb +13 -5
  8. data/lib/lutaml/model/attribute.rb +77 -3
  9. data/lib/lutaml/model/cached_type_resolver.rb +13 -6
  10. data/lib/lutaml/model/configuration.rb +13 -0
  11. data/lib/lutaml/model/serialize/builder.rb +22 -0
  12. data/lib/lutaml/model/serialize/format_conversion.rb +21 -3
  13. data/lib/lutaml/model/serialize/initialization.rb +14 -1
  14. data/lib/lutaml/model/type_resolver.rb +7 -5
  15. data/lib/lutaml/model/version.rb +1 -1
  16. data/lib/lutaml/xml/adapter/leptris_adapter.rb +1 -0
  17. data/lib/lutaml/xml/adapter/xml_parser.rb +5 -45
  18. data/lib/lutaml/xml/leptris/native_emission.rb +187 -0
  19. data/lib/lutaml/xml/leptris/plan_compiler.rb +161 -102
  20. data/lib/lutaml/xml/leptris/plan_hydrator.rb +276 -58
  21. data/lib/lutaml/xml/leptris/plan_serializer.rb +309 -47
  22. data/lib/lutaml/xml/leptris/plan_walk.rb +6 -3
  23. data/lib/lutaml/xml/leptris.rb +25 -84
  24. data/lib/lutaml/xml/model_transform.rb +5 -0
  25. data/lib/lutaml/xml/transformation/custom_method_wrapper.rb +15 -6
  26. data/lib/lutaml/yaml/adapter/yeptris_adapter.rb +29 -0
  27. data/lutaml-model.gemspec +3 -3
  28. data/spec/lutaml/json/yeptris_adapter_spec.rb +3 -10
  29. data/spec/lutaml/model/adapter_resolver_configured_spec.rb +32 -8
  30. data/spec/lutaml/model/cast_element_form_spec.rb +54 -0
  31. data/spec/lutaml/model/collection_strict_assignment_spec.rb +55 -0
  32. data/spec/lutaml/model/from_json_scalar_cast_spec.rb +70 -0
  33. data/spec/lutaml/model/singular_tolerance_spec.rb +159 -0
  34. data/spec/lutaml/model/unregistered_context_resolution_spec.rb +34 -0
  35. data/spec/lutaml/model/yeptris_native_detection_spec.rb +83 -0
  36. data/spec/lutaml/xml/adapter/empty_document_diagnostics_spec.rb +50 -0
  37. data/spec/lutaml/xml/plan_fast_path_spec.rb +34 -6
  38. data/spec/lutaml/xml/plan_type_ns_attribute_spec.rb +0 -11
  39. data/spec/lutaml/xml/plan_uri_exact_dispatch_spec.rb +287 -0
  40. data/spec/lutaml/xml/plan_wildcard_any_spec.rb +0 -9
  41. data/spec/lutaml/yaml/yeptris_adapter_spec.rb +3 -10
  42. metadata +19 -10
@@ -0,0 +1,187 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lutaml
4
+ module Xml
5
+ module Leptris
6
+ # The native emission lane of the plan fast path: instead of
7
+ # minting the output DOM node-by-node through the Ruby
8
+ # create_child chain, build a plan RESULT from ops
9
+ # (leptris_plan_result_build), render it through the engine's
10
+ # plan serializer, reparse the compact output, and write it with
11
+ # the engine's indented document writer. Both renderings are
12
+ # pure C; measured 2.4x faster than the DOM-mint loop on the
13
+ # 500-row audit at a modest allocation increase.
14
+ #
15
+ # Byte-parity with the DOM lane holds because both lanes end at
16
+ # the same document writer over structurally identical
17
+ # documents: the compact emission escapes exactly as the DOM
18
+ # text nodes do, and reparsing yields one text node per leaf,
19
+ # matching the minted tree. The reparse is an engine-internal
20
+ # cost, not a Ruby one.
21
+ #
22
+ # Emission is enabled for the provably static subset: trees with
23
+ # no namespace form anywhere (every spelling is the unwritten
24
+ # local name, nothing to declare, no instance-dependent
25
+ # prefix/default flips) and the non-ordered build() shapes
26
+ # (attributes, scalars, collections, nested models). Wildcard,
27
+ # raw, callback, and content rows and ordered models keep the
28
+ # DOM lane — their emission shapes are not expressible as ops
29
+ # (or not verified to be) and the DOM lane is byte-exact.
30
+ module NativeEmission
31
+ ROOT_ROW = 0xFFFF_FFFF
32
+ EMITTABLE_KINDS = %i[scalar collection nested].freeze
33
+
34
+ class << self
35
+ def compatible?(plan)
36
+ !plan[:ordered] && tree_emittable?(plan[:tree])
37
+ end
38
+
39
+ # Pre-order depth-first, children in row order — the same
40
+ # walk Descriptor.flatten_plan performs, so the plan_index
41
+ # counters here match the engine's flattened plans array.
42
+ def tree_emittable?(tree)
43
+ return false if tree[:ns]
44
+ return false if (tree[:attributes] || []).any? { |a| a[:ns].is_a?(Hash) }
45
+
46
+ (tree[:children] || []).all? do |row|
47
+ EMITTABLE_KINDS.include?(row[:kind]) &&
48
+ (row[:kind] != :nested || !row[:plan] || tree_emittable?(row[:plan]))
49
+ end
50
+ end
51
+
52
+ def emit(instance, plan, register)
53
+ ops = [{ kind: :element, row_index: ROOT_ROW }]
54
+ emit_element(ops, instance, plan, register, 0)
55
+ ops << { kind: :end }
56
+ compact = plan[:descriptor].serialize(
57
+ plan[:descriptor].build_result(ops),
58
+ )
59
+ ::Leptris::XML.parse(compact).to_xml(indent: 2, no_decl: true)
60
+ end
61
+
62
+ private
63
+
64
+ # plan_index: this plan's flattened index. The engine's
65
+ # flatten_plan pushes each nested row's child plan exactly
66
+ # once — collection members share that slot — so child flat
67
+ # indices come from a static per-plan layout (relative to
68
+ # this plan's index), never from a runtime counter.
69
+ def emit_element(ops, instance, plan, register, plan_index)
70
+ my_index = plan_index
71
+ layout = flat_layout_for(plan)
72
+
73
+ emit_attributes(ops, instance, plan, my_index, register)
74
+
75
+ plan[:rows].each_with_index do |(rule, attr, kind, _spelling, delegate), row_index|
76
+ value = value_of(instance, attr, delegate)
77
+ next unless rule.render?(value, instance)
78
+
79
+ case kind
80
+ when :scalar
81
+ # Multi-capture parity: an attribute that collected
82
+ # several occurrences serializes one element per item,
83
+ # as the DOM lane does.
84
+ if value.is_a?(::Array)
85
+ value.each do |item|
86
+ ops << scalar_op(my_index, row_index, attr, item, register)
87
+ end
88
+ else
89
+ ops << scalar_op(my_index, row_index, attr, value, register)
90
+ end
91
+ when :collection_native, :collection_cb
92
+ Array(value).each do |item|
93
+ ops << scalar_op(my_index, row_index, attr, item, register)
94
+ end
95
+ when :nested
96
+ _tree, child_rel = layout[row_index]
97
+ next unless child_rel
98
+
99
+ Array(value).each do |item|
100
+ child_plan = nested_plans_for(plan)[[item.class, rule.name.to_s]] ||
101
+ (nested_plans_for(plan)[[item.class, rule.name.to_s]] =
102
+ PlanCompiler.compile(item.class, register))
103
+ ops << { kind: :element, plan_index: my_index,
104
+ row_index: row_index }
105
+ emit_element(ops, item, child_plan,
106
+ register, my_index + child_rel)
107
+ ops << { kind: :end }
108
+ end
109
+ end
110
+ end
111
+ end
112
+
113
+ # {nested row_index => [child tree, relative flat index]} —
114
+ # children of this plan occupy slots my_index+1 .. in row
115
+ # order, each nested row's subtree sized once.
116
+ def flat_layout_for(plan)
117
+ plan[:flat_layout] ||= begin
118
+ layout = {}
119
+ rel = 1
120
+ (plan[:tree][:children] || []).each_with_index do |row, i|
121
+ next unless row[:kind] == :nested && row[:plan]
122
+
123
+ layout[i] = [row[:plan], rel]
124
+ rel += subtree_size(row[:plan])
125
+ end
126
+ layout
127
+ end
128
+ end
129
+
130
+ def subtree_size(tree)
131
+ 1 + (tree[:children] || []).sum do |row|
132
+ row[:kind] == :nested && row[:plan] ? subtree_size(row[:plan]) : 0
133
+ end
134
+ end
135
+
136
+ def emit_attributes(ops, instance, plan, plan_index, register)
137
+ recorded = instance.attribute_order
138
+ attr_rows = plan[:attr_rows]
139
+ if recorded && !recorded.empty?
140
+ by_name = {}
141
+ attr_rows.each { |rule, attr, _sp, delegate| by_name[rule.name.to_s] = [rule, attr, delegate] }
142
+ names = recorded | attr_rows.map { |rule, _a, _s, _d| rule.name.to_s }
143
+ else
144
+ names = attr_rows.map { |rule, _a, _s, _d| rule.name.to_s }
145
+ by_name = {}
146
+ attr_rows.each { |rule, attr, _sp, delegate| by_name[rule.name.to_s] = [rule, attr, delegate] }
147
+ end
148
+ tree_attrs = plan[:tree][:attributes] || []
149
+ names.each do |name|
150
+ entry = by_name[name]
151
+ next unless entry
152
+
153
+ _rule, attr, delegate = entry
154
+ value = value_of(instance, attr, delegate)
155
+ next if value.nil?
156
+
157
+ row_index = tree_attrs.index { |a| a[:name] == name }
158
+ next unless row_index
159
+
160
+ ops << { kind: :attr, plan_index: plan_index,
161
+ row_index: row_index, name: name,
162
+ value: attr.serialize(value, :xml, register).to_s }
163
+ end
164
+ end
165
+
166
+ def scalar_op(plan_index, row_index, attr, value, register)
167
+ { kind: :scalar, plan_index: plan_index, row_index: row_index,
168
+ value: attr.serialize(value, :xml, register).to_s }
169
+ end
170
+
171
+ def value_of(instance, attr, delegate)
172
+ if delegate
173
+ target = instance.public_send(delegate)
174
+ target&.public_send(attr.name)
175
+ else
176
+ instance.public_send(attr.name)
177
+ end
178
+ end
179
+
180
+ def nested_plans_for(plan)
181
+ plan[:nested_plans] ||= {}
182
+ end
183
+ end
184
+ end
185
+ end
186
+ end
187
+ end
@@ -53,12 +53,16 @@ module Lutaml
53
53
  # name and wildcard rows carry their own :any.
54
54
  ROW_KINDS_NS_UNQUALIFIED = %i[scalar collection nested callback
55
55
  raw].freeze
56
+ # Row kinds whose hydration can dispatch per source node —
57
+ # the only safe claimants of a contested (multi-row) name.
58
+ PER_NODE_ROW_KINDS = %i[nested ordered_deferred
59
+ child_deferred].freeze
56
60
 
57
61
  class << self
58
62
  def compile(model_class, register)
59
- # Version gate, not a capability probe: the plan path needs
60
- # leptris >= 1.9.273.0 (the child-handles snapshot). Older
61
- # gems take the interpretive path via the nil opt-out.
63
+ # Version gate: below MIN_LEPTRIS_VERSION (the single
64
+ # capability floor) gems take the interpretive path via
65
+ # the nil opt-out.
62
66
  return nil unless Leptris.plan_path_compatible?
63
67
 
64
68
  # A child model's declared lutaml_default_register takes
@@ -96,58 +100,31 @@ module Lutaml
96
100
  end
97
101
  end
98
102
 
99
- # Two engine walk defects gate walk-side consumers here; both
100
- # bail models to the interpretive matcher, whose exact-first-
101
- # then-any-qualification precedence binds what the walk drops.
102
- # The serialize side (PlanSerializer) reads the Ruby tree and
103
- # stays on the fast path. leptris 1.9.320.0 (#1587) fixes
104
- # both defects — the scan is skipped there and every model
105
- # rides the walk again.
106
- #
107
- # leptris#1585: exact-URI CHILD-row ns_uri strings are not
108
- # retained engine-side — once the build anchors are GC'd (a
109
- # warm walk plus one GC suffices), those rows stop matching
110
- # and hydration silently answers nil.
111
- #
112
- # leptris#1586: plain attribute rows do not leniently match
113
- # namespace-qualified wire attributes (uniword fontTable:
114
- # every w:name/w:val against a plain attr row hydrates nil).
115
- # A plan level carrying plain attr rows under a namespaced
116
- # chain therefore cannot be trusted on the walk; attribute
117
- # rows with an exact URI (or :any) and plans without any
118
- # namespace form are unaffected.
119
- def engine_walk_safe?(plan)
120
- return true if Leptris.walk_ns_safety_compatible?
121
-
122
- tree_safe?(plan[:tree], false)
123
- end
124
-
125
103
  private
126
104
 
127
- def tree_safe?(tree, ancestor_ns)
128
- namespaced = ancestor_ns || tree[:ns].is_a?(Hash)
129
- attrs = tree[:attributes] || []
130
- return false if namespaced &&
131
- attrs.any? { |a| !a[:ns].is_a?(Hash) && a[:ns] != :any }
132
-
133
- (tree[:children] || []).all? do |row|
134
- return false if row[:ns].is_a?(Hash)
135
-
136
- next true unless row[:kind] == :nested && row[:plan]
137
-
138
- tree_safe?(row[:plan], namespaced)
139
- end
140
- end
141
-
142
105
  def build(model_class, register)
143
106
  return nil unless model_class.is_a?(Class) &&
144
107
  model_class.include?(::Lutaml::Model::Serialize)
145
108
 
146
109
  mapping = model_class.mappings_for(:xml, register)
147
110
  return nil unless compilable_mapping?(mapping)
111
+ # Ordered/mixed models ride the plan path only when opted
112
+ # in: the per-node ordered hydration beats the interpretive
113
+ # lane on time but allocates above the downstream budget
114
+ # gates (niso pnas ratio 1.25) — it ships dark until the
115
+ # allocation diet lands.
116
+ if (mapping.ordered? || mapping.mixed_content?) &&
117
+ !Lutaml::Model::Config.instance.xml_plan_ordered_path
118
+ return opt_out!(:ordered_opt_in)
119
+ end
120
+ # Processing-instruction mappings (rfcxml's <?rfc toc?>)
121
+ # have no plan-row spelling — the interpretive path owns
122
+ # them (compiling here silently dropped pi_settings).
123
+ return opt_out!(:processing_instruction) unless
124
+ mapping.processing_instruction_mappings.empty?
148
125
 
149
126
  rows = []
150
- attr_rows = [] # [[rule, attr]] hydration metadata
127
+ attr_rows = [] # [[rule, attr, row_name]] hydration metadata
151
128
  plan_attrs = [] # [{name:, kind:}] rows for the engine plan
152
129
  compiled = [] # [rule, attr, kind, spelling, delegate_target]
153
130
  ns_attr_names = [] # exact-URI attribute rows (ambiguity guard)
@@ -243,28 +220,26 @@ module Lutaml
243
220
  type = attr.type(register)
244
221
  if serializable_type?(type)
245
222
  child = compile(type, register)
246
- return opt_out!(:child_uncompilable) unless child
247
-
248
- if child[:ordered]
249
- # Ordered/mixed children need element_order on their
250
- # instances; the walk hands back plan values, not
251
- # source nodes, so the subtree defers interpretively
252
- # (the fragment parse runs the full machinery,
253
- # order included).
254
- return opt_out!(:ordered_child_with_ns) if model_ns
255
-
256
- needs_nodes = true
257
- compiled << [rule, attr, :ordered_deferred, nil,
258
- delegate_target]
259
- rows << { name: rule.name.to_s, kind: :raw }
260
- else
261
- compiled << [rule, attr, :nested, nil, delegate_target]
262
- needs_nodes ||= child[:needs_nodes]
263
- nested_row = { name: rule.name.to_s, kind: :nested,
264
- plan: child[:tree] }
265
- nested_row[:ns] = child_ns(rule, model_ns) if rule.namespace_set?
266
- rows << nested_row
223
+ if child.nil? &&
224
+ !Lutaml::Model::Config.instance.xml_plan_ordered_path
225
+ child_mapping = type.mappings_for(:xml, register)
226
+ if child_mapping&.ordered? ||
227
+ child_mapping&.mixed_content?
228
+ # A child refused ONLY by the ordered opt-in gate
229
+ # cascades: the bridged-island deferral drops its
230
+ # mixed children (customxml schemaRefs), so the
231
+ # parent compiles to nil — main's whole-model
232
+ # interpretive shape.
233
+ return opt_out!(:ordered_opt_in_cascade)
234
+ end
267
235
  end
236
+ needs_nodes = true if child.nil? || child[:ordered]
237
+ compiled_entry, row = child_row_for(rule, attr, type, child,
238
+ delegate_target,
239
+ model_ns)
240
+ compiled << compiled_entry
241
+ needs_nodes ||= child && child[:needs_nodes]
242
+ rows << row
268
243
  elsif rule.multiple_mappings?
269
244
  rule.name.each do |spelling|
270
245
  compiled << [rule, attr, :spelling, spelling.to_s,
@@ -312,6 +287,19 @@ module Lutaml
312
287
 
313
288
  row_tags = partition_row_tags!(compiled, rows, tag)
314
289
 
290
+ # Same-name rows cross-echo in the engine walk: every source
291
+ # element of a name lands in every row of that name (two
292
+ # w14:/w15: docId rows each capture both elements). Names
293
+ # claimed by more than one element row are contested — they
294
+ # hydrate per source node (the ordered_deferred pattern)
295
+ # when every claimant is per-node-dispatchable, and opt the
296
+ # model out otherwise (the interpretive matcher owns the
297
+ # (URI, local) dispatch there).
298
+ contested = contested_element_names(rows, compiled)
299
+ return opt_out!(:contested_names) if contested == :unsafe
300
+
301
+ needs_nodes = true unless contested.empty?
302
+
315
303
  # The plan serializer does not emit namespace declarations —
316
304
  # namespaced models serialize through the interpretive
317
305
  # writer (lutaml-model#847: standalone to_xml under leptris
@@ -335,13 +323,13 @@ module Lutaml
335
323
  flags << :ns_lenient
336
324
 
337
325
  # Namespace-less model child rows bind the strict unwritten
338
- # spelling (bare, or inherited default xmlns) on engines
339
- # with the leptris#1560 :unqualified form — the interpretive
340
- # matcher's exact no-namespace semantic, refusing prefixed
341
- # spellings where the ns_lenient superset bound them.
342
- # Rows with an explicit form (rule ns, type ns, :none, :any)
343
- # are untouched; name-matched element row kinds only.
344
- if model_ns.nil? && Leptris.ns_unqualified_rows_compatible?
326
+ # spelling (bare, or inherited default xmlns) — the
327
+ # interpretive matcher's exact no-namespace semantic,
328
+ # refusing prefixed spellings where the ns_lenient
329
+ # superset bound them. Rows with an explicit form (rule ns,
330
+ # type ns, :none, :any) are untouched; name-matched element
331
+ # row kinds only.
332
+ if model_ns.nil?
345
333
  rows.each do |r|
346
334
  next if r[:ns]
347
335
 
@@ -373,14 +361,6 @@ module Lutaml
373
361
  # must receive one through the buckets chain.
374
362
  needs_nodes = true unless ns_attr_names.empty?
375
363
 
376
- # A model combining exact-URI attribute rows with nested
377
- # child rows needs the walk's nested attribute capture
378
- # (leptris#1563, fixed 1.9.313.0): older engines drop the
379
- # children's attribute capture under such plans, so they
380
- # stay interpretive. Leaf models are unaffected.
381
- return opt_out!(:attr_type_ns_nested) if plan_attrs.any? { |a| a[:ns] } &&
382
- rows.any? && !Leptris.nested_attr_capture_compatible?
383
-
384
364
  # The catch-all row: kind :wildcard with an explicit :any ns
385
365
  # form (pad0) — the two-pass walk routes every child no
386
366
  # named row claimed here, tagged for hydrator routing.
@@ -403,6 +383,7 @@ module Lutaml
403
383
  ordered: mapping.ordered? || mapping.mixed_content?,
404
384
  needs_nodes: needs_nodes,
405
385
  collection_defaults: collection_defaults,
386
+ contested_names: contested.empty? ? nil : contested,
406
387
  ns_attr_names: ns_attr_names.empty? ? nil : ns_attr_names,
407
388
  wildcard_tag: (wildcard_tag if defined?(wildcard_tag)) }
408
389
  end
@@ -424,9 +405,19 @@ module Lutaml
424
405
  attr_local_claims)
425
406
  return :non_scalar unless scalar_type?(attr, register)
426
407
 
427
- attr_rows << [rule, attr]
408
+ raw_name = rule.name.to_s
409
+ # W3C-reserved xml:* attributes bind by raw name — the xml
410
+ # prefix is implicit (never a model namespace), so form
411
+ # defaults must not qualify them under the model's URI
412
+ # (w:t's xml:space under a :qualified WML mapping).
413
+ if raw_name.start_with?("xml:")
414
+ attr_local_claims[raw_name][:plain] += 1
415
+ plan_attrs << { name: raw_name }
416
+ attr_rows << [rule, attr, raw_name]
417
+ return nil
418
+ end
428
419
  type_ns = attr.type_namespace_class(register)
429
- claim = attr_local_claims[rule.name.to_s]
420
+ raw_name
430
421
  if type_ns.nil?
431
422
  # attribute_form_default :qualified on the model's
432
423
  # namespace qualifies locally-declared attributes under
@@ -438,39 +429,108 @@ module Lutaml
438
429
  ns_class = mapping.namespace_class
439
430
  if rule.namespace_param != :blank && ns_class &&
440
431
  ns_class.attribute_form_default == :qualified
441
- claim[:ns] += 1
442
- plan_attrs << { name: rule.name.to_s,
432
+ row_name = local_attr_name(raw_name)
433
+ attr_local_claims[row_name][:ns] += 1
434
+ plan_attrs << { name: row_name,
443
435
  ns: { exact: ns_class.uri.to_s,
444
436
  prefix: ns_class.prefix_default&.to_s } }
445
- ns_attr_names << rule.name.to_s
437
+ ns_attr_names << row_name
438
+ attr_rows << [rule, attr, row_name]
446
439
  return nil
447
440
  end
448
441
 
449
- claim[:plain] += 1
450
- plan_attrs << { name: rule.name.to_s }
442
+ attr_local_claims[raw_name][:plain] += 1
443
+ plan_attrs << { name: raw_name }
444
+ attr_rows << [rule, attr, raw_name]
451
445
  return nil
452
446
  end
453
447
  return :attr_type_ns_symbol if type_ns.is_a?(Symbol)
454
- return :attr_type_ns_aliases unless Leptris.attr_ns_rows_compatible? &&
455
- type_ns.all_uris.size == 1
456
-
457
- claim[:ns] += 1
458
- plan_attrs << { name: rule.name.to_s,
448
+ return :attr_type_ns_aliases unless type_ns.all_uris.size == 1
449
+
450
+ # Exact-URI rows match the engine's (URI, local) capture by
451
+ # the LOCAL name — a prefixed rule spelling (xlink:href)
452
+ # binds its local part under the row's URI. Plain rows keep
453
+ # the raw name (matches-any-qualification capture). The
454
+ # claims tally keys by the effective match key so an exact
455
+ # row and a plain row claiming the same local name still
456
+ # opt the model out (#758).
457
+ row_name = local_attr_name(raw_name)
458
+ attr_local_claims[row_name][:ns] += 1
459
+ plan_attrs << { name: row_name,
459
460
  ns: { exact: type_ns.uri.to_s,
460
461
  prefix: type_ns.prefix_default&.to_s } }
461
- ns_attr_names << rule.name.to_s
462
+ ns_attr_names << row_name
463
+ attr_rows << [rule, attr, row_name]
462
464
  nil
463
465
  end
464
466
 
467
+ def local_attr_name(name)
468
+ name.include?(":") ? name.split(":", 2).last : name
469
+ end
470
+
471
+ # Names claimed by more than one element row (engine-row
472
+ # view; content and wildcard pseudo-rows are exempt). Returns
473
+ # the contested name list, or :unsafe when any claimant row
474
+ # cannot dispatch per node — the model then compiles to nil
475
+ # and the interpretive matcher owns the dispatch.
476
+ def contested_element_names(rows, compiled)
477
+ contested = []
478
+ pseudo_row_kinds = %i[content wildcard].freeze
479
+ rows.group_by { |r| r[:name] }.each do |name, group|
480
+ next if group.length < 2 || name.nil?
481
+ next if group.any? { |r| pseudo_row_kinds.include?(r[:kind]) }
482
+ # when_attribute partitions route exclusively by engine
483
+ # predicate (leptris 1.9.221+ #1272) — same-name partition
484
+ # groups are engine-managed, not cross-echoed.
485
+ next if group.any? { |r| r[:when] }
486
+
487
+ kinds = compiled.filter_map do |rule, _attr, kind, _spelling, _del|
488
+ next if rule.nil?
489
+
490
+ names = rule.name.is_a?(Array) ? rule.name.map(&:to_s) : [rule.name.to_s]
491
+ kind if names.include?(name)
492
+ end
493
+ return :unsafe unless kinds.all? do |k|
494
+ PER_NODE_ROW_KINDS.include?(k)
495
+ end
496
+
497
+ contested << name
498
+ end
499
+ contested
500
+ end
501
+
502
+ # Row entries for a serializable child. Three shapes: # - nil child (a cyclic graph, or a shape that opted out):
503
+ # :child_deferred — the ROW defers per-node instead of
504
+ # opting the whole model out; the buckets chain delivers
505
+ # source nodes and the interpretive cast runs the full
506
+ # machinery on each island. (The whole-model bail here
507
+ # cascaded child_uncompilable up entire trees — uniword's
508
+ # Run → pict cycle nilled DocumentRoot.)
509
+ # - ordered child: :ordered_deferred — the per-node plan
510
+ # walk + element_order rebuild (PlanOrder) carry the
511
+ # namespace context in the child plan's own exact-URI
512
+ # rows; nothing is lost under a namespaced parent.
513
+ # - otherwise: :nested with the child's tree.
514
+ # Returns [[compiled tuple], tree row].
515
+ def child_row_for(rule, attr, type, child, delegate_target,
516
+ model_ns)
517
+ if child.nil?
518
+ [[rule, attr, :child_deferred, nil, delegate_target, type],
519
+ { name: rule.name.to_s, kind: :raw }]
520
+ elsif child[:ordered]
521
+ [[rule, attr, :ordered_deferred, nil, delegate_target, type],
522
+ { name: rule.name.to_s, kind: :raw }]
523
+ else
524
+ row = { name: rule.name.to_s, kind: :nested,
525
+ plan: child[:tree] }
526
+ row[:ns] = child_ns(rule, model_ns) if rule.namespace_set?
527
+ [[rule, attr, :nested, nil, delegate_target, type], row]
528
+ end
529
+ end
530
+
465
531
  def compilable_mapping?(mapping)
466
532
  # Mapping's uniform interface: root_mappings defaults to
467
533
  # false on the base (KeyValue overrides with its own).
468
- # The map_any_element catch-all rides the engine's wildcard
469
- # row (leptris#1552, named rows take precedence); engines
470
- # without the kind keep the interpretive refusal.
471
- return false if mapping.any_element_rule &&
472
- !Leptris.wildcard_rows_compatible?
473
-
474
534
  mapping.root_element && !mapping.root_mappings?
475
535
  end
476
536
 
@@ -548,8 +608,7 @@ module Lutaml
548
608
  type_ns = attr.type_namespace_class(register)
549
609
  return nil if type_ns.nil? || type_ns == :inherit
550
610
  return :bail_symbol if type_ns.is_a?(Symbol)
551
- return :bail_aliases unless Leptris.attr_ns_rows_compatible? &&
552
- type_ns.all_uris.size == 1
611
+ return :bail_aliases unless type_ns.all_uris.size == 1
553
612
 
554
613
  form = { exact: type_ns.uri.to_s,
555
614
  prefix: type_ns.prefix_default&.to_s }