From 4f07afd4102d34a534e63c3921b20a8d5f0a041a Mon Sep 17 00:00:00 2001 From: Thai-Hoa Nguyen Date: Mon, 18 May 2026 03:32:48 -0400 Subject: [PATCH] Move linear layout app out of tensor-viz submodule --- .github/workflows/ARCHITECTURE.md | 8 +- .github/workflows/ci.yml | 23 +- .github/workflows/deploy-pages.yml | 28 +- .gitignore | 2 + .gitmodules | 4 - ARCHITECTURE.md | 8 +- CONTRIBUTING.md | 139 +- README.md | 17 +- docs/ARCHITECTURE.md | 4 +- docs/manual-site/ARCHITECTURE.md | 4 +- e2e/ARCHITECTURE.md | 12 + e2e/viewer-smoke.spec.ts | 63 + index.html | 13 + linear_layout_viz.py | 5 - package.json | 27 + playwright.config.ts | 14 + public/logo.webp | Bin 0 -> 818 bytes src/extensions/linear-layout/ARCHITECTURE.md | 14 + src/extensions/linear-layout/extension.ts | 680 ++++ .../linear-layout-multi-input.ts | 628 ++++ .../linear-layout/linear-layout-parser.ts | 262 ++ .../linear-layout-preset-model.ts | 972 +++++ .../linear-layout/linear-layout-state.ts | 974 +++++ .../linear-layout/linear-layout-ui.ts | 5 + .../linear-layout-viewer-sync.ts | 615 ++++ .../linear-layout/linear-layout-widgets.ts | 8 + .../linear-layout/linear-layout.test.ts | 1058 ++++++ src/extensions/linear-layout/linear-layout.ts | 3275 +++++++++++++++++ .../linear-layout/presets/ARCHITECTURE.md | 117 + .../linear-layout/presets/gpu-archs.ts | 30 + src/extensions/linear-layout/presets/index.ts | 82 + .../linear-layout/presets/ldmatrix.ts | 152 + src/extensions/linear-layout/presets/mma.ts | 1014 +++++ .../linear-layout/presets/stmatrix.ts | 42 + .../linear-layout/presets/swizzle.ts | 73 + src/extensions/linear-layout/presets/types.ts | 280 ++ src/extensions/linear-layout/presets/wgmma.ts | 170 + .../linear-layout/widgets/ARCHITECTURE.md | 19 + .../widgets/linear-layout-cell-text-widget.ts | 22 + .../widgets/linear-layout-color-widget.ts | 363 ++ .../widgets/linear-layout-editor-widgets.ts | 23 + .../widgets/linear-layout-preset-widget.ts | 728 ++++ .../widgets/linear-layout-specs-widget.ts | 347 ++ .../linear-layout-visible-tensors-widget.ts | 62 + .../widgets/linear-layout-widget-actions.ts | 145 + .../widgets/linear-layout-widget-shared.ts | 218 ++ src/main.ts | 5 + tensor-viz | 1 - tools/sync-linear-layout-examples.py | 188 + tsconfig.json | 23 + vite.config.ts | 12 + 51 files changed, 12847 insertions(+), 131 deletions(-) delete mode 100644 .gitmodules create mode 100644 e2e/ARCHITECTURE.md create mode 100644 e2e/viewer-smoke.spec.ts create mode 100644 index.html create mode 100644 package.json create mode 100644 playwright.config.ts create mode 100644 public/logo.webp create mode 100644 src/extensions/linear-layout/ARCHITECTURE.md create mode 100644 src/extensions/linear-layout/extension.ts create mode 100644 src/extensions/linear-layout/linear-layout-multi-input.ts create mode 100644 src/extensions/linear-layout/linear-layout-parser.ts create mode 100644 src/extensions/linear-layout/linear-layout-preset-model.ts create mode 100644 src/extensions/linear-layout/linear-layout-state.ts create mode 100644 src/extensions/linear-layout/linear-layout-ui.ts create mode 100644 src/extensions/linear-layout/linear-layout-viewer-sync.ts create mode 100644 src/extensions/linear-layout/linear-layout-widgets.ts create mode 100644 src/extensions/linear-layout/linear-layout.test.ts create mode 100644 src/extensions/linear-layout/linear-layout.ts create mode 100644 src/extensions/linear-layout/presets/ARCHITECTURE.md create mode 100644 src/extensions/linear-layout/presets/gpu-archs.ts create mode 100644 src/extensions/linear-layout/presets/index.ts create mode 100644 src/extensions/linear-layout/presets/ldmatrix.ts create mode 100644 src/extensions/linear-layout/presets/mma.ts create mode 100644 src/extensions/linear-layout/presets/stmatrix.ts create mode 100644 src/extensions/linear-layout/presets/swizzle.ts create mode 100644 src/extensions/linear-layout/presets/types.ts create mode 100644 src/extensions/linear-layout/presets/wgmma.ts create mode 100644 src/extensions/linear-layout/widgets/ARCHITECTURE.md create mode 100644 src/extensions/linear-layout/widgets/linear-layout-cell-text-widget.ts create mode 100644 src/extensions/linear-layout/widgets/linear-layout-color-widget.ts create mode 100644 src/extensions/linear-layout/widgets/linear-layout-editor-widgets.ts create mode 100644 src/extensions/linear-layout/widgets/linear-layout-preset-widget.ts create mode 100644 src/extensions/linear-layout/widgets/linear-layout-specs-widget.ts create mode 100644 src/extensions/linear-layout/widgets/linear-layout-visible-tensors-widget.ts create mode 100644 src/extensions/linear-layout/widgets/linear-layout-widget-actions.ts create mode 100644 src/extensions/linear-layout/widgets/linear-layout-widget-shared.ts create mode 100644 src/main.ts delete mode 160000 tensor-viz create mode 100644 tools/sync-linear-layout-examples.py create mode 100644 tsconfig.json create mode 100644 vite.config.ts diff --git a/.github/workflows/ARCHITECTURE.md b/.github/workflows/ARCHITECTURE.md index b274dcb..2db7aee 100644 --- a/.github/workflows/ARCHITECTURE.md +++ b/.github/workflows/ARCHITECTURE.md @@ -1,7 +1,7 @@ -The `.github/workflows/` directory contains automation for the LL-viz wrapper repository. +The `.github/workflows/` directory contains automation for the LL-viz app repository. -`ci.yml` validates the `tensor-viz/` submodule from a clean checkout. It installs the Python package, installs Node dependencies, installs Chromium for Playwright, then runs typecheck, tests, and build. This catches both TypeScript/Python unit failures and browser startup failures in the demo app. +`ci.yml` validates the root LL-viz frontend from a clean checkout. Until the new `@tensor-viz/*` npm packages are published, it checks out the tensor-viz extraction branch into a temporary directory, packs `@tensor-viz/viewer-core` and `@tensor-viz/viewer-demo`, installs those tarballs through npm, installs Chromium for Playwright, then runs typecheck, unit tests, browser e2e tests, and build. This catches linear-layout parser/model failures, package-boundary import failures, and browser startup regressions before release. -`deploy-pages.yml` builds the static viewer demo from the submodule and publishes `tensor-viz/packages/viewer-demo/dist` to GitHub Pages. It does not run the full test suite because deployment should consume a commit that CI has already validated. +`deploy-pages.yml` builds the root Vite app with the same temporary tensor-viz tarball install path and publishes `dist/` to GitHub Pages. It does not run the full test suite because deployment should consume a commit that CI has already validated. -Workflow changes should preserve the submodule checkout step. Without it, CI can pass repository setup while testing none of the viewer code. +Workflow changes should keep CI pointed at the root package. Tensor-viz is consumed through npm package artifacts, so bringing back submodule checkout would hide package-boundary failures that this repository is meant to catch. Once `@tensor-viz/viewer-core` and `@tensor-viz/viewer-demo` are published, replace the temporary checkout/pack steps with a plain `npm install`. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f20c3bb..e13b96c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,23 +8,28 @@ on: - linear-layout-website jobs: - tensor-viz: + linear-layout-viz: runs-on: ubuntu-latest - defaults: - run: - working-directory: tensor-viz + env: + TENSOR_VIZ_REF: ll-viz-extraction steps: + - uses: actions/checkout@v4 - uses: actions/checkout@v4 with: - submodules: true + repository: Deep-Learning-Profiling-Tools/tensor-viz + ref: ${{ env.TENSOR_VIZ_REF }} + path: .tensor-viz-source - uses: actions/setup-node@v4 with: node-version: 22 - - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - run: python -m pip install -e . - run: npm install + working-directory: .tensor-viz-source + - run: | + mkdir -p ../.tensor-viz-packs + npm pack --workspace @tensor-viz/viewer-core --pack-destination ../.tensor-viz-packs + npm pack --workspace @tensor-viz/viewer-demo --pack-destination ../.tensor-viz-packs + working-directory: .tensor-viz-source + - run: npm install --no-save .tensor-viz-packs/tensor-viz-viewer-core-0.1.0.tgz .tensor-viz-packs/tensor-viz-viewer-demo-0.1.0.tgz - run: npx playwright install --with-deps chromium - run: npm run typecheck - run: npm run test diff --git a/.github/workflows/deploy-pages.yml b/.github/workflows/deploy-pages.yml index 83e16b0..f18077c 100644 --- a/.github/workflows/deploy-pages.yml +++ b/.github/workflows/deploy-pages.yml @@ -18,11 +18,18 @@ concurrency: jobs: build: runs-on: ubuntu-latest + env: + TENSOR_VIZ_REF: ll-viz-extraction steps: - name: Checkout uses: actions/checkout@v5 + + - name: Checkout tensor-viz package source + uses: actions/checkout@v5 with: - submodules: recursive + repository: Deep-Learning-Profiling-Tools/tensor-viz + ref: ${{ env.TENSOR_VIZ_REF }} + path: .tensor-viz-source - name: Setup Node uses: actions/setup-node@v4 @@ -32,18 +39,27 @@ jobs: - name: Configure GitHub Pages uses: actions/configure-pages@v5 - - name: Install frontend dependencies + - name: Install tensor-viz package dependencies run: npm install - working-directory: tensor-viz + working-directory: .tensor-viz-source + + - name: Pack tensor-viz npm packages + run: | + mkdir -p ../.tensor-viz-packs + npm pack --workspace @tensor-viz/viewer-core --pack-destination ../.tensor-viz-packs + npm pack --workspace @tensor-viz/viewer-demo --pack-destination ../.tensor-viz-packs + working-directory: .tensor-viz-source + + - name: Install frontend dependencies + run: npm install --no-save .tensor-viz-packs/tensor-viz-viewer-core-0.1.0.tgz .tensor-viz-packs/tensor-viz-viewer-demo-0.1.0.tgz - name: Build static viewer demo - run: npm run build --workspace @tensor-viz/viewer-demo - working-directory: tensor-viz + run: npm run build - name: Upload Pages artifact uses: actions/upload-pages-artifact@v4 with: - path: tensor-viz/packages/viewer-demo/dist + path: dist deploy: runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index a4577b1..f26a201 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,6 @@ __pycache__/ node_modules/ dist/ build/ +test-results/ +playwright-report/ assets/*mp4 diff --git a/.gitmodules b/.gitmodules deleted file mode 100644 index cc0c989..0000000 --- a/.gitmodules +++ /dev/null @@ -1,4 +0,0 @@ -[submodule "tensor-viz"] - path = tensor-viz - url = https://github.com/Deep-Learning-Profiling-Tools/tensor-viz - branch = main diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 1704242..f11869e 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,9 +1,9 @@ -This repository is the LL-viz wrapper around the reusable `tensor-viz` viewer. +This repository is the LL-viz app built on the reusable `tensor-viz` viewer packages. The root owns the Triton linear-layout demo inputs and project documentation. `demo_linear_layout.py` defines the sample layouts that users can run locally. `linear_layout_viz.py` converts Triton `LinearLayout` objects into tensor-viz session data, including hardware/logical tensors, colors, markers, and linear-layout metadata. -The `tensor-viz/` submodule owns the viewer implementation. Its TypeScript packages render the UI, its Python package serves local sessions, and its linear-layout extension owns presets, widgets, parsing, and browser behavior. Root code should call into that package instead of duplicating viewer logic here. +The browser app lives at the root. `src/main.ts` imports `startDemoApp(...)` from `@tensor-viz/viewer-demo` and passes the local linear-layout extension factory. That package boundary is intentional: tensor-viz owns generic viewer rendering and shell behavior, while LL-viz owns GPU layout presets, compose-layout parsing, linear-layout widgets, and fallback tabs. -The root CI checks the submodule because that is where the app builds and tests. When the submodule pointer changes, CI installs Python and Node dependencies inside `tensor-viz/`, installs Chromium for Playwright, runs typecheck, runs tests, and builds packaged frontend assets. +The TypeScript extension lives in `src/extensions/linear-layout/`. `extension.ts` is the only file the tensor-viz shell sees; model files parse and evaluate layouts, preset files describe instruction families declaratively, and widget files render the sidebar controls. -Keep new root code narrowly focused on LL-viz examples or project-level docs. Runtime behavior, widget behavior, rendering, and preset data should normally be changed inside `tensor-viz/` with architecture notes next to that subsystem. +The root CI installs npm dependencies, runs typecheck, runs unit tests, runs Playwright against the real app, and builds `dist/` for Pages. Python helpers depend on the published `tensor-viz` Python package rather than a checked-out submodule. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 45065bd..6ee574c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,24 +1,35 @@ # Contributing -**NOTE: This project was purely built with coding agents, and any contributions will be purely reviewed by coding agents. As for now, only PRs that add/modify layout presets will be considered as the rest of the codebase is subject to change.** +**NOTE: This project was built with coding agents, and contributions are reviewed +with coding-agent assistance. For now, preset additions and fixes are the +expected external contribution path.** ## Setup -This repository wraps the `tensor-viz/` submodule, where the viewer code lives. +Install the root frontend dependencies and the published Python viewer package: ```bash -git submodule update --init --recursive -cd tensor-viz npm install npx playwright install chromium -git config core.hooksPath .githooks python -m venv .venv source .venv/bin/activate -pip install -e . +pip install tensor-viz npm run build ``` -To check that linear-layout-viz is installed correctly, run this in the project root directory after building tensor-viz above: +If the matching `@tensor-viz/viewer-core` and `@tensor-viz/viewer-demo` npm +packages have not been published yet, build local tarballs from the tensor-viz +extraction branch and install them with `npm install --no-save `. +The CI workflow does this automatically until those packages are available from +the registry. + +On Windows PowerShell, activate the environment with: + +```powershell +.venv\Scripts\Activate.ps1 +``` + +To check the Python demo, run: ```bash python demo_linear_layout.py @@ -26,101 +37,53 @@ python demo_linear_layout.py ## Testing Changes -Run the full test suite from `tensor-viz/`: +Run the full frontend suite before submitting a change: ```bash +npm run typecheck npm run test -``` - -Before submitting any code change, also run: - -```bash npm run build ``` -The build is required even if tests pass because it refreshes the built demo -assets served by the Python package. - Focused checks are useful while iterating: ```bash -npm run check:ts-docs -npm run check:ts-docs:staged -npm run test --workspace @tensor-viz/viewer-core -npm run test --workspace @tensor-viz/viewer-demo -PYTHONPATH=python/src python -m unittest discover -s python/tests -p 'test_*.py' +npm run test:unit +npm run test:e2e +npm run sync:linear-layout-examples ``` -The TypeScript docs check enforces JSDoc blocks on declarations, a minimum -comment-line density, and the helper-function rule from `AGENTS.md`: non-exported -top-level helpers should have at least three local references unless their JSDoc -marks them as an `@interfaceBoundary`. - -`npm run check:ts-docs:staged` is what the pre-commit hook runs; it audits each -staged TypeScript source file as a whole file. `npm run check:ts-docs` audits the -full TypeScript tree and may fail while existing files are still being brought up -to the documentation standard. For targeted cleanup, run -`node tools/check-ts-docs.mjs packages/viewer-demo/src/app-entry.ts` from -`tensor-viz/`. +`npm run sync:linear-layout-examples` rewrites the generated baked-example block +inside `src/extensions/linear-layout/linear-layout.ts` from +`demo_linear_layout.py`. Run it after changing the Python demo layouts. ## File Structure -- `README.md`: project overview, public website links, and top-level setup. -- `MANUAL.md`: user-facing viewer interaction guide. -- `linear_layout_viz.py`, `demo_linear_layout.py`: root-level linear layout demos. -- `assets/`: source media used by the README and project pages. -- `docs/manual-site/`, `docs/manual-vids/`, `docs/sample-svgs/`: generated or - curated documentation assets. -- `tensor-viz/`: submodule containing the Python package, TypeScript packages, - demo app, tests, docs, and build tooling. - -Inside `tensor-viz/`: - -- `packages/viewer-core/src/`: reusable viewer engine, layout math, session - model, rendering, and core tests. -- `packages/viewer-demo/src/`: the full linear-layout demo app and UI. -- `packages/viewer-demo/src/extensions/linear-layout/`: LL-viz extension, - parser/model code, preset definitions, widgets, and tests. -- `python/src/tensor_viz/`: Python package and local server. -- `python/tests/`: Python documentation and API tests. -- `docs/`: Sphinx and TypeDoc documentation sources. -- `tools/`: synchronization scripts for examples and built demo assets. +- `src/main.ts`: root Vite entrypoint that starts the tensor-viz shell with the + LL-viz extension factory. +- `src/extensions/linear-layout/`: parser/model code, preset definitions, + sidebar widgets, viewer synchronization, and unit tests. +- `linear_layout_viz.py`, `demo_linear_layout.py`: Python helpers and examples + that produce tensor-viz sessions for Triton `LinearLayout` objects. +- `e2e/`: Playwright smoke tests for the real browser app. +- `assets/`, `docs/`, `MANUAL.md`: website media, manual assets, and + user-facing docs. + +Tensor-viz itself is no longer vendored here. Generic viewer behavior belongs in +the `Deep-Learning-Profiling-Tools/tensor-viz` repository and should be consumed +through the published `@tensor-viz/*` npm packages. ## Architecture Guides -To add/modify features within the current system architecture, architecture docs live next to the code they describe: - -- [Linear layout demo app](./tensor-viz/packages/viewer-demo/src/ARCHITECTURE.md): - demo shell, extension registry, and app-level lifecycle hooks. -- [Root LL-viz wrapper](./ARCHITECTURE.md): root demo scripts, submodule - boundary, and CI/deploy ownership. -- [Tensor-viz monorepo](./tensor-viz/ARCHITECTURE.md): package boundaries, - build order, and test responsibilities. -- [Linear layout extension](./tensor-viz/packages/viewer-demo/src/extensions/linear-layout/ARCHITECTURE.md): - parsing, composition, runtime metadata, widgets, and generated Python. -- [Linear layout presets](./tensor-viz/packages/viewer-demo/src/extensions/linear-layout/presets/ARCHITECTURE.md): - adding NVIDIA, AMD, or other preset families. -- [Linear layout widgets](./tensor-viz/packages/viewer-demo/src/extensions/linear-layout/widgets/ARCHITECTURE.md): - sidebar UI ownership and shared widget code. -- [Viewer core](./tensor-viz/packages/viewer-core/src/ARCHITECTURE.md): - reusable layout, view, session, and rendering behavior. -- [Python package](./tensor-viz/python/src/tensor_viz/ARCHITECTURE.md): - Python API, session normalization, and local serving. -- [Root documentation assets](./docs/ARCHITECTURE.md) and - [tensor-viz package docs](./tensor-viz/docs/ARCHITECTURE.md): manual pages, - Sphinx docs, generated references, and demo asset synchronization. -- [Browser e2e tests](./tensor-viz/packages/viewer-demo/e2e/ARCHITECTURE.md): - Playwright smoke coverage for startup, widgets, command palette, and canvas - paint. - -## Documentation Standards - -For a given design change (e.g. adding/modifying a subsystem), update the relevant -`ARCHITECTURE.md` file(s) explaining the purpose of the subsystem, folder file -structure, what each file does, and instructions for workflows using your subsystem. -Keep user-facing behavior in `MANUAL.md` or `tensor-viz/docs/`, and keep this file -limited to setup, checks, review expectations, and pointers. - -When a change makes existing documentation misleading, update the docs in the -same change. Prefer one source of truth with links over copying the same -procedure into several files. +Architecture docs live next to the code they describe: + +- [Root LL-viz app](./ARCHITECTURE.md) +- [Linear layout extension](./src/extensions/linear-layout/ARCHITECTURE.md) +- [Linear layout presets](./src/extensions/linear-layout/presets/ARCHITECTURE.md) +- [Linear layout widgets](./src/extensions/linear-layout/widgets/ARCHITECTURE.md) +- [Root documentation assets](./docs/ARCHITECTURE.md) +- [Browser e2e tests](./e2e/ARCHITECTURE.md) + +When a change modifies a subsystem, update the relevant `ARCHITECTURE.md` in the +same change. Keep user-facing behavior in `MANUAL.md`; keep this file focused on +setup, checks, and review expectations. diff --git a/README.md b/README.md index 667151d..c86e398 100644 --- a/README.md +++ b/README.md @@ -13,23 +13,25 @@ See [docs/sample-svgs/](./docs/sample-svgs/README.md) for example exported SVGs. ## Structure -- `tensor-viz/`: git submodule pointing at `Deep-Learning-Profiling-Tools/tensor-viz` +- `src/extensions/linear-layout/`: LL-viz extension, parser/model code, presets, + widgets, and tests. +- `linear_layout_viz.py`, `demo_linear_layout.py`: Python helpers that generate + tensor-viz session data for Triton `LinearLayout` objects. +- `assets/`, `docs/`, `MANUAL.md`: website media and user-facing guides. ## Setup ```bash -git submodule update --init --recursive -cd tensor-viz npm install python -m venv .venv source .venv/bin/activate -pip install -e . +pip install tensor-viz npm run build ``` ### One-time GitHub setup -1. Push this repo, including the submodule pointer you want Pages to build. +1. Push this repo. 2. In GitHub, open `Settings -> Pages`. 3. Under `Build and deployment`, set `Source` to `GitHub Actions`. 4. Push to `main`, or run the `Deploy GitHub Pages` workflow manually from the Actions tab. @@ -37,12 +39,11 @@ npm run build ### Local preview of the same static site ```bash -cd tensor-viz npm install -npm run build --workspace @tensor-viz/viewer-demo +npm run build ``` -The built site is written to `tensor-viz/packages/viewer-demo/dist`. +The built site is written to `dist/`. ## Usage diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 6c5bb08..9caf7ea 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -4,4 +4,6 @@ The root `docs/` directory holds project-level documentation assets for the host The root `MANUAL.md` is the source of truth for user-facing viewer behavior in this repository. When behavior changes, update `MANUAL.md` first, then refresh or curate the assets that illustrate that behavior. Do not hand-edit generated manual output when the source document or sync script should own the change. -For package-level API documentation, use `tensor-viz/docs/ARCHITECTURE.md` instead. For demo assets copied into the package build, use the sync scripts under `tensor-viz/tools/` and finish with `npm run build` from `tensor-viz/`. +For package-level tensor-viz API documentation, use the upstream `tensor-viz` +repository. For LL-viz demo assets, keep source media in this docs tree and +finish with `npm run build` from the repository root. diff --git a/docs/manual-site/ARCHITECTURE.md b/docs/manual-site/ARCHITECTURE.md index 5fbad73..f30eafa 100644 --- a/docs/manual-site/ARCHITECTURE.md +++ b/docs/manual-site/ARCHITECTURE.md @@ -2,4 +2,6 @@ The `docs/manual-site/` directory contains the static manual page served by proj Treat this directory as generated or curated output, not as the primary design record. The root `MANUAL.md` should explain user-facing behavior first. Regenerate or refresh this site output only after the source manual text is correct. -Do not add runtime code here. The viewer implementation lives in `tensor-viz/`, and the project-level manual exists only to document how to use it. +Do not add runtime code here. Generic viewer implementation lives in the +upstream tensor-viz packages, LL-viz runtime code lives under `src/`, and the +project-level manual exists only to document how to use the app. diff --git a/e2e/ARCHITECTURE.md b/e2e/ARCHITECTURE.md new file mode 100644 index 0000000..92be45a --- /dev/null +++ b/e2e/ARCHITECTURE.md @@ -0,0 +1,12 @@ +The `e2e/` directory contains browser-level checks for the packaged LL-viz app. + +`viewer-smoke.spec.ts` starts the root Vite app through Playwright, verifies that +the tensor-viz shell boots, confirms the linear-layout extension widgets are +visible, checks that the viewport paints nonblank tensor content, and opens the +command palette. This catches the failures unit tests miss: missing package +exports, broken extension registration, CSS/asset mistakes, and renderer startup +problems. + +Keep e2e tests small. Parser, preset, and mapping behavior should be covered in +`src/extensions/linear-layout/linear-layout.test.ts`; Playwright should only +protect workflows that require a real browser. diff --git a/e2e/viewer-smoke.spec.ts b/e2e/viewer-smoke.spec.ts new file mode 100644 index 0000000..2def74d --- /dev/null +++ b/e2e/viewer-smoke.spec.ts @@ -0,0 +1,63 @@ +import { expect, test } from '@playwright/test'; +import { PNG } from 'pngjs'; + +test('viewer demo boots, paints tensors, and exposes core controls', async ({ page }) => { + const browserErrors: string[] = []; + const failedResponses: string[] = []; + const optionalStaticSession = /\/api\/session\.json$/; + const headlessShaderValidation = /^THREE\.THREE\.WebGLProgram: Shader Error/; + + page.on('pageerror', (error) => { + browserErrors.push(error.message); + }); + page.on('console', (message) => { + // headless chromium can emit three.js shader validation errors even when the 2d canvas paints + if (message.type() === 'error' && !headlessShaderValidation.test(message.text())) { + browserErrors.push(message.text()); + } + }); + page.on('response', (response) => { + // static demos intentionally probe for a live session manifest before falling back to baked tabs + if (response.status() >= 400 && !optionalStaticSession.test(response.url())) { + failedResponses.push(`${response.status()} ${response.url()}`); + } + }); + + await page.goto('/'); + + // startup + await expect(page.locator('.ribbon')).toBeVisible(); + await expect(page.locator('#viewport')).toBeVisible(); + expect(await page.locator('.tab-button').count()).toBeGreaterThan(0); + + // extension widgets + await expect(page.locator('#linear-layout-preset-widget')).toBeVisible(); + await expect(page.locator('#linear-layout-widget')).toBeVisible(); + await expect(page.locator('#tensor-view-widget')).toBeVisible(); + await expect(page.getByText('Preset', { exact: true })).toBeVisible(); + await expect(page.getByText('Layout Specs', { exact: true })).toBeVisible(); + + // viewport paint + await page.waitForFunction(() => ( + Array.from(document.querySelectorAll('#viewport canvas')) + .some((canvas) => canvas.width > 0 && canvas.height > 0) + )); + const viewportImage = PNG.sync.read(await page.locator('#viewport').screenshot()); + const colors = new Set(); + for (let index = 0; index < viewportImage.data.length; index += 16) { + const alpha = viewportImage.data[index + 3]; + if (alpha === 0) continue; + colors.add(`${viewportImage.data[index]},${viewportImage.data[index + 1]},${viewportImage.data[index + 2]}`); + if (colors.size > 16) break; + } + expect(colors.size).toBeGreaterThan(16); + + // command palette + await page.keyboard.press('?'); + await expect(page.locator('#command-palette')).toBeVisible(); + await page.locator('#command-palette-input').fill('display'); + await expect(page.getByText('Display as 2D', { exact: true })).toBeVisible(); + + expect(browserErrors).toEqual([]); + expect(failedResponses).toEqual([]); +}); diff --git a/index.html b/index.html new file mode 100644 index 0000000..e04adba --- /dev/null +++ b/index.html @@ -0,0 +1,13 @@ + + + + + + + Linear Layout Visualizer + + + +
+ + diff --git a/linear_layout_viz.py b/linear_layout_viz.py index 5ac0a9b..a550c1f 100644 --- a/linear_layout_viz.py +++ b/linear_layout_viz.py @@ -4,16 +4,11 @@ import colorsys import json -import sys from collections.abc import Mapping -from pathlib import Path from typing import Any import numpy as np -# use the checked-out submodule so the root demo works before tensor-viz is published -sys.path.insert(0, str(Path(__file__).resolve().parent / "tensor-viz" / "python" / "src")) - from tensor_viz import SessionData, ViewerSession, create_session_data, viz LayoutColorAxes = Mapping[str, str] diff --git a/package.json b/package.json new file mode 100644 index 0000000..ecc4a77 --- /dev/null +++ b/package.json @@ -0,0 +1,27 @@ +{ + "name": "linear-layout-viz", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build": "vite build", + "dev": "vite", + "sync:linear-layout-examples": "python tools/sync-linear-layout-examples.py", + "test": "npm run test:unit && npm run test:e2e", + "test:e2e": "playwright test", + "test:unit": "vitest run src", + "typecheck": "tsc --noEmit -p tsconfig.json" + }, + "dependencies": { + "@tensor-viz/viewer-core": "^0.1.0", + "@tensor-viz/viewer-demo": "^0.1.0" + }, + "devDependencies": { + "@playwright/test": "^1.60.0", + "@types/node": "^25.8.0", + "pngjs": "^7.0.0", + "typescript": "^5.9.3", + "vite": "^7.3.3", + "vitest": "^3.2.4" + } +} diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..17e3fc7 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + webServer: { + command: 'npm run dev -- --host 127.0.0.1', + url: 'http://127.0.0.1:5173', + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, + use: { + baseURL: 'http://127.0.0.1:5173', + }, +}); diff --git a/public/logo.webp b/public/logo.webp new file mode 100644 index 0000000000000000000000000000000000000000..8f16459061a05df5941775ec76456bb230eeba7f GIT binary patch literal 818 zcmaKqOK1~O7==$#LuyT^&@5C+GmB`U8n9ZVZlosK5)xsERg=_(Gm|E*WYRE`AU;44 zAKy3j>&sVInoZUsT`0b;u-+Db*S=t51$b0=cDXuQnjesli++{b@!JksCa z)dU>bACeEty@?h861(62BH^%HcJdd_a!%9DHGP1dFBSN@i5Qkqb_Avu)2b^5kqHA4|4)(J z#0fpa`9lXe2H4v%fCvsiW?5B~Fp3bz@A#aqBfO$Xdmo1C!T) zEm_Wew86QXY4}&S|K|LyA78G-%Yf{{4M&&6GVKYjzZj}iyg%V_s3~;DD(&7`|An*D zGapVq?OFHm@tut`>fGWvV0kGzv3&XC_NlGbqq)xL1NU#^^rU(BRI2)AVZQlz$Mw&% Q9VP$nXng)_axK^R1H`+fr2qf` literal 0 HcmV?d00001 diff --git a/src/extensions/linear-layout/ARCHITECTURE.md b/src/extensions/linear-layout/ARCHITECTURE.md new file mode 100644 index 0000000..bd0d7b0 --- /dev/null +++ b/src/extensions/linear-layout/ARCHITECTURE.md @@ -0,0 +1,14 @@ +The `extensions/linear-layout/` directory is the LL-viz feature package inside the tensor-viz demo shell. + +The extension exists so linear-layout work can grow without making the generic viewer app know about GPU instruction families, compose-layout syntax, propagated labels, or multi-input hover behavior. `extension.ts` is the only file the shell imports. It registers sidebar widgets, the Propagate Outputs toolbar control, tab/session lifecycle hooks, baked fallback tabs, hover popup behavior, tensor-view axis labels, multi-input sliders, inspector coordinate rows, and selection synchronization. + +`linear-layout.ts` is the center of the model. It owns compose-layout parsing, operation evaluation, preset normalization, matrix previews, generated Python, and metadata embedded into viewer tabs. If a change affects layout syntax, composition semantics, output labels, matrix blocks, or the session metadata emitted for a rendered layout, start there. + +`linear-layout-state.ts` bridges saved viewer tabs and browser storage back into live sidebar state. It should stay focused on cloning, validation, tab synchronization, and persistence. `linear-layout-viewer-sync.ts` bridges in the other direction: it translates current runtime metadata into viewer labels, colors, selection, hover popups, and multi-input display state. + +The two subdirectories keep contributor workflows local: + +- `presets/` contains instruction-family preset data and selector metadata. +- `widgets/` contains sidebar UI split by workflow. + +When changing parsing or composition, update `linear-layout.test.ts` with the smallest test that captures the behavior. Good tests here build a `ComposeRuntime`, inspect emitted metadata or generated Python, then assert the mapping behavior that would break in the UI. diff --git a/src/extensions/linear-layout/extension.ts b/src/extensions/linear-layout/extension.ts new file mode 100644 index 0000000..dfcbba8 --- /dev/null +++ b/src/extensions/linear-layout/extension.ts @@ -0,0 +1,680 @@ +import type { + LoadedBundleDocument, + TensorViewSnapshot, + ViewerSnapshot, +} from '@tensor-viz/viewer-core'; +import { controlIcons, escapeInfo, type AppShellWidgetSlot, type ControlSpec, type DemoAppExtension, type DemoExtensionContext, type DemoExtensionFactory, type DemoWidgetSpec, type LoadedSessionTab } from '@tensor-viz/viewer-demo/extension-api'; +import { + composeLayoutStateFromLegacySpec, + createComposeLayoutDocument, + isComposeLayoutMeta, + type ComposeLayoutMeta, +} from './linear-layout.js'; +import { + applyLinearLayoutCellText, + cloneLinearLayoutCellTextState, + cloneLinearLayoutMultiInputState, + cloneLinearLayoutState, + cloneLinearLayoutTensorViewsState, + composeLayoutMetaForTab, + defaultLinearLayoutCellTextState, + defaultLinearLayoutMultiInputState, + emptyLinearLayoutState, + inspectorCoordEntries, + isLinearLayoutCellTextState, + isLinearLayoutMultiInputState, + isLinearLayoutState, + isLinearLayoutTab, + linearLayoutHoverPopupEntries, + linearLayoutMultiInputModel, + linearLayoutSelectionMapForTab, + loadBakedLinearLayoutTabs, + loadLinearLayoutState, + preservedLinearLayoutTensorViews, + renderCellTextWidget, + renderLinearLayoutEditorWidgets, + renderLinearLayoutColorWidget, + renderLinearLayoutVisibleTensorsWidget, + renderLinearLayoutWidget, + renderLinearLayoutPresetWidget, + snapshotTensorViews, + syncLinearLayoutCellTextState, + syncLinearLayoutMultiInputState, + syncLinearLayoutSelection, + syncLinearLayoutSelectionPreview, + syncLinearLayoutState, + syncLinearLayoutViewFilters, + toggleLinearLayoutPropagateOutputs, + type LinearLayoutCellTextState, + type LinearLayoutFormState, + type LinearLayoutMultiInputState, + type LinearLayoutSelectionMap, + type LinearLayoutTensorViewsState, + type LinearLayoutUiContext, + type LinearLayoutUiState, +} from './linear-layout-ui.js'; +import { linearLayoutPropagateOutputsInfo } from './widgets/linear-layout-color-widget.js'; + +// extension.ts is the bridge between the generic demo shell and the +// linear-layout workflow. +// model files parse layouts and compute tensor data; widget files render forms. +// this file wires those pieces into the host extension lifecycle: widgets, +// tab creation, session load/save, hover/selection synchronization, and control +// dock commands. +// keep new linear-layout behavior data-driven in model/widget files whenever +// possible. Code added here should usually mean the host application needs a +// new lifecycle hook or a new connection between existing lifecycle hooks. +// tab-local maps below mirror the host tab ids because a single app session can +// switch between ordinary tensor tabs and compose-layout tabs without tearing +// down the extension. +// hover, selection, and tensor-view hooks all call back into state/viewer-sync +// modules so this lifecycle file does not duplicate mapping math. +// session load supports both current compose-layout snapshots and older +// linearLayoutSpec snapshots; remove neither path without a migration. +// widget ids must stay aligned with LINEAR_LAYOUT_WIDGET_SLOTS or the app shell +// cannot hand the extension real DOM hosts during startup. +// controls are optional host commands, while widgets are always declared here. +// use runtime.widgets for re-render loops so future widget additions do not +// need another hard-coded render list. + +/** + * Runtime contract returned by the linear-layout extension factory after it wires + * the demo shell hooks to the extension's UI state and DOM controls. + * + * The shell treats this as a DemoAppExtension while extension internals use the + * state, ui, and isTab members to synchronize saved tabs, sidebar widgets, hover + * popups, tensor labels, and selection behavior owned by the linear-layout feature. + * + * @example + * function syncIfLinearLayout(runtime: LinearLayoutExtensionRuntime, tab: LoadedBundleDocument | undefined) { + * if (!runtime.isTab(tab)) return false; + * + * runtime.ui.setStatus('Linear-layout tab selected.'); + * return true; + * } + */ +export type LinearLayoutExtensionRuntime = DemoAppExtension & { + state: LinearLayoutUiState; + ui: LinearLayoutUiContext; + isTab: (tab: LoadedBundleDocument | undefined) => boolean; +}; + +const LINEAR_LAYOUT_WIDGETS = [ + 'linear-layout-preset', + 'linear-layout', + 'linear-layout-visible-tensors', + 'linear-layout-color', + 'cell-text', +] as const; + +export const LINEAR_LAYOUT_WIDGET_SLOTS = [ + { id: 'linear-layout-preset', beforeHeader: true }, + { id: 'linear-layout', beforeHeader: true }, + { id: 'linear-layout-visible-tensors', beforeHeader: true }, + { id: 'linear-layout-color', beforeHeader: true }, + { id: 'cell-text', beforeHeader: true }, +] satisfies AppShellWidgetSlot[]; + +/** + * Look up a registered linear-layout sidebar widget element and fail fast when + * the demo shell did not provide that widget slot. + * + * @param ctx - Demo extension context whose `widgets` map is populated by the + * sidebar host with HTMLElement entries keyed by linear-layout widget id. + * @param widgetId - One of the `LINEAR_LAYOUT_WIDGETS` ids to retrieve from + * `ctx.widgets`. + * @returns The HTMLElement registered for `widgetId`, so callers can render or + * update that specific sidebar widget container. + * @throws Error when `ctx.widgets[widgetId]` is missing; the message is + * `Missing ${widgetId} widget.`. + * @example + * const presetElement = document.createElement('section'); + * const ctx = { widgets: { 'linear-layout-preset': presetElement } } as DemoExtensionContext; + * + * expect(requireWidget(ctx, 'linear-layout-preset')).toBe(presetElement); + * + * @example + * const ctx = { widgets: {} } as DemoExtensionContext; + * + * expect(() => requireWidget(ctx, 'linear-layout')).toThrow('Missing linear-layout widget.'); + */ +function requireWidget(ctx: DemoExtensionContext, widgetId: typeof LINEAR_LAYOUT_WIDGETS[number]): HTMLElement { + const widget = ctx.widgets[widgetId]; + if (!widget) throw new Error(`Missing ${widgetId} widget.`); + return widget; +} + +/** + * Return the inline SVG used as the sidebar icon for a linear-layout widget id. + * Unknown widget ids intentionally render no icon. + * + * @param widgetId - Widget id such as `linear-layout-preset`, + * `linear-layout-visible-tensors`, `linear-layout-color`, or `cell-text`. + * @returns SVG markup for the matching sidebar widget icon, or an empty string + * when the id is not one of the linear-layout icon cases. + * @noThrows The function only switches on the supplied string and returns string + * literals; unrecognized ids are handled by the default empty-string branch. + * @example + * expect(linearLayoutWidgetIcon('linear-layout-visible-tensors')).toContain(' + + + + `; + case 'linear-layout': + return ` + + + + + + + + + + `; + case 'linear-layout-visible-tensors': + return ` + + + + + `; + case 'linear-layout-color': + return ` + + + + + + + + + + + + T + + `; + case 'cell-text': + return ` + + + T:0 + + `; + default: + return ''; + } +} + +/** + * Build the sidebar widget specifications registered by the linear-layout + * extension, including labels, icons, collapse defaults, visibility predicate, + * and render callbacks for each widget panel. + * + * @param ui - Linear-layout UI context captured by the widget render callbacks + * so they can read and update the extension state when the sidebar host renders + * a panel. + * @returns Five DemoWidgetSpec entries for Preset, Linear Layout Specifications, + * Visible Tensors, Cell Color/Text, and Cell Text; the demo shell registers + * these specs to decide which panels are shown and which render function to call. + * @noThrows The function only assembles widget metadata and closures. It does not + * invoke the render callbacks or inspect the active tab while building the array. + * @example + * const widgets = linearLayoutWidgets(ui); + * + * expect(widgets.map((widget) => widget.id)).toEqual([ + * 'linear-layout-preset', + * 'linear-layout', + * 'linear-layout-visible-tensors', + * 'linear-layout-color', + * 'cell-text', + * ]); + * expect(widgets[0]?.defaultCollapsed).toBe(false); + * expect(widgets[2]?.defaultCollapsed).toBe(true); + */ +function linearLayoutWidgets(ui: LinearLayoutUiContext): DemoWidgetSpec[] { + /** + * Report whether the sidebar host should show linear-layout widgets for the + * currently selected tab. + * + * @param ctx - Demo extension context that supplies `getActiveTab()`, whose + * result is tested with `isLinearLayoutTab`. + * @returns `true` when the active tab contains linear-layout metadata; `false` + * when there is no active tab or the active tab belongs to another viewer flow. + * @noThrows A missing active tab is converted to `false`, and the predicate only + * performs a boolean check on the tab returned by the context. + * @example + * const linearCtx = { getActiveTab: () => linearLayoutTab } as DemoExtensionContext; + * const emptyCtx = { getActiveTab: () => null } as DemoExtensionContext; + * + * expect(active(linearCtx)).toBe(true); + * expect(active(emptyCtx)).toBe(false); + */ + const active = (ctx: DemoExtensionContext): boolean => { + const tab = ctx.getActiveTab(); + return Boolean(tab && isLinearLayoutTab(tab)); + }; + return [ + { + id: 'linear-layout-preset', + label: 'Preset', + icon: linearLayoutWidgetIcon('linear-layout-preset'), + defaultCollapsed: false, + visible: active, + render: () => { renderLinearLayoutPresetWidget(ui); }, + }, + { + id: 'linear-layout', + label: 'Linear Layout Specifications', + icon: linearLayoutWidgetIcon('linear-layout'), + defaultCollapsed: false, + visible: active, + render: () => { renderLinearLayoutWidget(ui); }, + }, + { + id: 'linear-layout-visible-tensors', + label: 'Visible Tensors', + icon: linearLayoutWidgetIcon('linear-layout-visible-tensors'), + defaultCollapsed: true, + visible: active, + render: () => { renderLinearLayoutVisibleTensorsWidget(ui); }, + }, + { + id: 'linear-layout-color', + label: 'Cell Color/Text', + icon: linearLayoutWidgetIcon('linear-layout-color'), + defaultCollapsed: true, + visible: active, + render: () => { renderLinearLayoutColorWidget(ui); }, + }, + { + id: 'cell-text', + label: 'Cell Text', + icon: linearLayoutWidgetIcon('cell-text'), + defaultCollapsed: true, + visible: active, + render: () => { renderCellTextWidget(ui); }, + }, + ]; +} + +/** + * Builds the linear-layout demo extension runtime, including sidebar widgets, tab hooks, hover-popup DOM, tensor-view sliders, inspector rows, and selection synchronization. + * + * @param ctx - Demo extension host context with the viewer instance, viewport element, widget lookup/title helpers, active-tab accessors, session-tab mutators, and tab-loading callback used by the linear-layout UI. + * @returns Runtime registered under the `linear-layout` id; the demo shell uses it to mount widgets, recognize linear-layout tabs, contribute tensor-view metadata, react to pointer/hover/selection events, and load baked fallback tabs. + * @throws Error when a required linear-layout widget slot such as `linear-layout-preset`, `linear-layout`, `linear-layout-visible-tensors`, `cell-text`, or `linear-layout-color` is absent from the supplied demo context. + * @example + * const viewport = document.createElement('div'); + * const ctx = makeDemoExtensionContextWithWidgets(viewport); + * const runtime = createLinearLayoutExtension(ctx); + * + * expect(runtime.id).toBe('linear-layout'); + * expect(runtime.widgets.length).toBeGreaterThan(0); + * expect(viewport.querySelector('.linear-layout-hover-popup.hidden')).not.toBeNull(); + * @example + * const ctx = makeDemoExtensionContextWithWidgets(document.createElement('div'), { + * omitWidget: 'linear-layout-color', + * }); + * + * expect(() => createLinearLayoutExtension(ctx)).toThrow(/linear-layout-color/); + */ +export function createLinearLayoutExtension(ctx: DemoExtensionContext): LinearLayoutExtensionRuntime { + const hoverPopup = document.createElement('div'); + hoverPopup.className = 'linear-layout-hover-popup hidden'; + ctx.viewport.appendChild(hoverPopup); + // popup placement is tracked in viewport-local pixels so scrolling the page + // does not move the popup away from the hovered canvas cell. + let hoverPopupPointer = { x: 16, y: 16 }; + let lastActiveTensorId: string | null = null; + const state: LinearLayoutUiState = { + linearLayoutState: loadLinearLayoutState(), + linearLayoutStates: new Map(), + linearLayoutCellTextState: defaultLinearLayoutCellTextState(), + linearLayoutCellTextStates: new Map(), + linearLayoutMultiInputState: defaultLinearLayoutMultiInputState(), + linearLayoutMultiInputStates: new Map(), + linearLayoutTensorViewsStates: new Map(), + linearLayoutSelectionMaps: new Map(), + linearLayoutNotice: null, + linearLayoutMatrixPreview: '', + showLinearLayoutMatrix: false, + syncingLinearLayoutSelection: false, + }; + const ui: LinearLayoutUiContext = { + viewer: ctx.viewer, + viewport: ctx.viewport, + linearLayoutPresetWidget: requireWidget(ctx, 'linear-layout-preset'), + linearLayoutWidget: requireWidget(ctx, 'linear-layout'), + linearLayoutVisibleTensorsWidget: requireWidget(ctx, 'linear-layout-visible-tensors'), + cellTextWidget: requireWidget(ctx, 'cell-text'), + linearLayoutColorWidget: requireWidget(ctx, 'linear-layout-color'), + state, + widgetTitle: ctx.widgetTitle, + getActiveTab: ctx.getActiveTab, + getActiveTabId: ctx.getActiveTabId, + getSessionTabs: ctx.getSessionTabs, + setSessionTabs: ctx.setSessionTabs, + loadTab: ctx.loadTab, + renderLinearLayoutEditorWidgets: () => { renderLinearLayoutEditorWidgets(ui); }, + }; + /** + * Rebuilds the linear-layout hover popup for the active tab by reading the viewer's live hovered cell and the tab's selection map, then showing matching input-cell labels and colors. + * + * @returns Void; callers observe the popup element becoming hidden with empty content when no linear-layout hover entries exist, or becoming visible with escaped input-cell rows when entries are available. + * @noThrows The normal path only reads the active tab, live hover, and selection map, then updates the already-created popup element; absent tabs or missing hover entries are handled by hiding the popup. + * @example + * viewer.setLiveHover({ tensorId: 'accumulator', coord: [0, 1] }); + * setActiveLinearLayoutTab(tabWithSelectionMapForInputCells(['a[0,1]', 'b[0,1]'])); + * + * renderHoverPopup(); + * + * expect(hoverPopup.classList.contains('hidden')).toBe(false); + * expect(hoverPopup.textContent).toContain('Input Cells'); + * expect(hoverPopup.textContent).toContain('a[0,1]'); + * @example + * viewer.setLiveHover(null); + * + * renderHoverPopup(); + * + * expect(hoverPopup.classList.contains('hidden')).toBe(true); + * expect(hoverPopup.innerHTML).toBe(''); + */ + const renderHoverPopup = (): void => { + const tab = ctx.getActiveTab(); + const linearLayoutTab = tab && isLinearLayoutTab(tab) ? tab : null; + const hover = ctx.viewer.getLiveHover(); + const selectionMap = linearLayoutTab ? linearLayoutSelectionMapForTab(ui, linearLayoutTab) : null; + const entries = linearLayoutHoverPopupEntries(ui, hover, selectionMap); + if (entries.length === 0) { + hoverPopup.classList.add('hidden'); + hoverPopup.innerHTML = ''; + return; + } + hoverPopup.innerHTML = ` +
Input Cells
+
${entries.map((entry) => ` +
+ + ${escapeInfo(entry.text).replace(/\n/g, '
')}
+
+ `).join('')}
+ `; + hoverPopup.classList.remove('hidden'); + placeHoverPopup(); + }; + /** + * Positions the visible hover popup near the last viewport-local pointer location while clamping it inside the viewport's bottom and right padding. + * + * @returns Void; callers observe `hoverPopup.style.left` and `hoverPopup.style.top` updated for visible popups, while hidden popups are left unchanged. + * @noThrows The routine only reads viewport/popup geometry and writes CSS pixel offsets; a hidden popup returns before any layout calculations are needed. + * @example + * mockViewportRect({ width: 200, height: 100 }); + * mockPopupSize(80, 40); + * hoverPopup.classList.remove('hidden'); + * hoverPopupPointer = { x: 190, y: 90 }; + * + * placeHoverPopup(); + * + * expect(hoverPopup.style.left).toBe('108px'); + * expect(hoverPopup.style.top).toBe('48px'); + * @example + * hoverPopup.classList.add('hidden'); + * hoverPopup.style.left = '24px'; + * + * placeHoverPopup(); + * + * expect(hoverPopup.style.left).toBe('24px'); + */ + const placeHoverPopup = (): void => { + if (hoverPopup.classList.contains('hidden')) return; + const rect = ctx.viewport.getBoundingClientRect(); + const width = hoverPopup.offsetWidth; + const height = hoverPopup.offsetHeight; + const maxLeft = Math.max(12, rect.width - width - 12); + const maxTop = Math.max(12, rect.height - height - 12); + hoverPopup.style.left = `${Math.min(maxLeft, hoverPopupPointer.x + 18)}px`; + hoverPopup.style.top = `${Math.min(maxTop, hoverPopupPointer.y + 18)}px`; + }; + const runtime: LinearLayoutExtensionRuntime = { + id: 'linear-layout', + widgets: linearLayoutWidgets(ui), + state, + ui, + isTab: (tab) => Boolean(tab && isLinearLayoutTab(tab)), + tensorView: (_tensorViewCtx, { tab, tensorId }) => { + if (!tab || !isLinearLayoutTab(tab)) return null; + const meta = composeLayoutMetaForTab(tab); + const selectionMap = linearLayoutSelectionMapForTab(ui, tab); + const multiInput = selectionMap ? linearLayoutMultiInputModel(ui, selectionMap) : null; + const axisLabels = meta?.tensors.find((tensor) => tensor.id === tensorId)?.axisLabels; + // multi-input sliders appear only for focused non-injective cells; + // the model returns null for ordinary one-root cells. + return { + axisLabels, + sliders: multiInput ? [{ + id: 'linear-layout-multi-input', + label: 'Multi-Input', + min: -1, + max: Math.max(0, multiInput.size - 1), + value: multiInput.value, + onChange: (value) => { + state.linearLayoutMultiInputState[multiInput.focusedTensorId] = value; + const activeTabId = ctx.getActiveTabId(); + if (activeTabId) state.linearLayoutMultiInputStates.set(activeTabId, cloneLinearLayoutMultiInputState(state.linearLayoutMultiInputState)); + syncLinearLayoutViewFilters(ui); + }, + }] : [], + }; + }, + afterTensorViewChange: () => { syncLinearLayoutViewFilters(ui); }, + inspectorCoords: (_inspectorCtx, { hover, hoveredStatus }) => { + const tab = ctx.getActiveTab(); + const linearLayoutTab = tab && isLinearLayoutTab(tab) ? tab : null; + if (!linearLayoutTab) return []; + return inspectorCoordEntries(ui, hover, hoveredStatus, linearLayoutSelectionMapForTab(ui, linearLayoutTab)); + }, + controls: (controlCtx, snapshot): ControlSpec[] => { + const tab = controlCtx.getActiveTab(); + const active = Boolean(tab && isLinearLayoutTab(tab)); + const injective = tab && isLinearLayoutTab(tab) + ? (composeLayoutMetaForTab(tab)?.injective ?? true) + : true; + return [{ + id: 'propagate-outputs', + label: 'Propagate Outputs', + description: active + ? linearLayoutPropagateOutputsInfo(injective) + : 'Propagate Outputs is available for linear-layout tabs.', + shortcut: 'N/A', + active: state.linearLayoutState.propagateOutputs, + disabled: !active, + content: controlIcons.propagateOutputs, + onClick: async () => { + await toggleLinearLayoutPropagateOutputs(ui); + }, + }]; + }, + createTab: (_tabCtx, id, title, snapshot) => { + state.linearLayoutState = emptyLinearLayoutState(); + const document = createComposeLayoutDocument(state.linearLayoutState, snapshot, title); + const meta = composeLayoutMetaForTab(document); + // new tabs snapshot the viewer immediately so later tensor-view + // edits can be restored when switching away and back. + state.linearLayoutCellTextState = defaultLinearLayoutCellTextState(meta?.rootInputLabels ?? []); + state.linearLayoutMultiInputState = defaultLinearLayoutMultiInputState(); + state.linearLayoutStates.set(id, cloneLinearLayoutState(state.linearLayoutState)); + state.linearLayoutCellTextStates.set(id, cloneLinearLayoutCellTextState(state.linearLayoutCellTextState)); + state.linearLayoutMultiInputStates.set(id, cloneLinearLayoutMultiInputState(state.linearLayoutMultiInputState)); + state.linearLayoutTensorViewsStates.set(id, snapshotTensorViews(document.manifest.viewer)); + return { ...document, id, title }; + }, + captureSnapshot: (_tabCtx, tab, snapshot) => { + if (!isLinearLayoutTab(tab)) return; + const extendedSnapshot = snapshot as ViewerSnapshot & { + composeLayoutMeta?: ComposeLayoutMeta; + composeLayoutState?: LinearLayoutFormState; + linearLayoutCellTextState?: LinearLayoutCellTextState; + linearLayoutMultiInputState?: LinearLayoutMultiInputState; + composeLayoutTensorViews?: LinearLayoutTensorViewsState; + }; + const cloned = cloneLinearLayoutState(state.linearLayoutState); + const clonedCellText = cloneLinearLayoutCellTextState(state.linearLayoutCellTextState); + const clonedMultiInput = cloneLinearLayoutMultiInputState(state.linearLayoutMultiInputState); + const tensorViews = preservedLinearLayoutTensorViews(ui, tab.id); + // write both tab-local caches and the serialized snapshot because a + // save can be followed by either in-session tab switching or reload. + state.linearLayoutStates.set(tab.id, cloned); + state.linearLayoutCellTextStates.set(tab.id, clonedCellText); + state.linearLayoutMultiInputStates.set(tab.id, clonedMultiInput); + state.linearLayoutTensorViewsStates.set(tab.id, tensorViews); + extendedSnapshot.composeLayoutState = cloned; + extendedSnapshot.linearLayoutCellTextState = clonedCellText; + extendedSnapshot.linearLayoutMultiInputState = clonedMultiInput; + extendedSnapshot.composeLayoutTensorViews = cloneLinearLayoutTensorViewsState(tensorViews); + const composeLayoutMeta = composeLayoutMetaForTab(tab); + if (composeLayoutMeta) extendedSnapshot.composeLayoutMeta = composeLayoutMeta; + }, + clearTab: (_tabCtx, tabId) => { + state.linearLayoutStates.delete(tabId); + state.linearLayoutCellTextStates.delete(tabId); + state.linearLayoutMultiInputStates.delete(tabId); + state.linearLayoutTensorViewsStates.delete(tabId); + state.linearLayoutSelectionMaps.delete(tabId); + }, + cloneTab: (_tabCtx, fromTabId, toTabId) => { + const linearLayoutState = state.linearLayoutStates.get(fromTabId); + if (linearLayoutState) state.linearLayoutStates.set(toTabId, cloneLinearLayoutState(linearLayoutState)); + const cellTextState = state.linearLayoutCellTextStates.get(fromTabId); + if (cellTextState) state.linearLayoutCellTextStates.set(toTabId, cloneLinearLayoutCellTextState(cellTextState)); + const multiInputState = state.linearLayoutMultiInputStates.get(fromTabId); + if (multiInputState) state.linearLayoutMultiInputStates.set(toTabId, cloneLinearLayoutMultiInputState(multiInputState)); + const tensorViewsState = state.linearLayoutTensorViewsStates.get(fromTabId); + if (tensorViewsState) state.linearLayoutTensorViewsStates.set(toTabId, cloneLinearLayoutTensorViewsState(tensorViewsState)); + state.linearLayoutSelectionMaps.delete(toTabId); + }, + beforeSessionLoad: () => { + state.linearLayoutMultiInputStates.clear(); + state.linearLayoutSelectionMaps.clear(); + }, + loadSessionTab: async (tabCtx, tab: LoadedSessionTab) => { + const legacySpec = (tab.viewer as { linearLayoutSpec?: unknown }).linearLayoutSpec; + const storedComposeState = (tab.viewer as { composeLayoutState?: unknown }).composeLayoutState; + const storedTensorViews = (tab.viewer as { composeLayoutTensorViews?: unknown }).composeLayoutTensorViews; + const composeMeta = (tab.viewer as { composeLayoutMeta?: unknown }).composeLayoutMeta; + const storedMultiInputState = (tab.viewer as { linearLayoutMultiInputState?: unknown }).linearLayoutMultiInputState; + if (legacySpec) { + // older demos stored one linearLayoutSpec field instead of the + // compose-layout state object; keep that path so saved examples + // and external links remain loadable. + const linearLayoutState = isLinearLayoutState(storedComposeState) + ? cloneLinearLayoutState(storedComposeState) + : composeLayoutStateFromLegacySpec(legacySpec, tab.title); + const document = createComposeLayoutDocument(linearLayoutState, { + ...tab.viewer, + showSelectionPanel: false, + }, tab.title); + state.linearLayoutStates.set(tab.id, cloneLinearLayoutState(linearLayoutState)); + if (storedTensorViews && typeof storedTensorViews === 'object') { + state.linearLayoutTensorViewsStates.set(tab.id, cloneLinearLayoutTensorViewsState(storedTensorViews as Record)); + } else { + state.linearLayoutTensorViewsStates.set(tab.id, snapshotTensorViews(document.manifest.viewer)); + } + if (isLinearLayoutMultiInputState(storedMultiInputState)) { + state.linearLayoutMultiInputStates.set(tab.id, cloneLinearLayoutMultiInputState(storedMultiInputState)); + } + return { ...document, id: tab.id, title: tab.title }; + } + const isLinearLayout = isComposeLayoutMeta(composeMeta); + if (!isLinearLayout) return null; + // loaded compose-layout tabs intentionally hide the generic + // selection panel because selection is mirrored across all tensors. + const viewerState = { + ...tab.viewer, + dimensionMappingScheme: tab.viewer.dimensionMappingScheme ?? 'contiguous', + showSelectionPanel: false, + }; + const storedLinearLayoutState = (viewerState as { composeLayoutState?: unknown }).composeLayoutState; + if (isLinearLayoutState(storedLinearLayoutState)) { + state.linearLayoutStates.set(tab.id, cloneLinearLayoutState(storedLinearLayoutState)); + } + if (storedTensorViews && typeof storedTensorViews === 'object') { + state.linearLayoutTensorViewsStates.set(tab.id, cloneLinearLayoutTensorViewsState(storedTensorViews as LinearLayoutTensorViewsState)); + } else { + state.linearLayoutTensorViewsStates.set(tab.id, snapshotTensorViews(viewerState)); + } + const storedCellTextState = (viewerState as { linearLayoutCellTextState?: unknown }).linearLayoutCellTextState; + if (isLinearLayoutCellTextState(storedCellTextState)) { + state.linearLayoutCellTextStates.set(tab.id, cloneLinearLayoutCellTextState(storedCellTextState)); + } + if (isLinearLayoutMultiInputState(storedMultiInputState)) { + state.linearLayoutMultiInputStates.set(tab.id, cloneLinearLayoutMultiInputState(storedMultiInputState)); + } + return { + id: tab.id, + title: tab.title, + manifest: { version: 1, viewer: viewerState, tensors: tab.tensors }, + tensors: await tabCtx.loadTabTensors(tab.tensors), + }; + }, + afterLoadTab: (_tabCtx, tab) => { + // tab load is the single place where form state, cell text, + // multi-input sliders, and viewer filters are all rehydrated. + syncLinearLayoutState(ui, tab); + syncLinearLayoutCellTextState(ui, tab); + syncLinearLayoutMultiInputState(ui, tab); + runtime.widgets.forEach((widget) => widget.render(ctx, ctx.viewer.getSnapshot())); + syncLinearLayoutViewFilters(ui); + applyLinearLayoutCellText(ui); + syncLinearLayoutSelectionPreview(ui, new Map()); + }, + beforeRender: (_renderCtx, snapshot) => { + const tab = ctx.getActiveTab(); + const activeTensorId = tab && isLinearLayoutTab(tab) ? (snapshot.activeTensorId ?? null) : null; + if (activeTensorId === lastActiveTensorId) return false; + lastActiveTensorId = activeTensorId; + if (!activeTensorId) return false; + // active tensor changes can expose a different multi-input slider + // without changing the underlying layout document. + syncLinearLayoutViewFilters(ui); + return true; + }, + afterRender: () => { + renderHoverPopup(); + }, + loadFallback: async () => loadBakedLinearLayoutTabs(ui), + pointerMove: (_pointerCtx, event) => { + const rect = ctx.viewport.getBoundingClientRect(); + hoverPopupPointer = { + x: Math.max(12, event.clientX - rect.left), + y: Math.max(12, event.clientY - rect.top), + }; + placeHoverPopup(); + }, + pointerLeave: () => { + hoverPopup.classList.add('hidden'); + }, + hover: () => { renderHoverPopup(); }, + selectionPreview: (_selectionCtx, selection) => { + syncLinearLayoutSelectionPreview(ui, selection); + }, + selection: (_selectionCtx, selection) => { + syncLinearLayoutSelection(ui, selection); + }, + }; + return runtime; +} + +export const linearLayoutExtensionFactory = { + widgetSlots: LINEAR_LAYOUT_WIDGET_SLOTS, + create: createLinearLayoutExtension, +} satisfies DemoExtensionFactory; diff --git a/src/extensions/linear-layout/linear-layout-multi-input.ts b/src/extensions/linear-layout/linear-layout-multi-input.ts new file mode 100644 index 0000000..9ae3ee2 --- /dev/null +++ b/src/extensions/linear-layout/linear-layout-multi-input.ts @@ -0,0 +1,628 @@ +import { + coordFromKey, + coordKey, + parseTensorView, + serializeTensorViewEditor, + visibleTensorCoords, + type LoadedBundleDocument, +} from '@tensor-viz/viewer-core'; +import { rootColorsForLayoutState } from './linear-layout.js'; +import { composeLayoutMetaForTab, type LinearLayoutSelectionMap, type LinearLayoutUiContext } from './linear-layout-state.js'; + +/** + * Display-time index map for a linear-layout selection, linking each tensor's visible coordinates to the root input cells currently shown in the viewer and to any ghosted duplicate roots. + * + * The multi-input hover and slider code uses this model to decide which root indexes are visible in the active slice, which root index each tensor coordinate represents, and which duplicate cells should be drawn as ghost overlays. + * + * @example + * const display: LinearLayoutDisplayModel = { + * rootIndexes: new Set([0, 1, 2]), + * sliceRootIndexes: new Set([0, 2]), + * displayedRootIndexByTensor: new Map([ + * ['accumulator', [0, null, 2]], + * ]), + * visibleCoordsByTensor: new Map([ + * ['accumulator', [[0, 0], [0, 1], [0, 2]]], + * ]), + * ghostRootIndexesByTensor: new Map([ + * ['accumulator', [{ coord: [0, 2], rootIndex: 1, layer: 0 }]], + * ]), + * }; + * + * expect(display.displayedRootIndexByTensor.get('accumulator')?.[2]).toBe(2); + * expect(display.ghostRootIndexesByTensor.get('accumulator')?.[0].coord).toEqual([0, 2]); + */ +export type LinearLayoutDisplayModel = { + rootIndexes: Set; + sliceRootIndexes: Set | null; + displayedRootIndexByTensor: Map>; + visibleCoordsByTensor: Map; + ghostRootIndexesByTensor: Map>; +}; + +/** + * Describes the optional slider shown when the focused linear-layout tensor cell + * represents multiple root inputs. `null` means the UI should hide the slider, + * either because no tensor is focused, output propagation is active, no mapping + * is available, or every visible cell maps to at most one root input. + * + * @example + * const visibleSlider: LinearLayoutMultiInputModel = { + * focusedTensorId: 'compose-step-2', + * value: 0, + * size: 4, + * }; + * + * const hiddenSlider: LinearLayoutMultiInputModel = null; + */ +export type LinearLayoutMultiInputModel = { + focusedTensorId: string; + value: number; + size: number; +} | null; + +/** + * Builds the coordinate lookup tables used to keep linear-layout tensor hovers, + * selections, colors, and ghost layers aligned with the root input space and the + * final propagated output space for a loaded tab. + * + * @param tab - Loaded bundle document whose manifest tensor ids and embedded compose-layout metadata are inspected. + * @returns A selection map containing root-input labels, final-output labels, tensor coordinate indexes, and loaded tensor ids, or `null` when the tab has no compose-layout metadata or the metadata contains no tensors. + * @noThrows Missing or empty compose-layout metadata is treated as an unsupported tab and reported with `null`; the function only derives in-memory arrays and maps from the loaded document. + * @example + * const mapping = linearLayoutSelectionMapForMeta(tab); + * if (mapping) { + * expect(mapping.orderedTensorIds).toContain('compose-step-1'); + * expect(mapping.rootKeyToIndex.get('0')).toBe(0); + * } else { + * expect(mapping).toBeNull(); + * } + */ +export function linearLayoutSelectionMapForMeta( + tab: LoadedBundleDocument, +): LinearLayoutSelectionMap | null { + const meta = composeLayoutMetaForTab(tab); + if (!meta || meta.tensors.length === 0) return null; + const loadedTensorIds = new Set(tab.manifest.tensors.map((tensor) => tensor.id)); + const finalOutputShape = meta.finalOutputBitCounts.map((bits) => bits === 0 ? 1 : 2 ** bits); + const rootInputShape = meta.rootInputBitCounts.map((bits) => bits === 0 ? 1 : 2 ** bits); + const rootKeys = meta.tensors[0]!.rootToTensor.map((coord) => coordKey(coord)); + const rootToFinalKeys = meta.tensors[0]!.tensorToFinal.map((coord) => coord ? coordKey(coord) : ''); + const tensors = new Map ? T : never>(); + meta.tensors.forEach((tensorMeta) => { + if (!loadedTensorIds.has(tensorMeta.id)) return; + const rootToTensorKeys = tensorMeta.rootToTensor.map((coord) => coordKey(coord)); + const coordKeyToFlatIndex = new Map(); + const cellRootIndexes = Array.from({ length: tensorMeta.shape.reduce((total, value) => total * value, 1) }, () => [] as number[]); + // non-injective tensors can map many root inputs into one cell. Keep + // all roots by flat cell so hover, selection, and ghost layers agree. + rootToTensorKeys.forEach((tensorKey, rootIndex) => { + const flat = coordFromKey(tensorKey).reduce((index, value, axis) => (index * tensorMeta.shape[axis]!) + value, 0); + coordKeyToFlatIndex.set(tensorKey, flat); + cellRootIndexes[flat]!.push(rootIndex); + }); + tensors.set(tensorMeta.id, { meta: tensorMeta, rootToTensorKeys, coordKeyToFlatIndex, cellRootIndexes }); + }); + return { + injective: meta.injective, + rootInputLabels: meta.rootInputLabels.slice(), + rootInputShape, + rootKeys: rootKeys.slice(), + rootKeyToIndex: new Map(rootKeys.map((key, index) => [key, index])), + finalOutputLabels: meta.finalOutputLabels.slice(), + finalOutputShape, + rootToFinalKeys, + tensors, + orderedTensorIds: meta.tensors.map((tensor) => tensor.id).filter((id) => tensors.has(id)), + }; +} + +/** + * Decides whether the focused tensor needs the multi-input slider and, when it + * does, returns the slider range and selected root-input offset for that tensor. + * + * @param ctx - Linear-layout UI context that provides the active tensor id, the propagate-outputs flag, and saved per-tensor slider positions. + * @param mapping - Selection map for the active linear-layout tab, or `null` when the tab cannot provide linear-layout coordinate metadata. + * @returns Slider state for the focused non-injective tensor cell: `focusedTensorId`, `size` as the largest number of root inputs sharing one tensor cell, and `value` clamped into `[-1, size - 1]`; returns `null` when no slider should be rendered. + * @noThrows Missing mapping, missing focus, propagated-output mode, unknown tensor ids, and one-to-one mappings are normal UI states and return `null` instead of raising an error. + * @example + * const model = linearLayoutMultiInputModel(ctx, mapping); + * expect(model).toEqual({ + * focusedTensorId: 'compose-step-2', + * size: 4, + * value: 0, + * }); + * + * ctx.state.linearLayoutState.propagateOutputs = true; + * expect(linearLayoutMultiInputModel(ctx, mapping)).toBeNull(); + */ +export function linearLayoutMultiInputModel( + ctx: LinearLayoutUiContext, + mapping: LinearLayoutSelectionMap | null, +): LinearLayoutMultiInputModel { + const focusedTensorId = ctx.viewer.getState().activeTensorId; + if (!mapping || !focusedTensorId) return null; + if (ctx.state.linearLayoutState?.propagateOutputs) return null; + const tensor = mapping.tensors.get(focusedTensorId); + if (!tensor) return null; + const size = Math.max(0, ...tensor.cellRootIndexes.map((roots) => roots.length)); + // the slider exists only for many-to-one cells; injective or currently + // one-to-one views should not expose an extra control. + if (size <= 1) return null; + const storedValue = ctx.state.linearLayoutMultiInputState[focusedTensorId] ?? -1; + const value = storedValue < 0 ? -1 : Math.min(size - 1, storedValue); + return { focusedTensorId, value, size }; +} + +/** + * Synchronizes the active linear-layout tab into the tensor viewer by writing + * per-cell root indexes, RGB colors, visible coordinates, and ghost layers for + * every loaded tensor in the layout mapping. + * + * @param ctx - Linear-layout UI context that supplies the active tab, layout state, cached selection maps, and viewer rendering methods such as `setTensorData`, `colorTensor`, `setTensorVisibleCoords`, and `setTensorGhostLayers`. + * @returns Nothing; callers observe the update through the viewer tensors being recolored, sliced, and assigned ghost-layer annotations. + * @noThrows If there is no active tab or the tab has no linear-layout selection map, the function returns before touching the viewer; otherwise it performs deterministic viewer API calls from existing metadata and UI state. + * @example + * applyLinearLayoutDisplay(ctx); + * + * expect(ctx.viewer.setTensorData).toHaveBeenCalledWith( + * 'compose-step-1', + * expect.any(Float32Array), + * 'float32', + * ); + * expect(ctx.viewer.colorTensor).toHaveBeenCalledWith('compose-step-1', expect.any(Float32Array)); + * expect(ctx.viewer.setTensorVisibleCoords).toHaveBeenCalledWith('compose-step-1', expect.any(Array)); + */ +export function applyLinearLayoutDisplay(ctx: LinearLayoutUiContext): void { + const tab = ctx.getActiveTab(); + if (!tab) return; + const mapping = linearLayoutSelectionMapForTab(ctx, tab); + if (!mapping) return; + const display = linearLayoutDisplayModel(ctx, mapping); + const [colorLabels, colorShape] = ctx.state.linearLayoutState.propagateOutputs + ? [mapping.finalOutputLabels, mapping.finalOutputShape] + : [mapping.rootInputLabels, mapping.rootInputShape]; + const colors = rootColorsForLayoutState( + colorLabels, + colorShape, + ctx.state.linearLayoutState, + ); + mapping.orderedTensorIds.forEach((tensorId) => { + const tensor = mapping.tensors.get(tensorId)!; + const displayed = display.displayedRootIndexByTensor.get(tensorId) ?? []; + const data = new Float32Array(tensor.meta.shape.reduce((total, value) => total * value, 1)).fill(-1); + const rgb = new Float32Array(data.length * 3); + displayed.forEach((rootIndex, flat) => { + if (rootIndex === null) return; + data[flat] = rootIndex; + rgb.set(colors[propagatedIndexForRoot(mapping, rootIndex, ctx.state.linearLayoutState.propagateOutputs)]!, flat * 3); + }); + // data, colors, visible coords, and ghost layers are updated together so + // rendering cannot show stale hidden roots after slicing or slider edits. + ctx.viewer.setTensorData(tensorId, data, 'float32'); + ctx.viewer.colorTensor(tensorId, rgb); + ctx.viewer.setTensorVisibleCoords(tensorId, display.visibleCoordsByTensor.get(tensorId) ?? []); + ctx.viewer.setTensorGhostLayers(tensorId, ctx.state.linearLayoutState.propagateOutputs ? null : display.ghostRootIndexesByTensor.get(tensorId)?.map((entry) => ({ + coord: entry.coord, + color: colors[propagatedIndexForRoot(mapping, entry.rootIndex, ctx.state.linearLayoutState.propagateOutputs)]! + .map((value) => Math.round(value * 255)) as [number, number, number], + bias: [entry.layer * 0.18, -(entry.layer * 0.18)] as const, + layer: entry.layer, + text: linearLayoutGhostText( + propagatedCoordForRoot(mapping, entry.rootIndex, ctx.state.linearLayoutState.propagateOutputs), + ctx.state.linearLayoutState.propagateOutputs ? mapping.finalOutputLabels : mapping.rootInputLabels, + ctx.state.linearLayoutCellTextState, + ), + })) ?? null); + }); +} + +/** + * Builds the linear-layout render model that decides which compose-root indexes are visible in each tensor view. + * + * The model intersects active tensor-view slice selections, optionally narrows the result to the root selected by the focused multi-input slider, and records the root displayed in each tensor cell. When a tensor cell maps to multiple roots, the first root is rendered as the main cell and the remaining roots are returned as ghost layers. + * + * @param ctx - Linear-layout UI context containing the viewer slice state, focused tensor-view slider state, and current extension settings. + * @param mapping - Selection map produced from linear-layout metadata, including ordered tensor ids, root keys, tensor shapes, coordinate lookup tables, and per-cell root-index memberships. + * @returns Display model used by render and selection synchronization code: the visible root-index set, the slice-only root-index set, displayed root indexes by tensor cell, visible coordinates by tensor, and ghost root layers for non-injective cells. + * @noThrows The function only reads Maps, Sets, arrays, and context state; missing optional focus/slice state is treated as an absent filter rather than as an exceptional condition. + * @example + * const display = linearLayoutDisplayModel(ctx, mapping); + * + * expect(Array.from(display.rootIndexes)).toEqual([2]); + * expect(display.displayedRootIndexByTensor.get('compose-root')).toEqual([null, null, 2, null]); + */ +export function linearLayoutDisplayModel( + ctx: LinearLayoutUiContext, + mapping: LinearLayoutSelectionMap, +): LinearLayoutDisplayModel { + const sliceVisibleRootIndexes = sliceVisibleRootIndexesByTensor(ctx, mapping); + const slicedRoots = intersectRootIndexes(sliceVisibleRootIndexes.values(), mapping.rootKeys.length); + const multiInput = linearLayoutMultiInputModel(ctx, mapping); + // visibility is the intersection of active tensor-view slices, then + // optionally narrowed to one many-to-one member by the focused tensor slider. + const focusedRoots = multiInput + ? focusedRootIndexes(mapping, multiInput.focusedTensorId, multiInput.value, sliceVisibleRootIndexes) + : null; + const rootIndexes = focusedRoots ?? slicedRoots ?? new Set(Array.from({ length: mapping.rootKeys.length }, (_entry, index) => index)); + const displayedRootIndexByTensor = new Map>(); + const visibleCoordsByTensor = new Map(); + const ghostRootIndexesByTensor = new Map>(); + mapping.orderedTensorIds.forEach((tensorId) => { + const tensor = mapping.tensors.get(tensorId)!; + const visibleRoots = tensor.cellRootIndexes.map((roots) => roots.filter((rootIndex) => rootIndexes.has(rootIndex))); + const displayed = visibleRoots.map((roots) => roots[0] ?? null); + displayedRootIndexByTensor.set(tensorId, displayed); + visibleCoordsByTensor.set(tensorId, displayed.flatMap((rootIndex, flat) => ( + rootIndex === null ? [] : [unravelIndex(flat, tensor.meta.shape)] + ))); + ghostRootIndexesByTensor.set(tensorId, visibleRoots.flatMap((roots, flat) => ( + // root zero is rendered as the main cell; additional roots become + // offset ghost layers so non-injective cells remain inspectable. + roots.slice(1).map((rootIndex, layer) => ({ + coord: unravelIndex(flat, tensor.meta.shape), + rootIndex, + layer: layer + 1, + })) + ))); + }); + return { rootIndexes, sliceRootIndexes: slicedRoots, displayedRootIndexByTensor, visibleCoordsByTensor, ghostRootIndexesByTensor }; +} + +/** + * Converts selected tensor coordinates into the compose-root indexes represented by those cells. + * + * This is used when a user selects cells in one tensor view and the linear-layout extension needs the shared root indexes to project that selection into the other tensor views. + * + * @param mapping - Linear-layout selection map containing the tensor coordinate-to-flat-index table and the root indexes attached to each tensor cell. + * @param tensorId - Identifier of the tensor whose coordinates were selected, such as `compose-step-1` or `compose-root`. + * @param coords - Tensor coordinates from the viewer selection, with each entry matching the tensor rank, for example `[[0]]` for a one-dimensional cell. + * @returns Set of root indexes attached to the requested coordinates; missing tensor ids, coordinates outside the tensor, and cells without roots contribute no entries. + * @noThrows The tensor and coordinate lookups are guarded: an unknown tensor id or coordinate key returns an empty contribution instead of throwing. + * @example + * const selectedRoots = rootIndexesForCoords(mapping, 'compose-step-1', [[0]]); + * + * expect(Array.from(selectedRoots)).toEqual([0, 2]); + * expect(Array.from(rootIndexesForCoords(mapping, 'missing-tensor', [[0]]))).toEqual([]); + */ +export function rootIndexesForCoords( + mapping: LinearLayoutSelectionMap, + tensorId: string, + coords: number[][], +): Set { + const tensor = mapping.tensors.get(tensorId); + if (!tensor) return new Set(); + return new Set(coords.flatMap((coord) => { + const flat = tensor.coordKeyToFlatIndex.get(coordKey(coord)); + return flat === undefined ? [] : tensor.cellRootIndexes[flat] ?? []; + })); +} + +/** + * Projects compose-root selections back into the coordinates of a tensor view. + * + * A tensor coordinate is returned when any root attached to that tensor cell is in `selectedRootIndexes`. When `visibleRootIndexes` is provided, the coordinate must also contain at least one root that survives the current slice filter. + * + * @param mapping - Linear-layout selection map containing tensor shapes and each cell's compose-root memberships. + * @param tensorId - Identifier of the tensor view to project into, such as `compose-root` or an intermediate compose step. + * @param selectedRootIndexes - Root indexes gathered from the source selection and propagated through the linear-layout graph. + * @param visibleRootIndexes - Optional slice-filter root set from the display model; pass `null` to include matching coordinates even when they are outside the current slice filter. + * @returns Tensor coordinates whose cells match the selected roots and, when supplied, the visible-root filter; returns an empty array for unknown tensors or an empty selected-root set. + * @noThrows The function checks for a missing tensor and empty selection before reading cell data, and the optional visibility filter is handled as a normal `null` case. + * @example + * const selectedRoots = new Set([0, 2]); + * + * expect(coordsForRootIndexes(mapping, 'compose-root', selectedRoots, null)).toEqual([[0], [2]]); + * expect(coordsForRootIndexes(mapping, 'compose-root', selectedRoots, new Set([2]))).toEqual([[2]]); + */ +export function coordsForRootIndexes( + mapping: LinearLayoutSelectionMap, + tensorId: string, + selectedRootIndexes: Set, + visibleRootIndexes: Set | null = null, +): number[][] { + const tensor = mapping.tensors.get(tensorId); + if (!tensor || selectedRootIndexes.size === 0) return []; + return tensor.cellRootIndexes.flatMap((roots, flat) => { + const matchesSelection = roots.some((rootIndex) => selectedRootIndexes.has(rootIndex)); + const matchesVisible = visibleRootIndexes === null || roots.some((rootIndex) => visibleRootIndexes.has(rootIndex)); + return matchesSelection && matchesVisible ? [unravelIndex(flat, tensor.meta.shape)] : []; + }); +} + +/** + * Reads the root index currently rendered for one tensor cell in a linear-layout display model. + * + * Hover popups and inspector rows use this helper to connect a visible tensor coordinate back to the compose-root entry that the display model chose for that cell. + * + * @param display - Display model returned by `linearLayoutDisplayModel`, including the per-tensor array of displayed root indexes. + * @param mapping - Linear-layout selection map used to translate the tensor coordinate into the flat cell index used by the display arrays. + * @param tensorId - Identifier of the tensor that owns the coordinate being inspected. + * @param coord - Tensor coordinate to inspect, with one number per tensor axis. + * @returns The displayed compose-root index for the cell, or `null` when the tensor id is unknown, the coordinate is outside the tensor, or the display model has no root for that cell. + * @noThrows Unknown tensor ids, unknown coordinate keys, and missing display entries are all checked with guarded lookups and return `null`. + * @example + * const display = linearLayoutDisplayModel(ctx, mapping); + * + * expect(displayedRootIndexForCoord(display, mapping, 'compose-root', [2])).toBe(2); + * expect(displayedRootIndexForCoord(display, mapping, 'compose-root', [99])).toBeNull(); + */ +export function displayedRootIndexForCoord( + display: LinearLayoutDisplayModel, + mapping: LinearLayoutSelectionMap, + tensorId: string, + coord: number[], +): number | null { + const tensor = mapping.tensors.get(tensorId); + if (!tensor) return null; + const flat = tensor.coordKeyToFlatIndex.get(coordKey(coord)); + if (flat === undefined) return null; + return display.displayedRootIndexByTensor.get(tensorId)?.[flat] ?? null; +} + +/** + * Narrows the visible linear-layout roots to the member selected by the focused tensor's multi-input slider. + * + * Each tensor cell can map to several root indexes. This picks the `index`th root from each focused-tensor cell, + * after applying any tensor-view slice filter for that tensor, so hover and display synchronization can show one + * many-to-one member at a time. + * + * @param mapping - Linear-layout selection map whose `tensors` entry contains `focusedTensorId` and per-cell root-index lists. + * @param focusedTensorId - Tensor id for the slider that currently controls the many-to-one focus. + * @param index - Zero-based slider position to select from each focused tensor cell's root list; negative values disable focus. + * @param sliceVisibleRootIndexes - Root indexes still visible for each tensor after tensor-view slicing. + * @returns A set of selected root indexes, or `null` when the index is negative or the focused tensor is not present in the mapping. + * @noThrows Missing focused tensors and disabled slider indexes are represented as `null`; missing slice filters mean all roots in the focused tensor remain eligible. + * @example + * const mapping = { + * tensors: new Map([ + * ['rhs', { cellRootIndexes: [[0, 2], [1, 3]] }], + * ]), + * } as LinearLayoutSelectionMap; + * const visibleByTensor = new Map([['rhs', new Set([2, 3])]]); + * + * Array.from(focusedRootIndexes(mapping, 'rhs', 0, visibleByTensor)!).sort(); + * // => [2, 3] + * + * focusedRootIndexes(mapping, 'rhs', -1, visibleByTensor); + * // => null + */ +function focusedRootIndexes( + mapping: LinearLayoutSelectionMap, + focusedTensorId: string, + index: number, + sliceVisibleRootIndexes: Map>, +): Set | null { + if (index < 0) return null; + const tensor = mapping.tensors.get(focusedTensorId); + if (!tensor) return null; + const visibleRoots = sliceVisibleRootIndexes.get(focusedTensorId) ?? null; + return new Set(tensor.cellRootIndexes.flatMap((roots) => { + const filteredRoots = visibleRoots ? roots.filter((rootIndex) => visibleRoots.has(rootIndex)) : roots; + const rootIndex = filteredRoots[index]; + return rootIndex === undefined ? [] : [rootIndex]; + })); +} + +/** + * Builds the per-tensor root visibility filters implied by each tensor's current tensor-view slice. + * + * The display model uses this map to hide linear-layout roots that are outside the active slices before applying + * multi-input focus. Tensors whose view does not resolve to any visible roots are omitted. + * + * @param ctx - Linear-layout UI context whose viewer provides tensor status, tensor-view editor snapshots, and hidden indices. + * @param mapping - Selection map that orders tensor ids and maps visible tensor coordinates back to linear-layout root indexes. + * @returns A map from tensor id to the root indexes visible in that tensor's parsed slice; tensors with no visible roots are absent. + * @noThrows Invalid tensor-view text is converted by `slicedTensorCoords` into `null`, which contributes an empty root set that is filtered out. + * @example + * const visibleByTensor = sliceVisibleRootIndexesByTensor(ctx, mapping); + * + * visibleByTensor.get('lhs'); + * // => Set containing the root indexes for coordinates still visible in the lhs tensor view + * + * visibleByTensor.has('tensor-with-invalid-view'); + * // => false + */ +function sliceVisibleRootIndexesByTensor( + ctx: LinearLayoutUiContext, + mapping: LinearLayoutSelectionMap, +): Map> { + return new Map(mapping.orderedTensorIds.map((tensorId) => { + const coords = slicedTensorCoords(ctx, tensorId); + return [tensorId, coords ? rootIndexesForCoords(mapping, tensorId, coords) : new Set()] as const; + }).filter(([_tensorId, roots]) => roots.size > 0)); +} + +/** + * Finds the linear-layout root indexes that remain visible in every active tensor-view slice. + * + * The result is the shared visibility mask used before optional focused-tensor narrowing. An empty iterable means no + * tensor-view slice is constraining the display. + * + * @param sets - Visible root-index sets produced for each tensor that currently has an active slice filter. + * @param rootCount - Total number of roots in the mapping; accepted for call-site symmetry with visibility calculations but not needed to compute the intersection. + * @returns A set containing only root indexes present in every supplied set, or `null` when no sets were supplied. + * @noThrows The function only iterates the supplied sets and allocates result sets; absence of slice filters is reported as `null` rather than an exception. + * @example + * const visibleRoots = intersectRootIndexes([ + * new Set([0, 1, 3]), + * new Set([1, 3, 4]), + * ], 5); + * + * Array.from(visibleRoots!).sort(); + * // => [1, 3] + * + * intersectRootIndexes([], 5); + * // => null + */ +function intersectRootIndexes(sets: Iterable>, rootCount: number): Set | null { + let intersection: Set | null = null; + for (const set of sets) { + intersection = intersection + ? new Set(Array.from(intersection).filter((rootIndex) => set.has(rootIndex))) + : new Set(set); + } + if (intersection) return intersection; + return null; +} + +/** + * Converts a tensor's current tensor-view editor state into the coordinates that remain visible after slicing. + * + * The linear-layout extension uses these coordinates to map viewer slices back to composed root indexes for + * multi-input visibility and hover synchronization. + * + * @param ctx - Linear-layout UI context whose viewer can read the tensor status and current tensor-view snapshot. + * @param tensorId - Id of the tensor whose shape, axis labels, hidden indices, and editor text should be parsed. + * @returns Visible tensor coordinates as index arrays, or `null` when the tensor-view editor text cannot be parsed for the tensor shape. + * @noThrows Tensor-view syntax errors are returned as `null` parse results, letting callers treat invalid editor state as no slice-derived visibility filter. + * @example + * const coords = slicedTensorCoords(ctx, 'lhs'); + * + * coords; + * // => [[0, 0], [0, 1]] for a 2-D tensor view that leaves the first row visible + * + * // If the lhs tensor-view editor contains invalid syntax: + * slicedTensorCoords(ctx, 'lhs'); + * // => null + */ +function slicedTensorCoords(ctx: LinearLayoutUiContext, tensorId: string): number[][] | null { + const status = ctx.viewer.getTensorStatus(tensorId); + const snapshot = ctx.viewer.getTensorView(tensorId); + const parsed = parseTensorView( + status.shape.slice(), + serializeTensorViewEditor(snapshot.editor), + snapshot.hiddenIndices, + status.axisLabels, + ); + return !parsed.ok ? null : visibleTensorCoords(parsed.spec); +} + +/** + * Decodes a row-major flat tensor position into one coordinate per axis of the supplied shape. + * + * @param index - Zero-based flat position in storage order for a tensor with the supplied dimensions. + * @param shape - Tensor dimensions ordered from outermost axis to innermost axis; an empty shape represents a scalar. + * @returns Coordinate tuple for the flat position, with `coord.length === shape.length`; scalars return an empty tuple. + * @noThrows Uses only array allocation and arithmetic on caller-supplied numbers; scalar shapes return before indexing. + * @example + * unravelIndex(5, [2, 3]); + * // => [1, 2] + * + * unravelIndex(0, []); + * // => [] + */ +function unravelIndex(index: number, shape: number[]): number[] { + if (shape.length === 0) return []; + const coord = new Array(shape.length).fill(0); + let remainder = index; + for (let axis = shape.length - 1; axis >= 0; axis -= 1) { + const size = shape[axis] ?? 1; + coord[axis] = remainder % size; + remainder = Math.floor(remainder / size); + } + return coord; +} + +/** + * Builds the hover ghost text that shows enabled linear-layout axis labels beside their coordinate values. + * + * @param coord - Coordinate tuple for the hovered root or propagated tensor cell. + * @param labels - Axis labels in the same order as `coord`; labels past the coordinate length are ignored. + * @param state - Map whose truthy entries mark which axis labels should be shown in the hover text. + * @returns Newline-delimited `label:value` lines for enabled labels, or `null` when no enabled label has a coordinate. + * @noThrows Only filters and formats the provided arrays and object; missing coordinate entries default to `0`. + * @example + * linearLayoutGhostText([3, 1, 0], ['m', 'n', 'k'], { m: true, n: false, k: true }); + * // => 'm:3\nk:0' + * + * linearLayoutGhostText([3, 1], ['m', 'n'], { m: false, n: false }); + * // => null + */ +function linearLayoutGhostText(coord: number[], labels: string[], state: Record): string | null { + const text = labels + .flatMap((label, axis) => (state[label] && axis < coord.length ? [`${label}:${coord[axis] ?? 0}`] : [])) + .join('\n'); + return text || null; +} + +/** + * Resolves the coordinate displayed for a root input cell, either in root-input space or after propagation to final-output space. + * + * @param mapping - Linear-layout selection map containing `rootKeys` for input coordinates and `rootToFinalKeys` for propagated output coordinates. + * @param rootIndex - Zero-based index of the root input cell whose selection or hover coordinate is being displayed. + * @param propagateOutputs - Whether the Propagate Outputs control is enabled and final-output coordinates should be used. + * @returns Coordinate decoded from the selected mapping key; missing keys resolve to the empty coordinate key. + * @noThrows Reads mapping arrays with fallback to an empty key before decoding, so absent entries do not throw. + * @example + * const mapping = { + * rootKeys: ['0,0', '0,1'], + * rootToFinalKeys: ['1,0', '1,1'], + * } as LinearLayoutSelectionMap; + * + * propagatedCoordForRoot(mapping, 1, false); + * // => [0, 1] + * + * propagatedCoordForRoot(mapping, 1, true); + * // => [1, 1] + */ +function propagatedCoordForRoot(mapping: LinearLayoutSelectionMap, rootIndex: number, propagateOutputs: boolean): number[] { + const key = propagateOutputs ? mapping.rootToFinalKeys[rootIndex] : mapping.rootKeys[rootIndex]; + return coordFromKey(key ?? ''); +} + +/** + * Converts a root cell's displayed coordinate into the row-major flat index used to look up propagated selection colors. + * + * @param mapping - Linear-layout selection map containing root/final coordinate keys and their corresponding input/output shapes. + * @param rootIndex - Zero-based index of the root input cell whose color-buffer position is needed. + * @param propagateOutputs - Whether to flatten the propagated final-output coordinate instead of the original root-input coordinate. + * @returns Row-major flat index within `rootInputShape` when propagation is off, or within `finalOutputShape` when it is on. + * @noThrows Delegates coordinate lookup to `propagatedCoordForRoot` and reduces over the returned coordinate; missing keys reduce to `0`. + * @example + * const mapping = { + * rootInputShape: [2, 3], + * finalOutputShape: [3, 2], + * rootKeys: ['0,0', '0,1'], + * rootToFinalKeys: ['1,0', '1,1'], + * } as LinearLayoutSelectionMap; + * + * propagatedIndexForRoot(mapping, 1, false); + * // => 1 + * + * propagatedIndexForRoot(mapping, 1, true); + * // => 3 + */ +function propagatedIndexForRoot(mapping: LinearLayoutSelectionMap, rootIndex: number, propagateOutputs: boolean): number { + const shape = propagateOutputs ? mapping.finalOutputShape : mapping.rootInputShape; + return propagatedCoordForRoot(mapping, rootIndex, propagateOutputs) + .reduce((index, value, axis) => (index * shape[axis]!) + value, 0); +} + +/** + * Retrieves the selection-synchronization map for a loaded linear-layout tab, reusing the per-tab cache before parsing the tab metadata. + * + * Hover popups, multi-input sliders, and inspector coordinate rows use this map to translate viewer selections back to propagated linear-layout labels. + * + * @param ctx - Linear-layout UI context whose state owns the `linearLayoutSelectionMaps` cache keyed by tab id. + * @param tab - Loaded viewer tab that may contain compose-layout metadata embedded when the layout was rendered. + * @returns The cached or newly parsed selection map for `tab`, or `null` when the tab metadata does not describe a linear-layout selection mapping. + * @noThrows Cache lookup and metadata probing are guarded by null checks; tabs without usable mapping metadata are reported as `null` instead of raising an error. + * @example + * const first = linearLayoutSelectionMapForTab(ctx, linearLayoutTab); + * if (first) { + * console.assert(ctx.state.linearLayoutSelectionMaps.get(linearLayoutTab.id) === first); + * console.assert(linearLayoutSelectionMapForTab(ctx, linearLayoutTab) === first); + * } + * + * const missing = linearLayoutSelectionMapForTab(ctx, ordinaryTensorTab); + * console.assert(missing === null); + */ +function linearLayoutSelectionMapForTab(ctx: LinearLayoutUiContext, tab: LoadedBundleDocument): LinearLayoutSelectionMap | null { + const cached = ctx.state.linearLayoutSelectionMaps.get(tab.id); + if (cached) return cached; + const mapping = linearLayoutSelectionMapForMeta(tab); + if (!mapping) return null; + ctx.state.linearLayoutSelectionMaps.set(tab.id, mapping); + return mapping; +} diff --git a/src/extensions/linear-layout/linear-layout-parser.ts b/src/extensions/linear-layout/linear-layout-parser.ts new file mode 100644 index 0000000..5b71042 --- /dev/null +++ b/src/extensions/linear-layout/linear-layout-parser.ts @@ -0,0 +1,262 @@ +/** + * Parsed representation of one named compose-layout basis block from the specs editor. + * + * `inputs` and `outputs` preserve the labels from the signature line, while `bases[inputIndex][bitIndex][outputIndex]` stores the numeric contribution of each input bit to each output label. + * + * @example + * const spec: NamedLayoutSpec = { + * name: 'mma', + * inputs: ['A', 'B'], + * outputs: ['M', 'N'], + * bases: [ + * [[1, 0], [0, 1]], + * [[1, 1]], + * ], + * }; + * console.assert(spec.inputs[0] === 'A'); + * console.assert(spec.bases[0][1][1] === 1); + */ +export type NamedLayoutSpec = { + name: string; + inputs: string[]; + outputs: string[]; + bases: number[][][]; +}; + +/** + * Parses the linear-layout specs editor notation into named basis blocks. + * + * The notation uses a signature line such as `mma: [A,B] -> [M,N]` followed by one `: ` basis row for each input label. Blank lines and `#` comments are ignored before syntax parsing. + * + * @param text - Raw contents of the layout specs textarea, including signature lines, labeled JSON basis rows, blank lines, and optional `#` comments. + * @returns Parsed layout specs in editor order; each spec contains the signature name, input labels, output labels, and basis rows reordered to match the signature input order. + * @throws Error when a required basis row is missing, a row does not use `