haveapi 0.30.2 → 0.31.0

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.
@@ -8,6 +8,8 @@ module HaveAPI
8
8
  end
9
9
  require 'digest'
10
10
  require 'json'
11
+ require 'open3'
12
+ require 'rbconfig'
11
13
 
12
14
  module HaveAPI
13
15
  module Performance
@@ -18,6 +20,7 @@ module HaveAPI
18
20
  MAX_ITERATIONS = 100_000
19
21
  REQUEST_LIMIT_SECONDS = 30.0
20
22
  SETUP_LIMIT_SECONDS = 120.0
23
+ BENCHMARK_INPUTS = %w[servers/ruby/benchmarks/Gemfile servers/ruby/benchmarks/Gemfile.lock servers/ruby/benchmarks/shell.nix].freeze
21
24
 
22
25
  def self.complex_cases(profile)
23
26
  sizes = profile == 'weekly' ? [32] : [16]
@@ -30,6 +33,34 @@ module HaveAPI
30
33
  RUNTIME_POLICY = { 'rack_env' => 'test', 'locale' => 'C.UTF-8', 'timezone' => 'UTC', 'yjit' => false, 'rjit' => false }.freeze
31
34
  class ContractError < StandardError; end
32
35
 
36
+ def self.gemspec(root, path)
37
+ script = <<~RUBY
38
+ require 'json'
39
+ spec = Gem::Specification.load(File.basename(ARGV.fetch(0)))
40
+ abort 'Cannot load gemspec' unless spec
41
+ puts JSON.generate(ruby: spec.required_ruby_version.to_s,
42
+ dependencies: spec.dependencies.map { |d| [d.name, d.type.to_s, d.requirement.to_s] }.sort)
43
+ RUBY
44
+ environment = (ENV.keys.grep(/\ABUNDLE/) + %w[RUBYOPT RUBYLIB RUBYGEMS_GEMDEPS]).to_h { |key| [key, nil] }
45
+ output, errors, status = Open3.capture3(environment, RbConfig.ruby, '-e', script, path, chdir: File.dirname(File.join(root, path)))
46
+ raise ContractError, "Cannot load source gemspec #{path}: #{errors.strip}" unless status.success?
47
+
48
+ specification = JSON.parse(output)
49
+ unless Gem::Requirement.new(specification.fetch('ruby')).satisfied_by?(Gem::Version.new(RUBY_VERSION))
50
+ raise ContractError, "Benchmark Ruby does not satisfy #{path}"
51
+ end
52
+
53
+ specification.fetch('dependencies').each do |name, type, requirement|
54
+ next unless type == 'runtime'
55
+ next if %w[haveapi haveapi-client haveapi-rust-client].include?(name)
56
+
57
+ unless Gem::Requirement.new(requirement.split(',').map(&:strip)).satisfied_by?(Gem::Specification.find_by_name(name).version)
58
+ raise ContractError, "Benchmark dependencies do not satisfy #{name} #{requirement}"
59
+ end
60
+ end
61
+ specification
62
+ end
63
+
33
64
  # One reviewed maintenance mapping, not a general cherry-pick certificate.
34
65
  # Both live entry points verify Git; replay verifies the retained raw delta.
35
66
  module Release
@@ -166,7 +197,10 @@ module HaveAPI
166
197
  receipt = inputs.fetch('release_versions')
167
198
  raise ContractError, 'Incomplete release input graph' unless receipt.fetch('contents').keys.sort == paths(profile).sort
168
199
  raise ContractError, 'Release input normalization mismatch' unless receipt == capture(receipt.fetch('contents'))
169
- raise ContractError, 'Release normalized graph mismatch' unless inputs.fetch('manifests') == receipt.fetch('normalized_sha256')
200
+
201
+ expected = receipt.fetch('normalized_sha256')
202
+ expected = expected.except('flake.nix', 'flake.lock') if inputs.has_key?('benchmark')
203
+ raise ContractError, 'Release normalized graph mismatch' unless inputs.fetch('manifests') == expected
170
204
 
171
205
  receipt.fetch('raw_sha256').each do |path, digest|
172
206
  raise ContractError, 'Release input escaped verified source' unless source.fetch('files_sha256').fetch(path) == digest
@@ -175,7 +209,7 @@ module HaveAPI
175
209
  end
176
210
 
177
211
  def self.graph(inputs)
178
- inputs.except('release_versions', 'json_preparation')
212
+ inputs.except('release_versions', 'json_preparation', 'contributor_flakes')
179
213
  end
180
214
 
181
215
  def self.html(body, own)
@@ -211,6 +245,19 @@ module HaveAPI
211
245
  raise ContractError, 'Weekly baseline must be the fixed reference'
212
246
  end
213
247
 
248
+ def self.push_pair!(selection, baseline:, candidate:)
249
+ return false unless selection['push_pair']
250
+
251
+ expected = { 'event_name' => 'push', 'before' => baseline, 'after' => candidate }
252
+ unless selection['event_name'] == 'push' && selection['profile'] == 'quick' && selection['push_pair'] == expected &&
253
+ selection['baseline_sha'] == baseline && selection['candidate_sha'] == candidate && baseline != candidate &&
254
+ [baseline, candidate].all? { |sha| sha.is_a?(String) && sha.match?(/\A[0-9a-f]{40}\z/) && sha != '0' * 40 }
255
+ raise ContractError, 'Invalid quick push event pair'
256
+ end
257
+
258
+ true
259
+ end
260
+
214
261
  # Canonicalization ignores map order only. Arrays and scalar types retain
215
262
  # their meaning; duplicate JSON or symbol/string-normalized keys fail closed.
216
263
  module Identity
@@ -270,6 +317,9 @@ module HaveAPI
270
317
  end
271
318
 
272
319
  class Comparator
320
+ POLICY = 'per-revision-response-stability-v1'.freeze
321
+ SOURCE_SHA256 = Digest::SHA256.file(__FILE__).hexdigest.freeze
322
+
273
323
  def initialize(samples, budgets, budget_sha256:)
274
324
  @samples = samples
275
325
  @budgets = budgets
@@ -357,6 +407,37 @@ module HaveAPI
357
407
  raise ContractError, 'Loaded runtime not in verified source tree' unless selected.fetch('files_sha256').fetch(path) == sha256
358
408
  end
359
409
  inputs = observation.fetch('inputs')
410
+ if inputs.has_key?('benchmark')
411
+ tool = inputs.fetch('benchmark')
412
+ raise ContractError, 'Incomplete benchmark inputs' unless tool.keys.sort == BENCHMARK_INPUTS.sort
413
+
414
+ tool.each do |path, input|
415
+ unless Digest::SHA256.hexdigest(input.fetch('content')) == input.fetch('sha256') &&
416
+ @samples.fetch('inputs').fetch('benchmark').fetch(path) == input
417
+ raise ContractError, 'Benchmark input digest mismatch'
418
+ end
419
+ end
420
+ unless inputs.fetch('locks') == { 'servers/ruby/benchmarks/Gemfile.lock' => tool.fetch('servers/ruby/benchmarks/Gemfile.lock').fetch('sha256') }
421
+ raise ContractError, 'Benchmark lock identity mismatch'
422
+ end
423
+
424
+ inputs.fetch('contributor_flakes').each do |path, input|
425
+ unless %w[flake.nix flake.lock].include?(path) && Digest::SHA256.hexdigest(input.fetch('content')) == selected.fetch('files_sha256').fetch(path)
426
+ raise ContractError, 'Contributor flake escaped source'
427
+ end
428
+ end
429
+ unless inputs.fetch('contributor_flakes').keys.sort == %w[flake.lock flake.nix]
430
+ raise ContractError, 'Missing contributor flake provenance'
431
+ end
432
+
433
+ inputs.fetch('manifests').each do |path, digest|
434
+ next if Release::JSON_GEMFILES.include?(path) || lineage
435
+
436
+ raise ContractError, 'Dependency manifest escaped source' unless selected.fetch('files_sha256').fetch(path) == digest
437
+ end
438
+ elsif inputs.has_key?('contributor_flakes')
439
+ raise ContractError, 'Contributor flake separation without benchmark inputs'
440
+ end
360
441
  if lineage
361
442
  Release.validate_inputs(inputs, selected, @samples.fetch('profile'))
362
443
  elsif inputs.has_key?('release_versions')
@@ -474,14 +555,28 @@ module HaveAPI
474
555
  end
475
556
  end
476
557
 
558
+ def output_counts(outputs, digest = 'canonical_sha256')
559
+ outputs.each_with_object(Hash.new(0)) do |output, counts|
560
+ identity = JSON.generate(output.values_at('http_status', 'envelope_status', digest))
561
+ counts[identity] += output.fetch('count')
562
+ end
563
+ end
564
+
477
565
  def same_outputs(left, right)
478
- summaries = [left, right].map do |outputs|
479
- outputs.each_with_object(Hash.new(0)) do |output, counts|
480
- identity = JSON.generate(output.values_at('http_status', 'envelope_status', 'canonical_sha256'))
481
- counts[identity] += output.fetch('count')
482
- end
566
+ output_counts(left) == output_counts(right)
567
+ end
568
+
569
+ def response_changes(pairs)
570
+ outputs = %w[baseline candidate].to_h do |role|
571
+ [role, pairs.flat_map { |pair| pair.fetch(role).fetch('outputs') }]
483
572
  end
484
- summaries.first == summaries.last
573
+ bytes = outputs.transform_values do |examples|
574
+ examples.sum { |output| output.fetch('body_bytes') * output.fetch('count') }.to_f / examples.sum { |output| output.fetch('count') }
575
+ end
576
+ delta = bytes.fetch('candidate') - bytes.fetch('baseline')
577
+ { 'raw_changed' => output_counts(outputs.fetch('baseline'), 'body_sha256') != output_counts(outputs.fetch('candidate'), 'body_sha256'),
578
+ 'canonical_changed' => !same_outputs(outputs.fetch('baseline'), outputs.fetch('candidate')),
579
+ 'bytes_per_request' => bytes.merge('delta' => delta, 'delta_percent' => 100.0 * delta / bytes.fetch('baseline')) }
485
580
  end
486
581
 
487
582
  def compare_case(item, budget)
@@ -499,7 +594,8 @@ module HaveAPI
499
594
  raise ContractError, 'Uncalibrated allocation ceiling' unless reference > 0 && ceiling == (reference * 1.25).ceil + 32
500
595
  end
501
596
 
502
- canonical_outputs = canonical_counters = nil
597
+ outputs_by_source = {}
598
+ canonical_counters = nil
503
599
  (self_pairs + pairs).each_with_index do |pair, index|
504
600
  expected_order = if index < self_pairs.length
505
601
  %w[baseline baseline]
@@ -530,8 +626,10 @@ module HaveAPI
530
626
  raise ContractError, 'Worker workload order mismatch'
531
627
  end
532
628
 
533
- canonical_outputs ||= sample.fetch('outputs')
534
- raise ContractError, 'Response identity differs between operations/revisions' unless same_outputs(canonical_outputs, sample['outputs'])
629
+ outputs_by_source[actual_sha] ||= sample.fetch('outputs')
630
+ unless same_outputs(outputs_by_source.fetch(actual_sha), sample.fetch('outputs'))
631
+ raise ContractError, "Response identity differs within revision #{actual_sha}"
632
+ end
535
633
  end
536
634
  end
537
635
  classifications = %w[wall_seconds cpu_seconds].to_h do |metric|
@@ -566,9 +664,10 @@ module HaveAPI
566
664
  'pass'
567
665
  end
568
666
  item.slice('id', 'dimensions', 'iterations')
569
- .merge('classification' => classification, 'timing' => classifications,
667
+ .merge('classification' => classification, 'timing' => classifications, 'responses' => response_changes(pairs),
570
668
  'correctness' => { 'status' => 'pass', 'validated_samples' => 2 * (self_pairs.length + pairs.length),
571
- 'operations_per_sample' => iterations, 'distinct_responses' => canonical_outputs.length },
669
+ 'operations_per_sample' => iterations, 'distinct_responses' => outputs_by_source.fetch(@samples.fetch('baseline_sha')).length,
670
+ 'distinct_responses_by_source' => outputs_by_source.transform_values(&:length) },
572
671
  'pilot' => item.fetch('pilot', {}).slice('selected_iterations', 'warmup_iterations', 'target_seconds',
573
672
  'baseline_batch_seconds', 'adequate_signal', 'capped', 'calibration_adjusted'),
574
673
  'allocations' => { 'baseline_median' => base_allocations, 'candidate_median' => candidate_allocations,
@@ -0,0 +1,21 @@
1
+ let
2
+ pkgs = import (builtins.fetchTree {
3
+ type = "github";
4
+ owner = "NixOS";
5
+ repo = "nixpkgs";
6
+ rev = "02c087f3fe673c9e317f206a627cb6b910336dbe";
7
+ narHash = "sha256-IoNisKcGbUjdd45qCqywGMCYmZ5EnkxezvLII0FWwNA=";
8
+ }) { };
9
+ in
10
+ pkgs.mkShell {
11
+ name = "haveapi-benchmarks";
12
+ packages = with pkgs; [ ruby_3_4 git gnumake pkg-config openssl libffi ];
13
+ LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath [ pkgs.stdenv.cc.cc.lib ];
14
+ shellHook = ''
15
+ for name in ''${!BUNDLE@}; do unset "$name"; done
16
+ unset RUBYOPT RUBYLIB RUBYGEMS_GEMDEPS
17
+ export GEM_HOME="''${HAVEAPI_BENCHMARK_GEM_HOME:-${toString ./.}/.gems}"
18
+ export GEM_PATH="$GEM_HOME:${pkgs.ruby_3_4}/lib/ruby/gems/3.4.0"
19
+ export PATH="$GEM_HOME/bin:$PATH"
20
+ '';
21
+ }
@@ -2,20 +2,23 @@
2
2
 
3
3
  The Ruby server renders examples from the current user's API description.
4
4
  JavaScript and TypeScript examples cover callback and async clients; Python
5
- examples cover sync and async clients. These clients need no server configuration.
5
+ examples cover sync and async clients. These clients need no server
6
+ configuration.
6
7
  Ruby, PHP, CLI, curl and HTTP examples are also available. A sample
7
8
  that a client cannot represent shows a limitation instead of dropping input or
8
9
  inventing a successful result.
9
10
 
10
- Go and Rust tabs require a profile for a generated SDK. Their package versions
11
- are independent of HaveAPI and the API version. Without a profile, the server
11
+ Go, Rust and generated TypeScript tabs require a profile for a generated SDK.
12
+ Their package versions are independent of HaveAPI and the API version. Without a
13
+ profile, the server
12
14
  hides those clients from the overview and all initialization, authentication and
13
15
  action panels. Install the matching generator gem only when configuring its
14
- profile; ordinary servers do not depend on either generator.
16
+ profile; ordinary servers do not depend on these generators.
15
17
 
16
18
  ## Configure a generated SDK
17
19
 
18
- Register profiles before mounting the server. For an SDK that matches the current
20
+ Register profiles before mounting the server. For an SDK that matches the
21
+ current
19
22
  API, configure its package identity:
20
23
 
21
24
  ```ruby
@@ -25,25 +28,34 @@ api.documentation_clients('1.0',
25
28
  },
26
29
  rust: {
27
30
  crate_name: 'widget-client'
31
+ },
32
+ typescript: {
33
+ package_name: '@example/widget-client'
28
34
  }
29
35
  )
30
36
  api.mount('/api/')
31
37
  ```
32
38
 
33
39
  `generator_version` defaults to the server's `HaveAPI::VERSION`. Install the
34
- optional `haveapi-go-client` or `haveapi-rust-client` generator gem at that
35
- version. An explicit `generator_version` requires the installed generator to
36
- report that version; it never downloads one. It is independent of the SDK and
37
- API versions.
38
-
39
- At the end of mount, the server builds one private structural model per configured
40
- API version. The model includes authentication resources, routes and changes from
40
+ matching generator in the server's Ruby environment: `haveapi-go-client`,
41
+ `haveapi-rust-client`, or the source-distributed `haveapi-typescript-client`.
42
+ See the [TypeScript generator guide](../clients/typescript/README.md) for its
43
+ setup. An explicit `generator_version` requires the installed generator to
44
+ report
45
+ that version; the server never downloads one. This version is independent of the
46
+ SDK and API versions.
47
+
48
+ At the end of mount, the server builds one private structural model per
49
+ configured
50
+ API version. The model includes authentication resources, routes and changes
51
+ from
41
52
  post-mount hooks, so canonical names and parent arguments use the complete API
42
53
  declaration. The private copy excludes examples, prose and parameter defaults.
43
54
  HTML requests use the current user's authorized description, fields and samples;
44
55
  they never expose the private model or its initialization diagnostics. Without
45
- a configured profile, the server loads no optional generator and shows no Go or
46
- Rust panels.
56
+ a configured profile, the server loads no optional generator and hides the Go,
57
+ Rust and generated TypeScript panels. The callback and async TypeScript
58
+ examples remain separate and need no profile.
47
59
 
48
60
  Automatic profiles assume that the installed SDK matches the current server's
49
61
  generation contract. A package name or version does not prove its contents.
@@ -53,8 +65,9 @@ schema override below when the SDK represents an older or restricted API.
53
65
 
54
66
  ### Optional SDK versions and sources
55
67
 
56
- Set `module_version: 'v1.2.3'` for Go or `crate_version: '1.2.3'` for Rust when a
57
- specific package version is needed. Omit the field or use nil to let the user's
68
+ Set `module_version: 'v1.2.3'` for Go, `crate_version: '1.2.3'` for Rust or
69
+ `package_version: '1.2.3'` for TypeScript when a specific package version is
70
+ needed. Omit the field or use nil to let the user's
58
71
  package manager select a release. The server does not query a registry or claim
59
72
  to have resolved a version. Omitted versions produce these installation hints:
60
73
 
@@ -63,15 +76,25 @@ go get example.org/widget/client@latest
63
76
  cargo add widget-client
64
77
  # Blocking Rust variant:
65
78
  cargo add widget-client --features blocking
79
+ npm install @example/widget-client haveapi-client@0.30.2
66
80
  ```
67
81
 
68
82
  Go resolves `@latest` by its normal rules. Cargo selects a compatible release
69
83
  according to its resolver, manifest and toolchain; existing lockfiles still
70
84
  apply. Explicit Cargo versions remain version requirements, without an added
71
- exact `=` pin. Latest selection can change over time; supply a version to control
85
+ exact `=` pin. Latest selection can change over time; supply a version to
86
+ control
72
87
  the selection. Cargo never receives a literal `version = "latest"`. Rust
73
88
  examples also show their Tokio and serde_json requirements.
74
89
 
90
+ TypeScript accepts ordinary lowercase npm package names, including scoped names,
91
+ and optional concrete SemVer versions. Tags, version ranges, Git URLs, local
92
+ paths
93
+ and custom factories are not profile options. Configure a package that users can
94
+ install; the server does not check its availability or publish it. Generated
95
+ snippets use the package's public `createClient` export and the published
96
+ `haveapi-client` 0.30.2 runtime.
97
+
75
98
  For an unpublished Rust SDK, supply an HTTPS Git URL and a full commit SHA:
76
99
 
77
100
  ```ruby
@@ -118,13 +141,20 @@ api.documentation_clients('1.0',
118
141
  snapshot_path: '/srv/widget-sdk/api-1.0.snapshot.json',
119
142
  generator_config_path: '/srv/widget-sdk/generator.toml',
120
143
  generator_version: '0.30.0'
144
+ },
145
+ typescript: {
146
+ package_name: '@example/widget-client',
147
+ package_version: '1.2.3',
148
+ snapshot_path: '/srv/widget-sdk/api-1.0.snapshot.json',
149
+ generator_version: '0.30.0'
121
150
  }
122
151
  )
123
152
  ```
124
153
 
125
154
  Paths name trusted absolute local files. `description_path` is Go's saved
126
- machine-readable description, not HTML. `snapshot_path` is Rust's saved schema
127
- and capture metadata. They select an exact SDK shape in place of the current
155
+ machine-readable description, not HTML. For Rust and TypeScript, `snapshot_path`
156
+ selects the saved schema and capture metadata. These files select an exact SDK
157
+ shape in place of the current
128
158
  server declaration. Keep the files private and retain them with the SDK release.
129
159
 
130
160
  Rust's legacy `generator_config_path` supplies the crate name/version and full
@@ -138,7 +168,8 @@ Saved files are read and validated during registration. Automatic models are
138
168
  initialized at the end of mount. Missing files or generators, actual generator
139
169
  version mismatches, unknown fields, conflicting identities and invalid schemas
140
170
  fail startup. Mounting also rejects unavailable API versions and mismatched Rust
141
- snapshot versions. Failed mounting publishes no profiles and leaves no usable
171
+ and TypeScript snapshot versions. Failed mounting publishes no profiles and
172
+ leaves no usable
142
173
  server application. An older explicitly pinned generator can serve a suitable
143
174
  legacy file profile, but automatic/inline Rust mode requires the new factory
144
175
  interface and never guesses a fallback.
@@ -165,7 +196,8 @@ Construction copies and freezes Hash, Array and String data without freezing
165
196
  the caller's containers. Each request owns its description snapshot.
166
197
  `context.with(...)` creates a distinct frozen context, sharing unchanged fields
167
198
  by identity and normalizing only explicitly supplied fields. A shallow-frozen
168
- input container still needs copying; explicitly supplying an existing owned Hash,
199
+ input container still needs copying; explicitly supplying an existing owned
200
+ Hash,
169
201
  Array or String also copies it. Renderers must treat context-owned data as
170
202
  read-only.
171
203
 
@@ -185,16 +217,21 @@ overview and every panel before rendering.
185
217
 
186
218
  Generated-client adapters belong to their generator gems and consume context
187
219
  readers without depending on the server gem. They resolve names using the
188
- complete private generator model and never run a compiler, formatter or generator
189
- in an HTML request. Keep adapter rendering read-only: configured adapters are frozen
190
- and shared, while authorized description snapshots belong to individual requests.
220
+ complete private generator model and never run a compiler, formatter or
221
+ generator
222
+ in an HTML request. Keep adapter rendering read-only: configured adapters are
223
+ frozen
224
+ and shared, while authorized description snapshots belong to individual
225
+ requests.
191
226
  Derived contexts within one request share unchanged owned data. Supplying a
192
- resource, action or sample still copies that supplied subtree, so a resource with
227
+ resource, action or sample still copies that supplied subtree, so a resource
228
+ with
193
229
  many actions can remain costly to render.
194
230
 
195
231
  ## Verification
196
232
 
197
- `make test-documentation` checks renderers, contexts, profiles, rendered HTML and
233
+ `make test-documentation` checks renderers, contexts, profiles, rendered HTML
234
+ and
198
235
  local assets. Prepare the component bundles and JavaScript development
199
236
  dependencies with `make release-deps` first. The target builds the JavaScript
200
237
  package before compiling TypeScript examples against its public declarations;
@@ -220,8 +257,10 @@ nix develop .#server-ruby --command bash -c \
220
257
  'make --no-print-directory benchmark-documentation > /tmp/documentation.json'
221
258
  ```
222
259
 
223
- The target synchronizes shared documentation before running the harness. Standard
224
- output is JSON; shell/dependency preparation happens before the redirected command.
260
+ The target synchronizes shared documentation before running the harness.
261
+ Standard
262
+ output is JSON; shell/dependency preparation happens before the redirected
263
+ command.
225
264
  No network server, database, generated SDK compilation or formatter is involved.
226
265
  The synthetic fixture uses Basic users `admin` and `member`, both with the local
227
266
  test password `fixture-only`.
@@ -229,8 +268,10 @@ test password `fixture-only`.
229
268
  Defaults measure two shapes with eight ordinary actions each: eight resources
230
269
  with one action, and one resource with eight actions. Each ordinary action also
231
270
  has a privileged sample; the first resource has an additional admin-only action.
232
- Context lanes derive a prepared parent with a changed profile or a constant small
233
- sample. The HTML lane performs one real Rack request per trial, with all available
271
+ Context lanes derive a prepared parent with a changed profile or a constant
272
+ small
273
+ sample. The HTML lane performs one real Rack request per trial, with all
274
+ available
234
275
  dynamic renderer panels. Use a minimal smoke run or a larger repeated campaign:
235
276
 
236
277
  ```sh
@@ -242,8 +283,10 @@ make benchmark-documentation \
242
283
  ```
243
284
 
244
285
  Run these `make` commands inside the prepared shell. `--shape custom --resources
245
- N --actions N` controls both dimensions independently; `--lanes context` isolates
246
- derivation and `--lanes html` isolates requests. `--user member` selects restricted
286
+ N --actions N` controls both dimensions independently; `--lanes context`
287
+ isolates
288
+ derivation and `--lanes html` isolates requests. `--user member` selects
289
+ restricted
247
290
  help. `--help` lists all options. `--rust` enables the fixture's matching Rust
248
291
  profile, includes both Rust variants in HTML, and adds separate async/blocking
249
292
  action and authentication adapter lanes. It requires the optional generator's
@@ -269,13 +312,16 @@ Rust lanes are never silently omitted. See the
269
312
 
270
313
  Every lane reports each trial and the median wall seconds, process CPU seconds
271
314
  and allocated objects, with the operation count per trial. Wall time uses a
272
- monotonic clock. A full GC runs before each trial, outside its timer; GC triggered
315
+ monotonic clock. A full GC runs before each trial, outside its timer; GC
316
+ triggered
273
317
  by the measured work remains included. Mount/profile construction has separate
274
318
  setup metrics. Ownership construction, description selection, warmups, output
275
319
  validation, hashing and JSON serialization are outside lane measurements.
276
320
  Reports include fixture dimensions, renderer IDs, profile mode, Ruby runtime,
277
- host, Git HEAD/status/diff digest, loaded source digests, dependency versions and
278
- available lockfile/harness/fixture digests. HTML and Rust outputs include status, bytes and
321
+ host, Git HEAD/status/diff digest, loaded source digests, dependency versions
322
+ and
323
+ available lockfile/harness/fixture digests. HTML and Rust outputs include
324
+ status, bytes and
279
325
  SHA-256; failed or missing output causes a nonzero exit.
280
326
 
281
327
  For a before/after comparison, retain one exact harness and fixture and point