lutaml-model 0.8.105 → 0.8.107

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c9d133a4a89e51d9a1ed811bd992faeac4f0a8513e283b46f079707bcc083f7e
4
- data.tar.gz: e4df7bb98a1f7518bdd9f2194934e91feea5367bbf536f75aa4353a067b65049
3
+ metadata.gz: 1bcd55c9589b05b0eb9c4c9e0231a8e92b9063c3c34a5f9c76da5fc99e2c28f9
4
+ data.tar.gz: eb53b700df96a27985ff448b264fc9c4f022200d0efc8ebb313db635c6618f49
5
5
  SHA512:
6
- metadata.gz: 7040ad58b0a31222207e14245293f8f20eb674180dcd55936de938151f16fc5260615f5ec3244769e4644af0bd6f02c9262e87717cbdff9d578a6b1365bbbb3a
7
- data.tar.gz: f5d38407f6e5ad1b87bad426f68398a6e1249b57c928aa1a94f0585b144517542f0565c38ad2f53b31bc2c80bdaf63a5425968ba883c9d222508940f9b8b1c17
6
+ metadata.gz: 3e865d6c056755e187437ebf83d2423b9027df62f0f91822ff85f52541b935219869fb76e3cdd5f98c7422aef828ecea618c454259e82f0c86fa9457ca4e43dc
7
+ data.tar.gz: 65b581acd3765958cbf7d0e2f1607dfc552d60b441ea42d85bbd247a5c04b4487957cd951853c1fc822b3bd79ad701e6c02820476b022af5f73398d7dc8191e4
data/Gemfile CHANGED
@@ -49,7 +49,7 @@ gem "rexml"
49
49
  # exact-URI attribute rows, nested-attribute capture, wildcard rows,
50
50
  # walk-side ns retention and leniency, :unqualified rows, and the
51
51
  # address-based text attach all ride it.
52
- gem "leptris", ">= 1.9.336.0"
52
+ gem "leptris", ">= 1.9.351.1"
53
53
  gem "teptris"
54
54
  gem "yeptris" unless Gem.win_platform?
55
55
  # TODO: revert rng branch to main when lutaml/rng#32 is merged
@@ -389,12 +389,18 @@ module Lutaml
389
389
 
390
390
  def cast_value(value, register)
391
391
  if list_like?(value)
392
- build_collection(cast_items(value, register), register: register)
392
+ items = cast_items(value, register)
393
393
  elsif coerce_to_collection?(value)
394
- build_collection(cast_items([value], register), register: register)
394
+ items = cast_items([value], register)
395
395
  else
396
- cast_element(value, register)
396
+ return cast_element(value, register)
397
397
  end
398
+ # cast_items already returns a fresh array, so the plain-Array
399
+ # collection needs no re-wrap — build_collection's
400
+ # collection_class.new would copy it again.
401
+ return items if !custom_collection? && collection_class == ::Array
402
+
403
+ build_collection(items, register: register)
398
404
  end
399
405
 
400
406
  # Plain Arrays count as list input. On singular attributes
@@ -37,7 +37,7 @@ module Lutaml
37
37
  @conversion_cache = nil
38
38
  @configured = false
39
39
  @xml_plan_fast_path = true
40
- @xml_plan_ordered_path = false
40
+ @xml_plan_ordered_path = true
41
41
  @element_order_tracking = true
42
42
  end
43
43
 
@@ -223,6 +223,7 @@ module Lutaml
223
223
  # Clear memoized attribute merge (see .attributes)
224
224
  @merged_attributes_cache = nil
225
225
  @instantiate_writers = nil
226
+ @instantiate_attr_pairs = nil
226
227
 
227
228
  # Clear per-Attribute type caches (stale entries from GC'd TypeContext objects)
228
229
  class_attributes.each_value(&:clear_type_cache)
@@ -402,7 +403,10 @@ module Lutaml
402
403
  attrs_by = attributes(register_id)
403
404
  writers = instantiate_writers(register_id)
404
405
  given = 0
405
- attrs_by.each do |name, attr|
406
+ # Memoized pair array: Hash#each mints a [name, attr] pair
407
+ # per attribute per instance (~5 allocs/instance on
408
+ # hydration-heavy corpora); Array#each reuses its elements.
409
+ instantiate_attr_pairs(register_id).each do |name, attr|
406
410
  next if attr.derived?
407
411
 
408
412
  # Symbol keys hit directly (native-extension callers);
@@ -468,6 +472,14 @@ module Lutaml
468
472
  end
469
473
  end
470
474
 
475
+ # [name, attr] pairs in attributes order — instantiate's loop
476
+ # iterates this instead of the attributes Hash so the per-call
477
+ # pair array disappears (see the instantiate comment).
478
+ def instantiate_attr_pairs(register_id = nil)
479
+ (@instantiate_attr_pairs ||= {})[register_id] ||=
480
+ attributes(register_id).to_a.freeze
481
+ end
482
+
471
483
  # Define register-specific attribute methods on the class itself.
472
484
  #
473
485
  # Called once per (class, register) combination. Replaces per-instance
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Lutaml
4
4
  module Model
5
- VERSION = "0.8.105"
5
+ VERSION = "0.8.107"
6
6
  end
7
7
  end
@@ -15,6 +15,10 @@ 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
23
  # plan: the compiled plan entry for model_class
20
24
  # value: the walk root PlanValue (element)
@@ -41,7 +45,7 @@ module Lutaml
41
45
  element_buckets(node, plan[:contested_names])
42
46
  end
43
47
  attr_kwargs = attributes_kwargs(plan, value, node)
44
- child_kwargs, children, delegates =
48
+ child_kwargs, children, delegates, run_children =
45
49
  children_kwargs(model_class, plan, value, buckets, register)
46
50
  plan[:collection_defaults].each do |name|
47
51
  next if attr_kwargs.key?(name) || child_kwargs.key?(name)
@@ -51,8 +55,8 @@ module Lutaml
51
55
  # TODO.max-perf/33: the bulk constructor — allocate +
52
56
  # compiled writers, from_hash semantics — measured ~30x on
53
57
  # hydration-heavy corpora versus the generic constructor.
54
- instance = model_class.instantiate(attr_kwargs.merge(child_kwargs),
55
- register)
58
+ attr_kwargs.update(child_kwargs)
59
+ instance = model_class.instantiate(attr_kwargs, register)
56
60
  instance.lutaml_parent = parent if parent
57
61
  instance.lutaml_root ||= parent&.lutaml_root || parent
58
62
  # Root prefix fidelity (the interpretive transform records
@@ -84,7 +88,8 @@ module Lutaml
84
88
  child.lutaml_parent = instance
85
89
  child.lutaml_root ||= instance.lutaml_root || instance
86
90
  end
87
- interpret_deferred(model_class, plan, value, instance, node, buckets)
91
+ interpret_deferred(model_class, plan, value, instance, node,
92
+ buckets, run_children)
88
93
  route_delegates(delegates, instance)
89
94
  instance
90
95
  end
@@ -144,8 +149,12 @@ module Lutaml
144
149
  kwargs = {}
145
150
  sole_claimants = sole_claimant_names(plan)
146
151
  ns_attr_names = plan[:ns_attr_names]
147
- plan_attr_rows(plan).each do |rule, attr, name|
148
- 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]
149
158
  if v.nil? && node && sole_claimants.include?(name)
150
159
  # #754/#790 parity: a sole-claimant attribute rule binds
151
160
  # any qualification (the interpretive matcher's lenient
@@ -235,7 +244,8 @@ module Lutaml
235
244
  def children_kwargs(_model_class, plan, value, buckets = nil,
236
245
  register = nil)
237
246
  register ||= Lutaml::Model::Config.default_register
238
- grouped, tagged = group_children(value, plan[:row_tags])
247
+ grouped, tagged, run_children = group_children(value,
248
+ plan[:row_tags])
239
249
  buckets = nil unless buckets && plan[:needs_nodes]
240
250
  kwargs = {}
241
251
  children = []
@@ -244,10 +254,21 @@ module Lutaml
244
254
  # the block-backed Hash is a fresh object per hydrated row.
245
255
  spellings = nil
246
256
  row_tags = plan[:row_tags]
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
247
265
  # rubocop:disable Metrics/BlockLength -- one row-kind
248
266
  # dispatch table; splitting it would scatter the contract
249
- plan[:rows].each_with_index do |(rule, attr, kind, spelling, delegate, row_type), idx|
267
+ rows.each do |row|
268
+ idx += 1
269
+ rule, attr, kind, spelling, delegate, _row_type = row
250
270
  tag = row_tags&.[](idx)
271
+ name = wire_names[idx]
251
272
  case kind
252
273
  when :scalar
253
274
  # Raw passthrough: the model constructor is the single
@@ -265,7 +286,7 @@ module Lutaml
265
286
  first = if tag
266
287
  tagged[tag]&.first
267
288
  else
268
- grouped.dig(rule.name.to_s, 0)
289
+ grouped.dig(name, 0)
269
290
  end
270
291
  v = first&.string_value
271
292
  v = mapped_empty_value(rule) if v.nil? && first
@@ -277,7 +298,7 @@ module Lutaml
277
298
  when :wildcard_any
278
299
  # interpreted post-instance (interpret_deferred)
279
300
  when :content
280
- runs = content_runs(value)
301
+ runs = content_runs(value, run_children)
281
302
  if runs.empty? && !attr.collection?
282
303
  # An empty element's content binds through the rule's
283
304
  # value map for scalar content attributes: "" for the
@@ -293,15 +314,13 @@ module Lutaml
293
314
  # walk against its own node + element_order from the
294
315
  # node's children. Without one (direct PlanHydrator
295
316
  # use), the subtree fragment-parses interpretively.
296
- if buckets
297
- child_type = row_type || attr.type(register)
298
- child_plan = PlanCompiler.compile(child_type, register)
299
- selected, = child_bucket(buckets, rule, child_plan,
300
- contested_name?(plan, rule))
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)
301
320
  items = (selected || []).map do |n|
302
321
  call(child_type, child_plan,
303
322
  child_plan[:descriptor].walk(n), node: n,
304
- register: register)
323
+ register: child_register)
305
324
  end
306
325
  unless items.empty?
307
326
  children.concat(items)
@@ -314,10 +333,10 @@ module Lutaml
314
333
  # source node casts through the attribute — the full
315
334
  # interpretive machinery on the bridged island,
316
335
  # identical semantics to the interpretive path.
317
- contested_uri = if plan[:contested_names]&.include?(rule.name.to_s)
336
+ contested_uri = if plan[:contested_names]&.include?(name)
318
337
  child_type_uri(attr, register)
319
338
  end
320
- elements = deferred_elements(value, rule, buckets,
339
+ elements = deferred_elements(value, name, buckets,
321
340
  contested_uri)
322
341
  next if elements.empty?
323
342
 
@@ -330,7 +349,7 @@ module Lutaml
330
349
  attr.collection? ? results : results.first)
331
350
  end
332
351
  when :collection_cb
333
- values = grouped[rule.name.to_s].to_a.map do |item|
352
+ values = grouped[name].to_a.map do |item|
334
353
  item&.string_value
335
354
  end
336
355
  values = mapped_capture_values(values, rule)
@@ -344,7 +363,7 @@ module Lutaml
344
363
  values = if tag
345
364
  tagged[tag].to_a.map(&:string_value)
346
365
  else
347
- native_collection(value, rule.name.to_s)
366
+ native_collection(value, name)
348
367
  end
349
368
  values = mapped_capture_values(values, rule) unless values.nil?
350
369
  unless values.nil?
@@ -354,29 +373,33 @@ module Lutaml
354
373
  spellings ||= Hash.new { |h, k| h[k] = [] }
355
374
  spellings[[rule, attr]] << grouped[spelling.to_s].to_a
356
375
  when :nested
357
- child_type = row_type || attr.type(register)
358
- child_plan = PlanCompiler.compile(child_type, register)
359
- selected, = child_bucket(buckets, rule, child_plan,
360
- contested_name?(plan, rule))
361
- if selected && plan[:contested_names]&.include?(rule.name.to_s)
376
+ child_type, child_plan, child_register, contested = row_memo[idx]
377
+ selected = child_selection(buckets, name, child_plan, contested)
378
+ if selected && contested
362
379
  # Contested name: the walk's grouped values cross-echo
363
380
  # every same-name element into every row — dispatch
364
381
  # per source node instead (the walk is row-scoped).
365
382
  items = selected.map do |n|
366
383
  call(child_type, child_plan,
367
384
  child_plan[:descriptor].walk(n), node: n,
368
- register: register)
385
+ register: child_register)
369
386
  end
370
387
  else
371
- values = grouped.fetch(rule.name.to_s, [])
388
+ values = grouped.fetch(name, [])
372
389
  cursor = if child_plan[:needs_nodes] && buckets
373
390
  selected
374
391
  end
375
- items = values.each_with_index
376
- .map do |v, i|
377
- call(child_type, child_plan, v,
378
- node: cursor && cursor[i], register: register)
379
- 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
380
403
  end
381
404
  next if items.empty?
382
405
 
@@ -399,7 +422,7 @@ module Lutaml
399
422
  attr.collection? ? values : values.first)
400
423
  end
401
424
  end
402
- [kwargs, children, delegates]
425
+ [kwargs, children, delegates, run_children]
403
426
  end
404
427
 
405
428
  # Deferred islands: raw subtrees become wrapper elements via a
@@ -410,11 +433,20 @@ module Lutaml
410
433
  # node (no fragment parse — the reparse dominated deferred
411
434
  # hydration). Ordered children only land here without a node.
412
435
  def interpret_deferred(model_class, plan, value, instance, node = nil,
413
- buckets = nil)
414
- 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]
415
447
  case kind
416
448
  when :content_deferred
417
- runs = content_runs(value)
449
+ runs = content_runs(value, run_children)
418
450
  if runs.empty?
419
451
  # The interpretive pipeline classifies the raw ""
420
452
  # through the rule's value map: the :empty directive
@@ -437,13 +469,13 @@ module Lutaml
437
469
  # concatenated).
438
470
  instance.public_send(:"#{attr.name}=", runs.join)
439
471
  when :raw
440
- raws = raw_strings(value, rule)
472
+ raws = raw_strings(value, wire_name)
441
473
  next if raws.empty?
442
474
 
443
475
  instance.public_send(:"#{attr.name}=",
444
476
  attr.collection? ? raws : raws.first)
445
477
  when :custom_method
446
- elements = deferred_elements(value, rule, buckets)
478
+ elements = deferred_elements(value, wire_name, buckets)
447
479
  next if elements.empty?
448
480
 
449
481
  args = attr.collection? ? elements : elements.first
@@ -453,7 +485,7 @@ module Lutaml
453
485
  when :ordered_deferred
454
486
  next if node # hydrated natively in children_kwargs
455
487
 
456
- results = deferred_elements(value, rule, buckets).map do |element|
488
+ results = deferred_elements(value, wire_name, buckets).map do |element|
457
489
  attr.cast(element, :xml, register,
458
490
  lutaml_parent: instance,
459
491
  lutaml_root: instance.lutaml_root || instance)
@@ -496,7 +528,7 @@ module Lutaml
496
528
  attr.collection? ? results : results.first,
497
529
  )
498
530
  when :polymorphic
499
- results = deferred_elements(value, rule, buckets).map do |element|
531
+ results = deferred_elements(value, wire_name, buckets).map do |element|
500
532
  attr.cast(element, :xml, register,
501
533
  polymorphic: rule.polymorphic,
502
534
  lutaml_parent: instance,
@@ -515,16 +547,16 @@ module Lutaml
515
547
  # construction only, no parse), falling back to a fragment
516
548
  # parse of the captured subtree when no node is available.
517
549
  # Both lists are document-ordered for the row's name.
518
- def deferred_elements(value, rule, buckets, contested_uri = nil)
519
- return raw_strings(value, rule).map { |raw| fragment_element(raw) } unless buckets
550
+ def deferred_elements(value, name, buckets, contested_uri = nil)
551
+ return raw_strings(value, name).map { |raw| fragment_element(raw) } unless buckets
520
552
 
521
- nodes = buckets[rule.name.to_s]
553
+ nodes = buckets[name]
522
554
  return [] unless nodes
523
555
 
524
556
  if contested_uri
525
557
  # Contested name: bind only the (URI, local) matches —
526
558
  # the type's own mapping namespace is the row identity.
527
- nodes = buckets[[contested_uri, rule.name.to_s]] || []
559
+ nodes = buckets[[contested_uri, name]] || []
528
560
  end
529
561
  nodes.map { |n| bridge_element(n) }
530
562
  end
@@ -580,15 +612,14 @@ module Lutaml
580
612
  end
581
613
  end
582
614
 
583
- def content_runs(value)
584
- out = []
585
- value.count.times do |i|
586
- c = value.at(i)
587
- next unless c.name.nil? && c.kind == :collection
588
-
589
- 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 }
590
622
  end
591
- out
592
623
  end
593
624
 
594
625
  # Native collection rows echo their producing row's name
@@ -603,11 +634,11 @@ module Lutaml
603
634
  nil
604
635
  end
605
636
 
606
- def raw_strings(value, rule)
637
+ def raw_strings(value, name)
607
638
  out = []
608
639
  value.count.times do |idx|
609
640
  child = value.at(idx)
610
- if child.name == rule.name.to_s && child.kind == :raw
641
+ if child.name == name && child.kind == :raw
611
642
  out << child.string_value
612
643
  end
613
644
  end
@@ -656,8 +687,14 @@ module Lutaml
656
687
  grouped = {}
657
688
  want_tags = !row_tags.nil?
658
689
  tagged = want_tags ? {} : nil
690
+ run_children = nil
659
691
  names.each_with_index do |name, i|
660
- 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
661
698
 
662
699
  child = children[i]
663
700
  (grouped[name] ||= []) << child
@@ -665,7 +702,7 @@ module Lutaml
665
702
  (tagged[tags[i]] ||= []) << child
666
703
  end
667
704
  end
668
- [grouped, tagged]
705
+ [grouped, tagged, run_children]
669
706
  end
670
707
 
671
708
  # Element children bucketed by local name, document order
@@ -693,27 +730,46 @@ module Lutaml
693
730
  buckets
694
731
  end
695
732
 
696
- # (URI, local) child identity — the interpretive matcher's
697
- # typed-element dispatch: same-name rows in different
698
- # namespaces bind by the child type's exact URI (w14:docId vs
699
- # w15:docId under one model). Only CONTESTED names dispatch
700
- # exactly — a uniquely-named row binds the whole name bucket
701
- # leniently (any qualification), and the uri-keyed view is
702
- # built only for plans that carry contested names. Returns
703
- # [selected, all] where all is the name bucket (aligned with
704
- # the walk's grouped values for position filtering).
705
- def child_bucket(buckets, rule, child_plan, contested)
706
- name = rule.name.to_s
707
- all = buckets ? buckets[name] : nil
708
- return [all, all] unless buckets && all && contested
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
709
741
 
710
742
  tree_ns = child_plan[:tree][:ns]
711
743
  exact_uri = tree_ns.is_a?(Hash) ? tree_ns[:exact] : tree_ns
712
- if exact_uri
713
- qualified = buckets[[exact_uri, name]]
714
- return [qualified || [], all]
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
715
772
  end
716
- [all, all]
717
773
  end
718
774
  end
719
775
  end
@@ -8,22 +8,33 @@ module Lutaml
8
8
  # reference leptris types directly (the moxml adapters and the
9
9
  # interpretive path never enter here).
10
10
  module Leptris
11
- # Single capability floor (leptris 1.9.336.0): the child-handles
11
+ # Single capability floor (leptris 1.9.351.1): the child-handles
12
12
  # snapshot (1.9.273), exact-URI attribute rows (#1486, 1.9.289),
13
13
  # nested-attribute capture (#1563, 1.9.313), wildcard rows
14
14
  # (#1552, 1.9.313), child-row ns_uri retention and qualified
15
15
  # attribute leniency (#1585/#1586, 1.9.320), the :unqualified ns
16
- # form (#1560, 1.9.323), and build_result with the address-based
17
- # text attach (#408, 1.9.334/1.9.336). Bundles resolving an
18
- # older gem keep the interpretive path — PlanCompiler.compile
19
- # answers nil — the opt-out protocol.
20
- MIN_LEPTRIS_VERSION = "1.9.336.0"
16
+ # form (#1560, 1.9.323), build_result with the address-based
17
+ # text attach (#408, 1.9.334/1.9.336), and the plan-value
18
+ # attributes batch face (PlanValue#attributes_snapshot,
19
+ # leptris-ruby#447). Bundles resolving an older gem keep the
20
+ # interpretive path — PlanCompiler.compile answers nil — the
21
+ # opt-out protocol.
22
+ MIN_LEPTRIS_VERSION = "1.9.351.1"
21
23
 
24
+ # Memoized: the compiler consults this on every cache hit (per
25
+ # child row per node), and the comparison minted a Gem::Version
26
+ # per call — visible in the niso ordered-lane allocation profile.
27
+ # The loaded gem cannot change mid-process.
22
28
  def self.plan_path_compatible?
23
- return false unless defined?(Gem)
29
+ return @plan_path_compatible if defined?(@plan_path_compatible)
24
30
 
25
- spec = Gem.loaded_specs["leptris"]
26
- spec && spec.version >= Gem::Version.new(MIN_LEPTRIS_VERSION)
31
+ @plan_path_compatible =
32
+ if defined?(Gem)
33
+ spec = Gem.loaded_specs["leptris"]
34
+ spec && spec.version >= Gem::Version.new(MIN_LEPTRIS_VERSION)
35
+ else
36
+ false
37
+ end
27
38
  end
28
39
 
29
40
  autoload :NativeEmission, "lutaml/xml/leptris/native_emission"
@@ -10,15 +10,24 @@ require "lutaml/xml/adapter/nokogiri_adapter"
10
10
  # must surface in the raised message — otherwise the error names the
11
11
  # input's emptiness while the input is neither empty nor the problem
12
12
  # (lutaml-model#956).
13
+ #
14
+ # moxml#369 changed the recoverable-malformation contract: the leptris
15
+ # adapter's non-strict parse now RECOVERS (the engine's recover mode,
16
+ # leptris#1660) and returns the best-effort tree instead of failing —
17
+ # a mismatched closer yields a document, not a raise. Only inputs with
18
+ # nothing to recover into (whitespace-only) still reach the guard,
19
+ # carrying the recovery diagnostic.
13
20
  RSpec.describe "empty-document errors surface engine parse diagnostics" do
14
21
  describe Lutaml::Xml::Adapter::LeptrisAdapter do
15
- it "appends the engine diagnostic when the parse failed" do
16
- expect do
17
- described_class.parse("<root><a>x</b></root>")
18
- end.to raise_error(Lutaml::Model::InvalidFormatError) do |error|
19
- expect(error.message).to include("the XML engine reported:")
20
- expect(error.message).to include("XML parse error at byte 14")
22
+ it "returns a recovered tree for recoverable malformations (moxml#369)" do
23
+ version = Gem.loaded_specs.fetch("moxml").version
24
+ recovered = version >= Gem::Version.new("0.5.131")
25
+ unless recovered
26
+ skip "moxml predates the recover-mode wiring (0.5.131)"
21
27
  end
28
+
29
+ doc = described_class.parse("<root><a>x</b></root>")
30
+ expect(doc.root.name).to eq("root")
22
31
  end
23
32
 
24
33
  it "reports the engine diagnostic for whitespace-only input" do
@@ -31,13 +31,14 @@ RSpec.describe "XML plan fast path" do
31
31
  let(:root_class) { classes[1] }
32
32
 
33
33
  around do |example|
34
- old = Lutaml::Model::Config.instance.xml_plan_fast_path
34
+ old_fast = Lutaml::Model::Config.instance.xml_plan_fast_path
35
+ old_ordered = Lutaml::Model::Config.instance.xml_plan_ordered_path
35
36
  Lutaml::Model::Config.instance.xml_plan_fast_path = true
36
37
  Lutaml::Model::Config.instance.xml_plan_ordered_path = true
37
38
  example.run
38
39
  ensure
39
- Lutaml::Model::Config.instance.xml_plan_fast_path = old
40
- Lutaml::Model::Config.instance.xml_plan_ordered_path = false
40
+ Lutaml::Model::Config.instance.xml_plan_fast_path = old_fast
41
+ Lutaml::Model::Config.instance.xml_plan_ordered_path = old_ordered
41
42
  end
42
43
 
43
44
  # Real-corpus crash (niso-jats): sec-in-sec nesting is a
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lutaml-model
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.105
4
+ version: 0.8.107
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-10-12 00:00:00.000000000 Z
11
+ date: 2026-10-14 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: base64