Skip to content

The start-up budget

YazSes has around 150 feature packages under src/yazses/, and every one of them ships switched off. Off-by-default is what a user sees. This page is about what they pay: a feature whose code is imported when the daemon or the CLI starts is loaded on every start, by every user, whether or not they ever turn it on. Nothing breaks. Start-up just gets a little slower with every feature, and no functional test ever notices.

So the rule is:

A feature that is disabled costs a default start no import. Its package, and the libraries it uses, are imported by the code that runs once the user turns it on — not by yazses.core.daemon, not by yazses.cli, and not by anything those import at module scope.

What is enforced, and where

What Enforced by Fails when
A disabled-by-default feature package is imported by import yazses.main (the daemon entry point) or by yazses --help tests/test_base_weight_budget.py A package that is not on its KNOWN_EAGER list appears in sys.modules
The KNOWN_EAGER list goes stale same file A listed package is no longer imported. Delete the line, so the list can only shrink
yazses.* modules loaded by each path same file, MAX_YAZSES_MODULES The count goes over the ceiling
A module either path loads imports a yazses module under a module-scope OS or Python-version branch same file, test_the_module_count_cannot_depend_on_the_os Such an import exists, which would make the ceiling mean something different on Windows or macOS
asyncio, the urllib/http/ssl network stack, cryptography, sqlite3, importlib.metadata at a default start same file, NOT_LOADED_BY One of them is loaded again
yazses --version or yazses --help imports yazses.config same file, CLI_ENTRY_PROBES cli.main() needs the config module again
A default daemon's Daemon.__init__ or _build_pipeline imports a disabled-by-default feature package tests/test_dormant_features_stay_unimported.py __init__ imports any; _build_pipeline imports one not on its KNOWN_AT_START list, or a listed one stops being imported
Opt-in feature packages, heavy third-party packages tests/test_default_start_stays_light.py Listed packages are imported at start
A module from an optional extra imported at module scope anywhere tests/test_no_module_imports_an_extra_at_module_scope.py An extra's import sits at module scope
Base dependency growth, eager extra imports, daemon import time scripts/check_dependency_budget.py (CI repo-hygiene) A new base dependency, or import time over 1.5× the recorded budget (CI only, advisory on a laptop)

The set of feature packages is derived, not listed. It is every [section] of yazses.config.Config whose default has enabled = False and whose name is also a package under src/yazses/. A feature added tomorrow is covered without anyone remembering to register it.

The guards count and name modules instead of timing them. Whether a module is in sys.modules after a fresh import gives the same answer on a busy CI runner and on a laptop. A wall-clock threshold does not. Timing is measured by check_dependency_budget.py with a wide tolerance and only fails in CI.

The module-count ceilings mean the same thing on every OS. They were measured on Linux and the test job also runs on Windows and macOS, so they are built not to depend on the runner:

  • Modules under platform/<os>/ are not counted. That is where AGENTS.md says OS differences live, and how many modules a backend needs is a property of the OS. The set of backends is read from where platform/factory.py imports build_platform, so a new OS is excluded the day it is registered.
  • Everything else either probe loads must import the same yazses modules on every OS. An AST check enforces this: no loaded module may import a yazses module inside a module-scope if that tests sys.platform, os.name, platform.system() or sys.version_info.
  • The ceiling is the Linux count plus 3. The headroom covers the one case the AST check cannot see, a function called at import time that imports something on one OS only. It is a review signal, not an allowance.

A separate per-OS baseline was rejected. It would need a number measured on each runner, and no run measures it, so it would be a guess that goes stale without anyone noticing.

How to stay inside it

  1. Import inside the function that runs when the feature is on. Do not import at the top of the file.

    def build_widget(cfg: WidgetConfig) -> Widget | None:
        if not cfg.enabled:
            return None
        from yazses.widget.engine import Widget  # paid only by users who turned it on
        return Widget(cfg)
    
  2. A type you only use in annotations goes under if TYPE_CHECKING:. With from __future__ import annotations the name is never evaluated at runtime.

  3. Keep the daemon's seam thin. When the daemon must hold a slot for your feature, keep the imported module to a small factory that returns None or a null object when the feature is off (as learning.capture.build_writer and tts.factory.build_tts do). The engine stays behind it.
  4. Watch the transitive imports. A small pure module that imports asyncio, urllib.request or cryptography at the top makes every start pay for them. Import those inside the method that opens the connection or the cipher, as remote/local_proxy.py, postprocess/llm_cleanup.py and learning/crypto.py do.
  5. Do not compute a module-level constant from a feature package in a core module. That import runs at start. When a core table must hold a value owned by a feature, store a function and resolve it on first lookup. configcheck._ENUMS does this for the face-gesture names (_ClosedSets). It reads like a dict, and only reading the value of facegesture.gesture imports yazses.facegesture.
  6. Check enabled before importing, not after. A factory that returns None when its feature is off is still an import if the caller imports it first. The daemon gates build_writer, build_tts and build_style_rules on the feature's enabled, and when [earcon] is off it holds a no-op _SilentEarcon instead of an EarconPlayer(False).
  7. Do not derive at start what you can generate. cli.main() escapes [section] names in help text on every invocation. It used to derive them from Config, which imported the whole of yazses.config (about 66 ms) even for --version. It now reads src/yazses/config_sections.py, which scripts/gen-docs.py writes and tests/test_gen_docs.py keeps in sync.

Measuring your change

# What a cold daemon import loads, and what each import costs (stderr, microseconds):
uv run python -X importtime -c "import yazses.main" 2>&1 | grep yazses.<your_package>

# Who imported it: the importer is the next line below with less indentation.
uv run python -X importtime -c "import yazses.main" 2>&1 | grep -A8 yazses.<your_package>

# The guards:
uv run python -m pytest tests/test_base_weight_budget.py tests/test_default_start_stays_light.py \
  tests/test_dormant_features_stay_unimported.py -q
uv run python scripts/check_dependency_budget.py

When the guard fails

  • "now imports disabled-by-default feature package(s)": find the module-scope import with the importtime command above and move it inside the function that needs it. Do not add the package to KNOWN_EAGER to get past the test. That list records debts that existed when the budget was introduced, each with its reason.
  • "no longer imported … delete them from KNOWN_EAGER": you made something lazy. Delete the line, which keeps the improvement from being lost later.
  • "over its budget": something at module scope gained an import. If the growth is deliberate (a new core module every start genuinely needs), raise MAX_YAZSES_MODULES in the test and explain why in the pull request.
  • "import yazses modules under an OS branch": a module-scope if sys.platform …: imports a yazses module, so the count differs by OS. Move the import behind platform/<os>/ or into the function that needs it.
  • "_build_pipeline now imports disabled-by-default feature package(s)": a builder imports its feature before it checks enabled. Check first, then import. The KNOWN_AT_START entries in that test show the shape to avoid.
  • "src/yazses/config_sections.py is stale": you added, renamed or removed a config section. Run uv run python scripts/gen-docs.py and commit the result.

The numbers when the budget was introduced

Measured on 2026-10-21 on a base install (uv sync, no extras) on Linux with CPython 3.14. Method: a fresh interpreter per sample, python -X importtime, the fastest of 9 runs, with the before and after trees run interleaved.

Start path Before After
import yazses.main (daemon) 425 modules, 92 of them yazses.*, 283 ms 333 modules, 92 yazses.*, 260 ms
import yazses.cli 197 modules, 22 yazses.*, 76 ms 184 modules, 22 yazses.*, 71 ms
yazses --help 374 modules, 32 yazses.*, 191 ms 374 modules, 32 yazses.*, 196 ms (unchanged)

The base dependency closure on that machine was 40 distributions, 419 MB installed. Most of that is the speech engine (faster-whisper, ctranslate2, onnxruntime, av) and numpy. Treat these as a dated measurement, not a current fact. Re-measure before you quote them.

The follow-up the same day

Also measured on 2026-10-21, with the same method on the same machine. The "before" tree is the start of the follow-up, which had already grown to 94 yazses.* modules on the daemon path. The machine was busy (load average about 7), so timings are noisier than the counts.

Start path Before After
import yazses.main (daemon) 340 modules, 94 yazses.*, 352 ms 306 modules, 73 yazses.*, 346 ms
yazses --version 237 modules, 32 yazses.*, 186 ms, imports yazses.config 225 modules, 25 yazses.*, 112 ms, no yazses.config
yazses --help 374 modules, 32 yazses.*, 250 ms 364 modules, 25 yazses.*, 192 ms
import yazses.cli 184 modules, 22 yazses.*, 104 ms 184 modules, 22 yazses.*, 106 ms (unchanged, as expected)

The 34 modules that left the daemon path (earcon, facegesture, learning, styleguard, tts, three system.* modules only tts needed, and sqlite3, tempfile, random, wave) cost 15 ms in import self-time, summed and taken as the fastest of 9. The whole-import difference on a loaded machine is smaller than the run-to-run noise, so read the daemon row for its module counts. The CLI rows are clear. yazses.config alone was 66 to 68 ms of --version (importtime's cumulative column, varying from run to run).

Two disabled packages are still imported by import yazses.main, cmdsafety and staged. The reason for each is in KNOWN_EAGER. A default daemon's _build_pipeline also imports gesture, perception, handsfree, headpointer and polyglot directly, and facegesture and gaze through them. Those are listed in tests/test_dormant_features_stay_unimported.py as the next things to make lazy.