Lesson 01 / 09
Gem Structure and Publishing
This course asks what determines a result. The lesson introduces the nine-observation measurement core, then measures the package specification, the origin of the file name, the dependency kinds and version comparison.
Published Updated
In this lesson
- Classifies for each of the nine observations whether code, dependency, order or environment determines the result
- Measures that the package's file name and full name derive from the package specification
- Separates whether a runtime dependency and a development dependency are installed with the package
- Shows that a version is compared by the version rule and not as a string; explains that a published version's content cannot be changed and where yanking stops
Contents
The previous course measured how long a value stays, and test isolation appeared at its close: two tests that shared the same application leaked through the persistent store between them. Once the application leaves the developer’s machine and goes to production, a new question arises: why does the result change when the code is the same? The question of this course is what determines this result? The rule reads how the result behaves, not how the code is written.
M08/K06 also searched for the cause of a result, but there the cause was a call in the source, a comment or a default value, all of it inside the code. Here the question concerns an input outside the code. The package specification is written in the source and is code; the installed version is not written in the source.
The rule places a result in one of four classes. Code is the case where the computation looks only at its own input. Dependency is what the versions in the lock file pin. Order is the sequence in which tests or threads run. Environment is what the machine reports: the clock, the core count, the environment variable. These four classes are called the determinant. The measurement model is built from three inputs: lock, seed and environment. The lock input is the lock file’s contents, the versions it pins. Everything outside these three is code.
The Measurement Core
The core is two files, and it is not rewritten during the course; excerpts are taken from it. determinant.rb carries the run model and the classifying helper; the block is source and has no output.
# determinant.rb — a run model and a helper that classifies what determines a result
# Run: the three inputs a result may depend on. Everything outside these three is code.
Run = Data.define(:lock, :seed, :environment)
BASE = Run.new(
lock: { "formatter" => "1.4.2", "parser" => "2.0.1" },
seed: 1,
environment: { clock: Time.utc(2026, 10, 16, 9, 0), cores: 4, variables: {} }
)
# Each variant changes a single input; the others stay as in the base.
VARIANTS = {
dependency: BASE.with(lock: { "formatter" => "1.5.0", "parser" => "2.1.0" }),
order: BASE.with(seed: 2),
environment: BASE.with(environment: { clock: Time.utc(2027, 3, 1, 9, 0), cores: 8,
variables: { "WORKER_COUNT" => "3" } })
}
# Rule: the computation runs first in the base, then once in each variant. The first variant
# that changes the result (dependency, order, environment, in that order) is the determinant;
# if none changes it, code.
def determinant(computation)
base = computation.call(BASE)
changed = VARIANTS.to_h { |name, run| [name, computation.call(run) != base] }
kind = changed.key(true)&.to_s || "code"
evidence = changed.map { |name, c| "#{name[0].upcase}#{c ? '+' : '-'}" }.join(" ")
[kind, evidence, base]
end
Run holds the three inputs. BASE is a starting run; VARIANTS changes only one input at a time. determinant(computation) runs the computation in the base and in the three variants and returns the name of the first variant that changes the result; if none changes it, the result is "code". The evidence column has the form D± O± E±: whether the result changed in the dependency, order and environment variants. The second file carries the small models of the ecosystem and the nine observations.
# observations.rb — nine observations: what determines this result
require "digest"
require_relative "determinant"
# Package specification: the name and version give the package its file name.
def package_spec(files = ["lib/gauge.rb", "lib/gauge/counter.rb"])
Gem::Specification.new do |s|
s.name = "gauge"
s.version = "0.3.0"
s.summary = "a small measurement package"
s.authors = ["sample author"]
s.files = files
s.add_dependency "formatter", "~> 1.4"
end
end
# Dependency resolution: if the version in the lock satisfies the constraint, that version is installed.
def installed_version(run, name, constraint)
version = Gem::Version.new(run.lock.fetch(name))
raise ArgumentError, "lock does not satisfy constraint" unless Gem::Requirement.new(constraint).satisfied_by?(version)
version.to_s
end
# Test runner: tests run in an order shuffled by the seed and share the same settings.
TESTS = {
"price_format" => ->(settings) { "12 #{settings[:currency]}" == "12 TRY" },
"currency_conversion" => ->(settings) { settings[:currency] = "USD"; true },
"invoice_total" => ->(settings) { settings[:currency] == "TRY" }
}
def run_tests(run, tests = TESTS)
settings = { currency: "TRY" }
order = tests.keys.shuffle(random: Random.new(run.seed))
order.reject { |name| tests[name].call(settings) }
end
# Mocking: the trial period is 30 days; the clock is passed in from outside.
def in_trial?(start, clock) = clock - start < 30 * 86_400
# Scheduler model: two threads read the counter and write one more; a switch can happen
# between the read and the write. The seed picks which thread takes the next step.
def unlocked_counter(run, increments: 3)
random = Random.new(run.seed)
counter = 0
jobs = Array.new(2) { { left: increments, read: nil } }
loop do
waiting = jobs.select { |j| j[:left].positive? }
break if waiting.empty?
job = waiting.sample(random: random)
if job[:read].nil?
job[:read] = counter
else
counter = job[:read] + 1
job[:read] = nil
job[:left] -= 1
end
end
counter
end
# Process-based scaling: the worker count comes from an environment variable, or else from the core count.
def worker_count(environment) = Integer(environment[:variables].fetch("WORKER_COUNT", environment[:cores]))
# Memory: the block runs three times; the first runs are warm-up, the last run's allocations are counted.
def allocated_objects
result = nil
3.times do
before = GC.stat(:total_allocated_objects)
yield
result = GC.stat(:total_allocated_objects) - before
end
result
end
# Profiling: calls to the methods the program defines are counted; the most called is the hot path.
def hot_path
counts = Hash.new(0)
trace = TracePoint.new(:call) { |t| counts[t.method_id] += 1 if t.defined_class == Object }
trace.enable { yield }
counts.max_by { |_, n| n }
end
def format_money(cents) = format("%d.%02d", cents / 100, cents % 100)
def format_line(line) = "#{line[:name]} #{format_money(line[:unit])} #{format_money(line[:total])}"
def report(lines) = lines.map { |l| format_line(l) }.join("\n")
# Deployment: the asset name carries a digest of its content; the release installs the versions in the lock.
def asset_name(name, content) = name.sub(/(\.\w+)\z/) { "-#{Digest::SHA256.hexdigest(content)[0, 8]}#{Regexp.last_match(1)}" }
def release_manifest(run)
[asset_name("app.css", "body { margin: 0 }"), *run.lock.map { |name, v| "#{name}-#{v}" }]
end
lines = Array.new(100) { |i| { name: "product#{i}", unit: 1250, total: 1250 * (i + 1) } }
observations = {
"package file name" => ->(_r) { package_spec.file_name },
"locked version" => ->(r) { installed_version(r, "formatter", "~> 1.4") },
"test result" => ->(r) { run_tests(r) },
"clock-reading test" => ->(r) { in_trial?(Time.utc(2026, 10, 1), r.environment[:clock]) },
"unlocked counter" => ->(r) { unlocked_counter(r) },
"worker count" => ->(r) { worker_count(r.environment) },
"allocated objects" => ->(_r) { allocated_objects { Array.new(1000) { |i| i.to_s } } },
"hot path" => ->(_r) { hot_path { report(lines) } },
"release manifest" => ->(r) { release_manifest(r) }
}
tally = { "code" => 0, "dependency" => 0, "order" => 0, "environment" => 0 }
printf("%-24s%-13s%-10s%s\n", "observation", "determinant", "evidence", "base")
observations.each do |name, computation|
kind, evidence, base = determinant(computation)
tally[kind] += 1
printf("%-24s%-13s%-10s%s\n", name, kind, evidence, base.inspect)
end
puts
tally.each { |kind, n| printf("%-13s%d\n", kind, n) }
puts "total observations: #{tally.values.sum}"
observation determinant evidence base package file name code D- O- E- "gauge-0.3.0.gem" locked version dependency D+ O- E- "1.4.2" test result order D- O+ E- [] clock-reading test environment D- O- E+ true unlocked counter order D- O+ E- 6 worker count environment D- O- E+ 4 allocated objects code D- O- E- 1001 hot path code D- O- E- [:format_money, 200] release manifest dependency D+ O- E- ["app-997facec.css", "formatter-1.4.2", "parser-2.0.1"] code 3 dependency 2 order 2 environment 2 total observations: 9
The 3/2/2/2 distribution in the output is the map of this course. Three observations are code, two are dependency, two are order and two are environment. Each lesson measures one observation in detail; when the course ends, all nine have been opened. installed_version is not a dependency resolver but a model that reads the version in the lock file. Among the nine rows, the observation of this lesson is the first: package file name, code. PG1. The core is not a real package ecosystem but a model of it; every block measured in this course is plain Ruby and the standard library.
Package Specification and File Name
A package carries its code, name, version and dependencies as one unit. What defines them is the package specification; in Ruby this is the Gem::Specification class. The package_spec helper in the core builds a small specification. The next file takes the same specification exactly; since determinant is not needed, it only calls the specification.
# package.rb — package specification: the name and version give the package its file name
# Package specification: the name and version give the package its file name.
def package_spec(files = ["lib/gauge.rb", "lib/gauge/counter.rb"])
Gem::Specification.new do |s|
s.name = "gauge"
s.version = "0.3.0"
s.summary = "a small measurement package"
s.authors = ["sample author"]
s.files = files
s.add_dependency "formatter", "~> 1.4"
end
end
Now the file name and the full name of the same specification are examined. To make the version 0.3.0.pre1, the version field is changed; a pre-release is a version that comes before the stable version.
# file_name.rb — the package's file name comes from the name and version require_relative "package" s = package_spec p s.file_name p s.full_name s.version = "0.3.0.pre1" p s.file_name p s.full_name
"gauge-0.3.0.gem" "gauge-0.3.0" "gauge-0.3.0.pre1.gem" "gauge-0.3.0.pre1"
The file name has the form name-version.gem. file_name carries the extension and full_name does not. With a pre-release, the suffix becomes part of the version string and enters the file name. This explains why the rule sees code: the name and version are written in the source and do not come from the lock file, the seed or the clock. PG2. A single package specification was measured here; that a real specification with many fields follows the same rule was not tested.
Runtime and Development Dependency
A package carries two kinds of dependency. A runtime dependency is what arrives together when the package is installed. A development dependency is what only the person developing the package needs, such as a checking tool used for tests. In the package specification the two are written with separate methods.
# dependencies.rb — runtime and development dependency
require_relative "package"
s = package_spec
s.add_development_dependency "checker"
p s.dependencies.map { |d| [d.name, d.requirement.to_s, d.type] }
p s.runtime_dependencies.map(&:name)
[["formatter", "~> 1.4", :runtime], ["checker", ">= 0", :development]] ["formatter"]
The first line lists the two dependencies with their version constraints and kinds. The constraint of the development dependency is >= 0: no version limit was written. The second line gives those that will be installed with the package, and only formatter remains. Whoever installs the package does not receive checker. PG3. The lock file pins the version of a dependency and the constraint only bounds it; measuring this is the subject of the next lesson.
A Version Is Not a String
A version number consists of numbers separated by dots, but its comparison does not work like a string comparison. Version 0.10.0 comes after 0.3.0; compared as strings, 1 meets 3 at the first differing character and the order reverses.
# version.rb — a version is not compared like a string
p "0.10.0" > "0.3.0"
p Gem::Version.new("0.10.0") > Gem::Version.new("0.3.0")
pre_release = Gem::Version.new("1.0.0.pre1")
p pre_release < Gem::Version.new("1.0.0")
p pre_release.prerelease?
begin
Gem::Version.new("one.two")
rescue => e
p e.class
end
false true true true ArgumentError
The string comparison gives false and the version comparison gives true. A pre-release is smaller than the stable version with the same number, and prerelease? reports that it is a pre-release. A string that does not start with a number ("one.two") is not accepted as a version, and ArgumentError is raised; a part with letters such as pre1 raises no error when it comes after the numbers and is read as a pre-release label. Semantic versioning splits a version into three numbers, major, minor and patch: the major version rises with an incompatible change, the minor version with a compatible new feature and the patch version with a bug fix. This is a written contract; Ruby does not verify it. PG4. The measurement shows how the version rule performs the comparison; it does not show that a package actually follows semantic versioning.
Publishing
Producing the package and sending it to the package registry are two separate jobs. The first makes the .gem file from the package specification, and the second uploads the file to the public registry. The sandbox has neither the package command nor a network, so the two commands below are not run.
gem build gauge.gemspec gem push gauge-0.3.0.gem
gem build takes the file name from the package specification; it is the same name as the measured file_name. The content of a version published with gem push cannot be changed afterward. A faulty version can be removed from the registry index with gem yank, but the copy held by those who already downloaded that version stays in place; the corrected content is released under a new version number. If a password or a key entered the package by mistake, removal is not enough and that secret is changed at once. This is why pre-releases exist: trying with 0.3.0.pre1 before publishing protects the stable version.
The Bounding Measurement
The rule sees only three inputs: lock, seed and environment. The working directory is not one of them. The measurement below shows this limit. The package’s file list is read from the directory with Dir.glob; the computation is classified first while the directory is empty and then after a file is written.
# boundary.rb — when the file list comes from the working directory
require_relative "determinant"
require_relative "package"
computation = ->(_r) { package_spec(Dir.glob("lib/**/*.rb")).files }
p determinant(computation)
Dir.mkdir("lib")
File.write("lib/gauge.rb", "module Gauge; end\n")
p determinant(computation)
["code", "D- O- E-", []] ["code", "D- O- E-", ["lib/gauge.rb"]]
The result was code both times, yet the file list changed: first empty, then lib/gauge.rb. The rule saw no difference in any of the three variants, because none of them touches the directory. The result actually depends on the working directory. Since the directory is not an input of the model, the rule names this dependency code, and that name is misleading: what changed is not the code itself but the file system the code reads. PG5. If this limit is left unmarked, the sentence “this result depends on the code” appears stronger than it is.
Summary
- The question of this course is “what determines this result?”; the rule classifies a result as code, dependency, order or environment, and the distribution is 3/2/2/2.
- The determinant of this lesson is code: the package’s file name
gauge-0.3.0.gemcomes from the name and version, and thepackage file namerow showsD- O- E-. - A runtime dependency is installed with the package and a development dependency is not; a pre-release enters the file name.
- A version is not compared as a string; the
Gem::Versionrule says0.10.0 > 0.3.0. - The content of a published version cannot be changed; a package specification that reads its file list from the directory depends on an input the rule does not see.
Next Step
What determines the package’s file name is now visible. But when a package is installed, which version of the dependency inside it will arrive is not written in the package specification. The next lesson, Dependency Management, measures why the installed version does not change while the lock file stays the same.
To keep your progress and take notes, Log in
My notes
Log in to take notes.