diff --git a/.codex/skills/gothic-lock-solver/SKILL.md b/.codex/skills/gothic-lock-solver/SKILL.md deleted file mode 100644 index a3d5792..0000000 --- a/.codex/skills/gothic-lock-solver/SKILL.md +++ /dev/null @@ -1,45 +0,0 @@ ---- -name: gothic-lock-solver -description: Use when a user provides Gothic 1 Remake lock-plate positions or links, or requests a shortest combination; not for ordinary code locks or cases requiring guessed unconfirmed links. ---- - -# Gothic Lock Solver - -Используй вычислительное ядро проекта, а не языковую модель, для поиска комбинации. - -## Правила модели - -- Пластин от 2 до 7, позиции каждой от 1 до 7, цель всегда 4. -- Влево означает `+1`, вправо означает `-1`. -- Каждая строка матрицы направленная и независимая; обратную связь не подразумевай. -- Связи не каскадируют. Диагональ матрицы равна 0. -- Если зависимая пластина не может выполнить весь сдвиг, команда заблокирована полностью. -- Не используй приложенный PDF в этом workflow. - -## Сбор входа - -1. Зафиксируй число пластин и текущий вектор слева направо. -2. Для каждой исходной пластины отдельно зафиксируй, какие другие пластины движутся вместе с ней. -3. Запиши `1` для того же направления, `-1` для противоположного, `0` только для подтвержденного отсутствия связи. -4. Не запускай решатель, пока в каждой строке не устранены неизвестные связи. -5. Создай JSON с полями `state` и `links` по схеме `gothic-lock-solver-spec.md`. - -## Запуск - -Разреши корень проекта как три уровня вверх от каталога этого skill. Запусти: - -```bash -node /solve-lock.mjs -``` - -Добавляй `--output ` только когда пользователь просит файл результата. - -Передай пользователю команды без перегруппировки и с исходной нумерацией пластин: - -```text -Пластина K - влево|вправо xS -``` - -## Проверка в игре - -После каждой выполненной команды сверяй наблюдаемое состояние с рассчитанным, если пользователь сообщает его. При первом расхождении останови текущую комбинацию, зафиксируй фактический вектор и уточни только затронутую строку матрицы. Не продолжай по заведомо неверной модели. diff --git a/.codex/skills/gothic-lock-solver/agents/openai.yaml b/.codex/skills/gothic-lock-solver/agents/openai.yaml deleted file mode 100644 index 64d5729..0000000 --- a/.codex/skills/gothic-lock-solver/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "Gothic Lock Solver" - short_description: "Собирает модель замка и находит кратчайшую комбинацию" - default_prompt: "Помоги описать и решить замок Gothic 1 Remake." diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..cecdeb8 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,13 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + open-pull-requests-limit: 5 + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + versioning-strategy: increase + open-pull-requests-limit: 5 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..206508e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,162 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + actions: read + +concurrency: + group: ci-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +env: + SOURCE_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + TARGET_SHA: ${{ github.event.pull_request.base.sha || github.event.before }} + +jobs: + build: + name: Build and benchmark + runs-on: ubuntu-24.04 + timeout-minutes: 20 + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ env.SOURCE_SHA }} + persist-credentials: false + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + with: + version: 12.3.4 + run_install: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24.19.0 + cache: pnpm + - name: Install tools and dependencies + run: | + npm install --global --ignore-scripts npm@12.0.2 + pnpm install --frozen-lockfile + - name: Check and build the distribution + run: | + pnpm check + pnpm build + pnpm test:distribution + - name: Benchmark against the exact target SHA when available + env: + GH_TOKEN: ${{ github.token }} + run: | + args=() + if [[ -n "$TARGET_SHA" && "$TARGET_SHA" != 0000000000000000000000000000000000000000 ]]; then + args+=(--baseline-sha "$TARGET_SHA") + run_id=$(gh run list --repo "$GITHUB_REPOSITORY" --workflow ci.yml --commit "$TARGET_SHA" --status success --limit 1 --json databaseId --jq '.[0].databaseId // empty') + if [[ -n "$run_id" ]]; then + artifact_id=$(gh api --paginate "repos/$GITHUB_REPOSITORY/actions/runs/$run_id/artifacts" --jq ".artifacts[] | select(.name == \"benchmark-$TARGET_SHA\" and .expired == false) | .id") + if [[ -n "$artifact_id" ]]; then + gh run download "$run_id" --repo "$GITHUB_REPOSITORY" --name "benchmark-$TARGET_SHA" --dir "$RUNNER_TEMP/baseline" + args+=(--baseline-report "$RUNNER_TEMP/baseline/benchmark.json") + fi + fi + fi + pnpm benchmark "${args[@]}" + - name: Show the benchmark report + if: always() + run: test ! -f artifacts/benchmark/benchmark.md || cat artifacts/benchmark/benchmark.md >> "$GITHUB_STEP_SUMMARY" + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + if: always() + with: + name: benchmark-${{ env.SOURCE_SHA }} + overwrite: true + path: artifacts/benchmark/ + retention-days: 90 + - name: Pack the built distribution + run: | + mkdir "$RUNNER_TEMP/package" + npm pack --ignore-scripts --pack-destination "$RUNNER_TEMP/package" + mkdir -p artifacts/build + mv "$RUNNER_TEMP/package/"*.tgz artifacts/build/package.tgz + cp artifacts/benchmark/benchmark.{json,md} artifacts/build/ + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: build-${{ env.SOURCE_SHA }} + overwrite: true + path: artifacts/build/ + if-no-files-found: error + retention-days: 90 + + test: + name: Node ${{ matrix.node }} / ${{ matrix.project }} + needs: build + runs-on: ubuntu-24.04 + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: + - { node: '22.23.2', project: node } + - { node: '24.19.0', project: node } + - { node: '26.8.1', project: node } + - { node: '24.19.0', project: chromium } + - { node: '24.19.0', project: firefox } + - { node: '24.19.0', project: webkit } + defaults: + run: + shell: bash + env: + NODE_VERSION: ${{ matrix.node }} + PROJECT: ${{ matrix.project }} + BROWSER: ${{ matrix.project == 'node' && 'none' || matrix.project }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ env.SOURCE_SHA }} + persist-credentials: false + - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 + with: + version: 12.3.4 + run_install: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: ${{ matrix.node }} + cache: pnpm + - name: Install tools and dependencies + run: | + npm install --global --ignore-scripts npm@12.0.2 + pnpm install --frozen-lockfile + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: build-${{ env.SOURCE_SHA }} + path: ${{ runner.temp }}/build + - name: Extract the same built distribution + run: tar -xzf "$RUNNER_TEMP/build/package.tgz" --strip-components=1 -C . package/dist + - name: Install the selected browser + if: matrix.project != 'node' + run: pnpm exec playwright install --with-deps "$PROJECT" + - name: Test the selected environment + run: | + if [[ "$PROJECT" == node && "$NODE_VERSION" == 24.19.0 ]]; then + pnpm test:coverage + else + pnpm exec vitest run --project "$PROJECT" + fi + - name: Verify browser pages and Workers + if: matrix.project != 'node' + run: | + node tests/e2e/build-fixtures.ts + pnpm exec playwright test --project "$PROJECT" + - name: Verify the installed build archive + run: pnpm verify:package --archive "$RUNNER_TEMP/build/package.tgz" --browser "$BROWSER" + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + if: always() + with: + name: diagnostics-${{ github.run_id }}-${{ github.run_attempt }}-node-${{ matrix.node }}-${{ matrix.project }} + path: | + artifacts/coverage/ + artifacts/playwright-report/ + artifacts/test-results/ + retention-days: 14 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..e0e6522 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,103 @@ +name: Release + +on: + workflow_run: + workflows: [CI] + types: [completed] + branches: [main] + workflow_dispatch: + inputs: + ci_run_id: + description: Successful main-push CI run + type: string + required: true + version: + description: Stable version, for example 1.0.0 + type: string + required: true + +permissions: + contents: read + +concurrency: + group: release + cancel-in-progress: false + +jobs: + publish: + if: >- + github.ref == 'refs/heads/main' && vars.NPM_PUBLICATION_READY == 'true' && + (github.event_name == 'workflow_dispatch' || + (github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push')) + runs-on: ubuntu-24.04 + timeout-minutes: 15 + environment: npm-production + permissions: + actions: read + contents: write + id-token: write + env: + GH_TOKEN: ${{ github.token }} + GH_REPO: ${{ github.repository }} + CI_RUN_ID: ${{ inputs.ci_run_id || github.event.workflow_run.id }} + STABLE_VERSION: ${{ inputs.version }} + defaults: + run: + shell: bash + steps: + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24.19.0 + registry-url: https://registry.npmjs.org + - run: npm install --global --ignore-scripts npm@12.0.2 + - name: Download the successful main CI package + run: | + [[ "$CI_RUN_ID" =~ ^[1-9][0-9]*$ ]] + gh api "repos/$GH_REPO/actions/runs/$CI_RUN_ID" > "$RUNNER_TEMP/run.json" + SOURCE_SHA=$(jq -er --arg repo "$GH_REPO" ' + select(.repository.full_name == $repo and .head_repository.full_name == $repo and + .event == "push" and .head_branch == "main" and .status == "completed" and .conclusion == "success" and + (.path | split("@")[0]) == ".github/workflows/ci.yml" and (.head_sha | test("^[a-f0-9]{40}$"))) | .head_sha + ' "$RUNNER_TEMP/run.json") + gh run download "$CI_RUN_ID" --repo "$GH_REPO" --name "build-$SOURCE_SHA" --dir "$RUNNER_TEMP/build" + echo "SOURCE_SHA=$SOURCE_SHA" >> "$GITHUB_ENV" + echo "CI_RUN_ATTEMPT=$(jq -r .run_attempt "$RUNNER_TEMP/run.json")" >> "$GITHUB_ENV" + - name: Prepare version metadata and release files + run: | + tar -xzf "$RUNNER_TEMP/build/package.tgz" -C "$RUNNER_TEMP" + cd "$RUNNER_TEMP/package" + BASE_VERSION=$(jq -r .version package.json) + VERSION="${BASE_VERSION}-canary.${CI_RUN_ID}.${CI_RUN_ATTEMPT}.${SOURCE_SHA:0:12}" + NPM_TAG=canary + if [[ "$GITHUB_EVENT_NAME" == workflow_dispatch ]]; then + [[ "$STABLE_VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] + VERSION="$STABLE_VERSION" + NPM_TAG=latest + fi + for file in package.json dist/manifest.json; do + jq --arg version "$VERSION" '.version = $version' "$file" > "$file.tmp" + mv "$file.tmp" "$file" + done + mkdir "$RUNNER_TEMP/release" + npm pack --ignore-scripts --silent --pack-destination "$RUNNER_TEMP/release" + mv "$RUNNER_TEMP/release/gothic-lock-solver-$VERSION.tgz" "$RUNNER_TEMP/release/package.tgz" + cp dist/*.js dist/*.mjs "$RUNNER_TEMP/release/" + cp "$RUNNER_TEMP/build/benchmark.json" "$RUNNER_TEMP/build/benchmark.md" "$RUNNER_TEMP/release/" + { + printf '# Gothic Lock Solver %s\n\nSource: `%s`.\n\n' "$VERSION" "$SOURCE_SHA" + printf 'npm: https://www.npmjs.com/package/gothic-lock-solver/v/%s\n\n' "$VERSION" + printf 'CDN: https://cdn.jsdelivr.net/npm/gothic-lock-solver@%s/dist/\n\n' "$VERSION" + cat "$RUNNER_TEMP/build/benchmark.md" + } > "$RUNNER_TEMP/release-notes.md" + cat "$RUNNER_TEMP/release-notes.md" >> "$GITHUB_STEP_SUMMARY" + echo "VERSION=$VERSION" >> "$GITHUB_ENV" + echo "NPM_TAG=$NPM_TAG" >> "$GITHUB_ENV" + - name: Publish npm package + run: npm publish "$RUNNER_TEMP/release/package.tgz" --access public --tag "$NPM_TAG" --ignore-scripts + - name: Create GitHub Release + run: | + flags=(--latest) + if [[ "$NPM_TAG" == canary ]]; then flags=(--prerelease --latest=false); fi + gh release create "v$VERSION" "$RUNNER_TEMP/release/"* --repo "$GH_REPO" \ + --target "$SOURCE_SHA" --title "Gothic Lock Solver $VERSION" \ + --notes-file "$RUNNER_TEMP/release-notes.md" "${flags[@]}" diff --git a/.gitignore b/.gitignore index 4d5b0c2..328bcac 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,10 @@ node_modules/ coverage/ +dist/ +artifacts/ +playwright-report/ +test-results/ +*.cpuprofile *.tmp project_sources/ upload/ diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 0000000..946ea01 --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,43 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": [ + "typescript", + "unicorn", + "oxc" + ], + "categories": { + "correctness": "error" + }, + "rules": { + "typescript/no-explicit-any": "error", + "typescript/no-non-null-assertion": "error", + "typescript/ban-ts-comment": [ + "error", + { + "ts-ignore": true, + "ts-nocheck": true, + "ts-expect-error": "allow-with-description" + } + ] + }, + "ignorePatterns": [ + "dist/**", + "node_modules/**", + "artifacts/**", + "tests/e2e/distribution/fixtures/**" + ], + "overrides": [ + { + "files": [ + "src/**/*.ts", + "vite.config.ts" + ], + "rules": { + "max-depth": [ + "error", + 3 + ] + } + } + ] +} diff --git a/AGENTS.md b/AGENTS.md index 75ce2b4..6d7dc0f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,78 +1,78 @@ -# AGENTS.md - -Этот файл — основная точка входа для coding-agent, работающего с репозиторием. - -## Назначение проекта - -`gothic-lock-solver` вычисляет кратчайшую последовательность команд для пластинчатых замков **Gothic 1 Remake**. Текущее приложение — CLI на Node.js; вычислительное ядро подготовлено для повторного использования в будущем сайте. - -## Что прочитать перед изменениями - -1. [README.md](README.md) — пользовательский интерфейс и примеры. -2. [Спецификацию](docs/superpowers/specs/2026-08-31-gothic-lock-solver.md) — источник требований к механике. -3. [Выполненный план](docs/superpowers/plans/2026-08-31-gothic-lock-solver.md) — архитектурные решения и история реализации. -4. Тесты изменяемого модуля. - -Если README, план или код расходятся со спецификацией, приоритет имеет спецификация и подтверждённое тестами поведение. - -## Архитектура - -- `solve-lock.mjs` — исполняемая точка входа CLI. -- `src/index.mjs` — стабильный facade для CLI и будущего сайта. -- `src/lock-definition.mjs` — разбор и валидация модели замка. -- `src/state-codec.mjs` — base-7 кодирование состояний. -- `src/transition.mjs` — применение команд и генерация переходов. -- `src/solver.mjs` — BFS и восстановление кратчайшего пути. -- `src/result.mjs` — результат, метрики и консольное представление. -- `src/cli.mjs` — аргументы, файлы и коды завершения. -- `test/` — модульные, дифференциальные и интеграционные тесты. -- `.codex/skills/gothic-lock-solver/` — workflow интерактивного решения замка. - -## Неизменяемые правила модели - -- От 2 до 7 пластин; позиции каждой — целые числа от `1` до `7`. -- Цель — позиция `4` у каждой пластины. -- Влево означает `+1`, вправо — `-1`. -- Матрица связей направленная; значения только `-1`, `0`, `1`, диагональ равна `0`. -- Движение выбранной пластины учитывается отдельно от матрицы. -- Связи применяются один раз и не каскадируют. -- Если хотя бы одна затронутая пластина выходит за границы, вся команда блокируется без изменения состояния. -- Одна команда может сдвигать пластину на `1..6` делений и имеет стоимость `1` для поиска. -- Среди равных решений сохраняется порядок: меньшая пластина, `left` перед `right`, большее `steps` перед меньшим. - -Не изменяй эти правила без явного изменения спецификации и новых тестов. - -## Правила разработки - -- Node.js 20+, JavaScript ESM (`.mjs`), без сторонних runtime-зависимостей. -- Делай минимальные изменения и сохраняй существующие публичные интерфейсы. -- Не связывай вычислительное ядро с файловой системой, CLI или UI. -- Для сайта используй `solveLock` из `src/index.mjs`, а не копию алгоритма. -- Изменение поведения начинай с теста, воспроизводящего требуемый результат или дефект. -- Не добавляй `project_sources/`, `upload/`, PDF, временные файлы и секреты в Git. -- Не редактируй `.env` и не добавляй новые зависимости без явной необходимости. -- Пользовательские сообщения CLI остаются на русском; JSON-поля и значения направления остаются совместимыми с текущим API. - -## Команды - -```bash -# Полная проверка -npm test - -# Пример запуска -node solve-lock.mjs examples/lock.example.json - -# JSON-результат -node solve-lock.mjs examples/lock.example.json --output solution.json - -# Ручной benchmark -node scripts/benchmark.mjs -``` - -## Критерии готовности - -- Все тесты проходят без пропусков. -- Новое поведение покрыто тестами. -- Пример из README соответствует фактическому выводу. -- Спецификация и публичная документация обновлены при изменении интерфейса. -- В diff нет PDF, секретов, временных файлов и несвязанных изменений. +# Agent instructions + +Read [README](README.md), [API](wiki/api.md), [configuration](wiki/configuration.md), +[mathematics](wiki/algorithm.md) and relevant tests before editing. The approved +work is in the [current plan](docs/superpowers/plans/2026-09-07-simplify-library.md). + +## Contract and ownership + +- `solveLock(state, links, config?)` returns readonly `[index, delta]` commands. +- `createSolverConfig(overrides?)` validates settings and fills frozen defaults. +- Positions 1–7, target 4, at least two plates; source-first matrix, zero diagonal, + entries −1/0/+1, direct effects without cascading. Every affected pin stays in range. +- Commands have zero-based indices and nonzero signed displacement −6…+6. + Inputs and caller configuration remain unchanged. +- Successful paths minimize grouped actions, then unit shifts among equal-action + paths. Do not promise minimum distinct plates or universal timing. `null` is proved unreachable; resource + exhaustion throws `SearchLimitError`; invalid lock/config throws `LockInputError`. +- `src/index.ts` and `src/cli.ts` are the two build entries. CLI invokes the same + public solver/factory. Shared mathematics has one implementation. +- Keep source flat under `src/`; tests in `tests/unit`, `tests/e2e` and + `tests/benchmarks`; maintained documentation in `wiki/`. Build configuration + belongs in `vite.config.ts`; CI and publication steps stay directly in workflow + YAML. Do not introduce orchestration wrappers or a scripts directory. + Keep only the current plan in `docs/`. + +## Engineering + +Use TDD with observed failing behavior before changes. Tests use independent +replay and an exhaustive oracle; avoid assertions that mirror private structure. +Use domain terms and clear invariant ownership. Prefer composition, small stateful +classes and plain functions over registries, extra layers or helper-file scaffolding. +Apply SOLID, DRY, KISS and YAGNI proportionately. + +Use descriptive domain names and one operation per statement. Give domain bounds, +byte sizes and sentinel values names; ordinary loop indices need no constant. +Keep the top-level search/release flow readable in order. Necessary matrix loops +remain explicit, with short bodies; do not hide them in allocation-heavy pipelines. +Oxlint limits block nesting to three levels in `src/` and `vite.config.ts`. +Review responsibilities and data flow as well as tests; passing gates alone does +not establish maintainability. + +Use pnpm and exact current stable dependencies with a frozen lockfile. Preserve +strict TypeScript, checked indexed access, exact optional properties and +`skipLibCheck: false`. No `any`, non-null assertions, `ts-ignore` or unchecked casts. +Explained `ts-expect-error` belongs only in negative type tests. Do not patch +libraries or weaken checks to conceal incompatibilities; verify official APIs. + +## Verification + +Run focused checks and `pnpm check`, then `pnpm test`. Coverage includes all shipped +source and requires 80% per metric. Verify Node.js 22/24/26, real Chromium/Firefox/ +WebKit, all five generated files, Workers and an installed archive. + +Run the [45-input benchmark](wiki/benchmarks.md) without competing heavy jobs. +Compare only a saved CI report for the exact target SHA. If it is absent or +expired, record that comparison was skipped. Never rebuild a target checkout or +fall back to a registry/release baseline. Validate report source and catalog identity. +Historical timing and memory ratios are observations across runs, not paired +experiments. Preserve deterministic quality checks; do not change expected minima +or comparison thresholds merely to turn a regression green. + +All local gates precede workflow edits. Independently review the final diff and +check hosted CI on the exact head. CI builds and benchmarks once on Node 24, then +tests that same archive in six matrix rows: Node 22/24/26 and Chromium/Firefox/ +WebKit. Browser rows use Node 24 to drive tests. A separate release +workflow publishes main canaries after successful CI and stable versions only +through a manual dispatch selecting a tested main CI run. Working logs/review outputs belong in ignored +`artifacts/` and CI artifacts. Use official SHA-pinned Actions, npm/gh CLIs and free +standard runners. Keep the PR draft; merging and publication are separate actions. + +## Hygiene + +Track TypeScript source, fixed test inputs, Wiki and configuration. Do not track +JavaScript/MJS/MTS sources, copied implementations, builds, result snapshots, +benchmark reports, coverage, traces, reviewer reports, dependencies, uploaded PDFs +or credentials. Do not edit `.env` files. Do not reference other solver projects +in current code, data, docs or the PR description. Official tool documentation +and this project's own package/repository/CDN URLs remain valid. diff --git a/README.md b/README.md index 3330164..14df3ca 100644 --- a/README.md +++ b/README.md @@ -1,113 +1,83 @@ # Gothic Lock Solver -Решатель пластинчатых замков из **Gothic 1 Remake**. По начальному состоянию и направленной матрице зависимостей программа находит кратчайшую последовательность команд для установки всех пластин в позицию `4`. +An exact solver for Gothic 1 Remake coupled plate locks: minimum grouped actions, +then minimum unit shifts among equally short action sequences. A +TypeScript library and standalone Node.js CLI share one mathematical core with +no runtime dependencies. One action can move a plate several positions. -## Возможности +> The first public package is being prepared. npm/CDN examples become available +> after publication; replace `VERSION` with an exact published version. -- от 2 до 7 пластин, по 7 позиций у каждой; -- направленные синхронные и реверсивные зависимости; -- атомарная блокировка недопустимого хода; -- поиск кратчайшего решения по количеству команд; -- вывод в консоль или JSON-файл; -- отсутствие сторонних зависимостей. +## Library -## Требования - -- Node.js 20 или новее. - -Устанавливать пакеты не требуется. +```sh +npm install gothic-lock-solver +``` -## Формат входного файла +```ts +import { solveLock, createSolverConfig } from "gothic-lock-solver"; -```json -{ - "state": [1, 7, 4, 2, 6], - "links": [ - [0, 0, 0, 0, 0], - [0, 0, 0, 0, 0], - [0, 0, 0, 0, 0], - [0, 0, 0, 0, 0], - [0, 0, 0, 0, 0] - ] -} +const state = [6, 2] as const; +const links = [[0, -1], [0, 0]] as const; +solveLock(state, links); // [[0, -2]] +solveLock(state, links, createSolverConfig({ maxVisited: 500_000 })); ``` -- `state` — текущие позиции пластин от `1` до `7`; -- `links[i][j] = 1` — пластина `j` движется вместе с выбранной пластиной `i`; -- `links[i][j] = -1` — пластина `j` движется в противоположном направлении; -- `links[i][j] = 0` — зависимости нет; -- диагональ матрицы всегда равна `0`. +Namespace imports (`import * as GothicLockSolver`) are also supported. Positions +are 1–7, the goal is 4, and `links[source][target]` is −1/0/+1 with zero diagonal. +Links apply directly without cascading. Commands are `[zeroBasedIndex, signedPinDelta]`. +Inputs remain unchanged; successful results minimize grouped actions. -Влево увеличивает позицию на `1`, вправо уменьшает её на `1`. Цель — состояние `[4, 4, ...]`. +`[]` means already open; `null` means proved unreachable. Invalid input/config +throws `LockInputError`; exhausted computation throws `SearchLimitError`. +Read [API](wiki/api.md), [configuration](wiki/configuration.md) and +[mathematics and complexity](wiki/algorithm.md) for precise guarantees and limits. -## Пример использования +## Browser and CLI -```bash -node solve-lock.mjs examples/lock.example.json +```html + + ``` -Ответ в консоли: +Browser modules import named exports from the corresponding `.mjs` URL. -```text -Найдено команд: 4 -1. Пластина 1 - влево x3 -2. Пластина 2 - вправо x3 -3. Пластина 4 - влево x2 -4. Пластина 5 - вправо x2 +```sh +gothic-lock-solver lock.json --config solver.config.json --output solution.json +node gothic-lock-solver.cli.mjs lock.json --config solver.config.json ``` -Для сохранения результата в JSON: +`lock.json` contains `state` and `links`; config JSON contains partial overrides. +Both flags are optional. CLI messages are Russian; exits are 0 solved, 2 unreachable +and 1 error. `--help` lists options. Use maintained Node.js 22, 24 or 26. -```bash -node solve-lock.mjs examples/lock.example.json --output solution.json -``` +Vite builds `src/index.ts` and `src/cli.ts` into five self-contained choices: -Пример `solution.json`: - -```json -{ - "status": "solved", - "initialState": [1, 7, 4, 2, 6], - "targetState": [4, 4, 4, 4, 4], - "commands": [ - { - "plate": 1, - "direction": "left", - "steps": 3 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - }, - { - "plate": 4, - "direction": "left", - "steps": 2 - }, - { - "plate": 5, - "direction": "right", - "steps": 2 - } - ], - "finalState": [4, 4, 4, 4, 4], - "metrics": { - "commands": 4, - "divisions": 10, - "plateSwitches": 3 - } -} -``` +| Generated file | Consumer | +| --- | --- | +| `gothic-lock-solver.mjs` / `gothic-lock-solver.min.mjs` | Readable / minified ESM | +| `gothic-lock-solver.js` / `gothic-lock-solver.min.js` | Readable / minified classic script | +| `gothic-lock-solver.cli.mjs` | Standalone Node.js CLI | + +Core browser builds use ES2022. A Worker can isolate synchronous search from the UI. -## Проверка +## Development -```bash -npm test +```sh +pnpm install --frozen-lockfile +pnpm exec playwright install --with-deps chromium firefox webkit +pnpm test ``` -Подробная [спецификация](docs/superpowers/specs/2026-08-31-gothic-lock-solver.md) и [выполненный план реализации](docs/superpowers/plans/2026-08-31-gothic-lock-solver.md) находятся в `docs/superpowers`. +Tests cover 45 fixed inputs, independent small-state oracles, configuration, +Node/browser consumers and the installed package. Coverage requires 80% each for +statements, branches, functions and lines; strict types and lint warnings are enforced. -## Лицензия +Read [development](wiki/development.md), [benchmarks](wiki/benchmarks.md), +[releases](wiki/releasing.md) and [AGENTS](AGENTS.md). Generated builds, +reports and coverage are CI artifacts, with the benchmark report attached to GitHub +Releases. They are not committed to the source repository. -[Apache License 2.0](LICENSE) +Apache-2.0 — see [LICENSE](LICENSE). diff --git a/docs/superpowers/plans/2026-08-31-gothic-lock-solver.md b/docs/superpowers/plans/2026-08-31-gothic-lock-solver.md deleted file mode 100644 index a477f22..0000000 --- a/docs/superpowers/plans/2026-08-31-gothic-lock-solver.md +++ /dev/null @@ -1,1430 +0,0 @@ -# План реализации решателя замков Gothic 1 Remake - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Создать проверяемую Node.js CLI-утилиту, которая по состоянию замка и направленной матрице связей находит минимальную последовательность сгруппированных команд `пластина + направление + xN`. - -**Status:** Выполнен 2026-08-31. Все задачи реализованы и проверены. - -**Architecture:** Вычислительное ядро состоит из чистых ESM-модулей: валидация модели, кодирование состояния, переходы и поиск в ширину. CLI отвечает только за файлы, аргументы, форматирование и коды выхода; благодаря этому ядро позднее можно напрямую использовать на сайте. - -**Tech Stack:** Node.js 20+, pnpm, JavaScript ESM (`.mjs`), встроенные `node:test`, `node:assert`, `node:fs`, `node:child_process`; сторонних runtime- и test-зависимостей нет. - -**Spec:** `../specs/2026-08-31-gothic-lock-solver.md` - -## Global Constraints - -- Число пластин: от 2 до 7; у каждой ровно 7 позиций. -- Цель: все пластины в позиции `4`. -- Влево означает `+1`, вправо означает `-1`. -- Матрица направленная, коэффициенты только `-1`, `0`, `1`, диагональ только `0`. -- Связи применяются один раз от выбранной строки, без обратного вывода связей и без каскада. -- Если хотя бы одна затронутая пластина не может пройти все `steps`, команда полностью заблокирована. -- Стоимость каждого допустимого макрохода `x1..x6` равна одной команде. -- Среди кратчайших решений порядок действий: пластина по возрастанию, `left` перед `right`, большее `steps` перед меньшим. -- Состояния кодируются числами в системе счисления по основанию 7; BFS использует типизированные массивы. -- Ядро не читает и не пишет файлы и не зависит от CLI. -- Пользовательские сообщения CLI выводятся по-русски; значения `direction` в JSON: только `left` и `right`. -- Грамматика CLI строгая: `` либо ` --output `; неизвестные флаги и лишние аргументы дают код `1`. -- Дополнительные свойства входного JSON игнорируются; нормализованная модель содержит только `state` и `links`. -- Обычный результат идет в stdout, ошибки - в stderr; `unsolvable` с `--output` сначала записывает JSON, затем завершает процесс кодом `2`. -- PDF не используется. Сайт, распознавание изображений, база известных замков и преждевременная оптимизация не входят в план. -- Разработка идет по TDD: сначала наблюдаемый правильный провал теста, затем минимальная реализация, затем полный зеленый прогон. - ---- - -## Карта файлов - -| Файл | Ответственность | -|---|---| -| `package.json` | Метаданные Node.js, команды `test` и `solve`, ограничение Node.js 20+. | -| `.gitignore` | Исключение временных данных, зависимостей и приложенных исходных материалов. | -| `solve-lock.mjs` | Минимальная исполняемая точка входа. | -| `src/lock-definition.mjs` | Разбор JSON и полная валидация определения замка. | -| `src/state-codec.mjs` | Кодирование и декодирование состояний в base-7. | -| `src/transition.mjs` | Атомарное применение команды и генерация переходов в стабильном порядке. | -| `src/solver.mjs` | BFS, массивы родителей и восстановление кратчайшего пути. | -| `src/result.mjs` | Формирование JSON-результата, метрик и русского консольного текста. | -| `src/index.mjs` | Стабильный website-safe facade `solveLock` вычислительного ядра. | -| `src/cli.mjs` | Разбор аргументов, чтение/запись файлов и коды завершения. | -| `test/*.test.mjs` | Модульные, дифференциальные и интеграционные тесты. | -| `examples/lock.example.json` | Проверяемый пример входного замка. | -| `scripts/benchmark.mjs` | Ручное измерение предельного семипластинчатого случая. | -| `.codex/skills/gothic-lock-solver/` | Проектный skill интерактивного сбора модели и запуска решателя. | - ---- - -### Task 1: Каркас проекта и валидация определения замка - -**Files:** -- Create: `package.json` -- Create: `.gitignore` -- Create: `src/lock-definition.mjs` -- Create: `test/lock-definition.test.mjs` -- Include in first commit: `docs/superpowers/specs/2026-08-31-gothic-lock-solver.md` -- Include in first commit: `docs/superpowers/plans/2026-08-31-gothic-lock-solver.md` - -**Interfaces:** -- Consumes: строку JSON или неизвестное JavaScript-значение. -- Produces: `LockInputError`, `parseLockDefinition(text)`, `validateLockDefinition(value)`. -- Return shape: `{ state: number[], links: number[][] }`; функции возвращают защитные копии массивов. - -- [x] **Step 1: Подготовить Git без затрагивания приложенного PDF** - -Run: - -```bash -git rev-parse --is-inside-work-tree -``` - -Expected: если команда завершается ошибкой, выполнить `git init`. Не добавлять `project_sources/` в индекс. - -Run: - -```bash -mkdir -p src test -``` - -Expected: каталоги для первых production- и test-файлов существуют. - -- [x] **Step 2: Создать падающие тесты валидации** - -```js -// test/lock-definition.test.mjs -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { - LockInputError, - parseLockDefinition, - validateLockDefinition, -} from '../src/lock-definition.mjs'; - -const validLock = { - state: [7, 4, 1], - links: [ - [0, 1, -1], - [0, 0, 0], - [1, 0, 0], - ], -}; - -test('validateLockDefinition accepts and copies a valid directed lock', () => { - const result = validateLockDefinition(validLock); - assert.deepEqual(result, validLock); - assert.notEqual(result.state, validLock.state); - assert.notEqual(result.links[0], validLock.links[0]); -}); - -for (const [name, value, message] of [ - ['requires 2 to 7 plates', { state: [4], links: [[0]] }, /от 2 до 7/], - ['rejects a position outside 1..7', { ...validLock, state: [7, 8, 1] }, /state\[1\]/], - ['requires a square matrix', { ...validLock, links: [[0], [0], [0]] }, /links\[0\]/], - ['rejects an unknown coefficient', { ...validLock, links: [[0, 2, 0], [0, 0, 0], [0, 0, 0]] }, /-1, 0 или 1/], - ['requires a zero diagonal', { ...validLock, links: [[1, 0, 0], [0, 0, 0], [0, 0, 0]] }, /диагонали/], -]) { - test(name, () => { - assert.throws(() => validateLockDefinition(value), message); - }); -} - -test('parseLockDefinition reports malformed JSON as LockInputError', () => { - assert.throws( - () => parseLockDefinition('{'), - (error) => error instanceof LockInputError && /Некорректный JSON/.test(error.message), - ); -}); -``` - -- [x] **Step 3: Запустить тест и увидеть правильный RED** - -Run: - -```bash -node --test test/lock-definition.test.mjs -``` - -Expected: FAIL с `ERR_MODULE_NOT_FOUND` для `src/lock-definition.mjs`. - -- [x] **Step 4: Создать минимальный проект и реализацию валидации** - -```json -{ - "name": "gothic-lock-solver", - "version": "0.1.0", - "private": true, - "type": "module", - "engines": { - "node": ">=20" - }, - "scripts": { - "test": "node --test" - } -} -``` - -```gitignore -# .gitignore -node_modules/ -coverage/ -*.tmp -project_sources/ -``` - -```js -// src/lock-definition.mjs -export class LockInputError extends Error { - constructor(message, options) { - super(message, options); - this.name = 'LockInputError'; - } -} - -export function validateLockDefinition(value) { - if (value === null || typeof value !== 'object' || Array.isArray(value)) { - throw new LockInputError('Определение замка должно быть JSON-объектом.'); - } - - const { state, links } = value; - if (!Array.isArray(state) || state.length < 2 || state.length > 7) { - throw new LockInputError('state должен содержать от 2 до 7 пластин.'); - } - - state.forEach((position, index) => { - if (!Number.isInteger(position) || position < 1 || position > 7) { - throw new LockInputError(`state[${index}] должен быть целым числом от 1 до 7.`); - } - }); - - if (!Array.isArray(links) || links.length !== state.length) { - throw new LockInputError(`links должен быть матрицей ${state.length}x${state.length}.`); - } - - links.forEach((row, rowIndex) => { - if (!Array.isArray(row) || row.length !== state.length) { - throw new LockInputError(`links[${rowIndex}] должен содержать ${state.length} элементов.`); - } - row.forEach((coefficient, columnIndex) => { - if (![ -1, 0, 1 ].includes(coefficient)) { - throw new LockInputError(`links[${rowIndex}][${columnIndex}] должен быть -1, 0 или 1.`); - } - if (rowIndex === columnIndex && coefficient !== 0) { - throw new LockInputError(`links[${rowIndex}][${columnIndex}] на диагонали должен быть 0.`); - } - }); - }); - - return { - state: [...state], - links: links.map((row) => [...row]), - }; -} - -export function parseLockDefinition(text) { - try { - return validateLockDefinition(JSON.parse(text)); - } catch (error) { - if (error instanceof LockInputError) { - throw error; - } - throw new LockInputError(`Некорректный JSON: ${error.message}`, { cause: error }); - } -} -``` - -- [x] **Step 5: Запустить тесты и увидеть GREEN** - -Run: - -```bash -pnpm test -``` - -Expected: все тесты `lock-definition` PASS. - -- [x] **Step 6: Зафиксировать первый самостоятельно проверяемый результат** - -```bash -git add .gitignore package.json docs/superpowers/specs/2026-08-31-gothic-lock-solver.md docs/superpowers/plans/2026-08-31-gothic-lock-solver.md src/lock-definition.mjs test/lock-definition.test.mjs -git commit -m "feat: validate Gothic lock definitions" -``` - ---- - -### Task 2: Числовой кодек состояний - -**Files:** -- Create: `src/state-codec.mjs` -- Create: `test/state-codec.test.mjs` - -**Interfaces:** -- Consumes: `state: number[]`, `code: number`, `plateCount: number`. -- Produces: `stateSpaceSize(plateCount)`, `encodeState(state)`, `decodeState(code, plateCount)`, `createTargetState(plateCount)`. -- Encoding invariant: `[1, 1] -> 0`, `[7, 7] -> 48`, любой валидный вектор проходит round-trip без изменений. -- Порядок разрядов: `state[0]` - старший base-7 разряд; обязательный `plateCount` при декодировании сохраняет ведущие позиции `1`. - -- [x] **Step 1: Написать падающий тест кодека** - -```js -// test/state-codec.test.mjs -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { - createTargetState, - decodeState, - encodeState, - stateSpaceSize, -} from '../src/state-codec.mjs'; - -test('base-7 encoding has stable boundary values', () => { - assert.equal(encodeState([1, 1]), 0); - assert.equal(encodeState([7, 7]), 48); - assert.equal(stateSpaceSize(7), 823_543); -}); - -test('encodeState and decodeState round-trip a seven-plate state', () => { - const state = [7, 4, 1, 3, 6, 2, 5]; - assert.deepEqual(decodeState(encodeState(state), state.length), state); -}); - -test('createTargetState creates only center positions', () => { - assert.deepEqual(createTargetState(5), [4, 4, 4, 4, 4]); -}); -``` - -- [x] **Step 2: Подтвердить RED** - -Run: `node --test test/state-codec.test.mjs` -Expected: FAIL с `ERR_MODULE_NOT_FOUND`. - -- [x] **Step 3: Реализовать кодек без файловых и CLI-зависимостей** - -```js -// src/state-codec.mjs -export function stateSpaceSize(plateCount) { - return 7 ** plateCount; -} - -export function encodeState(state) { - let code = 0; - for (const position of state) { - code = code * 7 + (position - 1); - } - return code; -} - -export function decodeState(code, plateCount) { - const state = new Array(plateCount); - let remainder = code; - for (let index = plateCount - 1; index >= 0; index -= 1) { - state[index] = (remainder % 7) + 1; - remainder = Math.floor(remainder / 7); - } - return state; -} - -export function createTargetState(plateCount) { - return Array.from({ length: plateCount }, () => 4); -} -``` - -- [x] **Step 4: Подтвердить GREEN и отсутствие регрессии** - -Run: `pnpm test` -Expected: тесты `lock-definition` и `state-codec` PASS. - -- [x] **Step 5: Commit** - -```bash -git add src/state-codec.mjs test/state-codec.test.mjs -git commit -m "feat: encode lock states as base-7 integers" -``` - ---- - -### Task 3: Атомарные макрокоманды и стабильный генератор переходов - -**Files:** -- Create: `src/transition.mjs` -- Create: `test/transition.test.mjs` - -**Interfaces:** -- Consumes: `state`, `links`, `command`. -- Produces: `applyCommand(state, links, command): number[] | null`. -- Produces: `generateTransitions(state, links): Array<{ command, state }>`. -- Public command shape: `{ plate: number, direction: 'left' | 'right', steps: number }`; `plate` is one-based, `steps` is 1..6. -- `null` means the whole command is blocked; input arrays remain unchanged. - -- [x] **Step 1: Написать падающие тесты направления, инверсии, отсутствия каскада и блокировки** - -```js -// test/transition.test.mjs -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { applyCommand, generateTransitions } from '../src/transition.mjs'; - -test('applyCommand moves direct and inverse links by the full step count', () => { - const state = [3, 5, 4]; - const links = [ - [0, 1, -1], - [0, 0, 0], - [0, 0, 0], - ]; - const result = applyCommand(state, links, { plate: 1, direction: 'left', steps: 2 }); - assert.deepEqual(result, [5, 7, 2]); - assert.deepEqual(state, [3, 5, 4]); -}); - -test('applyCommand does not cascade through a moved target row', () => { - const links = [ - [0, 1, 0], - [0, 0, 1], - [0, 0, 0], - ]; - assert.deepEqual( - applyCommand([3, 5, 4], links, { plate: 1, direction: 'left', steps: 2 }), - [5, 7, 4], - ); -}); - -test('applyCommand atomically blocks when one linked plate cannot finish', () => { - const state = [3, 6]; - const links = [[0, 1], [0, 0]]; - assert.deepEqual( - applyCommand(state, links, { plate: 1, direction: 'left', steps: 1 }), - [4, 7], - ); - assert.equal( - applyCommand(state, links, { plate: 1, direction: 'left', steps: 2 }), - null, - ); - assert.deepEqual(state, [3, 6]); -}); - -test('a directed source row does not imply a reverse link', () => { - const links = [[0, 1], [0, 0]]; - assert.deepEqual( - applyCommand([3, 5], links, { plate: 2, direction: 'right', steps: 1 }), - [3, 4], - ); -}); - -test('generateTransitions uses plate, direction, descending steps order', () => { - const transitions = generateTransitions([4, 4], [[0, 0], [0, 0]]); - assert.deepEqual( - transitions.slice(0, 6).map(({ command }) => command), - [ - { plate: 1, direction: 'left', steps: 3 }, - { plate: 1, direction: 'left', steps: 2 }, - { plate: 1, direction: 'left', steps: 1 }, - { plate: 1, direction: 'right', steps: 3 }, - { plate: 1, direction: 'right', steps: 2 }, - { plate: 1, direction: 'right', steps: 1 }, - ], - ); -}); -``` - -- [x] **Step 2: Подтвердить RED** - -Run: `node --test test/transition.test.mjs` -Expected: FAIL с `ERR_MODULE_NOT_FOUND`. - -- [x] **Step 3: Реализовать один атомарный переход и генератор** - -```js -// src/transition.mjs -const DIRECTIONS = ['left', 'right']; - -export function applyCommand(state, links, command) { - const sourceIndex = command.plate - 1; - const directionDelta = command.direction === 'left' ? 1 : -1; - const nextState = [...state]; - - for (let targetIndex = 0; targetIndex < state.length; targetIndex += 1) { - const coefficient = targetIndex === sourceIndex - ? 1 - : links[sourceIndex][targetIndex]; - - if (coefficient === 0) { - continue; - } - - const nextPosition = state[targetIndex] - + directionDelta * coefficient * command.steps; - if (nextPosition < 1 || nextPosition > 7) { - return null; - } - nextState[targetIndex] = nextPosition; - } - - return nextState; -} - -export function generateTransitions(state, links) { - const transitions = []; - for (let plate = 1; plate <= state.length; plate += 1) { - const sourcePosition = state[plate - 1]; - for (const direction of DIRECTIONS) { - const sourceLimit = direction === 'left' - ? 7 - sourcePosition - : sourcePosition - 1; - for (let steps = sourceLimit; steps >= 1; steps -= 1) { - const command = { plate, direction, steps }; - const nextState = applyCommand(state, links, command); - if (nextState !== null) { - transitions.push({ command, state: nextState }); - } - } - } - } - return transitions; -} -``` - -- [x] **Step 4: Подтвердить GREEN полным прогоном** - -Run: `pnpm test` -Expected: все тесты PASS. - -- [x] **Step 5: Commit** - -```bash -git add src/transition.mjs test/transition.test.mjs -git commit -m "feat: model atomic linked plate commands" -``` - ---- - -### Task 4: BFS и восстановление минимальной комбинации - -**Files:** -- Create: `src/solver.mjs` -- Create: `test/solver.test.mjs` - -**Interfaces:** -- Consumes: проверенный объект `{ state, links }`. -- Produces: `findShortestCommands(definition): Command[] | null`. -- `[]` означает уже открытый замок; `null` означает недостижимую цель. -- Внутренние `packCommand`/`unpackCommand` кодируют одну команду в `Uint8Array`. - -- [x] **Step 1: Написать падающие тесты готовой цели, макрооптимальности, tie-break и недостижимости** - -```js -// test/solver.test.mjs -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { - findShortestCommands, - packCommand, - unpackCommand, -} from '../src/solver.mjs'; - -const noLinks = [[0, 0], [0, 0]]; - -test('packs every possible command into one byte and restores it', () => { - for (let plate = 1; plate <= 7; plate += 1) { - for (const direction of ['left', 'right']) { - for (let steps = 1; steps <= 6; steps += 1) { - const command = { plate, direction, steps }; - const packed = packCommand(command); - assert.ok(packed >= 0 && packed <= 83); - assert.deepEqual(unpackCommand(packed), command); - } - } - } -}); - -test('returns no commands for an already open lock', () => { - assert.deepEqual( - findShortestCommands({ state: [4, 4], links: noLinks }), - [], - ); -}); - -test('uses one macro command per independent plate', () => { - assert.deepEqual( - findShortestCommands({ state: [1, 7], links: noLinks }), - [ - { plate: 1, direction: 'left', steps: 3 }, - { plate: 2, direction: 'right', steps: 3 }, - ], - ); -}); - -test('prefers the lower plate when two one-command solutions exist', () => { - const links = [[0, 1], [1, 0]]; - assert.deepEqual( - findShortestCommands({ state: [1, 1], links }), - [{ plate: 1, direction: 'left', steps: 3 }], - ); -}); - -test('returns null when every possible move is blocked', () => { - const links = [[0, 1], [1, 0]]; - assert.equal(findShortestCommands({ state: [1, 7], links }), null); -}); - -test('prefers left over right when both start equally short solutions', () => { - const definition = { - state: [2, 7, 1], - links: [ - [0, 0, 0], - [-1, 0, -1], - [-1, -1, 0], - ], - }; - assert.deepEqual(findShortestCommands(definition), [ - { plate: 1, direction: 'left', steps: 5 }, - { plate: 3, direction: 'left', steps: 3 }, - ]); -}); - -test('prefers larger steps when earlier action fields are equal', () => { - const definition = { - state: [3, 2, 1], - links: [ - [0, 0, -1], - [-1, 0, -1], - [-1, -1, 0], - ], - }; - assert.deepEqual(findShortestCommands(definition), [ - { plate: 1, direction: 'right', steps: 2 }, - { plate: 3, direction: 'right', steps: 2 }, - { plate: 2, direction: 'right', steps: 2 }, - { plate: 1, direction: 'right', steps: 3 }, - { plate: 3, direction: 'right', steps: 2 }, - ]); -}); -``` - -- [x] **Step 2: Подтвердить RED** - -Run: `node --test test/solver.test.mjs` -Expected: FAIL с `ERR_MODULE_NOT_FOUND`. - -- [x] **Step 3: Реализовать упаковку команды и восстановление пути** - -```js -// начало src/solver.mjs -import { - createTargetState, - decodeState, - encodeState, - stateSpaceSize, -} from './state-codec.mjs'; -import { generateTransitions } from './transition.mjs'; - -const UNSEEN = -2; -const ROOT = -1; - -export function packCommand(command) { - const directionBit = command.direction === 'left' ? 0 : 1; - return (((command.plate - 1) * 2 + directionBit) * 6) + command.steps - 1; -} - -export function unpackCommand(packed) { - const steps = (packed % 6) + 1; - const plateDirection = Math.floor(packed / 6); - return { - plate: Math.floor(plateDirection / 2) + 1, - direction: plateDirection % 2 === 0 ? 'left' : 'right', - steps, - }; -} - -function reconstructCommands(startCode, targetCode, parents, actions) { - const commands = []; - let code = targetCode; - while (code !== startCode) { - commands.push(unpackCommand(actions[code])); - code = parents[code]; - } - commands.reverse(); - return commands; -} -``` - -- [x] **Step 4: Реализовать минимальный BFS на типизированных массивах** - -```js -// продолжение src/solver.mjs -export function findShortestCommands({ state, links }) { - const plateCount = state.length; - const startCode = encodeState(state); - const targetCode = encodeState(createTargetState(plateCount)); - if (startCode === targetCode) { - return []; - } - - const size = stateSpaceSize(plateCount); - const parents = new Int32Array(size); - parents.fill(UNSEEN); - const actions = new Uint8Array(size); - const queue = new Int32Array(size); - - parents[startCode] = ROOT; - let head = 0; - let tail = 0; - queue[tail] = startCode; - tail += 1; - - while (head < tail) { - const currentCode = queue[head]; - head += 1; - const currentState = decodeState(currentCode, plateCount); - - for (const transition of generateTransitions(currentState, links)) { - const nextCode = encodeState(transition.state); - if (parents[nextCode] !== UNSEEN) { - continue; - } - - parents[nextCode] = currentCode; - actions[nextCode] = packCommand(transition.command); - if (nextCode === targetCode) { - return reconstructCommands(startCode, targetCode, parents, actions); - } - queue[tail] = nextCode; - tail += 1; - } - } - - return null; -} -``` - -- [x] **Step 5: Подтвердить кратчайший и стабильный GREEN** - -Run: `pnpm test` -Expected: все тесты PASS; независимый замок решается двумя командами, а равный однокомандный путь выбирает пластину 1. - -- [x] **Step 6: Commit** - -```bash -git add src/solver.mjs test/solver.test.mjs -git commit -m "feat: find shortest lock command sequence" -``` - ---- - -### Task 5: Результат, метрики и консольное представление - -**Files:** -- Create: `src/result.mjs` -- Create: `src/index.mjs` -- Create: `test/result.test.mjs` - -**Interfaces:** -- Consumes: проверенное определение замка и `Command[] | null`. -- Produces: `createSolveResult(definition, commands)` строго по JSON-структуре спеки. -- Produces: `formatConsoleResult(result): string` без завершающего перевода строки. -- Produces: `solveLock(value)` как единственную стабильную высокоуровневую функцию для будущего сайта. - -- [x] **Step 1: Написать падающие тесты solved, already-open и unsolvable** - -```js -// test/result.test.mjs -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { solveLock } from '../src/index.mjs'; -import { createSolveResult, formatConsoleResult } from '../src/result.mjs'; - -const definition = { - state: [1, 7], - links: [[0, 0], [0, 0]], -}; -const commands = [ - { plate: 1, direction: 'left', steps: 3 }, - { plate: 2, direction: 'right', steps: 3 }, -]; - -test('createSolveResult replays commands and calculates metrics', () => { - const result = createSolveResult(definition, commands); - assert.deepEqual(result.finalState, [4, 4]); - assert.deepEqual(result.metrics, { - commands: 2, - divisions: 6, - plateSwitches: 1, - }); -}); - -test('formatConsoleResult renders grouped Russian commands', () => { - const result = createSolveResult(definition, commands); - assert.equal( - formatConsoleResult(result), - 'Найдено команд: 2\n' - + '1. Пластина 1 - влево x3\n' - + '2. Пластина 2 - вправо x3', - ); -}); - -test('already-open and unsolvable results have distinct text', () => { - const open = createSolveResult({ state: [4, 4], links: definition.links }, []); - const unsolvable = createSolveResult(definition, null); - assert.equal(formatConsoleResult(open), 'Замок уже открыт.'); - assert.equal(formatConsoleResult(unsolvable), 'Решение не найдено.'); - assert.deepEqual(unsolvable.commands, []); -}); - -test('solveLock validates and solves through the website-safe facade', () => { - const result = solveLock(definition); - assert.equal(result.status, 'solved'); - assert.deepEqual(result.finalState, [4, 4]); -}); -``` - -- [x] **Step 2: Подтвердить RED** - -Run: `node --test test/result.test.mjs` -Expected: FAIL с `ERR_MODULE_NOT_FOUND`. - -- [x] **Step 3: Реализовать построение результата через повторное применение команд** - -```js -// src/result.mjs -import { createTargetState } from './state-codec.mjs'; -import { applyCommand } from './transition.mjs'; - -export function createSolveResult(definition, commands) { - const initialState = [...definition.state]; - const targetState = createTargetState(initialState.length); - - if (commands === null) { - return { status: 'unsolvable', initialState, targetState, commands: [] }; - } - - let finalState = initialState; - for (const command of commands) { - const nextState = applyCommand(finalState, definition.links, command); - if (nextState === null) { - throw new Error('Решатель вернул заблокированную команду.'); - } - finalState = nextState; - } - - if (!finalState.every((position) => position === 4)) { - throw new Error('Решатель не привел замок к целевому состоянию.'); - } - - const plateSwitches = commands.reduce((count, command, index) => { - if (index === 0 || command.plate === commands[index - 1].plate) { - return count; - } - return count + 1; - }, 0); - - return { - status: 'solved', - initialState, - targetState, - commands: commands.map((command) => ({ ...command })), - finalState, - metrics: { - commands: commands.length, - divisions: commands.reduce((sum, command) => sum + command.steps, 0), - plateSwitches, - }, - }; -} - -export function formatConsoleResult(result) { - if (result.status === 'unsolvable') { - return 'Решение не найдено.'; - } - if (result.commands.length === 0) { - return 'Замок уже открыт.'; - } - - const directionNames = { left: 'влево', right: 'вправо' }; - const lines = result.commands.map((command, index) => ( - `${index + 1}. Пластина ${command.plate} - ${directionNames[command.direction]} x${command.steps}` - )); - return [`Найдено команд: ${result.commands.length}`, ...lines].join('\n'); -} -``` - -```js -// src/index.mjs -import { validateLockDefinition } from './lock-definition.mjs'; -import { createSolveResult } from './result.mjs'; -import { findShortestCommands } from './solver.mjs'; - -export function solveLock(value) { - const definition = validateLockDefinition(value); - return createSolveResult(definition, findShortestCommands(definition)); -} - -export { applyCommand, generateTransitions } from './transition.mjs'; -``` - -- [x] **Step 4: Подтвердить GREEN** - -Run: `pnpm test` -Expected: все тесты PASS. - -- [x] **Step 5: Commit** - -```bash -git add src/index.mjs src/result.mjs test/result.test.mjs -git commit -m "feat: format lock solving results" -``` - ---- - -### Task 6: CLI, консоль, создание и перезапись JSON-файла - -**Files:** -- Create: `src/cli.mjs` -- Create: `solve-lock.mjs` -- Create: `test/cli.test.mjs` -- Modify: `package.json` - -**Interfaces:** -- Produces: `parseArgs(argv): { inputPath: string, outputPath: string | null }`. -- Produces: `runCli(argv, streams?): Promise<0 | 1 | 2>`. -- `solve-lock.mjs` передает `process.argv.slice(2)` и устанавливает `process.exitCode`. -- При `--output` файл записывается до печати успешного результата; `writeFile` полностью перезаписывает существующий файл. - -- [x] **Step 1: Написать интеграционные тесты реального процесса Node.js** - -```js -// test/cli.test.mjs -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { spawnSync } from 'node:child_process'; -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const projectRoot = dirname(dirname(fileURLToPath(import.meta.url))); -const entry = join(projectRoot, 'solve-lock.mjs'); - -async function createCase(t, definition) { - const directory = await mkdtemp(join(tmpdir(), 'gothic-lock-solver-')); - t.after(() => rm(directory, { recursive: true, force: true })); - const inputPath = join(directory, 'lock.json'); - await writeFile(inputPath, JSON.stringify(definition), 'utf8'); - return { directory, inputPath }; -} - -function run(args) { - return spawnSync(process.execPath, [entry, ...args], { encoding: 'utf8' }); -} - -test('prints a solution without creating an output file by default', async (t) => { - const { directory, inputPath } = await createCase(t, { - state: [1, 7], - links: [[0, 0], [0, 0]], - }); - const outputPath = join(directory, 'solution.json'); - const result = run([inputPath]); - assert.equal(result.status, 0); - assert.match(result.stdout, /Найдено команд: 2/); - assert.equal(result.stderr, ''); - await assert.rejects(readFile(outputPath, 'utf8'), { code: 'ENOENT' }); -}); - -test('--output creates and then overwrites a JSON result', async (t) => { - const { directory, inputPath } = await createCase(t, { - state: [1, 7], - links: [[0, 0], [0, 0]], - }); - const outputPath = join(directory, 'solution.json'); - const created = run([inputPath, '--output', outputPath]); - assert.equal(created.status, 0); - assert.equal(JSON.parse(await readFile(outputPath, 'utf8')).status, 'solved'); - - await writeFile(outputPath, 'stale'.repeat(1_000), 'utf8'); - const overwritten = run([inputPath, '--output', outputPath]); - assert.equal(overwritten.status, 0); - assert.match(overwritten.stdout, /Пластина 1 - влево x3/); - assert.equal(JSON.parse(await readFile(outputPath, 'utf8')).status, 'solved'); -}); - -test('invalid JSON exits with code 1 and a Russian error', async (t) => { - const { inputPath } = await createCase(t, { - state: [4, 4], - links: [[0, 0], [0, 0]], - }); - await writeFile(inputPath, '{', 'utf8'); - const result = run([inputPath]); - assert.equal(result.status, 1); - assert.match(result.stderr, /Ошибка: Некорректный JSON/); -}); - -test('an unsolvable lock writes JSON and exits with code 2', async (t) => { - const { directory, inputPath } = await createCase(t, { - state: [1, 7], - links: [[0, 1], [1, 0]], - }); - const outputPath = join(directory, 'solution.json'); - const result = run([inputPath, '--output', outputPath]); - assert.equal(result.status, 2); - assert.match(result.stdout, /Решение не найдено/); - assert.equal(JSON.parse(await readFile(outputPath, 'utf8')).status, 'unsolvable'); -}); - -test('read, write, and CLI grammar errors exit with code 1', async (t) => { - const { directory, inputPath } = await createCase(t, { - state: [4, 4], - links: [[0, 0], [0, 0]], - }); - - const missingInput = run([join(directory, 'missing.json')]); - assert.equal(missingInput.status, 1); - assert.match(missingInput.stderr, /Ошибка:/); - - for (const args of [ - [inputPath, '--unknown'], - [inputPath, '--output'], - [inputPath, 'extra'], - ]) { - const invalidArgs = run(args); - assert.equal(invalidArgs.status, 1); - assert.match(invalidArgs.stderr, /Ошибка: Использование:/); - } - - const writeError = run([inputPath, '--output', directory]); - assert.equal(writeError.status, 1); - assert.match(writeError.stderr, /Ошибка:/); -}); -``` - -- [x] **Step 2: Подтвердить RED** - -Run: `node --test test/cli.test.mjs` -Expected: FAIL, потому что `solve-lock.mjs` отсутствует. - -- [x] **Step 3: Реализовать CLI-оркестрацию и точные коды выхода** - -```js -// src/cli.mjs -import { readFile, writeFile } from 'node:fs/promises'; -import { solveLock } from './index.mjs'; -import { parseLockDefinition } from './lock-definition.mjs'; -import { formatConsoleResult } from './result.mjs'; - -export function parseArgs(argv) { - if (argv.length === 1) { - return { inputPath: argv[0], outputPath: null }; - } - if (argv.length === 3 && argv[1] === '--output') { - return { inputPath: argv[0], outputPath: argv[2] }; - } - throw new Error('Использование: node solve-lock.mjs [--output ]'); -} - -export async function runCli(argv, streams = {}) { - const stdout = streams.stdout ?? process.stdout; - const stderr = streams.stderr ?? process.stderr; - - try { - const { inputPath, outputPath } = parseArgs(argv); - const definition = parseLockDefinition(await readFile(inputPath, 'utf8')); - const result = solveLock(definition); - - if (outputPath !== null) { - await writeFile(outputPath, `${JSON.stringify(result, null, 2)}\n`, 'utf8'); - } - stdout.write(`${formatConsoleResult(result)}\n`); - return result.status === 'unsolvable' ? 2 : 0; - } catch (error) { - stderr.write(`Ошибка: ${error.message}\n`); - return 1; - } -} -``` - -```js -#!/usr/bin/env node -// solve-lock.mjs -import { runCli } from './src/cli.mjs'; - -process.exitCode = await runCli(process.argv.slice(2)); -``` - -Replace `package.json` with the complete final content: - -```json -{ - "name": "gothic-lock-solver", - "version": "0.1.0", - "private": true, - "type": "module", - "engines": { - "node": ">=20" - }, - "scripts": { - "test": "node --test", - "solve": "node solve-lock.mjs" - }, - "bin": { - "gothic-lock-solver": "./solve-lock.mjs" - } -} -``` - -- [x] **Step 4: Сделать точку входа исполняемой и подтвердить GREEN** - -Run: - -```bash -chmod +x solve-lock.mjs -pnpm test -``` - -Expected: все модульные и CLI-тесты PASS. - -- [x] **Step 5: Отдельно проверить ошибки аргументов** - -Run: - -```bash -node solve-lock.mjs -``` - -Expected: stderr начинается с `Ошибка: Использование:`, код выхода `1`. - -- [x] **Step 6: Commit** - -```bash -git add package.json solve-lock.mjs src/cli.mjs test/cli.test.mjs -git commit -m "feat: add Gothic lock solver CLI" -``` - ---- - -### Task 7: Сквозной пример и измерение предельного случая - -**Files:** -- Create: `examples/lock.example.json` -- Create: `test/e2e.test.mjs` -- Create: `test/differential.test.mjs` -- Create: `scripts/benchmark.mjs` - -**Interfaces:** -- Consumes: готовый CLI и внутреннюю тестируемую `findShortestCommands`; стабильным внешним facade остается `solveLock` из `src/index.mjs`. -- Produces: воспроизводимый пяти-пластинчатый пример и ручной benchmark без жесткого временного порога. - -- [x] **Step 1: Сначала создать только падающий сквозной тест** - -Run: - -```bash -mkdir -p examples scripts -``` - -Expected: каталоги существуют, но `examples/lock.example.json` еще отсутствует, поэтому следующий тест действительно будет RED. - -```js -// test/e2e.test.mjs -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { spawnSync } from 'node:child_process'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const projectRoot = dirname(dirname(fileURLToPath(import.meta.url))); - -test('example lock produces the documented four-command solution', () => { - const result = spawnSync( - process.execPath, - [join(projectRoot, 'solve-lock.mjs'), join(projectRoot, 'examples/lock.example.json')], - { encoding: 'utf8' }, - ); - assert.equal(result.status, 0); - assert.equal( - result.stdout, - 'Найдено команд: 4\n' - + '1. Пластина 1 - влево x3\n' - + '2. Пластина 2 - вправо x3\n' - + '3. Пластина 4 - влево x2\n' - + '4. Пластина 5 - вправо x2\n', - ); -}); -``` - -- [x] **Step 2: Подтвердить RED до создания example-файла** - -Run: `node --test test/e2e.test.mjs` -Expected: FAIL из-за отсутствующего `examples/lock.example.json`. - -- [x] **Step 3: Добавить example-файл и получить GREEN** - -```json -{ - "state": [1, 7, 4, 2, 6], - "links": [ - [0, 0, 0, 0, 0], - [0, 0, 0, 0, 0], - [0, 0, 0, 0, 0], - [0, 0, 0, 0, 0], - [0, 0, 0, 0, 0] - ] -} -``` - -Run: `pnpm test` -Expected: все тесты PASS, включая точный четырехкомандный вывод. - -- [x] **Step 4: Добавить дифференциальную проверку BFS для всех двухпластинчатых состояний и матриц** - -```js -// test/differential.test.mjs -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { createTargetState } from '../src/state-codec.mjs'; -import { findShortestCommands } from '../src/solver.mjs'; -import { generateTransitions } from '../src/transition.mjs'; - -function referenceBfs(definition) { - const targetKey = createTargetState(definition.state.length).join(','); - const startKey = definition.state.join(','); - if (startKey === targetKey) { - return []; - } - - const queue = [{ state: definition.state, commands: [] }]; - const visited = new Set([startKey]); - for (let head = 0; head < queue.length; head += 1) { - const current = queue[head]; - for (const transition of generateTransitions(current.state, definition.links)) { - const key = transition.state.join(','); - if (visited.has(key)) { - continue; - } - const commands = [...current.commands, transition.command]; - if (key === targetKey) { - return commands; - } - visited.add(key); - queue.push({ state: transition.state, commands }); - } - } - return null; -} - -test('typed-array BFS matches reference BFS for every N=2 lock', () => { - for (const link12 of [-1, 0, 1]) { - for (const link21 of [-1, 0, 1]) { - const links = [[0, link12], [link21, 0]]; - for (let first = 1; first <= 7; first += 1) { - for (let second = 1; second <= 7; second += 1) { - const definition = { state: [first, second], links }; - assert.deepEqual( - findShortestCommands(definition), - referenceBfs(definition), - `state=${first},${second}; links=${link12},${link21}`, - ); - } - } - } - } -}); -``` - -Run: `node --test test/differential.test.mjs` -Expected: PASS для всех `9 * 49 = 441` конфигураций. - -- [x] **Step 5: Создать benchmark максимального пространства без performance-assertion** - -```js -// scripts/benchmark.mjs -import { performance } from 'node:perf_hooks'; -import { findShortestCommands } from '../src/solver.mjs'; - -const plateCount = 7; -const definition = { - state: Array.from({ length: plateCount }, () => 1), - links: Array.from( - { length: plateCount }, - () => Array.from({ length: plateCount }, () => 0), - ), -}; - -const startedAt = performance.now(); -const commands = findShortestCommands(definition); -const elapsedMs = performance.now() - startedAt; - -console.log(JSON.stringify({ - elapsedMs: Math.round(elapsedMs), - commands: commands?.length ?? null, - rssMiB: Math.round(process.memoryUsage().rss / 1024 / 1024), -}, null, 2)); - -if (commands?.length !== 7) { - process.exitCode = 1; -} -``` - -- [x] **Step 6: Выполнить функциональную и предельную проверку** - -Run: - -```bash -pnpm test -node scripts/benchmark.mjs -``` - -Expected: тесты PASS; benchmark завершается с кодом `0`, сообщает `commands: 7` и фактические `elapsedMs`/`rssMiB`. Не менять алгоритм только из-за субъективной оценки времени; сначала зафиксировать измерение. - -- [x] **Step 7: Commit** - -```bash -git add examples/lock.example.json test/e2e.test.mjs test/differential.test.mjs scripts/benchmark.mjs -git commit -m "test: verify solver end to end" -``` - ---- - -### Task 8: Проектный skill для интерактивного решения замков - -**Files:** -- Create: `.codex/skills/gothic-lock-solver/SKILL.md` -- Create: `.codex/skills/gothic-lock-solver/agents/openai.yaml` - -**Interfaces:** -- Consumes: наблюдаемое состояние и подтвержденные пользователем направленные связи. -- Produces: полный `lock.json`, запуск `solve-lock.mjs` и сгруппированную комбинацию. -- Depends on: стабильные интерфейсы CLI из Task 6 и проверенный пример из Task 7. - -- [x] **Step 1: Перед редактированием прочитать обязательные skill-инструкции** - -Read and follow полностью: - -```text -superpowers:writing-skills -skill-creator -``` - -Expected: дальнейшее создание следует их требованиям, а изменения skill будут включены в Git-коммит. - -- [x] **Step 2: Инициализировать проектный skill штатным генератором** - -Run: - -```bash -python3 /root/.codex/skills/.system/skill-creator/scripts/init_skill.py gothic-lock-solver --path .codex/skills -``` - -Expected: созданы `SKILL.md` и `agents/openai.yaml`; незаполненные scaffold-подсказки не остаются в финальном файле. - -- [x] **Step 3: Заменить `SKILL.md` точными рабочими инструкциями** - -````markdown ---- -name: gothic-lock-solver -description: Собирает модель пластинчатого замка Gothic 1 Remake и запускает проектный Node.js-решатель. Используй, когда пользователь сообщает позиции или связи пластин либо просит кратчайшую комбинацию; не применяй к обычным кодовым замкам и не угадывай неподтвержденные связи. ---- - -# Gothic Lock Solver - -Используй вычислительное ядро проекта, а не языковую модель, для поиска комбинации. - -## Правила модели - -- Пластин от 2 до 7, позиции каждой от 1 до 7, цель всегда 4. -- Влево означает `+1`, вправо означает `-1`. -- Каждая строка матрицы направленная и независимая; обратную связь не подразумевай. -- Связи не каскадируют. Диагональ матрицы равна 0. -- Если зависимая пластина не может выполнить весь сдвиг, команда заблокирована полностью. -- Не используй приложенный PDF в этом workflow. - -## Сбор входа - -1. Зафиксируй число пластин и текущий вектор слева направо. -2. Для каждой исходной пластины отдельно зафиксируй, какие другие пластины движутся вместе с ней. -3. Запиши `1` для того же направления, `-1` для противоположного, `0` только для подтвержденного отсутствия связи. -4. Не запускай решатель, пока в каждой строке не устранены неизвестные связи. -5. Создай JSON с полями `state` и `links` по схеме `docs/superpowers/specs/2026-08-31-gothic-lock-solver.md`. - -## Запуск - -Разреши корень проекта как три уровня вверх от каталога этого skill. Запусти: - -```bash -node /solve-lock.mjs -``` - -Добавляй `--output ` только когда пользователь просит файл результата. - -Передай пользователю команды без перегруппировки и с исходной нумерацией пластин: - -```text -Пластина K - влево|вправо xS -``` - -## Проверка в игре - -После каждой выполненной команды сверяй наблюдаемое состояние с рассчитанным, если пользователь сообщает его. При первом расхождении останови текущую комбинацию, зафиксируй фактический вектор и уточни только затронутую строку матрицы. Не продолжай по заведомо неверной модели. -```` - -- [x] **Step 4: Сгенерировать интерфейсные метаданные** - -Run: - -```bash -python3 /root/.codex/skills/.system/skill-creator/scripts/generate_openai_yaml.py .codex/skills/gothic-lock-solver --interface "display_name=Gothic Lock Solver" --interface "short_description=Собирает модель замка и находит кратчайшую комбинацию" --interface "default_prompt=Помоги описать и решить замок Gothic 1 Remake." -``` - -Expected: `agents/openai.yaml` описывает только этот skill и сохраняет автоматическое обнаружение. - -- [x] **Step 5: Валидировать структуру skill** - -Run: - -```bash -python3 /root/.codex/skills/.system/skill-creator/scripts/quick_validate.py .codex/skills/gothic-lock-solver -``` - -Expected: validation PASS, нет scaffold-placeholder и ошибок frontmatter. - -- [x] **Step 6: Выполнить независимый forward-test** - -Передать свежему subagent только skill и запрос: - -```text -Используй skill gothic-lock-solver из .codex/skills/gothic-lock-solver. Замок: две пластины, состояние [1, 7], связей нет. Получи комбинацию через проектный решатель. PDF не используй. -``` - -Expected: агент формирует нулевую матрицу `2x2`, запускает CLI и возвращает ровно две команды: пластина 1 влево `x3`, затем пластина 2 вправо `x3`. - -- [x] **Step 7: Зафиксировать skill в Git** - -```bash -git add .codex/skills/gothic-lock-solver -git commit -m "feat: add Gothic lock solving skill" -``` - ---- - -## Покрытие утвержденной спеки - -| Раздел спеки | Реализующая задача | -|---|---| -| §1 Цель, Node.js 20+, ESM, отсутствие зависимостей | Tasks 1 и 6 | -| §2 Состояние, направления и направленная матрица | Tasks 1 и 3 | -| §3 Атомарная команда `x1..x6`, блокировка, отсутствие каскада | Task 3 | -| §4 JSON и валидация | Tasks 1 и 6 | -| §5 Base-7 и BFS на типизированных массивах | Tasks 2, 4 и 7 | -| §5.1 Независимое ядро и CLI-адаптер | Tasks 1-6 | -| §6 Минимум макрокоманд и стабильный tie-break | Tasks 3, 4 и 7 | -| §7 CLI, overwrite и коды `0/1/2` | Task 6 | -| §8 JSON-результат, метрики и русский вывод | Tasks 5 и 6 | -| §9 Все обязательные сценарии | Tasks 1-7 | -| §10 Не-цели | Global Constraints | -| §11 Проектный skill | Task 8 | - ---- - -## Финальная проверка перед завершением реализации - -- [x] Прочитать и применить `superpowers:verification-before-completion`. -- [x] Run: `pnpm test` — Expected: весь набор PASS без пропусков. -- [x] Run: `node solve-lock.mjs examples/lock.example.json` — Expected: точные четыре сгруппированные команды из Task 7. -- [x] Run: `node scripts/benchmark.mjs` — Expected: код `0`, `commands: 7`, измерения напечатаны. -- [x] Run: `python3 /root/.codex/skills/.system/skill-creator/scripts/quick_validate.py .codex/skills/gothic-lock-solver` — Expected: PASS. -- [x] Run: `git status --short` — Expected: пустой вывод; все изменения сохранены коммитами. -- [x] Не заявлять о завершении, пока каждая команда выше не выполнена заново после последнего изменения. diff --git a/docs/superpowers/plans/2026-09-07-simplify-library.md b/docs/superpowers/plans/2026-09-07-simplify-library.md new file mode 100644 index 0000000..2b023a6 --- /dev/null +++ b/docs/superpowers/plans/2026-09-07-simplify-library.md @@ -0,0 +1,67 @@ +# Flat CI and canary releases + +The owner replaced the previous delivery design with two simple workflows. +Keep the solver, public API, five runtime outputs and strict local checks. +This plan supersedes checkout-based benchmarks and the previous release flow. +The September 8 refinement removes `.github/scripts` entirely: configuration +belongs in Vite, ordinary build commands in package.json, and CI/release +coordination directly in workflow YAML. Do not relocate wrappers or implement +a custom publication recovery engine. +The further simplification uses the runner's GitHub CLI directly, a single latest +successful-run lookup for the benchmark, and standard download/pack/publish +commands for releases. Do not repeat CI's file and benchmark validation in the +publication job or build a CDN verification subsystem. + +## CI + +- One Node 24 build job checks types/lint, builds, checks the distribution, measures + benchmarks and uploads one package archive. +- A dependent test job uses six Ubuntu matrix rows: Node 22/24/26 and Chromium/ + Firefox/WebKit. Each row tests the same archive without rebuilding; browser rows + use Node 24. The Node 24/node row collects coverage. Diagnostic names include + Node version and environment. All rows must succeed for release. +- Measure the current built solver on all 45 fixed inputs. +- For a PR, use its target SHA; for main pushes, use the previous main SHA. +- Select the latest successful CI run for exactly that commit and its named report. +- If no unexpired artifact exists, explicitly skip comparison. No source checkout, + rebuild, npm lookup, previous-release fallback or synthetic baseline. +- Compare deterministic quality metrics; report historical timing/memory ratios + with their cross-run limitations. Keep generated output out of Git. +- Save the benchmark report separately from the tested package artifact, named + by commit SHA within the selected CI run. Full reruns overwrite these artifacts; + failed-test reruns reuse the successful build. Diagnostics retain attempt names. + +## Release + +- A separate workflow runs after successful main-push CI to publish a canary. +- CI leaves tracked files unchanged. Release preparation assigns the canary or + stable version by changing package/build version metadata only. +- Manual stable dispatch takes a successful main CI run ID and stable SemVer. +- Download only that run's tested build artifact. No algorithm rebuild. +- Stable packaging changes only package/build version metadata. Locally verify + that preparation preserves all other archive files; CI tests the built archive. +- Publish to npm through OIDC, include CDN links, attach five runtime + files, package archive and the benchmark report to the GitHub Release. +- Keep the two workflows readable: CI build and matrix test jobs, and one + publication job, using official pinned Actions. +- Retain direct-publish setup and the readiness switch for first-package bootstrap. + +## Execution + +1. Replace the programmatic build wrapper with direct Vite/tsc commands and a + shared Vite configuration for readable, minified and CLI builds. Preserve + global declarations and the source/checksum manifest; verify distribution tests. +2. Replace the artifact wrapper with official gh/jq commands in a candidate CI + workflow. Exercise exact-SHA, missing/expired, foreign-source and API-error cases. +3. Replace the release wrapper with explicit commands in a candidate release + workflow. Verify trusted main-run selection, metadata-only repack and file + identity using the existing tested archive, without publishing. +4. Delete tests belonging only to removed wrappers; retain mathematical, consumer, + configuration, benchmark-report and package checks. Run all local gates and + all 45 locks before installing the candidate workflow YAML. +5. Update Wiki, agent instructions and lint/type configuration. Independently + review both workflow commands and the complete diff, then run actionlint. +6. Update draft PR #3 and verify CI plus artifacts on the exact new head. Keep + minimum-(actions, shifts) behavior: lock-018 remains 14/45 and all 45 total 483/1713. + +No actual merge or package publication is authorized by this implementation task. diff --git a/docs/superpowers/specs/2026-08-31-gothic-lock-solver.md b/docs/superpowers/specs/2026-08-31-gothic-lock-solver.md deleted file mode 100644 index a0862d4..0000000 --- a/docs/superpowers/specs/2026-08-31-gothic-lock-solver.md +++ /dev/null @@ -1,358 +0,0 @@ -# Решатель замков Gothic 1 Remake - -## Спецификация 1.0 - -Дата: 2026-08-31 -Статус: утверждена пользователем и реализована. - -## 1. Цель - -Создать детерминированный решатель замков Gothic 1 Remake без использования искусственного интеллекта. - -Первая версия - CLI-утилита на Node.js. Она получает описание замка из JSON-файла, ищет выигрышную комбинацию и по умолчанию выводит ее в консоль. По флагу результат записывается в JSON-файл. - -Технические ограничения первой версии: - -- обычный JavaScript в формате ES modules (`.mjs`); -- Node.js 20 или новее; -- отсутствие сторонних runtime-зависимостей; -- тесты на встроенном `node:test`. - -В дальнейшем вычислительное ядро должно быть пригодно для использования на сайте. - -## 2. Модель замка - -### 2.1. Пластины и позиции - -- Количество пластин `N`: от 2 до 7. -- Каждая пластина имеет ровно 7 положений: от `1` до `7`. -- `1` - крайнее левое положение. -- `7` - крайнее правое положение. -- Целевое положение каждой пластины - `4`. -- Состояние замка - упорядоченный вектор длины `N`. - -Пример состояния пяти пластин: - -```json -[7, 4, 1, 3, 6] -``` - -Целевое состояние: - -```json -[4, 4, 4, 4, 4] -``` - -### 2.2. Направления - -- Движение влево увеличивает номер позиции: `+1`. -- Движение вправо уменьшает номер позиции: `-1`. - -Например, пластина в позиции `3` после одного деления влево окажется в позиции `4`. - -### 2.3. Матрица связей - -Связи задаются направленной квадратной матрицей `N x N`. - -Допустимые значения: - -- `0` - связи нет; -- `1` - зависимая пластина движется в том же направлении; -- `-1` - зависимая пластина движется в противоположном направлении. - -Строка `i` описывает только эффект ручного движения пластины `i` на остальные пластины. - -Матрица не обязана быть симметричной. Связь `i -> j` не означает наличие связи `j -> i`. - -Диагональ всегда равна нулю: - -```text -links[i][i] = 0 -``` - -Движение выбранной пластины учитывается отдельно и не кодируется связью с самой собой. - -Пример: - -```json -[ - [0, 1, -1], - [0, 0, 0], - [1, 0, 0] -] -``` - -При ручном движении первой пластины: - -- первая движется в выбранном направлении; -- вторая движется в том же направлении; -- третья движется в противоположном направлении. - -Движение второй пластины не затрагивает остальные. Движение третьей затрагивает первую, но не обязано повторять связи первой строки. - -## 3. Правило перехода - -Одна пользовательская команда имеет вид: - -```text -Пластина K - направление xS -``` - -где `K` - номер выбранной пластины, а `S` - количество делений от 1 до 6. - -Команда рассчитывается атомарно: - -1. Выбираются исходная пластина, направление и количество делений. -2. Исходная пластина сдвигается на `S` делений. -3. Все ненулевые связи из строки исходной пластины применяются один раз с тем же множителем `S`. -4. Связанные пластины не запускают собственные строки связей. Каскада и рекурсии нет. -5. Проверяются границы всех затронутых пластин. - -Если хотя бы одна затронутая пластина должна выйти за диапазон `1..7`: - -- весь переход считается заблокированным; -- ни одна пластина не перемещается; -- исходное состояние остается неизменным. - -Заблокированный переход не включается в найденную комбинацию. Решатель генерирует только полностью допустимые команды; частичное выполнение до первой блокировки не считается отдельным переходом. - -## 4. Формат входного файла - -Предварительно согласованный формат: - -```json -{ - "state": [7, 4, 1, 3, 6], - "links": [ - [0, 1, 0, 0, -1], - [0, 0, 0, 0, 0], - [1, 0, 0, 1, 0], - [0, 0, 0, 0, 1], - [0, -1, 0, 0, 0] - ] -} -``` - -Обязательная валидация: - -- `state` - массив длины от 2 до 7; -- каждое состояние - целое число от 1 до 7; -- `links` - квадратная матрица того же размера; -- каждый коэффициент - только `-1`, `0` или `1`; -- каждый диагональный коэффициент - `0`. - -Некорректный вход должен завершать программу с понятной ошибкой и ненулевым кодом выхода. - -## 5. Алгоритм - -Выбран поиск в ширину по конечному графу состояний. - -Это именно граф, а не обычное дерево: одно состояние может быть получено разными последовательностями, а каждый успешный переход обратим противоположным движением той же исходной пластины. Повторно посещать уже обработанное состояние нельзя. - -Поиск в ширину выбран потому, что он: - -- гарантирует кратчайшее решение согласно окончательно выбранной стоимости перехода; -- определяет, что цель недостижима; -- имеет предсказуемые верхние границы; -- не требует эвристики или очереди с приоритетом. - -Количество возможных состояний: - -```text -S = 7^N -``` - -При `N = 7`: - -```text -S = 823 543 -``` - -Для одной пластины из любого состояния существует не более шести возможных ненулевых сдвигов суммарно по обоим направлениям. Поэтому: - -- не более `6N = 42` допустимых команд-кандидатов из состояния; -- не более `34 588 806` сгенерированных переходов по всему пространству при `N = 7`; -- не более примерно `242,2 млн` простых проверок координат при наивной проверке каждой команды; -- ориентировочно `7-8 МБ` служебной памяти при числовом кодировании состояний и использовании типизированных массивов. - -Временная сложность в худшем случае: - -```text -O(N^2 * 7^N) -``` - -Память: - -```text -O(7^N) -``` - -Рекомендуемое внутреннее представление состояния - одно целое число, кодирующее позиции пластин в системе счисления по основанию 7. - -Двунаправленный поиск, A*, Dijkstra и IDDFS не входят в первую версию. Их можно пересмотреть только после измерения обычного поиска в ширину на предельных случаях. - -### 5.1. Компоненты вычислительного ядра - -Решение разделяется на независимые модули: - -1. Загрузка и валидация входного JSON. -2. Кодирование и декодирование состояния числом в системе счисления по основанию 7. -3. Чистая функция применения одной команды к состоянию. -4. Генератор всех допустимых команд из состояния. -5. Поиск в ширину с таблицами посещенных состояний, родителей и команд. -6. Восстановление последовательности команд. -7. Формирование JSON-результата и человекочитаемого консольного вывода. -8. CLI-оболочка для аргументов, кодов выхода и записи файла. - -Вычислительное ядро не должно зависеть от CLI или файловой системы, чтобы позднее его можно было перенести на сайт. - -## 6. Выбор среди нескольких решений - -Стоимость одного перехода равна одной пользовательской команде независимо от количества делений `S`. - -Главный критерий оптимальности: - -```text -минимальное количество команд -``` - -Это напрямую минимизирует количество переключений между пластинами. Две соседние команды для одной пластины всегда можно объединить или сократить, поэтому они не могут входить в минимальное решение как отдельные команды. - -Одна команда сразу отображается в сгруппированном виде: - -```text -Пластина 3 - влево x5 -``` - -Если существует несколько решений с одинаковым минимальным количеством команд, поиск использует стабильный порядок: - -1. меньший номер пластины; -2. движение влево перед движением вправо; -3. большее количество делений перед меньшим. - -FIFO-очередь поиска в ширину и такой порядок генерации переходов дают детерминированно первое решение среди равных. - -## 7. Интерфейс командной строки - -Входной файл обязателен: - -```bash -node solve-lock.mjs lock.json -``` - -По умолчанию комбинация выводится в консоль. - -Для записи результата в файл: - -```bash -node solve-lock.mjs lock.json --output solution.json -``` - -Правила `--output`: - -- отсутствующий файл создается; -- существующий файл полностью перезаписывается; -- без флага никакой выходной файл не создается. -- с флагом JSON-файл записывается дополнительно, а человекочитаемый результат по-прежнему выводится в консоль. - -Коды завершения: - -- `0` - решение найдено или замок уже находится в целевом состоянии; -- `1` - ошибка входных данных, аргументов CLI, чтения или записи; -- `2` - корректный замок не имеет решения из указанного состояния. - -## 8. Структура результата - -JSON-результат должен позволять проверить решение программно и показать его человеку: - -- `metrics.commands` - количество пользовательских команд; -- `metrics.divisions` - сумма `steps` выбранных пластин без дополнительного подсчета движений связанных пластин; -- `metrics.plateSwitches` - количество смен выбранной пластины между соседними командами. - -```json -{ - "status": "solved", - "initialState": [7, 4, 1], - "targetState": [4, 4, 4], - "commands": [ - { - "plate": 1, - "direction": "right", - "steps": 3 - } - ], - "finalState": [4, 4, 4], - "metrics": { - "commands": 1, - "divisions": 3, - "plateSwitches": 0 - } -} -``` - -Если решения нет: - -```json -{ - "status": "unsolvable", - "initialState": [1, 1, 1], - "targetState": [4, 4, 4], - "commands": [] -} -``` - -Консольное представление найденного решения: - -```text -Найдено команд: 2 -1. Пластина 1 - влево x3 -2. Пластина 3 - вправо x2 -``` - -Для уже открытого замка выводится `Замок уже открыт.`. Для недостижимой цели выводится `Решение не найдено.`. - -## 9. Требования к тестированию - -Минимальный набор сценариев: - -1. Начальное состояние уже является целевым. -2. Независимые пластины без связей. -3. Прямая связь `1`. -4. Инвертированная связь `-1`. -5. Несимметричные направленные связи. -6. Отсутствие каскадного применения связей. -7. Полная блокировка атомарного перехода на границе. -8. Обнаружение недостижимой цели. -9. Гарантия минимального количества сгруппированных команд. -10. Команда со сдвигом сразу на несколько делений. -11. Блокировка всей команды, если связанная пластина не может пройти все запрошенные деления. -12. Правило выбора среди нескольких равных решений. -13. Вывод в консоль. -14. Создание нового выходного файла. -15. Перезапись существующего выходного файла. -16. Ошибки валидации входного JSON. - -## 10. Границы первой версии - -В первую версию не входят: - -- сайт или графический интерфейс; -- автоматическое распознавание состояния по изображению; -- автоматическое определение связей пластин; -- искусственный интеллект; -- база известных игровых замков; -- анализ приложенного PDF; -- оптимизация алгоритма без подтвержденной проблемы производительности. - -## 11. Проектный skill - -После реализации и проверки решателя должен быть создан проектный skill, который зафиксирует: - -- как интерактивно собирать исходное состояние замка; -- как проверять направленные связи каждой пластины; -- как переводить наблюдения в `lock.json`; -- как запускать решатель; -- как проверять найденную комбинацию в игре; -- как уточнять модель, если фактический переход отличается от ожидаемого. - -Skill создается после реализации и проверки интерфейсов, чтобы его инструкции соответствовали фактическому поведению утилиты. diff --git a/package.json b/package.json index a3db798..a07f646 100644 --- a/package.json +++ b/package.json @@ -1,16 +1,72 @@ { "name": "gothic-lock-solver", - "version": "0.1.0", - "private": true, + "version": "1.0.0", + "private": false, "type": "module", "engines": { - "node": ">=20" + "node": "^22.0.0 || ^24.0.0 || ^26.0.0" }, "scripts": { - "test": "node --test", - "solve": "node solve-lock.mjs" + "lint": "oxlint --deny-warnings src tests *.ts", + "typecheck": "tsc --noEmit", + "check": "pnpm lint && pnpm typecheck", + "test": "pnpm check && pnpm test:coverage && pnpm build && pnpm test:distribution && pnpm test:browser && pnpm test:e2e && pnpm verify:package", + "test:node": "vitest run --project node", + "test:distribution": "vitest run --project distribution", + "test:browser": "vitest run --project chromium --project firefox --project webkit", + "test:e2e": "node tests/e2e/build-fixtures.ts && playwright test", + "test:coverage": "vitest run --project node --coverage", + "build": "vite build --mode readable && tsc -p tsconfig.build.json && vite build --mode minified && vite build --mode cli", + "verify:package": "node tests/e2e/verify-package.ts", + "benchmark": "node tests/benchmarks/benchmark.ts", + "solve": "node src/cli.ts" + }, + "description": "Exact shortest-action solver for Gothic 1 Remake plate locks.", + "license": "Apache-2.0", + "packageManager": "pnpm@12.3.4", + "repository": { + "type": "git", + "url": "git+https://github.com/Marcus-Rise/gothic-lock-solver.git" + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "types": "./dist/index.d.ts", + "module": "./dist/gothic-lock-solver.mjs", + "unpkg": "./dist/gothic-lock-solver.min.js", + "jsdelivr": "./dist/gothic-lock-solver.min.js", + "sideEffects": false, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/gothic-lock-solver.mjs" + }, + "./dist/gothic-lock-solver.mjs": { + "types": "./dist/index.d.ts", + "import": "./dist/gothic-lock-solver.mjs" + }, + "./dist/gothic-lock-solver.min.mjs": { + "types": "./dist/index.d.ts", + "import": "./dist/gothic-lock-solver.min.mjs" + }, + "./dist/gothic-lock-solver.js": "./dist/gothic-lock-solver.js", + "./dist/gothic-lock-solver.min.js": "./dist/gothic-lock-solver.min.js", + "./package.json": "./package.json" + }, + "devDependencies": { + "@playwright/test": "1.63.0", + "@types/node": "26.4.1", + "@vitest/browser-playwright": "5.0.0", + "@vitest/coverage-v8": "5.0.0", + "oxlint": "1.81.0", + "playwright": "1.63.0", + "typescript": "7.0.2", + "vite": "8.2.2", + "vitest": "5.0.0" }, "bin": { - "gothic-lock-solver": "./solve-lock.mjs" + "gothic-lock-solver": "./dist/gothic-lock-solver.cli.mjs" } } diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..9e71f88 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,30 @@ +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests/e2e', + testMatch: '**/*.spec.ts', + fullyParallel: true, + forbidOnly: Boolean(process.env['CI']), + retries: 0, + workers: 2, + timeout: 60_000, + reporter: [['list'], ['html', { open: 'never', outputFolder: 'artifacts/playwright-report' }]], + outputDir: 'artifacts/test-results', + use: { + baseURL: 'http://127.0.0.1:4177', + serviceWorkers: 'block', + trace: 'retain-on-failure', + }, + projects: [ + { name: 'chromium', use: { browserName: 'chromium' } }, + { name: 'firefox', use: { browserName: 'firefox' } }, + { name: 'webkit', use: { browserName: 'webkit' } }, + ], + webServer: { + command: 'node tests/e2e/server.ts', + url: 'http://127.0.0.1:4177/health', + reuseExistingServer: false, + timeout: 10_000, + gracefulShutdown: { signal: 'SIGTERM', timeout: 1_000 }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..329c428 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,1479 @@ +--- +lockfileVersion: '9.0' + +importers: + + .: + configDependencies: {} + packageManagerDependencies: + pnpm: + specifier: 12.3.4 + version: 12.3.4 + +packages: + + '@pnpm/exe.darwin-arm64@12.3.4': + resolution: {integrity: sha512-PAyUol8T1+/+ViOiXAt51ECA+QnfXCqz6foL4bW+LsoX0NcVd5XVEM2mRQu+LV4oc7uRz9zf9U0P+XFfuQeDAw==} + cpu: [arm64] + os: [darwin] + + '@pnpm/exe.darwin-x64@12.3.4': + resolution: {integrity: sha512-fxP9JCk0Cdye+ePuj+GJJLMUMTqHGWRdb1dtv4How876uQ2ehxvenpgiYAir/ceO9PsYUZkFTtyZdx+rRu5QOA==} + cpu: [x64] + os: [darwin] + + '@pnpm/exe.linux-arm64-musl@12.3.4': + resolution: {integrity: sha512-FBOt0/7ye6O6q4AllVV5QMviB6qE6fqkeczV/+MDWQsmo+QJrlfsh6X7CpH/tClVpBZEyIbjpUoT8bNhCYBxEg==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-arm64@12.3.4': + resolution: {integrity: sha512-t71AVA7LRqiKTyZ5xMYaZc2n5DfdpMbfokZuiIOXHBOM03ECnF0t4iYwaBDqJgVjlKYUOwaF/bRQajGNA4cJ4w==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.linux-x64-musl@12.3.4': + resolution: {integrity: sha512-RPmk7Jb/aYaFvL2iyDN/AtMY+hUEsue732WmXpcuQ9tBpMnGyA5py7Z3+e+qmQaJ0zY/4ni9jJiyPBQHujmv6w==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@pnpm/exe.linux-x64@12.3.4': + resolution: {integrity: sha512-2ZqOlSPkfwX1h5cR+FPiWf8+F+2hZT/3TvhUK5sigHqwaQCIiq8R7CGxhndKs63JtcLi2a1Qpo+wX/EoyfjyJQ==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@pnpm/exe.win32-arm64@12.3.4': + resolution: {integrity: sha512-ANyrHqyqco6SXBysUTRF74itDyyraea7IbFsKFdNXTjcFnfycTDx37EwuhdpPYFNSIh2JhUG4fByclsRfiHX7w==} + cpu: [arm64] + os: [win32] + + '@pnpm/exe.win32-x64@12.3.4': + resolution: {integrity: sha512-WH/KqBPY/hq2Tb7SgQltEZytimcjgKRaCRL/aM9CI0c67iKc5TVmHUhIiL3Ux9FB4bWn36i6XewUcScQI+zG8w==} + cpu: [x64] + os: [win32] + + pnpm@12.3.4: + resolution: {integrity: sha512-lhqkH7B32joEpEHZ+OFevAyW2o73ELLrZ7+e58sGEOq9SPH9hfUc/+c4RnhfoPh8VqOocqHYk/hEZ0G1zORUVw==} + engines: {node: '>=18.*'} + hasBin: true + +snapshots: + + '@pnpm/exe.darwin-arm64@12.3.4': + optional: true + + '@pnpm/exe.darwin-x64@12.3.4': + optional: true + + '@pnpm/exe.linux-arm64-musl@12.3.4': + optional: true + + '@pnpm/exe.linux-arm64@12.3.4': + optional: true + + '@pnpm/exe.linux-x64-musl@12.3.4': + optional: true + + '@pnpm/exe.linux-x64@12.3.4': + optional: true + + '@pnpm/exe.win32-arm64@12.3.4': + optional: true + + '@pnpm/exe.win32-x64@12.3.4': + optional: true + + pnpm@12.3.4: + optionalDependencies: + '@pnpm/exe.darwin-arm64': 12.3.4 + '@pnpm/exe.darwin-x64': 12.3.4 + '@pnpm/exe.linux-arm64': 12.3.4 + '@pnpm/exe.linux-arm64-musl': 12.3.4 + '@pnpm/exe.linux-x64': 12.3.4 + '@pnpm/exe.linux-x64-musl': 12.3.4 + '@pnpm/exe.win32-arm64': 12.3.4 + '@pnpm/exe.win32-x64': 12.3.4 + +--- +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + '@playwright/test': + specifier: 1.63.0 + version: 1.63.0 + '@types/node': + specifier: 26.4.1 + version: 26.4.1 + '@vitest/browser-playwright': + specifier: 5.0.0 + version: 5.0.0(playwright@1.63.0)(vite@8.2.2(@types/node@26.4.1))(vitest@5.0.0) + '@vitest/coverage-v8': + specifier: 5.0.0 + version: 5.0.0(@vitest/browser@5.0.0)(vitest@5.0.0) + oxlint: + specifier: 1.81.0 + version: 1.81.0 + playwright: + specifier: 1.63.0 + version: 1.63.0 + typescript: + specifier: 7.0.2 + version: 7.0.2 + vite: + specifier: 8.2.2 + version: 8.2.2(@types/node@26.4.1) + vitest: + specifier: 5.0.0 + version: 5.0.0(@types/node@26.4.1)(@vitest/browser-playwright@5.0.0)(@vitest/coverage-v8@5.0.0)(@vitest/ui@5.0.0)(vite@8.2.2(@types/node@26.4.1)) + +packages: + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.8': + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + + '@blazediff/core@1.10.0': + resolution: {integrity: sha512-AOQff0zgR7cGsZL+4E7hVkmujoPUpm0J9xzWGWZj5wCjd3gmxESXAPfKyuzs93VdpQNFhHlBhfOjrcZ+XTERtQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@oxc-project/types@0.148.0': + resolution: {integrity: sha512-Nm4s/jB+4FpFsPhWGEC4h7rzksesmtnMXomo6rCMcg/b8zLQuOziRgkCS1fxDCXOlJB/6Q8oABOZ/OP6RIPj9A==} + + '@oxlint/binding-android-arm-eabi@1.81.0': + resolution: {integrity: sha512-IcCRsXiedJoJopY6mpZUBEeVFsUrutmrG7dZ87zMuKJlhg70Ora9bBl1WcCxZQtyI10YpnVdEso5oCg7YcfSHw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@oxlint/binding-android-arm64@1.81.0': + resolution: {integrity: sha512-GRrIPyTGVhx3L3h+0T5xT2A0jFAcdPv4+IfuXpGDLIdl6XeYhgg/zw72A5ILZoUgRqZuM8F1y+V/gfDriXSxzQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@oxlint/binding-darwin-arm64@1.81.0': + resolution: {integrity: sha512-qNQ9tXRgLuKbqSV1S2h9h4KPHjbovO7RRR2/enUOtHzTkFZ7B9X5zqqHJua8dRyc7dBy7Aoyq5pqTSLFVcAzGQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@oxlint/binding-darwin-x64@1.81.0': + resolution: {integrity: sha512-q0QTm32jWga2Gv4j7IaVZN0jYMi9UV73sWVgFtDA4iIfqwMCLLZ3ve+9KwfYtsaKZSgQhmPaogeZWqDZpcY1Pw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@oxlint/binding-freebsd-x64@1.81.0': + resolution: {integrity: sha512-/+8wVWDXEC7wHVAhOc59Fw/SkMc1arLkFD8iQCaSsmzenK1X4doFqquL9H1wrtGUzaiycVqkf/sSpcILK6W1UA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@oxlint/binding-linux-arm-gnueabihf@1.81.0': + resolution: {integrity: sha512-4xt422FEgioRq9hAL4Tq7fujGUWnc8z1BJ+Oi8RN8vB8axaP+sdK6a2xdlcQCCYnJg9QMuMFS0AucuIFx/EacA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm-musleabihf@1.81.0': + resolution: {integrity: sha512-u3vna8KdGplH4DRCW9K54D68fcMo7IxVrkCJWwXnIhwtBdnDnYrmzOUA/XjmBlPpcLsgw9Z5BNdY4za9+Dj+MQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@oxlint/binding-linux-arm64-gnu@1.81.0': + resolution: {integrity: sha512-3j9k+gsYsE7nv71GWotXsqsa2l9/aJenD7dVHNt/CBvsb0SgRjSMnHFeP59IXUAl1wvVFhqGl2wJNMwWU3UBlA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-arm64-musl@1.81.0': + resolution: {integrity: sha512-k5iAp3dNxW0/uDCBY+WSm8jKB2szu7SkEQZdgRRpDXvuDd69vvDcqhB3A/pWCfCwXyenjNjFn9Td1fVoyAc+Yg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@oxlint/binding-linux-ppc64-gnu@1.81.0': + resolution: {integrity: sha512-TFqLja3uYmVSte6nof9GWrex9Z8WgdZrNiLC6Te5rXGDqXB2y4j/26iFhwosXiAFqDhE9JJVuuCkDKLwptTn1g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-riscv64-gnu@1.81.0': + resolution: {integrity: sha512-UEcySvGS0NOVo7h7n7CYyJL9+6gFAh7Zc/ToDXVScFvzHSTIxtzkMVU30rmQ6+nQ1LF+UdiRDdJajpDu+OylLg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-riscv64-musl@1.81.0': + resolution: {integrity: sha512-H+diDbhD00+wI1IRP8Kz88x/lat+DgtoBJzoTthS16xkTJGNaEkfb8gzmd1rzc/2uDQQMl7GNl+JFUacVeWxIA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@oxlint/binding-linux-s390x-gnu@1.81.0': + resolution: {integrity: sha512-8znJ/5TekjOKg1j1Acho4PJMdiAHLtlcXuWEiipOhAMV6rQcXdmDdXCbheyDczN6TjBwiNfjcP81k4AthrKRzw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-x64-gnu@1.81.0': + resolution: {integrity: sha512-Q2Wj70yFsvn5QjlmifFzbj4H+kJy53bwqc41o1fzoM7MpLV1NIbhg/LpWXRfC6KOkSAdUx1Wd8VJsdPmhp/HRA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@oxlint/binding-linux-x64-musl@1.81.0': + resolution: {integrity: sha512-cPInHp/ddEe5qkyK2IiyQ8Q3Mp2oLLEhhsGgTK2oZx4L6+llGam1H1yBvJZ7qHfOXj8N3hxBS8sj4tO+gtFlIg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@oxlint/binding-openharmony-arm64@1.81.0': + resolution: {integrity: sha512-0CQxSX4ajqm07AHBf5U33qQzXKdd7wtq/oTL/7vpY6RNNuxrRi8W4bqUV1Jyu/vj+9KmxQyDhxfeVX1nQL6kfg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@oxlint/binding-win32-arm64-msvc@1.81.0': + resolution: {integrity: sha512-l0hbeISm9673hVrrQU8j/p2M7YH9Ouoj7p7E/QM55NTrKVLP+P3PF8hLu+OY+x0VtGRW+ggiQKZqmdYps9H+TA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@oxlint/binding-win32-ia32-msvc@1.81.0': + resolution: {integrity: sha512-ksqPP5jbFXcYreEQ7zdJh06rJQBymCTyGRCdaXjfcf2aG4f8KxUWY5wcgYHmaTK+FJ4bPG5sUAdOX+6trnH1JA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] + + '@oxlint/binding-win32-x64-msvc@1.81.0': + resolution: {integrity: sha512-IZuUCwGw9emG5JtCp+fYGB+Z4OWEoeEcM8R5BA1pYw63/ieYFVdcU2ylxTpHbVHSenZnsYE+ZZ20uHAJszQ4cA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@playwright/test@1.63.0': + resolution: {integrity: sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==} + engines: {node: '>=20'} + hasBin: true + + '@polka/url@1.0.0-next.29': + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + + '@rolldown/binding-android-arm-eabi@1.2.7': + resolution: {integrity: sha512-EypzgnYCwyVY4NDHKzGmNJT5b+XaQEBniHxsMdeIQLB/tcCzZnhqrzHpZFbX9iaxx+5RiB8caATBtfvZP7zVxQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] + + '@rolldown/binding-android-arm64@1.2.7': + resolution: {integrity: sha512-l17HE9EweWaqJZhuUuNBN/FzM62xw+DECVnJyvMsxn8vJFAGLy5QfLDoYAcronkAN8VxKZHezDpulHDPx95vFw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.7': + resolution: {integrity: sha512-8ED8ELFvHXc6OCETIn4gXObPiaR6bckM/ipXtbzlPVDRMBfEGjCKgO90F9YtfdpDatVx/ZQw7aZ1vUMf/+T3Mw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.7': + resolution: {integrity: sha512-/WPripjtiAIZ2tWY7ddijORT0Ujg87wxWW/qcoFVCKAWVDPhtY0xr7Dj0M3GyNGz60jGwTElhro/mkF9dT7dDQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.7': + resolution: {integrity: sha512-14DI4NcqpvbICxSnGLx3PmtDaWqRP/KGSGb6C+JLLVPeZRl6dKdHba3pGsqT3vpdTqhEYIPG0MMQ8c0xYqoJxA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.7': + resolution: {integrity: sha512-bxrWIRvHWQvbJwi+VIie/kDJmQxcNE6xxWwZdqF/ExVAigtHkv54WTLQPb+QsZdnFy18fg7JPfWGL0RH6vwIlQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.7': + resolution: {integrity: sha512-toOY2BChBZyuxU7OYX6Tn389di4IzAqPTycVcci0O7FSfBqzRB3RZn+K5Is6ANf4tmgRd/K1yZTsNTXbkXsnLg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.2.7': + resolution: {integrity: sha512-lAIXTH/aiLRLxsTgQvfhjo4K1ydWIp00+V0voOr9beb/9ZmkUFrSIb03dXNFRgMNvkE6oGsF10ioQ6UsI+vS5Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.2.7': + resolution: {integrity: sha512-kdnwS28Pkenp/mZMRwjXXXwxQ7pIsm+bF919LUK93BOyhcLsrVKdP2p9fxpiPNPAbNuch8ypQt0pm2P2LYCAGg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.2.7': + resolution: {integrity: sha512-516OdsyLdr5E65paF3yBF55t8mfm9+gmtCsK3xI7XKXIT7EfRlHhxL8K/NR6Hu8BWSgF5+1w74lTL0+nxcc8Qw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.2.7': + resolution: {integrity: sha512-r8/z8n7GFaYRln3xmP1Cxy0HH/HLM0uBUPkEuSVEfKGDA89M0FsZRZJRSwe/tJjRx+fpH/gjorfhB8tmEbSFLA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.2.7': + resolution: {integrity: sha512-pAsE8iiDxUg1xBqdhrTfg45AVDVpirjz00sblEYClGNNcMnDb+e8beQgqIAw6LvauX/APvgxUnwrgun/YYGBhw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.2.7': + resolution: {integrity: sha512-lTcIYmmnQQA8Or/2DatS6oSqcdLHvendjS+zLu+FwgToynWMRSmQdpM65fTANJgIS4mjbMOo5KT2lnT9SAb96w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.7': + resolution: {integrity: sha512-e3Gu3WxbNk/UqQhxqU7YIYO+9ZBvWNz3U+h/qRFosscMFzdRPbXYSaSWgSnklv2fz1TgzBTcti2z35c/7irsHw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.7': + resolution: {integrity: sha512-W/jg5qoRSqjsEv0+dZi4e687mcHqmVuU0P4fK6qS/xjetW2Gmc1W8j//z5nAeNcC8Ttm0hV46IjcYeuVwYhuiw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/node@26.4.1': + resolution: {integrity: sha512-k97ENvZWtvA6yqz5/FS6a7duDgOPEeOQOc2iKS/nY6mX6qJUKtLnWzQS+Xj6tXweyj6ZcTAK2Qecetnvi9nCLA==} + + '@typescript/typescript-aix-ppc64@7.0.2': + resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [aix] + + '@typescript/typescript-darwin-arm64@7.0.2': + resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [darwin] + + '@typescript/typescript-darwin-x64@7.0.2': + resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [darwin] + + '@typescript/typescript-freebsd-arm64@7.0.2': + resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [freebsd] + + '@typescript/typescript-freebsd-x64@7.0.2': + resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [freebsd] + + '@typescript/typescript-linux-arm64@7.0.2': + resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [linux] + + '@typescript/typescript-linux-arm@7.0.2': + resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==} + engines: {node: '>=16.20.0'} + cpu: [arm] + os: [linux] + + '@typescript/typescript-linux-loong64@7.0.2': + resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==} + engines: {node: '>=16.20.0'} + cpu: [loong64] + os: [linux] + + '@typescript/typescript-linux-mips64el@7.0.2': + resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==} + engines: {node: '>=16.20.0'} + cpu: [mips64el] + os: [linux] + + '@typescript/typescript-linux-ppc64@7.0.2': + resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [linux] + + '@typescript/typescript-linux-riscv64@7.0.2': + resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==} + engines: {node: '>=16.20.0'} + cpu: [riscv64] + os: [linux] + + '@typescript/typescript-linux-s390x@7.0.2': + resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==} + engines: {node: '>=16.20.0'} + cpu: [s390x] + os: [linux] + + '@typescript/typescript-linux-x64@7.0.2': + resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [linux] + + '@typescript/typescript-netbsd-arm64@7.0.2': + resolution: {integrity: sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [netbsd] + + '@typescript/typescript-netbsd-x64@7.0.2': + resolution: {integrity: sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [netbsd] + + '@typescript/typescript-openbsd-arm64@7.0.2': + resolution: {integrity: sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [openbsd] + + '@typescript/typescript-openbsd-x64@7.0.2': + resolution: {integrity: sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [openbsd] + + '@typescript/typescript-sunos-x64@7.0.2': + resolution: {integrity: sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [sunos] + + '@typescript/typescript-win32-arm64@7.0.2': + resolution: {integrity: sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [win32] + + '@typescript/typescript-win32-x64@7.0.2': + resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [win32] + + '@vitest/browser-playwright@5.0.0': + resolution: {integrity: sha512-N+gED9y4/8pypaHjz/x0ah3CjoBr+N0hWWT+Gq4VXtMzyB+rUIdHni0ulzpD1j4H77iWy5Jg8PRMlVn6kjxo6g==} + peerDependencies: + playwright: '*' + vitest: 5.0.0 + + '@vitest/browser@5.0.0': + resolution: {integrity: sha512-JC9FG5xIRxPHXJPcdCaluIJcEoeM0IwGQ3xneuJk09LXKHRNs40BqWDWymQikbb20yOpYvzpKzmYgPRuSzKmvg==} + peerDependencies: + vitest: 5.0.0 + + '@vitest/coverage-v8@5.0.0': + resolution: {integrity: sha512-toMg6PZGCIa/lQNCDoASrfb1ly4hsUKXFtFYC9kD4t78o5Y6LyNJU7AENt8eHPr3quYdxaxK7hj2mnbFfUk9NA==} + peerDependencies: + '@vitest/browser': 5.0.0 + vitest: 5.0.0 + peerDependenciesMeta: + '@vitest/browser': + optional: true + + '@vitest/istanbul-lib-coverage@1.0.1': + resolution: {integrity: sha512-k3DJZ8LhMBK9NS4SclF1ASD3OgXEWDorbIcPTRDK0/Zae6fRvu+fJRxtFdLfHsa9Y24beCdPnoNZ4LviTNstfA==} + engines: {node: '>=22'} + + '@vitest/istanbul-lib-report@1.0.1': + resolution: {integrity: sha512-1EOLRfsTMnyAr3+kEAsP4o9dhaDlGPpD7H5iLBBeq//YpNB1VIahkPhB+eRp9N2Dkfw8oySROjE3yf9XDeaIkQ==} + engines: {node: '>=22'} + + '@vitest/mocker@5.0.0': + resolution: {integrity: sha512-66PGTMIiVJP3t4a5yxU9qPtf7MdTBs8jmToMvy+HVflB3Yy13WJZTtPePdvU+wjRV02SKK5doLbSA6o9pwOmiA==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@5.0.0': + resolution: {integrity: sha512-PVRNuB3wpReb4SQEs4zTKM4KWFhQ5pw3spE8naoDJNB5T5aWRzGKHwXcLUllr0WeOTXpB6bSr3CJLo5+7XQSSQ==} + + '@vitest/spy@5.0.0': + resolution: {integrity: sha512-uy+luWBAPw9XfthoHi5AkfHUnuPYEESjl0p/r+meoBnU8bxg5GDQ3Ey8MjcJ6sqahkL4PFyrvfMJJBw7LbU06g==} + + '@vitest/ui@5.0.0': + resolution: {integrity: sha512-h2FIFwggCY2GxUd2UdQoYNVQkOIqEQLPhNREcl3FUiRsdzQep7NWwYbSmhGEA9nFLPDq5pXzRMcBZQU8Py83sg==} + peerDependencies: + vitest: 5.0.0 + + '@vitest/utils@5.0.0': + resolution: {integrity: sha512-dO++xL3vDfvhTAVimfkuQUA3k+JClIF1i1vAkPqpcGAthRmeWnXmHB7YPViPvgCwviX8u7Y5W1u2N//AaQr3fw==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + ast-v8-to-istanbul@1.0.5: + resolution: {integrity: sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + es-module-lexer@2.3.2: + resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + + flatted@3.4.4: + resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + magic-string@1.2.3: + resolution: {integrity: sha512-Bpb0W2TbLKOZ7vJnOUnVRGq3WL2p+ISV29M6hYPL1AFCpyKZpdr5ytiXoTSSxRVhg8YW7f65+6gbG8WG6PCa/g==} + + magicast@0.5.4: + resolution: {integrity: sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==} + + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + obug@2.1.4: + resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} + engines: {node: '>=12.20.0'} + + oxlint@1.81.0: + resolution: {integrity: sha512-HyrJYqeoOCL0iqaLEzGewGT48ZX99P3hxYh8udAF9RGGIghSamkXE4ClUyBpEDNqasamThgmlPbuMOe7SAZmHg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + oxlint-tsgolint: '>=7.0.2001' + vite-plus: '*' + peerDependenciesMeta: + oxlint-tsgolint: + optional: true + vite-plus: + optional: true + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + playwright-core@1.63.0: + resolution: {integrity: sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.63.0: + resolution: {integrity: sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==} + engines: {node: '>=20'} + hasBin: true + + pngjs@7.0.0: + resolution: {integrity: sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==} + engines: {node: '>=14.19.0'} + + postcss@8.5.28: + resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} + engines: {node: ^10 || ^12 || >=14} + + rolldown@1.2.7: + resolution: {integrity: sha512-g0EtLvBjTUB7jhyV0S/TCup3v/XSVl45vUIGbOGU4QPiyjTenCe4mKuFvW9fEgYmS2Fo42AUssRmNuMziXdrig==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + sirv@3.0.2: + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} + engines: {node: '>=18'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + + tinybench@6.1.4: + resolution: {integrity: sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==} + engines: {node: '>=20.0.0'} + + tinyexec@1.3.0: + resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.1: + resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} + engines: {node: '>=14.0.0'} + + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + + typescript@7.0.2: + resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} + engines: {node: '>=16.20.0'} + hasBin: true + + undici-types@8.3.0: + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + + vite@8.2.2: + resolution: {integrity: sha512-cFKLV/PRgAUlIRm5WjMjJ86jrftzpqcgH+Us+DS8mI3CDNiH30Whrz8uHL3+MOLPAgqbMBAqWdAHAphOAM+z/Q==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.4.0 || ^0.5.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@5.0.0: + resolution: {integrity: sha512-gpsMNoRhMjMktVxPtstOH4/PJuPyovVaMDr4oDilXaGH1EcqM2OE96SoHT2VIQ6fTGtTjqmHDrEu2X9RQiXf8Q==} + engines: {node: ^22.12.0 || ^24.0.0 || >=26.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 5.0.0 + '@vitest/browser-preview': 5.0.0 + '@vitest/browser-webdriverio': ^5.0.0-beta.5 || >=5.0.0 + '@vitest/coverage-istanbul': 5.0.0 + '@vitest/coverage-v8': 5.0.0 + '@vitest/ui': 5.0.0 + happy-dom: '*' + jsdom: '*' + vite: ^6.4.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + ws@8.21.3: + resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + +snapshots: + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/parser@7.29.8': + dependencies: + '@babel/types': 7.29.8 + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@bcoe/v8-coverage@1.0.2': {} + + '@blazediff/core@1.10.0': {} + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.6.0': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.6.0 + + '@oxc-project/types@0.148.0': {} + + '@oxlint/binding-android-arm-eabi@1.81.0': + optional: true + + '@oxlint/binding-android-arm64@1.81.0': + optional: true + + '@oxlint/binding-darwin-arm64@1.81.0': + optional: true + + '@oxlint/binding-darwin-x64@1.81.0': + optional: true + + '@oxlint/binding-freebsd-x64@1.81.0': + optional: true + + '@oxlint/binding-linux-arm-gnueabihf@1.81.0': + optional: true + + '@oxlint/binding-linux-arm-musleabihf@1.81.0': + optional: true + + '@oxlint/binding-linux-arm64-gnu@1.81.0': + optional: true + + '@oxlint/binding-linux-arm64-musl@1.81.0': + optional: true + + '@oxlint/binding-linux-ppc64-gnu@1.81.0': + optional: true + + '@oxlint/binding-linux-riscv64-gnu@1.81.0': + optional: true + + '@oxlint/binding-linux-riscv64-musl@1.81.0': + optional: true + + '@oxlint/binding-linux-s390x-gnu@1.81.0': + optional: true + + '@oxlint/binding-linux-x64-gnu@1.81.0': + optional: true + + '@oxlint/binding-linux-x64-musl@1.81.0': + optional: true + + '@oxlint/binding-openharmony-arm64@1.81.0': + optional: true + + '@oxlint/binding-win32-arm64-msvc@1.81.0': + optional: true + + '@oxlint/binding-win32-ia32-msvc@1.81.0': + optional: true + + '@oxlint/binding-win32-x64-msvc@1.81.0': + optional: true + + '@playwright/test@1.63.0': + dependencies: + playwright: 1.63.0 + + '@polka/url@1.0.0-next.29': {} + + '@rolldown/binding-android-arm-eabi@1.2.7': + optional: true + + '@rolldown/binding-android-arm64@1.2.7': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.7': + optional: true + + '@rolldown/binding-darwin-x64@1.2.7': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.7': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.7': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.7': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.7': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.7': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.7': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.7': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.7': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.7': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.7': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.7': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/node@26.4.1': + dependencies: + undici-types: 8.3.0 + + '@typescript/typescript-aix-ppc64@7.0.2': + optional: true + + '@typescript/typescript-darwin-arm64@7.0.2': + optional: true + + '@typescript/typescript-darwin-x64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-x64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm@7.0.2': + optional: true + + '@typescript/typescript-linux-loong64@7.0.2': + optional: true + + '@typescript/typescript-linux-mips64el@7.0.2': + optional: true + + '@typescript/typescript-linux-ppc64@7.0.2': + optional: true + + '@typescript/typescript-linux-riscv64@7.0.2': + optional: true + + '@typescript/typescript-linux-s390x@7.0.2': + optional: true + + '@typescript/typescript-linux-x64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-sunos-x64@7.0.2': + optional: true + + '@typescript/typescript-win32-arm64@7.0.2': + optional: true + + '@typescript/typescript-win32-x64@7.0.2': + optional: true + + '@vitest/browser-playwright@5.0.0(playwright@1.63.0)(vite@8.2.2(@types/node@26.4.1))(vitest@5.0.0)': + dependencies: + '@vitest/browser': 5.0.0(vite@8.2.2(@types/node@26.4.1))(vitest@5.0.0) + '@vitest/mocker': 5.0.0(vite@8.2.2(@types/node@26.4.1)) + playwright: 1.63.0 + tinyrainbow: 3.1.1 + vitest: 5.0.0(@types/node@26.4.1)(@vitest/browser-playwright@5.0.0)(@vitest/coverage-v8@5.0.0)(@vitest/ui@5.0.0)(vite@8.2.2(@types/node@26.4.1)) + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + + '@vitest/browser@5.0.0(vite@8.2.2(@types/node@26.4.1))(vitest@5.0.0)': + dependencies: + '@blazediff/core': 1.10.0 + '@vitest/mocker': 5.0.0(vite@8.2.2(@types/node@26.4.1)) + '@vitest/ui': 5.0.0(vitest@5.0.0) + '@vitest/utils': 5.0.0 + magic-string: 1.2.3 + pngjs: 7.0.0 + sirv: 3.0.2 + tinyrainbow: 3.1.1 + vitest: 5.0.0(@types/node@26.4.1)(@vitest/browser-playwright@5.0.0)(@vitest/coverage-v8@5.0.0)(@vitest/ui@5.0.0)(vite@8.2.2(@types/node@26.4.1)) + ws: 8.21.3 + transitivePeerDependencies: + - bufferutil + - msw + - utf-8-validate + - vite + + '@vitest/coverage-v8@5.0.0(@vitest/browser@5.0.0)(vitest@5.0.0)': + dependencies: + '@bcoe/v8-coverage': 1.0.2 + '@vitest/istanbul-lib-coverage': 1.0.1 + '@vitest/istanbul-lib-report': 1.0.1 + ast-v8-to-istanbul: 1.0.5 + magicast: 0.5.4 + obug: 2.1.4 + std-env: 4.2.0 + tinyrainbow: 3.1.1 + vitest: 5.0.0(@types/node@26.4.1)(@vitest/browser-playwright@5.0.0)(@vitest/coverage-v8@5.0.0)(@vitest/ui@5.0.0)(vite@8.2.2(@types/node@26.4.1)) + optionalDependencies: + '@vitest/browser': 5.0.0(vite@8.2.2(@types/node@26.4.1))(vitest@5.0.0) + + '@vitest/istanbul-lib-coverage@1.0.1': {} + + '@vitest/istanbul-lib-report@1.0.1': + dependencies: + '@vitest/istanbul-lib-coverage': 1.0.1 + + '@vitest/mocker@5.0.0(vite@8.2.2(@types/node@26.4.1))': + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + '@vitest/spy': 5.0.0 + estree-walker: 3.0.3 + magic-string: 1.2.3 + optionalDependencies: + vite: 8.2.2(@types/node@26.4.1) + + '@vitest/pretty-format@5.0.0': + dependencies: + tinyrainbow: 3.1.1 + + '@vitest/spy@5.0.0': {} + + '@vitest/ui@5.0.0(vitest@5.0.0)': + dependencies: + '@vitest/utils': 5.0.0 + fflate: 0.8.3 + flatted: 3.4.4 + pathe: 2.0.3 + sirv: 3.0.2 + tinyrainbow: 3.1.1 + vitest: 5.0.0(@types/node@26.4.1)(@vitest/browser-playwright@5.0.0)(@vitest/coverage-v8@5.0.0)(@vitest/ui@5.0.0)(vite@8.2.2(@types/node@26.4.1)) + + '@vitest/utils@5.0.0': + dependencies: + '@vitest/pretty-format': 5.0.0 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.1 + + assertion-error@2.0.1: {} + + ast-v8-to-istanbul@1.0.5: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + + chai@6.2.2: {} + + convert-source-map@2.0.0: {} + + detect-libc@2.1.2: {} + + es-module-lexer@2.3.2: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.4.0: {} + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + fflate@0.8.3: {} + + flatted@3.4.4: {} + + fsevents@2.3.3: + optional: true + + js-tokens@10.0.0: {} + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + magic-string@1.2.3: + dependencies: + '@jridgewell/sourcemap-codec': 1.6.0 + + magicast@0.5.4: + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + source-map-js: 1.2.1 + + mrmime@2.0.1: {} + + nanoid@3.3.18: {} + + obug@2.1.4: {} + + oxlint@1.81.0: + optionalDependencies: + '@oxlint/binding-android-arm-eabi': 1.81.0 + '@oxlint/binding-android-arm64': 1.81.0 + '@oxlint/binding-darwin-arm64': 1.81.0 + '@oxlint/binding-darwin-x64': 1.81.0 + '@oxlint/binding-freebsd-x64': 1.81.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.81.0 + '@oxlint/binding-linux-arm-musleabihf': 1.81.0 + '@oxlint/binding-linux-arm64-gnu': 1.81.0 + '@oxlint/binding-linux-arm64-musl': 1.81.0 + '@oxlint/binding-linux-ppc64-gnu': 1.81.0 + '@oxlint/binding-linux-riscv64-gnu': 1.81.0 + '@oxlint/binding-linux-riscv64-musl': 1.81.0 + '@oxlint/binding-linux-s390x-gnu': 1.81.0 + '@oxlint/binding-linux-x64-gnu': 1.81.0 + '@oxlint/binding-linux-x64-musl': 1.81.0 + '@oxlint/binding-openharmony-arm64': 1.81.0 + '@oxlint/binding-win32-arm64-msvc': 1.81.0 + '@oxlint/binding-win32-ia32-msvc': 1.81.0 + '@oxlint/binding-win32-x64-msvc': 1.81.0 + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@4.0.7: {} + + playwright-core@1.63.0: {} + + playwright@1.63.0: + dependencies: + playwright-core: 1.63.0 + + pngjs@7.0.0: {} + + postcss@8.5.28: + dependencies: + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + rolldown@1.2.7: + dependencies: + '@oxc-project/types': 0.148.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm-eabi': 1.2.7 + '@rolldown/binding-android-arm64': 1.2.7 + '@rolldown/binding-darwin-arm64': 1.2.7 + '@rolldown/binding-darwin-x64': 1.2.7 + '@rolldown/binding-freebsd-x64': 1.2.7 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.7 + '@rolldown/binding-linux-arm64-gnu': 1.2.7 + '@rolldown/binding-linux-arm64-musl': 1.2.7 + '@rolldown/binding-linux-ppc64-gnu': 1.2.7 + '@rolldown/binding-linux-s390x-gnu': 1.2.7 + '@rolldown/binding-linux-x64-gnu': 1.2.7 + '@rolldown/binding-linux-x64-musl': 1.2.7 + '@rolldown/binding-openharmony-arm64': 1.2.7 + '@rolldown/binding-win32-arm64-msvc': 1.2.7 + '@rolldown/binding-win32-x64-msvc': 1.2.7 + + siginfo@2.0.0: {} + + sirv@3.0.2: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@4.2.0: {} + + tinybench@6.1.4: {} + + tinyexec@1.3.0: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + tinyrainbow@3.1.1: {} + + totalist@3.0.1: {} + + typescript@7.0.2: + optionalDependencies: + '@typescript/typescript-aix-ppc64': 7.0.2 + '@typescript/typescript-darwin-arm64': 7.0.2 + '@typescript/typescript-darwin-x64': 7.0.2 + '@typescript/typescript-freebsd-arm64': 7.0.2 + '@typescript/typescript-freebsd-x64': 7.0.2 + '@typescript/typescript-linux-arm': 7.0.2 + '@typescript/typescript-linux-arm64': 7.0.2 + '@typescript/typescript-linux-loong64': 7.0.2 + '@typescript/typescript-linux-mips64el': 7.0.2 + '@typescript/typescript-linux-ppc64': 7.0.2 + '@typescript/typescript-linux-riscv64': 7.0.2 + '@typescript/typescript-linux-s390x': 7.0.2 + '@typescript/typescript-linux-x64': 7.0.2 + '@typescript/typescript-netbsd-arm64': 7.0.2 + '@typescript/typescript-netbsd-x64': 7.0.2 + '@typescript/typescript-openbsd-arm64': 7.0.2 + '@typescript/typescript-openbsd-x64': 7.0.2 + '@typescript/typescript-sunos-x64': 7.0.2 + '@typescript/typescript-win32-arm64': 7.0.2 + '@typescript/typescript-win32-x64': 7.0.2 + + undici-types@8.3.0: {} + + vite@8.2.2(@types/node@26.4.1): + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.5.28 + rolldown: 1.2.7 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 26.4.1 + fsevents: 2.3.3 + + vitest@5.0.0(@types/node@26.4.1)(@vitest/browser-playwright@5.0.0)(@vitest/coverage-v8@5.0.0)(@vitest/ui@5.0.0)(vite@8.2.2(@types/node@26.4.1)): + dependencies: + '@types/chai': 5.2.3 + '@vitest/mocker': 5.0.0(vite@8.2.2(@types/node@26.4.1)) + chai: 6.2.2 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 + magic-string: 1.2.3 + obug: 2.1.4 + picomatch: 4.0.7 + std-env: 4.2.0 + tinybench: 6.1.4 + tinyexec: 1.3.0 + tinyglobby: 0.2.17 + vite: 8.2.2(@types/node@26.4.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 26.4.1 + '@vitest/browser-playwright': 5.0.0(playwright@1.63.0)(vite@8.2.2(@types/node@26.4.1))(vitest@5.0.0) + '@vitest/coverage-v8': 5.0.0(@vitest/browser@5.0.0)(vitest@5.0.0) + '@vitest/ui': 5.0.0(vitest@5.0.0) + transitivePeerDependencies: + - msw + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + ws@8.21.3: {} diff --git a/scripts/benchmark.mjs b/scripts/benchmark.mjs deleted file mode 100644 index 5d8e771..0000000 --- a/scripts/benchmark.mjs +++ /dev/null @@ -1,25 +0,0 @@ -import { performance } from 'node:perf_hooks'; -import { findShortestCommands } from '../src/solver.mjs'; - -const plateCount = 7; -const definition = { - state: Array.from({ length: plateCount }, () => 1), - links: Array.from( - { length: plateCount }, - () => Array.from({ length: plateCount }, () => 0), - ), -}; - -const startedAt = performance.now(); -const commands = findShortestCommands(definition); -const elapsedMs = performance.now() - startedAt; - -console.log(JSON.stringify({ - elapsedMs: Math.round(elapsedMs), - commands: commands?.length ?? null, - rssMiB: Math.round(process.memoryUsage().rss / 1024 / 1024), -}, null, 2)); - -if (commands?.length !== 7) { - process.exitCode = 1; -} diff --git a/solve-lock.mjs b/solve-lock.mjs deleted file mode 100755 index 5d0856c..0000000 --- a/solve-lock.mjs +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env node -import { runCli } from './src/cli.mjs'; - -process.exitCode = await runCli(process.argv.slice(2)); diff --git a/src/astar.ts b/src/astar.ts new file mode 100644 index 0000000..51ca76c --- /dev/null +++ b/src/astar.ts @@ -0,0 +1,250 @@ +import { SearchLimitError } from './errors.ts'; +import { + DIRECTIONS, + MAX_SHIFT, + commandFor, + decodeDigits, + minimumActionsForShift, + movementLimits, + readAt, + readByte, +} from './lock.ts'; +import type { Effect, LockModel, PreparedSearch } from './lock.ts'; +import { IndexedHeap, NOT_QUEUED } from './priority-queue.ts'; +import type { SearchNode } from './priority-queue.ts'; +import { analyzeMatrix } from './matrix.ts'; +import { BfsSearch } from './bfs.ts'; +import type { SearchBudget } from './config.ts'; +import type { Command } from './types.ts'; + +function findExecutablePlate( + prepared: PreparedSearch, + remainingShifts: readonly number[], + positionDigits: Uint8Array, +): number | null { + for (let plate = 0; plate < prepared.plateCount; plate += 1) { + const shift = readAt(remainingShifts, plate); + if (shift === 0) { + continue; + } + const effect = readAt(prepared.effects, plate); + const [positiveLimit, negativeLimit] = movementLimits(positionDigits, effect); + const allowedShift = shift > 0 ? positiveLimit : negativeLimit; + if (Math.abs(shift) <= allowedShift) { + return plate; + } + } + return null; +} + +/** + * A sufficient certificate: one legal action per nonzero net shift attains the + * action and unit-shift lower bounds. Failure proves nothing; A* starts anew. + */ +function greedyCertificate( + prepared: PreparedSearch, + requiredShifts: readonly number[], + budget: SearchBudget, +): readonly Command[] | null { + if (requiredShifts.some((shift) => Math.abs(shift) > MAX_SHIFT)) { + return null; + } + + const remainingShifts = [...requiredShifts]; + const positionDigits = new Uint8Array(prepared.plateCount); + decodeDigits(prepared.initialCode, positionDigits); + const commands: Command[] = []; + let pendingActions = remainingShifts.filter((shift) => shift !== 0).length; + while (pendingActions > 0) { + const plate = findExecutablePlate(prepared, remainingShifts, positionDigits); + if (plate === null) { + return null; + } + const shift = readAt(remainingShifts, plate); + const effect = readAt(prepared.effects, plate); + for (const { target, sign } of effect.shifts) { + positionDigits[target] = readByte(positionDigits, target) + shift * sign; + } + commands.push(commandFor(plate, shift)); + remainingShifts[plate] = 0; + pendingActions -= 1; + } + budget.check('maxVisited', commands.length + 1); + budget.check('maxExpanded', commands.length); + return commands; +} + +function reconstruct(node: SearchNode): readonly Command[] { + const commands: Command[] = []; + while (node.previous !== null) { + commands.push(commandFor(node.plate, node.delta)); + node = node.previous; + } + return commands.reverse(); +} + +/** + * An invertible matrix gives unique remaining shifts for each physical state. + * Both cost lower bounds are consistent, so the first popped goal minimizes + * actions first, then unit shifts among all minimum-action paths. + */ +export class MatrixSearch { + private readonly model: LockModel; + private readonly prepared: PreparedSearch; + private readonly budget: SearchBudget; + + constructor(model: LockModel, prepared: PreparedSearch, budget: SearchBudget) { + this.model = model; + this.prepared = prepared; + this.budget = budget; + } + + solve(): readonly Command[] | null { + if (this.prepared.initialCode === this.prepared.goalCode) { + this.budget.visit(); + return []; + } + const analysis = analyzeMatrix(this.model); + if (analysis.kind === 'inconsistent' || analysis.kind === 'noninteger') { + return null; + } + if (analysis.kind === 'singular') { + return new BfsSearch(this.prepared, this.budget).solve(); + } + const certificate = greedyCertificate(this.prepared, analysis.net, this.budget); + if (certificate !== null) { + return certificate; + } + return new AStarSearch(this.prepared, this.budget).solve(analysis.net, analysis.lowerBound); + } +} + +/** Mutable A* state is allocated only after the certificate cannot finish. */ +class AStarSearch { + private readonly prepared: PreparedSearch; + private readonly budget: SearchBudget; + private readonly positionDigits: Uint8Array; + private readonly frontier = new IndexedHeap(); + private readonly records = new Map(); + private nextSequence = 1; + + constructor(prepared: PreparedSearch, budget: SearchBudget) { + this.prepared = prepared; + this.budget = budget; + this.positionDigits = new Uint8Array(prepared.plateCount); + } + + solve(requiredShifts: readonly number[], lowerBound: number): readonly Command[] | null { + this.budget.visit(); + const shiftEstimate = requiredShifts.reduce((sum, shift) => sum + Math.abs(shift), 0); + if (!Number.isSafeInteger(shiftEstimate)) { + throw new SearchLimitError('matrixArithmetic', Number.MAX_SAFE_INTEGER, shiftEstimate); + } + const root: SearchNode = { + state: this.prepared.initialCode, + actionCount: 0, + estimate: lowerBound, + unitShifts: 0, + shiftEstimate, + remainingShifts: requiredShifts, + previous: null, + // The root has no incoming command; reconstruction stops at previous=null. + plate: -1, + delta: 0, + sequence: 0, + heapIndex: NOT_QUEUED, + closed: false, + }; + this.frontier.push(root); + this.records.set(root.state, root); + + while (this.frontier.size > 0) { + const node = this.frontier.pop(); + if (node.state === this.prepared.goalCode) { + return reconstruct(node); + } + this.budget.expand(); + node.closed = true; + decodeDigits(node.state, this.positionDigits); + for (let plate = 0; plate < this.prepared.plateCount; plate += 1) { + this.expandPlate(node, plate); + } + } + return null; + } + + private expandPlate(node: SearchNode, plate: number): void { + const effect = readAt(this.prepared.effects, plate); + const remainingShift = readAt(node.remainingShifts, plate); + const preferredDirection = remainingShift < 0 ? -1 : 1; + const [positiveLimit, negativeLimit] = movementLimits(this.positionDigits, effect); + + // Preserve plate order, preferred direction, then larger displacement first. + for (const preference of DIRECTIONS) { + const direction = preferredDirection * preference; + const limit = direction > 0 ? positiveLimit : negativeLimit; + for (let steps = limit; steps >= 1; steps -= 1) { + this.visitNeighbor(node, plate, direction * steps, effect); + } + } + } + + private visitNeighbor(parent: SearchNode, plate: number, delta: number, effect: Effect): void { + const state = parent.state + delta * effect.stateCodeOffset; + const actionCount = parent.actionCount + 1; + const unitShifts = parent.unitShifts + Math.abs(delta); + const existing = this.records.get(state); + if (existing !== undefined) { + const noImprovement = existing.actionCount < actionCount + || (existing.actionCount === actionCount && existing.unitShifts <= unitShifts); + if (existing.closed || noImprovement) { + return; + } + if (!Number.isSafeInteger(unitShifts + existing.shiftEstimate)) { + throw new SearchLimitError('matrixArithmetic', Number.MAX_SAFE_INTEGER, unitShifts); + } + // The physical state fixes remaining shifts and their lower bound. + // A better cost only changes the predecessor and queue priority. + existing.actionCount = actionCount; + existing.unitShifts = unitShifts; + existing.previous = parent; + existing.plate = plate; + existing.delta = delta; + this.frontier.decreasePriority(existing); + return; + } + + this.budget.visit(); + this.budget.check('maxFrontier', this.frontier.size + 1); + const previousShift = readAt(parent.remainingShifts, plate); + const remainingShift = previousShift - delta; + const remainingShifts = [...parent.remainingShifts]; + remainingShifts[plate] = remainingShift; + const estimate = parent.estimate + - minimumActionsForShift(previousShift) + + minimumActionsForShift(remainingShift); + const shiftEstimate = parent.shiftEstimate - Math.abs(previousShift) + Math.abs(remainingShift); + if (!Number.isSafeInteger(remainingShift) || !Number.isSafeInteger(actionCount + estimate) + || !Number.isSafeInteger(unitShifts + shiftEstimate)) { + throw new SearchLimitError('matrixArithmetic', Number.MAX_SAFE_INTEGER, String(remainingShift)); + } + + const next: SearchNode = { + state, + actionCount, + estimate, + unitShifts, + shiftEstimate, + remainingShifts, + previous: parent, + plate, + delta, + sequence: this.nextSequence, + heapIndex: NOT_QUEUED, + closed: false, + }; + this.nextSequence += 1; + this.records.set(state, next); + this.frontier.push(next); + } +} diff --git a/src/bfs.ts b/src/bfs.ts new file mode 100644 index 0000000..b5aed85 --- /dev/null +++ b/src/bfs.ts @@ -0,0 +1,244 @@ +import { SearchLimitError } from './errors.ts'; +import { + DIRECTIONS, + MAX_SHIFT, + commandFor, + decodeDigits, + movementLimits, + readAt, + readByte, + readFloat64, + readInt32, +} from './lock.ts'; +import type { PreparedSearch } from './lock.ts'; +import type { SearchBudget } from './config.ts'; +import type { Command } from './types.ts'; + +const UNVISITED = -2; +const ROOT_PARENT = -1; +const MAX_INT32 = 0x7fffffff; +const DIRECTION_COUNT = DIRECTIONS.length; + +interface Predecessor { + readonly parent: number; + readonly action: number; + readonly actionCount: number; + readonly unitShifts: number; +} + +type Records = + | { + readonly dense: true; + readonly parents: Int32Array; + readonly actions: Uint8Array; + readonly actionCounts: Int32Array; + readonly unitShifts: Float64Array; + } + | { readonly dense: false; readonly parents: Map }; + +function encodeCommand(plate: number, direction: number, steps: number): number { + const directionIndex = direction > 0 ? 0 : 1; + return (plate * DIRECTION_COUNT + directionIndex) * MAX_SHIFT + steps - 1; +} + +function decodeCommand(packed: number): Command { + const plateAndDirection = Math.floor(packed / MAX_SHIFT); + const plate = Math.floor(plateAndDirection / DIRECTION_COUNT); + const direction = readAt(DIRECTIONS, plateAndDirection % DIRECTION_COUNT); + const steps = packed % MAX_SHIFT + 1; + return commandFor(plate, direction * steps); +} + +function hasVisited(records: Records, state: number): boolean { + return records.dense + ? readInt32(records.parents, state) !== UNVISITED + : records.parents.has(state); +} + +function remember(records: Records, state: number, parent: number, action: number, + actionCount: number, unitShifts: number): void { + if (records.dense) { + records.parents[state] = parent; + records.actions[state] = action; + records.actionCounts[state] = actionCount; + records.unitShifts[state] = unitShifts; + } else { + records.parents.set(state, { parent, action, actionCount, unitShifts }); + } +} + +function readPredecessor(records: Records, state: number): Predecessor { + if (records.dense) { + return { + parent: readInt32(records.parents, state), + action: readByte(records.actions, state), + actionCount: readInt32(records.actionCounts, state), + unitShifts: readFloat64(records.unitShifts, state), + }; + } + const predecessor = records.parents.get(state); + if (predecessor === undefined) { + throw new Error('Internal BFS predecessor invariant failed.'); + } + return predecessor; +} + +function reconstruct(records: Records, initialCode: number, state: number): readonly Command[] { + const commands: Command[] = []; + while (state !== initialCode) { + const predecessor = readPredecessor(records, state); + commands.push(decodeCommand(predecessor.action)); + state = predecessor.parent; + } + return commands.reverse(); +} + +/** Exact action layers; each next-layer state retains its least unit-shift cost. */ +export class BfsSearch { + private readonly prepared: PreparedSearch; + private readonly budget: SearchBudget; + private readonly positionDigits: Uint8Array; + private records: Records = { dense: false, parents: new Map() }; + private queue: Int32Array | number[] = []; + private queueCapacity = 1; + private readIndex = 0; + private writeIndex = 0; + private queuedStates = 0; + private goalFound = false; + + constructor(prepared: PreparedSearch, budget: SearchBudget) { + this.prepared = prepared; + this.budget = budget; + this.positionDigits = new Uint8Array(prepared.plateCount); + } + + solve(): readonly Command[] | null { + this.budget.visit(); + if (this.prepared.initialCode === this.prepared.goalCode) { + return []; + } + this.initializeStorage(); + remember(this.records, this.prepared.initialCode, ROOT_PARENT, 0, 0, 0); + this.enqueue(this.prepared.initialCode); + + while (this.queuedStates > 0) { + const layerSize = this.queuedStates; + for (let index = 0; index < layerSize; index += 1) { + const state = this.dequeue(); + this.expandState(state); + } + // Every predecessor at this depth has now offered its cheapest goal path. + if (this.goalFound) { + return reconstruct(this.records, this.prepared.initialCode, this.prepared.goalCode); + } + } + return null; + } + + private initializeStorage(): void { + const { stateCount } = this.prepared; + const { maxVisited, maxFrontier, maxDenseBytes } = this.budget.options; + this.queueCapacity = Math.min(stateCount, maxVisited, maxFrontier); + const bytesPerRecord = 2 * Int32Array.BYTES_PER_ELEMENT + + Uint8Array.BYTES_PER_ELEMENT + Float64Array.BYTES_PER_ELEMENT; + const denseBytes = stateCount * bytesPerRecord + this.queueCapacity * Int32Array.BYTES_PER_ELEMENT; + // Dense parents are signed state codes; negative values are sentinels. + const useDenseStorage = stateCount <= MAX_INT32 && denseBytes <= maxDenseBytes; + try { + this.records = useDenseStorage + ? { + dense: true, + parents: new Int32Array(stateCount).fill(UNVISITED), + actions: new Uint8Array(stateCount), + actionCounts: new Int32Array(stateCount), + unitShifts: new Float64Array(stateCount), + } + : { dense: false, parents: new Map() }; + this.queue = useDenseStorage ? new Int32Array(this.queueCapacity) : []; + } catch (error) { + if (!(error instanceof RangeError)) { + throw error; + } + throw new SearchLimitError('maxDenseBytes', maxDenseBytes, denseBytes, { cause: error }); + } + this.readIndex = 0; + this.writeIndex = 0; + this.queuedStates = 0; + this.goalFound = false; + } + + /** Ring-buffer storage retains only the live frontier, including sparse mode. */ + private enqueue(state: number): void { + this.budget.check('maxFrontier', this.queuedStates + 1); + this.queue[this.writeIndex] = state; + this.writeIndex = (this.writeIndex + 1) % this.queueCapacity; + this.queuedStates += 1; + } + + private dequeue(): number { + const state = this.queue instanceof Int32Array + ? readInt32(this.queue, this.readIndex) + : readAt(this.queue, this.readIndex); + this.readIndex = (this.readIndex + 1) % this.queueCapacity; + this.queuedStates -= 1; + return state; + } + + private expandState(state: number): void { + this.budget.expand(); + decodeDigits(state, this.positionDigits); + const distance = readPredecessor(this.records, state); + for (let plate = 0; plate < this.prepared.plateCount; plate += 1) { + if (this.expandPlate(state, plate, distance)) { + // Every one-command route from this state to the goal costs the same. + return; + } + } + } + + private expandPlate(state: number, plate: number, distance: Predecessor): boolean { + const effect = readAt(this.prepared.effects, plate); + const [positiveLimit, negativeLimit] = movementLimits(this.positionDigits, effect); + for (const direction of DIRECTIONS) { + const limit = direction > 0 ? positiveLimit : negativeLimit; + for (let steps = limit; steps >= 1; steps -= 1) { + const nextState = state + direction * steps * effect.stateCodeOffset; + if (this.visitNeighbor(state, nextState, encodeCommand(plate, direction, steps), distance, steps)) { + return true; + } + } + } + return false; + } + + private visitNeighbor(state: number, nextState: number, action: number, + distance: Predecessor, steps: number): boolean { + const isGoal = nextState === this.prepared.goalCode; + if (this.goalFound && !isGoal) { + return false; + } + const actionCount = distance.actionCount + 1; + const unitShifts = distance.unitShifts + steps; + if (!Number.isSafeInteger(unitShifts)) { + throw new SearchLimitError('matrixArithmetic', Number.MAX_SAFE_INTEGER, unitShifts); + } + const visited = hasVisited(this.records, nextState); + if (visited) { + const previous = readPredecessor(this.records, nextState); + if (previous.actionCount !== actionCount || previous.unitShifts <= unitShifts) { + return false; + } + } else { + this.budget.visit(); + } + remember(this.records, nextState, state, action, actionCount, unitShifts); + if (isGoal) { + this.goalFound = true; + return true; + } + if (!visited) { + this.enqueue(nextState); + } + return false; + } +} diff --git a/src/cli.mjs b/src/cli.mjs deleted file mode 100644 index f0b0efa..0000000 --- a/src/cli.mjs +++ /dev/null @@ -1,36 +0,0 @@ -import { readFile, writeFile } from 'node:fs/promises'; -import { solveLock } from './index.mjs'; -import { parseLockDefinition } from './lock-definition.mjs'; -import { formatConsoleResult } from './result.mjs'; - -export function parseArgs(argv) { - const isPath = (value) => value.length > 0 && !value.startsWith('--'); - - if (argv.length === 1 && isPath(argv[0])) { - return { inputPath: argv[0], outputPath: null }; - } - if (argv.length === 3 && isPath(argv[0]) && argv[1] === '--output' && isPath(argv[2])) { - return { inputPath: argv[0], outputPath: argv[2] }; - } - throw new Error('Использование: node solve-lock.mjs [--output ]'); -} - -export async function runCli(argv, streams = {}) { - const stdout = streams.stdout ?? process.stdout; - const stderr = streams.stderr ?? process.stderr; - - try { - const { inputPath, outputPath } = parseArgs(argv); - const definition = parseLockDefinition(await readFile(inputPath, 'utf8')); - const result = solveLock(definition); - - if (outputPath !== null) { - await writeFile(outputPath, `${JSON.stringify(result, null, 2)}\n`, 'utf8'); - } - stdout.write(`${formatConsoleResult(result)}\n`); - return result.status === 'unsolvable' ? 2 : 0; - } catch (error) { - stderr.write(`Ошибка: ${error.message}\n`); - return 1; - } -} diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..dcc62c3 --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,188 @@ +#!/usr/bin/env node +import { realpathSync } from 'node:fs'; +import { readFile, writeFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { validateSolverConfig } from './config.ts'; +import type { SolverConfig } from './config.ts'; +import { createSolverConfig, solveLock } from './index.ts'; +import { LockModel, TARGET_POSITION } from './lock.ts'; +import type { Command, Position } from './types.ts'; + +interface TextWriter { + write(text: string): unknown; +} + +interface Streams { + readonly stdout: TextWriter; + readonly stderr: TextWriter; +} + +interface CliOptions { + readonly inputPath: string; + readonly configPath: string | null; + readonly outputPath: string | null; +} + +interface CliCommand { + readonly plate: number; + readonly direction: 'left' | 'right'; + readonly steps: number; +} + +interface ResultBase { + readonly initialState: readonly Position[]; + readonly targetState: readonly Position[]; + readonly commands: readonly CliCommand[]; +} + +type CliResult = ResultBase & ( + | { readonly status: 'unsolvable' } + | { + readonly status: 'solved'; + readonly finalState: readonly Position[]; + readonly metrics: { + readonly commands: number; + readonly divisions: number; + readonly plateSwitches: number; + }; + } +); + +const USAGE = 'Использование: gothic-lock-solver [--config ] [--output ]' + + '\n gothic-lock-solver --help'; + +function parseArgs(argv: readonly string[]): CliOptions { + const inputPath = argv[0]; + if (typeof inputPath !== 'string' || inputPath.length === 0 || inputPath.startsWith('--')) { + throw new Error(USAGE); + } + + let configPath: string | null = null; + let outputPath: string | null = null; + // Each optional flag consumes its following file path. + for (let index = 1; index < argv.length; index += 2) { + const flag = argv[index]; + const path = argv[index + 1]; + if (typeof path !== 'string' || path.length === 0 || path.startsWith('--')) { + throw new Error(USAGE); + } + if (flag === '--config' && configPath === null) { + configPath = path; + } else if (flag === '--output' && outputPath === null) { + outputPath = path; + } else { + throw new Error(USAGE); + } + } + return { inputPath, configPath, outputPath }; +} + +function parseJson(json: string): unknown { + try { + return JSON.parse(json); + } catch { + throw new Error('Некорректный JSON.'); + } +} + +async function readLock(path: string): Promise { + const json = await readFile(path, 'utf8'); + const value = parseJson(json); + if (typeof value !== 'object' || value === null || Array.isArray(value) || !('state' in value) || !('links' in value)) { + throw new Error('Ожидается объект с полями state и links.'); + } + return new LockModel(value.state, value.links); +} + +async function readConfig(path: string | null): Promise { + if (path === null) { + return createSolverConfig(); + } + const json = await readFile(path, 'utf8'); + return validateSolverConfig(parseJson(json), 'overrides'); +} + +/** CLI files use one-based plates and physical-direction notation. */ +function createResult(state: readonly Position[], solution: readonly Command[] | null): CliResult { + const initialState = [...state]; + const targetState = state.map((): Position => TARGET_POSITION); + if (solution === null) { + return { status: 'unsolvable', initialState, targetState, commands: [] }; + } + + let previousPlate: number | undefined; + let divisions = 0; + let plateSwitches = 0; + const commands: CliCommand[] = []; + for (const [plate, delta] of solution) { + if (previousPlate !== undefined && previousPlate !== plate) { + plateSwitches += 1; + } + previousPlate = plate; + const steps = Math.abs(delta); + divisions += steps; + commands.push({ plate: plate + 1, direction: delta > 0 ? 'left' : 'right', steps }); + } + return { + status: 'solved', + initialState, + targetState, + commands, + finalState: [...targetState], + metrics: { commands: commands.length, divisions, plateSwitches }, + }; +} + +function formatResult(result: CliResult): string { + if (result.status === 'unsolvable') { + return 'Решение не найдено.'; + } + if (result.commands.length === 0) { + return 'Замок уже открыт.'; + } + const lines = result.commands.map((command, index) => { + const direction = command.direction === 'left' ? 'влево' : 'вправо'; + return `${index + 1}. Пластина ${command.plate} - ${direction} x${command.steps}`; + }); + return [`Найдено команд: ${result.commands.length}`, ...lines].join('\n'); +} + +/** Exit status: 0 solved, 2 proved unreachable, 1 malformed input/IO/resource failure. */ +export async function runCli(argv: readonly string[], streams: Streams = process): Promise<0 | 1 | 2> { + try { + if (argv.length === 1 && argv[0] === '--help') { + streams.stdout.write(`${USAGE}\n`); + return 0; + } + const { inputPath, configPath, outputPath } = parseArgs(argv); + const model = await readLock(inputPath); + const config = await readConfig(configPath); + const solution = solveLock(model.state, model.links, config); + const result = createResult(model.state, solution); + if (outputPath !== null) { + await writeFile(outputPath, `${JSON.stringify(result, null, 2)}\n`, 'utf8'); + } + streams.stdout.write(`${formatResult(result)}\n`); + return result.status === 'unsolvable' ? 2 : 0; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + streams.stderr.write(`Ошибка: ${message}\n`); + return 1; + } +} + +function isEntryPoint(): boolean { + const entryPath = process.argv[1]; + if (entryPath === undefined) { + return false; + } + try { + return realpathSync(entryPath) === realpathSync(fileURLToPath(import.meta.url)); + } catch { + return false; + } +} + +if (isEntryPoint()) { + process.exitCode = await runCli(process.argv.slice(2)); +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..3d4eb79 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,86 @@ +import { LockInputError, SearchLimitError } from './errors.ts'; + +/** Per-solve resource limits. Byte and expansion limits may be zero. */ +export interface SolverConfig { + readonly maxVisited: number; + readonly maxExpanded: number; + readonly maxFrontier: number; + readonly maxDenseBytes: number; +} + +const DEFAULTS: SolverConfig = Object.freeze({ + maxVisited: 2_000_000, + maxExpanded: 1_000_000, + maxFrontier: 1_000_000, + maxDenseBytes: 64 * 1024 * 1024, +}); + +type ConfigMode = 'complete' | 'overrides'; + +function isConfigField(key: PropertyKey): key is keyof SolverConfig { + return key === 'maxVisited' + || key === 'maxExpanded' + || key === 'maxFrontier' + || key === 'maxDenseBytes'; +} + +/** Shared factory implementation for typed overrides, complete configs and JSON. */ +export function validateSolverConfig(value: unknown, mode: ConfigMode = 'complete'): SolverConfig { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new LockInputError('Конфигурация поиска должна быть объектом.'); + } + + const config = { ...DEFAULTS }; + for (const key of Reflect.ownKeys(value)) { + if (!isConfigField(key)) { + throw new LockInputError(`Неизвестное поле конфигурации: ${String(key)}.`); + } + const setting: unknown = Reflect.get(value, key); + const minimum = key === 'maxVisited' || key === 'maxFrontier' ? 1 : 0; + if (typeof setting !== 'number' || !Number.isSafeInteger(setting) || setting < minimum) { + throw new LockInputError(`${key} должен быть безопасным целым числом не меньше ${minimum}.`); + } + config[key] = setting; + } + if (mode === 'complete') { + for (const key of Object.keys(DEFAULTS)) { + if (!Object.hasOwn(value, key)) { + throw new LockInputError(`В конфигурации отсутствует поле ${key}.`); + } + } + } + return Object.freeze(config); +} + +/** Return complete immutable settings without changing the caller's overrides. */ +export function createSolverConfig(overrides: Partial = {}): SolverConfig { + return validateSolverConfig(overrides, 'overrides'); +} + +/** Shared consumption counters for the certificate and both search algorithms. */ +export class SearchBudget { + readonly options: SolverConfig; + private visitedStates = 0; + private expandedStates = 0; + + constructor(options: SolverConfig = createSolverConfig()) { + this.options = options; + } + + check(name: keyof SolverConfig, used: number): void { + const maximum = this.options[name]; + if (used > maximum) { + throw new SearchLimitError(name, maximum, used); + } + } + + visit(): void { + this.check('maxVisited', this.visitedStates + 1); + this.visitedStates += 1; + } + + expand(): void { + this.check('maxExpanded', this.expandedStates + 1); + this.expandedStates += 1; + } +} diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 0000000..5da13a2 --- /dev/null +++ b/src/errors.ts @@ -0,0 +1,35 @@ +/** Invalid lock shape, positions, links or solver configuration. */ +export class LockInputError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options); + this.name = 'LockInputError'; + } +} + +export type SearchLimit = + | 'maxVisited' + | 'maxExpanded' + | 'maxFrontier' + | 'maxDenseBytes' + | 'stateEncoding' + | 'matrixArithmetic'; + +/** Computation exhausted a resource; this is never proof of unreachability. */ +export class SearchLimitError extends Error { + readonly limit: SearchLimit; + readonly maximum: number; + readonly used: number | string; + + constructor( + limit: SearchLimit, + maximum: number, + used: number | string, + options?: ErrorOptions, + ) { + super(`Превышен ресурсный предел поиска: ${limit} (предел ${maximum}, требуется ${used}).`, options); + this.name = 'SearchLimitError'; + this.limit = limit; + this.maximum = maximum; + this.used = used; + } +} diff --git a/src/index.mjs b/src/index.mjs deleted file mode 100644 index 5cc1f05..0000000 --- a/src/index.mjs +++ /dev/null @@ -1,10 +0,0 @@ -import { validateLockDefinition } from './lock-definition.mjs'; -import { createSolveResult } from './result.mjs'; -import { findShortestCommands } from './solver.mjs'; - -export function solveLock(value) { - const definition = validateLockDefinition(value); - return createSolveResult(definition, findShortestCommands(definition)); -} - -export { applyCommand, generateTransitions } from './transition.mjs'; diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..01e6803 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,35 @@ +import { LockModel, PreparedSearch } from './lock.ts'; +import { MatrixSearch } from './astar.ts'; +import { SearchBudget, createSolverConfig, validateSolverConfig } from './config.ts'; +import type { SolverConfig } from './config.ts'; +import type { Command, Links, Position, StateConstraint } from './types.ts'; + +/** + * Minimize actions, then total absolute pin displacement among equal-action + * solutions; return null when the goal is proved unreachable. + * Each [index, delta] directly changes the selected + * pin's numeric position and applies links[index][target] once without cascades. + * Inputs are copied and never modified; all goal positions are 4. + * + * Fixed tuples must have N >= 2 and matching N×N links. Dynamic arrays undergo + * the same shape and value checks at runtime. + * @throws {LockInputError} When an input or configuration is invalid. + * @throws {SearchLimitError} When a computation limit is exceeded (not unsolvable). + */ +export function solveLock( + state: S & StateConstraint, + links: NoInfer>, + config?: SolverConfig, +): readonly Command[] | null { + const options = config === undefined ? createSolverConfig() : validateSolverConfig(config); + const model = new LockModel(state, links); + const prepared = new PreparedSearch(model); + const budget = new SearchBudget(options); + return new MatrixSearch(model, prepared, budget).solve(); +} + +export { createSolverConfig } from './config.ts'; +export type { SolverConfig } from './config.ts'; +export { LockInputError, SearchLimitError } from './errors.ts'; +export type { SearchLimit } from './errors.ts'; +export type { Command, Delta, Link, Links, Position, State } from './types.ts'; diff --git a/src/lock-definition.mjs b/src/lock-definition.mjs deleted file mode 100644 index 63a5719..0000000 --- a/src/lock-definition.mjs +++ /dev/null @@ -1,57 +0,0 @@ -export class LockInputError extends Error { - constructor(message, options) { - super(message, options); - this.name = 'LockInputError'; - } -} - -export function validateLockDefinition(value) { - if (value === null || typeof value !== 'object' || Array.isArray(value)) { - throw new LockInputError('Определение замка должно быть JSON-объектом.'); - } - - const { state, links } = value; - if (!Array.isArray(state) || state.length < 2 || state.length > 7) { - throw new LockInputError('state должен содержать от 2 до 7 пластин.'); - } - - state.forEach((position, index) => { - if (!Number.isInteger(position) || position < 1 || position > 7) { - throw new LockInputError(`state[${index}] должен быть целым числом от 1 до 7.`); - } - }); - - if (!Array.isArray(links) || links.length !== state.length) { - throw new LockInputError(`links должен быть матрицей ${state.length}x${state.length}.`); - } - - links.forEach((row, rowIndex) => { - if (!Array.isArray(row) || row.length !== state.length) { - throw new LockInputError(`links[${rowIndex}] должен содержать ${state.length} элементов.`); - } - row.forEach((coefficient, columnIndex) => { - if (![-1, 0, 1].includes(coefficient)) { - throw new LockInputError(`links[${rowIndex}][${columnIndex}] должен быть -1, 0 или 1.`); - } - if (rowIndex === columnIndex && coefficient !== 0) { - throw new LockInputError(`links[${rowIndex}][${columnIndex}] на диагонали должен быть 0.`); - } - }); - }); - - return { - state: [...state], - links: links.map((row) => [...row]), - }; -} - -export function parseLockDefinition(text) { - try { - return validateLockDefinition(JSON.parse(text)); - } catch (error) { - if (error instanceof LockInputError) { - throw error; - } - throw new LockInputError(`Некорректный JSON: ${error.message}`, { cause: error }); - } -} diff --git a/src/lock.ts b/src/lock.ts new file mode 100644 index 0000000..9ac270f --- /dev/null +++ b/src/lock.ts @@ -0,0 +1,214 @@ +import { LockInputError, SearchLimitError } from './errors.ts'; +import type { Command, Delta, Link, Position } from './types.ts'; + +export const MIN_POSITION = 1; +export const MAX_POSITION = 7; +export const TARGET_POSITION = 4; +export const POSITION_COUNT = MAX_POSITION - MIN_POSITION + 1; +export const MAX_SHIFT = MAX_POSITION - MIN_POSITION; +export const DIRECTIONS = [1, -1] as const; +const MIN_PLATES = 2; + +function isArray(value: unknown): value is readonly unknown[] { + return Array.isArray(value); +} + +function isPosition(value: unknown): value is Position { + return typeof value === 'number' + && Number.isInteger(value) + && value >= MIN_POSITION + && value <= MAX_POSITION; +} + +function isLink(value: unknown): value is Link { + return value === -1 || value === 0 || value === 1; +} + +function copyState(state: unknown): readonly Position[] { + if (!isArray(state) || state.length < MIN_PLATES) { + throw new LockInputError(`state должен содержать не менее ${MIN_PLATES} пластин.`); + } + + const positions: Position[] = []; + for (let plate = 0; plate < state.length; plate += 1) { + const position = state[plate]; + if (!Object.hasOwn(state, plate) || !isPosition(position)) { + throw new LockInputError(`state[${plate}] должен быть целым числом от ${MIN_POSITION} до ${MAX_POSITION}.`); + } + positions.push(position); + } + return Object.freeze(positions); +} + +function copyLinkRow(row: unknown, source: number, plateCount: number): readonly Link[] { + if (!isArray(row) || row.length !== plateCount) { + throw new LockInputError(`links[${source}] должен содержать ${plateCount} элементов.`); + } + + const coefficients: Link[] = []; + for (let target = 0; target < plateCount; target += 1) { + const coefficient = row[target]; + if (!Object.hasOwn(row, target) || !isLink(coefficient)) { + throw new LockInputError(`links[${source}][${target}] должен быть -1, 0 или 1.`); + } + if (source === target && coefficient !== 0) { + throw new LockInputError(`links[${source}][${target}] на диагонали должен быть 0.`); + } + coefficients.push(coefficient); + } + return Object.freeze(coefficients); +} + +function copyLinks(links: unknown, plateCount: number): readonly (readonly Link[])[] { + if (!isArray(links) || links.length !== plateCount) { + throw new LockInputError(`links должен быть матрицей ${plateCount}x${plateCount}.`); + } + + const rows: (readonly Link[])[] = []; + for (let source = 0; source < plateCount; source += 1) { + if (!Object.hasOwn(links, source)) { + throw new LockInputError(`links[${source}] должен содержать ${plateCount} элементов.`); + } + rows.push(copyLinkRow(links[source], source, plateCount)); + } + return Object.freeze(rows); +} + +/** Owns validated immutable inputs, independent of caller mutations. */ +export class LockModel { + readonly state: readonly Position[]; + readonly links: readonly (readonly Link[])[]; + + constructor(state: unknown, links: unknown) { + this.state = copyState(state); + this.links = copyLinks(links, this.state.length); + } +} + +export interface Effect { + readonly shifts: readonly { readonly target: number; readonly sign: number }[]; + readonly stateCodeOffset: number; +} + +/** Base-7 state codes and direct plate effects prepared once per search. */ +export class PreparedSearch { + readonly plateCount: number; + readonly stateCount: number; + readonly initialCode: number; + readonly goalCode: number; + readonly effects: readonly Effect[]; + + constructor({ state, links }: LockModel) { + this.plateCount = state.length; + this.stateCount = POSITION_COUNT ** this.plateCount; + // Enforce the representation limit before even the already-open shortcut. + if (!Number.isSafeInteger(this.stateCount)) { + throw new SearchLimitError('stateEncoding', Number.MAX_SAFE_INTEGER, this.stateCount); + } + + const placeValues = Array.from( + { length: this.plateCount }, + (_, plate) => POSITION_COUNT ** (this.plateCount - plate - 1), + ); + let initialCode = 0; + let goalCode = 0; + for (let plate = 0; plate < this.plateCount; plate += 1) { + const placeValue = readAt(placeValues, plate); + initialCode += (readAt(state, plate) - MIN_POSITION) * placeValue; + goalCode += (TARGET_POSITION - MIN_POSITION) * placeValue; + } + this.initialCode = initialCode; + this.goalCode = goalCode; + this.effects = links.map((row, plate) => prepareEffect(row, plate, placeValues)); + } +} + +function prepareEffect(row: readonly Link[], plate: number, placeValues: readonly number[]): Effect { + const shifts: { target: number; sign: number }[] = []; + let stateCodeOffset = 0; + for (let target = 0; target < row.length; target += 1) { + const sign = plate === target ? 1 : readAt(row, target); + if (sign === 0) { + continue; + } + shifts.push({ target, sign }); + stateCodeOffset += sign * readAt(placeValues, target); + } + return { shifts, stateCodeOffset }; +} + +export function decodeDigits(stateCode: number, digits: Uint8Array): void { + for (let plate = digits.length - 1; plate >= 0; plate -= 1) { + digits[plate] = stateCode % POSITION_COUNT; + stateCode = Math.floor(stateCode / POSITION_COUNT); + } +} + +/** Monotone direct motion makes endpoint bounds sufficient for every unit step. */ +export function movementLimits(digits: Uint8Array, effect: Effect): readonly [positive: number, negative: number] { + let positiveLimit = MAX_SHIFT; + let negativeLimit = MAX_SHIFT; + for (const { target, sign } of effect.shifts) { + const positionDigit = readByte(digits, target); + const distanceToStart = positionDigit; + const distanceToEnd = MAX_SHIFT - positionDigit; + if (sign === 1) { + positiveLimit = Math.min(positiveLimit, distanceToEnd); + negativeLimit = Math.min(negativeLimit, distanceToStart); + } else { + positiveLimit = Math.min(positiveLimit, distanceToStart); + negativeLimit = Math.min(negativeLimit, distanceToEnd); + } + } + return [positiveLimit, negativeLimit]; +} + +/** One grouped action can contribute at most MAX_SHIFT positions on one plate. */ +export function minimumActionsForShift(shift: number): number { + return Math.ceil(Math.abs(shift) / MAX_SHIFT); +} + +function isDelta(value: number): value is Delta { + return Number.isInteger(value) && value !== 0 && value >= -MAX_SHIFT && value <= MAX_SHIFT; +} + +export function commandFor(plate: number, delta: number): Command { + if (!isDelta(delta)) { + throw new Error('Internal command displacement invariant failed.'); + } + return [plate, delta]; +} + +/** Checked access keeps dense-array invariants executable under strict indexing. */ +export function readAt(values: readonly T[], index: number): T { + const value = values[index]; + if (value === undefined) { + throw new Error(`Internal indexed-access invariant failed at ${index}.`); + } + return value; +} + +/** Typed readers keep hot typed-array access separate from object-array shapes. */ +export function readByte(values: Uint8Array, index: number): number { + const value = values[index]; + if (value === undefined) { + throw new Error(`Internal byte-access invariant failed at ${index}.`); + } + return value; +} + +export function readInt32(values: Int32Array, index: number): number { + const value = values[index]; + if (value === undefined) { + throw new Error(`Internal integer-access invariant failed at ${index}.`); + } + return value; +} + +export function readFloat64(values: Float64Array, index: number): number { + const value = values[index]; + if (value === undefined) { + throw new Error(`Internal number-access invariant failed at ${index}.`); + } + return value; +} diff --git a/src/matrix.ts b/src/matrix.ts new file mode 100644 index 0000000..f84ca9b --- /dev/null +++ b/src/matrix.ts @@ -0,0 +1,161 @@ +import { SearchLimitError } from './errors.ts'; +import { TARGET_POSITION, minimumActionsForShift, readAt } from './lock.ts'; +import type { LockModel } from './lock.ts'; + +interface Rational { + readonly numerator: bigint; + readonly denominator: bigint; +} + +function absolute(value: bigint): bigint { + return value < 0n ? -value : value; +} + +function greatestCommonDivisor(left: bigint, right: bigint): bigint { + while (right !== 0n) { + const remainder = left % right; + left = right; + right = remainder; + } + return left; +} + +/** Coprime numerator and denominator, with a positive denominator. */ +function fraction(numerator: bigint, denominator: bigint = 1n): Rational { + if (denominator === 0n) { + throw new Error('Internal zero-denominator invariant failed.'); + } + if (numerator === 0n) { + return { numerator: 0n, denominator: 1n }; + } + if (denominator < 0n) { + numerator = -numerator; + denominator = -denominator; + } + const divisor = greatestCommonDivisor(absolute(numerator), denominator); + return { numerator: numerator / divisor, denominator: denominator / divisor }; +} + +function divide(value: Rational, divisor: Rational): Rational { + return fraction( + value.numerator * divisor.denominator, + value.denominator * divisor.numerator, + ); +} + +function subtractProduct(value: Rational, factor: Rational, pivotValue: Rational): Rational { + const denominator = value.denominator * factor.denominator * pivotValue.denominator; + const scaledValue = value.numerator * factor.denominator * pivotValue.denominator; + const scaledProduct = factor.numerator * pivotValue.numerator * value.denominator; + return fraction(scaledValue - scaledProduct, denominator); +} + +export type MatrixAnalysis = + | { readonly kind: 'inconsistent'; readonly rank: number } + | { readonly kind: 'singular'; readonly rank: number } + | { readonly kind: 'noninteger'; readonly rank: number } + | { + readonly kind: 'unique-integer'; + readonly rank: number; + readonly net: readonly number[]; + readonly lowerBound: number; + }; + +/** Transpose source-first links, add self-motion, then append goal minus state. */ +function createAugmentedMatrix({ state, links }: LockModel): Rational[][] { + const rows: Rational[][] = []; + for (let target = 0; target < state.length; target += 1) { + const row: Rational[] = []; + for (let source = 0; source < state.length; source += 1) { + const coefficient = source === target ? 1 : readAt(readAt(links, source), target); + row.push(fraction(BigInt(coefficient))); + } + row.push(fraction(BigInt(TARGET_POSITION - readAt(state, target)))); + rows.push(row); + } + return rows; +} + +function findPivotRow(rows: readonly Rational[][], column: number, firstRow: number): number | null { + for (let row = firstRow; row < rows.length; row += 1) { + if (readAt(readAt(rows, row), column).numerator !== 0n) { + return row; + } + } + return null; +} + +function normalizePivotRow(row: Rational[], pivotColumn: number): void { + const divisor = readAt(row, pivotColumn); + for (let column = pivotColumn; column < row.length; column += 1) { + row[column] = divide(readAt(row, column), divisor); + } +} + +function eliminatePivotColumn(rows: Rational[][], pivotRowIndex: number, pivotColumn: number): void { + const pivotRow = readAt(rows, pivotRowIndex); + for (let rowIndex = 0; rowIndex < rows.length; rowIndex += 1) { + const row = readAt(rows, rowIndex); + const factor = readAt(row, pivotColumn); + if (rowIndex === pivotRowIndex || factor.numerator === 0n) { + continue; + } + for (let column = pivotColumn; column < row.length; column += 1) { + row[column] = subtractProduct(readAt(row, column), factor, readAt(pivotRow, column)); + } + } +} + +function toSafeInteger({ numerator }: Rational): number { + if (absolute(numerator) > BigInt(Number.MAX_SAFE_INTEGER)) { + throw new SearchLimitError('matrixArithmetic', Number.MAX_SAFE_INTEGER, String(numerator)); + } + return Number(numerator); +} + +/** + * Solve the exact necessary equation A z = goal - state using Gauss–Jordan. + * Inconsistency or a fractional unique z proves no integer path exists. + * Rank deficiency alone says nothing about reachability and requires exact BFS. + */ +export function analyzeMatrix(model: LockModel): MatrixAnalysis { + const rows = createAugmentedMatrix(model); + const plateCount = model.state.length; + let rank = 0; + + for (let column = 0; column < plateCount; column += 1) { + const pivot = findPivotRow(rows, column, rank); + if (pivot === null) { + continue; + } + const pivotRow = readAt(rows, pivot); + rows[pivot] = readAt(rows, rank); + rows[rank] = pivotRow; + normalizePivotRow(pivotRow, column); + eliminatePivotColumn(rows, rank, column); + rank += 1; + } + + for (let row = rank; row < plateCount; row += 1) { + const rightHandSide = readAt(readAt(rows, row), plateCount); + if (rightHandSide.numerator !== 0n) { + return { kind: 'inconsistent', rank }; + } + } + if (rank < plateCount) { + return { kind: 'singular', rank }; + } + + // A full-rank square system has one pivot per column, in plate order. + const exactShifts = rows.map((row) => readAt(row, plateCount)); + if (exactShifts.some(({ denominator }) => denominator !== 1n)) { + return { kind: 'noninteger', rank }; + } + + const net = exactShifts.map(toSafeInteger); + const lowerBound = net.reduce((sum, shift) => sum + minimumActionsForShift(shift), 0); + if (!Number.isSafeInteger(lowerBound)) { + throw new SearchLimitError('matrixArithmetic', Number.MAX_SAFE_INTEGER, lowerBound); + } + return { kind: 'unique-integer', rank, net, lowerBound }; +} diff --git a/src/priority-queue.ts b/src/priority-queue.ts new file mode 100644 index 0000000..c973711 --- /dev/null +++ b/src/priority-queue.ts @@ -0,0 +1,110 @@ +export const NOT_QUEUED = -1; + +export interface SearchNode { + readonly state: number; + actionCount: number; + readonly estimate: number; + unitShifts: number; + readonly shiftEstimate: number; + readonly remainingShifts: readonly number[]; + previous: SearchNode | null; + plate: number; + delta: number; + readonly sequence: number; + heapIndex: number; + closed: boolean; +} + +/** One entry per open state; decreasing priority never leaves obsolete nodes. */ +export class IndexedHeap { + private readonly nodes: SearchNode[] = []; + + get size(): number { + return this.nodes.length; + } + + private hasHigherPriority(left: SearchNode, right: SearchNode): boolean { + const leftTotal = left.actionCount + left.estimate; + const rightTotal = right.actionCount + right.estimate; + if (leftTotal !== rightTotal) { + return leftTotal < rightTotal; + } + const leftShifts = left.unitShifts + left.shiftEstimate; + const rightShifts = right.unitShifts + right.shiftEstimate; + if (leftShifts !== rightShifts) { + return leftShifts < rightShifts; + } + if (left.estimate !== right.estimate) { + return left.estimate < right.estimate; + } + return left.sequence < right.sequence; + } + + decreasePriority(node: SearchNode): void { + let index = node.heapIndex; + while (index > 0) { + const parentIndex = Math.floor((index - 1) / 2); + const parent = this.nodes[parentIndex]; + if (parent === undefined) { + throw new Error('Internal heap parent invariant failed.'); + } + if (!this.hasHigherPriority(node, parent)) { + break; + } + this.nodes[index] = parent; + parent.heapIndex = index; + index = parentIndex; + } + this.nodes[index] = node; + node.heapIndex = index; + } + + push(node: SearchNode): void { + node.heapIndex = this.nodes.length; + this.nodes.push(node); + this.decreasePriority(node); + } + + pop(): SearchNode { + const first = this.nodes[0]; + const last = this.nodes.pop(); + if (first === undefined || last === undefined) { + throw new Error('Internal empty-heap invariant failed.'); + } + first.heapIndex = NOT_QUEUED; + if (this.nodes.length === 0) { + return first; + } + + let index = 0; + while (true) { + let childIndex = index * 2 + 1; + if (childIndex >= this.nodes.length) { + break; + } + let child = this.nodes[childIndex]; + if (child === undefined) { + throw new Error('Internal heap child invariant failed.'); + } + if (childIndex + 1 < this.nodes.length) { + const rightChild = this.nodes[childIndex + 1]; + if (rightChild === undefined) { + throw new Error('Internal heap child invariant failed.'); + } + if (this.hasHigherPriority(rightChild, child)) { + childIndex += 1; + child = rightChild; + } + } + if (!this.hasHigherPriority(child, last)) { + break; + } + this.nodes[index] = child; + child.heapIndex = index; + index = childIndex; + } + this.nodes[index] = last; + last.heapIndex = index; + return first; + } +} diff --git a/src/result.mjs b/src/result.mjs deleted file mode 100644 index 03fd343..0000000 --- a/src/result.mjs +++ /dev/null @@ -1,59 +0,0 @@ -import { createTargetState } from './state-codec.mjs'; -import { applyCommand } from './transition.mjs'; - -export function createSolveResult(definition, commands) { - const initialState = [...definition.state]; - const targetState = createTargetState(initialState.length); - - if (commands === null) { - return { status: 'unsolvable', initialState, targetState, commands: [] }; - } - - let finalState = [...initialState]; - for (const command of commands) { - const nextState = applyCommand(finalState, definition.links, command); - if (nextState === null) { - throw new Error('Решатель вернул заблокированную команду.'); - } - finalState = nextState; - } - - if (!finalState.every((position) => position === 4)) { - throw new Error('Решатель не привел замок к целевому состоянию.'); - } - - const plateSwitches = commands.reduce((count, command, index) => { - if (index === 0 || command.plate === commands[index - 1].plate) { - return count; - } - return count + 1; - }, 0); - - return { - status: 'solved', - initialState, - targetState, - commands: commands.map((command) => ({ ...command })), - finalState, - metrics: { - commands: commands.length, - divisions: commands.reduce((sum, command) => sum + command.steps, 0), - plateSwitches, - }, - }; -} - -export function formatConsoleResult(result) { - if (result.status === 'unsolvable') { - return 'Решение не найдено.'; - } - if (result.commands.length === 0) { - return 'Замок уже открыт.'; - } - - const directionNames = { left: 'влево', right: 'вправо' }; - const lines = result.commands.map((command, index) => ( - `${index + 1}. Пластина ${command.plate} - ${directionNames[command.direction]} x${command.steps}` - )); - return [`Найдено команд: ${result.commands.length}`, ...lines].join('\n'); -} diff --git a/src/solver.mjs b/src/solver.mjs deleted file mode 100644 index 50d30b5..0000000 --- a/src/solver.mjs +++ /dev/null @@ -1,80 +0,0 @@ -import { - createTargetState, - decodeState, - encodeState, - stateSpaceSize, -} from './state-codec.mjs'; -import { generateTransitions } from './transition.mjs'; - -const UNSEEN = -2; -const ROOT = -1; - -export function packCommand(command) { - const directionBit = command.direction === 'left' ? 0 : 1; - return (((command.plate - 1) * 2 + directionBit) * 6) + command.steps - 1; -} - -export function unpackCommand(packed) { - const steps = (packed % 6) + 1; - const plateDirection = Math.floor(packed / 6); - return { - plate: Math.floor(plateDirection / 2) + 1, - direction: plateDirection % 2 === 0 ? 'left' : 'right', - steps, - }; -} - -function reconstructCommands(startCode, targetCode, parents, actions) { - const commands = []; - let code = targetCode; - while (code !== startCode) { - commands.push(unpackCommand(actions[code])); - code = parents[code]; - } - commands.reverse(); - return commands; -} - -export function findShortestCommands({ state, links }) { - const plateCount = state.length; - const startCode = encodeState(state); - const targetCode = encodeState(createTargetState(plateCount)); - if (startCode === targetCode) { - return []; - } - - const size = stateSpaceSize(plateCount); - const parents = new Int32Array(size); - parents.fill(UNSEEN); - const actions = new Uint8Array(size); - const queue = new Int32Array(size); - - parents[startCode] = ROOT; - let head = 0; - let tail = 0; - queue[tail] = startCode; - tail += 1; - - while (head < tail) { - const currentCode = queue[head]; - head += 1; - const currentState = decodeState(currentCode, plateCount); - - for (const transition of generateTransitions(currentState, links)) { - const nextCode = encodeState(transition.state); - if (parents[nextCode] !== UNSEEN) { - continue; - } - - parents[nextCode] = currentCode; - actions[nextCode] = packCommand(transition.command); - if (nextCode === targetCode) { - return reconstructCommands(startCode, targetCode, parents, actions); - } - queue[tail] = nextCode; - tail += 1; - } - } - - return null; -} diff --git a/src/state-codec.mjs b/src/state-codec.mjs deleted file mode 100644 index ea0431c..0000000 --- a/src/state-codec.mjs +++ /dev/null @@ -1,25 +0,0 @@ -export function stateSpaceSize(plateCount) { - return 7 ** plateCount; -} - -export function encodeState(state) { - let code = 0; - for (const position of state) { - code = code * 7 + (position - 1); - } - return code; -} - -export function decodeState(code, plateCount) { - const state = new Array(plateCount); - let remainder = code; - for (let index = plateCount - 1; index >= 0; index -= 1) { - state[index] = (remainder % 7) + 1; - remainder = Math.floor(remainder / 7); - } - return state; -} - -export function createTargetState(plateCount) { - return Array.from({ length: plateCount }, () => 4); -} diff --git a/src/transition.mjs b/src/transition.mjs deleted file mode 100644 index 6886872..0000000 --- a/src/transition.mjs +++ /dev/null @@ -1,46 +0,0 @@ -const DIRECTIONS = ['left', 'right']; - -export function applyCommand(state, links, command) { - const sourceIndex = command.plate - 1; - const directionDelta = command.direction === 'left' ? 1 : -1; - const nextState = [...state]; - - for (let targetIndex = 0; targetIndex < state.length; targetIndex += 1) { - const coefficient = targetIndex === sourceIndex - ? 1 - : links[sourceIndex][targetIndex]; - - if (coefficient === 0) { - continue; - } - - const nextPosition = state[targetIndex] - + directionDelta * coefficient * command.steps; - if (nextPosition < 1 || nextPosition > 7) { - return null; - } - nextState[targetIndex] = nextPosition; - } - - return nextState; -} - -export function generateTransitions(state, links) { - const transitions = []; - for (let plate = 1; plate <= state.length; plate += 1) { - const sourcePosition = state[plate - 1]; - for (const direction of DIRECTIONS) { - const sourceLimit = direction === 'left' - ? 7 - sourcePosition - : sourcePosition - 1; - for (let steps = sourceLimit; steps >= 1; steps -= 1) { - const command = { plate, direction, steps }; - const nextState = applyCommand(state, links, command); - if (nextState !== null) { - transitions.push({ command, state: nextState }); - } - } - } - } - return transitions; -} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..d12453e --- /dev/null +++ b/src/types.ts @@ -0,0 +1,20 @@ +/** A pin's numbered position; all pins are open at position 4. */ +export type Position = 1 | 2 | 3 | 4 | 5 | 6 | 7; + +/** Direct influence from links[source][target]; the diagonal must be zero. */ +export type Link = -1 | 0 | 1; + +/** Signed numeric pin displacement in one action; zero is not an action. */ +export type Delta = -6 | -5 | -4 | -3 | -2 | -1 | 1 | 2 | 3 | 4 | 5 | 6; + +export type State = readonly [Position, Position, ...Position[]]; +export type Command = readonly [index: number, delta: Delta]; + +/** Dynamic arrays are runtime checked; fixed tuples need at least two plates. */ +export type StateConstraint = + number extends S['length'] ? unknown : S extends State ? unknown : never; + +/** A square matrix when S has a fixed tuple length; runtime-checked otherwise. */ +export type Links = { + readonly [Row in keyof S]: { readonly [Column in keyof S]: Link }; +}; diff --git a/test/cli.test.mjs b/test/cli.test.mjs deleted file mode 100644 index e9f14a4..0000000 --- a/test/cli.test.mjs +++ /dev/null @@ -1,160 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { spawnSync } from 'node:child_process'; -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const projectRoot = dirname(dirname(fileURLToPath(import.meta.url))); -const entry = join(projectRoot, 'solve-lock.mjs'); - -async function createCase(t, definition) { - const directory = await mkdtemp(join(tmpdir(), 'gothic-lock-solver-')); - t.after(() => rm(directory, { recursive: true, force: true })); - const inputPath = join(directory, 'lock.json'); - await writeFile(inputPath, JSON.stringify(definition), 'utf8'); - return { directory, inputPath }; -} - -function run(args) { - return spawnSync(process.execPath, [entry, ...args], { encoding: 'utf8' }); -} - -const expectedSolutionJson = `{ - "status": "solved", - "initialState": [ - 1, - 7 - ], - "targetState": [ - 4, - 4 - ], - "commands": [ - { - "plate": 1, - "direction": "left", - "steps": 3 - }, - { - "plate": 2, - "direction": "right", - "steps": 3 - } - ], - "finalState": [ - 4, - 4 - ], - "metrics": { - "commands": 2, - "divisions": 6, - "plateSwitches": 1 - } -} -`; - -test('prints a solution without creating an output file by default', async (t) => { - const { directory, inputPath } = await createCase(t, { - state: [1, 7], - links: [[0, 0], [0, 0]], - }); - const outputPath = join(directory, 'solution.json'); - const result = run([inputPath]); - assert.equal(result.status, 0); - assert.match(result.stdout, /Найдено команд: 2/); - assert.equal(result.stderr, ''); - await assert.rejects(readFile(outputPath, 'utf8'), { code: 'ENOENT' }); -}); - -test('--output creates and then overwrites exact pretty JSON bytes', async (t) => { - const { directory, inputPath } = await createCase(t, { - state: [1, 7], - links: [[0, 0], [0, 0]], - }); - const outputPath = join(directory, 'solution.json'); - const created = run([inputPath, '--output', outputPath]); - assert.equal(created.status, 0); - assert.equal(created.stderr, ''); - assert.equal(await readFile(outputPath, 'utf8'), expectedSolutionJson); - - await writeFile(outputPath, 'stale'.repeat(1_000), 'utf8'); - const overwritten = run([inputPath, '--output', outputPath]); - assert.equal(overwritten.status, 0); - assert.match(overwritten.stdout, /Пластина 1 - влево x3/); - assert.equal(overwritten.stderr, ''); - assert.equal(await readFile(outputPath, 'utf8'), expectedSolutionJson); -}); - -test('invalid JSON exits with code 1 and a Russian error', async (t) => { - const { inputPath } = await createCase(t, { - state: [4, 4], - links: [[0, 0], [0, 0]], - }); - await writeFile(inputPath, '{', 'utf8'); - const result = run([inputPath]); - assert.equal(result.status, 1); - assert.equal(result.stdout, ''); - assert.match(result.stderr, /Ошибка: Некорректный JSON/); -}); - -test('an unsolvable lock writes JSON and exits with code 2', async (t) => { - const { directory, inputPath } = await createCase(t, { - state: [1, 7], - links: [[0, 1], [1, 0]], - }); - const outputPath = join(directory, 'solution.json'); - const result = run([inputPath, '--output', outputPath]); - assert.equal(result.status, 2); - assert.match(result.stdout, /Решение не найдено/); - assert.equal(result.stderr, ''); - assert.equal(JSON.parse(await readFile(outputPath, 'utf8')).status, 'unsolvable'); -}); - -test('read errors exit with code 1 and keep stdout empty', async (t) => { - const { directory, inputPath } = await createCase(t, { - state: [4, 4], - links: [[0, 0], [0, 0]], - }); - - const missingInput = run([join(directory, 'missing.json')]); - assert.equal(missingInput.status, 1); - assert.equal(missingInput.stdout, ''); - assert.match(missingInput.stderr, /Ошибка:/); -}); - -test('strict CLI grammar errors exit with code 1 and keep stdout empty', async (t) => { - const { inputPath, directory } = await createCase(t, { - state: [4, 4], - links: [[0, 0], [0, 0]], - }); - - for (const args of [ - [], - ['--unknown'], - ['--output'], - [''], - [inputPath, '--unknown'], - [inputPath, '--output'], - [inputPath, '--output', ''], - [inputPath, 'extra'], - [inputPath, '--output', join(directory, 'solution.json'), 'extra'], - ]) { - const invalidArgs = run(args); - assert.equal(invalidArgs.status, 1); - assert.equal(invalidArgs.stdout, ''); - assert.match(invalidArgs.stderr, /Ошибка: Использование:/); - } -}); - -test('write errors exit with code 1 and keep stdout empty', async (t) => { - const { directory, inputPath } = await createCase(t, { - state: [4, 4], - links: [[0, 0], [0, 0]], - }); - const writeError = run([inputPath, '--output', directory]); - assert.equal(writeError.status, 1); - assert.equal(writeError.stdout, ''); - assert.match(writeError.stderr, /Ошибка:/); -}); diff --git a/test/differential.test.mjs b/test/differential.test.mjs deleted file mode 100644 index 60ace08..0000000 --- a/test/differential.test.mjs +++ /dev/null @@ -1,50 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { createTargetState } from '../src/state-codec.mjs'; -import { findShortestCommands } from '../src/solver.mjs'; -import { generateTransitions } from '../src/transition.mjs'; - -function referenceBfs(definition) { - const targetKey = createTargetState(definition.state.length).join(','); - const startKey = definition.state.join(','); - if (startKey === targetKey) { - return []; - } - - const queue = [{ state: definition.state, commands: [] }]; - const visited = new Set([startKey]); - for (let head = 0; head < queue.length; head += 1) { - const current = queue[head]; - for (const transition of generateTransitions(current.state, definition.links)) { - const key = transition.state.join(','); - if (visited.has(key)) { - continue; - } - const commands = [...current.commands, transition.command]; - if (key === targetKey) { - return commands; - } - visited.add(key); - queue.push({ state: transition.state, commands }); - } - } - return null; -} - -test('typed-array BFS matches reference BFS for every N=2 lock', () => { - for (const link12 of [-1, 0, 1]) { - for (const link21 of [-1, 0, 1]) { - const links = [[0, link12], [link21, 0]]; - for (let first = 1; first <= 7; first += 1) { - for (let second = 1; second <= 7; second += 1) { - const definition = { state: [first, second], links }; - assert.deepEqual( - findShortestCommands(definition), - referenceBfs(definition), - `state=${first},${second}; links=${link12},${link21}`, - ); - } - } - } - } -}); diff --git a/test/e2e.test.mjs b/test/e2e.test.mjs deleted file mode 100644 index e0af50d..0000000 --- a/test/e2e.test.mjs +++ /dev/null @@ -1,24 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { spawnSync } from 'node:child_process'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const projectRoot = dirname(dirname(fileURLToPath(import.meta.url))); - -test('example lock produces the documented four-command solution', () => { - const result = spawnSync( - process.execPath, - [join(projectRoot, 'solve-lock.mjs'), join(projectRoot, 'examples/lock.example.json')], - { encoding: 'utf8' }, - ); - assert.equal(result.status, 0); - assert.equal( - result.stdout, - 'Найдено команд: 4\n' - + '1. Пластина 1 - влево x3\n' - + '2. Пластина 2 - вправо x3\n' - + '3. Пластина 4 - влево x2\n' - + '4. Пластина 5 - вправо x2\n', - ); -}); diff --git a/test/lock-definition.test.mjs b/test/lock-definition.test.mjs deleted file mode 100644 index ecfe78c..0000000 --- a/test/lock-definition.test.mjs +++ /dev/null @@ -1,53 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { - LockInputError, - parseLockDefinition, - validateLockDefinition, -} from '../src/lock-definition.mjs'; - -const validLock = { - state: [7, 4, 1], - links: [ - [0, 1, -1], - [0, 0, 0], - [1, 0, 0], - ], -}; - -test('validateLockDefinition accepts and copies a valid directed lock', () => { - const result = validateLockDefinition(validLock); - assert.deepEqual(result, validLock); - assert.notEqual(result.state, validLock.state); - assert.notEqual(result.links, validLock.links); - for (const index of validLock.links.keys()) { - assert.notEqual(result.links[index], validLock.links[index]); - } - assert.notEqual(result.links[0], validLock.links[0]); -}); - -test('validateLockDefinition ignores additional input properties', () => { - const result = validateLockDefinition({ ...validLock, name: 'ignored', metadata: { source: 'test' } }); - assert.deepEqual(result, validLock); - assert.equal('name' in result, false); - assert.equal('metadata' in result, false); -}); - -for (const [name, value, message] of [ - ['requires 2 to 7 plates', { state: [4], links: [[0]] }, /от 2 до 7/], - ['rejects a position outside 1..7', { ...validLock, state: [7, 8, 1] }, /state\[1\]/], - ['requires a square matrix', { ...validLock, links: [[0], [0], [0]] }, /links\[0\]/], - ['rejects an unknown coefficient', { ...validLock, links: [[0, 2, 0], [0, 0, 0], [0, 0, 0]] }, /-1, 0 или 1/], - ['requires a zero diagonal', { ...validLock, links: [[1, 0, 0], [0, 0, 0], [0, 0, 0]] }, /диагонали/], -]) { - test(name, () => { - assert.throws(() => validateLockDefinition(value), message); - }); -} - -test('parseLockDefinition reports malformed JSON as LockInputError', () => { - assert.throws( - () => parseLockDefinition('{'), - (error) => error instanceof LockInputError && /Некорректный JSON/.test(error.message), - ); -}); diff --git a/test/result.test.mjs b/test/result.test.mjs deleted file mode 100644 index fe4e4e4..0000000 --- a/test/result.test.mjs +++ /dev/null @@ -1,54 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { solveLock } from '../src/index.mjs'; -import { createSolveResult, formatConsoleResult } from '../src/result.mjs'; - -const definition = { - state: [1, 7], - links: [[0, 0], [0, 0]], -}; -const commands = [ - { plate: 1, direction: 'left', steps: 3 }, - { plate: 2, direction: 'right', steps: 3 }, -]; - -test('createSolveResult replays commands and calculates metrics', () => { - const result = createSolveResult(definition, commands); - assert.deepEqual(result.finalState, [4, 4]); - assert.deepEqual(result.metrics, { - commands: 2, - divisions: 6, - plateSwitches: 1, - }); -}); - -test('formatConsoleResult renders grouped Russian commands', () => { - const result = createSolveResult(definition, commands); - assert.equal( - formatConsoleResult(result), - 'Найдено команд: 2\n' - + '1. Пластина 1 - влево x3\n' - + '2. Пластина 2 - вправо x3', - ); -}); - -test('already-open and unsolvable results have distinct text', () => { - const open = createSolveResult({ state: [4, 4], links: definition.links }, []); - const unsolvable = createSolveResult(definition, null); - assert.equal(formatConsoleResult(open), 'Замок уже открыт.'); - assert.equal(formatConsoleResult(unsolvable), 'Решение не найдено.'); - assert.deepEqual(unsolvable.commands, []); -}); - -test('already-open result exposes independent initial and final states', () => { - const result = createSolveResult({ state: [4, 4], links: definition.links }, []); - assert.notStrictEqual(result.initialState, result.finalState); - result.finalState[0] = 1; - assert.deepEqual(result.initialState, [4, 4]); -}); - -test('solveLock validates and solves through the website-safe facade', () => { - const result = solveLock(definition); - assert.equal(result.status, 'solved'); - assert.deepEqual(result.finalState, [4, 4]); -}); diff --git a/test/solver.test.mjs b/test/solver.test.mjs deleted file mode 100644 index 810968d..0000000 --- a/test/solver.test.mjs +++ /dev/null @@ -1,85 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { - findShortestCommands, - packCommand, - unpackCommand, -} from '../src/solver.mjs'; - -const noLinks = [[0, 0], [0, 0]]; - -test('packs every possible command into one byte and restores it', () => { - for (let plate = 1; plate <= 7; plate += 1) { - for (const direction of ['left', 'right']) { - for (let steps = 1; steps <= 6; steps += 1) { - const command = { plate, direction, steps }; - const packed = packCommand(command); - assert.ok(packed >= 0 && packed <= 83); - assert.deepEqual(unpackCommand(packed), command); - } - } - } -}); - -test('returns no commands for an already open lock', () => { - assert.deepEqual( - findShortestCommands({ state: [4, 4], links: noLinks }), - [], - ); -}); - -test('uses one macro command per independent plate', () => { - assert.deepEqual( - findShortestCommands({ state: [1, 7], links: noLinks }), - [ - { plate: 1, direction: 'left', steps: 3 }, - { plate: 2, direction: 'right', steps: 3 }, - ], - ); -}); - -test('prefers the lower plate when two one-command solutions exist', () => { - const links = [[0, 1], [1, 0]]; - assert.deepEqual( - findShortestCommands({ state: [1, 1], links }), - [{ plate: 1, direction: 'left', steps: 3 }], - ); -}); - -test('returns null when every possible move is blocked', () => { - const links = [[0, 1], [1, 0]]; - assert.equal(findShortestCommands({ state: [1, 7], links }), null); -}); - -test('prefers left over right when both start equally short solutions', () => { - const definition = { - state: [2, 7, 1], - links: [ - [0, 0, 0], - [-1, 0, -1], - [-1, -1, 0], - ], - }; - assert.deepEqual(findShortestCommands(definition), [ - { plate: 1, direction: 'left', steps: 5 }, - { plate: 3, direction: 'left', steps: 3 }, - ]); -}); - -test('prefers larger steps when earlier action fields are equal', () => { - const definition = { - state: [3, 2, 1], - links: [ - [0, 0, -1], - [-1, 0, -1], - [-1, -1, 0], - ], - }; - assert.deepEqual(findShortestCommands(definition), [ - { plate: 1, direction: 'right', steps: 2 }, - { plate: 3, direction: 'right', steps: 2 }, - { plate: 2, direction: 'right', steps: 2 }, - { plate: 1, direction: 'right', steps: 3 }, - { plate: 3, direction: 'right', steps: 2 }, - ]); -}); diff --git a/test/state-codec.test.mjs b/test/state-codec.test.mjs deleted file mode 100644 index f898793..0000000 --- a/test/state-codec.test.mjs +++ /dev/null @@ -1,23 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { - createTargetState, - decodeState, - encodeState, - stateSpaceSize, -} from '../src/state-codec.mjs'; - -test('base-7 encoding has stable boundary values', () => { - assert.equal(encodeState([1, 1]), 0); - assert.equal(encodeState([7, 7]), 48); - assert.equal(stateSpaceSize(7), 823_543); -}); - -test('encodeState and decodeState round-trip a seven-plate state', () => { - const state = [7, 4, 1, 3, 6, 2, 5]; - assert.deepEqual(decodeState(encodeState(state), state.length), state); -}); - -test('createTargetState creates only center positions', () => { - assert.deepEqual(createTargetState(5), [4, 4, 4, 4, 4]); -}); diff --git a/test/transition.test.mjs b/test/transition.test.mjs deleted file mode 100644 index 2e73728..0000000 --- a/test/transition.test.mjs +++ /dev/null @@ -1,64 +0,0 @@ -import test from 'node:test'; -import assert from 'node:assert/strict'; -import { applyCommand, generateTransitions } from '../src/transition.mjs'; - -test('applyCommand moves direct and inverse links by the full step count', () => { - const state = [3, 5, 4]; - const links = [ - [0, 1, -1], - [0, 0, 0], - [0, 0, 0], - ]; - const result = applyCommand(state, links, { plate: 1, direction: 'left', steps: 2 }); - assert.deepEqual(result, [5, 7, 2]); - assert.deepEqual(state, [3, 5, 4]); -}); - -test('applyCommand does not cascade through a moved target row', () => { - const links = [ - [0, 1, 0], - [0, 0, 1], - [0, 0, 0], - ]; - assert.deepEqual( - applyCommand([3, 5, 4], links, { plate: 1, direction: 'left', steps: 2 }), - [5, 7, 4], - ); -}); - -test('applyCommand atomically blocks when one linked plate cannot finish', () => { - const state = [3, 6]; - const links = [[0, 1], [0, 0]]; - assert.deepEqual( - applyCommand(state, links, { plate: 1, direction: 'left', steps: 1 }), - [4, 7], - ); - assert.equal( - applyCommand(state, links, { plate: 1, direction: 'left', steps: 2 }), - null, - ); - assert.deepEqual(state, [3, 6]); -}); - -test('a directed source row does not imply a reverse link', () => { - const links = [[0, 1], [0, 0]]; - assert.deepEqual( - applyCommand([3, 5], links, { plate: 2, direction: 'right', steps: 1 }), - [3, 4], - ); -}); - -test('generateTransitions uses plate, direction, descending steps order', () => { - const transitions = generateTransitions([4, 4], [[0, 0], [0, 0]]); - assert.deepEqual( - transitions.slice(0, 6).map(({ command }) => command), - [ - { plate: 1, direction: 'left', steps: 3 }, - { plate: 1, direction: 'left', steps: 2 }, - { plate: 1, direction: 'left', steps: 1 }, - { plate: 1, direction: 'right', steps: 3 }, - { plate: 1, direction: 'right', steps: 2 }, - { plate: 1, direction: 'right', steps: 1 }, - ], - ); -}); diff --git a/tests/benchmarks/benchmark.ts b/tests/benchmarks/benchmark.ts new file mode 100644 index 0000000..e81d8cb --- /dev/null +++ b/tests/benchmarks/benchmark.ts @@ -0,0 +1,8 @@ +import { main } from './cli.ts'; + +try { + await main(); +} catch (error) { + console.error(error); + process.exitCode = 1; +} diff --git a/tests/benchmarks/catalog.ts b/tests/benchmarks/catalog.ts new file mode 100644 index 0000000..851391c --- /dev/null +++ b/tests/benchmarks/catalog.ts @@ -0,0 +1,43 @@ +import { createHash } from 'node:crypto'; +import { execFileSync } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { array, integer, record, string, type Definition, type Metrics } from './validation.ts'; + +export const catalogSha256 = '902a57e0c867b7ed7513af09257fde765396593ab45738bb60967b412b4bcd69'; +export type Fixture = Definition & { id: string; expectedActions: number }; +export function sha256(value: string | Uint8Array): string { return createHash('sha256').update(value).digest('hex'); } +export function jsonFile(path: string | URL): unknown { const parsed: unknown = JSON.parse(readFileSync(path, 'utf8')); return parsed; } +export function readMetrics(value: unknown): Metrics { + const item = record(value, 'metrics'); + return { A: integer(item['A']), U: integer(item['U']), C: integer(item['C']), plateSwitches: integer(item['plateSwitches']) }; +} +export function loadCatalog(): Fixture[] { + const bytes = readFileSync(new URL('./fixtures.json', import.meta.url)); + if (sha256(bytes) !== catalogSha256) throw new Error('Fixture hash differs from the fixed 45-input pin.'); + const parsed: unknown = JSON.parse(bytes.toString('utf8')); + const locks = array(parsed, 'fixtures').map((raw, index): Fixture => { + const lock = record(raw, 'fixture'); + if (Object.keys(lock).sort().join(',') !== 'expectedActions,id,links,state') throw new Error('Unexpected fixture fields.'); + const id = string(lock['id']); + const state = array(lock['state']).map((value) => integer(value)); + const links = array(lock['links']).map((row) => array(row).map((value) => integer(value))); + const expectedActions = integer(lock['expectedActions']); + if (id !== `lock-${String(index + 1).padStart(3, '0')}` || expectedActions < 0 || state.length === 0 + || state.some((value) => value < 1 || value > 7) || links.length !== state.length + || links.some((row) => row.length !== state.length || row.some((value) => value < -1 || value > 1))) throw new Error('Invalid fixed fixture.'); + return { id, state, links, expectedActions }; + }); + if (locks.length !== 45) throw new Error('Expected 45 unique fixed inputs.'); + return locks; +} +export function git(directory: string, args: readonly string[]): string { + return execFileSync('git', ['-C', directory, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); +} + +export function assertArtifactOutput(path: string): void { + const root = fileURLToPath(new URL('../../', import.meta.url)); + const local = relative(root, resolve(path)); + if (local !== 'artifacts' && !local.startsWith('artifacts/')) throw new Error('Benchmark output must be under the ignored artifacts directory.'); +} diff --git a/tests/benchmarks/cli.ts b/tests/benchmarks/cli.ts new file mode 100644 index 0000000..f2a9e8e --- /dev/null +++ b/tests/benchmarks/cli.ts @@ -0,0 +1,186 @@ +import { readFileSync } from 'node:fs'; +import os from 'node:os'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { assertArtifactOutput, git, jsonFile, sha256 } from './catalog.ts'; +import { + createReport, + loadImplementation, + publishReport, + readReport, + runBenchmark, + type Environment, + type Settings, + type Source, +} from './harness.ts'; +import { collectMemory } from './memory.ts'; +import { record } from './validation.ts'; + +const root = fileURLToPath(new URL('../../', import.meta.url)); +const modulePath = join(root, 'dist/gothic-lock-solver.mjs'); +export type BenchmarkOptions = Settings & { + baselineReport?: string; + baselineSha?: string; + outputDir: string; + help: boolean; +}; +export function parseBenchmarkArgs(args: readonly string[]): BenchmarkOptions { + const options: BenchmarkOptions = { + outputDir: join(root, 'artifacts/benchmark'), + warmups: 2, + repetitions: 7, + memoryRepetitions: 3, + help: false, + }; + const seen = new Set(); + for (let index = 0; index < args.length; index += 1) { + const argument = args[index]; + if (argument === undefined) { + throw new Error('Missing argument.'); + } + if (seen.has(argument)) { + throw new Error(`Duplicate argument: ${argument}`); + } + seen.add(argument); + if (argument === '--help') { + options.help = true; + continue; + } + if (!['--baseline-report', '--baseline-sha', '--output-dir', '--warmups', '--repetitions', '--memory-repetitions'].includes(argument)) { + throw new Error(`Unknown argument: ${argument}`); + } + const value = args[++index]; + if (value === undefined || value === '' || value.startsWith('--')) { + throw new Error(`Missing argument value: ${argument}`); + } + switch (argument) { + case '--baseline-report': + options.baselineReport = resolve(value); + break; + case '--baseline-sha': + if (!/^[a-f0-9]{40}$/.test(value)) { + throw new Error('Baseline SHA must be an exact 40-character source SHA.'); + } + options.baselineSha = value; + break; + case '--output-dir': + options.outputDir = resolve(value); + break; + case '--warmups': + case '--repetitions': + case '--memory-repetitions': { + if (!/^(0|[1-9][0-9]*)$/.test(value)) { + throw new Error('Expected a nonnegative integer.'); + } + const count = Number(value); + const minimum = argument === '--warmups' ? 0 : 1; + if (!Number.isSafeInteger(count) || count < minimum) { + throw new Error('Invalid repetition/warmup integer.'); + } + if (argument === '--warmups') { + options.warmups = count; + } else if (argument === '--repetitions') { + options.repetitions = count; + } else { + options.memoryRepetitions = count; + } + break; + } + } + } + if (options.baselineReport !== undefined && options.baselineSha === undefined) { + throw new Error('A provided baseline report requires --baseline-sha for the exact target revision.'); + } + return options; +} + +export { assertArtifactOutput } from './catalog.ts'; +export function collectSource(): Source { + const revision = git(root, ['rev-parse', 'HEAD']); + const moduleSha256 = sha256(readFileSync(modulePath)); + const manifest = record(jsonFile(join(root, 'dist/manifest.json')), 'build manifest'); + if (manifest['sourceSha'] !== revision || record(manifest['files'], 'build files')['gothic-lock-solver.mjs'] !== moduleSha256) { + throw new Error('Built solver manifest does not match the current source SHA and module checksum; run pnpm build.'); + } + return { + revision, + moduleSha256, + dirty: git(root, ['status', '--porcelain', '--untracked-files=normal']).length > 0, + }; +} + +function collectEnvironment(): Environment { + return { + node: process.version, + v8: process.versions.v8, + platform: process.platform, + arch: process.arch, + cpu: os.cpus()[0]?.model ?? 'unknown', + logicalCpus: os.cpus().length, + osRelease: os.release(), + totalMemoryBytes: os.totalmem(), + }; +} + +export async function main(): Promise { + const options = parseBenchmarkArgs(process.argv.slice(2)); + if (options.help) { + console.log([ + 'Usage: pnpm benchmark [--baseline-report PATH --baseline-sha SHA]', + '[--output-dir artifacts/benchmark] [--warmups 2]', + '[--repetitions 7] [--memory-repetitions 3]', + ].join(' ')); + console.log([ + 'Measures the current built ESM on all 45 fixed locks.', + 'Only a saved report for the exact target SHA is compared;', + 'without one the comparison is explicitly skipped.', + ].join(' ')); + return; + } + assertArtifactOutput(options.outputDir); + const baseline = options.baselineReport === undefined ? undefined : readReport(options.baselineReport, options.baselineSha); + if (baseline !== undefined && baseline.quality !== 'passed') { + throw new Error('Saved baseline report quality must pass.'); + } + const source = collectSource(); + const environment = collectEnvironment(); + const solve = await loadImplementation(modulePath); + const results = runBenchmark({ + solve, + warmups: options.warmups, + repetitions: options.repetitions, + onProgress: (completed, total, id) => { + process.stderr.write(`[${completed}/${total}] ${id}: replay and optimum checked\n`); + }, + }); + const memory = collectMemory(modulePath, options.memoryRepetitions, (line) => process.stderr.write(`${line}\n`)); + if (sha256(readFileSync(modulePath)) !== source.moduleSha256 || git(root, ['rev-parse', 'HEAD']) !== source.revision) { + throw new Error('Current solver source or module changed during measurement.'); + } + const settings = { + warmups: options.warmups, + repetitions: options.repetitions, + memoryRepetitions: options.memoryRepetitions, + }; + const report = createReport({ + source, + environment, + settings, + results, + memory, + ...(baseline === undefined ? {} : { baseline }), + }); + if (report.comparison.status === 'skipped' && options.baselineSha !== undefined) { + report.comparison.reason = `No saved benchmark report is available for exact target SHA ${options.baselineSha}.`; + } + const paths = await publishReport(report, options.outputDir); + console.log(JSON.stringify({ + ...paths, + lockCount: report.fixtures.count, + quality: report.quality, + comparison: report.comparison.status, + }, null, 2)); + if (report.quality === 'regression') { + process.exitCode = 1; + } +} diff --git a/tests/benchmarks/comparison.ts b/tests/benchmarks/comparison.ts new file mode 100644 index 0000000..ef1a658 --- /dev/null +++ b/tests/benchmarks/comparison.ts @@ -0,0 +1,50 @@ +import type { Metrics } from './validation.ts'; + +export type Quality = 'passed' | 'regression'; +export const metricNames = ['A', 'U', 'C', 'plateSwitches'] as const; +export type MetricComparison = { + quality: Quality; + deltas: Metrics; + regressions: string[]; +}; + +export function compareMetrics(candidate: Metrics, baseline: Metrics): MetricComparison { + const deltas = { + A: candidate.A - baseline.A, + U: candidate.U - baseline.U, + C: candidate.C - baseline.C, + plateSwitches: candidate.plateSwitches - baseline.plateSwitches, + }; + const regressions = metricNames.filter((name) => deltas[name] > 0); + return { + quality: regressions.length === 0 ? 'passed' : 'regression', + deltas, + regressions, + }; +} + +export function statistics(samples: readonly number[]): { min: number; median: number; p95: number; max: number } { + if (samples.length === 0 || samples.some((sample) => !Number.isFinite(sample) || sample < 0)) { + throw new Error('Invalid measurement samples.'); + } + const sorted = [...samples].sort((a, b) => a - b); + const first = sorted[0]; + const last = sorted.at(-1); + const middle = Math.floor(sorted.length / 2); + const high = sorted[middle]; + const low = sorted[Math.max(0, middle - 1)]; + const p95 = sorted[Math.ceil(sorted.length * 0.95) - 1]; + if (first === undefined || last === undefined || high === undefined || low === undefined || p95 === undefined) { + throw new Error('Missing sample.'); + } + return { + min: first, + median: sorted.length % 2 === 0 ? (low + high) / 2 : high, + p95, + max: last, + }; +} + +export function historicalRatio(current: number, previous: number): number | null { + return previous === 0 ? null : current / previous; +} diff --git a/tests/benchmarks/fixtures.json b/tests/benchmarks/fixtures.json new file mode 100644 index 0000000..830d6fc --- /dev/null +++ b/tests/benchmarks/fixtures.json @@ -0,0 +1,2580 @@ +[ + { + "id": "lock-001", + "state": [ + 5, + 6, + 2, + 2, + 1, + 1 + ], + "links": [ + [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + -1, + 1, + 1, + 0 + ], + [ + 0, + 0, + 0, + 1, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + -1, + 1, + -1, + 0, + 0, + -1 + ], + [ + -1, + 1, + 0, + 0, + 0, + 0 + ] + ], + "expectedActions": 17 + }, + { + "id": "lock-002", + "state": [ + 6, + 3, + 2, + 1, + 7 + ], + "links": [ + [ + 0, + 0, + 0, + 1, + 0 + ], + [ + 0, + 0, + 0, + 0, + 1 + ], + [ + 0, + 0, + 0, + 0, + -1 + ], + [ + 0, + 1, + -1, + 0, + -1 + ], + [ + 0, + 0, + 0, + 0, + 0 + ] + ], + "expectedActions": 9 + }, + { + "id": "lock-003", + "state": [ + 7, + 7, + 6, + 1 + ], + "links": [ + [ + 0, + 0, + 0, + 1 + ], + [ + 1, + 0, + 0, + 1 + ], + [ + 1, + -1, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0 + ] + ], + "expectedActions": 5 + }, + { + "id": "lock-004", + "state": [ + 6, + 6, + 5, + 7, + 1, + 4 + ], + "links": [ + [ + 0, + 0, + 1, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + -1, + 0, + 0, + -1, + 0 + ], + [ + -1, + 0, + 1, + 0, + 0, + 0 + ], + [ + 0, + -1, + 0, + 1, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0 + ] + ], + "expectedActions": 14 + }, + { + "id": "lock-005", + "state": [ + 1, + 5, + 1, + 7, + 2, + 6 + ], + "links": [ + [ + 0, + 0, + -1, + 0, + 0, + 0 + ], + [ + 0, + 0, + 1, + -1, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 1, + 0, + -1, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + -1 + ], + [ + 0, + -1, + 0, + 0, + 1, + 0 + ] + ], + "expectedActions": 11 + }, + { + "id": "lock-006", + "state": [ + 3, + 7, + 2, + 6, + 5 + ], + "links": [ + [ + 0, + -1, + -1, + -1, + -1 + ], + [ + -1, + 0, + 0, + 0, + 0 + ], + [ + -1, + -1, + 0, + 0, + 0 + ], + [ + 0, + 0, + 1, + 0, + 0 + ], + [ + 1, + 0, + 0, + 0, + 0 + ] + ], + "expectedActions": 9 + }, + { + "id": "lock-007", + "state": [ + 2, + 1, + 5, + 4, + 3, + 1 + ], + "links": [ + [ + 0, + 0, + 0, + -1, + 0, + 0 + ], + [ + -1, + 0, + 1, + -1, + -1, + 1 + ], + [ + 0, + 1, + 0, + 0, + 0, + 1 + ], + [ + 0, + 0, + 0, + 0, + 0, + -1 + ], + [ + 0, + -1, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0 + ] + ], + "expectedActions": 9 + }, + { + "id": "lock-008", + "state": [ + 1, + 3, + 1, + 1 + ], + "links": [ + [ + 0, + 0, + 0, + -1 + ], + [ + -1, + 0, + 1, + 1 + ], + [ + -1, + -1, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0 + ] + ], + "expectedActions": 5 + }, + { + "id": "lock-009", + "state": [ + 1, + 4, + 1, + 5, + 7, + 1 + ], + "links": [ + [ + 0, + -1, + 0, + 0, + 0, + 0 + ], + [ + -1, + 0, + -1, + 0, + -1, + 0 + ], + [ + -1, + 0, + 0, + 0, + 0, + -1 + ], + [ + 0, + 0, + 1, + 0, + 0, + 0 + ], + [ + 0, + 1, + -1, + 0, + 0, + -1 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0 + ] + ], + "expectedActions": 17 + }, + { + "id": "lock-010", + "state": [ + 1, + 1, + 3, + 1, + 7, + 6, + 7 + ], + "links": [ + [ + 0, + 0, + 0, + 0, + 0, + -1, + 0 + ], + [ + 0, + 0, + 1, + 0, + -1, + 0, + 1 + ], + [ + 0, + 0, + 0, + -1, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + -1, + 0, + 0 + ], + [ + 0, + 0, + 0, + -1, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 1, + 0, + 0, + 0 + ], + [ + -1, + 0, + -1, + 0, + 0, + 0, + 0 + ] + ], + "expectedActions": 7 + }, + { + "id": "lock-011", + "state": [ + 7, + 3, + 2, + 6, + 2, + 6 + ], + "links": [ + [ + 0, + -1, + -1, + -1, + -1, + -1 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + -1 + ], + [ + 0, + 0, + 0, + 0, + 1, + -1 + ], + [ + 0, + 0, + 0, + 1, + 0, + 0 + ], + [ + 0, + 0, + 0, + -1, + 0, + 0 + ] + ], + "expectedActions": 9 + }, + { + "id": "lock-012", + "state": [ + 6, + 1, + 2, + 1, + 7 + ], + "links": [ + [ + 0, + -1, + 0, + 0, + -1 + ], + [ + 0, + 0, + 0, + 0, + 1 + ], + [ + -1, + -1, + 0, + -1, + 1 + ], + [ + 0, + 0, + 0, + 0, + 0 + ], + [ + 1, + 0, + 1, + 0, + 0 + ] + ], + "expectedActions": 23 + }, + { + "id": "lock-013", + "state": [ + 3, + 1, + 7, + 1, + 2, + 2 + ], + "links": [ + [ + 0, + 0, + 0, + -1, + -1, + -1 + ], + [ + -1, + 0, + -1, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + -1, + 1, + 0 + ], + [ + 0, + 0, + 1, + 0, + 0, + 0 + ], + [ + 0, + -1, + 1, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0 + ] + ], + "expectedActions": 20 + }, + { + "id": "lock-014", + "state": [ + 7, + 6, + 4, + 7, + 2, + 7, + 5 + ], + "links": [ + [ + 0, + 0, + 0, + -1, + 0, + -1, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 1, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 1, + 0, + -1, + 1, + 0, + 1, + -1 + ], + [ + 0, + 0, + 0, + -1, + 1, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ] + ], + "expectedActions": 9 + }, + { + "id": "lock-015", + "state": [ + 7, + 7, + 7, + 7, + 2 + ], + "links": [ + [ + 0, + -1, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0 + ], + [ + -1, + -1, + 0, + 1, + 1 + ], + [ + -1, + 1, + 0, + 0, + 0 + ], + [ + 0, + -1, + 0, + 1, + 0 + ] + ], + "expectedActions": 7 + }, + { + "id": "lock-016", + "state": [ + 4, + 3, + 3, + 2, + 1, + 6 + ], + "links": [ + [ + 0, + 0, + 0, + 1, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + -1, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 1 + ], + [ + 0, + -1, + 0, + -1, + 0, + 0 + ], + [ + 0, + 0, + 0, + -1, + 1, + 0 + ] + ], + "expectedActions": 11 + }, + { + "id": "lock-017", + "state": [ + 2, + 6, + 5, + 6, + 5, + 7 + ], + "links": [ + [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 1, + 0, + 0, + 0, + 1, + -1 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + -1, + 0, + 0, + 0 + ], + [ + 1, + -1, + 0, + 1, + 0, + 0 + ], + [ + 0, + 0, + 0, + 1, + -1, + 0 + ] + ], + "expectedActions": 13 + }, + { + "id": "lock-018", + "state": [ + 1, + 2, + 7, + 7, + 6, + 5 + ], + "links": [ + [ + 0, + -1, + -1, + 0, + -1, + 0 + ], + [ + 0, + 0, + 0, + 1, + 0, + 1 + ], + [ + 0, + 1, + 0, + 0, + 0, + -1 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + -1, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 1, + 0, + 1, + 0 + ] + ], + "expectedActions": 14 + }, + { + "id": "lock-019", + "state": [ + 4, + 5, + 7, + 7, + 7 + ], + "links": [ + [ + 0, + 0, + 0, + -1, + 0 + ], + [ + -1, + 0, + -1, + 1, + 0 + ], + [ + 0, + -1, + 0, + 1, + -1 + ], + [ + 0, + -1, + 0, + 0, + -1 + ], + [ + 0, + 0, + -1, + 0, + 0 + ] + ], + "expectedActions": 14 + }, + { + "id": "lock-020", + "state": [ + 3, + 6, + 3, + 7, + 7, + 7 + ], + "links": [ + [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 1, + 0, + 0, + 0, + -1, + -1 + ], + [ + 0, + 1, + 0, + 0, + 0, + 0 + ], + [ + 1, + -1, + 0, + 0, + 0, + 0 + ], + [ + 1, + 0, + 0, + 0, + 0, + -1 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0 + ] + ], + "expectedActions": 12 + }, + { + "id": "lock-021", + "state": [ + 7, + 6, + 4, + 5, + 5, + 4 + ], + "links": [ + [ + 0, + 0, + 0, + -1, + -1, + 1 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 1 + ], + [ + -1, + 1, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 1, + 1, + -1, + 0, + 0, + 0 + ] + ], + "expectedActions": 11 + }, + { + "id": "lock-022", + "state": [ + 7, + 2, + 2, + 1, + 3, + 7 + ], + "links": [ + [ + 0, + 0, + -1, + 0, + -1, + -1 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 1, + 0, + 0, + 1, + 1 + ], + [ + 1, + 0, + 0, + 0, + -1, + 1 + ], + [ + 0, + 0, + 0, + 1, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0 + ] + ], + "expectedActions": 8 + }, + { + "id": "lock-023", + "state": [ + 2, + 3, + 5, + 7, + 6, + 1 + ], + "links": [ + [ + 0, + 1, + 1, + 1, + 0, + 0 + ], + [ + -1, + 0, + -1, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + -1, + 0 + ], + [ + -1, + 0, + 0, + -1, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0 + ] + ], + "expectedActions": 16 + }, + { + "id": "lock-024", + "state": [ + 2, + 7, + 5, + 6, + 7 + ], + "links": [ + [ + 0, + 0, + 1, + 0, + 0 + ], + [ + 0, + 0, + 1, + 0, + 0 + ], + [ + -1, + 0, + 0, + 0, + 1 + ], + [ + 0, + 0, + 1, + 0, + 0 + ], + [ + -1, + -1, + 1, + 0, + 0 + ] + ], + "expectedActions": 8 + }, + { + "id": "lock-025", + "state": [ + 2, + 2, + 6, + 5, + 7 + ], + "links": [ + [ + 0, + -1, + 1, + 0, + -1 + ], + [ + 1, + 0, + 1, + 1, + 0 + ], + [ + 0, + 0, + 0, + 0, + -1 + ], + [ + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 1, + 0, + 0, + 0 + ] + ], + "expectedActions": 14 + }, + { + "id": "lock-026", + "state": [ + 3, + 7, + 1, + 3, + 1 + ], + "links": [ + [ + 0, + 0, + 0, + -1, + 0 + ], + [ + -1, + 0, + 1, + -1, + 1 + ], + [ + 1, + 0, + 0, + -1, + -1 + ], + [ + 0, + 0, + 0, + 0, + 1 + ], + [ + 0, + 0, + 0, + 0, + 0 + ] + ], + "expectedActions": 9 + }, + { + "id": "lock-027", + "state": [ + 4, + 6, + 6, + 7, + 1, + 7, + 4 + ], + "links": [ + [ + 0, + 0, + 0, + 0, + -1, + -1, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + -1, + 0, + 0, + 0, + 0, + 0 + ], + [ + -1, + 0, + 1, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 1, + -1, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 1, + 1, + 0, + 0 + ], + [ + -1, + 0, + 0, + 0, + 0, + 0, + 0 + ] + ], + "expectedActions": 8 + }, + { + "id": "lock-028", + "state": [ + 5, + 7, + 2, + 3, + 5, + 7 + ], + "links": [ + [ + 0, + 0, + -1, + 0, + -1, + 0 + ], + [ + 0, + 0, + 0, + 1, + 0, + 0 + ], + [ + -1, + 0, + 0, + 0, + 0, + 0 + ], + [ + -1, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + -1, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 1, + 0, + 0, + 0 + ] + ], + "expectedActions": 11 + }, + { + "id": "lock-029", + "state": [ + 5, + 4, + 1, + 1, + 6, + 2 + ], + "links": [ + [ + 0, + -1, + -1, + 1, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 1, + 0, + -1, + 0, + 0 + ], + [ + 1, + 0, + 0, + 0, + 1, + 1 + ], + [ + -1, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0 + ] + ], + "expectedActions": 20 + }, + { + "id": "lock-030", + "state": [ + 5, + 1, + 7, + 7, + 6, + 4 + ], + "links": [ + [ + 0, + 0, + 0, + 0, + 1, + -1 + ], + [ + 0, + 0, + 0, + 0, + 0, + 1 + ], + [ + -1, + 0, + 0, + 0, + 0, + 0 + ], + [ + -1, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + -1 + ], + [ + 0, + 0, + -1, + 0, + 0, + 0 + ] + ], + "expectedActions": 10 + }, + { + "id": "lock-031", + "state": [ + 4, + 3, + 7, + 2, + 5 + ], + "links": [ + [ + 0, + -1, + -1, + 0, + 0 + ], + [ + 1, + 0, + 0, + -1, + 0 + ], + [ + -1, + 1, + 0, + 1, + 0 + ], + [ + -1, + 0, + 0, + 0, + 1 + ], + [ + 0, + 0, + -1, + 0, + 0 + ] + ], + "expectedActions": 9 + }, + { + "id": "lock-032", + "state": [ + 2, + 4, + 4, + 6, + 2, + 7 + ], + "links": [ + [ + 0, + 1, + 0, + 0, + 0, + 1 + ], + [ + 0, + 0, + 1, + 0, + 0, + -1 + ], + [ + 0, + 0, + 0, + 1, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 1, + 0, + 0, + 1 + ], + [ + 0, + 0, + -1, + 0, + 0, + 0 + ] + ], + "expectedActions": 9 + }, + { + "id": "lock-033", + "state": [ + 5, + 5, + 6, + 2, + 7, + 7 + ], + "links": [ + [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 1, + 0, + 0, + -1 + ], + [ + 0, + -1, + 0, + 0, + 1, + -1 + ], + [ + 1, + 0, + -1, + 0, + 0, + 0 + ], + [ + -1, + 0, + 0, + 0, + 0, + -1 + ], + [ + 0, + 0, + -1, + 0, + 0, + 0 + ] + ], + "expectedActions": 10 + }, + { + "id": "lock-034", + "state": [ + 5, + 1, + 7, + 1, + 1, + 3 + ], + "links": [ + [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 1, + 0, + 0, + -1, + 0, + 1 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 1 + ], + [ + 0, + 0, + 1, + -1, + 0, + -1 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0 + ] + ], + "expectedActions": 8 + }, + { + "id": "lock-035", + "state": [ + 7, + 2, + 2, + 6, + 1, + 3 + ], + "links": [ + [ + 0, + 0, + 0, + 0, + 1, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 1, + 0, + 0, + 0, + -1, + 1 + ], + [ + 0, + 0, + 0, + 0, + 1, + 0 + ], + [ + 0, + -1, + 0, + 0, + 0, + 0 + ], + [ + 0, + 1, + 0, + -1, + 0, + 0 + ] + ], + "expectedActions": 10 + }, + { + "id": "lock-036", + "state": [ + 6, + 2, + 1, + 1, + 5 + ], + "links": [ + [ + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 1, + 0, + 1 + ], + [ + -1, + 0, + 0, + 0, + 1 + ], + [ + 0, + 0, + -1, + 0, + 0 + ], + [ + 0, + 1, + 0, + 0, + 0 + ] + ], + "expectedActions": 8 + }, + { + "id": "lock-037", + "state": [ + 2, + 1, + 6, + 5, + 4, + 7 + ], + "links": [ + [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 1, + 0, + -1, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 1, + 0, + 0, + -1, + 0 + ], + [ + 0, + -1, + 1, + 0, + 0, + -1 + ], + [ + 0, + 0, + 0, + 0, + 1, + 0 + ] + ], + "expectedActions": 11 + }, + { + "id": "lock-038", + "state": [ + 5, + 2, + 4, + 1, + 4, + 5 + ], + "links": [ + [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 1, + -1, + 0 + ], + [ + 1, + -1, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + -1, + -1, + 1, + -1, + 0 + ] + ], + "expectedActions": 8 + }, + { + "id": "lock-039", + "state": [ + 3, + 1, + 2, + 7, + 2, + 1 + ], + "links": [ + [ + 0, + 0, + 0, + 1, + 0, + 1 + ], + [ + 1, + 0, + -1, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 1, + 0, + 0, + -1 + ], + [ + 0, + 0, + 0, + 1, + 0, + 0 + ] + ], + "expectedActions": 8 + }, + { + "id": "lock-040", + "state": [ + 7, + 1, + 2, + 2, + 5 + ], + "links": [ + [ + 0, + 0, + 0, + -1, + 1 + ], + [ + 1, + 0, + -1, + 0, + -1 + ], + [ + 1, + -1, + 0, + 0, + 0 + ], + [ + 1, + -1, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + -1, + 0 + ] + ], + "expectedActions": 13 + }, + { + "id": "lock-041", + "state": [ + 1, + 3, + 7, + 6, + 5 + ], + "links": [ + [ + 0, + 0, + 0, + -1, + 0 + ], + [ + 0, + 0, + 0, + -1, + 0 + ], + [ + 1, + 0, + 0, + 0, + -1 + ], + [ + 0, + 0, + 0, + 0, + 1 + ], + [ + 1, + 1, + 0, + 0, + 0 + ] + ], + "expectedActions": 9 + }, + { + "id": "lock-042", + "state": [ + 1, + 2, + 1, + 1 + ], + "links": [ + [ + 0, + 0, + 0, + -1 + ], + [ + 0, + 0, + 0, + 0 + ], + [ + 0, + -1, + 0, + 0 + ], + [ + 1, + -1, + -1, + 0 + ] + ], + "expectedActions": 4 + }, + { + "id": "lock-043", + "state": [ + 1, + 7, + 4, + 6, + 4 + ], + "links": [ + [ + 0, + -1, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + -1 + ], + [ + -1, + 0, + 0, + 0, + 0 + ], + [ + 1, + 1, + 1, + 0, + -1 + ], + [ + 0, + -1, + -1, + -1, + 0 + ] + ], + "expectedActions": 5 + }, + { + "id": "lock-044", + "state": [ + 2, + 4, + 5, + 7, + 5, + 5 + ], + "links": [ + [ + 0, + -1, + 1, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 1, + 0, + 0, + 0, + -1, + 0 + ], + [ + 0, + -1, + -1, + 0, + 0, + 1 + ], + [ + 0, + 0, + 0, + -1, + 0, + 1 + ], + [ + 1, + 0, + 0, + 0, + 0, + 0 + ] + ], + "expectedActions": 11 + }, + { + "id": "lock-045", + "state": [ + 1, + 4, + 5, + 2, + 1, + 4 + ], + "links": [ + [ + 0, + 0, + 1, + 0, + -1, + 0 + ], + [ + 0, + 0, + -1, + -1, + 0, + 0 + ], + [ + 0, + 0, + 0, + 1, + 0, + 1 + ], + [ + -1, + 0, + -1, + 0, + 0, + 1 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0 + ], + [ + 0, + 0, + 0, + 0, + 0, + 0 + ] + ], + "expectedActions": 10 + } +] diff --git a/tests/benchmarks/harness.ts b/tests/benchmarks/harness.ts new file mode 100644 index 0000000..bbae41a --- /dev/null +++ b/tests/benchmarks/harness.ts @@ -0,0 +1,487 @@ +import assert from 'node:assert/strict'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; +import { performance } from 'node:perf_hooks'; +import { pathToFileURL } from 'node:url'; +import { + array, + commandMetrics, + integer, + record, + replayCommands, + string, + tupleCommands, + type Definition, + type Metrics, + type TupleCommand, +} from './validation.ts'; +import { catalogSha256, jsonFile, loadCatalog, readMetrics, type Fixture } from './catalog.ts'; +import { + compareMetrics, + historicalRatio, + statistics, + type MetricComparison, + type Quality, +} from './comparison.ts'; +import { parseMemory, type MemoryMeasurement } from './memory.ts'; + +export type Solver = (definition: Definition) => unknown; +export type Solution = Metrics & { commands: readonly TupleCommand[] }; +export type Measurement = Solution & { + id: string; + optimumA: number; + samplesMs: number[]; + timingMs: ReturnType; +}; +export type Source = { + revision: string; + dirty: boolean; + moduleSha256: string; +}; +export type Environment = { + node: string; + v8: string; + platform: string; + arch: string; + cpu: string; + logicalCpus: number; + osRelease: string; + totalMemoryBytes: number; +}; +export type Settings = { + warmups: number; + repetitions: number; + memoryRepetitions: number; +}; +export type ComparedLock = MetricComparison & { + id: string; + baseline: Metrics; + timingMedianRatio: number | null; + timingP95Ratio: number | null; +}; +export type Comparison = + | { status: 'skipped'; reason: string } + | { + status: 'compared'; + baselineSha: string; + quality: Quality; + results: ComparedLock[]; + memoryPeakMedianRatio: number | null; + limitations: string; + }; +export type BenchmarkReport = { + schemaVersion: 2; + createdAt: string; + source: Source; + environment: Environment; + settings: Settings; + fixtures: { count: 45; catalogSha256: string }; + quality: Quality; + results: Measurement[]; + totals: Metrics; + memory: MemoryMeasurement; + comparison: Comparison; +}; +const historicalLimitations = [ + 'Historical timing and RSS ratios are informational observations.', + 'Hosts, runtimes, load, sample counts and measurement noise can differ;', + 'these are not paired performance gates.', +].join(' '); + +export async function loadImplementation(modulePath: string): Promise { + const module: unknown = await import(pathToFileURL(resolve(modulePath)).href); + const solveLock = record(module, 'solver module')['solveLock']; + if (typeof solveLock !== 'function') { + throw new Error(`Missing named solveLock export: ${modulePath}`); + } + return (definition) => { + const result: unknown = solveLock(definition.state, definition.links); + return result; + }; +} + +export function validateSolution(lock: Fixture, raw: unknown): Solution { + if (raw === null) { + throw new Error(`${lock.id}: lost solvability.`); + } + const commands = tupleCommands(raw); + assert.deepEqual(replayCommands(lock, commands), lock.state.map(() => 4), `${lock.id}: target not reached.`); + const metrics = commandMetrics(commands); + assert.equal(metrics.A, lock.expectedActions, `${lock.id}: wrong exact action minimum.`); + return { ...metrics, commands }; +} + +export function runBenchmark(options: { + solve: Solver; + locks?: readonly Fixture[]; + repetitions?: number; + warmups?: number; + clock?: () => number; + onProgress?: (completed: number, total: number, id: string) => void; +}): Measurement[] { + const locks = options.locks ?? loadCatalog(); + const repetitions = count(options.repetitions ?? 7, 1, 'repetitions'); + const warmups = count(options.warmups ?? 2, 0, 'warmups'); + const clock = options.clock ?? (() => performance.now()); + if (locks.length === 0 || new Set(locks.map((lock) => lock.id)).size !== locks.length) { + throw new Error('Expected unique nonempty fixtures.'); + } + return locks.map((lock, index) => { + const samplesMs: number[] = []; + let solution: Solution | undefined; + for (let round = -warmups; round < repetitions; round += 1) { + const definition = structuredClone({ state: lock.state, links: lock.links }); + const before = JSON.stringify(definition); + const start = clock(); + const raw = options.solve(definition); + const elapsed = clock() - start; + assert.equal(JSON.stringify(definition), before, `${lock.id}: solver mutated its input.`); + const measured = validateSolution(lock, raw); + if (solution !== undefined) { + assert.deepEqual(measured, solution, `${lock.id}: nondeterministic solution.`); + } + solution = measured; + if (round >= 0) { + samplesMs.push(elapsed); + } + } + if (solution === undefined) { + throw new Error('Missing measured solution.'); + } + options.onProgress?.(index + 1, locks.length, lock.id); + return { id: lock.id, optimumA: lock.expectedActions, ...solution, samplesMs, timingMs: statistics(samplesMs) }; + }); +} + +function count(value: unknown, minimum: number, label: string): number { + const parsed = integer(value, label); + if (parsed < minimum) { + throw new Error(`Invalid ${label} count.`); + } + return parsed; +} + +function digest(value: unknown, length: number, label: string): string { + const parsed = string(value, label); + if (!new RegExp(`^[a-f0-9]{${length}}$`).test(parsed)) { + throw new Error(`Invalid ${label}.`); + } + return parsed; +} + +function totalMetrics(results: readonly Metrics[]): Metrics { + const totals = { A: 0, U: 0, C: 0, plateSwitches: 0 }; + for (const row of results) { + totals.A += row.A; + totals.U += row.U; + totals.C += row.C; + totals.plateSwitches += row.plateSwitches; + } + return totals; +} + +function compareReport(results: readonly Measurement[], memory: MemoryMeasurement, baseline?: BenchmarkReport): Comparison { + if (baseline === undefined) { + return { status: 'skipped', reason: 'No saved benchmark report was supplied for the exact target SHA.' }; + } + const compared = results.map((row): ComparedLock => { + const previous = baseline.results.find((item) => item.id === row.id); + if (previous === undefined) { + throw new Error(`Baseline fixture missing: ${row.id}`); + } + return { + id: row.id, + baseline: readMetrics(previous), + ...compareMetrics(row, previous), + timingMedianRatio: historicalRatio(row.timingMs.median, previous.timingMs.median), + timingP95Ratio: historicalRatio(row.timingMs.p95, previous.timingMs.p95), + }; + }); + return { + status: 'compared', + baselineSha: baseline.source.revision, + quality: compared.some((row) => row.quality === 'regression') ? 'regression' : 'passed', + results: compared, + memoryPeakMedianRatio: memory.metric === baseline.memory.metric + ? historicalRatio(memory.peakRssBytes.median, baseline.memory.peakRssBytes.median) + : null, + limitations: historicalLimitations, + }; +} + +export function createReport(options: { + source: Source; + environment: Environment; + settings: Settings; + results: Measurement[]; + memory: MemoryMeasurement; + baseline?: BenchmarkReport; +}): BenchmarkReport { + const comparison = compareReport(options.results, options.memory, options.baseline); + return { + schemaVersion: 2, + createdAt: new Date().toISOString(), + source: options.source, + environment: options.environment, + settings: options.settings, + fixtures: { count: 45, catalogSha256 }, + quality: comparison.status === 'compared' ? comparison.quality : 'passed', + results: options.results, + totals: totalMetrics(options.results), + memory: options.memory, + comparison, + }; +} + +function parseMeasurements(raw: unknown, repetitions: number): Measurement[] { + const rows = array(raw, 'benchmark results'); + const catalog = loadCatalog(); + if (rows.length !== catalog.length) { + throw new Error('Benchmark report requires all 45 locks.'); + } + return catalog.map((lock, index) => { + const row = record(rows[index], 'benchmark lock'); + if (row['id'] !== lock.id || row['optimumA'] !== lock.expectedActions) { + throw new Error('Benchmark fixture identity or optimum mismatch.'); + } + const solution = validateSolution(lock, row['commands']); + assert.deepEqual(readMetrics(row), commandMetrics(solution.commands), 'Report metric/path mismatch.'); + const samplesMs = array(row['samplesMs'], 'timing samples').map((value) => { + if (typeof value !== 'number') { + throw new Error('Invalid timing sample.'); + } + return value; + }); + if (samplesMs.length !== repetitions) { + throw new Error('Timing sample count mismatch.'); + } + const timingMs = statistics(samplesMs); + assert.deepEqual(row['timingMs'], timingMs, 'Timing statistics mismatch.'); + return { id: lock.id, optimumA: lock.expectedActions, ...solution, samplesMs, timingMs }; + }); +} + +function ratio(value: unknown): number | null { + if (value === null) { + return null; + } + if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) { + throw new Error('Invalid historical ratio.'); + } + return value; +} + +function parseComparison(raw: unknown, results: readonly Measurement[]): Comparison { + const value = record(raw, 'comparison'); + if (value['status'] === 'skipped') { + const reason = string(value['reason'], 'comparison skip reason'); + if (reason.trim() === '') { + throw new Error('Comparison skip reason missing.'); + } + return { status: 'skipped', reason }; + } + if (value['status'] !== 'compared') { + throw new Error('Invalid comparison status.'); + } + const rows = array(value['results'], 'comparison results'); + if (rows.length !== results.length) { + throw new Error('Comparison requires every lock.'); + } + const compared = results.map((current, index): ComparedLock => { + const row = record(rows[index], 'comparison lock'); + if (row['id'] !== current.id) { + throw new Error('Comparison fixture mismatch.'); + } + const baseline = readMetrics(row['baseline']); + if (Object.values(baseline).some((metric) => metric < 0) || baseline.A !== current.optimumA) { + throw new Error('Invalid baseline metrics.'); + } + const comparison = compareMetrics(current, baseline); + assert.deepEqual(row['deltas'], comparison.deltas, 'Comparison delta mismatch.'); + assert.deepEqual(row['regressions'], comparison.regressions, 'Comparison regressions mismatch.'); + if (row['quality'] !== comparison.quality) { + throw new Error('Comparison lock quality mismatch.'); + } + return { + id: current.id, + baseline, + ...comparison, + timingMedianRatio: ratio(row['timingMedianRatio']), + timingP95Ratio: ratio(row['timingP95Ratio']), + }; + }); + const quality = compared.some((row) => row.quality === 'regression') ? 'regression' : 'passed'; + if (value['quality'] !== quality) { + throw new Error('Comparison quality mismatch.'); + } + return { + status: 'compared', + baselineSha: digest(value['baselineSha'], 40, 'baseline SHA'), + quality, + results: compared, + memoryPeakMedianRatio: ratio(value['memoryPeakMedianRatio']), + limitations: string(value['limitations'], 'historical limitations'), + }; +} + +export function parseReport(raw: unknown, expectedSha?: string): BenchmarkReport { + const value = record(raw, 'benchmark report'); + const fixtures = record(value['fixtures'], 'report fixtures'); + if (value['schemaVersion'] !== 2 || fixtures['count'] !== 45 || fixtures['catalogSha256'] !== catalogSha256) { + throw new Error('Report schema or fixture catalog mismatch.'); + } + const rawSource = record(value['source'], 'report source'); + const revision = digest(rawSource['revision'], 40, 'source revision'); + const moduleSha256 = digest(rawSource['moduleSha256'], 64, 'source module checksum'); + if (typeof rawSource['dirty'] !== 'boolean') { + throw new Error('Missing source dirty status.'); + } + if (expectedSha !== undefined && revision !== digest(expectedSha, 40, 'expected source SHA')) { + throw new Error('Report source revision differs from exact target SHA.'); + } + if (expectedSha !== undefined && rawSource['dirty']) { + throw new Error('An exact source SHA report requires a clean checkout; report is dirty.'); + } + const source = { revision, moduleSha256, dirty: rawSource['dirty'] }; + const rawSettings = record(value['settings'], 'benchmark settings'); + const settings = { + warmups: count(rawSettings['warmups'], 0, 'warmups'), + repetitions: count(rawSettings['repetitions'], 1, 'repetitions'), + memoryRepetitions: count(rawSettings['memoryRepetitions'], 1, 'memory repetitions'), + }; + const rawEnvironment = record(value['environment'], 'benchmark environment'); + const environment = { + node: string(rawEnvironment['node']), + v8: string(rawEnvironment['v8']), + platform: string(rawEnvironment['platform']), + arch: string(rawEnvironment['arch']), + cpu: string(rawEnvironment['cpu']), + logicalCpus: count(rawEnvironment['logicalCpus'], 1, 'logical CPUs'), + osRelease: string(rawEnvironment['osRelease']), + totalMemoryBytes: count(rawEnvironment['totalMemoryBytes'], 1, 'total memory'), + }; + const createdAt = string(value['createdAt'], 'report timestamp'); + if (!Number.isFinite(Date.parse(createdAt))) { + throw new Error('Invalid report timestamp.'); + } + const results = parseMeasurements(value['results'], settings.repetitions); + const totals = totalMetrics(results); + assert.deepEqual(value['totals'], totals, 'Report totals mismatch.'); + const memory = parseMemory(value['memory'], settings.memoryRepetitions); + const comparison = parseComparison(value['comparison'], results); + const quality = comparison.status === 'compared' ? comparison.quality : 'passed'; + if (value['quality'] !== quality) { + throw new Error('Report quality mismatch.'); + } + return { + schemaVersion: 2, + createdAt, + source, + environment, + settings, + fixtures: { count: 45, catalogSha256 }, + quality, + results, + totals, + memory, + comparison, + }; +} + +export function readReport(path: string, expectedSha?: string): BenchmarkReport { + return parseReport(jsonFile(path), expectedSha); +} + +function renderComparison(comparison: Comparison): string { + if (comparison.status === 'skipped') { + return `Comparison: **skipped**. ${comparison.reason}`; + } + const rows = comparison.results.map((row) => [ + '', + row.id, + row.deltas.A, + row.deltas.U, + row.deltas.C, + row.deltas.plateSwitches, + row.timingMedianRatio?.toFixed(3) ?? 'unavailable', + row.timingP95Ratio?.toFixed(3) ?? 'unavailable', + '', + ].join(' | ')); + const memoryRatio = comparison.memoryPeakMedianRatio?.toFixed(3) + ?? 'unavailable (different metrics or zero baseline)'; + return [ + `Comparison: **${comparison.quality}**, saved report for ${comparison.baselineSha}.`, + '', + comparison.limitations, + '', + '| Lock | Δ actions | Δ distinct plates | Δ unit shifts | Δ switches | Median ratio | p95 ratio |', + '| --- | ---: | ---: | ---: | ---: | ---: | ---: |', + ...rows, + '', + `Whole-catalog RSS median ratio: ${memoryRatio}.`, + ].join('\n'); +} + +function renderMarkdown(report: BenchmarkReport): string { + const rows = report.results.map((row) => [ + '', + row.id, + row.A, + row.U, + row.C, + row.plateSwitches, + row.timingMs.median.toFixed(3), + row.timingMs.p95.toFixed(3), + '', + ].join(' | ')); + const summary = [ + `Quality: **${report.quality}**. All 45 fixed locks replayed and exact action minima checked.`, + `Totals: ${report.totals.A} actions, ${report.totals.U} distinct plates summed per lock,`, + `${report.totals.C} unit shifts, ${report.totals.plateSwitches} switches.`, + ].join(' '); + const environment = [ + `Node ${report.environment.node}; ${report.environment.platform}/${report.environment.arch};`, + `${report.environment.cpu}. ${report.settings.warmups} warmups and`, + `${report.settings.repetitions} measured solves per lock.`, + 'Timings cover the solver call, excluding input cloning and independent validation.', + ].join(' '); + const memory = [ + `Whole-catalog Node peak RSS median: ${report.memory.peakRssBytes.median} bytes;`, + `p95: ${report.memory.peakRssBytes.p95} bytes`, + `(${report.memory.samples.length} fresh-process observations, ${report.memory.metric}).`, + ].join(' '); + return [ + '# Gothic Lock Solver benchmark', + '', + `Source: ${report.source.revision}; dirty: ${report.source.dirty}. ${report.createdAt}`, + '', + summary, + '', + environment, + '', + '| Lock | Actions | Distinct plates | Unit shifts | Switches | Median ms | p95 ms |', + '| --- | ---: | ---: | ---: | ---: | ---: | ---: |', + ...rows, + '', + renderComparison(report.comparison), + '', + memory, + '', + report.memory.limitations, + '', + report.memory.browser.reason, + '', + 'Commands, raw timings, RSS samples, and environment details are retained in benchmark.json.', + '', + ].join('\n'); +} + +export async function publishReport(report: BenchmarkReport, outputDir: string): Promise<{ jsonPath: string; markdownPath: string }> { + parseReport(report); + await mkdir(outputDir, { recursive: true }); + const jsonPath = join(outputDir, 'benchmark.json'); + const markdownPath = join(outputDir, 'benchmark.md'); + await writeFile(jsonPath, `${JSON.stringify(report, null, 2)}\n`); + await writeFile(markdownPath, renderMarkdown(report)); + return { jsonPath, markdownPath }; +} diff --git a/tests/benchmarks/memory-worker.ts b/tests/benchmarks/memory-worker.ts new file mode 100644 index 0000000..a8a01b0 --- /dev/null +++ b/tests/benchmarks/memory-worker.ts @@ -0,0 +1,34 @@ +import { readPeakRss } from './rss.ts'; +import { loadCatalog } from './catalog.ts'; +import { loadImplementation, runBenchmark } from './harness.ts'; + +const startupRssBytes = process.memoryUsage().rss; +const startupPeak = readPeakRss(); +const [modulePath, fixtureIds] = process.argv.slice(2); +if (modulePath === undefined) { + throw new Error('Missing RSS worker module.'); +} +const solve = await loadImplementation(modulePath); +const loadedRssBytes = process.memoryUsage().rss; +const loadedPeak = readPeakRss(); +const catalog = loadCatalog(); +const selected = fixtureIds === undefined ? null : new Set(fixtureIds.split(',')); +const locks = selected === null ? catalog : catalog.filter((lock) => selected.has(lock.id)); +if (locks.length === 0 || (selected !== null && locks.length !== selected.size)) { + throw new Error('RSS fixture not found.'); +} +runBenchmark({ locks, solve, warmups: 0, repetitions: 1 }); +const finalPeak = readPeakRss(); +process.stdout.write(JSON.stringify({ + status: 'passed', + startupRssBytes, + loadedRssBytes, + peakRssBytes: finalPeak.peakRssBytes, + peakRssMetric: finalPeak.metric, + startupPeakRssBytes: startupPeak.peakRssBytes, + loadedPeakRssBytes: loadedPeak.peakRssBytes, + resourceUsageMaxRssBytes: finalPeak.resourceUsageMaxRssBytes, + startupResourceUsageMaxRssBytes: startupPeak.resourceUsageMaxRssBytes, + resourceUsageLaunchFloorDetected: startupPeak.resourceUsageMaxRssBytes > startupPeak.peakRssBytes, + lockCount: locks.length, +})); diff --git a/tests/benchmarks/memory.ts b/tests/benchmarks/memory.ts new file mode 100644 index 0000000..0312052 --- /dev/null +++ b/tests/benchmarks/memory.ts @@ -0,0 +1,134 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { array, integer, record, string } from './validation.ts'; +import { statistics } from './comparison.ts'; + +export type RssMetric = 'linux-proc-VmHWM' | 'node-resourceUsage-maxRSS'; +export type RssSample = { + peakRssBytes: number; + startupRssBytes: number; + loadedRssBytes: number; + lockCount: number; + peakRssMetric: RssMetric; + startupPeakRssBytes: number; + loadedPeakRssBytes: number; + resourceUsageMaxRssBytes: number; + startupResourceUsageMaxRssBytes: number; + resourceUsageLaunchFloorDetected: boolean; +}; +export type MemoryMeasurement = { + metric: RssMetric; + limitations: string; + browser: { available: false; reason: string }; + samples: RssSample[]; + peakRssBytes: ReturnType; +}; + +function positive(value: unknown): number { + const result = integer(value, 'RSS measurement'); + if (result <= 0) { + throw new Error('Invalid positive RSS measurement.'); + } + return result; +} + +function metric(value: unknown): RssMetric { + if (value !== 'linux-proc-VmHWM' && value !== 'node-resourceUsage-maxRSS') { + throw new Error('Unknown RSS metric.'); + } + return value; +} + +function parseSample(raw: unknown): RssSample { + const value = record(raw, 'RSS sample'); + if (typeof value['resourceUsageLaunchFloorDetected'] !== 'boolean') { + throw new Error('Missing RSS launch-floor diagnostic.'); + } + return { + peakRssBytes: positive(value['peakRssBytes']), + startupRssBytes: positive(value['startupRssBytes']), + loadedRssBytes: positive(value['loadedRssBytes']), + lockCount: positive(value['lockCount']), + peakRssMetric: metric(value['peakRssMetric']), + startupPeakRssBytes: positive(value['startupPeakRssBytes']), + loadedPeakRssBytes: positive(value['loadedPeakRssBytes']), + resourceUsageMaxRssBytes: positive(value['resourceUsageMaxRssBytes']), + startupResourceUsageMaxRssBytes: positive(value['startupResourceUsageMaxRssBytes']), + resourceUsageLaunchFloorDetected: value['resourceUsageLaunchFloorDetected'], + }; +} + +export function parseMemory(raw: unknown, repetitions: number): MemoryMeasurement { + const value = record(raw, 'memory measurement'); + const samples = array(value['samples'], 'RSS samples').map(parseSample); + const rssMetric = metric(value['metric']); + if (samples.length !== repetitions || samples.some((sample) => sample.lockCount !== 45 || sample.peakRssMetric !== rssMetric)) { + throw new Error('RSS sample count, workload or metric mismatch.'); + } + const peakRssBytes = statistics(samples.map((sample) => sample.peakRssBytes)); + assert.deepEqual(value['peakRssBytes'], peakRssBytes, 'RSS statistics mismatch.'); + const browser = record(value['browser'], 'browser memory limitation'); + if (browser['available'] !== false) { + throw new Error('Node RSS cannot establish browser memory.'); + } + return { + metric: rssMetric, + limitations: string(value['limitations']), + browser: { available: false, reason: string(browser['reason']) }, + samples, + peakRssBytes, + }; +} + +export function measureRss(modulePath: string, fixtureIds?: readonly string[]): RssSample { + const worker = fileURLToPath(new URL('./memory-worker.ts', import.meta.url)); + const args = [worker, modulePath]; + if (fixtureIds !== undefined) { + args.push(fixtureIds.join(',')); + } + const stdout = execFileSync(process.execPath, args, { + encoding: 'utf8', + timeout: 120_000, + maxBuffer: 1_048_576, + stdio: ['ignore', 'pipe', 'pipe'], + }); + const parsed: unknown = JSON.parse(stdout); + const value = record(parsed, 'RSS worker result'); + if (value['status'] !== 'passed') { + throw new Error('RSS worker failed.'); + } + const sample = parseSample(value); + if (sample.lockCount !== (fixtureIds?.length ?? 45)) { + throw new Error('RSS worker measured a different workload.'); + } + return sample; +} + +export function collectMemory(modulePath: string, repetitions = 3, onProgress?: (text: string) => void): MemoryMeasurement { + if (!Number.isSafeInteger(repetitions) || repetitions < 1) { + throw new Error('Invalid memory repetition count.'); + } + const samples: RssSample[] = []; + for (let round = 0; round < repetitions; round += 1) { + samples.push(measureRss(modulePath)); + onProgress?.(`RSS whole-catalog observation ${round + 1}/${repetitions}`); + } + return { + metric: process.platform === 'linux' ? 'linux-proc-VmHWM' : 'node-resourceUsage-maxRSS', + limitations: [ + 'Approximate whole-process peak RSS in fresh Node processes running all 45 locks.', + 'Includes Node, the TypeScript loader, module and fixture loading, independent replay, and solver allocations.', + 'Linux uses /proc/self/status VmHWM; other platforms use process.resourceUsage().maxRSS × 1024', + 'with platform-dependent launch high-water behavior.', + 'Startup diagnostics are observations, not exact allocation subtraction.', + 'Historical hosts and runtimes can differ.', + ].join(' '), + browser: { + available: false, + reason: 'Node RSS is not browser memory; no portable browser peak-memory API is measured.', + }, + samples, + peakRssBytes: statistics(samples.map((sample) => sample.peakRssBytes)), + }; +} diff --git a/tests/benchmarks/rss.ts b/tests/benchmarks/rss.ts new file mode 100644 index 0000000..a1355f9 --- /dev/null +++ b/tests/benchmarks/rss.ts @@ -0,0 +1,14 @@ +import { readFileSync } from 'node:fs'; +export type PeakRss = { peakRssBytes: number; metric: 'linux-proc-VmHWM' | 'node-resourceUsage-maxRSS'; resourceUsageMaxRssBytes: number }; +export function readPeakRss(): PeakRss { + const resourceUsageMaxRssBytes = process.resourceUsage().maxRSS * 1024; + if (process.platform === 'linux') { + // VmHWM belongs to the current process image; getrusage can retain the heavy launcher high-water across exec. + const match = /^VmHWM:\s+(\d+)\s+kB$/m.exec(readFileSync('/proc/self/status', 'utf8')); + const digits = match?.[1]; + const peakRssBytes = digits === undefined ? NaN : Number(digits) * 1024; + if (!Number.isSafeInteger(peakRssBytes) || peakRssBytes <= 0) throw new Error('Linux /proc/self/status has no valid VmHWM RSS observation.'); + return { peakRssBytes, metric: 'linux-proc-VmHWM', resourceUsageMaxRssBytes }; + } + return { peakRssBytes: resourceUsageMaxRssBytes, metric: 'node-resourceUsage-maxRSS', resourceUsageMaxRssBytes }; +} diff --git a/tests/benchmarks/validation.ts b/tests/benchmarks/validation.ts new file mode 100644 index 0000000..62c2d8c --- /dev/null +++ b/tests/benchmarks/validation.ts @@ -0,0 +1,54 @@ +// Deliberately independent of production types, transitions, encoders and search. +export type TupleCommand = readonly [index: number, delta: number]; +export type Definition = { state: number[]; links: number[][] }; +export type Metrics = { A: number; U: number; C: number; plateSwitches: number }; + +export function record(value: unknown, label = 'value'): Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new Error(`Invalid ${label}: expected object.`); + // Object.entries exposes own properties only; no unchecked structural assertion. + return Object.fromEntries(Object.entries(value)); +} +export function array(value: unknown, label = 'value'): unknown[] { + if (!Array.isArray(value)) throw new Error(`Invalid ${label}: expected array.`); + return Array.from(value, (item: unknown) => item); +} +export function integer(value: unknown, label = 'value'): number { + if (typeof value !== 'number' || !Number.isSafeInteger(value)) throw new Error(`Invalid ${label}: expected integer.`); + return value; +} +export function string(value: unknown, label = 'value'): string { + if (typeof value !== 'string') throw new Error(`Invalid ${label}: expected string.`); + return value; +} +export function tupleCommands(value: unknown): TupleCommand[] { + return array(value, 'commands').map((command) => { + const tuple = array(command, 'command'); + const index = integer(tuple[0], 'command index'); + const delta = integer(tuple[1], 'command delta'); + if (tuple.length !== 2 || index < 0 || delta === 0 || Math.abs(delta) > 6) throw new Error('Invalid command coordinates.'); + return [index, delta]; + }); +} +export function replayCommands(definition: Definition, value: unknown): number[] { + let state = [...definition.state]; + for (const [index, delta] of tupleCommands(value)) { + const row = definition.links[index]; + if (index >= state.length || row === undefined) throw new Error('Invalid command plate index.'); + for (let click = 0; click < Math.abs(delta); click += 1) { + const next = state.map((position, target) => { + const link = row[target]; + if (link === undefined) throw new Error('Invalid link matrix.'); + return position + Math.sign(delta) * (target === index ? 1 : link); + }); + if (next.some((position) => position < 1 || position > 7 || !Number.isInteger(position))) throw new Error('Blocked intermediate command click.'); + state = next; + } + } + return state; +} +export function commandMetrics(value: unknown): Metrics { + const commands = tupleCommands(value); + return { A: commands.length, U: new Set(commands.map(([index]) => index)).size, + C: commands.reduce((sum, [, delta]) => sum + Math.abs(delta), 0), + plateSwitches: commands.reduce((sum, [index], position) => sum + Number(position > 0 && commands[position - 1]?.[0] !== index), 0) }; +} diff --git a/tests/e2e/assets.d.ts b/tests/e2e/assets.d.ts new file mode 100644 index 0000000..76af98e --- /dev/null +++ b/tests/e2e/assets.d.ts @@ -0,0 +1,4 @@ +declare module '*.json?raw' { + const content: string; + export default content; +} diff --git a/tests/e2e/browser/catalog.test.ts b/tests/e2e/browser/catalog.test.ts new file mode 100644 index 0000000..763c615 --- /dev/null +++ b/tests/e2e/browser/catalog.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from 'vitest'; +import rawCatalog from '../../benchmarks/fixtures.json' with { type: 'json' }; +import { replayCommands } from '../../benchmarks/validation.ts'; +import { solveLock, type Link, type Position } from '../../../src/index.ts'; + +function position(value: number): Position { + if (value === 1 || value === 2 || value === 3 || value === 4 || value === 5 || value === 6 || value === 7) return value; + throw new Error('Invalid pinned position'); +} +function link(value: number): Link { + if (value === -1 || value === 0 || value === 1) return value; + throw new Error('Invalid pinned link'); +} + +describe('shared immutable 45-lock catalog', () => { + test('contains exactly 45 unique fixed fixtures', () => { + expect(rawCatalog).toHaveLength(45); + expect(new Set(rawCatalog.map(({ id }) => id)).size).toBe(45); + }); + for (const fixture of rawCatalog) { + test(`${fixture.id}: independently replays the exact action minimum`, () => { + const state = fixture.state.map(position); + const links = fixture.links.map((row) => row.map(link)); + const before = JSON.stringify([state, links]); + const commands = solveLock(state, links); + expect(commands).not.toBeNull(); + expect(commands).toHaveLength(fixture.expectedActions); + expect(replayCommands(fixture, commands)).toEqual(state.map(() => 4)); + expect(JSON.stringify([state, links])).toBe(before); + expect(solveLock(state, links)).toEqual(commands); + }); + } +}); diff --git a/tests/e2e/build-fixtures.ts b/tests/e2e/build-fixtures.ts new file mode 100644 index 0000000..2be61b6 --- /dev/null +++ b/tests/e2e/build-fixtures.ts @@ -0,0 +1,40 @@ +import { rm } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { build } from 'vite'; + +const root = fileURLToPath(new URL('../../', import.meta.url)); +const output = fileURLToPath(new URL('../../artifacts/e2e/fixtures/', import.meta.url)); +await rm(output, { recursive: true, force: true }); +for (const minified of [false, true]) { + const suffix = minified ? '.min' : ''; + for (const entry of ['module', 'worker', 'classic']) { + await build({ + root, + configFile: false, + logLevel: 'warn', + plugins: [{ + name: 'built-library-consumer', + enforce: 'pre', + resolveId(source) { + if (source === '../../../src/index.ts') { + return { id: `../../../dist/gothic-lock-solver${suffix}.mjs`, external: true }; + } + return null; + }, + }], + build: { + target: 'es2022', + outDir: output, + emptyOutDir: false, + minify: false, + lib: { + name: 'GothicConsumer', + entry: fileURLToPath(new URL(`./fixtures/${entry}.ts`, import.meta.url)), + formats: [entry === 'classic' ? 'iife' : 'es'], + fileName: () => `${entry}${suffix}.${entry === 'classic' ? 'js' : 'mjs'}`, + }, + rolldownOptions: { output: { codeSplitting: false } }, + }, + }); + } +} diff --git a/tests/e2e/catalog.ts b/tests/e2e/catalog.ts new file mode 100644 index 0000000..212f68f --- /dev/null +++ b/tests/e2e/catalog.ts @@ -0,0 +1,52 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { loadCatalog as loadPinnedCatalog } from '../benchmarks/catalog.ts'; +import { array, replayCommands, tupleCommands } from '../benchmarks/validation.ts'; +import { distDirectory } from './paths.ts'; + +export interface CatalogFixture { + readonly id: string; + readonly state: readonly number[]; + readonly links: readonly (readonly number[])[]; + readonly expectedActions: number; +} + +export function loadCatalog(): readonly CatalogFixture[] { + return loadPinnedCatalog().map(({ id, state, links, expectedActions }) => ({ + id, + state, + links, + expectedActions, + })); +} + +export function verifySolution(fixture: CatalogFixture, commands: unknown): void { + const path = tupleCommands(commands); + assert.equal(path.length, fixture.expectedActions, `${fixture.id}: minimum actions`); + const definition = { + state: [...fixture.state], + links: fixture.links.map((row) => [...row]), + }; + // The benchmark oracle checks every intermediate division without production imports. + assert.deepEqual(replayCommands(definition, path), fixture.state.map(() => 4), `${fixture.id}: goal state`); +} + +export function loadExpectedSolutions(fixtures: readonly CatalogFixture[]): readonly unknown[] { + const moduleUrl = pathToFileURL(join(distDirectory, 'gothic-lock-solver.mjs')).href; + // A subprocess guarantees the native Node loader, outside Playwright's loader. + const code = `import { solveLock } from ${JSON.stringify(moduleUrl)}; + let input = ''; + for await (const chunk of process.stdin) input += chunk; + console.log(JSON.stringify(JSON.parse(input).map(({ state, links }) => solveLock(state, links))));`; + const output = execFileSync(process.execPath, ['--input-type=module', '-e', code], { + encoding: 'utf8', + input: JSON.stringify(fixtures), + }); + const raw: unknown = JSON.parse(output); + const results = array(raw, 'native Node solutions'); + assert.equal(results.length, fixtures.length); + for (const [index, fixture] of fixtures.entries()) verifySolution(fixture, results[index]); + return results; +} diff --git a/tests/e2e/consumers.spec.ts b/tests/e2e/consumers.spec.ts new file mode 100644 index 0000000..39b958c --- /dev/null +++ b/tests/e2e/consumers.spec.ts @@ -0,0 +1,207 @@ +import { copyFile, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { expect, test } from '@playwright/test'; +import type { BrowserContext, Page } from '@playwright/test'; +import { loadCatalog, loadExpectedSolutions, verifySolution } from './catalog.ts'; +import type { CatalogFixture } from './catalog.ts'; +import { distDirectory } from './paths.ts'; + +const catalog = loadCatalog(); +let nativeNodeSolutions: readonly unknown[] | undefined; +const reportedBrowserVersions = new Set(); +test.beforeEach(({ browser, browserName }, testInfo) => { + const version = browser.version(); + testInfo.annotations.push({ type: 'browser-version', description: `${browserName} ${version}` }); + if (!reportedBrowserVersions.has(browserName)) { + console.log(JSON.stringify({ browser: browserName, version })); + reportedBrowserVersions.add(browserName); + } +}); +const formats = [ + { fixture: 'module.html', bundle: 'gothic-lock-solver.mjs', classic: false }, + { fixture: 'module.min.html', bundle: 'gothic-lock-solver.min.mjs', classic: false }, + { fixture: 'classic.html', bundle: 'gothic-lock-solver.js', classic: true }, + { fixture: 'classic.min.html', bundle: 'gothic-lock-solver.min.js', classic: true }, +]; + +function observeRequests(context: BrowserContext, page: Page) { + const requests: string[] = []; + const failures: string[] = []; + const pageErrors: string[] = []; + context.on('request', (request) => requests.push(request.url())); + context.on('requestfailed', (request) => failures.push(request.url())); + page.on('pageerror', (error) => pageErrors.push(error.message)); + return { requests, failures, pageErrors }; +} + +// Playwright serializes this function into the consumer page. All invocation logic +// stays here; the only solver available to it came from the HTML's dist import. +function exerciseConsumer(fixtures: readonly CatalogFixture[]) { + const consumer: unknown = Reflect.get(globalThis, 'consumer'); + if (typeof consumer !== 'object' || consumer === null) { + throw new Error('The plain HTML consumer did not load'); + } + const solveLock: unknown = Reflect.get(consumer, 'solveLock'); + if (typeof solveLock !== 'function') { + throw new Error('Missing public solveLock function'); + } + const factory: unknown = Reflect.get(consumer, 'createSolverConfig'); + if (typeof factory !== 'function') throw new Error('Missing configuration factory'); + const config: unknown = Reflect.apply(factory, undefined, [{ maxDenseBytes: 0 }]); + const invoke = (state: unknown, links: unknown): unknown => Reflect.apply( + solveLock, undefined, [state, links, config], + ); + const publicExports: unknown = Reflect.get(consumer, 'exports'); + const namedMatchesNamespace: unknown = Reflect.get(consumer, 'namedMatchesNamespace'); + const hasClassicGlobal: unknown = Reflect.get(consumer, 'hasClassicGlobal'); + let invalidError: string | null = null; + try { + invoke([0, 4], [[0, 0], [0, 0]]); + } catch (error) { + invalidError = error instanceof Error ? error.name : 'non-Error thrown'; + } + return { + publicExports, + config, + configFrozen: Object.isFrozen(config), + namedMatchesNamespace, + hasClassicGlobal, + signed: invoke([6, 2], [[0, -1], [0, 0]]), + opened: invoke([4, 4], [[0, 0], [0, 0]]), + impossible: invoke([1, 1, 1], [[0, -1, -1], [-1, 0, -1], [-1, -1, 0]]), + invalidError, + catalog: fixtures.map((fixture) => { + const state = Object.freeze([...fixture.state]); + const links = Object.freeze(fixture.links.map((row) => Object.freeze([...row]))); + const commands = invoke(state, links); + return { id: fixture.id, commands, repeat: invoke(state, links), state, links }; + }), + }; +} + +for (const format of formats) { + test(`${format.bundle}: static HTML, public contract, and all 45 locks`, async ({ context, page }) => { + nativeNodeSolutions ??= loadExpectedSolutions(catalog); + const observed = observeRequests(context, page); + const bundlePath = `/dist/${format.bundle}`; + const fixturePath = `/tests/e2e/fixtures/${format.fixture}`; + const responsePromise = page.waitForResponse((response) => new URL(response.url()).pathname === bundlePath); + await page.goto(fixturePath); + await expect(page.locator('#status')).toHaveText('ready'); + const response = await responsePromise; + expect(response.status()).toBe(200); + expect(response.headers()['content-type']).toBe('application/javascript; charset=utf-8'); + expect(await response.body()).toEqual(await readFile(join(distDirectory, format.bundle))); + + const result = await page.evaluate(exerciseConsumer, catalog); + expect(result.publicExports).toContain('solveLock'); + expect(result.publicExports).toContain('createSolverConfig'); + expect(result.config).toEqual({ maxVisited: 2_000_000, maxExpanded: 1_000_000, maxFrontier: 1_000_000, maxDenseBytes: 0 }); + expect(result.configFrozen).toBe(true); + expect(result.publicExports).not.toContain('default'); + expect(result.namedMatchesNamespace).toBe(true); + expect(result.hasClassicGlobal).toBe(format.classic); + expect(result.signed).toEqual([[0, -2]]); + expect(result.opened).toEqual([]); + expect(result.impossible).toBeNull(); + expect(result.invalidError).toBe('LockInputError'); + expect(result.catalog).toHaveLength(45); + for (const [index, entry] of result.catalog.entries()) { + const fixture = catalog[index]; + if (fixture === undefined) throw new Error(`Unexpected result ${entry.id}`); + expect(entry.id).toBe(fixture.id); + verifySolution(fixture, entry.commands); + expect(entry.commands, `${fixture.id}: exact format and environment equivalence`).toEqual(nativeNodeSolutions[index]); + expect(entry.repeat, `${fixture.id}: deterministic repeat`).toEqual(entry.commands); + expect(entry.state, `${fixture.id}: state mutation`).toEqual(fixture.state); + expect(entry.links, `${fixture.id}: links mutation`).toEqual(fixture.links); + } + expect(observed.pageErrors).toEqual([]); + expect(observed.failures).toEqual([]); + expect(observed.requests.sort()).toEqual([ + `http://127.0.0.1:4177${fixturePath}`, + `http://127.0.0.1:4177${bundlePath}`, + `http://127.0.0.1:4177/tests/e2e/fixtures/${format.fixture.replace('.html', format.classic ? '.js' : '.mjs')}`, + ].sort()); + }); +} + +for (const minified of [false, true]) { + const suffix = minified ? '.min' : ''; + test(`module Worker imports gothic-lock-solver${suffix}.mjs`, async ({ context, page }) => { + const observed = observeRequests(context, page); + await page.goto('/tests/e2e/fixtures/worker.html'); + const results = await page.evaluate(async (workerFile) => { + const worker = new Worker(`./${workerFile}`, { type: 'module' }); + const cases = [ + { state: [6, 2], links: [[0, -1], [0, 0]] }, + { state: [4, 4], links: [[0, 0], [0, 0]] }, + ]; + return await new Promise((resolve, reject) => { + const received: unknown[] = []; + worker.onerror = (event) => { + worker.terminate(); + reject(new Error(event.message)); + }; + worker.onmessage = (event: MessageEvent) => { + received.push(event.data); + if (received.length === cases.length) { + worker.terminate(); + resolve(received); + } else { + worker.postMessage(cases[received.length]); + } + }; + worker.postMessage(cases[0]); + }); + }, `worker${suffix}.mjs`); + expect(results).toEqual([[[0, -2]], []]); + expect(observed.pageErrors).toEqual([]); + expect(observed.failures).toEqual([]); + expect(observed.requests.sort()).toEqual([ + 'http://127.0.0.1:4177/tests/e2e/fixtures/worker.html', + `http://127.0.0.1:4177/tests/e2e/fixtures/worker${suffix}.mjs`, + `http://127.0.0.1:4177/dist/gothic-lock-solver${suffix}.mjs`, + ].sort()); + }); + + test(`classic gothic-lock-solver${suffix}.js loads through file://`, async ({ context, page }) => { + const observed = observeRequests(context, page); + const isolatedDirectory = await mkdtemp(join(tmpdir(), 'gothic-classic-')); + try { + const fixtureName = `classic${suffix}.html`; + const bundleName = `gothic-lock-solver${suffix}.js`; + const html = await readFile(new URL(`./fixtures/${fixtureName}`, import.meta.url), 'utf8'); + await writeFile(join(isolatedDirectory, fixtureName), html.replace('src="../../../dist/', 'src="./')); + await copyFile(join(distDirectory, bundleName), join(isolatedDirectory, bundleName)); + const consumerName = `classic${suffix}.js`; + await copyFile(new URL(`../../artifacts/e2e/fixtures/${consumerName}`, import.meta.url), join(isolatedDirectory, consumerName)); + const consumerUrl = pathToFileURL(join(isolatedDirectory, consumerName)).href; + const fixtureUrl = pathToFileURL(join(isolatedDirectory, fixtureName)).href; + const bundleUrl = pathToFileURL(join(isolatedDirectory, bundleName)).href; + await page.goto(fixtureUrl); + await expect(page.locator('#status')).toHaveText('ready'); + expect(page.url()).toBe(fixtureUrl); + const externalScript = page.locator('script[src]').first(); + await expect(externalScript).toHaveCount(1); + await expect(externalScript).toHaveJSProperty('src', bundleUrl); + await expect(externalScript).toHaveJSProperty('type', ''); + const result = await page.evaluate(exerciseConsumer, []); + expect(result.signed).toEqual([[0, -2]]); + expect(result.opened).toEqual([]); + expect(result.impossible).toBeNull(); + expect(result.invalidError).toBe('LockInputError'); + expect(result.hasClassicGlobal).toBe(true); + expect(observed.pageErrors).toEqual([]); + expect(observed.failures).toEqual([]); + // Firefox does not emit request events for file:// loads. Verify the actual + // local document/script above, and reject unexpected URLs when events exist. + const allowedRequests = new Set([fixtureUrl, bundleUrl, consumerUrl]); + expect(observed.requests.filter((url) => !allowedRequests.has(url))).toEqual([]); + } finally { + await rm(isolatedDirectory, { recursive: true, force: true }); + } + }); +} diff --git a/tests/e2e/distribution/artifacts.test.ts b/tests/e2e/distribution/artifacts.test.ts new file mode 100644 index 0000000..3c2b437 --- /dev/null +++ b/tests/e2e/distribution/artifacts.test.ts @@ -0,0 +1,58 @@ +import { createHash } from 'node:crypto'; +import { execFileSync } from 'node:child_process'; +import { readFile, readdir } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, test } from 'vitest'; + +const dist = new URL('../../../dist/', import.meta.url); +const runtime = ['gothic-lock-solver.js', 'gothic-lock-solver.min.js', 'gothic-lock-solver.min.mjs', 'gothic-lock-solver.mjs']; + +describe('published artifacts', () => { + test('ships exactly four core runtime files and one Node CLI', async () => { + expect((await readdir(dist)).filter((name) => /\.(?:m?js)$/.test(name)).sort()).toEqual(['gothic-lock-solver.cli.mjs', ...runtime].sort()); + }); + + for (const extension of ['js', 'mjs']) { + test(`minifies ${extension} bytes and whitespace`, async () => { + const readable = await readFile(new URL(`gothic-lock-solver.${extension}`, dist), 'utf8'); + const minified = await readFile(new URL(`gothic-lock-solver.min.${extension}`, dist), 'utf8'); + expect(minified.length).toBeLessThan(readable.length * 0.8); + expect(minified.split('\n').length).toBeLessThan(readable.split('\n').length * 0.1); + expect(minified).not.toBe(readable); + }); + } + + for (const filename of runtime) { + test(`${filename} has no external loading or Node runtime requirements`, async () => { + const code = await readFile(new URL(filename, dist), 'utf8'); + expect(code).not.toMatch(/\b(?:require|fetch|importScripts)\s*\(|\bimport\s*(?:\(|["'{*])|\bfrom\s*["']|\b(?:process|Buffer|__dirname|__filename)\b|\bnode:/); + expect(code).not.toMatch(/sourceMappingURL/); + expect(code).not.toContain('export default'); + }); + } + + for (const filename of runtime.filter((name) => name.endsWith('.mjs'))) { + test(`${filename} exposes the actual ESM namespace`, async () => { + // A variable file URL deliberately bypasses Vite source transformation. + const url = new URL(filename, dist).href; + const api: unknown = await import(/* @vite-ignore */ url); + expect(api).not.toHaveProperty('default'); + expect(api).toHaveProperty('solveLock'); + const node = await import('node:child_process'); + const output = node.execFileSync(process.execPath, ['--input-type=module', '-e', `import { solveLock, createSolverConfig } from ${JSON.stringify(url)}; import * as api from ${JSON.stringify(url)}; console.log(JSON.stringify([solveLock([6,2],[[0,-1],[0,0]],createSolverConfig({maxDenseBytes:0})),api.solveLock([4,4],[[0,0],[0,0]]),Object.keys(api).sort()]));`], { encoding: 'utf8', cwd: fileURLToPath(dist) }); + expect(JSON.parse(output)).toEqual([[[0, -2]], [], ['LockInputError', 'SearchLimitError', 'createSolverConfig', 'solveLock']]); + }); + } + + test('manifest binds every runtime file and declaration to its bytes', async () => { + const raw: unknown = JSON.parse(await readFile(new URL('manifest.json', dist), 'utf8')); + const packageMetadata: unknown = JSON.parse(await readFile(new URL('../../../package.json', import.meta.url), 'utf8')); + if (typeof packageMetadata !== 'object' || packageMetadata === null || !('version' in packageMetadata)) throw new Error('Malformed package metadata'); + expect(raw).toMatchObject({ schemaVersion: 1, package: 'gothic-lock-solver', version: packageMetadata.version, sourceSha: execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim() }); + if (typeof raw !== 'object' || raw === null || !('files' in raw) || typeof raw.files !== 'object' || raw.files === null) throw new Error('Malformed distribution manifest'); + expect(Object.keys(raw.files).sort()).toEqual((await readdir(dist)).filter((name) => name !== 'manifest.json').sort()); + for (const [name, digest] of Object.entries(raw.files)) { + expect(createHash('sha256').update(await readFile(new URL(name, dist))).digest('hex')).toBe(digest); + } + }); +}); diff --git a/tests/e2e/distribution/catalog.test.ts b/tests/e2e/distribution/catalog.test.ts new file mode 100644 index 0000000..396ce20 --- /dev/null +++ b/tests/e2e/distribution/catalog.test.ts @@ -0,0 +1,20 @@ +import { execFileSync } from 'node:child_process'; +import { test, expect } from 'vitest'; +import { loadCatalog } from '../../benchmarks/catalog.ts'; +import { replayCommands } from '../../benchmarks/validation.ts'; + +for (const filename of ['gothic-lock-solver.mjs', 'gothic-lock-solver.min.mjs']) { + test(`${filename}: real Node loader solves all 45 fixtures at the exact minimum`, () => { + const catalog = loadCatalog(); + const code = `import { solveLock } from ${JSON.stringify(new URL(`../../../dist/${filename}`, import.meta.url).href)};let input='';for await (const chunk of process.stdin) input+=chunk;console.log(JSON.stringify(JSON.parse(input).map(({state,links})=>solveLock(state,links))));`; + const output = execFileSync(process.execPath, ['--input-type=module', '-e', code], { input: JSON.stringify(catalog), encoding: 'utf8' }); + const results: unknown = JSON.parse(output); + if (!Array.isArray(results)) throw new Error('Node consumer returned a malformed result'); + expect(results).toHaveLength(45); + for (const [index, fixture] of catalog.entries()) { + const commands: unknown = results[index]; + expect(commands).toHaveLength(fixture.expectedActions); + expect(replayCommands(fixture, commands)).toEqual(fixture.state.map(() => 4)); + } + }); +} diff --git a/tests/e2e/distribution/cli.test.ts b/tests/e2e/distribution/cli.test.ts new file mode 100644 index 0000000..d9d8d05 --- /dev/null +++ b/tests/e2e/distribution/cli.test.ts @@ -0,0 +1,45 @@ +import { spawnSync } from 'node:child_process'; +import { copyFile, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, test } from 'vitest'; + +describe('standalone Node CLI artifact', () => { + test('runs copied bytes outside the repository with exit codes 0, 1 and 2 and --output', async () => { + const directory = await mkdtemp(join(tmpdir(), 'gothic-standalone-cli-')); + try { + const executable = join(directory, 'gothic-lock-solver.cli.mjs'); + const input = join(directory, 'lock.json'); + const output = join(directory, 'solution.json'); + const config = join(directory, 'config.json'); + await writeFile(config, JSON.stringify({ maxDenseBytes: 0 })); + await copyFile(new URL('../../../dist/gothic-lock-solver.cli.mjs', import.meta.url), executable); + const contents = await readFile(executable, 'utf8'); + expect(contents.startsWith('#!/usr/bin/env node\n')).toBe(true); + // The sole Node file can import builtins; no sibling core modules or npm + // runtime package is present in this temporary consumer directory. + expect(contents).not.toMatch(/\bfrom\s*["'](?:\.|\/|gothic-lock-solver)/); + await writeFile(input, JSON.stringify({ state: [6, 2], links: [[0, -1], [0, 0]] })); + const solved = spawnSync(process.execPath, [executable, input, '--config', config, '--output', output], { cwd: directory, encoding: 'utf8' }); + expect(solved.status).toBe(0); + expect(solved.stderr).toBe(''); + expect(solved.stdout).toContain('Найдено команд: 1'); + expect(JSON.parse(await readFile(output, 'utf8'))).toMatchObject({ status: 'solved', commands: [{ plate: 1, direction: 'right', steps: 2 }] }); + await writeFile(input, JSON.stringify({ state: [1, 7], links: [[0, 1], [1, 0]] })); + const unreachable = spawnSync(process.execPath, [executable, input], { cwd: directory, encoding: 'utf8' }); + expect(unreachable.status).toBe(2); + expect(unreachable.stdout).toContain('Решение не найдено.'); + await writeFile(config, JSON.stringify({ unknown: 1 })); + const invalidConfig = spawnSync(process.execPath, [executable, input, '--config', config], { cwd: directory, encoding: 'utf8' }); + expect(invalidConfig.status).toBe(1); + expect(invalidConfig.stderr).not.toBe(''); + await writeFile(input, '{'); + const invalid = spawnSync(process.execPath, [executable, input], { cwd: directory, encoding: 'utf8' }); + expect(invalid.status).toBe(1); + expect(invalid.stdout).toBe(''); + expect(invalid.stderr).toContain('Некорректный JSON'); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/e2e/distribution/fixtures/global-consumer.ts b/tests/e2e/distribution/fixtures/global-consumer.ts new file mode 100644 index 0000000..27d919a --- /dev/null +++ b/tests/e2e/distribution/fixtures/global-consumer.ts @@ -0,0 +1,8 @@ +/// +const globalConfig: GothicLockSolver.SolverConfig = GothicLockSolver.createSolverConfig({ maxDenseBytes: 0 }); +const globalCommands: readonly GothicLockSolver.Command[] | null = GothicLockSolver.solveLock([6, 2], [[0, -1], [0, 0]], globalConfig); +void globalCommands; +// @ts-expect-error The classic namespace exposes no default property. +GothicLockSolver.default; +// @ts-expect-error The global solver requires a complete configuration. +GothicLockSolver.solveLock([4, 4], [[0, 0], [0, 0]], { maxExpanded: 0 }); diff --git a/tests/e2e/distribution/fixtures/runtime-consumer.ts b/tests/e2e/distribution/fixtures/runtime-consumer.ts new file mode 100644 index 0000000..0c3dc0d --- /dev/null +++ b/tests/e2e/distribution/fixtures/runtime-consumer.ts @@ -0,0 +1,62 @@ +import assert from 'node:assert/strict'; +import { readFileSync, writeFileSync, existsSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { delimiter, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { solveLock, createSolverConfig } from 'gothic-lock-solver'; +import * as GothicLockSolver from 'gothic-lock-solver'; + +const config = createSolverConfig({ maxDenseBytes: 0 }); +assert.ok(Object.isFrozen(config)); +assert.deepEqual(solveLock([6, 2], [[0, -1], [0, 0]], config), [[0, -2]]); +assert.deepEqual(GothicLockSolver.solveLock([4, 4], [[0, 0], [0, 0]]), []); +assert.deepEqual(Object.keys(GothicLockSolver).sort(), ['LockInputError', 'SearchLimitError', 'createSolverConfig', 'solveLock']); +assert.ok(import.meta.resolve('gothic-lock-solver').includes('/node_modules/gothic-lock-solver/dist/')); +const manifest: unknown = JSON.parse(readFileSync(new URL('./node_modules/gothic-lock-solver/package.json', import.meta.url), 'utf8')); +assert.ok(typeof manifest === 'object' && manifest !== null); +for (const field of ['dependencies', 'optionalDependencies', 'peerDependencies']) assert.deepEqual(Reflect.get(manifest, field) ?? {}, {}); +assert.deepEqual(Reflect.get(manifest, 'bundleDependencies') ?? [], []); +assert.equal(Reflect.get(manifest, 'private'), false); +const scripts: unknown = Reflect.get(manifest, 'scripts'); +assert.ok(typeof scripts === 'object' && scripts !== null); +for (const lifecycle of ['preinstall', 'install', 'postinstall', 'prepare']) assert.equal(Reflect.get(scripts, lifecycle), undefined); +assert.deepEqual(Reflect.get(manifest, 'bin'), { 'gothic-lock-solver': './dist/gothic-lock-solver.cli.mjs' }); +for (const path of ['gothic-lock-solver/dist/gothic-lock-solver.mjs', 'gothic-lock-solver/dist/gothic-lock-solver.min.mjs']) { + const api: unknown = await import(path); + assert.ok(typeof api === 'object' && api !== null); + assert.deepEqual(Object.keys(api).sort(), Object.keys(GothicLockSolver).sort()); + const factory: unknown = Reflect.get(api, 'createSolverConfig'); + const solve: unknown = Reflect.get(api, 'solveLock'); + assert.equal(typeof factory, 'function'); + assert.equal(typeof solve, 'function'); + if (typeof factory !== 'function' || typeof solve !== 'function') throw new Error('Malformed ESM exports'); + const settings: unknown = Reflect.apply(factory, undefined, [{ maxDenseBytes: 0 }]); + assert.deepEqual(settings, config); + assert.deepEqual(Reflect.apply(solve, undefined, [[6, 2], [[0, -1], [0, 0]], settings]), [[0, -2]]); +} +console.log(JSON.stringify({ node: process.version, installed: true, namespace: Object.keys(GothicLockSolver).sort() })); + +const input = new URL('./lock.json', import.meta.url); +const configFile = new URL('./config.json', import.meta.url); +const output = new URL('./solution.json', import.meta.url); +const executable = new URL('./node_modules/gothic-lock-solver/dist/gothic-lock-solver.cli.mjs', import.meta.url); +const bin = fileURLToPath(new URL('./node_modules/.bin/gothic-lock-solver', import.meta.url)); +assert.ok(existsSync(bin)); +writeFileSync(input, JSON.stringify({ state: [6, 2], links: [[0, -1], [0, 0]] })); +writeFileSync(configFile, JSON.stringify({ maxDenseBytes: 0 })); +let cli = spawnSync(bin, [fileURLToPath(input), '--config', fileURLToPath(configFile), '--output', fileURLToPath(output)], { encoding: 'utf8', env: { ...process.env, PATH: `${dirname(process.execPath)}${delimiter}${process.env['PATH'] ?? ''}` } }); +assert.equal(cli.status, 0); +assert.equal(cli.stderr, ''); +const solution: unknown = JSON.parse(readFileSync(output, 'utf8')); +assert.ok(typeof solution === 'object' && solution !== null); +assert.deepEqual(Reflect.get(solution, 'commands'), [{ plate: 1, direction: 'right', steps: 2 }]); +writeFileSync(input, JSON.stringify({ state: [1, 7], links: [[0, 1], [1, 0]] })); +cli = spawnSync(process.execPath, [fileURLToPath(executable), fileURLToPath(input)], { encoding: 'utf8' }); +assert.equal(cli.status, 2); +writeFileSync(configFile, JSON.stringify({ unknown: 1 })); +cli = spawnSync(process.execPath, [fileURLToPath(executable), fileURLToPath(input), '--config', fileURLToPath(configFile)], { encoding: 'utf8' }); +assert.equal(cli.status, 1); +writeFileSync(input, '{'); +cli = spawnSync(process.execPath, [fileURLToPath(executable), fileURLToPath(input)], { encoding: 'utf8' }); +assert.equal(cli.status, 1); +console.log(JSON.stringify({ installedCli: true, node: process.version, exitCodes: [0, 1, 2] })); diff --git a/tests/e2e/distribution/fixtures/types-consumer.ts b/tests/e2e/distribution/fixtures/types-consumer.ts new file mode 100644 index 0000000..3a28242 --- /dev/null +++ b/tests/e2e/distribution/fixtures/types-consumer.ts @@ -0,0 +1,46 @@ +import { solveLock, createSolverConfig, LockInputError, SearchLimitError } from 'gothic-lock-solver'; +import * as GothicLockSolver from 'gothic-lock-solver'; +import { solveLock as minified } from 'gothic-lock-solver/dist/gothic-lock-solver.min.mjs'; +import { solveLock as readable } from 'gothic-lock-solver/dist/gothic-lock-solver.mjs'; +import type { Command, Delta, Link, Links, Position, State, SearchLimit, SolverConfig } from 'gothic-lock-solver'; + +const state: State = [6, 2]; +const position: Position = 4; +const delta: Delta = -2; +const link: Link = -1; +const links: Links = [[0, link], [0, 0]]; +const config: SolverConfig = createSolverConfig({ maxExpanded: 100, maxDenseBytes: 0 }); +const commands: readonly Command[] | null = solveLock(state, links, config); +solveLock(state, links, createSolverConfig()); +const dynamic: Position[] = [position, position]; +const dynamicLinks: Link[][] = [[0, 0], [0, 0]]; +const limit: SearchLimit | undefined = undefined; +void [commands, delta, limit, new LockInputError('input'), SearchLimitError]; +GothicLockSolver.solveLock(dynamic, dynamicLinks); +minified([6, 2], [[0, -1], [0, 0]]); +readable([4, 4], [[0, 0], [0, 0]]); +// @ts-expect-error The public ESM entry has no default export. +import defaultSolver from 'gothic-lock-solver'; +void defaultSolver; +// @ts-expect-error Internal search modules are not public package exports. +import internalSearch from 'gothic-lock-solver/dist/astar.js'; +void internalSearch; +// @ts-expect-error Position zero is outside the lock model. +solveLock([0, 4], [[0, 0], [0, 0]]); +// @ts-expect-error Fixed input tuples require at least two positions. +solveLock([4], [[0]]); +// @ts-expect-error Fixed input tuples require a matching matrix. +solveLock([4, 4], [[0, 0]]); +// @ts-expect-error The solver requires a complete configuration object. +solveLock([4, 4], [[0, 0], [0, 0]], {}); +// @ts-expect-error A returned zero delta would be invalid. +const zero: Command = [0, 0]; +void zero; +// @ts-expect-error Solver configuration is readonly. +config.maxExpanded = 1; +// @ts-expect-error Configuration fields require numbers. +createSolverConfig({ maxVisited: '100' }); +// @ts-expect-error Unknown factory options are invalid. +createSolverConfig({ timeout: 100 }); +// @ts-expect-error Configured fixed tuples still require matching matrix dimensions. +solveLock([4, 4], [[0]], config); diff --git a/tests/e2e/fixtures/classic.html b/tests/e2e/fixtures/classic.html new file mode 100644 index 0000000..f3ee919 --- /dev/null +++ b/tests/e2e/fixtures/classic.html @@ -0,0 +1,9 @@ + + + + + Plain HTML classic consumer + loading + + + diff --git a/tests/e2e/fixtures/classic.min.html b/tests/e2e/fixtures/classic.min.html new file mode 100644 index 0000000..d741389 --- /dev/null +++ b/tests/e2e/fixtures/classic.min.html @@ -0,0 +1,9 @@ + + + + + Plain HTML minified classic consumer + loading + + + diff --git a/tests/e2e/fixtures/classic.ts b/tests/e2e/fixtures/classic.ts new file mode 100644 index 0000000..7b739e3 --- /dev/null +++ b/tests/e2e/fixtures/classic.ts @@ -0,0 +1,12 @@ +const namespace: unknown = Reflect.get(globalThis, 'GothicLockSolver'); +if (typeof namespace !== 'object' || namespace === null) throw new Error('Missing classic global'); +Reflect.set(globalThis, 'consumer', { + solveLock: Reflect.get(namespace, 'solveLock'), + createSolverConfig: Reflect.get(namespace, 'createSolverConfig'), + exports: Object.keys(namespace), + namedMatchesNamespace: true, + hasClassicGlobal: Object.hasOwn(globalThis, 'GothicLockSolver'), +}); +const status = document.querySelector('#status'); +if (status === null) throw new Error('Missing consumer status'); +status.textContent = 'ready'; diff --git a/examples/lock.example.json b/tests/e2e/fixtures/lock.example.json similarity index 100% rename from examples/lock.example.json rename to tests/e2e/fixtures/lock.example.json diff --git a/tests/e2e/fixtures/module.html b/tests/e2e/fixtures/module.html new file mode 100644 index 0000000..f32b198 --- /dev/null +++ b/tests/e2e/fixtures/module.html @@ -0,0 +1,8 @@ + + + + + Plain HTML ESM consumer + loading + + diff --git a/tests/e2e/fixtures/module.min.html b/tests/e2e/fixtures/module.min.html new file mode 100644 index 0000000..5c640df --- /dev/null +++ b/tests/e2e/fixtures/module.min.html @@ -0,0 +1,8 @@ + + + + + Plain HTML minified ESM consumer + loading + + diff --git a/tests/e2e/fixtures/module.ts b/tests/e2e/fixtures/module.ts new file mode 100644 index 0000000..e92359b --- /dev/null +++ b/tests/e2e/fixtures/module.ts @@ -0,0 +1,13 @@ +import { createSolverConfig, solveLock } from '../../../src/index.ts'; +import * as namespace from '../../../src/index.ts'; + +Reflect.set(globalThis, 'consumer', { + solveLock, + createSolverConfig, + exports: Object.keys(namespace), + namedMatchesNamespace: solveLock === namespace.solveLock && createSolverConfig === namespace.createSolverConfig, + hasClassicGlobal: Object.hasOwn(globalThis, 'GothicLockSolver'), +}); +const status = document.querySelector('#status'); +if (status === null) throw new Error('Missing consumer status'); +status.textContent = 'ready'; diff --git a/tests/e2e/fixtures/worker.html b/tests/e2e/fixtures/worker.html new file mode 100644 index 0000000..2b660d8 --- /dev/null +++ b/tests/e2e/fixtures/worker.html @@ -0,0 +1,7 @@ + + + + + Plain HTML module Worker consumer + ready + diff --git a/tests/e2e/fixtures/worker.ts b/tests/e2e/fixtures/worker.ts new file mode 100644 index 0000000..69d0045 --- /dev/null +++ b/tests/e2e/fixtures/worker.ts @@ -0,0 +1,9 @@ +import { createSolverConfig, solveLock } from '../../../src/index.ts'; + +self.addEventListener('message', (event: MessageEvent) => { + const data = event.data; + if (typeof data !== 'object' || data === null || !('state' in data) || !('links' in data)) { + throw new Error('Malformed Worker input'); + } + self.postMessage(Reflect.apply(solveLock, undefined, [data.state, data.links, createSolverConfig()])); +}); diff --git a/tests/e2e/paths.ts b/tests/e2e/paths.ts new file mode 100644 index 0000000..fb498e1 --- /dev/null +++ b/tests/e2e/paths.ts @@ -0,0 +1,10 @@ +import { isAbsolute } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const override = process.env['GOTHIC_DIST_DIRECTORY']; +if (override !== undefined && !isAbsolute(override)) { + throw new Error('GOTHIC_DIST_DIRECTORY must be an absolute directory path.'); +} + +// Package verification points this at the installed tarball's dist directory. +export const distDirectory = override ?? fileURLToPath(new URL('../../dist/', import.meta.url)); diff --git a/tests/e2e/server.ts b/tests/e2e/server.ts new file mode 100644 index 0000000..1dc688a --- /dev/null +++ b/tests/e2e/server.ts @@ -0,0 +1,79 @@ +import { access, readFile } from 'node:fs/promises'; +import { createServer } from 'node:http'; +import { join } from 'node:path'; +import { distDirectory } from './paths.ts'; + +const root = new URL('../../', import.meta.url); +const runtimeFiles = [ + 'gothic-lock-solver.mjs', + 'gothic-lock-solver.min.mjs', + 'gothic-lock-solver.js', + 'gothic-lock-solver.min.js', +]; +const fixtureFiles = [ + 'module.html', + 'module.min.html', + 'classic.html', + 'classic.min.html', + 'worker.html', + +]; +const generatedFiles = ['module.mjs', 'module.min.mjs', 'worker.mjs', 'worker.min.mjs', 'classic.js', 'classic.min.js']; +const generated = new URL('../../artifacts/e2e/fixtures/', import.meta.url); +const paths = new Set([ + ...runtimeFiles.map((file) => `/dist/${file}`), + ...fixtureFiles.map((file) => `/tests/e2e/fixtures/${file}`), + ...generatedFiles.map((file) => `/tests/e2e/fixtures/${file}`), +]); + +// Starting this server never builds or transforms the artifacts being tested. +await Promise.all(runtimeFiles.map(async (file) => { + try { + await access(join(distDirectory, file)); + } catch (cause) { + throw new Error(`Missing ${join(distDirectory, file)}; run pnpm build before pnpm test:e2e.`, { cause }); + } +})); + +const server = createServer((request, response) => { + const pathname = new URL(request.url ?? '/', 'http://127.0.0.1:4177').pathname; + if (request.method !== 'GET' && request.method !== 'HEAD') { + response.writeHead(405, { Allow: 'GET, HEAD' }); + response.end(); + return; + } + if (pathname === '/health') { + response.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' }); + response.end(request.method === 'HEAD' ? undefined : 'ready'); + return; + } + if (!paths.has(pathname)) { + response.writeHead(404); + response.end(); + return; + } + const file = pathname.startsWith('/dist/') + ? join(distDirectory, pathname.slice('/dist/'.length)) + : generatedFiles.includes(pathname.split('/').at(-1) ?? '') + ? new URL(pathname.split('/').at(-1) ?? '', generated) + : new URL(pathname.slice(1), root); + void readFile(file).then((bytes) => { + response.writeHead(200, { + 'Content-Type': pathname.endsWith('.html') + ? 'text/html; charset=utf-8' + : 'application/javascript; charset=utf-8', + 'Content-Length': bytes.byteLength, + 'Cache-Control': 'no-store', + 'X-Content-Type-Options': 'nosniff', + }); + response.end(request.method === 'HEAD' ? undefined : bytes); + }, () => { + response.writeHead(500); + response.end(); + }); +}); + +server.listen(4177, '127.0.0.1'); +process.once('SIGTERM', () => { + server.close(); +}); diff --git a/tests/e2e/verify-package.ts b/tests/e2e/verify-package.ts new file mode 100644 index 0000000..23dcd34 --- /dev/null +++ b/tests/e2e/verify-package.ts @@ -0,0 +1,99 @@ +import { strict as assert } from 'node:assert'; +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { copyFile, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { parseArgs } from 'node:util'; + +const { values } = parseArgs({ + options: { + archive: { type: 'string' }, + browser: { type: 'string' }, + node: { type: 'string', multiple: true }, + }, +}); +const browsers = ['none', 'chromium', 'firefox', 'webkit']; +assert.ok(values.browser === undefined || browsers.includes(values.browser), 'Browser must be none, chromium, firefox or webkit'); +assert.ok(values.archive !== '', 'Archive path must not be empty'); +assert.ok(values.node?.every(path => path.length > 0) ?? true, 'Node executable path must not be empty'); +const browser = values.browser ?? 'all'; +const suppliedArchive = values.archive === undefined ? undefined : resolve(values.archive); +const consumerNodes = new Set([process.execPath, ...(values.node ?? [])]); + +const root = fileURLToPath(new URL('../../', import.meta.url)); +const temporary = await mkdtemp(join(tmpdir(), 'gothic-package-consumer-')); +const artifacts = join(root, 'artifacts', 'package'); +const destination = join(artifacts, 'package.tgz'); +const checksumPath = join(artifacts, 'package.sha256'); +const compiler = fileURLToPath(new URL('../../node_modules/typescript/bin/tsc', import.meta.url)); +const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm'; +function run(command: string, args: readonly string[], cwd = temporary): string { + return execFileSync(command, args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'inherit'] }); +} +try { + await mkdir(artifacts, { recursive: true }); + // Preserve a supplied archive even when its verification fails. + if (suppliedArchive !== destination) await rm(destination, { force: true }); + await rm(checksumPath, { force: true }); + let archivePath = suppliedArchive; + if (archivePath === undefined) { + run(npm, ['pack', '--ignore-scripts', '--pack-destination', temporary], root); + const archives = (await readdir(temporary)).filter((name) => name.endsWith('.tgz')); + const archive = archives[0]; + assert.equal(archives.length, 1, 'Pack must create exactly one tarball'); + assert.ok(archive); + archivePath = join(temporary, archive); + } + await writeFile(join(temporary, 'package.json'), JSON.stringify({ name: 'gothic-clean-consumer', version: '1.0.0', private: true, type: 'module' })); + run(npm, ['install', '--offline', '--ignore-scripts', '--no-audit', '--no-fund', archivePath]); + const packageRoot = join(temporary, 'node_modules', 'gothic-lock-solver'); + const packageDist = join(packageRoot, 'dist'); + assert.deepEqual((await readdir(packageRoot)).sort(), ['LICENSE', 'README.md', 'dist', 'package.json']); + const installedMetadata: unknown = JSON.parse(await readFile(join(packageRoot, 'package.json'), 'utf8')); + const distributionManifest: unknown = JSON.parse(await readFile(join(packageDist, 'manifest.json'), 'utf8')); + assert.ok(typeof installedMetadata === 'object' && installedMetadata !== null && 'version' in installedMetadata); + assert.ok(typeof distributionManifest === 'object' && distributionManifest !== null && 'version' in distributionManifest && 'sourceSha' in distributionManifest); + assert.equal(distributionManifest.version, installedMetadata.version, 'Build version is stale'); + assert.equal(distributionManifest.sourceSha, run('git', ['rev-parse', 'HEAD'], root).trim(), 'Build source SHA is stale'); + const repoDist = join(root, 'dist'); + const sourceFiles = (await readdir(repoDist)).sort(); + assert.deepEqual((await readdir(packageDist)).sort(), sourceFiles); + for (const file of sourceFiles) assert.deepEqual(await readFile(join(packageDist, file)), await readFile(join(repoDist, file)), `Pack changed ${file}`); + for (const file of ['types-consumer.ts', 'runtime-consumer.ts', 'global-consumer.ts']) { + await copyFile(join(root, 'tests', 'e2e', 'distribution', 'fixtures', file), join(temporary, file)); + } + const rawConfig: unknown = JSON.parse(await readFile(join(root, 'tsconfig.json'), 'utf8')); + assert.ok(typeof rawConfig === 'object' && rawConfig !== null && 'compilerOptions' in rawConfig); + assert.ok(typeof rawConfig.compilerOptions === 'object' && rawConfig.compilerOptions !== null); + await writeFile(join(temporary, 'tsconfig.json'), JSON.stringify({ + compilerOptions: { ...rawConfig.compilerOptions, types: [], lib: ['ES2022'], moduleDetection: 'legacy' }, + include: ['types-consumer.ts', 'global-consumer.ts'], + })); + run(process.execPath, [compiler, '--project', join(temporary, 'tsconfig.json')]); + await writeFile(join(temporary, 'tsconfig.runtime.json'), JSON.stringify({ + compilerOptions: { ...rawConfig.compilerOptions, types: ['node'], typeRoots: [join(root, 'node_modules', '@types')], noEmit: false, allowImportingTsExtensions: false }, + include: ['runtime-consumer.ts'], + })); + run(process.execPath, [compiler, '--project', join(temporary, 'tsconfig.runtime.json')]); + for (const executable of consumerNodes) { + process.stdout.write(run(executable, [join(temporary, 'runtime-consumer.js')])); + } + if (browser !== 'none') { + run(process.execPath, [join(root, 'tests', 'e2e', 'build-fixtures.ts')], root); + // Isolated HTTP/file consumers load installed tarball bytes without Vite. + const browserArgs = browser === 'all' ? [] : ['--project', browser]; + execFileSync(process.execPath, [join(root, 'node_modules', 'playwright', 'cli.js'), 'test', ...browserArgs], { + cwd: root, + env: { ...process.env, GOTHIC_DIST_DIRECTORY: packageDist }, + stdio: 'inherit', + }); + } + if (archivePath !== destination) await copyFile(archivePath, destination); + const sha256 = createHash('sha256').update(await readFile(destination)).digest('hex'); + await writeFile(checksumPath, `${sha256} package.tgz\n`); + console.log(JSON.stringify({ verified: true, node: process.version, tarball: destination, sha256 })); +} finally { + await rm(temporary, { recursive: true, force: true }); +} diff --git a/tests/unit/benchmark.test.ts b/tests/unit/benchmark.test.ts new file mode 100644 index 0000000..f7db364 --- /dev/null +++ b/tests/unit/benchmark.test.ts @@ -0,0 +1,177 @@ +import { mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { commandMetrics, replayCommands, type Definition } from '../benchmarks/validation.ts'; +import { compareMetrics, statistics } from '../benchmarks/comparison.ts'; +import { catalogSha256, loadCatalog } from '../benchmarks/catalog.ts'; +import { createReport, loadImplementation, parseReport, publishReport, readReport, runBenchmark, type BenchmarkReport } from '../benchmarks/harness.ts'; +import { assertArtifactOutput, parseBenchmarkArgs } from '../benchmarks/cli.ts'; +import { measureRss, type MemoryMeasurement } from '../benchmarks/memory.ts'; + +const definition = { state: [3, 4], links: [[0, 0], [0, 0]] }; +const commands: readonly (readonly [number, number])[] = [[0, 1]]; +const fixture = { id: 'unit', ...definition, expectedActions: 1 }; +const source = { revision: '1'.repeat(40), dirty: false, moduleSha256: '2'.repeat(64) }; +const environment = { node: process.version, v8: process.versions.v8, platform: process.platform, arch: process.arch, + cpu: 'unit test', logicalCpus: 1, osRelease: 'test', totalMemoryBytes: 1024 }; +const settings = { warmups: 0, repetitions: 1, memoryRepetitions: 1 }; +const memory: MemoryMeasurement = { metric: 'linux-proc-VmHWM', limitations: 'Whole-process approximate RSS, including Node; not browser memory.', + browser: { available: false, reason: 'No portable browser peak-memory API.' }, + samples: [{ peakRssBytes: 20, startupRssBytes: 10, loadedRssBytes: 12, lockCount: 45, peakRssMetric: 'linux-proc-VmHWM', startupPeakRssBytes: 10, + loadedPeakRssBytes: 12, resourceUsageMaxRssBytes: 30, startupResourceUsageMaxRssBytes: 30, resourceUsageLaunchFloorDetected: true }], + peakRssBytes: statistics([20]) }; +let fullReport: BenchmarkReport; +beforeAll(async () => { + const solver = await loadImplementation(new URL('../../src/index.ts', import.meta.url).pathname); + const results = runBenchmark({ solve: solver, warmups: 0, repetitions: 1 }); + fullReport = createReport({ source, environment, settings, results, memory }); +}); + +describe('independent benchmark replay', () => { + it('uses outgoing links and checks every intermediate unit shift', () => { + expect(replayCommands(definition, commands)).toEqual([4, 4]); + expect(replayCommands({ state: [2, 5, 6], links: [[0, -1, 0], [0, 0, 1], [0, 0, 0]] }, [[0, 2]])).toEqual([4, 3, 6]); + expect(() => replayCommands({ state: [6, 6], links: [[0, 1], [0, 0]] }, [[0, 2]])).toThrow(/blocked/i); + expect(definition.state).toEqual([3, 4]); + }); + it('rejects invalid commands', () => { + for (const invalid of [[[0, 0]], [[-1, 1]], [[2, 1]], [[0, 7]], [[0, 1.5]], [[0, 1, 2]], [{ plate: 1 }]]) { + expect(() => replayCommands(definition, invalid)).toThrow(/command/i); + } + }); + it('reports actions, distinct plates, unit shifts and plate switches', () => { + expect(commandMetrics([[0, 2], [1, -3], [0, 1]])).toEqual({ A: 3, U: 2, C: 6, plateSwitches: 2 }); + }); +}); + +describe('current solver measurement', () => { + it('runs all 45 fixed inputs with their unchanged exact minima', () => { + expect(loadCatalog()).toHaveLength(45); + expect(loadCatalog().reduce((sum, lock) => sum + lock.expectedActions, 0)).toBe(483); + expect(fullReport.results).toHaveLength(45); + expect(fullReport.totals.A).toBe(483); + expect(fullReport.quality).toBe('passed'); + expect(fullReport.comparison).toMatchObject({ status: 'skipped', reason: expect.stringMatching(/saved.*report/i) }); + }); + it('retains raw measurements while excluding warmups', () => { + let calls = 0; + let clock = 0; + const results = runBenchmark({ locks: [fixture], solve: () => { calls += 1; clock += calls; return commands; }, + warmups: 2, repetitions: 3, clock: () => clock }); + expect(calls).toBe(5); + expect(results[0]?.samplesMs).toEqual([3, 4, 5]); + expect(results[0]?.timingMs).toEqual({ min: 3, median: 4, p95: 5, max: 5 }); + }); + it('checks input immutability, completion and minimum actions during warmups too', () => { + const run = (solve: (input: Definition) => unknown) => runBenchmark({ locks: [fixture], solve, warmups: 1, repetitions: 1 }); + expect(() => run(() => null)).toThrow(/solvability/i); + expect(() => run(() => [])).toThrow(/target/i); + expect(() => run(() => [[0, 2], [0, -1]])).toThrow(/minimum/i); + expect(() => run((input) => { input.state[0] = 4; return commands; })).toThrow(/mutat/i); + }); + it('rejects nondeterministic equal-optimum paths', () => { + const lock = { id: 'choice', state: [3, 3], links: [[0, 1], [1, 0]], expectedActions: 1 }; + let calls = 0; + expect(() => runBenchmark({ locks: [lock], solve: () => [[calls++ % 2, 1]], warmups: 1, repetitions: 1 })).toThrow(/determin/i); + }); + it('computes median and nearest-rank p95 without reordering samples', () => { + const samples = [9, 1, 5, 3]; + expect(statistics(samples)).toEqual({ min: 1, median: 4, p95: 9, max: 9 }); + expect(samples).toEqual([9, 1, 5, 3]); + for (const bad of [[], [-1], [NaN]]) expect(() => statistics(bad)).toThrow(/sample/i); + }); +}); + +describe('saved report comparison', () => { + it('compares stored metrics and historical ratios without executing a baseline', () => { + const baseline = structuredClone(fullReport); + for (const row of baseline.results) { row.samplesMs = row.samplesMs.map((value) => value / 2); row.timingMs = statistics(row.samplesMs); } + const report = createReport({ source, environment, settings, results: fullReport.results, memory, baseline }); + expect(report.quality).toBe('passed'); + expect(report.comparison).toMatchObject({ status: 'compared', baselineSha: source.revision, quality: 'passed', + memoryPeakMedianRatio: 1, limitations: expect.stringMatching(/historical.*informational/i) }); + if (report.comparison.status !== 'compared') throw new Error('Missing comparison'); + expect(report.comparison.results[0]?.timingMedianRatio).toBe(2); + expect(report.comparison.results[0]?.deltas).toEqual({ A: 0, U: 0, C: 0, plateSwitches: 0 }); + expect(parseReport(report)).toEqual(report); + }); + it('fails an increase in every deterministic metric independently', () => { + const baseline = { A: 3, U: 2, C: 8, plateSwitches: 1 }; + for (const name of ['A', 'U', 'C', 'plateSwitches'] as const) { + expect(compareMetrics({ ...baseline, [name]: baseline[name] + 1 }, baseline)).toMatchObject({ quality: 'regression', regressions: [name] }); + } + expect(compareMetrics({ A: 3, U: 1, C: 9, plateSwitches: 0 }, baseline).quality).toBe('regression'); + }); + it('accepts only exact source SHA reports and requires clean source identity', () => { + expect(parseReport(fullReport, source.revision)).toEqual(fullReport); + expect(() => parseReport(fullReport, '3'.repeat(40))).toThrow(/source|revision|SHA/i); + expect(() => parseReport({ ...fullReport, source: { ...source, dirty: true } }, source.revision)).toThrow(/dirty|clean/i); + expect(() => parseReport({ ...fullReport, source: {} })).toThrow(/source|revision/i); + }); + it('rejects corrupt schema, catalog, incomplete rows and fabricated measurements', () => { + const first = fullReport.results[0]; + if (first === undefined) throw new Error('Fixture missing'); + for (const change of [ + { schemaVersion: 1 }, { fixtures: { count: 45, catalogSha256: '0'.repeat(64) } }, + { results: fullReport.results.slice(1) }, { results: [first, ...fullReport.results.slice(0, 44)] }, + { results: [{ ...first, C: first.C + 1 }, ...fullReport.results.slice(1)] }, + { results: [{ ...first, commands: [] }, ...fullReport.results.slice(1)] }, + { results: [{ ...first, timingMs: { ...first.timingMs, median: first.timingMs.median + 1 } }, ...fullReport.results.slice(1)] }, + { totals: { ...fullReport.totals, A: 0 } }, { memory: { ...memory, peakRssBytes: statistics([1]) } }, + { quality: 'regression' }, { comparison: { status: 'skipped' } }, + ]) expect(() => parseReport({ ...fullReport, ...change })).toThrow(); + }); + it('rejects missing and malformed provided report files', async () => { + const root = await mkdtemp(join(tmpdir(), 'benchmark-corrupt-')); + try { + const path = join(root, 'benchmark.json'); + expect(() => readReport(path, source.revision)).toThrow(); + await writeFile(path, '{broken'); + expect(() => readReport(path, source.revision)).toThrow(); + } finally { await rm(root, { recursive: true, force: true }); } + }); +}); + +it('accepts a standalone CLI and requires a target SHA for a provided report', () => { + expect(parseBenchmarkArgs([])).toMatchObject({ warmups: 2, repetitions: 7, memoryRepetitions: 3 }); + expect(parseBenchmarkArgs([]).outputDir).toMatch(/artifacts\/benchmark$/); + expect(parseBenchmarkArgs(['--baseline-sha', source.revision]).baselineReport).toBeUndefined(); + expect(parseBenchmarkArgs(['--baseline-report', '/saved.json', '--baseline-sha', source.revision]).baselineReport).toBe('/saved.json'); + for (const args of [['--baseline-report', '/saved.json'], ['--baseline-sha', 'main'], ['--baseline-module', '/base.mjs'], + ['--baseline-snapshot', '/snapshot.json'], ['--smoke'], ['--skip-memory'], ['--warmups', '-1'], ['--warmups', '01'], + ['--repetitions', '0'], ['--warmups', '1', '--warmups', '2'], ['--unknown']]) { + expect(() => parseBenchmarkArgs(args)).toThrow(); + } +}); + +it('publishes exactly JSON and Markdown with full rows and explicit skipped comparison', async () => { + const root = await mkdtemp(join(tmpdir(), 'benchmark-output-')); + try { + const paths = await publishReport(fullReport, root); + expect((await readdir(root)).sort()).toEqual(['benchmark.json', 'benchmark.md']); + expect(readReport(paths.jsonPath, source.revision)).toEqual(fullReport); + const markdown = await readFile(paths.markdownPath, 'utf8'); + expect(markdown).toMatch(/comparison.*skipped/i); + expect(markdown).toContain('lock-045'); + expect(markdown).toMatch(/actions.*distinct plates.*unit shifts.*switches/i); + expect(markdown).toMatch(/Node.*browser/i); + } finally { await rm(root, { recursive: true, force: true }); } +}); + +it('measures only the requested current solver in an isolated RSS worker', () => { + const ids = loadCatalog().slice(0, 2).map((lock) => lock.id); + const sample = measureRss(new URL('../../src/index.ts', import.meta.url).pathname, ids); + expect(sample.lockCount).toBe(2); + expect(sample.peakRssBytes).toBeGreaterThan(0); + expect(sample.startupRssBytes).toBeGreaterThan(0); + expect(sample.peakRssMetric).toBe(process.platform === 'linux' ? 'linux-proc-VmHWM' : 'node-resourceUsage-maxRSS'); + expect(sample.resourceUsageMaxRssBytes).toBeGreaterThan(0); +}); + +it('keeps report output in ignored artifacts and retains the fixed catalog hash', () => { + expect(() => assertArtifactOutput('docs/benchmarks')).toThrow(/ignored artifacts/); + expect(() => assertArtifactOutput('artifacts/../tests')).toThrow(/ignored artifacts/); + expect(() => assertArtifactOutput('artifacts/benchmark')).not.toThrow(); + expect(catalogSha256).toBe('902a57e0c867b7ed7513af09257fde765396593ab45738bb60967b412b4bcd69'); +}); diff --git a/tests/unit/cli.test.ts b/tests/unit/cli.test.ts new file mode 100644 index 0000000..3998162 --- /dev/null +++ b/tests/unit/cli.test.ts @@ -0,0 +1,170 @@ +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { runCli } from '../../src/cli.ts'; + +const directories: string[] = []; +afterEach(async () => { + await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))); +}); + +async function fixture(contents: unknown) { + const directory = await mkdtemp(join(tmpdir(), 'gothic-cli-')); + directories.push(directory); + const input = join(directory, 'lock.json'); + await writeFile(input, JSON.stringify(contents)); + return { directory, input, output: join(directory, 'solution.json') }; +} + +async function run(args: readonly string[]) { + let stdout = ''; + let stderr = ''; + const code = await runCli(args, { + stdout: { write: (text) => { stdout += text; } }, + stderr: { write: (text) => { stderr += text; } }, + }); + return { code, stdout, stderr }; +} + +describe('CLI file adapter', () => { + it('prints grouped actions while leaving files untouched unless --output is requested', async () => { + const { input, output } = await fixture({ state: [1, 7], links: [[0, 0], [0, 0]] }); + expect(await run([input])).toEqual({ code: 0, stdout: 'Найдено команд: 2\n1. Пластина 1 - влево x3\n2. Пластина 2 - вправо x3\n', stderr: '' }); + await expect(readFile(output)).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('creates and overwrites the existing structured JSON contract', async () => { + const { input, output } = await fixture({ state: [1, 7], links: [[0, 0], [0, 0]] }); + const expected = { + status: 'solved', initialState: [1, 7], targetState: [4, 4], + commands: [{ plate: 1, direction: 'left', steps: 3 }, { plate: 2, direction: 'right', steps: 3 }], + finalState: [4, 4], metrics: { commands: 2, divisions: 6, plateSwitches: 1 }, + }; + for (const stale of ['', 'stale'.repeat(1000)]) { + await writeFile(output, stale); + expect((await run([input, '--output', output])).code).toBe(0); + expect(await readFile(output, 'utf8')).toBe(`${JSON.stringify(expected, null, 2)}\n`); + } + }); + + it('distinguishes an already open lock from a proven unreachable goal', async () => { + const open = await fixture({ state: [4, 4], links: [[0, 0], [0, 0]] }); + expect(await run([open.input])).toEqual({ code: 0, stdout: 'Замок уже открыт.\n', stderr: '' }); + const blocked = await fixture({ state: [1, 7], links: [[0, 1], [1, 0]] }); + expect(await run([blocked.input, '--output', blocked.output])).toEqual({ code: 2, stdout: 'Решение не найдено.\n', stderr: '' }); + const result: unknown = JSON.parse(await readFile(blocked.output, 'utf8')); + expect(result).toEqual({ status: 'unsolvable', initialState: [1, 7], targetState: [4, 4], commands: [] }); + }); + + it.each([ + {}, + { maxVisited: 2_000_000, maxExpanded: 1_000_000, maxFrontier: 1_000_000, maxDenseBytes: 67_108_864 }, + { maxDenseBytes: 0 }, + ])('uses defaults for omitted settings in configuration %j', async (config) => { + const files = await fixture({ state: [1, 7], links: [[0, 0], [0, 0]] }); + const configPath = join(files.directory, 'solver.config.json'); + const contents = JSON.stringify(config); + await writeFile(configPath, contents); + expect(await run([files.input, '--config', configPath])).toEqual({ + code: 0, + stdout: 'Найдено команд: 2\n1. Пластина 1 - влево x3\n2. Пластина 2 - вправо x3\n', + stderr: '', + }); + expect(await readFile(configPath, 'utf8')).toBe(contents); + }); + + it('applies a partial configuration and reports exhaustion as an error', async () => { + const files = await fixture({ state: [3, 1, 1], links: [[0, -1, -1], [-1, 0, -1], [-1, -1, 0]] }); + const configPath = join(files.directory, 'solver.config.json'); + await writeFile(configPath, JSON.stringify({ maxExpanded: 0 })); + expect(await run([files.input, '--config', configPath, '--output', files.output])).toMatchObject({ + code: 1, stdout: '', stderr: expect.stringContaining('maxExpanded'), + }); + await expect(readFile(files.output)).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('accepts output before configuration and preserves the JSON result', async () => { + const files = await fixture({ state: [4, 4], links: [[0, 0], [0, 0]] }); + const configPath = join(files.directory, 'solver.config.json'); + await writeFile(configPath, JSON.stringify({ maxExpanded: 0 })); + expect(await run([files.input, '--output', files.output, '--config', configPath])).toEqual({ + code: 0, stdout: 'Замок уже открыт.\n', stderr: '', + }); + const output: unknown = JSON.parse(await readFile(files.output, 'utf8')); + expect(output).toEqual({ + status: 'solved', initialState: [4, 4], targetState: [4, 4], commands: [], finalState: [4, 4], + metrics: { commands: 0, divisions: 0, plateSwitches: 0 }, + }); + }); + + it.each([null, [], 1, 'config', { unknown: 1 }, { maxVisited: '10' }, { maxExpanded: null }, { maxFrontier: 0 }].map((config) => ({ config })))( + 'rejects an invalid configuration $config without running the solver', async ({ config }) => { + const files = await fixture({ state: [4, 4], links: [[0, 0], [0, 0]] }); + const configPath = join(files.directory, 'solver.config.json'); + await writeFile(configPath, JSON.stringify(config)); + const result = await run([files.input, '--config', configPath]); + expect(result).toMatchObject({ code: 1, stdout: '' }); + expect(result.stderr).toMatch(/^Ошибка:/u); + expect(result.stderr).not.toContain('Использование'); + }, + ); + + it('reports a missing configuration file', async () => { + const files = await fixture({ state: [4, 4], links: [[0, 0], [0, 0]] }); + const configPath = join(files.directory, 'solver.config.json'); + expect(await run([files.input, '--config', configPath])).toMatchObject({ + code: 1, stdout: '', stderr: expect.stringContaining('ENOENT'), + }); + }); + + it('reports malformed configuration JSON', async () => { + const files = await fixture({ state: [4, 4], links: [[0, 0], [0, 0]] }); + const configPath = join(files.directory, 'solver.config.json'); + await writeFile(configPath, '{'); + expect(await run([files.input, '--config', configPath])).toMatchObject({ + code: 1, stdout: '', stderr: expect.stringContaining('Некорректный JSON'), + }); + }); + + it('prints help successfully without requiring an input file', async () => { + const result = await run(['--help']); + expect(result.code).toBe(0); + expect(result.stderr).toBe(''); + expect(result.stdout).toMatch(/^Использование:/u); + expect(result.stdout).toContain('--config'); + expect(result.stdout).toContain('--output'); + }); + + it.each([ + [], [''], ['--unknown'], ['x', '--output'], ['x', 'extra'], ['x', '--output', ''], + ['x', '--output', 'y', 'extra'], ['x', '--config'], ['x', '--config', ''], + ['x', '--config', '--output', 'y'], ['x', '--unknown', 'y'], + ['x', '--config', 'a', '--config', 'b'], ['x', '--output', 'a', '--output', 'b'], + ['--help', '--unknown'], ['x', '--help'], + ])('rejects malformed command line %j', async (...args) => { + const result = await run(args); + expect(result.code).toBe(1); + expect(result.stdout).toBe(''); + expect(result.stderr).toMatch(/^Ошибка: Использование:/u); + }); + + it.each([null, [], {}, { state: [0, 4], links: [[0, 0], [0, 0]] }].map((input) => ({ input })))('rejects malformed input $input without reporting unsolvable', async ({ input }) => { + const files = await fixture(input); + const result = await run([files.input]); + expect(result.code).toBe(1); + expect(result.stdout).toBe(''); + expect(result.stderr).toMatch(/^Ошибка:/u); + }); + + it('reports JSON, input IO and output IO failures without a success message', async () => { + const files = await fixture({ state: [4, 4], links: [[0, 0], [0, 0]] }); + for (const args of [[join(files.directory, 'missing.json')], [files.input, '--output', files.directory]]) { + const result = await run(args); + expect(result).toMatchObject({ code: 1, stdout: '' }); + expect(result.stderr).toMatch(/^Ошибка:/u); + } + await writeFile(files.input, '{'); + expect(await run([files.input])).toMatchObject({ code: 1, stdout: '', stderr: expect.stringContaining('Некорректный JSON') }); + }); +}); diff --git a/tests/unit/config.test.ts b/tests/unit/config.test.ts new file mode 100644 index 0000000..cc25e7e --- /dev/null +++ b/tests/unit/config.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from 'vitest'; +import * as api from '../../src/index.ts'; +import type { SolverConfig } from '../../src/index.ts'; + +const defaults = { + maxVisited: 2_000_000, + maxExpanded: 1_000_000, + maxFrontier: 1_000_000, + maxDenseBytes: 67_108_864, +}; +const state = [1, 7] as const; +const links = [[0, 0], [0, 0]] as const; + +function configFromJavaScript(value: unknown): unknown { + return Reflect.apply(api.createSolverConfig, undefined, [value]); +} + +function solveFromJavaScript(value: unknown): unknown { + return Reflect.apply(api.solveLock, undefined, [state, links, value]); +} + +describe('public solver configuration', () => { + it('exports a factory that returns complete frozen defaults', () => { + expect(api).toHaveProperty('createSolverConfig'); + const config = api.createSolverConfig(); + expect(config).toEqual(defaults); + expect(Object.isFrozen(config)).toBe(true); + expect(api.createSolverConfig(undefined)).toEqual(defaults); + expect(api.solveLock(state, links, config)).toEqual([[0, 3], [1, -3]]); + expect(api.solveLock(state, links)).toEqual([[0, 3], [1, -3]]); + }); + + it('copies partial overrides without freezing or mutating the caller', () => { + const overrides = { maxVisited: 3, maxDenseBytes: 0 }; + const config = api.createSolverConfig(overrides); + expect(config).toEqual({ ...defaults, maxVisited: 3, maxDenseBytes: 0 }); + expect(overrides).toEqual({ maxVisited: 3, maxDenseBytes: 0 }); + expect(Object.isFrozen(overrides)).toBe(false); + overrides.maxVisited = 1; + expect(config.maxVisited).toBe(3); + expect(Reflect.set(config, 'maxVisited', 1)).toBe(false); + expect(api.solveLock(state, links, config)).toEqual([[0, 3], [1, -3]]); + }); + + it('accepts inclusive integer bounds and zero for expansion and dense storage', () => { + expect(api.createSolverConfig({ maxVisited: 1, maxFrontier: 1, maxExpanded: 0, maxDenseBytes: 0 })) + .toEqual({ maxVisited: 1, maxFrontier: 1, maxExpanded: 0, maxDenseBytes: 0 }); + const maximum = Object.fromEntries(Object.keys(defaults).map((key) => [key, Number.MAX_SAFE_INTEGER])); + expect(configFromJavaScript(maximum)).toEqual(maximum); + }); + + it.each([null, [], 1, 'config', true, { unknown: 1 }, { [Symbol('unknown')]: 1 }].map((value) => ({ value }))) + ('rejects invalid override objects and unknown fields: $value', ({ value }) => { + expect(() => configFromJavaScript(value)).toThrow(api.LockInputError); + }); + + for (const field of Object.keys(defaults)) { + it.each([undefined, null, true, '1', 1n, -1, 0.5, Number.NaN, Infinity, Number.MAX_SAFE_INTEGER + 1]) + (`rejects invalid ${field} values: %s`, (value) => { + expect(() => configFromJavaScript({ [field]: value })).toThrow(api.LockInputError); + expect(() => solveFromJavaScript({ ...defaults, [field]: value })).toThrow(api.LockInputError); + }); + } + + it('rejects zero visited/frontier bounds and nonenumerable unknown fields', () => { + for (const field of ['maxVisited', 'maxFrontier']) { + expect(() => configFromJavaScript({ [field]: 0 })).toThrow(api.LockInputError); + } + expect(() => configFromJavaScript(Object.defineProperty({}, 'unknown', { value: 1 }))).toThrow(api.LockInputError); + }); + + it.each([null, [], false, {}, { maxVisited: 3 }, { ...defaults, unknown: 1 }].map((value) => ({ value }))) + ('revalidates complete configs from JavaScript: $value', ({ value }) => { + expect(() => solveFromJavaScript(value)).toThrow(api.LockInputError); + }); + + it('requires own complete fields and validates ordinary caller mutations', () => { + const inherited: unknown = Object.create(defaults); + expect(() => solveFromJavaScript(inherited)).toThrow(api.LockInputError); + const ordinary = { ...defaults }; + expect(solveFromJavaScript(ordinary)).toEqual([[0, 3], [1, -3]]); + expect(ordinary).toEqual(defaults); + expect(Object.isFrozen(ordinary)).toBe(false); + ordinary.maxExpanded = -1; + expect(() => solveFromJavaScript(ordinary)).toThrow(api.LockInputError); + }); +}); + +describe('public search budgets', () => { + const cases = [ + { name: 'certificate', state, links }, + { name: 'A*', state: [3, 1, 1] as const, links: [[0, -1, -1], [-1, 0, -1], [-1, -1, 0]] as const }, + { name: 'singular BFS', state: [1, 1] as const, links: [[0, 1], [1, 0]] as const }, + ]; + for (const lock of cases) { + for (const overrides of [{ maxExpanded: 0 }, { maxVisited: 1 }]) { + it(`${lock.name} reports ${Object.keys(overrides)[0]} exhaustion instead of null`, () => { + const config = api.createSolverConfig(overrides); + expect(() => api.solveLock(lock.state, lock.links, config)).toThrow(api.SearchLimitError); + }); + } + } + + it('enforces frontier limits in both exact search strategies', () => { + for (const lock of cases.slice(1)) { + expect(() => api.solveLock(lock.state, lock.links, api.createSolverConfig({ maxFrontier: 1 }))) + .toThrow(api.SearchLimitError); + } + }); + + it('counts the certificate path and permits exactly sufficient budgets', () => { + const overrides: Partial = { maxVisited: 3, maxExpanded: 2, maxFrontier: 1 }; + expect(api.solveLock(state, links, api.createSolverConfig(overrides))).toEqual([[0, 3], [1, -3]]); + expect(() => api.solveLock(state, links, api.createSolverConfig({ ...overrides, maxVisited: 2 }))) + .toThrow(api.SearchLimitError); + expect(() => api.solveLock(state, links, api.createSolverConfig({ ...overrides, maxExpanded: 1 }))) + .toThrow(api.SearchLimitError); + }); + + it('preserves singular BFS action order in dense and forced sparse storage', () => { + const singular = [[0, 1, 0], [1, 0, 0], [0, 0, 0]] as const; + for (const maxDenseBytes of [0, defaults.maxDenseBytes]) { + expect(api.solveLock([1, 1, 7], singular, api.createSolverConfig({ maxDenseBytes }))) + .toEqual([[0, 3], [2, -3]]); + } + }); + + it('allows open and algebraically unreachable locks without state expansion', () => { + const config = api.createSolverConfig({ maxExpanded: 0 }); + expect(api.solveLock([4, 4], links, config)).toEqual([]); + expect(api.solveLock([3, 4], [[0, 1], [-1, 0]], config)).toBeNull(); + }); +}); diff --git a/tests/unit/core-contract.test.ts b/tests/unit/core-contract.test.ts new file mode 100644 index 0000000..8ea9d31 --- /dev/null +++ b/tests/unit/core-contract.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'vitest'; +import * as api from '../../src/index.ts'; +import type { Link, Position } from '../../src/index.ts'; +import { referenceActions, replay, unitMove } from './oracle.ts'; + +const inverse = [[0, -1, -1], [-1, 0, -1], [-1, -1, 0]] as const; +const independent = (state: readonly Position[]) => state.map(() => state.map((): Link => 0)); + +function solveUnknown(state: unknown, links: unknown): unknown { + return Reflect.apply(api.solveLock, undefined, [state, links]); +} + +describe('public tuple contract', () => { + it('exports the named solver, config factory and documented error classes', () => { + expect(Object.keys(api).sort()).toEqual(['LockInputError', 'SearchLimitError', 'createSolverConfig', 'solveLock']); + }); + it('uses numeric pin delta and links[source][target] without reverse influence', () => { + expect(api.solveLock([6, 2], [[0, -1], [0, 0]])).toEqual([[0, -2]]); + expect(api.solveLock([4, 2], [[0, -1], [0, 0]])).toEqual([[1, 2]]); + }); + it('applies direct effects once without cascades', () => { + const state = [3, 3, 4] as const; + const links = [[0, 1, 0], [0, 0, 1], [0, 0, 0]] as const; + expect(api.solveLock(state, links)).toEqual([[0, 1]]); + }); + it('does not mutate frozen input and returns independent repeatable results', () => { + const state: readonly Position[] = Object.freeze([1, 7, 2, 6, 3, 5, 4, 1]); + const links = Object.freeze(independent(state).map((row) => Object.freeze(row))); + const result = api.solveLock(state, links); + expect(result).toHaveLength(7); + if (result === null) throw new Error('Expected solution'); + expect(replay({ state, links }, result)).toEqual(state.map(() => 4)); + expect(api.solveLock(state, links)).toEqual(result); + expect(api.solveLock(state, links)).not.toBe(result); + }); + it('returns [] for open and null only for proved unreachable locks', () => { + expect(api.solveLock([4, 4], [[0, 0], [0, 0]])).toEqual([]); + expect(api.solveLock([1, 1, 1], inverse)).toBeNull(); + expect(api.solveLock([3, 4], [[0, 1], [-1, 0]])).toBeNull(); + expect(api.solveLock([3, 4], [[0, 1], [1, 0]])).toBeNull(); + }); + it('handles singular matrices and repeated-plate schedules exactly', () => { + expect(api.solveLock([1, 1], [[0, 1], [1, 0]])).toEqual([[0, 3]]); + const lock = { state: [3, 1, 1] as const, links: inverse }; + const result = api.solveLock(lock.state, lock.links); + expect(result).toHaveLength(4); + if (result === null) throw new Error('Expected solution'); + expect(replay(lock, result)).toEqual([4, 4, 4]); + expect(new Set(result.map(([index]) => index)).size).toBeLessThan(result.length); + }); + it('allows net movements above six while minimizing actions', () => { + const lock = { state: [7, 7, 1] as const, links: [[0, -1, 1], [0, 0, 1], [0, 0, 0]] as const }; + const result = api.solveLock(lock.state, lock.links); + expect(referenceActions(lock)).toBe(4); + expect(result).toHaveLength(4); + if (result === null) throw new Error('Expected solution'); + expect(replay(lock, result)).toEqual([4, 4, 4]); + }); + it('rejects invalid dimensions, positions, links, diagonals and sparse arrays', () => { + const sparseState = [4, 4, 4]; + delete sparseState[1]; + const sparseMatrix = [[0, 0], [0, 0]]; + delete sparseMatrix[0]; + const sparseRow = [0, 0]; + delete sparseRow[1]; + const bad: readonly (readonly [unknown, unknown])[] = [ + [null, null], [[], []], [[4], [[0]]], [[0, 4], [[0, 0], [0, 0]]], + [[4, 8], [[0, 0], [0, 0]]], [[4, 2.5], [[0, 0], [0, 0]]], + [[4, NaN], [[0, 0], [0, 0]]], [[4, '4'], [[0, 0], [0, 0]]], + [[4, 4], [[0, 0]]], [[4, 4], [[0, 0], [0]]], + [[4, 4], [[1, 0], [0, 0]]], [[4, 4], [[0, 2], [0, 0]]], + [sparseState, [[0, 0, 0], [0, 0, 0], [0, 0, 0]]], + [[4, 4], sparseMatrix], [[4, 4], [sparseRow, [0, 0]]], + ]; + for (const [state, links] of bad) expect(() => solveUnknown(state, links)).toThrow(api.LockInputError); + const inherited = [4, 4]; + delete inherited[0]; + Object.setPrototypeOf(inherited, Object.assign(Object.create(Array.prototype), { 0: 4 })); + expect(() => solveUnknown(inherited, [[0, 0], [0, 0]])).toThrow(api.LockInputError); + }); + it('blocks the complete direct movement when a linked pin hits a boundary', () => { + expect(unitMove([4, 7], [[0, 1], [0, 0]], 0, 1)).toBeNull(); + const lock = { state: [1, 7] as const, links: [[0, 1], [0, 0]] as const }; + const result = api.solveLock(lock.state, lock.links); + const expected = referenceActions(lock); + if (expected === null) throw new Error('Expected reachable oracle lock'); + expect(result).toHaveLength(expected); + if (result === null) throw new Error('Expected solution'); + expect(replay(lock, result)).toEqual([4, 4]); + }); + it('supports 18-plate safe codes and reports encoding exhaustion separately from null', () => { + const state: Position[] = [1, ...Array.from({ length: 17 }, (): Position => 4)]; + expect(api.solveLock(state, independent(state))).toEqual([[0, 3]]); + const tooWide = Array.from({ length: 19 }, (): Position => 4); + expect(() => api.solveLock(tooWide, independent(tooWide))).toThrow(api.SearchLimitError); + try { api.solveLock(tooWide, independent(tooWide)); } catch (error) { + expect(error).toBeInstanceOf(api.SearchLimitError); + if (error instanceof api.SearchLimitError) expect(error.limit).toBe('stateEncoding'); + } + }); +}); diff --git a/tests/unit/core-contract.types.ts b/tests/unit/core-contract.types.ts new file mode 100644 index 0000000..6656b04 --- /dev/null +++ b/tests/unit/core-contract.types.ts @@ -0,0 +1,37 @@ +import { solveLock } from '../../src/index.ts'; +import type { Command, Delta, Link, Links, Position, State } from '../../src/index.ts'; +const state = [6, 2] as const satisfies State; +const links = [[0, -1], [0, 0]] as const satisfies Links; +const result: readonly Command[] | null = solveLock(state, links); +void result; +const dynamicState: readonly Position[] = [1, 7, 4]; +const dynamicLinks: readonly (readonly Link[])[] = [[0, 0, 0], [0, 0, 0], [0, 0, 0]]; +solveLock(dynamicState, dynamicLinks); +// @ts-expect-error one plate is not a fixed valid state +solveLock([4], [[0]]); +// @ts-expect-error no state positions outside 1..7 +solveLock([0, 4], [[0, 0], [0, 0]]); +// @ts-expect-error N is inferred only from state; row count must be N +solveLock([4, 4], [[0, 0], [0, 0], [0, 0]]); +// @ts-expect-error fixed tuples require square rows +solveLock([4, 4], [[0, 0, 0], [0, 0, 0]]); +// @ts-expect-error short rows must be rejected +solveLock(state, [[0], [0]]); +// @ts-expect-error links are -1, 0 or 1 +solveLock(state, [[0, 2], [0, 0]]); +// @ts-expect-error old object wrapper is not public +solveLock({ state, links }); +// @ts-expect-error unknown algorithm selectors are not solver configuration +solveLock(state, links, { algorithm: 'bfs' }); +// @ts-expect-error zero delta is not a command +const invalidDelta: Delta = 0; +void invalidDelta; +if (result !== null) { + // @ts-expect-error returned list is readonly + result.push([0, 1]); + const first = result[0]; + if (first !== undefined) { + // @ts-expect-error returned command is readonly + first[1] = 1; + } +} diff --git a/tests/unit/core-limits.test.ts b/tests/unit/core-limits.test.ts new file mode 100644 index 0000000..b57d17c --- /dev/null +++ b/tests/unit/core-limits.test.ts @@ -0,0 +1,47 @@ +import { expect, it } from 'vitest'; +import { SearchLimitError } from '../../src/index.ts'; +import { LockModel } from '../../src/lock.ts'; +import { PreparedSearch, movementLimits } from '../../src/lock.ts'; +import { SearchBudget } from '../../src/config.ts'; +import { createSolverConfig } from '../../src/index.ts'; +import { BfsSearch } from '../../src/bfs.ts'; +import { MatrixSearch } from '../../src/astar.ts'; +import { analyzeMatrix } from '../../src/matrix.ts'; + +it('reports exhaustion separately on BFS, A*, singular fallback and greedy certificate', () => { + const inputs = [ + new LockModel([1, 7], [[0, 0], [0, 0]]), + new LockModel([3, 1, 1], [[0, -1, -1], [-1, 0, -1], [-1, -1, 0]]), + new LockModel([1, 1], [[0, 1], [1, 0]]), + ]; + for (const model of inputs) { + const prepared = new PreparedSearch(model); + for (const options of [{ maxExpanded: 0 }, { maxVisited: 1 }]) { + expect(() => new MatrixSearch(model, prepared, new SearchBudget(createSolverConfig(options))).solve()).toThrow(SearchLimitError); + expect(() => new BfsSearch(prepared, new SearchBudget(createSolverConfig(options))).solve()).toThrow(SearchLimitError); + } + expect(() => new BfsSearch(prepared, new SearchBudget(createSolverConfig({ maxFrontier: 1 }))).solve()).toThrow(SearchLimitError); + } +}); +it('dense and sparse BFS agree on minimum-cost commands, including wider safe codes', () => { + const model = new LockModel([2, 7, 1], [[0, 0, 0], [-1, 0, -1], [-1, -1, 0]]); + const prepared = new PreparedSearch(model); + const expected = [[0, -1], [1, -3]]; + expect(new BfsSearch(prepared, new SearchBudget()).solve()).toEqual(expected); + expect(new BfsSearch(prepared, new SearchBudget(createSolverConfig({ maxDenseBytes: 0 }))).solve()).toEqual(expected); + const state = [1, ...Array.from({ length: 17 }, () => 4)]; + const wide = new PreparedSearch(new LockModel(state, state.map(() => state.map(() => 0)))); + expect(new BfsSearch(wide, new SearchBudget(createSolverConfig({ maxDenseBytes: 0, maxVisited: 5, maxExpanded: 1, maxFrontier: 4 }))).solve()).toEqual([[0, 3]]); +}); +it('distinguishes exact rational proofs from rank deficiency', () => { + expect(analyzeMatrix(new LockModel([3, 4], [[0, 1], [-1, 0]])).kind).toBe('noninteger'); + expect(analyzeMatrix(new LockModel([3, 4], [[0, 1], [1, 0]])).kind).toBe('inconsistent'); + expect(analyzeMatrix(new LockModel([1, 1], [[0, 1], [1, 0]])).kind).toBe('singular'); + expect(analyzeMatrix(new LockModel([7, 7, 1], [[0, -1, 1], [0, 0, 1], [0, 0, 0]]))).toEqual({ kind: 'unique-integer', rank: 3, net: [-3, -6, 12], lowerBound: 4 }); +}); +it('computes the legal range from all direct affected pins atomically', () => { + const prepared = new PreparedSearch(new LockModel([4, 7], [[0, 1], [0, 0]])); + const effect = prepared.effects[0]; + if (effect === undefined) throw new Error('Missing prepared effect'); + expect(movementLimits(new Uint8Array([3, 6]), effect)).toEqual([0, 3]); +}); diff --git a/tests/unit/core-oracle.test.ts b/tests/unit/core-oracle.test.ts new file mode 100644 index 0000000..5737924 --- /dev/null +++ b/tests/unit/core-oracle.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest'; +import { solveLock } from '../../src/index.ts'; +import type { Link, Position } from '../../src/index.ts'; +import { LockModel } from '../../src/lock.ts'; +import { PreparedSearch } from '../../src/lock.ts'; +import { SearchBudget } from '../../src/config.ts'; +import { createSolverConfig } from '../../src/index.ts'; +import { BfsSearch } from '../../src/bfs.ts'; +import { commandCost, referenceCost, replay } from './oracle.ts'; +import fixtures from '../benchmarks/fixtures.json' with { type: 'json' }; + +function position(value: number): Position { + if (value === 1 || value === 2 || value === 3 || value === 4 || value === 5 || value === 6 || value === 7) return value; + throw new Error('Invalid fixture position'); +} +function link(value: number): Link { + if (value === -1 || value === 0 || value === 1) return value; + throw new Error('Invalid fixture link'); +} + +describe('independent minimum-action then minimum-unit-shift oracle', () => { + it('checks all 441 two-plate cases and replays every intermediate unit move', () => { + let checked = 0; + for (const a of [-1, 0, 1] as const) for (const b of [-1, 0, 1] as const) { + for (let first = 1; first <= 7; first += 1) for (let second = 1; second <= 7; second += 1) { + const lock = { state: [position(first), position(second)] as const, links: [[0, a], [b, 0]] as const }; + const prepared = new PreparedSearch(new LockModel(lock.state, lock.links)); + const expected = referenceCost(lock); + const results = [solveLock(lock.state, lock.links), new BfsSearch(prepared, new SearchBudget()).solve(), + new BfsSearch(prepared, new SearchBudget(createSolverConfig({ maxDenseBytes: 0 }))).solve()]; + for (const result of results) { + expect(commandCost(result), JSON.stringify(lock)).toEqual(expected); + if (result !== null) expect(replay(lock, result)).toEqual([4, 4]); + } + checked += 1; + } + } + expect(checked).toBe(441); + }); + it('checks 192 seeded three-plate inputs against A*, dense BFS and sparse BFS', () => { + let seed = 0x51405; + const random = (max: number): number => { + seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0; + return seed % max; + }; + for (let sample = 0; sample < 192; sample += 1) { + const state = Array.from({ length: 3 }, () => position(1 + random(7))); + const links = Array.from({ length: 3 }, (_, row) => Array.from({ length: 3 }, (_, column) => row === column ? 0 : link(random(3) - 1))); + const lock = { state, links }; + const expected = referenceCost(lock); + const prepared = new PreparedSearch(new LockModel(state, links)); + const results = [solveLock(state, links), new BfsSearch(prepared, new SearchBudget()).solve(), + new BfsSearch(prepared, new SearchBudget(createSolverConfig({ maxDenseBytes: 0 }))).solve()]; + for (const result of results) { + expect(commandCost(result), JSON.stringify(lock)).toEqual(expected); + if (result !== null) expect(replay(lock, result)).toEqual([4, 4, 4]); + } + } + }); +}); + +describe('fixed mathematical expectations', () => { + it.each([ + { id: 'lock-011', actions: 9, unitShifts: 37 }, + { id: 'lock-018', actions: 14, unitShifts: 45 }, + { id: 'lock-019', actions: 14, unitShifts: 41 }, + ])('$id: minimizes unit shifts among minimum-action solutions', ({ id, actions, unitShifts }) => { + const entry = fixtures.find((fixture) => fixture.id === id); + if (entry === undefined) throw new Error('Missing regression fixture'); + const lock = { state: entry.state.map(position), links: entry.links.map((row) => row.map(link)) }; + const result = solveLock(lock.state, lock.links); + expect(commandCost(result)).toEqual({ actions, unitShifts }); + if (result === null) throw new Error('Expected catalog solution'); + expect(replay(lock, result)).toEqual(lock.state.map(() => 4)); + }); + it('keeps all 45 numerical configurations and action minima', async () => { + const semantics = fixtures.map(({ id, state, links, expectedActions }) => ({ id, state, links, expectedActions })); + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(JSON.stringify(semantics))); + const hash = [...new Uint8Array(digest)].map((byte) => byte.toString(16).padStart(2, '0')).join(''); + expect(hash).toBe('22cfb8a9610dd81a487dcecf40420474f9a634916af054622cbfe0ef9fafb4a4'); + expect(fixtures).toHaveLength(45); + expect(new Set(fixtures.map((entry) => entry.id)).size).toBe(45); + }); + for (const entry of fixtures) { + it(`${entry.id}: solves in ${entry.expectedActions} actions with legal intermediate moves`, () => { + const state = entry.state.map(position); + const links = entry.links.map((row) => row.map(link)); + const before = { state: [...state], links: links.map((row) => [...row]) }; + const result = solveLock(state, links); + expect(result).toHaveLength(entry.expectedActions); + if (result === null) throw new Error('Expected catalog solution'); + expect(replay({ state, links }, result)).toEqual(state.map(() => 4)); + expect({ state, links }).toEqual(before); + }); + } +}); diff --git a/tests/unit/core-priority-queue.test.ts b/tests/unit/core-priority-queue.test.ts new file mode 100644 index 0000000..e5dcf41 --- /dev/null +++ b/tests/unit/core-priority-queue.test.ts @@ -0,0 +1,37 @@ +import { expect, it } from 'vitest'; +import { IndexedHeap, NOT_QUEUED } from '../../src/priority-queue.ts'; +import type { SearchNode } from '../../src/priority-queue.ts'; + +function node(state: number, cost: readonly [actions: number, units: number], + estimate: readonly [actions: number, units: number]): SearchNode { + return { + state, actionCount: cost[0], unitShifts: cost[1], estimate: estimate[0], shiftEstimate: estimate[1], + remainingShifts: [], previous: null, plate: -1, delta: 0, sequence: state, + heapIndex: NOT_QUEUED, closed: false, + }; +} + +it('orders total actions before total units, with remaining actions only breaking cost ties', () => { + const frontier = new IndexedHeap(); + const fewerActions = node(0, [1, 100], [0, 0]); + const fewerUnits = node(1, [1, 3], [1, 1]); + const moreUnits = node(2, [2, 5], [0, 0]); + const equalCostCloser = node(3, [2, 4], [0, 0]); + for (const entry of [moreUnits, fewerUnits, equalCostCloser, fewerActions]) frontier.push(entry); + expect([frontier.pop(), frontier.pop(), frontier.pop(), frontier.pop()]) + .toEqual([fewerActions, equalCostCloser, fewerUnits, moreUnits]); +}); + +it('decreases equal-action unit cost in place without stale queue entries', () => { + const frontier = new IndexedHeap(); + const first = node(0, [2, 7], [0, 0]); + const improved = node(1, [1, 8], [1, 2]); + frontier.push(first); + frontier.push(improved); + improved.unitShifts = 3; + frontier.decreasePriority(improved); + expect(frontier.size).toBe(2); + expect(frontier.pop()).toBe(improved); + expect(frontier.pop()).toBe(first); + expect(frontier.size).toBe(0); +}); diff --git a/tests/unit/oracle.ts b/tests/unit/oracle.ts new file mode 100644 index 0000000..3c65e81 --- /dev/null +++ b/tests/unit/oracle.ts @@ -0,0 +1,83 @@ +/** Independent oracle: imports no production transitions, encodings or search. */ +export interface OracleLock { + readonly state: readonly number[]; + readonly links: readonly (readonly number[])[]; +} + +function element(values: readonly T[], index: number): T { + const value = values[index]; + if (value === undefined) throw new Error(`Missing oracle element ${index}`); + return value; +} + +export function unitMove(state: readonly number[], links: OracleLock['links'], source: number, direction: number): number[] | null { + const row = element(links, source); + const next = state.map((position, target) => position + direction * (source === target ? 1 : element(row, target))); + return next.some((position) => position < 1 || position > 7) ? null : next; +} + +export function replay(lock: OracleLock, commands: readonly (readonly [number, number])[]): number[] { + let state = [...lock.state]; + for (const [index, delta] of commands) { + if (!Number.isInteger(index) || index < 0 || index >= state.length || !Number.isInteger(delta) || delta === 0 || Math.abs(delta) > 6) { + throw new Error('Invalid command'); + } + for (let step = 0; step < Math.abs(delta); step += 1) { + const next = unitMove(state, lock.links, index, Math.sign(delta)); + if (next === null) throw new Error('Blocked intermediate unit movement'); + state = next; + } + } + return state; +} + +export interface OracleCost { + readonly actions: number; + readonly unitShifts: number; +} + +export function commandCost(commands: readonly (readonly [number, number])[] | null): OracleCost | null { + return commands === null ? null : { + actions: commands.length, + unitShifts: commands.reduce((sum, [, delta]) => sum + Math.abs(delta), 0), + }; +} + +function compareCost(left: OracleCost, right: OracleCost): number { + return left.actions - right.actions || left.unitShifts - right.unitShifts; +} + +/** Deliberately simple Dijkstra: string keys, sorted queue, unit-by-unit edges. */ +export function referenceCost(lock: OracleLock): OracleCost | null { + const root = { actions: 0, unitShifts: 0 }; + const queue = [{ state: [...lock.state], cost: root }]; + const best = new Map([[lock.state.join(','), root]]); + while (queue.length > 0) { + queue.sort((left, right) => compareCost(left.cost, right.cost)); + const current = queue.shift(); + if (current === undefined) throw new Error('Missing oracle frontier state'); + if (best.get(current.state.join(',')) !== current.cost) continue; + if (current.state.every((position) => position === 4)) return current.cost; + for (let source = 0; source < current.state.length; source += 1) { + for (const direction of [1, -1]) { + let next = current.state; + for (let steps = 1; steps <= 6; steps += 1) { + const moved = unitMove(next, lock.links, source, direction); + if (moved === null) break; + next = moved; + const key = next.join(','); + const cost = { actions: current.cost.actions + 1, unitShifts: current.cost.unitShifts + steps }; + const previous = best.get(key); + if (previous !== undefined && compareCost(previous, cost) <= 0) continue; + best.set(key, cost); + queue.push({ state: next, cost }); + } + } + } + } + return null; +} + +export function referenceActions(lock: OracleLock): number | null { + return referenceCost(lock)?.actions ?? null; +} diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..7acca1d --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,21 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "emitDeclarationOnly": true, + "declaration": true, + "rootDir": "src", + "outDir": "dist", + "types": [], + "lib": [ + "ES2022" + ] + }, + "include": [ + "src/index.ts" + ], + "exclude": [ + "node_modules", + "dist" + ] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..450f8c7 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,44 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": [ + "ES2022", + "DOM", + "DOM.Iterable" + ], + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noPropertyAccessFromIndexSignature": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noUncheckedSideEffectImports": true, + "verbatimModuleSyntax": true, + "isolatedModules": true, + "allowUnreachableCode": false, + "allowUnusedLabels": false, + "skipLibCheck": false, + "noEmit": true, + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "types": [ + "node" + ] + }, + "include": [ + "src/**/*.ts", + "tests/**/*.ts", + "*.config.ts" + ], + "exclude": [ + "node_modules", + "dist", + "artifacts", + "tests/e2e/distribution/fixtures" + ] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..10a141c --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,65 @@ +import { execFileSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { readFile, readdir, writeFile } from 'node:fs/promises'; +import { isBuiltin } from 'node:module'; +import { fileURLToPath } from 'node:url'; +import { defineConfig } from 'vite'; +import metadata from './package.json' with { type: 'json' }; + +async function writeDistributionManifest(): Promise { + const dist = new URL('./dist/', import.meta.url); + await writeFile(new URL('global.d.ts', dist), [ + '/** Types for the classic GothicLockSolver global. */', + 'export as namespace GothicLockSolver;', + "export * from './index.js';", + '', + ].join('\n')); + + const files: Record = {}; + for (const filename of (await readdir(dist)).sort()) { + if (filename === 'manifest.json') continue; + const bytes = await readFile(new URL(filename, dist)); + files[filename] = createHash('sha256').update(bytes).digest('hex'); + } + const manifest = { + schemaVersion: 1, + package: metadata.name, + version: metadata.version, + sourceSha: execFileSync('git', ['rev-parse', 'HEAD'], { cwd: import.meta.dirname, encoding: 'utf8' }).trim(), + files, + }; + await writeFile(new URL('manifest.json', dist), `${JSON.stringify(manifest, null, 2)}\n`); +} + +export default defineConfig(({ mode }) => { + const minified = mode === 'minified'; + const cli = mode === 'cli'; + return { + plugins: cli ? [{ name: 'distribution-manifest', closeBundle: writeDistributionManifest }] : [], + build: { + target: cli ? 'node22' : 'es2022', + outDir: 'dist', + emptyOutDir: !minified && !cli, + sourcemap: false, + minify: false, + lib: { + entry: fileURLToPath(new URL(cli ? './src/cli.ts' : './src/index.ts', import.meta.url)), + name: 'GothicLockSolver', + formats: cli ? ['es'] : ['es', 'iife'], + fileName: (format) => cli ? 'gothic-lock-solver.cli.mjs' + : `gothic-lock-solver${minified ? '.min' : ''}.${format === 'es' ? 'mjs' : 'js'}`, + }, + rolldownOptions: { + external: cli ? isBuiltin : [], + output: { + // Explicit full Rolldown minification includes ESM whitespace, unlike + // the Vite library-mode build.minify shortcut. + minify: minified, + exports: 'named', + codeSplitting: false, + comments: { legal: false }, + }, + }, + }, + }; +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..16addf8 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,27 @@ +import { playwright } from '@vitest/browser-playwright'; +import type { TestUserConfig } from 'vitest/node'; + +const shared = ['tests/unit/core*.test.ts', 'tests/unit/config.test.ts', 'tests/e2e/browser/*.test.ts']; + +// Plain Vite config keeps test options checked without globally augmenting Vite. +export default { + test: { + coverage: { provider: 'v8', reportsDirectory: 'artifacts/coverage', include: ['src/**/*.ts'], thresholds: { statements: 80, branches: 80, functions: 80, lines: 80 }, reporter: ['text', 'json-summary', 'html'] }, + projects: [ + { test: { name: 'node', environment: 'node', include: [...shared, 'tests/unit/*.test.ts'], testTimeout: 120_000 } }, + { test: { name: 'distribution', environment: 'node', include: ['tests/e2e/distribution/*.test.ts'], testTimeout: 120_000 } }, + { + test: { + include: shared, + testTimeout: 120_000, + browser: { + enabled: true, + headless: true, + provider: playwright(), + instances: [{ browser: 'chromium', name: 'chromium' }, { browser: 'firefox', name: 'firefox' }, { browser: 'webkit', name: 'webkit' }], + }, + }, + }, + ], + } satisfies TestUserConfig, +}; diff --git a/wiki/algorithm.md b/wiki/algorithm.md new file mode 100644 index 0000000..f34aa46 --- /dev/null +++ b/wiki/algorithm.md @@ -0,0 +1,170 @@ +# Model, optimality and computational complexity + +## Objective and coordinates + +The state is a vector `x` of N integers in 1–7. The UI's grid visualizes this +vector; the dependency matrix `L` is N×N, with `L[source][target]` describing a +direct influence. Its diagonal is zero. Effects do not cascade. + +A command `[i, k]`, where `i` is zero-based and `k` is one of −6…−1 or +1…+6, +costs one **action**. It changes the selected pin and its directly linked pins. +Every intermediate pin position must remain in 1–7. An illegal action is blocked +in its entirety. + +| Metric | Definition | +| --- | --- | +| A | Number of commands, or grouped actions | +| U | Number of distinct selected plates, excluding indirectly affected plates | +| C | Number of unit shifts: sum of absolute command deltas | +| Switches | Changes of selected plate between adjacent commands | + +A successfully returned solution minimizes the pair (A, C) lexicographically: +first grouped actions, then unit shifts among paths with that minimum action count. +Minimum U is not a general guarantee. An optimal action sequence never has two +adjacent commands selecting the same plate: movements in the same direction can +be combined, and opposite movements can be cancelled. The combined displacement +still lies within the plate's six-position travel range. Thus a nonempty shortest +solution has A−1 switches, although it may revisit a plate later and have U` preserves the dimensions of a fixed-length state tuple. + +```ts +const state = [6, 2] as const satisfies State; +const links = [[0, -1], [0, 0]] as const satisfies Links; +const result = solveLock(state, links); +``` + +The state determines the matrix size. Literal tuples with too few plates, +wrong dimensions or out-of-range values fail type checking. Dynamically sized +`readonly Position[]` arrays are supported and checked at runtime. JSON remains +untrusted input regardless of a caller's TypeScript annotations. + +## Inputs and configuration + +`solveLock(state, links, config?)` accepts an optional complete `SolverConfig`. +Use `createSolverConfig(overrides?)` to fill defaults and validate partial settings; +see [configuration](configuration.md). Omitting config selects the same defaults. +`state[i]` is the numeric +pin position on plate `i`. Both plate indices and matrix indices start at zero; +pin positions retain their game labels 1 through 7. All target positions are 4. + +The square matrix is **source first**: `links[i][j]` specifies the direct effect +on plate `j` when selecting plate `i`. A diagonal value other than zero is invalid. +The selected plate moves independently of the links. A reverse link has value −1, +a synchronous link +1, and no link 0. Effects apply once and never cascade. + +The solver validates dense arrays, dimensions, integers and ranges. It makes +immutable internal copies; caller-owned arrays remain unchanged, including on +failure. `readonly` expresses the type contract but does not replace validation. + +## Commands and outcomes + +`[i, k]` moves pin `i` by signed numeric displacement `k` and applies its links. +Zero displacement is not a command. A command costs one action irrespective of +`abs(k)`. Each affected pin must remain within 1–7 during every unit shift. + +The result is a sequence of these commands, `[]` if already open, or `null` if +unreachability has been proved. It is deterministic for identical valid inputs +within a version. Consumers must not depend on a particular tie-breaking path +across versions. The guarantees are legal completion, minimum grouped actions, +and then minimum unit shifts among solutions with that action count. + +`LockInputError` signals malformed lock input or configuration. `SearchLimitError` exposes a documented +`limit` discriminator and `maximum`; it signals unavailable computation, never +unreachability. The current resource limits are documented with +[the algorithm](algorithm.md#computation-limits). + +```ts +try { + const result = solveLock([6, 2], [[0, -1], [0, 0]]); + if (result === null) { + console.log("No legal solution exists."); + } else { + for (const [index, delta] of result) console.log(index, delta); + } +} catch (error) { + if (error instanceof LockInputError) console.error("Invalid lock", error.message); + else if (error instanceof SearchLimitError) console.error("Search limit", error.limit); + else throw error; +} +``` + +Positive delta increases the numbered pin position. Physical left/right may be +opposite depending on whether an interface describes plate movement or pin +movement. That conversion belongs in the adapter, not in the matrix solver. + +## Web Workers + +For a local installed or copied ESM bundle, a module Worker can isolate synchronous +search from the page's main thread: + +```js +// solver-worker.mjs, hosted by the integrator +import { solveLock } from "https://cdn.jsdelivr.net/npm/gothic-lock-solver@VERSION/dist/gothic-lock-solver.min.mjs"; + +self.onmessage = ({ data: { state, links } }) => { + try { + self.postMessage({ commands: solveLock(state, links) }); + } catch (error) { + self.postMessage({ error: String(error) }); + } +}; +``` + +```js +// page.js +const worker = new Worker(new URL("./solver-worker.mjs", import.meta.url), { type: "module" }); +worker.onmessage = ({ data }) => console.log(data); +worker.postMessage({ state: [6, 2], links: [[0, -1], [0, 0]] }); +``` + +Host the Worker entry on your own origin and pin the library's exact CDN version. +Browser CSP must permit the chosen script source. No Worker wrapper is required +by the core contract; message shape and cancellation belong to the integrator. + +## CLI + +The fifth distribution file bundles the same solver and a Node.js file adapter. +It requires no sibling files or installed dependencies. + +```sh +node dist/gothic-lock-solver.cli.mjs lock.json +node dist/gothic-lock-solver.cli.mjs lock.json --config solver.config.json --output solution.json +# Equivalent installed npm bin: +gothic-lock-solver lock.json --output solution.json +# Source entry for development: +pnpm solve lock.json --config solver.config.json +``` + +The input file is a JSON object with `state` and `links`. `--config` reads a +partial config JSON object through the same factory; `--help` lists CLI options. The console is Russian +for compatibility. The optional output preserves the original structured format: + +```json +{ + "status": "solved", + "initialState": [6, 2], + "targetState": [4, 4], + "commands": [{ "plate": 1, "direction": "right", "steps": 2 }], + "finalState": [4, 4], + "metrics": { "commands": 1, "divisions": 2, "plateSwitches": 0 } +} +``` + +Here plate numbers are one-based, `left` means positive numeric displacement, +and `right` means negative. A proved unreachable result has `status: "unsolvable"`, +`initialState`, `targetState`, and `commands: []`, without success metrics. +An open lock has `status: "solved"` and an empty command list. + +Exit codes: 0 solved, 2 proved unreachable, 1 validation, IO or resource error. +With `--output`, the destination is created or overwritten before a success +message is printed. Without it, no result file is created. + +A CLI process measures startup, parsing and filesystem work as well as the solver. +Use the [benchmark harness](benchmarks.md) for algorithm timings; +use the CLI to measure your actual end-to-end command-line workload. diff --git a/wiki/benchmarks.md b/wiki/benchmarks.md new file mode 100644 index 0000000..23daf9d --- /dev/null +++ b/wiki/benchmarks.md @@ -0,0 +1,58 @@ +# Benchmarks + +The catalog contains 45 fixed numerical locks. Every run independently replays +solutions, verifies the known minimum action counts, and measures the built module. + +```sh +pnpm build +pnpm benchmark +``` + +Output is `artifacts/benchmark/benchmark.json` and `benchmark.md`. These files are +CI artifacts, never committed. JSON contains each solution, raw timings, memory +samples, environment information, source SHA and module/catalog hashes. + +## Optional comparison with a saved report + +```sh +pnpm benchmark --baseline-report /path/to/benchmark.json --baseline-sha TARGET_SHA +``` + +CI obtains `TARGET_SHA` from the PR target commit or the previous main commit. +It selects the latest successful CI run for that exact commit and its named +benchmark report artifact. If the artifact +is absent or expired, the current benchmark still runs and explicitly records +`comparison: skipped`. CI does not check out the target, rebuild it, execute its +code or substitute an npm/GitHub release. API access failures and malformed reports +are errors, not missing baselines. + +A supplied report must match its claimed SHA, current schema and fixed catalog. +Its commands are replayed and its recorded deterministic metrics checked before +comparison. The canonical `benchmark-SHA` artifact belongs to the selected CI +run. Rerunning the build replaces its report; rerunning only failed test rows +reuses the successful build and its report. + +## Metrics and limits + +| Metric | Meaning | +| --- | --- | +| Actions | Number of grouped commands; one command can shift several positions | +| Distinct plates | Number of different directly selected plates per lock | +| Unit shifts | Sum of absolute command displacements | +| Switches | Changes between consecutive selected plates | +| Median / p95 | Solver-call latency, excluding input cloning and replay | +| Peak RSS | Whole-catalog Node process memory, including runtime and imported module | + +When a baseline exists, deterministic quality is compared per lock. The current +policy reports a regression if any action, distinct-plate, unit-shift or switch +count increases. Aggregate improvements do not hide a per-lock increase. + +Saved timings and RSS come from different runs. Ratios are informative observations; +different hosts, runtimes, loads and measurement noise prevent treating them as a +paired performance experiment. They do not produce a statistical pass/fail gate. +RSS is not solver-only allocated memory and does not measure browser heap usage. + +Defaults are two warmups, seven measured solves per lock and three fresh Node +process memory samples. `--warmups`, `--repetitions`, `--memory-repetitions` and +`--output-dir` allow local investigation. Run measurements after other heavy tasks +finish. Keep raw observations and environment details with any performance claim. diff --git a/wiki/configuration.md b/wiki/configuration.md new file mode 100644 index 0000000..aadfc87 --- /dev/null +++ b/wiki/configuration.md @@ -0,0 +1,55 @@ +# Solver configuration + +`solveLock(state, links, config?)` accepts a complete readonly `SolverConfig`. +`createSolverConfig(overrides?)` accepts partial overrides, fills defaults, validates +values and returns a frozen object. Both APIs are available through ESM and the +`GothicLockSolver` browser global. + +```ts +import { createSolverConfig, solveLock } from "gothic-lock-solver"; +import type { SolverConfig } from "gothic-lock-solver"; + +const config: SolverConfig = createSolverConfig({ maxVisited: 500_000 }); +solveLock([6, 2], [[0, -1], [0, 0]], config); +solveLock([6, 2], [[0, -1], [0, 0]]); // Defaults +``` + +| Field | Default | Unit / permitted range | Effect | +| --- | ---: | --- | --- | +| `maxVisited` | 2,000,000 | Discovered states; positive safe integer | Bounds retained search history | +| `maxExpanded` | 1,000,000 | Expanded states; nonnegative safe integer | Bounds processing; zero permits no expansion | +| `maxFrontier` | 1,000,000 | Pending states; positive safe integer | Bounds the queue | +| `maxDenseBytes` | 67,108,864 | Bytes; nonnegative safe integer | Dense-storage budget; zero selects sparse storage | + +Unknown fields, null, arrays, strings, fractional/unsafe numbers and invalid ranges +are rejected with `LockInputError`. The solver revalidates complete configurations +supplied from ordinary JavaScript. Neither API mutates the caller's object. + +Larger budgets permit more resource consumption without guaranteeing success. +Safe-number state representation limits still apply. `maxDenseBytes` controls +only dense storage, not total heap or process RSS; sparse structures and runtime +costs remain relevant. Exceeding a search budget throws `SearchLimitError` with +`limit` and `maximum`, never `null` for interrupted work. An open state or algebraic +impossibility can be decided without expansion. Every returned path still has the +minimum action count and, among equal-action paths, minimum unit shifts. Positions 1–7, goal 4 and direct links are fixed domain rules. + +## CLI + +Save partial overrides in `solver.config.json`: + +```json +{ + "maxVisited": 500000, + "maxFrontier": 100000 +} +``` + +```sh +gothic-lock-solver lock.json --config solver.config.json +node gothic-lock-solver.cli.mjs lock.json --config solver.config.json --output solution.json +``` + +CLI uses the same factory. Without `--config`, defaults apply. Unreadable files, +invalid JSON and invalid settings produce an error and exit 1; exit 2 is reserved +for proved unreachability. `--help` lists flags; `--output` optionally creates or +overwrites the result JSON. See [API](api.md#cli). diff --git a/wiki/development.md b/wiki/development.md new file mode 100644 index 0000000..f688a0d --- /dev/null +++ b/wiki/development.md @@ -0,0 +1,82 @@ +# Development + +Use pnpm and maintained Node.js 22, 24 or 26 for source development. Exact versions +and integrity hashes live in `package.json` and `pnpm-lock.yaml`. Generated +JavaScript supports Node.js 22/24/26 and ES2022-capable core consumers. + +```sh +npm install --global --ignore-scripts npm@12.0.2 +pnpm install --frozen-lockfile +pnpm exec playwright install --with-deps chromium firefox webkit +pnpm test +``` + +Use npm 12.0.2 for package verification and release preparation, matching CI. + +`pnpm test` runs lint, strict types, Node/coverage tests, Vite builds, distribution +checks, real browsers, static-file E2E and installed-package verification. Coverage +requires 80% each for statements, branches, functions and lines over all shipped +`src/` code, including unimported entries. Subprocess tests also exercise the CLI. + +Narrower commands: `pnpm check`, `pnpm test:node`, `pnpm test:coverage`, +`pnpm test:distribution`, `pnpm test:browser`, `pnpm test:e2e`, `pnpm build`, +`pnpm verify:package` and `pnpm solve lock.json`. To check one archive across Node +versions: `pnpm verify:package --node /path/to/node22 --node /path/to/node26`. +Run [benchmarks](benchmarks.md) separately, after competing heavy tasks stop. + +CI builds and benchmarks once on Node 24, then distributes the same archive to +six Ubuntu matrix rows: Node 22/24/26 and Chromium/Firefox/WebKit. Each row runs +its selected Vitest project and verifies the installed archive. Browser rows use +Node 24 to drive their static-page and installed-package E2E tests; the Node 24 +row collects coverage. All rows must pass before release. + +To verify an existing build without repacking, use +`pnpm verify:package --archive /path/to/package.tgz --browser chromium`. +The browser selector accepts `none`, `chromium`, `firefox` or `webkit`; omitting +it runs all three browsers. The archive must match the checkout and its `dist/`. + +## Structure + +`src/index.ts` and `src/cli.ts` are build entries for integrations and Node tooling. +CLI invokes the exported solver/factory. The model, matrix analysis, searches and +priority queue each own one responsibility. Config/default validation lives in +`config.ts`. Classes encapsulate useful state; arithmetic uses plain functions. + +Use domain names, named mechanical bounds and explicit units for packed state or +byte arithmetic. Keep one operation per statement and short loop bodies. Matrix +iteration remains explicit; search and release orchestration should read as ordered +steps. Oxlint limits block nesting to three levels in source and Vite configuration. +Review these properties independently of test success. + +Tests live in `tests/unit`, `tests/e2e` and `tests/benchmarks`. TS consumer fixtures +are compiled into ignored output when JavaScript is needed. Vite bundles the +production files. `pnpm build` invokes Vite and TypeScript directly; all three +Vite modes live in `vite.config.ts`. Its final build hook emits only the global +declaration and source/checksum manifest. CI and publication use explicit commands +in workflow YAML, without a scripts directory or repository orchestration wrappers. + +The 45 inputs use independent replay and mathematical expected minima. A separate +exhaustive small-state oracle helps expose shared defects. Tests assert behavior, +not private structures. Add a failing test before a behavioral change, then the +smallest implementation preserving invariants. Apply DDD, BDD, TDD, SOLID, DRY, +KISS and YAGNI through these boundaries rather than extra frameworks or layers. + +## Strictness and evidence + +Strict flags include checked indexed access, exact optional properties and +library declaration checking. No `any`, non-null assertions or suppressed errors +are allowed; explained `ts-expect-error` proves negative type-consumer cases. +Vitest exports a plain configuration checked against public `TestUserConfig`; +dependencies remain unmodified, without coupling Vite's resolved config types. + +Builds, archives, benchmark reports, coverage, traces and review +logs stay outside Git. Local output is ignored; CI uploads the same evidence and +releases retain distribution evidence. Maintained explanations live in Wiki. +Complete local gates before workflow edits, independently review the final diff, +and check hosted CI. Saved benchmark timing and memory ratios are historical +observations, not paired performance guarantees. + +Official references: [TypeScript](https://www.typescriptlang.org/docs/handbook/intro.html), +[Vite configuration](https://vite.dev/config/), [Vitest configuration](https://vitest.dev/config/), +[coverage](https://vitest.dev/guide/coverage.html), [Playwright browsers](https://playwright.dev/docs/browsers), +[GitHub matrices](https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/run-job-variations). diff --git a/wiki/releasing.md b/wiki/releasing.md new file mode 100644 index 0000000..859ef8e --- /dev/null +++ b/wiki/releasing.md @@ -0,0 +1,101 @@ +# CI and releases + +Two workflows. CI has a build job and a test matrix: + +| Workflow | Trigger | Result | +| --- | --- | --- | +| `ci.yml` | Pull request or push to main | One build and benchmark; six Node/browser test environments | +| `release.yml` | Successful main-push CI | Automatic npm canary and GitHub prerelease | +| `release.yml` | Manual dispatch | Stable version from a selected successful main CI run | + +Build commands invoke Vite and TypeScript directly. CI and release coordination +lives in the two workflow files; there are no repository script wrappers. + +The Node 24 build job runs lint, type checking, the build, distribution checks +and benchmarks. Six Ubuntu matrix rows then test the same package archive: +Node 22/24/26 and Chromium/Firefox/WebKit. Browser rows use Node 24 as their test +driver and install only the selected browser. The matrix does not rebuild the +distribution. Diagnostics identify the Node version and test environment. +The build and every matrix row must succeed before automatic canary publication. +Within a CI run, `build-SHA` and `benchmark-SHA` identify its canonical artifacts. +Rerunning failed tests reuses the successful build; rerunning the build replaces +its artifacts through GitHub's `overwrite` option. + +CI downloads a benchmark baseline only when a report +exists for the exact target SHA; otherwise comparison is skipped. See +[benchmarks](benchmarks.md). All build output, reports, coverage and browser +diagnostics remain CI artifacts. + +## First publication setup + +Establish the public `gothic-lock-solver` npm package and configure a GitHub trusted +publisher for this repository, `release.yml` and the `npm-production` environment. +Enable direct `npm publish` permission in the publisher settings. Follow the +[official npm instructions](https://docs.npmjs.com/trusted-publishers/). +Then set the repository variable `NPM_PUBLICATION_READY=true`. + +The workflow uses OIDC rather than a stored npm token. Actions are pinned to +official releases; GitHub CLI and jq are provided by GitHub's Ubuntu runner. +Public npm, standard public GitHub +runners and jsDelivr require no paid subscription for this setup. + +## Automatic canaries + +A successful CI run for a push to main triggers the release workflow. The release +job checks the selected CI run's workflow, branch, event and success, +then downloads its named `build-SHA` artifact with `gh run download`. +PR artifacts cannot be published. + +Preparation assigns a unique version such as +`1.0.0-canary.RUN_ID.ATTEMPT.SOURCE_SHA_PREFIX`. It changes only the package version +and the build manifest's version, then packs with lifecycle scripts disabled. +Every other archive entry must remain byte-identical to the tested package. +The five runtime files are not rebuilt. + +The package is published under npm's `canary` tag and attached to a GitHub +prerelease. Stable `latest` is unchanged. The full JSON/Markdown benchmark report, +source identity and package/CDN links accompany the release. + +## Manual stable release + +In GitHub Actions, choose **Release → Run workflow** on **main** and provide: + +- `ci_run_id`: the successful main CI run whose tested artifact you reviewed. +- `version`: the stable SemVer to publish, for example `1.0.0`. + +The workflow validates that run and prepares a new package version from its tested +archive. Only the same two version metadata fields may change. npm versions are +immutable: assigning the `latest` tag to a canary would not turn its prerelease +version into a stable SemVer, so stable delivery needs this metadata-only repack. +The resulting npm version uses `latest` and a regular GitHub Release. + +If the artifact expired, run CI again before releasing. No source rebuild or +fallback artifact is hidden inside the release workflow. Publication uses ordinary `npm publish` and `gh release create` commands. +Standard npm and GitHub CLI errors stop publication. If delivery +fails after npm succeeds, inspect the job log and published version, then complete the +remaining delivery manually; rerunning the entire job is not an automatic recovery +mechanism. Published npm versions are never overwritten. + +## Browser delivery + +jsDelivr serves the files already published to npm. No CDN account, SDK, Action or +upload is needed. Pin the complete version in integrations: + +```html + + +``` + +CI checks the runtime files in Chromium, Firefox and WebKit and verifies the +installed archive in every matrix row. Release downloads that successful build, +updates version metadata, packs and publishes it through the standard CLIs. The fifth +file is the standalone Node CLI. Release assets include all five runtime files, +the npm archive and the two benchmark reports. + +Official references: [workflow run triggers](https://docs.github.com/en/actions/reference/workflows-and-actions/events-that-trigger-workflows#workflow_run), +[artifact downloads](https://cli.github.com/manual/gh_run_download), +[runner tools](https://github.com/actions/runner-images/blob/main/images/ubuntu/Ubuntu2404-Readme.md), +[npm publishing](https://docs.npmjs.com/cli/commands/npm-publish/), +[jsDelivr npm delivery](https://github.com/jsdelivr/jsdelivr#npm).