From 44dc5d69d1e64868adc2b1fdd20f85d2153ca55d Mon Sep 17 00:00:00 2001 From: a9lim Date: Mon, 2 Nov 2026 19:00:31 -0500 Subject: [PATCH 1/2] Slim CI and the guard checks: tests run once on Python 3.13 with xdist and no coverage; the package-isolation build, the Ledger ratchet, the test-wiring meta-check and the layering and server-boundary tests go (the layer order stays documented) --- .github/workflows/ci.yml | 59 +----- AGENTS.md | 7 +- ARCHITECTURE.md | 2 +- CONTRIBUTING.md | 2 +- drowse/cli/AGENTS.md | 2 +- drowse/fit/AGENTS.md | 2 +- drowse/generation/AGENTS.md | 4 +- drowse/geometry/AGENTS.md | 2 +- drowse/lens/AGENTS.md | 2 +- drowse/loom/AGENTS.md | 4 +- drowse/model/AGENTS.md | 2 +- drowse/readout/AGENTS.md | 2 +- drowse/sae/AGENTS.md | 2 +- drowse/server/AGENTS.md | 5 +- drowse/session/AGENTS.md | 2 +- drowse/steering/AGENTS.md | 2 +- drowse/store/AGENTS.md | 4 +- drowse/units/AGENTS.md | 2 +- drowse/web/AGENTS.md | 24 +-- scripts/check_package_isolation.py | 109 ---------- tests/AGENTS.md | 4 +- tests/invariants/test_boundary_guard.py | 92 -------- tests/invariants/test_layering.py | 218 ------------------- webui/README.md | 3 +- webui/ledger-ratchet.json | 30 --- webui/package.json | 3 +- webui/scripts/check-ledger.mjs | 266 ------------------------ webui/scripts/check-test-coverage.mjs | 20 -- 28 files changed, 31 insertions(+), 845 deletions(-) delete mode 100644 scripts/check_package_isolation.py delete mode 100644 tests/invariants/test_boundary_guard.py delete mode 100644 tests/invariants/test_layering.py delete mode 100644 webui/ledger-ratchet.json delete mode 100644 webui/scripts/check-ledger.mjs delete mode 100644 webui/scripts/check-test-coverage.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 26af66ac4..78d9d099a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,70 +33,17 @@ jobs: # while it analyzes the package, tests, examples, and maintained scripts. - run: pip install -e ".[dev]" - run: pyright - # The dashboard's REST types are generated from the FastAPI app's - # OpenAPI schema, so an unmirrored response-shape change has to fail - # the build rather than a panel. It lives in this job rather than in - # ``webui`` because the generator imports the package: this is the job - # that already has it installed, and duplicating the install would - # double CI's heaviest step for one diff. ``webui`` keeps the - # sibling parity check for the committed bundle. - - name: Verify the generated dashboard types are current - run: python scripts/generate_webui_types.py --check - test-matrix: + test: runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - python-version: ["3.11", "3.12", "3.13"] steps: - uses: actions/checkout@v7 - uses: actions/setup-python@v7 with: - python-version: ${{ matrix.python-version }} + python-version: "3.13" - run: python -m pip install --upgrade pip - run: pip install -e ".[dev]" - # Coverage is informational only — no --cov-fail-under, and --cov stays out - # of pyproject addopts so local runs without pytest-cov keep working. - - run: pytest -q -m "not gpu" --cov=drowse --cov-report=term-missing - - # Stable aggregator so branch protection can require a single ``test`` context - # instead of every ``test-matrix (x.y)`` leg by name. ``if: always()`` makes the - # gate run even when a leg fails, and the explicit result check turns a failed or - # skipped matrix into a failed gate (a skipped required check would otherwise let - # a PR merge). - test: - runs-on: ubuntu-latest - needs: [test-matrix] - if: always() - steps: - - name: Verify the test matrix succeeded - run: | - if [ "${{ needs.test-matrix.result }}" != "success" ]; then - echo "test-matrix did not succeed: ${{ needs.test-matrix.result }}" - exit 1 - fi - - build: - # Validates the PEP 621 dynamic-version setup in pyproject.toml: confirms - # ``python -m build`` resolves ``drowse.__version__`` via AST parse without - # executing the module (which would import torch and break the build). - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v7 - - uses: actions/setup-python@v7 - with: - python-version: "3.12" - - run: python -m pip install --upgrade pip build - - name: Seed a stale dashboard build cache - run: python scripts/check_package_isolation.py seed - - run: python -m build --sdist --wheel - - name: Verify wheel dashboard files match the source distribution - run: python scripts/check_package_isolation.py check - - name: Verify wheel imports and version round-trips - run: | - pip install dist/drowse_ai-*.whl - python -c "import drowse; from importlib.metadata import version; assert version('drowse.ai') == drowse.__version__; print('version:', drowse.__version__)" + - run: pytest -q -m "not gpu" -n auto webui: runs-on: ubuntu-latest diff --git a/AGENTS.md b/AGENTS.md index 7f37817eb..deff6b53c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -117,7 +117,7 @@ deprecation shims or second spellings. ## Package map and layer order -`tests/invariants/test_layering.py` enforces this order, lowest first. A +The order runs lowest first. A module may import its own layer or any lower one. Every import counts: module-level, function-level and `if TYPE_CHECKING:`. A lower layer that needs a higher object types it against a narrow `Protocol` of its own @@ -306,9 +306,8 @@ use the autospec `tests.support.server.mock_session`. GPU tests (`gpu` marker) load `HuggingFaceTB/SmolLM2-360M-Instruct` unless `DROWSE_TEST_MODEL` names another; `gpu_gemma` needs gated Gemma access. Every bug fix lands with a regression test. A test never touches the real home (an autouse fixture sets a -scratch `DROWSE_HOME`). CI runs ruff, pyright, the types `--check`, the CPU -suite on Python 3.11–3.13, the package-isolation build and the webui build -with a dist-drift check. Details: `tests/AGENTS.md`. +scratch `DROWSE_HOME`). CI runs ruff, pyright, the CPU suite on Python 3.13 +(`-n auto`) and the webui build with a dist-drift check. Details: `tests/AGENTS.md`. ## State and environment diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index a45835650..f1b8b5505 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -72,7 +72,7 @@ next steered forward. | Conversation tree, recipes, replay, transcripts | `loom/` | | Protocols, dashboard mount, CLI | `server/`, `web/`, `cli/` | -Dependencies point downward only (`tests/invariants/test_layering.py`). A +Dependencies point downward only. A collaborator gets its dependencies through its constructor; nothing outside `drowse/session/` reads a session private. That rule is what lets each collaborator be tested without a session and lets the browser runtime replace diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 81abf3f46..5530447f3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -28,7 +28,7 @@ pytest -q tests/gpu/test_smoke.py # real-model smoke tests ``` Tests marked `gpu` need CUDA or Apple Silicon MPS and may download model weights. -CI runs the non-GPU suite on Python 3.11, 3.12, and 3.13. +CI runs the non-GPU suite on Python 3.13. ## Static checks diff --git a/drowse/cli/AGENTS.md b/drowse/cli/AGENTS.md index e85ef8493..a6246bedf 100644 --- a/drowse/cli/AGENTS.md +++ b/drowse/cli/AGENTS.md @@ -4,7 +4,7 @@ The `drowse` console script (`drowse.cli.main:main`; `python -m drowse` is the same entry point): nine verb groups — `serve`, `manifold`, `pack`, `config`, `experiment`, `template`, `lens`, `sae`, `unit` — as thin frontends over `DrowseSession` and the `store`/`fit`/`lens`/`sae`/`units` packages. The CLI is in the top layer beside -`server`, `web` and `notebook` (`tests/invariants/test_layering.py`): it may +`server`, `web` and `notebook`: it may import any lower layer and never reaches a session private (`session._x`). ## Module map diff --git a/drowse/fit/AGENTS.md b/drowse/fit/AGENTS.md index 4ee80dd5d..41f65ff1c 100644 --- a/drowse/fit/AGENTS.md +++ b/drowse/fit/AGENTS.md @@ -4,7 +4,7 @@ Corpora in, fitted manifold tensors out: the one extraction pipeline, corpus generation, the neutral-activation capture behind the whitener, the offline bake, cross-model alignment orchestration, and the naturalness eval. `fit` sits above `generation` and below `loom`/`session` -(`tests/invariants/test_layering.py`), so it never imports the session; hosts +so it never imports the session; hosts are Protocols (`ModelHandle`, `NaturalnessHost`) that `DrowseSession` satisfies. The math it calls is in `geometry/AGENTS.md` (Fisher basis, frame, share, discover coords, topology, RBF, σ-field, fold). The folder format, diff --git a/drowse/generation/AGENTS.md b/drowse/generation/AGENTS.md index a44971912..999558ee0 100644 --- a/drowse/generation/AGENTS.md +++ b/drowse/generation/AGENTS.md @@ -8,8 +8,8 @@ routing, the transaction) is `session/engine.py`; the multi-row runner is `session/batch.py` (see `drowse/session/AGENTS.md`). Layer: above `readout`, `lens`, `sae`, `steering.composer`/`hooks`; below `fit`, -`loom`, `session`. `tests/invariants/test_layering.py` counts `TYPE_CHECKING` -imports too, so collaborators here type against a narrow `Protocol` +`loom`, `session`. `TYPE_CHECKING` +imports count too, so collaborators here type against a narrow `Protocol` (`render.ConversationHistory`) or a lower-layer class. | Module | Owns | diff --git a/drowse/geometry/AGENTS.md b/drowse/geometry/AGENTS.md index 2f6b4d980..d5c17af75 100644 --- a/drowse/geometry/AGENTS.md +++ b/drowse/geometry/AGENTS.md @@ -7,7 +7,7 @@ whitener, the direction view (fold, `Profile`, GGUF) and cross-model transfer. Pure tensor math. Geometry sits directly above `errors`/`naming`/`events` and `base` in the layer order and imports nothing else from drowse -(`tests/invariants/test_layering.py`; `drowse.__version__` is exempt). The +(`drowse.__version__` is exempt). The manifold tensor codec is `store/manifold_tensors.py`; capture and fit orchestration are `fit/` (`pipeline.py`, `node_stats.py`, `capture.py::compute_dls_axes`); the gains that scale the kernel are in diff --git a/drowse/lens/AGENTS.md b/drowse/lens/AGENTS.md index a45b1ed34..1136383aa 100644 --- a/drowse/lens/AGENTS.md +++ b/drowse/lens/AGENTS.md @@ -13,7 +13,7 @@ atom `K_l ẽ_v` and the `tokens-in` decomposition. `read.py` reads any vector at a place through both, by the four-lens rule. Inside a session the face is `session.lens` (`LensInstrument`). The package sits beside `sae`, above `readout` and below `steering.composer`, and imports only `errors`, `base`, `model` and `readout` -(`tests/invariants/test_layering.py`, which counts `TYPE_CHECKING` imports). +(`TYPE_CHECKING` imports included). | Module | Owns | |---|---| diff --git a/drowse/loom/AGENTS.md b/drowse/loom/AGENTS.md index c4d100ae3..c9bae4b08 100644 --- a/drowse/loom/AGENTS.md +++ b/drowse/loom/AGENTS.md @@ -28,8 +28,8 @@ recipes; the verbs, `restore_tree`, the joint-logprob cache), ## Layering -`loom` sits above `fit` and below `session`. `tests/invariants/test_layering.py` -enforces this, `TYPE_CHECKING` imports included. Lower layers never import +`loom` sits above `fit` and below `session`. `TYPE_CHECKING` +imports count too. Lower layers never import loom: `generation/render.py` types the tree as `ConversationHistory`, and `generation/finalize.py` takes `tree: Any`. diff --git a/drowse/model/AGENTS.md b/drowse/model/AGENTS.md index 06656ef4c..64d657d59 100644 --- a/drowse/model/AGENTS.md +++ b/drowse/model/AGENTS.md @@ -5,7 +5,7 @@ loading and identity, where the residual blocks are and what they compute (`BlockSpec`), unit directions, the logits path, layer selectors, token spans, chat-template facts, role substitution, the scene stitcher, and compile/StaticCache plumbing. The package imports only `errors`/`naming`/`events`, `base` and `geometry` -(`tests/invariants/test_layering.py`) and holds no session state: every +and holds no session state: every function takes a model, tokenizer or config. Render *policy* (which path a conversation takes) lives in `generation/decode.py::build_chat_input` and `generation/render.py`; this diff --git a/drowse/readout/AGENTS.md b/drowse/readout/AGENTS.md index 7e4959707..c5b1e246e 100644 --- a/drowse/readout/AGENTS.md +++ b/drowse/readout/AGENTS.md @@ -35,7 +35,7 @@ other three families (`drowse/units/AGENTS.md`); ## Layering `readout` sits above `steering.{grammar,terms,triggers}` and below -`lens`/`sae`/`units` (`tests/invariants/test_layering.py`; `TYPE_CHECKING` +`lens`/`sae`/`units` (`TYPE_CHECKING` imports count). It must not import `lens`, `sae`, `units`, `steering.composer`/`hooks`, `generation` or anything above; collaborators from those layers are typed against the diff --git a/drowse/sae/AGENTS.md b/drowse/sae/AGENTS.md index 0ba0e930a..1deb70277 100644 --- a/drowse/sae/AGENTS.md +++ b/drowse/sae/AGENTS.md @@ -9,7 +9,7 @@ local training, and the `sae` read instrument (`session.sae`, `sae` shares a layer with `lens` (above `readout`, below `steering.composer`): it never imports `generation`, `fit`, `loom` or `session`, type-only imports -included (`tests/invariants/test_layering.py`). `DrowseSession.__init__` wires +included. `DrowseSession.__init__` wires every dependency explicitly. | Module | Owns | diff --git a/drowse/server/AGENTS.md b/drowse/server/AGENTS.md index efd1d90dd..4a40b07fc 100644 --- a/drowse/server/AGENTS.md +++ b/drowse/server/AGENTS.md @@ -30,9 +30,8 @@ an API path. ## Rules for editing - `server/` is in the top layer with `cli`, `web`, `notebook`. It never reads - a session-private attribute (`tests/invariants/test_layering.py`), and no - server module imports a sibling's `_private` helper - (`tests/invariants/test_boundary_guard.py`); shared helpers go in + a session-private attribute, and no server module imports a sibling's + `_private` helper; shared helpers go in `runtime.py`, `errors.py`, `operations.py`, or `request_helpers.py`. - Native request bodies subclass `NativeRequest` (`extra="forbid"`), nested objects included. Ollama bodies are `extra="allow"` (clients send fields diff --git a/drowse/session/AGENTS.md b/drowse/session/AGENTS.md index 35d6f0e63..9ea876e27 100644 --- a/drowse/session/AGENTS.md +++ b/drowse/session/AGENTS.md @@ -9,7 +9,7 @@ collaborator with explicit constructor dependencies, not into session state. Outside this package nothing reads a session private (`session._x`, `sess._x`, `.session._x`, `self._session._x`); modules inside it may (the engine calls `_start_loom_assistant`, `_plan_authored_prompt_capture`, `_steering_composer`). -`tests/invariants/test_layering.py` enforces this and pins `session` directly +`session` sits directly below `cli`/`server`/`web`/`notebook`. Two collaborators take the session as a Protocol-typed handle, so keep those properties stable: `Extraction` (`fit.pipeline.ModelHandle`: `model`, `tokenizer`, `model_id`, `device`, diff --git a/drowse/steering/AGENTS.md b/drowse/steering/AGENTS.md index 7570f5427..7341204d1 100644 --- a/drowse/steering/AGENTS.md +++ b/drowse/steering/AGENTS.md @@ -15,7 +15,7 @@ injected, where, when, and how strongly. | `composer.py` | `SteeringComposer` (stack, scopes, resolution, composition, plan cache, stack queries, `term_layers`), `SteeringScope`, `TermLayers`, `resolve_scope_role`, `select_term_layers`, `affine_manifold_push`, the `ArtifactSource` / `NeutralSource` protocols | | `hooks.py` | `SteeringPlan`, `_lower_affine_subspaces`, `AffineOp`, `SteeringHook`, `UnitTerm` / `UnitOp` / `UnitHook` (unit ablation), `SteeringManager`, `install_persistent_offset_hooks`, the gain constants | -Layering (`tests/invariants/test_layering.py`): `grammar`/`terms`/`triggers` +Layering: `grammar`/`terms`/`triggers` sit above `store` (parsing resolves bare atoms against installed manifolds via `store.selectors`) and below `readout`; `composer`/`hooks` sit above `lens`/`sae` and below `generation`. Lower layers that must refuse work under diff --git a/drowse/store/AGENTS.md b/drowse/store/AGENTS.md index b051f9aee..274143f95 100644 --- a/drowse/store/AGENTS.md +++ b/drowse/store/AGENTS.md @@ -7,8 +7,8 @@ alignment caches. Layer: above `model`, below `steering.grammar` (`parse_expr` resolves bare atoms through `store.selectors`). Store imports only `errors`/`naming`/`events`, `base`, -`geometry`, `model` and itself; `tests/invariants/test_layering.py` counts -function-level and `TYPE_CHECKING` imports too. No barrel module: import from the +`geometry`, `model` and itself; function-level +and `TYPE_CHECKING` imports count too. No barrel module: import from the submodule. This package owns formats and folder primitives; the fit's folder transactions (authoring snapshot, compare-and-swap publish) are `fit/publish.py`, the weight-free fit no-op proof `fit/preflight.py`, the capture cache diff --git a/drowse/units/AGENTS.md b/drowse/units/AGENTS.md index 67ea202dc..e078e473d 100644 --- a/drowse/units/AGENTS.md +++ b/drowse/units/AGENTS.md @@ -17,7 +17,7 @@ with the other steering hooks in `steering/hooks.py`. | `reads.py` | `UnitReader` (static reads, `top`, `find`, `stats`), the results (`NeuronRead`/`NeuronPoleRead`, `HeadRead`, `PlaceRead`/`UnitHalf`, `Firing`, `TopResult`/`TopHit`, `FindResult`), `ContrastCache`/`ContrastStats`, `own_token_z`, `copy_score`, `logit_moments`, `token_logits` | | `catalog.py` | the neuron-lens catalog: `CATALOG_REPO`/`CATALOG_REVISION`/`CATALOG_MODELS`, `fetch_catalog`, `catalog_status`, `list_catalogs`, `remove_catalog`, `UnitCatalog` (rows, labels, `url`), `CatalogBinding` (the session's view), `unit_url`, `LABEL_CREDIT`, the errors | -Layer 7, beside `lens` and `sae` (`tests/invariants/test_layering.py`): it +Layer 7, beside `lens` and `sae`: it imports `model`, `store`, `readout`, `lens` and below, never `steering.composer`, `generation` or the session. The composer (layer 8) reaches it through its `UnitSource` protocol; the session wires everything diff --git a/drowse/web/AGENTS.md b/drowse/web/AGENTS.md index a77315ce9..e956754c9 100644 --- a/drowse/web/AGENTS.md +++ b/drowse/web/AGENTS.md @@ -77,29 +77,9 @@ npm run build # vite build into ../drowse/web/dist + check-build-isolatio - The SAE description indexes in `lib/data/sae-descriptions/` ship on purpose (as `assets/gemma-3-{270m,1b,4b}-it.json`): they label local SAELens gemma-scope-2 releases offline. Regenerate them with `node scripts/build-sae-descriptions.mjs`. -- CI's `build` job seeds a stale `build/lib` cache, then runs - `scripts/check_package_isolation.py check`. The wheel's dashboard files must equal the - sdist's and the source tree's. - `npm run check` runs `svelte-check`, `check-theme.mjs` (every referenced CSS custom property must be declared, here or in `@stdlogits/ledger`, so an old token name fails - here), `check-ledger.mjs` (the Ledger ratchet, below), `check-runtime-boundary.mjs`, the - `test:*` node suites and `check-test-coverage.mjs`. The last requires every - `scripts/*.test.mjs` to be reachable from `check`, so a new test file needs a line in - `package.json`. -- `check-ledger.mjs` counts, per file, the departures from Ledger: borders; outlines outside - a `:focus`/`:focus-visible` rule; shadows other than a marked token's 2px same-colour spread - (the tip's `--float` is Ledger's own, so any use here counts); monospace; capitals; - letter-spaced text; easing off the one curve; animations that loop; colours outside the - tokens; colour mixes outside oklch (the accent's) and oklab (a grey's: Chrome renders a - near-grey mixed in oklch with a red cast); and text coloured by the depth accent (`--tint`, - `--deep`, `--late`, or a component's own accent property). It reads `style:prop={…}` - directives as `prop: …` declarations. It fails if any count differs from - `webui/ledger-ratchet.json`, which holds every count at zero: the bridge that once mapped - the old token names onto Ledger is gone, and with it the ratchet's bridge category. A count - that rises fails; one that falls asks for `node scripts/check-ledger.mjs --write`, which - lowers the ratchet and never raises it. A category the ratchet does not record yet fails - the check until `--write` takes today's count as its first ceiling. `--cut` (the 6.0 cut) - requires every count at zero. + here), `check-runtime-boundary.mjs` and the `test:*` node suites. ## Generated REST types (`lib/types.gen.ts`) @@ -674,7 +654,7 @@ token). the theme preflight), the 404 for a missing asset or any file the bundle lacks, the SPA fallback, traversal, a public shell under an API key, `web=False`, and an empty dist. `tests/invariants/test_webui_types_parity.py` -gates the generated types, and `test_layering` places `web` in the top layer. +gates the generated types. ## Deliberately absent diff --git a/scripts/check_package_isolation.py b/scripts/check_package_isolation.py deleted file mode 100644 index 35c06e9c4..000000000 --- a/scripts/check_package_isolation.py +++ /dev/null @@ -1,109 +0,0 @@ -from __future__ import annotations - -import argparse -import hashlib -import tarfile -import zipfile -from pathlib import Path, PurePosixPath - -ROOT = Path(__file__).resolve().parents[1] -SENTINEL = PurePosixPath("assets/stale-dashboard-sentinel.js") - - -def _dashboard_path(member: str) -> PurePosixPath | None: - parts = PurePosixPath(member).parts - for index in range(len(parts) - 2): - if parts[index : index + 3] == ("drowse", "web", "dist"): - relative = parts[index + 3 :] - return PurePosixPath(*relative) if relative else None - return None - - -def _wheel_dashboard_files(path: Path) -> dict[PurePosixPath, str]: - with zipfile.ZipFile(path) as archive: - return { - relative: hashlib.sha256(archive.read(member)).hexdigest() - for member in archive.infolist() - if not member.is_dir() and (relative := _dashboard_path(member.filename)) is not None - } - - -def _sdist_dashboard_files(path: Path) -> dict[PurePosixPath, str]: - with tarfile.open(path) as archive: - files: dict[PurePosixPath, str] = {} - for member in archive.getmembers(): - relative = _dashboard_path(member.name) - if not member.isfile() or relative is None: - continue - extracted = archive.extractfile(member) - if extracted is None: - raise SystemExit(f"could not read sdist member {member.name}") - files[relative] = hashlib.sha256(extracted.read()).hexdigest() - return files - - -def _one_archive(dist_dir: Path, pattern: str) -> Path: - matches = sorted(dist_dir.glob(pattern)) - if len(matches) != 1: - raise SystemExit(f"expected one {pattern} archive in {dist_dir}, found {len(matches)}") - return matches[0] - - -def seed() -> None: - dashboard_cache = ROOT / "build/lib/drowse/web/dist" - sentinel = dashboard_cache / Path(SENTINEL) - sentinel.parent.mkdir(parents=True, exist_ok=True) - sentinel.write_text("stale build cache sentinel\n", encoding="utf-8") - (dashboard_cache / "index.html").write_text("stale cached dashboard\n", encoding="utf-8") - - -def check(dist_dir: Path) -> None: - wheel_files = _wheel_dashboard_files(_one_archive(dist_dir, "*.whl")) - sdist_files = _sdist_dashboard_files(_one_archive(dist_dir, "*.tar.gz")) - source_root = ROOT / "drowse/web/dist" - source_files = { - PurePosixPath(path.relative_to(source_root).as_posix()) - for path in source_root.rglob("*") if path.is_file() - } - - unexpected = sorted(wheel_files.keys() - sdist_files.keys()) - missing = sorted(sdist_files.keys() - wheel_files.keys()) - unpackaged = sorted(source_files - wheel_files.keys()) - content_mismatch = sorted(path for path in wheel_files.keys() & sdist_files.keys() if wheel_files[path] != sdist_files[path]) - absent_from_source = sorted(path for path in wheel_files if not (source_root / Path(path)).is_file()) - source_mismatch = sorted( - path - for path, digest in wheel_files.items() - if (source := source_root / Path(path)).is_file() and hashlib.sha256(source.read_bytes()).hexdigest() != digest - ) - if unexpected or missing or unpackaged or content_mismatch or absent_from_source or source_mismatch or SENTINEL in wheel_files: - details = [ - *(f"wheel-only dashboard file: {path}" for path in unexpected), - *(f"sdist-only dashboard file: {path}" for path in missing), - *(f"dashboard source file missing from wheel: {path}" for path in unpackaged), - *(f"wheel/sdist dashboard content mismatch: {path}" for path in content_mismatch), - *(f"dashboard file absent from source: {path}" for path in absent_from_source), - *(f"wheel/source dashboard content mismatch: {path}" for path in source_mismatch), - ] - raise SystemExit("package dashboard isolation failed\n" + "\n".join(details)) - - print(f"package dashboard isolation verified ({len(wheel_files)} files)") - - -def main() -> None: - parser = argparse.ArgumentParser() - subparsers = parser.add_subparsers(dest="command", required=True) - subparsers.add_parser("seed") - check_parser = subparsers.add_parser("check") - check_parser.add_argument("dist_dir", nargs="?", type=Path, default=Path("dist")) - args = parser.parse_args() - - if args.command == "seed": - seed() - else: - dist_dir = args.dist_dir if args.dist_dir.is_absolute() else ROOT / args.dist_dir - check(dist_dir) - - -if __name__ == "__main__": - main() diff --git a/tests/AGENTS.md b/tests/AGENTS.md index 9256b06c5..c2e844773 100644 --- a/tests/AGENTS.md +++ b/tests/AGENTS.md @@ -10,7 +10,7 @@ goldens the drowse.ai browser runtime replays. | Command | Runs | |---|---| -| `pytest -q -m "not gpu"` | The CPU suite. CI runs exactly this (plus informational `--cov`) on Python 3.11, 3.12 and 3.13. | +| `pytest -q -m "not gpu" -n auto` | The CPU suite, about 50 s on Apple Silicon. CI runs exactly this on Python 3.13. | | `pytest -q -n auto -m "not perf"` | Everything this machine can run, GPU included (about 80 s on Apple Silicon with MPS). | | `pytest -m perf -p no:xdist` | The four throughput gates, serially, on an otherwise idle machine. | | `pytest tests/gpu/test_smoke.py` | Real-model smoke tests only. | @@ -241,8 +241,6 @@ files from it, so their paths are part of its interface: | Test | Enforces | |---|---| -| `invariants/test_layering.py` | The package layer order (`LAYERS`; the order and its rationale are in the root `AGENTS.md`): no module under `drowse/` imports a higher layer. Module-level, function-level and `if TYPE_CHECKING:` imports all count; a lower layer types against a narrow `Protocol` of its own instead. Every module must fall in a layer, so a new subpackage needs a `LAYERS` entry. `ALLOWED` is empty, and `test_allowlist_is_current` fails on an entry whose import is gone. A second walk forbids `session._x`, `sess._x`, `.session._x` and `self._session._x` outside `drowse/session/`. | -| `invariants/test_boundary_guard.py` | No `drowse/server` module imports a sibling module's `_private` name (comments and strings are ignored). Promote a shared helper into `server/runtime.py` or the shared plumbing (`errors`, `operations`, `request_helpers`). | | `invariants/test_user_message.py` | The error family, over every non-underscore module under `drowse/`: each public exception class subclasses `DrowseError`, and the reparented ones keep their stdlib base; each `RequestError` subclass is a `ValueError` answering 400 with its own text. `_OVERRIDES` pins every overriding class's status; changing one is a user-visible contract change. | | `invariants/test_webui_types_parity.py` | `webui/src/lib/types.gen.ts` equals a fresh render (regenerate with `python scripts/generate_webui_types.py`); every native JSON route declares a response schema; native paths are kebab-case. | diff --git a/tests/invariants/test_boundary_guard.py b/tests/invariants/test_boundary_guard.py deleted file mode 100644 index fea1b7c95..000000000 --- a/tests/invariants/test_boundary_guard.py +++ /dev/null @@ -1,92 +0,0 @@ -"""Server import-surface guards. - -A source-grep check over ``drowse/server``: no server module imports a -sibling's private helper. (Session-private attribute reaches are an AST -invariant over the whole package in ``tests/invariants/test_layering.py``.) - -Comments and string literals are stripped via :mod:`tokenize` before the -match, so a historical note or a docstring example doesn't trip the guard — -only live code does. - -CPU-only: pure source-grep, no model load. -""" -from __future__ import annotations - -import io -import re -import tokenize -from pathlib import Path - - -def _drowse_root() -> Path: - return Path(__file__).resolve().parents[2] / "drowse" - - -def _strip_comments_and_strings(source: str) -> str: - """Return ``source`` with comment and string-literal tokens blanked. - - Keeps line/column structure so a flagged hit still reports a usable line - number. Blanking (rather than deleting) string tokens means a reach-in - that only ever appears inside a docstring or an f-string literal is *not* - a real reach-in and is correctly ignored. - """ - out_lines = source.splitlines() - # Rebuild as a mutable char grid so we can null out token spans in place. - grid = [list(line) for line in out_lines] - try: - tokens = tokenize.generate_tokens(io.StringIO(source).readline) - for tok in tokens: - if tok.type not in (tokenize.COMMENT, tokenize.STRING): - continue - (srow, scol), (erow, ecol) = tok.start, tok.end - for row in range(srow, erow + 1): - line = grid[row - 1] - lo = scol if row == srow else 0 - hi = ecol if row == erow else len(line) - for col in range(lo, min(hi, len(line))): - line[col] = " " - except (tokenize.TokenError, IndentationError): - # A file we can't tokenize shouldn't mask a reach-in: fall back to the - # raw source (the regex still runs; at worst a commented hit surfaces, - # which is a louder failure than a silent miss). - return source - return "\n".join("".join(line) for line in grid) - - -# --------------------------------------------------------------------------- -# Intra-server private-import guard -# --------------------------------------------------------------------------- - -_SERVER_PRIVATE_IMPORT_RE = re.compile( - r"(?:^|\s)from\s+drowse\.server[\w.]*\s+import\s+[^#\n]*?(? None: - """A route module must not reach into a sibling's underscore helper. - - Shared native-tree primitives belong in ``server/runtime.py`` - (``resolve_session_id``, ``refuse_if_busy``, the session lock) or the - shared plumbing modules (``errors`` / ``operations`` / - ``request_helpers``). Importing ``manifold_routes._refuse_if_busy`` from - ``profile_routes`` — the pattern this guard retires — made a guard that is - not manifold-specific look like manifold-route internals, and made either - module's private rename break the other. - """ - root = _drowse_root() - offenders: list[str] = [] - for path in sorted((root / "server").rglob("*.py")): - rel = path.relative_to(root).as_posix() - code = _strip_comments_and_strings(path.read_text(encoding="utf-8")) - for lineno, line in enumerate(code.splitlines(), start=1): - for m in _SERVER_PRIVATE_IMPORT_RE.finditer(line): - offenders.append( - f"{rel}:{lineno}: imports private {m.group(1)!r} " - "from another server module" - ) - assert not offenders, ( - "A server module imports a sibling module's private helper.\n" - "Promote the helper into server/runtime.py (or the relevant " - "shared plumbing module) instead.\n\n" + "\n".join(offenders) - ) diff --git a/tests/invariants/test_layering.py b/tests/invariants/test_layering.py deleted file mode 100644 index 5b9387fe1..000000000 --- a/tests/invariants/test_layering.py +++ /dev/null @@ -1,218 +0,0 @@ -"""The package layer order: no drowse module imports a higher layer. - -An AST walk over every module under ``drowse/``. Every import counts — module -level, function level and ``if TYPE_CHECKING:`` alike: a deferred import is -still a dependency, and a type-only one still couples the lower layer to the -higher one's shape (type such a parameter against a narrow ``Protocol`` at the -lower layer instead). - -Lower layers come first; a module may import its own layer or any lower one. - -A second walk keeps the session's collaborators honest: outside -``drowse/session/`` no code reaches a session private (``session._x`` / -``sess._x``, ``.session._x``, ``self._session._x``); collaborators and -frontends take explicit dependencies or the public API instead. -""" -from __future__ import annotations - -import ast -from pathlib import Path - -import pytest - -ROOT = Path(__file__).resolve().parents[2] -PACKAGE = ROOT / "drowse" - -#: Lowest first. An entry names a package (``geometry``) or a module inside -#: one (``steering.grammar``); the longest matching entry wins. -LAYERS: tuple[tuple[str, ...], ...] = ( - ("errors", "naming", "events"), - ("base",), - ("geometry",), - ("model",), - ("store",), - # Parsing an expression resolves bare atoms against installed manifolds - # (store.selectors), so the grammar sits above the store. - ("steering", "steering.grammar", "steering.terms", "steering.triggers"), - ("readout",), - ("lens", "sae", "units"), - ("steering.composer", "steering.hooks"), - ("generation",), - ("fit",), - ("loom",), - ("session",), - ("cli", "server", "web", "notebook"), -) -#: ``drowse/__init__.py`` (the public facade) and ``__main__`` sit on top. -TOP = len(LAYERS) - -#: Upward imports that cannot move yet: ``(importer, imported module) -> why``. -#: Every entry must still occur (see ``test_allowlist_is_current``), so moving -#: the code forces deleting its entry. -ALLOWED: dict[tuple[str, str], str] = {} - - -def _module_name(path: Path, root: Path = ROOT) -> str: - parts = list(path.relative_to(root).with_suffix("").parts) - if parts[-1] == "__init__": - parts.pop() - return ".".join(parts) - - -def _layer(module: str) -> int | None: - parts = module.split(".")[1:] - if not parts or parts == ["__main__"]: - return TOP - best: tuple[int, int] | None = None - for index, group in enumerate(LAYERS): - for entry in group: - key = entry.split(".") - if parts[: len(key)] == key and (best is None or len(key) > best[1]): - best = (index, len(key)) - return None if best is None else best[0] - - -def _imports( - path: Path, modules: set[str], root: Path = ROOT, -) -> list[tuple[int, str]]: - """``(line, imported drowse module)`` for every import in ``path``.""" - me = _module_name(path, root) - package = me.split(".") if path.name == "__init__.py" else me.split(".")[:-1] - found: list[tuple[int, str]] = [] - for node in ast.walk(ast.parse(path.read_text(), filename=str(path))): - if isinstance(node, ast.Import): - found.extend((node.lineno, alias.name) for alias in node.names) - elif isinstance(node, ast.ImportFrom): - if node.level: - base = package[: len(package) - (node.level - 1)] - source = ".".join(base + ([node.module] if node.module else [])) - else: - source = node.module or "" - for alias in node.names: - # The root's version constant: every submodule import has - # already initialized ``drowse/__init__``, and pyproject reads - # the version from there (one place to bump). - if source == "drowse" and alias.name == "__version__": - continue - submodule = f"{source}.{alias.name}" - found.append((node.lineno, submodule if submodule in modules else source)) - found.sort() - return [(line, target) for line, target in found if target.split(".")[0] == "drowse"] - - -def _is_private(attr: str) -> bool: - return attr.startswith("_") and not attr.startswith("__") - - -def _session_private_reaches(path: Path) -> list[tuple[int, str]]: - """``(line, source)`` for every ``session._x`` / ``sess._x`` / - ``.session._x`` / ``self._session._x`` attribute access in ``path``.""" - found: list[tuple[int, str]] = [] - for node in ast.walk(ast.parse(path.read_text(), filename=str(path))): - if not (isinstance(node, ast.Attribute) and _is_private(node.attr)): - continue - owner = node.value - if (isinstance(owner, ast.Name) and owner.id in ("session", "sess")) or ( - isinstance(owner, ast.Attribute) and owner.attr in ("session", "_session") - ): - found.append((node.lineno, ast.unparse(node))) - return found - - -def _scan() -> tuple[list[str], set[tuple[str, str]], list[str]]: - paths = sorted(PACKAGE.rglob("*.py")) - modules = {_module_name(path) for path in paths} - violations: list[str] = [] - allowed_seen: set[tuple[str, str]] = set() - unassigned: list[str] = [] - for path in paths: - me = _module_name(path) - mine = _layer(me) - if mine is None: - unassigned.append(me) - continue - for line, target in _imports(path, modules): - theirs = _layer(target) - if theirs is None or theirs <= mine: - continue - if (me, target) in ALLOWED: - allowed_seen.add((me, target)) - continue - violations.append( - f"{path.relative_to(ROOT)}:{line}: {me} (layer {mine}) imports " - f"{target} (layer {theirs})" - ) - return violations, allowed_seen, unassigned - - -def test_every_module_has_a_layer() -> None: - _, _, unassigned = _scan() - assert not unassigned, f"assign these modules a layer in LAYERS: {unassigned}" - - -def test_no_module_imports_a_higher_layer() -> None: - violations, _, _ = _scan() - assert not violations, "upward imports:\n" + "\n".join(violations) - - -def test_allowlist_is_current() -> None: - _, allowed_seen, _ = _scan() - stale = sorted(set(ALLOWED) - allowed_seen) - assert not stale, f"delete these ALLOWED entries, the imports are gone: {stale}" - - -@pytest.mark.parametrize(("source", "expected"), [ - ("from drowse.fit.capture import x\n", [(1, "drowse.fit.capture")]), - ("def f():\n from drowse.fit import capture\n", [(2, "drowse.fit.capture")]), - ("import drowse.loom.tree\n", [(1, "drowse.loom.tree")]), - ( - "from typing import TYPE_CHECKING\nif TYPE_CHECKING:\n from drowse.loom import tree\n", - [(3, "drowse.loom.tree")], - ), - ( - "import typing\nif typing.TYPE_CHECKING:\n pass\nelse:\n import drowse.loom.tree\n", - [(5, "drowse.loom.tree")], - ), - ("from drowse import __version__\n", []), -]) -def test_scanner_reads_every_import( - tmp_path: Path, source: str, expected: list[tuple[int, str]], -) -> None: - """The walk sees module-level, function-level and type-only imports.""" - module = tmp_path / "drowse" / "geometry" / "probe.py" - module.parent.mkdir(parents=True) - module.write_text(source) - modules = {"drowse.fit.capture", "drowse.loom.tree", "drowse.geometry.probe"} - assert _imports(module, modules, tmp_path) == expected - - -def test_no_session_private_reaches_outside_the_session_package() -> None: - """Collaborators take explicit dependencies; only ``drowse/session/`` - reads the session's private attributes.""" - session_pkg = PACKAGE / "session" - reaches = [ - f"{path.relative_to(ROOT)}:{line}: {source}" - for path in sorted(PACKAGE.rglob("*.py")) - if session_pkg not in path.parents - for line, source in _session_private_reaches(path) - ] - assert not reaches, "session-private reaches:\n" + "\n".join(reaches) - - -@pytest.mark.parametrize(("source", "expected"), [ - ("session._monitor.add(x)\n", [(1, "session._monitor")]), - ("self._session._engine\n", [(1, "self._session._engine")]), - ("app.state.session._read_plane\n", [(1, "app.state.session._read_plane")]), - ("sess._gen_lock.acquire()\n", [(1, "sess._gen_lock")]), - ("# session._monitor\nx = 'session._monitor'\n", []), - ("my_session._monitor\n", []), - ("self._session.monitor\n", []), - ("session.__class__\n", []), - ("other._monitor\n", []), -]) -def test_session_private_scanner( - tmp_path: Path, source: str, expected: list[tuple[int, str]], -) -> None: - module = tmp_path / "probe.py" - module.write_text(source) - assert _session_private_reaches(module) == expected diff --git a/webui/README.md b/webui/README.md index cd34234ab..380ea59a5 100644 --- a/webui/README.md +++ b/webui/README.md @@ -124,5 +124,4 @@ The dashboard is drawn in Ledger, the Standard Logits design language, from the washes, three inks, one accent, no lines. The theme is auto (the system's look), light or dark, behind one theme button. The accent is depth, the ramp from `--deep` to `--late`, and it only fills. Wix Madefor is the only face, with -tabular numbers. `src/lib/style/drowse.css` holds the shell's classes, and -`scripts/check-ledger.mjs` holds every departure from Ledger at zero. +tabular numbers. `src/lib/style/drowse.css` holds the shell's classes. diff --git a/webui/ledger-ratchet.json b/webui/ledger-ratchet.json deleted file mode 100644 index 0a93ebee4..000000000 --- a/webui/ledger-ratchet.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "about": "Ledger ratchet: per-file counts that may only go down (scripts/check-ledger.mjs). The 6.0 cut needs every count at zero.", - "categories": { - "border": "borders other than 0 or none", - "outline": "outlines other than a focus ring (outside :focus and :focus-visible rules)", - "shadow": "shadows other than a marked token's 2px same-colour spread (the tip's --float is Ledger's own)", - "mono": "monospace", - "uppercase": "text-transform: uppercase or small capitals", - "tracking": "letter-spaced text (positive letter-spacing)", - "easing": "easing other than Ledger's one curve", - "loop": "animations that loop (infinite)", - "colour": "colours written outside the tokens", - "mix": "colour mixes outside oklch and oklab (the accent mixes in oklch; a grey mixes in oklab, since Chrome gives a near-grey's powerless oklch hue a red cast)", - "tintText": "text coloured by the depth accent (--tint, --deep or --late)" - }, - "totals": { - "border": 0, - "outline": 0, - "shadow": 0, - "mono": 0, - "uppercase": 0, - "tracking": 0, - "easing": 0, - "loop": 0, - "colour": 0, - "mix": 0, - "tintText": 0 - }, - "files": {} -} diff --git a/webui/package.json b/webui/package.json index c66da512a..3f8341ff2 100644 --- a/webui/package.json +++ b/webui/package.json @@ -11,9 +11,8 @@ "dev": "vite", "build": "vite build && node ./scripts/check-build-isolation.mjs ../drowse/web/dist", "preview": "vite preview", - "check": "svelte-check --tsconfig ./tsconfig.json && node ./scripts/check-theme.mjs && node ./scripts/check-ledger.mjs && node ./scripts/check-runtime-boundary.mjs && npm run test:interface && npm run test:state && npm run test:lifecycles && npm run test:webmcp && node ./scripts/check-test-coverage.mjs", + "check": "svelte-check --tsconfig ./tsconfig.json && node ./scripts/check-theme.mjs && node ./scripts/check-runtime-boundary.mjs && npm run test:interface && npm run test:state && npm run test:lifecycles && npm run test:webmcp", "check:theme": "node ./scripts/check-theme.mjs", - "check:ledger": "node ./scripts/check-ledger.mjs", "test:interface": "node ./scripts/chart-ui.test.mjs && node ./scripts/focus-trap.test.mjs && node ./scripts/surface-geometry.test.mjs && node ./scripts/manifold-routing.test.mjs && node ./scripts/security-boundaries.test.mjs && node ./scripts/sae-descriptions.test.mjs && node ./scripts/form-primitives.test.mjs && node ./scripts/slider-drag.test.mjs && node ./scripts/modal-surfaces.test.mjs && node ./scripts/profile-analytics.test.mjs && node ./scripts/raw-colors.test.mjs && node ./scripts/workbench-layout.test.mjs && node ./scripts/social-card.test.mjs && node ./scripts/lens-room.test.mjs && node ./scripts/rooms.test.mjs", "test:state": "node ./scripts/loom-store.test.mjs && node ./scripts/loom-layout.test.mjs && node ./scripts/sampling-store.test.mjs && node ./scripts/steering-rack.test.mjs && node ./scripts/term-editor.test.mjs && node ./scripts/recipe-chips.test.mjs && node ./scripts/conversation-snapshot.test.mjs && node ./scripts/conversation-library.test.mjs && node ./scripts/chat-backup.test.mjs && node ./scripts/faithful-readouts.test.mjs && node ./scripts/units.test.mjs && node ./scripts/steer-actions.test.mjs && node ./scripts/token-cursor.test.mjs && node ./scripts/token-anatomy.test.mjs && node ./scripts/bootstrap-fetch.test.mjs && node ./scripts/stream-resync.test.mjs && node ./scripts/authored-rows.test.mjs", "test:lifecycles": "node ./scripts/frame-queue.test.mjs && node ./scripts/probe-lifecycle.test.mjs && node ./scripts/readout-lifecycle.test.mjs && node ./scripts/manifold-progress.test.mjs && node ./scripts/http-runtime-lifecycle.test.mjs", diff --git a/webui/scripts/check-ledger.mjs b/webui/scripts/check-ledger.mjs deleted file mode 100644 index f2b782dd2..000000000 --- a/webui/scripts/check-ledger.mjs +++ /dev/null @@ -1,266 +0,0 @@ -// The Ledger ratchet. Counts, per source file, the departures from Ledger (DESIGN.md in -// @stdlogits/ledger: flat -// and borderless, the one shadow under the tip (which lives in Ledger, not here), Wix Madefor -// only, no capitals, one curve, nothing looping, colours only from tokens and mixed in oklch, the -// depth accent only as a fill), and fails if any count rises above webui/ledger-ratchet.json. The -// committed ratchet must equal today's counts, and counts only go down: -// -// node scripts/check-ledger.mjs check (npm run check) -// node scripts/check-ledger.mjs --write lower the ratchet to today's counts (never raises one) -// node scripts/check-ledger.mjs --cut the 6.0 cut: every count zero -// -// A category the committed ratchet does not list yet is new: check fails until --write records -// it, and --write takes today's count as its first ceiling. After that it only goes down. -import { readdir, readFile, writeFile } from "node:fs/promises"; -import { dirname, extname, join, relative, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; - -const webui = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const src = join(webui, "src"); -const ratchetPath = join(webui, "ledger-ratchet.json"); -const mode = process.argv.includes("--write") ? "write" : process.argv.includes("--cut") ? "cut" : "check"; - -export const CATEGORIES = { - border: "borders other than 0 or none", - outline: "outlines other than a focus ring (outside :focus and :focus-visible rules)", - shadow: "shadows other than a marked token's 2px same-colour spread (the tip's --float is Ledger's own)", - mono: "monospace", - uppercase: "text-transform: uppercase or small capitals", - tracking: "letter-spaced text (positive letter-spacing)", - easing: "easing other than Ledger's one curve", - loop: "animations that loop (infinite)", - colour: "colours written outside the tokens", - mix: "colour mixes outside oklch and oklab (the accent mixes in oklch; a grey mixes in oklab, since Chrome gives a near-grey's powerless oklch hue a red cast)", - tintText: "text coloured by the depth accent (--tint, --deep or --late)", -}; - -const SKIP = new Set(["lib/types.gen.ts"]); -/** The depth accent's names. */ -const TINT_NAMES = new Set(["--tint", "--deep", "--late"]); -const SYSTEM_COLOURS = /\b(Canvas|CanvasText|Highlight|HighlightText|ButtonText|ButtonFace|ButtonBorder|GrayText|LinkText|Field|FieldText|Mark|MarkText)\b/; -const NAMED_COLOURS = /(?/g, " "); - if (ext !== ".css") out = out.replace(/(^|[^:"'`\\])\/\/[^\n]*/g, "$1"); - return out; -} - -/** The parts of a file that are CSS: a stylesheet, a component's