lutaml-model 0.8.48 → 0.8.50

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: a482362c928135d0733413ae200b20e5a9a7f05af8faf57696bc4461b093d691
4
- data.tar.gz: 3b22e8a6881ad4a832d90a6b064d6999a5e5b8a969d507fd467d44c66f7c0279
3
+ metadata.gz: f2f88962ec380d731bd7a31ce5ff62f9aec9dcef4b7783aebfb128b6b5c86bbb
4
+ data.tar.gz: b282edc8ccf91c2381315e58a34d54a15642153e673ba31bbbd46c084c7634c4
5
5
  SHA512:
6
- metadata.gz: a6047ddb9f43fa9a7012e794070891a0d997ca261f2c76f324223a1c1414bab39f1f05b5f627ecde1e21a083b971c4cc002d15928db410e97411b5dcca9a5b97
7
- data.tar.gz: b849ae1daa34c19f6f570d1389a2ea43cf3af5ffba5db32bdef33e53a0b7ba960736b511ecfc5f520a926690134a4247227b1fa7a437923a8774a511569f6e88
6
+ metadata.gz: f92f932e37bed4d355b3a41c1d2599bf23a5f0907f2ba18967f27cb5a572a4be11d080d54d0a73787055b6e9143414928e87086f2269f42acfb77665e9cccda6
7
+ data.tar.gz: a5801daa0947b0a2308fc720412d0add3cd5956f6a902cf20aa62b0ece6d883ec801dc4c4112319ab161a5d68934d54aac0253d601515823d04440df52011400
@@ -55,4 +55,50 @@ jobs:
55
55
  run: rake vendor:compile
56
56
 
57
57
  - name: Run performance benchmarks
58
- run: bundle exec rake performance:compare
58
+ run: bundle exec rake performance:compare
59
+ # TODO.max-perf/36: YJIT moves exactly the interpretive paths the
60
+ # model layer still runs. Sequential off/on probes on one runner give
61
+ # a directly comparable pair per commit - a measurement leg, not a
62
+ # gate. The plan fast path shrinks the YJIT win; watch both.
63
+ performance-yjit:
64
+ runs-on: ubuntu-latest
65
+ timeout-minutes: 15
66
+ steps:
67
+ - uses: actions/checkout@v6
68
+ with:
69
+ submodules: "recursive"
70
+
71
+ - name: Set up Ruby
72
+ uses: ruby/setup-ruby@v1
73
+ with:
74
+ ruby-version: '3.4'
75
+ bundler-cache: false
76
+
77
+ - name: Install ragel
78
+ run: sudo apt-get update && sudo apt-get install -y ragel
79
+
80
+ - name: Install ruby-ll (for rake vendor:prepare)
81
+ run: gem install ruby-ll --no-document
82
+
83
+ - name: Generate ragel / ruby-ll outputs in vendored forks
84
+ run: rake vendor:prepare
85
+
86
+ - name: Configure bundler
87
+ run: |
88
+ mkdir -p .bundle
89
+ echo "---" > .bundle/config
90
+ echo 'BUNDLE_BUILD__RUBY___LL: "--with-cflags=-std=gnu17"' >> .bundle/config
91
+
92
+ - name: Install dependencies
93
+ run: bundle install
94
+
95
+ - name: Compile liboga / libll native extensions
96
+ run: rake vendor:compile
97
+
98
+ - name: Probe YJIT off
99
+ run: bundle exec rake performance:probe
100
+
101
+ - name: Probe YJIT on
102
+ env:
103
+ RUBY_YJIT_ENABLE: "1"
104
+ run: bundle exec rake performance:probe
@@ -31,6 +31,36 @@ leptris and yeptris ship prebuilt platform gems for Linux and macOS
31
31
  every platform, including Windows. When a gem is absent, lutaml-model
32
32
  automatically falls back to the standard engine for that format.
33
33
 
34
+ == YJIT doubles the model pipeline
35
+
36
+ The model layer's own work — mapping dispatch, casts, hydration — is
37
+ plain Ruby and exactly the code shape YJIT compiles well; the native
38
+ engines' C work is unaffected either way. On a controlled runner
39
+ (ubuntu-latest, Ruby 3.4, the repository's performance probe):
40
+
41
+ |===
42
+ | op | YJIT off | YJIT on | change
43
+
44
+ | from_yaml | 75.1 i/s | 129.7 i/s | +73%
45
+ | to_yaml | 67.3 i/s | 145.1 i/s | +116%
46
+ | from_json | 73.2 i/s | 128.4 i/s | +75%
47
+ | to_json | 115.4 i/s | 255.5 i/s | +121%
48
+ |===
49
+
50
+ Enable it like any Ruby application does — this is the application's
51
+ call, never a gem's:
52
+
53
+ [source,sh]
54
+ ----
55
+ RUBY_YJIT_ENABLE=1 ruby app.rb
56
+ # or in code, before heavy work:
57
+ RubyVM::YJIT.enable
58
+ ----
59
+
60
+ The opt-in XML plan fast path (see below) moves work out of Ruby and
61
+ into the engine, which shrinks the YJIT win — enabling both is still
62
+ net faster than either alone.
63
+
34
64
  == Selecting adapters explicitly
35
65
 
36
66
  The engines register as ordinary adapter types, so explicit selection and
@@ -350,6 +350,59 @@ module Lutaml
350
350
  instance
351
351
  end
352
352
 
353
+ # Fast bulk constructor for deserialization-heavy callers
354
+ # (native extensions building object trees bottom-up).
355
+ #
356
+ # Allocates without running #initialize and applies each present
357
+ # attribute through its compiled writer, which casts the value
358
+ # and marks it as explicitly set — so defaults, to_hash output,
359
+ # and using_default? behave exactly as if the instance had been
360
+ # produced by from_hash. Values may be primitives or already
361
+ # built instances (instances pass through casting unchanged);
362
+ # absent keys keep their defaults. Unknown keys raise.
363
+ #
364
+ # @param attrs [Hash] attribute names (String or Symbol) to
365
+ # pre-cast values
366
+ # @param register [Symbol, nil] The register context
367
+ # @return [Object] The hydrated instance
368
+ def instantiate(attrs = {}, register = nil)
369
+ instance = allocate_for_deserialization(register)
370
+ register_id = instance.lutaml_register
371
+ given = {}
372
+ attrs.each do |key, value|
373
+ name = key.to_sym
374
+ next if name == :lutaml_register
375
+
376
+ unless attributes(register_id).key?(name)
377
+ raise Error, "unknown attribute '#{name}' for #{self}"
378
+ end
379
+
380
+ given[name] = value
381
+ end
382
+ attributes(register_id).each do |name, attr|
383
+ next if attr.derived?
384
+
385
+ if given.key?(name)
386
+ instance.public_send(:"#{name}=", given[name])
387
+ else
388
+ # Absent keys seed their default explicitly, mirroring
389
+ # initialize_attributes: every mapped attribute ends up
390
+ # set (nil when no default), never left as the
391
+ # uninitialized sentinel — readers and formatters touch
392
+ # absent attributes freely.
393
+ default = attr.default_value(register_id, instance)
394
+ value = if Lutaml::Model::Utils.uninitialized?(default)
395
+ nil
396
+ else
397
+ attr.cast_value(default, register_id)
398
+ end
399
+ instance.public_send(:"#{name}=", value)
400
+ instance.using_default_for(name)
401
+ end
402
+ end
403
+ instance
404
+ end
405
+
353
406
  # Define register-specific attribute methods on the class itself.
354
407
  #
355
408
  # 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.48"
5
+ VERSION = "0.8.50"
6
6
  end
7
7
  end
@@ -200,7 +200,14 @@ module Lutaml
200
200
 
201
201
  def compilable_mapping?(mapping)
202
202
  mapping.root_element &&
203
- !(mapping.respond_to?(:root_mappings) && mapping.root_mappings)
203
+ !(mapping.respond_to?(:root_mappings) && mapping.root_mappings) &&
204
+ # lutaml-model#88: when_attribute partitions route same-name
205
+ # occurrences by attribute value — name-keyed plan rows would
206
+ # hydrate every occurrence into EVERY partition attribute
207
+ # (verified double-capture under the flag). The interpretive
208
+ # filter is the only correct path until the descriptor ABI
209
+ # grows row predicates.
210
+ mapping.mappings.none?(&:when_attribute?)
204
211
  end
205
212
 
206
213
  # Attribute for a rule — delegate rules resolve against their
@@ -30,7 +30,11 @@ module Lutaml
30
30
 
31
31
  child_kwargs[name] = []
32
32
  end
33
- instance = model_class.new(**attr_kwargs, **child_kwargs)
33
+ # TODO.max-perf/33: the bulk constructor — allocate +
34
+ # compiled writers, from_hash semantics — measured ~30x on
35
+ # hydration-heavy corpora versus the generic constructor.
36
+ instance = model_class.instantiate(attr_kwargs.merge(child_kwargs),
37
+ register)
34
38
  instance.lutaml_parent = parent if parent
35
39
  instance.lutaml_root ||= parent&.lutaml_root || parent
36
40
  instance.element_order = PlanOrder.build(node) if node && plan[:ordered]
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "spec_helper"
4
+ require_relative "../../../lib/lutaml/model"
5
+
6
+ module InstantiateMapping
7
+ class Part < Lutaml::Model::Serializable
8
+ attribute :label, :string
9
+ attribute :count, :integer
10
+ end
11
+
12
+ class Widget < Lutaml::Model::Serializable
13
+ attribute :name, :string
14
+ attribute :flag, :boolean
15
+ attribute :part, Part
16
+ attribute :tags, :string, collection: true
17
+
18
+ key_value do
19
+ map "name", to: :name
20
+ map "flag", to: :flag
21
+ map "part", to: :part
22
+ map "tags", to: :tags
23
+ end
24
+ end
25
+ end
26
+
27
+ RSpec.describe "Serializable.instantiate" do
28
+ it "hydrates attributes from primitives" do
29
+ widget = InstantiateMapping::Widget.instantiate("name" => "w", "flag" => true,
30
+ "tags" => %w[a b])
31
+ expect(widget.name).to eq("w")
32
+ expect(widget.flag).to be(true)
33
+ expect(widget.tags).to eq(%w[a b])
34
+ end
35
+
36
+ it "accepts pre-built instances for typed attributes" do
37
+ part = InstantiateMapping::Part.instantiate(label: "p", count: 2)
38
+ widget = InstantiateMapping::Widget.instantiate(name: "w", part: part)
39
+
40
+ expect(widget.part).to equal(part)
41
+ end
42
+
43
+ it "keeps defaults for absent keys and reports them as defaults" do
44
+ widget = InstantiateMapping::Widget.instantiate
45
+ # Absent scalars read as the uninitialized sentinel, exactly like the
46
+ # XML fast path; serialization omits them either way.
47
+ expect(Lutaml::Model::Utils.uninitialized?(widget.name) ||
48
+ widget.name.nil?).to be(true)
49
+ expect(widget).to be_using_default(:name)
50
+ end
51
+
52
+ it "marks set attributes as explicitly set" do
53
+ widget = InstantiateMapping::Widget.instantiate(name: "w")
54
+ expect(widget).not_to be_using_default(:name)
55
+ end
56
+
57
+ it "serializes identically to from_hash" do
58
+ input = { "name" => "w", "flag" => false,
59
+ "part" => { "label" => "p", "count" => 3 },
60
+ "tags" => ["x"] }
61
+ from_hash = InstantiateMapping::Widget.from_hash(input)
62
+
63
+ part = InstantiateMapping::Part.instantiate(input["part"])
64
+ fast = InstantiateMapping::Widget.instantiate(
65
+ name: "w", flag: false, part: part, tags: ["x"],
66
+ )
67
+
68
+ expect(fast.to_hash).to eq(from_hash.to_hash)
69
+ end
70
+
71
+ it "raises on unknown attribute names" do
72
+ expect { InstantiateMapping::Widget.instantiate(bogus: 1) }
73
+ .to raise_error(Lutaml::Model::Error, /unknown attribute/)
74
+ end
75
+ end
@@ -517,6 +517,83 @@ RSpec.describe "XML plan fast path" do
517
517
  expect(model.to_xml).to eq(interpretive)
518
518
  end
519
519
 
520
+ # lutaml-model#88: name-keyed plan rows would hydrate every
521
+ # same-name occurrence into EVERY partition attribute (verified
522
+ # double-capture) — discriminator models must take the interpretive
523
+ # path, and a partitioned CHILD model must opt its parent out too.
524
+ it "falls back to the interpretive path for when_attribute models" do
525
+ component = Class.new(Lutaml::Model::Serializable) do
526
+ attribute :text, :string
527
+
528
+ xml do
529
+ element "component"
530
+ map_element "text", to: :text
531
+ end
532
+ end
533
+ stub_const("PlanFastPath::Component", component)
534
+
535
+ partitioned = Class.new(Lutaml::Model::Serializable) do
536
+ attribute :guidance, component, collection: true
537
+ attribute :purpose, component, collection: true
538
+
539
+ xml do
540
+ element "requirement"
541
+ map_element "component", when_attribute: "type",
542
+ to: { "guidance" => :guidance,
543
+ "purpose" => :purpose }
544
+ end
545
+ end
546
+ stub_const("PlanFastPath::Partitioned", partitioned)
547
+
548
+ req = partitioned.from_xml(<<~XML)
549
+ <requirement>
550
+ <component type="guidance"><text>g1</text></component>
551
+ <component type="purpose"><text>p1</text></component>
552
+ <component type="guidance"><text>g2</text></component>
553
+ </requirement>
554
+ XML
555
+
556
+ expect(req.guidance.map(&:text)).to eq(%w[g1 g2])
557
+ expect(req.purpose.map(&:text)).to eq(["p1"])
558
+
559
+ expect(Lutaml::Xml::PlanCompiler.compile(partitioned,
560
+ :default)).to be_nil
561
+ end
562
+
563
+ it "opts a parent out when a child model partitions with when_attribute" do
564
+ component = Class.new(Lutaml::Model::Serializable) do
565
+ attribute :text, :string
566
+
567
+ xml do
568
+ element "component"
569
+ map_element "text", to: :text
570
+ end
571
+ end
572
+ inner = Class.new(Lutaml::Model::Serializable) do
573
+ attribute :guidance, component, collection: true
574
+
575
+ xml do
576
+ element "req"
577
+ map_element "component", to: :guidance,
578
+ when_attribute: { "type" => "guidance" }
579
+ end
580
+ end
581
+ outer = Class.new(Lutaml::Model::Serializable) do
582
+ attribute :req, inner
583
+
584
+ xml do
585
+ element "holder"
586
+ map_element "req", to: :req
587
+ end
588
+ end
589
+ stub_const("PlanFastPath::Nested", outer)
590
+
591
+ doc = outer.from_xml(
592
+ "<holder><req><component type=\"guidance\"><text>g1</text></component></req></holder>",
593
+ )
594
+ expect(doc.req.guidance.map(&:text)).to eq(["g1"])
595
+ end
596
+
520
597
  it "round-trips through both fast paths" do
521
598
  item = Class.new(Lutaml::Model::Serializable) do
522
599
  attribute :id, :integer
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lutaml-model
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.48
4
+ version: 0.8.50
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
@@ -1797,6 +1797,7 @@ files:
1797
1797
  - spec/lutaml/model/schema/yaml_schema_spec.rb
1798
1798
  - spec/lutaml/model/sequence_choice_compositor_spec.rb
1799
1799
  - spec/lutaml/model/sequence_spec.rb
1800
+ - spec/lutaml/model/serializable_instantiate_spec.rb
1800
1801
  - spec/lutaml/model/serializable_spec.rb
1801
1802
  - spec/lutaml/model/serializable_validation_spec.rb
1802
1803
  - spec/lutaml/model/serialize/builder_spec.rb