Skip to content

Add paywall preload benchmark CI for release PRs#432

Merged
ianrumac merged 43 commits into
developfrom
claude/paywall-preload-benchmark-ci-b56ahw
Jul 7, 2026
Merged

Add paywall preload benchmark CI for release PRs#432
ianrumac merged 43 commits into
developfrom
claude/paywall-preload-benchmark-ci-b56ahw

Conversation

@ianrumac

@ianrumac ianrumac commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

Changes in this pull request

  • Adds PaywallPreloadBenchmark, an instrumented test in :app that measures the time from Superwall.preloadAllPaywalls() (using the dev app's embedded CONSTANT_API_KEY) until every paywall announced by paywallPreload_start reaches PaywallLoadingState.Ready. Runs 5 iterations (cold + warm), records per-paywall ready times and mean/median/stddev/CV, and writes JSON results to the device's shared Downloads folder for CI to pull.
  • Adds .github/workflows/preload-benchmark.yml, which runs on release PRs (PRs into main), merges to main/develop, and workflow_dispatch. It benchmarks on 3 emulators in parallel for LOW/MID/HIGH device tiers — all pinned to the same Play Store image (pixel_6, API 34, google_apis_playstore, x86_64) with only cores/RAM differing (2/2GB, 3/4GB, 4/6GB), a 5s settle after boot, and the same KVM/free-disk/AVD-snapshot-cache setup as pr-tests.yml.
  • Adds scripts/benchmark_compare.py, which aggregates the tier results into .benchmark/results/ + .benchmark/REPORT.md (committed back to the branch and mirrored to the job summary) and fails the workflow when a tier's mean regresses more than the delta limit — default 10% in .benchmark/config.json, per-tier overrides supported.
  • Baselines live in .benchmark/baseline/<tier>.json and are recorded by dispatching the workflow with update_baseline=true; until then runs are informational. Each result also records the SDK's own DeviceClassifier tier so the emulator configs can be validated against real tier classification and tuned.
  • Adds :app:pullBenchmarkResults / :app:clearBenchmarkResults Gradle tasks for local runs (docs in .benchmark/README.md).

Checklist

  • All unit tests pass.
  • All UI tests pass.
  • Demo project builds and runs.
  • I added/updated tests or detailed why my change isn't tested. (This PR is itself a test/benchmark harness; :app:compileDebugAndroidTestKotlin passes. The workflow's first live run on a release PR will exercise it end-to-end.)
  • I added an entry to the CHANGELOG.md for any breaking changes, enhancements, or bug fixes. (CI/tooling only — no SDK behavior change.)
  • I have run ktlint in the main directory and fixed any issues. (ktlint isn't wired into this Gradle build; new code follows the repo's .editorconfig style.)
  • I have updated the SDK documentation as well as the online docs. (Not applicable — internal benchmark docs live in .benchmark/README.md.)
  • I have reviewed the contributing guide

@ianrumac ianrumac added the benchmark label Jul 6, 2026 — with Claude
claude and others added 29 commits July 7, 2026 11:17
On devices without the Play Store, the billing client reports
BILLING_UNAVAILABLE and product fetches throw BillingNotAvailable,
which failed the whole paywall request even when test mode was active
and products should have come from the test catalog instead.

Two gaps caused this:

- Race on activation: the test product catalog is fetched over the
  network when test mode activates, but a paywall requested right after
  config landed read an empty catalog and fell through to Play billing.
  StoreManager now waits (up to 5s) for the catalog to load before
  resolving products while test mode is active.

- No test-mode exemption: any product id not resolved from the test
  catalog hit Play billing, and a BillingNotAvailable error failed the
  entire load, discarding the test products that did resolve. In test
  mode, BillingNotAvailable is now swallowed and whatever resolved from
  the test catalog and caches is served.

Also stop re-evaluations from replacing an active test-mode session,
which wiped the loaded catalog and entitlement selections and left the
new session's products forever unloaded.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117xhsmLu2YmArN8632vmtF
…egration test

When test mode re-activates for a different reason (e.g. another test
user matched after identify), the simulated entitlements, selections and
overridden subscription status are now reset while the user-independent
product catalog is kept.

Also add a JVM integration test that wires the real GoogleBillingWrapper
to a BillingClient whose setup finishes with BILLING_UNAVAILABLE — the
exact signal a device without the Play Store produces — and verifies the
StoreManager + TestMode chain serves test-catalog products (including
after the billing failure is permanently cached), while non-test-mode
loads still fail as before.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117xhsmLu2YmArN8632vmtF
Adds a CI benchmark that measures the time from preloadAllPaywalls()
until every paywall for the dev app's embedded API key reaches
PaywallLoadingState.Ready.

- PaywallPreloadBenchmark instrumented test in :app runs N iterations
  (cold + warm), polls the SDK view store for Ready state, and writes
  JSON results to the device's Downloads collection
- preload-benchmark.yml workflow runs on PRs into main (release PRs)
  across 3 parallel emulators (LOW/MID/HIGH tiers) pinned to the same
  Play Store device image (pixel_6, API 34, google_apis_playstore),
  differing only in cores/RAM, with a 5s settle after boot
- scripts/benchmark_compare.py aggregates tier results, writes the
  report to .benchmark/, and fails the run when a tier's mean regresses
  more than the delta limit (default 10%) over its committed baseline
- Baselines are recorded via workflow_dispatch with update_baseline;
  each result records the SDK's own DeviceClassifier tier so the
  emulator configs can be verified against real tier classification

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DiCiN2qg1dCDh7TqJMYTY1
…ting workflows

- Also trigger on push to main/develop so merged branches keep fresh
  benchmark reports
- Opt in to ExperimentalCoroutinesApi for resetReplayCache (verified
  :app:compileDebugAndroidTestKotlin passes)
- Document that the emulator setup mirrors pr-tests.yml/build+test.yml
  and that the pixel_6 Play Store device matches maestro-run.yml's
  DeviceCloud config

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DiCiN2qg1dCDh7TqJMYTY1
Each emulator now invokes connectedDebugAndroidTest runsPerEmulator
times (default 3); every invocation reinstalls the app and clears its
data, so each run's first iteration is a true cold start. The test tags
results with a run index and writes one JSON per run, and the compare
script merges all runs per tier, computing the gated mean (plus
cold/warm means, median, stddev, CV) across all runs x iterations.

The AVD snapshot prewarm stays separate from pr-tests.yml on purpose:
those AVDs are API 33 google_apis pixel_7_pro without Play Store, while
the benchmark needs the API 34 google_apis_playstore pixel_6 image, and
per-tier cores/RAM are baked into each snapshot.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DiCiN2qg1dCDh7TqJMYTY1
…d benchmark on develop PRs

test_preload_active_paywall in :superwall androidTest still asserted
the pre-#430 behavior where updatePaywall mutated the cached paywall,
leaking presentationSourceType onto the instance returned by the
preload-of-active-paywall path. Since ad31f3a returns a copy, the
cached paywall no longer carries the presenter's source type, so the
test now verifies the presented copy kept its request-specific values
and that preloading didn't clobber the original experiment (mirroring
the unit test updated in #430). Verified by replicating the scenario
as a JVM test: old assertion fails on presentationSourceType, new
assertions pass.

The benchmark workflow now also runs on PRs into develop when the PR
carries the 'benchmark' label (release PRs into main still always
run), so the pipeline can be exercised before merge.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DiCiN2qg1dCDh7TqJMYTY1
…te shells

android-emulator-runner passes each line of its script input to its own
sh -c, so the multi-line for-loop died with 'Syntax error: end of file
unexpected' on all three tiers. Move the settle/loop/pull sequence into
scripts/run_preload_benchmark.sh and invoke it as a single line.
Verified with bash -n and a stubbed adb/gradlew dry run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DiCiN2qg1dCDh7TqJMYTY1
…per run

The gradle build previously ran inside the emulator session, competing
with the emulator for the runner's cores; assembling :app debug +
androidTest APKs beforehand means the three in-session
connectedDebugAndroidTest invocations only install and run (the class
filter already restricts execution to the benchmark test). Iterations
per run drop from 3 to 2 — still 3 fully cold runs per emulator,
averaged over 6 samples. The Play Store system image download and AVD
snapshot creation were first-run-only costs; subsequent runs restore
the per-tier cached AVD.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DiCiN2qg1dCDh7TqJMYTY1
… am instrument

A single build-apks job assembles both APKs and shares them as an
artifact; the three tier jobs download them, adb install once, and per
run do pm clear + am instrument directly. This removes the JDK/gradle
setup and the ~2-3 min of gradle configuration+install overhead from
each of the 3 in-session runs while keeping the cold-start guarantee
(pm clear wipes app data before every run). am instrument exits 0
through adb even on failure, so the script parses the instrumentation
output for OK/FAILURES/crash markers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DiCiN2qg1dCDh7TqJMYTY1
All three benchmark tiers hung for 90+ minutes because the wait for
ConfigurationStatus.Configured sat outside the iteration timeout: when
configuration fails or stalls on the emulator the status never becomes
Configured and the test blocked forever. The wait is now bounded
(benchmarkConfigureTimeoutSec, default 120s), fails fast with the
actual status on ConfigurationStatus.Failed, and the tier jobs carry
timeout-minutes: 30 so a hang can never ride to GitHub's 6h default.
On a failed run the script now dumps filtered logcat so the root cause
is visible in the CI log.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DiCiN2qg1dCDh7TqJMYTY1
… runners

tee failed with 'No such device or address' in the runner's sh -c
context; combined with set -e + pipefail this killed the script after
am instrument completed and swallowed its output, so all three tiers
failed without diagnostics. Capture plain, print after, tolerate adb's
exit status, and log per-run wall time. Result upload downgraded to
if-no-files-found: warn so failed runs don't stack a misleading
artifact error on top (the report job already fails on missing tiers).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DiCiN2qg1dCDh7TqJMYTY1
All three tiers failed fast with 'SDK configuration timed out (status
still Pending)': GitHub runners resolve via systemd-resolved on
127.0.0.53, which the emulator guest cannot use, so api.superwall.me
never resolves and the config fetch retries forever. Nothing else in
CI does real network from a GH-hosted emulator (superwall tests mock
it; real-network app tests run on DeviceCloud), which is why this
never surfaced. Pass -dns-server 8.8.8.8 to both emulator steps, add a
guest/host connectivity pre-flight to the script, and dump an
unfiltered logcat tail on failure.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DiCiN2qg1dCDh7TqJMYTY1
…s a network

DNS now resolves in the guest (the -dns-server fix worked: the
pre-flight printed api.superwall.me's IP, and the host reaches the API
with HTTP 200), yet configuration still timed out at Pending. The SDK's
awaitUntilNetworkExists() requires an active network with a WIFI or
CELLULAR transport, and netsim WiFi does not reliably come back after
the AVD snapshot restore — the guest believes it is offline even
though the NAT path works, so the config fetch never starts. Toggle
WiFi off/on after boot, wait bounded for an active default network,
and print the connectivity state in the pre-flight.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DiCiN2qg1dCDh7TqJMYTY1
…rors

ConnectivityManager now reports a validated LTE network 2s after the
WiFi cycle and DNS resolves in the guest, yet configuration still
never leaves Pending — the config request is failing and silently
retrying (getConfig's retry callback flips state to Retrying, which
maps to Pending externally, and nothing logs at error level until
retries are exhausted). Run the benchmark with LogLevel.debug and
widen the failure logcat window (+System.err, since HandleFetchFailure
uses printStackTrace) so the next run shows the actual exception.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DiCiN2qg1dCDh7TqJMYTY1
The emulator logs verbosely enough that a 4000-line tail covers ~30s
and missed the entire configure window, so the debug run still showed
no SDK output. Clear logcat per run and dump tag-filtered
(SWPreloadBenchmark/System.out/System.err/TestRunner) over the whole
per-run buffer instead. Also bump the AVD cache key to v2 — AVDs
cached before the -dns-server change fail snapshot load and force a
slow second cold boot with an adb-offline loop.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DiCiN2qg1dCDh7TqJMYTY1
…explicit status

The debug trace showed the real blocker: CI emulators have no
signed-in Google account, so Play Billing reports BILLING_UNAVAILABLE
and never connects ('Billing client not ready' retry loop), stalling
configuration and leaving entitlement status unknown. The benchmark
now configures with a stub external PurchaseController and sets
SubscriptionStatus.Inactive immediately, taking the billing subsystem
out of the measured path entirely — the benchmark's subject is paywall
preloading, not Play handshakes on an accountless emulator.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DiCiN2qg1dCDh7TqJMYTY1
…ound

The complete SDK trace showed guest HTTPS working (collector requests
succeed) while no request to api.superwall.me was ever issued.
Network.getConfig starts with awaitUntilAppInForeground() — with no
resumed activity in the instrumented process, the config fetch
suspends indefinitely, which is the true cause of the Pending hangs
(production apps are always foregrounded when configuring, so this
never surfaces there). Launch MainActivity via ActivityScenario for
the duration of the benchmark; its onCreate doesn't touch
Superwall.instance, so launching before configure is safe.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DiCiN2qg1dCDh7TqJMYTY1
The full pipeline now works end-to-end (config fetched, preload ran,
paywalls reached Ready) but the embedded key has 47 paywalls: the low
tier got 4 Ready in ~16s then stalled for nearly 5 minutes under the
concurrent-WebView load and hit the per-iteration timeout. Preloading
everything 6x per tier can never fit 15 minutes on a 2-core emulator,
and it couples the metric to unrelated dashboard campaign edits.

Preload the placements pinned in .benchmark/config.json (default
campaign_trigger) via the public preloadPaywalls() API instead — same
SDK code path and Ready criterion, stable workload. Placements are
recorded in the result JSON; set placements to [] to measure
preloadAllPaywalls().

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DiCiN2qg1dCDh7TqJMYTY1
campaign_trigger is not a placement on CONSTANT_API_KEY (preload
started with paywall_count=0 and failed fast; configure itself now
completes in ~3.6s). Use present_data, present_products and
present_urls — placements this key actually has, kept stable by the
repo's own UI tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DiCiN2qg1dCDh7TqJMYTY1
First real preload run: configure 2.8s, 3 paywalls resolved, first
Ready in 3.3s — then the process died silently ~40s in with
'Process crashed' and no AndroidRuntime trace, which is the signature
of MainApplication's VmPolicy penaltyDeath (detectActivityLeaks) that
is installed unconditionally in onCreate. The benchmark now resets
StrictMode policies at start (the app's leak watchdog is not the
subject under measurement), and the failure logcat dump includes
StrictMode/DEBUG/CRASH/libc tags so any further process death is
attributable.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DiCiN2qg1dCDh7TqJMYTY1
…er SDK-accurate RAM

The process died ~35s in on every tier with no crash trace in any
runtime tag — a system kill. With config now working and an activity
foregrounded, the key's implicit placements (app_launch/on_start/...)
present real paywalls over MainActivity mid-benchmark: main-thread
WebView work on a throttled emulator that can ANR-kill the process.
Set subscription status active with entitlements so
CHECK_USER_SUBSCRIPTION presentations skip (the preload path doesn't
gate on subscription). LOW tier gets 3072M — per the SDK's own
DeviceClassifier docs LOW is 3-4GB, and 2048M models ULTRA_LOW, which
OOMs webview-heavy workloads. AVD cache bumped (snapshots don't
survive RAM changes) and the failure dump now includes
ActivityManager/Zygote for kill attribution.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DiCiN2qg1dCDh7TqJMYTY1
The ActivityManager trace finally named the killer: ~40s after boot
the Play Store self-updates (installPackageLI), the install
force-stops Google Play Services, and the OS kills our app because it
holds a GMS FontsProvider connection (appcompat downloadable fonts) —
'Killing com.superwall.superapp (adj 0): depends on provider
com.google.android.gms/.fonts.provider.FontsProvider in dying proc'.
Same timing on every tier because it's a timed background event, not
load. Disable com.android.vending for the benchmark session (the
image remains a Play Store device; billing is already bypassed via the
external purchase controller).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DiCiN2qg1dCDh7TqJMYTY1
Disabling the Play Store didn't stop GMS from restarting (~30s after
app start, same as before — self-update/accountless checkin), and the
OS kept killing the benchmark app for holding a dying provider
connection. Cut the dependency at the source instead: emoji2's
auto-initializer establishes the FontsProvider connection, so the
debug manifest removes it (system emoji font is used; release builds
unchanged, and paywall rendering happens in WebView which doesn't use
GMS fonts). The vending disable is reverted — it destabilizes GMS and
the emulator should remain a genuine Play Store device.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DiCiN2qg1dCDh7TqJMYTY1
Removing emoji2's startup initializer was not enough: appcompat lazily
configures EmojiCompat when any AppCompatTextView inflates, opening
the same GMS FontsProvider connection, and MainActivity is full of
appcompat/Compose text — the kill recurred identically. The benchmark
now launches a plain android.app.Activity declared in the test APK:
it satisfies the SDK's foreground gate while touching no appcompat and
no GMS, so Play services churn can no longer kill the process.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DiCiN2qg1dCDh7TqJMYTY1
startActivitySync refuses activities declared in the instrumentation
APK ('Intent resolved to different process com.superwall.superapp.test'),
so the bare activity now lives in app/src/debug (debug-only, absent
from release) and launches into the app's process.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DiCiN2qg1dCDh7TqJMYTY1
…kill

The FontsProvider connection survives every in-app mitigation because
Chromium WebView queries GMS downloadable fonts from inside the
embedding app's process — unavoidable on a production Play image. GMS
persistent reliably restarts during the first ~3 minutes after boot on
accountless devices, killing the app mid-run. Two defenses: wait until
GMS persistent has kept the same pid for 45s (max 4 min) before
measuring, and retry a run exactly once when the instrumentation
reports 'Process crashed' without a test failure (external kill);
genuine test failures are never retried. Verified both paths with a
stubbed dry run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DiCiN2qg1dCDh7TqJMYTY1
The GMS stability gate worked (no external kill this time) but two of
the three pinned paywalls never reached Ready: their product fetches
fail (Play Billing is permanently unavailable on accountless CI
emulators) and the SDK by design does not finish preloading a paywall
whose products fail to load. Pin present_urls + no_products — stable
UI-test placements whose paywalls reference no products — and drop the
per-iteration timeout to 180s so a stall costs 3 minutes, not 5.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DiCiN2qg1dCDh7TqJMYTY1
The measurement itself finally completed — 'all 2 paywalls Ready in
5531ms' on mid — and the remaining crash was between iterations: after
Superwall.teardown() a stray SDK coroutine touches Superwall.instance
and the uncaught IllegalStateException kills the process before
results are written. Make runs the sample unit instead: iterations=1
(no teardown ever — each am-instrument run is already cold via pm
clear) and runsPerEmulator 3->4 to keep 4 samples per tier. Teardown
still exists between iterations for local use but is skipped after the
last one so results always get persisted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DiCiN2qg1dCDh7TqJMYTY1
@ianrumac ianrumac force-pushed the claude/paywall-preload-benchmark-ci-b56ahw branch from 66d2818 to d0b07bb Compare July 7, 2026 11:31
claude and others added 14 commits July 7, 2026 11:42
-PbenchmarkTestMode=true (passed by the workflow's build-apks job)
bakes BuildConfig.BENCHMARK_TEST_MODE into the app; when set, the
benchmark configures the SDK with TestModeBehavior.ALWAYS. On top of
the rebased BILLING_UNAVAILABLE test-mode fix this makes product
fetches non-fatal on CI emulators (no Play account), paving the way to
benchmark product-bearing paywalls. Defaults to false for every other
build.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DiCiN2qg1dCDh7TqJMYTY1
With TestModeBehavior.ALWAYS active (and the rebased
BILLING_UNAVAILABLE fix underneath), product fetches are non-fatal on
CI emulators, so the pinned set grows back to four placements:
present_urls, no_products, present_data, present_products — covering
both product-less and product-bearing paywalls.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DiCiN2qg1dCDh7TqJMYTY1
Adds present_video, present_localized, present_free_trial,
present_data_v4, scroll_test and price_readout to the existing four —
covering video, localized, free-trial, long-scroll and price-heavy
paywalls alongside the product-less and product-bearing ones. Known
traps stay excluded: no_paywalljs never reaches Ready by design,
holdout placements resolve to no paywall, incorrect_product_identifier
is deliberately broken. The SDK dedupes placements sharing a paywall,
so the report's paywall count is the source of truth for set size.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DiCiN2qg1dCDh7TqJMYTY1
Ten new placements (surveys, state/style/theme tests, subscription
variants, consumable, non-recurring) join the existing ten. Ten cold
runs per tier give the statistics room to settle at this workload
size; per-iteration timeout rises to 240s and the tier job cap to 40
minutes to accommodate the larger set on the low tier.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DiCiN2qg1dCDh7TqJMYTY1
At 20 placements (~25 paywalls) the concurrent WebView load saturated
every tier: HIGH measured slower than MID, CVs hit 39-49%, and runner
noise drowned the hardware signal the delta gate exists to protect.
The 10-paywall set kept tier ordering monotonic with markedly lower
variance. Keeps 10 cold runs per tier and the enlarged timeouts as
headroom.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DiCiN2qg1dCDh7TqJMYTY1
Deltas derived from measured spread of the 10x10 configuration (CV:
mid 9.5%, high 17.6%, low 38.3% -> ~3-sigma on run-to-run mean
comparisons): low 30%, mid 15%, high 20%, default 20%. The gated
metric switches to medianMs since low/high means are dragged by
outlier runs the median shrugs off. Baselines now update automatically
on every push to main/develop, so release PRs always compare against
the latest mainline numbers; manual update_baseline dispatch remains.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DiCiN2qg1dCDh7TqJMYTY1
Feature PRs into develop no longer run the benchmark (the label-gated
opt-in is removed); release PRs into main, merges to main/develop
(which roll baselines forward) and manual dispatch remain.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DiCiN2qg1dCDh7TqJMYTY1
@ianrumac ianrumac merged commit 7d32d7d into develop Jul 7, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants