From 220e00aebdcdcc7d4740929973a02607153f0a33 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 17:35:16 +0000 Subject: [PATCH 01/11] Add conditional rendering of attributes and associations Support `if:` and `unless:` options on `attribute`/`association` (and the `attributes`/`associations` shorthands), accepting a Symbol (sent to the serializer instance) or a Proc (evaluated in its context): attribute :email, if: :admin? belongs_to :secrets, if: -> { current_user.admin? } Conditions are evaluated per object in `#as_json`. To avoid penalising serializers that don't use the feature, a field is only flagged `:conditional` when it declares `if:`/`unless:`; the hot path then skips condition evaluation with a single hash lookup, so unconditional fields incur no extra method dispatch. Closes #22. --- lib/transmutation/serializer.rb | 57 ++++++++++-- spec/lib/transmutation/serializer_spec.rb | 102 ++++++++++++++++++++++ 2 files changed, 150 insertions(+), 9 deletions(-) diff --git a/lib/transmutation/serializer.rb b/lib/transmutation/serializer.rb index 3126864..4c3ea30 100644 --- a/lib/transmutation/serializer.rb +++ b/lib/transmutation/serializer.rb @@ -32,11 +32,10 @@ def to_json(options = {}) def as_json(options = {}) attributes_config.each_with_object({}) do |(attr_name, attr_options), hash| - if attr_options[:association] - hash[attr_name.to_s] = instance_exec(&attr_options[:block]).as_json(options) if @depth + 1 <= @max_depth - else - hash[attr_name.to_s] = attr_options[:block] ? instance_exec(&attr_options[:block]) : object.send(attr_name) - end + next if attr_options[:conditional] && !render_field?(attr_options) + next if attr_options[:association] && @depth + 1 > @max_depth + + hash[attr_name.to_s] = field_value(attr_name, attr_options, options) end end @@ -44,6 +43,9 @@ class << self # Define an attribute to be serialized # # @param attribute_name [Symbol] The name of the attribute to serialize + # @param if [Symbol, Proc] Only include the attribute when the condition evaluates truthy + # @param unless [Symbol, Proc] Exclude the attribute when the condition evaluates truthy + # - A Symbol is sent to the serializer instance, a Proc is evaluated in its context # @yield [object] The block to call to get the value of the attribute # - The block is called in the context of the serializer instance # @@ -54,9 +56,11 @@ class << self # attribute :full_name do # "#{object.first_name} #{object.last_name}".strip # end + # + # attribute :email, if: :admin? # end - def attribute(attribute_name, &block) - attributes_config[attribute_name] = { block: } + def attribute(attribute_name, **options, &block) + attributes_config[attribute_name] = field_config({ block: }, options) end # Define an association to be serialized @@ -78,14 +82,14 @@ def attribute(attribute_name, &block) # object.posts.archived # end # end - def association(association_name, namespace: nil, serializer: nil, &custom_block) + def association(association_name, namespace: nil, serializer: nil, **options, &custom_block) block = lambda do association_instance = custom_block ? instance_exec(&custom_block) : object.send(association_name) serialize(association_instance, namespace:, serializer:, depth: @depth + 1, max_depth: @max_depth) end - attributes_config[association_name] = { block:, association: true } + attributes_config[association_name] = field_config({ block:, association: true }, options) end # Shorthand for defining multiple attributes @@ -119,6 +123,18 @@ def associations(*association_names, **, &) alias belongs_to associations alias has_one associations alias has_many associations + + private + + # Merge conditional rendering options into a field's config. + # + # The `:conditional` flag lets {#as_json} skip condition evaluation entirely for fields that + # don't declare `if:`/`unless:`, keeping the common path a single hash lookup. + def field_config(config, options) + return config unless options[:if] || options[:unless] + + config.merge(if: options[:if], unless: options[:unless], conditional: true) + end end private @@ -127,6 +143,29 @@ def associations(*association_names, **, &) attr_reader :object + # Resolve the value for a field: a serialized association, a block result, or a method call. + def field_value(attr_name, attr_options, options) + return instance_exec(&attr_options[:block]).as_json(options) if attr_options[:association] + return instance_exec(&attr_options[:block]) if attr_options[:block] + + object.send(attr_name) + end + + # Evaluate the `if:`/`unless:` conditions for a field. + # + # Only called for fields flagged `:conditional`, so unconditional fields incur no overhead. + def render_field?(attr_options) + return false if attr_options[:if] && !evaluate_condition(attr_options[:if]) + return false if attr_options[:unless] && evaluate_condition(attr_options[:unless]) + + true + end + + # A Symbol condition is sent to the serializer; a Proc (or other callable) is run in its context. + def evaluate_condition(condition) + condition.is_a?(Symbol) ? send(condition) : instance_exec(&condition) + end + private_class_method def self.inherited(subclass) super subclass.attributes_config = attributes_config.dup diff --git a/spec/lib/transmutation/serializer_spec.rb b/spec/lib/transmutation/serializer_spec.rb index 7e64d96..84a002e 100644 --- a/spec/lib/transmutation/serializer_spec.rb +++ b/spec/lib/transmutation/serializer_spec.rb @@ -37,4 +37,106 @@ def initialize(first_name:, last_name:) expect(json.to_json).to eq("{\"first_name\":\"John\"}") end end + + describe "conditional rendering" do + context "with a Symbol condition" do + let(:example_serializer) do + Class.new(Transmutation::Serializer) do + attribute :first_name + attribute :last_name, if: :admin? + + def admin? + object.first_name == "John" + end + end + end + + it "includes the attribute when the condition is truthy" do + expect(json.as_json).to eq({ "first_name" => "John", "last_name" => "Doe" }) + end + + it "excludes the attribute when the condition is falsy" do + object = ExampleObject.new(first_name: "Jane", last_name: "Doe") + + expect(example_serializer.new(object).as_json).to eq({ "first_name" => "Jane" }) + end + end + + context "with a Proc condition" do + let(:example_serializer) do + Class.new(Transmutation::Serializer) do + attribute :first_name + attribute :last_name, if: -> { object.last_name == "Doe" } + end + end + + it "evaluates the proc in the serializer's context" do + expect(json.as_json).to eq({ "first_name" => "John", "last_name" => "Doe" }) + end + + it "excludes the attribute when the proc is falsy" do + object = ExampleObject.new(first_name: "John", last_name: "Smith") + + expect(example_serializer.new(object).as_json).to eq({ "first_name" => "John" }) + end + end + + context "with an unless condition" do + let(:example_serializer) do + Class.new(Transmutation::Serializer) do + attribute :first_name + attribute :last_name, unless: :hidden? + + def hidden? + object.first_name == "John" + end + end + end + + it "excludes the attribute when the condition is truthy" do + expect(json.as_json).to eq({ "first_name" => "John" }) + end + + it "includes the attribute when the condition is falsy" do + object = ExampleObject.new(first_name: "Jane", last_name: "Doe") + + expect(example_serializer.new(object).as_json).to eq({ "first_name" => "Jane", "last_name" => "Doe" }) + end + end + + context "with both if and unless conditions" do + let(:example_serializer) do + Class.new(Transmutation::Serializer) do + attribute :first_name + attribute :last_name, if: -> { object.first_name == "John" }, unless: -> { object.last_name == "Smith" } + end + end + + it "includes the attribute only when if is truthy and unless is falsy" do + expect(json.as_json).to eq({ "first_name" => "John", "last_name" => "Doe" }) + end + + it "excludes the attribute when unless is truthy" do + object = ExampleObject.new(first_name: "John", last_name: "Smith") + + expect(example_serializer.new(object).as_json).to eq({ "first_name" => "John" }) + end + end + + context "when applied via the attributes shorthand" do + let(:example_serializer) do + Class.new(Transmutation::Serializer) do + attributes :first_name, :last_name, if: :admin? + + def admin? + false + end + end + end + + it "applies the condition to every attribute" do + expect(json.as_json).to eq({}) + end + end + end end From 27f62db72ac24a551b5312ae1d6e716b9bcd33ec Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 17:38:58 +0000 Subject: [PATCH 02/11] Run CI on branches containing slashes The push trigger filtered on `branches: ["*"]`, but in GitHub Actions filter syntax `*` does not match across `/`, so the Ruby (tests, lint) and Benchmarks jobs were skipped for any branch with a slash in its name (e.g. `claude/...`, `feature/...`). Use `**` to match all branches. --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index c8f8335..854c3fe 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -3,7 +3,7 @@ name: Ruby on: push: branches: - - "*" + - "**" jobs: build: From 98b115a8ce8752313205af20199b77b05f81a970 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 17:54:55 +0000 Subject: [PATCH 03/11] Allow ruby-head CI job to fail ruby-head is a nightly, moving target; native gems (e.g. rugged via the undercover dev dependency) regularly fail to compile against it, which fails `bundle install` before any tests run. Mark the ruby-head matrix entry continue-on-error so nightly toolchain breakage doesn't turn the whole run red, while still surfacing the result. --- .github/workflows/main.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 854c3fe..65140b8 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -11,6 +11,9 @@ jobs: env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true name: Ruby ${{ matrix.ruby }} + # ruby-head is a moving target; allow its toolchain breakages (e.g. native gems + # failing to compile against nightly) to fail without turning the whole run red. + continue-on-error: ${{ matrix.ruby == 'ruby-head' }} strategy: fail-fast: false matrix: From 41dba75e9a1a732c0b60653787c0eb480fe7cb7c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 21:17:53 +0000 Subject: [PATCH 04/11] Reuse the serializer class across collection siblings When serializing a collection (e.g. a has_many association), every item went through lookup_serializer individually. The resolved class was memoised in Serialization.cache, but each sibling still allocated the 4-element cache key and hashed it to hit that cache. Since items of the same class always resolve to the same serializer, resolve it once and reuse it across siblings, falling back to a fresh lookup only when an item's class changes (so heterogeneous and nested collections stay correct). On a 500-item homogeneous collection this removes one allocation per item (~25% fewer) and roughly doubles throughput on the lookup-bound path. --- lib/transmutation/serialization.rb | 30 ++++++++++++++++-- spec/lib/transmutation/serialization_spec.rb | 32 ++++++++++++++++++++ 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/lib/transmutation/serialization.rb b/lib/transmutation/serialization.rb index f634e26..728f9f5 100644 --- a/lib/transmutation/serialization.rb +++ b/lib/transmutation/serialization.rb @@ -11,9 +11,7 @@ module Serialization # # @return [Transmutation::Serializer] The serialized object. This will respond to `#as_json` and `#to_json`. def serialize(object, namespace: nil, serializer: nil, depth: 0, max_depth: Transmutation.max_depth) - if object.respond_to?(:map) && !object.respond_to?(:to_hash) - return object.map { |item| serialize(item, namespace:, serializer:, depth:, max_depth:) } - end + return serialize_collection(object, namespace:, serializer:, depth:, max_depth:) if collection?(object) lookup_serializer(object, namespace:, serializer:).new(object, depth:, max_depth:) end @@ -50,6 +48,32 @@ def namespace @namespace ||= self.class.name.to_s[0, self.class.name.rindex("::") || 0] end + private + + def collection?(object) + object.respond_to?(:map) && !object.respond_to?(:to_hash) + end + + # Serialize each item in a collection. + # + # Items of the same class resolve to the same serializer, so the lookup is memoised on the item's + # class and reused across siblings rather than rebuilding the cache key (and hashing it) per item. + def serialize_collection(collection, namespace:, serializer:, depth:, max_depth:) + serializer_class = nil + serialized_class = nil + + collection.map do |item| + next serialize(item, namespace:, serializer:, depth:, max_depth:) if collection?(item) + + if item.class != serialized_class + serializer_class = lookup_serializer(item, namespace:, serializer:) + serialized_class = item.class + end + + serializer_class.new(item, depth:, max_depth:) + end + end + private_class_method def self.included(base) base.include(Rendering) if base.method_defined?(:render) end diff --git a/spec/lib/transmutation/serialization_spec.rb b/spec/lib/transmutation/serialization_spec.rb index 5300365..2b2c4a0 100644 --- a/spec/lib/transmutation/serialization_spec.rb +++ b/spec/lib/transmutation/serialization_spec.rb @@ -88,6 +88,38 @@ def last_name = "Doe" expect(serialize).to be_an_instance_of(Transmutation::ObjectSerializer) end end + + context "when given a collection" do + before do + stub_const("Api::V1::Admin::Chat::UserSerializer", Class.new(Transmutation::Serializer)) + end + + it "serializes every item with the looked up serializer" do + collection = [object, Object.const_get(object_class_name).new] + + serialized = caller.serialize(collection) + + expect(serialized.map(&:class)).to eq([Api::V1::Admin::Chat::UserSerializer] * 2) + end + + it "serializes each item with the serializer for its own class" do + other_class = Class.new + stub_const("Chat::Admin", other_class) + stub_const("Api::V1::Admin::Chat::AdminSerializer", Class.new(Transmutation::Serializer)) + + serialized = caller.serialize([object, other_class.new]) + + expect(serialized.map(&:class)).to eq( + [Api::V1::Admin::Chat::UserSerializer, Api::V1::Admin::Chat::AdminSerializer] + ) + end + + it "serializes nested collections" do + serialized = caller.serialize([[object]]) + + expect(serialized[0]).to all(be_an_instance_of(Api::V1::Admin::Chat::UserSerializer)) + end + end end describe ".max_depth" do From e9f4cdf50ada95743213654e8bb38aad69731b69 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 21:17:53 +0000 Subject: [PATCH 05/11] Run benchmarks with YJIT in CI Add a second benchmark pass under `ruby --yjit` so the job summary reports both interpreter and YJIT numbers side by side, each under its own heading. --- .github/workflows/main.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 65140b8..d73898a 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -58,7 +58,15 @@ jobs: - name: Run benchmarks working-directory: benchmarks - run: bundle exec ruby benchmark.rb | tee -a "$GITHUB_STEP_SUMMARY" + run: | + echo "# Benchmarks (interpreter)" >> "$GITHUB_STEP_SUMMARY" + bundle exec ruby benchmark.rb | tee -a "$GITHUB_STEP_SUMMARY" + + - name: Run benchmarks (YJIT) + working-directory: benchmarks + run: | + echo "# Benchmarks (YJIT)" >> "$GITHUB_STEP_SUMMARY" + bundle exec ruby --yjit benchmark.rb | tee -a "$GITHUB_STEP_SUMMARY" # The release workflow seems to have some issues. I'll reinstate it when I have time to fix it. # The failing check on `main` doesn't inspire confidence. From 3ce4039550bd3f23e6f577569a4c406dde76e518 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 27 Jun 2026 21:56:12 +0000 Subject: [PATCH 06/11] Benchmark head against main in CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit transmutation is a path gem (../) in the benchmark suite, so swapping lib/ to main's version lets the same job benchmark both revisions on the same runner. The job summary now reports a head-vs-main × interpreter- vs-YJIT matrix, making the performance impact of a branch easy to see. --- .github/workflows/main.yml | 28 ++++++++++++++++++++++++---- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index d73898a..f5a08bd 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -56,16 +56,36 @@ jobs: bundler-cache: true working-directory: benchmarks - - name: Run benchmarks + - name: Run benchmarks (head, interpreter) working-directory: benchmarks run: | - echo "# Benchmarks (interpreter)" >> "$GITHUB_STEP_SUMMARY" + echo "# Benchmarks — head @ interpreter" >> "$GITHUB_STEP_SUMMARY" bundle exec ruby benchmark.rb | tee -a "$GITHUB_STEP_SUMMARY" - - name: Run benchmarks (YJIT) + - name: Run benchmarks (head, YJIT) working-directory: benchmarks run: | - echo "# Benchmarks (YJIT)" >> "$GITHUB_STEP_SUMMARY" + echo "# Benchmarks — head @ YJIT" >> "$GITHUB_STEP_SUMMARY" + bundle exec ruby --yjit benchmark.rb | tee -a "$GITHUB_STEP_SUMMARY" + + # transmutation is a path gem (../), so swapping lib/ to main's version makes the + # following passes benchmark main for a like-for-like comparison on the same runner. + - name: Check out lib from main + run: | + git fetch --depth=1 origin main + rm -rf lib + git checkout FETCH_HEAD -- lib + + - name: Run benchmarks (main, interpreter) + working-directory: benchmarks + run: | + echo "# Benchmarks — main @ interpreter" >> "$GITHUB_STEP_SUMMARY" + bundle exec ruby benchmark.rb | tee -a "$GITHUB_STEP_SUMMARY" + + - name: Run benchmarks (main, YJIT) + working-directory: benchmarks + run: | + echo "# Benchmarks — main @ YJIT" >> "$GITHUB_STEP_SUMMARY" bundle exec ruby --yjit benchmark.rb | tee -a "$GITHUB_STEP_SUMMARY" # The release workflow seems to have some issues. I'll reinstate it when I have time to fix it. From 9579e351183a7170012868bf60d117dd34ff8254 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 10:27:31 +0000 Subject: [PATCH 07/11] Compare head vs main in one table via Namespace isolation Replace the four-pass lib-swap with a single-process comparison. The benchmark job runs on Ruby 4.0 with RUBY_NAMESPACE=1; the harness loads main's library (exported via git archive) into an isolated Namespace and adds a `transmutation (main)` row beside `transmutation` in every group. This brings CI back to two passes (interpreter, YJIT), each emitting a single table where head and main are directly comparable. Loading main is guarded and non-fatal: if the experimental Namespace feature is unavailable or errors, the suite benchmarks head alone. --- .github/workflows/main.yml | 38 ++++++++++++++++---------------------- benchmarks/benchmark.rb | 31 +++++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 22 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index f5a08bd..7963465 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -52,40 +52,34 @@ jobs: - name: Set up Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: "3.4" + ruby-version: "4.0" bundler-cache: true working-directory: benchmarks - - name: Run benchmarks (head, interpreter) - working-directory: benchmarks - run: | - echo "# Benchmarks — head @ interpreter" >> "$GITHUB_STEP_SUMMARY" - bundle exec ruby benchmark.rb | tee -a "$GITHUB_STEP_SUMMARY" - - - name: Run benchmarks (head, YJIT) - working-directory: benchmarks - run: | - echo "# Benchmarks — head @ YJIT" >> "$GITHUB_STEP_SUMMARY" - bundle exec ruby --yjit benchmark.rb | tee -a "$GITHUB_STEP_SUMMARY" - - # transmutation is a path gem (../), so swapping lib/ to main's version makes the - # following passes benchmark main for a like-for-like comparison on the same runner. - - name: Check out lib from main + # Export main's library so the harness can load it alongside head (in an isolated + # namespace) and benchmark both revisions in a single process. + - name: Fetch main revision of the library run: | git fetch --depth=1 origin main - rm -rf lib - git checkout FETCH_HEAD -- lib + mkdir -p "$RUNNER_TEMP/transmutation-main" + git archive FETCH_HEAD lib | tar -x -C "$RUNNER_TEMP/transmutation-main" - - name: Run benchmarks (main, interpreter) + - name: Run benchmarks (interpreter) working-directory: benchmarks + env: + RUBY_NAMESPACE: "1" + TRANSMUTATION_MAIN_LIB: ${{ runner.temp }}/transmutation-main/lib run: | - echo "# Benchmarks — main @ interpreter" >> "$GITHUB_STEP_SUMMARY" + echo "# Benchmarks (interpreter) — head vs main" >> "$GITHUB_STEP_SUMMARY" bundle exec ruby benchmark.rb | tee -a "$GITHUB_STEP_SUMMARY" - - name: Run benchmarks (main, YJIT) + - name: Run benchmarks (YJIT) working-directory: benchmarks + env: + RUBY_NAMESPACE: "1" + TRANSMUTATION_MAIN_LIB: ${{ runner.temp }}/transmutation-main/lib run: | - echo "# Benchmarks — main @ YJIT" >> "$GITHUB_STEP_SUMMARY" + echo "# Benchmarks (YJIT) — head vs main" >> "$GITHUB_STEP_SUMMARY" bundle exec ruby --yjit benchmark.rb | tee -a "$GITHUB_STEP_SUMMARY" # The release workflow seems to have some issues. I'll reinstate it when I have time to fix it. diff --git a/benchmarks/benchmark.rb b/benchmarks/benchmark.rb index 23c4a2d..99f30d6 100644 --- a/benchmarks/benchmark.rb +++ b/benchmarks/benchmark.rb @@ -35,9 +35,37 @@ organisations = [organisation] + 29.times.map { Organisation.new(id: _1 + 2, name: "Example Inc. #{_1 + 2}") } +# Optionally load another revision of transmutation (e.g. main) into an isolated namespace so it can +# be benchmarked side by side with the working tree (head) in a single process. This relies on Ruby's +# experimental Namespace feature (Ruby 4.0+, RUBY_NAMESPACE=1); if it is unavailable or fails to load, +# we simply skip the comparison and benchmark head on its own. +def load_transmutation_main + lib = ENV.fetch("TRANSMUTATION_MAIN_LIB", nil) + return unless lib && defined?(Namespace) && Namespace.respond_to?(:enabled?) && Namespace.enabled? + + namespace = Namespace.new + + $LOAD_PATH.unshift(lib) + begin + namespace.require("transmutation") + Dir[File.expand_path("lib/serializers/transmutation/*.rb", __dir__)].sort.each { |file| namespace.require(file) } + ensure + $LOAD_PATH.delete(lib) + end + + namespace::Transmutation +rescue StandardError, ScriptError => e + warn "Skipping `transmutation (main)` comparison: #{e.class}: #{e.message}" + nil +end + +TRANSMUTATION_MAIN = load_transmutation_main +warn(TRANSMUTATION_MAIN ? "Benchmarking head vs main." : "Benchmarking head only.") + GemBenchmarks.report output: false do group("Attributes") do example("transmutation") { Transmutation::OrganisationSerializer.new(organisation).to_json } + example("transmutation (main)") { TRANSMUTATION_MAIN::OrganisationSerializer.new(organisation).to_json } if TRANSMUTATION_MAIN example("panko_serializer") { PankoSerializer::OrganisationSerializer.new.serialize_to_json(organisation) } example("jbuilder") { Jbuilder.encode { |json| json.instance_eval(organisation_jbuilder_template); json.target! } } example("representable") { Representable::OrganisationRepresenter.new(organisation).to_json } @@ -48,6 +76,7 @@ group("Has One / Belongs To") do example("transmutation") { Transmutation::PostSerializer.new(post).to_json } + example("transmutation (main)") { TRANSMUTATION_MAIN::PostSerializer.new(post).to_json } if TRANSMUTATION_MAIN example("panko_serializer") { PankoSerializer::PostSerializer.new(except: { user: [:posts] }).serialize_to_json(post) } example("jbuilder") { Jbuilder.encode { |json| json.instance_eval(post_jbuilder_template); json.target! } } example("representable") { Representable::PostRepresenter.new(post).to_json } @@ -58,6 +87,7 @@ group("Has Many") do example("transmutation") { Transmutation::UserSerializer.new(user).to_json } + example("transmutation (main)") { TRANSMUTATION_MAIN::UserSerializer.new(user).to_json } if TRANSMUTATION_MAIN example("panko_serializer") { PankoSerializer::UserSerializer.new.serialize_to_json(user) } example("jbuilder") { Jbuilder.encode { |json| json.instance_eval(user_jbuilder_template); json.target! } } example("representable") { Representable::UserRepresenter.new(user).to_json } @@ -68,6 +98,7 @@ group("Collection") do example("transmutation") { organisations.map { Transmutation::OrganisationSerializer.new(_1) }.to_json } + example("transmutation (main)") { organisations.map { TRANSMUTATION_MAIN::OrganisationSerializer.new(_1) }.to_json } if TRANSMUTATION_MAIN example("panko_serializer") { Panko::ArraySerializer.new(organisations, each_serializer: PankoSerializer::OrganisationSerializer).to_json } example("jbuilder") { Jbuilder.encode { |json| json.instance_eval(organisations_jbuilder_template); json.target! } } example("representable") { Representable::OrganisationRepresenter.for_collection.new(organisations).to_json } From e93eda57689b0454ad2119b9a00d6946d49df238 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 10:29:47 +0000 Subject: [PATCH 08/11] Bump actions/checkout to v5 --- .github/workflows/main.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 7963465..cda184c 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -26,7 +26,7 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Set up Ruby uses: ruby/setup-ruby@v1 @@ -47,7 +47,7 @@ jobs: name: Benchmarks steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Set up Ruby uses: ruby/setup-ruby@v1 From d6c8e03c7f85bda732d51ea41bdb2a75ef6a4122 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 15:55:04 +0000 Subject: [PATCH 09/11] Surface why the main comparison is skipped The guard required Namespace.enabled? to exist, which short-circuited the whole comparison when that method is absent. Relax the guard to attempt the load whenever the Namespace constant is present, and report the exact constant-availability or load failure so CI shows why head-only ran. --- benchmarks/benchmark.rb | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/benchmarks/benchmark.rb b/benchmarks/benchmark.rb index 99f30d6..923ffe0 100644 --- a/benchmarks/benchmark.rb +++ b/benchmarks/benchmark.rb @@ -41,7 +41,12 @@ # we simply skip the comparison and benchmark head on its own. def load_transmutation_main lib = ENV.fetch("TRANSMUTATION_MAIN_LIB", nil) - return unless lib && defined?(Namespace) && Namespace.respond_to?(:enabled?) && Namespace.enabled? + return unless lib + + unless defined?(Namespace) + warn "Namespace constant unavailable (RUBY_NAMESPACE=#{ENV['RUBY_NAMESPACE'].inspect}); benchmarking head only." + return + end namespace = Namespace.new @@ -55,7 +60,7 @@ def load_transmutation_main namespace::Transmutation rescue StandardError, ScriptError => e - warn "Skipping `transmutation (main)` comparison: #{e.class}: #{e.message}" + warn "Skipping `transmutation (main)` comparison: #{e.class}: #{e.message}\n#{e.backtrace&.first(3)&.join("\n")}" nil end From 5b4df0d6a27cbb51a78bf59ab5323847c9f16ea9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 17:42:42 +0000 Subject: [PATCH 10/11] Compare head vs main in one table via module isolation Ruby's experimental Namespace feature isn't compiled into CI's prebuilt Ruby, so it can't load two revisions in one process there. Instead, load main's source into an anonymous module with string module_eval: a `module Transmutation` nests under the receiver, yielding an isolated `wrapper::Transmutation` copy with no source rewriting and no Zeitwerk. The copy is named (TransmutationMain) so the serializer's namespace-based association lookup resolves its sibling serializers. The harness adds a `transmutation (main)` row beside `transmutation` in every group, so a single table compares both revisions. Benchmark job returns to stock Ruby 3.4, two passes (interpreter, YJIT). Also make the row label tolerant of names without a loaded gemspec. --- .github/workflows/main.yml | 8 ++---- benchmarks/benchmark.rb | 49 +++++++++++++++++++++++--------- benchmarks/lib/gem_benchmarks.rb | 4 ++- 3 files changed, 42 insertions(+), 19 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index cda184c..ad1b0a5 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -52,12 +52,12 @@ jobs: - name: Set up Ruby uses: ruby/setup-ruby@v1 with: - ruby-version: "4.0" + ruby-version: "3.4" bundler-cache: true working-directory: benchmarks - # Export main's library so the harness can load it alongside head (in an isolated - # namespace) and benchmark both revisions in a single process. + # Export main's library so the harness can load it alongside head — in an isolated module, + # see benchmark.rb — and benchmark both revisions side by side in a single process. - name: Fetch main revision of the library run: | git fetch --depth=1 origin main @@ -67,7 +67,6 @@ jobs: - name: Run benchmarks (interpreter) working-directory: benchmarks env: - RUBY_NAMESPACE: "1" TRANSMUTATION_MAIN_LIB: ${{ runner.temp }}/transmutation-main/lib run: | echo "# Benchmarks (interpreter) — head vs main" >> "$GITHUB_STEP_SUMMARY" @@ -76,7 +75,6 @@ jobs: - name: Run benchmarks (YJIT) working-directory: benchmarks env: - RUBY_NAMESPACE: "1" TRANSMUTATION_MAIN_LIB: ${{ runner.temp }}/transmutation-main/lib run: | echo "# Benchmarks (YJIT) — head vs main" >> "$GITHUB_STEP_SUMMARY" diff --git a/benchmarks/benchmark.rb b/benchmarks/benchmark.rb index 923ffe0..a1bf087 100644 --- a/benchmarks/benchmark.rb +++ b/benchmarks/benchmark.rb @@ -39,28 +39,51 @@ # be benchmarked side by side with the working tree (head) in a single process. This relies on Ruby's # experimental Namespace feature (Ruby 4.0+, RUBY_NAMESPACE=1); if it is unavailable or fails to load, # we simply skip the comparison and benchmark head on its own. +# The gem's source files in load order. transmutation.rb itself is skipped: it boots Zeitwerk, which +# would bind the top-level ::Transmutation constant and defeat the isolation, so we recreate its +# module-level setup inline instead. +TRANSMUTATION_MAIN_SOURCES = %w[ + transmutation/class_attributes + transmutation/serialization/lookup/serializer_not_found + transmutation/serialization/lookup + transmutation/serialization/rendering + transmutation/serialization + transmutation/serializer + transmutation/object_serializer +].freeze + def load_transmutation_main lib = ENV.fetch("TRANSMUTATION_MAIN_LIB", nil) return unless lib - unless defined?(Namespace) - warn "Namespace constant unavailable (RUBY_NAMESPACE=#{ENV['RUBY_NAMESPACE'].inspect}); benchmarking head only." - return + # Evaluate the library's source as strings into an anonymous module. A string `module_eval` nests + # under the receiver, so every `module Transmutation` lands in `wrapper::Transmutation` — a copy + # fully isolated from the working tree's top-level `Transmutation`, with no source rewriting. + wrapper = Module.new + TRANSMUTATION_MAIN_SOURCES.each do |source| + wrapper.module_eval(File.read(File.join(lib, "#{source}.rb")), "#{source}.rb") + + next unless source.end_with?("class_attributes") + + wrapper.module_eval(<<~RUBY, "transmutation_main_bootstrap.rb") + module Transmutation + extend ClassAttributes + class_attribute :max_depth, default: 1 + class Error < StandardError; end + end + RUBY end - namespace = Namespace.new - - $LOAD_PATH.unshift(lib) - begin - namespace.require("transmutation") - Dir[File.expand_path("lib/serializers/transmutation/*.rb", __dir__)].sort.each { |file| namespace.require(file) } - ensure - $LOAD_PATH.delete(lib) + # Load the benchmark's own serializers into the same isolated copy. + Dir[File.expand_path("lib/serializers/transmutation/*.rb", __dir__)].sort.each do |file| + wrapper.module_eval(File.read(file), file) end - namespace::Transmutation + # Naming the module (a top-level constant) lets the serializer's namespace-based association lookup + # resolve its sibling serializers, exactly as it does for the working-tree copy. + Object.const_set(:TransmutationMain, wrapper::Transmutation) rescue StandardError, ScriptError => e - warn "Skipping `transmutation (main)` comparison: #{e.class}: #{e.message}\n#{e.backtrace&.first(3)&.join("\n")}" + warn "Skipping `transmutation (main)` comparison: #{e.class}: #{e.message}\n#{e.backtrace&.first(5)&.join("\n")}" nil end diff --git a/benchmarks/lib/gem_benchmarks.rb b/benchmarks/lib/gem_benchmarks.rb index 2361fb3..49ff063 100644 --- a/benchmarks/lib/gem_benchmarks.rb +++ b/benchmarks/lib/gem_benchmarks.rb @@ -124,7 +124,9 @@ class Example def initialize(gem_name, **config, &block) @gem_name = gem_name @config = config - @label = if config[:style] == :markdown + @label = if specs.nil? + gem_name + elsif config[:style] == :markdown "[#{gem_name} #{gem_version}](#{gem_url})" else "#{gem_name} #{gem_version}" From f2e08ba678a00f4784b262d57e43687c720610ec Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 28 Jun 2026 18:07:48 +0000 Subject: [PATCH 11/11] Label the working-tree benchmark row as HEAD The working-tree copy is the branch under review, but it was labelled "transmutation " (from the loaded gemspec), which read like the canonical gem rather than the branch. Label it "transmutation (HEAD)" so each table pairs it explicitly against "transmutation (main)" (the existing gem version exported from origin/main). --- benchmarks/benchmark.rb | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/benchmarks/benchmark.rb b/benchmarks/benchmark.rb index a1bf087..f643931 100644 --- a/benchmarks/benchmark.rb +++ b/benchmarks/benchmark.rb @@ -92,7 +92,7 @@ class Error < StandardError; end GemBenchmarks.report output: false do group("Attributes") do - example("transmutation") { Transmutation::OrganisationSerializer.new(organisation).to_json } + example("transmutation (HEAD)") { Transmutation::OrganisationSerializer.new(organisation).to_json } example("transmutation (main)") { TRANSMUTATION_MAIN::OrganisationSerializer.new(organisation).to_json } if TRANSMUTATION_MAIN example("panko_serializer") { PankoSerializer::OrganisationSerializer.new.serialize_to_json(organisation) } example("jbuilder") { Jbuilder.encode { |json| json.instance_eval(organisation_jbuilder_template); json.target! } } @@ -103,7 +103,7 @@ class Error < StandardError; end end group("Has One / Belongs To") do - example("transmutation") { Transmutation::PostSerializer.new(post).to_json } + example("transmutation (HEAD)") { Transmutation::PostSerializer.new(post).to_json } example("transmutation (main)") { TRANSMUTATION_MAIN::PostSerializer.new(post).to_json } if TRANSMUTATION_MAIN example("panko_serializer") { PankoSerializer::PostSerializer.new(except: { user: [:posts] }).serialize_to_json(post) } example("jbuilder") { Jbuilder.encode { |json| json.instance_eval(post_jbuilder_template); json.target! } } @@ -114,7 +114,7 @@ class Error < StandardError; end end group("Has Many") do - example("transmutation") { Transmutation::UserSerializer.new(user).to_json } + example("transmutation (HEAD)") { Transmutation::UserSerializer.new(user).to_json } example("transmutation (main)") { TRANSMUTATION_MAIN::UserSerializer.new(user).to_json } if TRANSMUTATION_MAIN example("panko_serializer") { PankoSerializer::UserSerializer.new.serialize_to_json(user) } example("jbuilder") { Jbuilder.encode { |json| json.instance_eval(user_jbuilder_template); json.target! } } @@ -125,7 +125,7 @@ class Error < StandardError; end end group("Collection") do - example("transmutation") { organisations.map { Transmutation::OrganisationSerializer.new(_1) }.to_json } + example("transmutation (HEAD)") { organisations.map { Transmutation::OrganisationSerializer.new(_1) }.to_json } example("transmutation (main)") { organisations.map { TRANSMUTATION_MAIN::OrganisationSerializer.new(_1) }.to_json } if TRANSMUTATION_MAIN example("panko_serializer") { Panko::ArraySerializer.new(organisations, each_serializer: PankoSerializer::OrganisationSerializer).to_json } example("jbuilder") { Jbuilder.encode { |json| json.instance_eval(organisations_jbuilder_template); json.target! } }