Skip to content

perf(contracts): granularize entry surfaces so hub importers stop evaluating the facade clump - #1969

Merged
thymikee merged 2 commits into
mainfrom
perf/1959-contracts-entry-granularity
Aug 22, 2026
Merged

perf(contracts): granularize entry surfaces so hub importers stop evaluating the facade clump#1969
thymikee merged 2 commits into
mainfrom
perf/1959-contracts-entry-granularity

Conversation

@thymikee

Copy link
Copy Markdown
Member

Closes #1959.

Problem

@agent-device/contracts/platform value-re-exports 32 vocabulary modules; /interaction another 18. A file that value-imports either evaluates the whole union to reach one function — and permanent hubs sat behind them (command-descriptor/registry.ts, core/capabilities.ts, interactors/register-builtins.ts, command-descriptor/platform-execution-entry.ts), so that union rode into roughly half of the unit suite's test graphs.

Change

Every vocabulary module gets its own entry subpath, and all value-importers move onto the module that owns the symbol. Type-only importers are left alone exactly as the issue scoped it — import type is erased, so it already evaluated nothing.

The rewrite was mechanical: symbol → owning-module resolved from each façade's own re-export table, so no import was hand-picked. Every symbol resolved; nothing was skipped.

Measured

Eager-import-closure walker from #1950, over all 974 unit-core test files, against base e5bfde3d1:

base now
aggregate eager module evaluations 143,248 129,738 (−9.4%)
facades/platform.ts carried by 466 graphs 1
facades/interaction.ts carried by 451 graphs 0

Per hub:

file base now
command-descriptor/registry.ts 105 66
core/capabilities.ts 113 76
interactors/register-builtins.ts 111 73
command-descriptor/platform-execution-entry.ts 43 3
core/dispatch.ts 134 100

The issue projected ~10–14%. The gap is the part the leaf-simulation could not model: consumers still evaluate the modules they genuinely use, so the recovered work is only what they didn't.

Two hubs (capabilities.ts, register-builtins.ts) did not move on the first pass — platforms/apple/plugin.ts pulled the clump back in behind them. That is why the change covers every value-importer rather than the four files the issue named.

Regression pin

src/__tests__/contracts-entry-closure.test.ts, built on the #1950 walker, from both directions:

  1. the four named hubs never evaluate either façade;
  2. no source file anywhere value-imports them — so the clump cannot re-form behind a hub nobody thought to list. Type-only imports stay legal, which the walker distinguishes for free.

A non-vacuity assertion requires the scan still sees the narrow imports that replaced these, so a broken walker fails loudly instead of passing empty.

Planted red (reverted before commit): restoring capabilities.ts to contracts/platform failed both tests, each naming the offending edge —

src/core/capabilities.ts -> packages/contracts/src/facades/platform.ts
src/core/capabilities.ts -> @agent-device/contracts/platform

Gate adjustments the split forces

  • R11 — the pinned contracts subpath list grows to the new entries. The resolver test's "must not resolve" example moves to ./clipboard, since ./gesture-plan is now a real entry.
  • R16 — anchored the record-runtime join on the literal contracts/platform specifier; it now accepts any contracts entry. The assertion's provenance is what the rule pins, not which subpath carried it.
  • no-bare-stargesture-plan.ts became an entry target, and the rule rejects the export * from './gesture-plan-types.ts' it carried. Its one internal consumer (client-gesture.ts) now imports the owning module directly.

Known follow-up

Both façades keep their type re-exports for the ~490 type-only importers, so every symbol on them now reads as value-unused. One .fallowrc.json entry records that with the reasoning. Net check:production-exports findings go 37 → 29 (below base, because the entry also absorbs pre-existing façade findings).

Retiring the two façades outright — moving the type-only importers too and deleting the files — is the change that makes that entry unnecessary. It is ~490 further one-line edits with no additional perf gain (type imports are already free), so it is deliberately not bundled here.

Worth tightening after #1965 lands: its budget table seeds façade budgets from pre-#1959 closures, which this PR makes much smaller.

Verification

pnpm typecheck, pnpm lint, pnpm format, pnpm check:layering (181/181), pnpm check:fallow (no issues in 258 changed files), pnpm gate production-exports, and pnpm test:unit1002 files / 7473 tests, all green.

One earlier run showed a single runner-client.test.ts timeout; it passes in isolation and is the known under-contention signature (timeouts, never assertion failures) from four agents sharing this host. Clean on the full re-run.

…luating the facade clump

`@agent-device/contracts/platform` unions 32 vocabulary modules and
`/interaction` another 18. A file that value-imports either evaluates the whole
union to reach one function, and because permanent hubs sat behind them —
`command-descriptor/registry.ts`, `core/capabilities.ts`,
`interactors/register-builtins.ts`, `command-descriptor/platform-execution-entry.ts` —
that union rode into roughly half the unit suite's test graphs.

Give every vocabulary module its own entry subpath and move all value-importers
onto the module that owns the symbol. Type-only importers are left alone: `import
type` is erased, so it already evaluated nothing.

Measured with the #1950 eager-import-closure walker over all 974 unit-core test
files, against base e5bfde3:

  aggregate eager module evaluations  143,248 -> 129,738  (-9.4%)
  facades/platform.ts   carried by    466 -> 1 test graphs
  facades/interaction.ts carried by   451 -> 0 test graphs

  registry.ts                 105 -> 66
  capabilities.ts             113 -> 76
  register-builtins.ts        111 -> 73
  platform-execution-entry.ts  43 -> 3
  dispatch.ts                 134 -> 100

Three gate adjustments the split forces:

- R11's pinned contracts subpath list grows to the new entries, and the resolver
  test's "must not resolve" example moves to `./clipboard`, since `./gesture-plan`
  is now a real entry.
- R16 anchored the record-runtime join on the literal `contracts/platform`
  specifier. It now accepts any contracts entry — the assertion's provenance is
  what the rule pins, not which subpath carried it.
- `gesture-plan.ts` became an entry target, and the no-bare-star rule rejects the
  `export * from './gesture-plan-types.ts'` it carried. Its one internal consumer
  now imports the owning module directly.

Both facades keep their type re-exports for the ~490 type-only importers, so
every symbol on them now reads as value-unused; one fallow entry records that
and names retiring them as the follow-up.

Closes #1959
@github-actions

github-actions Bot commented Aug 22, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
JS raw 2.36 MB 2.37 MB +2.1 kB
JS gzip 792.0 kB 794.8 kB +2.8 kB
npm tarball 916.1 kB 914.6 kB -1.5 kB
npm unpacked 3.17 MB 3.17 MB -6.1 kB

npm unpacked components

Component Base Current Diff
JS / dist source 2.51 MB 2.51 MB +2.1 kB
Apple runner source/project 564.2 kB 555.9 kB -8.3 kB
macOS helper source 54.5 kB 54.5 kB 0 B
Android helper artifacts 0 B 0 B 0 B
Other package files 44.4 kB 44.4 kB 0 B

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 25.3 ms 25.4 ms +0.2 ms
CLI --help 71.2 ms 72.4 ms +1.1 ms

Top changed chunks:

Chunk Raw diff Gzip diff
dist/src/interaction.js +29.8 kB +9.2 kB
dist/src/sdk-batch-runner.js -16.7 kB -4.8 kB
dist/src/runtime.js +284 B +105 B
dist/src/runtime2.js +276 B +31 B
dist/src/internal/daemon.js +97 B +23 B

Top changed packed files

Packed file Base Current Diff
dist/src/interaction2.js 29.7 kB 0 B -29.7 kB
dist/src/sdk-batch-runner.js 92.7 kB 76.0 kB -16.7 kB
dist/src/app-log-runtime2.js 0 B 13.8 kB +13.8 kB
dist/src/app-log-runtime.js 13.5 kB 33 B -13.5 kB
dist/src/interaction.js 19.5 kB 29.8 kB +10.3 kB
dist/src/script.js 12.5 kB 20.8 kB +8.2 kB
dist/src/gesture-plan.js 0 B 6.1 kB +6.1 kB
dist/src/application-lifecycle-interaction.js 0 B 4.6 kB +4.6 kB
dist/apple/runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+SnapshotPresentation.swift 14.1 kB 10.6 kB -3.6 kB
dist/src/element-text-runtime.js 0 B 3.3 kB +3.3 kB

The repo-wide scan parsed all ~3000 sources, which the coverage lane's
instrumentation pushed past both the 5s test timeout and the 2.5s slow-test
budget. A file that never names the specifier cannot import it, so filter on the
text first and parse only the ~490 candidates.

Non-vacuity moves with it: instead of counting narrow imports across every file,
require that the surviving type-only importers were seen and classified as
erased — which an empty scan cannot satisfy.
@thymikee

thymikee commented Aug 22, 2026

Copy link
Copy Markdown
Member Author

Reviewed exact head 391f2990: no actionable code findings. The 259-file scope is a mechanical, complete value-import migration; the narrower hub-only edit was correctly rejected because platforms/apple/plugin.ts pulled the façade clump back behind the named hubs, while migrating ~490 erased type-only imports would add churn with no evaluation gain. The regression scan is planted-red and pins the owning invariant. Exact-head checks are green, size is below escalation threshold (npm unpacked -6.1 kB; JS gzip +2.8 kB), and the measured closure reduction matches #1959. Ready for human merge.

@thymikee thymikee added the ready-for-human Valid work that needs human implementation, judgment, or maintainer merge label Aug 22, 2026
@thymikee
thymikee merged commit 03c3984 into main Aug 22, 2026
28 checks passed
@thymikee
thymikee deleted the perf/1959-contracts-entry-granularity branch August 22, 2026 13:35
@github-actions

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-08-22 13:35 UTC

thymikee added a commit that referenced this pull request Aug 22, 2026
… reseed tight

Review findings on #1965:

1. Discovery scanned only `packages/*/src/facades/*.ts`, which omits every
   package that publishes its entry surface straight from the manifest —
   including all six `packages/platform-*/src/index.ts` façades, the exact
   subject of ADR-0019's implementation-laziness rule. Discovery now derives
   from `readWorkspacePackages(...).exportTargets` and then adds `/src/facades/`
   files, reusing the R11 helper rather than reimplementing it so the two gates
   cannot disagree about what an entry surface is. The table grows from 14
   façades + 2 hubs to 95 entry surfaces + 8 hubs.

2. Budgets carried a few files of slack each. They are now exact ratchets with
   no headroom, matching how the repo pins R9/R10 and test-file size: growth is
   allowed, it just has to be a visible number change in the diff of the PR that
   causes it. Every budget is reseeded from post-#1969 measurement.

3. Violations printed a flat sorted set, which named the offender but not the
   route. `eagerClosureGraphOf` records each file's discoverer, so failures now
   print the transitive chain entry -> ... -> offender. `eagerClosureOf` keeps
   its contract and is expressed in terms of the new walk; per-file edges are
   memoized, which also cuts the existing pins' runtime (cli closure test
   2336ms -> ~550ms).

The platform-package façades evaluate exactly one module each — themselves —
so their budget of 1 is the tightest statement of "metadata-eager,
implementation-lazy" the walker can make.
thymikee added a commit that referenced this pull request Aug 22, 2026
…equest-bound-migration-803b60

* origin/main:
  perf(contracts): granularize entry surfaces so hub importers stop evaluating the facade clump (#1969)
  fix(ios): preserve regular snapshot depth through structural wrappers (#1947)
  diagnose(1874): instrument the synthesized commit wait and add a dispatchable stall loop (#1941)
  refactor(tests): replace the test-utils barrel with direct module imports (#1956)
  fix(ios): enforce regular snapshot clip invariant (#1946)
  refactor(daemon): lazy-load platform cleanup helpers in session teardown (#1950)
  perf(typecheck): make the root tsc project incremental (#1957)
  docs: drop stale apps.ts over-budget warning from AGENTS.md (#1954)
  refactor: dedupe containsPoint and rectArea into @agent-device/kernel (#1953)

# Conflicts:
#	packages/platform-android/src/runtime.ts
#	packages/platform-harmonyos/src/runtime.ts
#	packages/platform-linux/src/runtime.ts
#	packages/platform-vega/src/runtime.ts
#	packages/platform-web/src/runtime.ts
#	packages/provider-limrun/src/app-log-runtime.test.ts
#	packages/provider-limrun/src/interaction-operations.ts
#	packages/provider-webdriver/src/platform-runtime.ts
#	src/core/command-descriptor/registry.ts
#	src/core/dispatch.ts
#	src/platforms/apple/plugin.ts
thymikee added a commit that referenced this pull request Aug 22, 2026
…/tv-remote/keyboard

Following main's #1969 (facade granularization), give each of this branch's five
new contract modules their own package.json entry subpath and move every
value-importer (owner runtime packages, the daemon binders, and their tests) off
the wide @agent-device/contracts/platform facade onto the specific module that
owns the symbol — the same convention #1969 established for the rest of the
vocabulary. Keeps this migration's files out of the contracts-entry-closure gate
and out of the eager-evaluation cost #1969 measured for the daemon's permanent
hubs (registry.ts, dispatch.ts).
thymikee added a commit that referenced this pull request Aug 22, 2026
Stryker runs the suite from a sandbox copy under `.tmp/stryker/`, so a test that
asserts about the repository checkout itself — its files on disk, or its git
history — reads a repository that does not exist, and no answer it gives there
means anything.

`src/__tests__/test-file-size-ratchet.test.ts` is such a gate, and it fails in
the sandbox for two independent reasons. Stryker's `disableTypeChecks` default
prepends `// @ts-nocheck` to every copied file, so all 26 pinned files read one
line longer than they are; and the sandbox has no `origin/main`, so the gate's
history-backed half cannot resolve its merge-base. Fixing either leaves the
other — turning off the type-check rewrite was tried first, and CI then failed on
the missing ref. The gate's own `.tmp` skip entry cannot save it either: that is
matched relative to `REPO_ROOT`, which inside the sandbox *is* the sandbox.

Excluding it hides nothing, for the reason this module's header already gives: a
mutant only an excluded test could kill shows up as a survivor. A repo-shape gate
cannot kill a kernel mutant at all — it asserts about file lengths and history,
never about behavior under mutation.

Derived rather than listed, matching the neighbouring thread-hostile exclusion,
so a future sibling gate is caught the same way. Nothing was wrong with the
ratchet or with #1964; before #1969 rewired the module graph, nothing pulled this
gate into a lane's scope and the trap stayed hidden.

Verified with `pnpm mutation:run --modules kernel-errors`: scope 804 -> 803 test
files (exactly this one), dry run clean, lane `pass` in 560s, score 75.2%
(188 killed / 62 survived / 250). `pnpm mutation:test` 39/39.

`stryker.config.json` is deliberately untouched, so the config content hash the
report and lane envelope carry is unchanged and scores stay comparable.

Unblocks #1964, whose two mutation checks fail on main's tip without its code.
thymikee added a commit that referenced this pull request Aug 22, 2026
Stryker runs the suite from a sandbox copy under `.tmp/stryker/`, so a test that
asserts about the repository checkout itself — its files on disk, or its git
history — reads a repository that does not exist, and no answer it gives there
means anything.

`src/__tests__/test-file-size-ratchet.test.ts` is such a gate, and it fails in
the sandbox for two independent reasons. Stryker's `disableTypeChecks` default
prepends `// @ts-nocheck` to every copied file, so all 26 pinned files read one
line longer than they are; and the sandbox has no `origin/main`, so the gate's
history-backed half cannot resolve its merge-base. Fixing either leaves the
other — turning off the type-check rewrite was tried first, and CI then failed on
the missing ref. The gate's own `.tmp` skip entry cannot save it either: that is
matched relative to `REPO_ROOT`, which inside the sandbox *is* the sandbox.

Excluding it hides nothing, for the reason this module's header already gives: a
mutant only an excluded test could kill shows up as a survivor. A repo-shape gate
cannot kill a kernel mutant at all — it asserts about file lengths and history,
never about behavior under mutation.

Derived rather than listed, matching the neighbouring thread-hostile exclusion,
so a future sibling gate is caught the same way. Nothing was wrong with the
ratchet or with #1964; before #1969 rewired the module graph, nothing pulled this
gate into a lane's scope and the trap stayed hidden.

Verified with `pnpm mutation:run --modules kernel-errors`: scope 804 -> 803 test
files (exactly this one), dry run clean, lane `pass` in 560s, score 75.2%
(188 killed / 62 survived / 250). `pnpm mutation:test` 39/39.

`stryker.config.json` is deliberately untouched, so the config content hash the
report and lane envelope carry is unchanged and scores stay comparable.

Unblocks #1964, whose two mutation checks fail on main's tip without its code.
thymikee added a commit that referenced this pull request Aug 22, 2026
… reseed tight

Review findings on #1965:

1. Discovery scanned only `packages/*/src/facades/*.ts`, which omits every
   package that publishes its entry surface straight from the manifest —
   including all six `packages/platform-*/src/index.ts` façades, the exact
   subject of ADR-0019's implementation-laziness rule. Discovery now derives
   from `readWorkspacePackages(...).exportTargets` and then adds `/src/facades/`
   files, reusing the R11 helper rather than reimplementing it so the two gates
   cannot disagree about what an entry surface is. The table grows from 14
   façades + 2 hubs to 95 entry surfaces + 8 hubs.

2. Budgets carried a few files of slack each. They are now exact ratchets with
   no headroom, matching how the repo pins R9/R10 and test-file size: growth is
   allowed, it just has to be a visible number change in the diff of the PR that
   causes it. Every budget is reseeded from post-#1969 measurement.

3. Violations printed a flat sorted set, which named the offender but not the
   route. `eagerClosureGraphOf` records each file's discoverer, so failures now
   print the transitive chain entry -> ... -> offender. `eagerClosureOf` keeps
   its contract and is expressed in terms of the new walk; per-file edges are
   memoized, which also cuts the existing pins' runtime (cli closure test
   2336ms -> ~550ms).

The platform-package façades evaluate exactly one module each — themselves —
so their budget of 1 is the tightest statement of "metadata-eager,
implementation-lazy" the walker can make.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready-for-human Valid work that needs human implementation, judgment, or maintainer merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

perf(contracts): granularize entry surfaces so hub importers stop evaluating the facade clump

1 participant