diff --git a/.github/actions/setup-node-pnpm/action.yml b/.github/actions/setup-node-pnpm/action.yml deleted file mode 100644 index b7ccd8d..0000000 --- a/.github/actions/setup-node-pnpm/action.yml +++ /dev/null @@ -1,69 +0,0 @@ -name: "⚙️ setup node and pnpm" -description: "install the dev toolchain and deps" - -inputs: - cache: - description: "Restore pnpm caches" - required: false - default: "false" - -outputs: - store: - description: "pnpm store path" - value: ${{ steps.paths.outputs.store }} - metadata: - description: "pnpm metadata cache path" - value: ${{ steps.paths.outputs.metadata }} - cache-hit: - description: "Whether cache was restored" - value: ${{ steps.cache.outputs.cache-hit }} - cache-key: - description: "Dependency cache key" - value: ${{ steps.cache.outputs.cache-primary-key }} - -runs: - using: composite - steps: - - name: "⚙️ set up node" - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version-file: package.json - package-manager-cache: false - - - name: "⚙️ set up pnpm" - uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0 - with: - package_json_file: package.json - run_install: false - cache: false - - - name: "📂 locate pnpm caches" - id: paths - if: inputs.cache == 'true' - shell: bash - run: | - store=$(pnpm store path --silent) - metadata=$(pnpm cache path) - - for path in "$store" "$metadata"; do - if [[ "$path" != /* || "$path" == *$'\n'* ]]; then - echo "Expected a single absolute pnpm cache path" >&2 - exit 1 - fi - done - - printf 'store=%s\nmetadata=%s\n' "$store" "$metadata" >> "$GITHUB_OUTPUT" - - - name: "📦 restore pnpm caches" - id: cache - if: inputs.cache == 'true' - uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: | - ${{ steps.paths.outputs.store }} - ${{ steps.paths.outputs.metadata }} - key: pnpm-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('pnpm-lock.yaml', 'package.json', 'pnpm-workspace.yaml') }} - - - name: "📥 install dependencies" - shell: bash - run: pnpm install --frozen-lockfile diff --git a/.github/rulesets/main.json b/.github/rulesets/main.json index 632f392..114c7f3 100644 --- a/.github/rulesets/main.json +++ b/.github/rulesets/main.json @@ -28,7 +28,10 @@ "type": "required_status_checks", "parameters": { "strict_required_status_checks_policy": true, - "required_status_checks": [{ "context": "🧪 check", "integration_id": 15368 }] + "required_status_checks": [ + { "context": "🧪 check", "integration_id": 15368 }, + { "context": "📊 compare benchmarks", "integration_id": 15368 } + ] } } ] diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml new file mode 100644 index 0000000..92a44d8 --- /dev/null +++ b/.github/workflows/benchmark.yml @@ -0,0 +1,58 @@ +name: "📊 benchmark" + +on: + pull_request: + +permissions: + contents: read + +concurrency: + group: benchmark-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + benchmark: + name: "📊 compare benchmarks" + runs-on: ubuntu-24.04 + timeout-minutes: 15 + + steps: + - name: "📥 check out head" + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: ${{ github.event.pull_request.head.repo.full_name }} + ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false + + - name: "⚙️ set up node and pnpm" + uses: devlsh/tools/github/setup@5a43d10d4535a7cf1368acd9313e70d417ac6235 + + - name: "📥 check out base" + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: ${{ github.event.pull_request.base.repo.full_name }} + ref: ${{ github.event.pull_request.base.sha }} + path: benchmark-base + persist-credentials: false + + - name: "📥 install base dependencies" + working-directory: benchmark-base + run: pnpm install --frozen-lockfile + + - name: "📦 build base" + working-directory: benchmark-base + run: pnpm build + + - name: "📂 stage base output" + run: | + mkdir -p dist + cp -R benchmark-base/dist/. dist/ + + - name: "📊 capture base benchmark" + run: pnpm bench:full --mode capture --reporter=default + + - name: "📦 build head" + run: pnpm build + + - name: "📊 compare benchmark" + run: pnpm bench:full --mode compare --reporter=default --reporter=github-actions diff --git a/.github/workflows/demo.yml b/.github/workflows/demo.yml index c0e8835..b923ec7 100644 --- a/.github/workflows/demo.yml +++ b/.github/workflows/demo.yml @@ -29,7 +29,7 @@ jobs: persist-credentials: false - name: "⚙️ set up node and pnpm" - uses: ./.github/actions/setup-node-pnpm + uses: devlsh/tools/github/setup@5a43d10d4535a7cf1368acd9313e70d417ac6235 - name: "✅ run checks" run: pnpm check @@ -66,7 +66,7 @@ jobs: persist-credentials: false - name: "⚙️ set up node and pnpm" - uses: ./.github/actions/setup-node-pnpm + uses: devlsh/tools/github/setup@5a43d10d4535a7cf1368acd9313e70d417ac6235 - name: "📦 build demo" run: pnpm demo build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index addcb57..cbdcc17 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -73,7 +73,7 @@ jobs: persist-credentials: false - name: "⚙️ set up node and pnpm" - uses: ./.github/actions/setup-node-pnpm + uses: devlsh/tools/github/setup@5a43d10d4535a7cf1368acd9313e70d417ac6235 - name: "📦 publish" run: pnpm publish --access public --tag latest --provenance --no-git-checks diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 968fbd3..f5f35fa 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -28,9 +28,7 @@ jobs: - name: "⚙️ set up node and pnpm" id: setup - uses: ./.github/actions/setup-node-pnpm - with: - cache: "true" + uses: devlsh/tools/github/setup@5a43d10d4535a7cf1368acd9313e70d417ac6235 - name: "✅ run checks" run: pnpm check diff --git a/.openchamber/.gitignore b/.openchamber/.gitignore new file mode 100644 index 0000000..d2c874d --- /dev/null +++ b/.openchamber/.gitignore @@ -0,0 +1,3 @@ +* +!.gitignore +!project.json diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1ee119e..927a558 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -65,6 +65,67 @@ pnpm demo build For demo behavior changes, run `pnpm demo dev` and inspect the affected controls, terrain and endpoint edits, search options, resizing, and cleanup in the browser. See the [demo guide](demo/README.md) for its controls. Library checks do not replace demo checks. +## Benchmarks + +The native Vitest benchmark measures the minified package export, not transformed source. Build current source before each benchmark session: + +```sh +pnpm build +``` + +Run either portfolio with the direct Vitest conveniences: + +```sh +pnpm bench:quick +pnpm bench:full +``` + +Quick has 12 workloads and 48 prebuilt inputs. Full has 27 workloads and 108 prebuilt inputs. Each workload has four deterministic rotations. Benchmarks measure performance only and do not validate path correctness. + +Domain files in `bench/fixtures/` declare grid factories and search options. The shared portfolio creates fresh grids and endpoint copies before it prepares the four rotations. Stable workload IDs identify reference files. A changed workload needs a new ID and a new comparison series. + +Run the quick cases with an adjacent target: + +```sh +pnpm bench:quick --reporter=default -t 'Adjacent target' +``` + +Run `pnpm test` for the existing library correctness tests. These tests use the source public export and need no build. + +The initial settings are provisional minimum floors, not deadlines: + +- Quick: 250 ms warmup, at least 16 warmup iterations, 250 ms measurement, and at least 32 measured iterations. +- Full: 250 ms warmup, at least 4 warmup iterations, 250 ms measurement, and at least 32 measured iterations. + +The process targets are approximately 10 seconds and 60 seconds. Slow callbacks can exceed these targets to reach the iteration floors. Full uses fewer samples to reduce benchmark compute cost, with less statistical precision. These settings do not guarantee a total runtime or change any timeout. Do not reduce existing workloads or change production code to meet these targets. + +Each native result measures a batch of four searches. Native `hz` counts batches per second. Native p99 describes a batch, not a single search. The four rotations are inputs, not independent statistical samples. Allocation and normal GC remain inside search timings. + +Divide mean latency by four for mean latency per search. Multiply native `hz` by four for searches per second. + +Capture a local reference only when you intend to create or replace it. Then display the native comparison: + +```sh +pnpm bench:quick --mode capture +pnpm bench:quick --mode compare --reporter=default +``` + +Use `bench:full` instead of `bench:quick` for a full reference. + +When full sampling settings change, capture a fresh local full reference before comparison, even when workload IDs stay unchanged. + +Native reference files do not record sampling settings, so comparison cannot detect this mismatch. Quick references need no replacement for a full-only sampling change. CI captures a fresh base reference with the head harness for each paired run. + +Check warmup sensitivity with the same unchanged build: + +```sh +pnpm bench:quick --mode double-warmup --reporter=default +``` + +This mode doubles warmup time without changing measurement floors or reference files. + +Use `bench:full` for the full control. Repeat ordinary runs manually on a quiet host. Inspect sample counts and spread across fresh processes. + ## Pull Requests Search existing issues and PRs before proposing duplicate work. Keep your change focused and update affected callers, tests, [README examples](README.md#usage), and contributor instructions together. diff --git a/bench.config.ts b/bench.config.ts new file mode 100644 index 0000000..b5b1bd0 --- /dev/null +++ b/bench.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig(({ mode }) => ({ + test: { + benchmark: { include: ['bench/**/*.bench.ts'] }, + fileParallelism: false, + maxWorkers: 1, + projects: (['quick', 'full'] as const).map((name) => ({ + extends: true, + test: { + name, + pool: 'forks', + testTimeout: 0, + provide: { + benchmarkSuite: name, + benchmarkMode: mode, + }, + }, + })), + }, +})); diff --git a/bench/fixtures/corridor.ts b/bench/fixtures/corridor.ts new file mode 100644 index 0000000..1bb2c28 --- /dev/null +++ b/bench/fixtures/corridor.ts @@ -0,0 +1,24 @@ +import { grid } from '../helpers'; +import { type CaseDefinition } from './types'; + +export const cases: CaseDefinition[] = [ + { + id: 'corridor.128.numeric.default', + name: 'Straight corridor, end to end | 128×1 | numeric tiles | four-way | default heuristic', + quick: true, + grid: () => grid(128, 1), + options: { + from: [0, 0], + to: [127, 0], + }, + }, + { + id: 'corridor.1024.numeric.default', + name: 'Straight corridor, end to end | 1024×1 | numeric tiles | four-way | default heuristic', + grid: () => grid(1024, 1), + options: { + from: [0, 0], + to: [1023, 0], + }, + }, +]; diff --git a/bench/fixtures/index.ts b/bench/fixtures/index.ts new file mode 100644 index 0000000..b525a61 --- /dev/null +++ b/bench/fixtures/index.ts @@ -0,0 +1,62 @@ +import { rotate } from '../helpers'; +import { type SearchOptions } from '../../src/types'; +import { cases as corridor } from './corridor'; +import { cases as maze } from './maze'; +import { randomCases, disconnected, corners } from './obstacles'; +import { cases as open } from './open'; +import { cases as rooms } from './rooms'; +import { cases as terrain } from './terrain'; +import { cheap, representation } from './tiles'; +import { type Suite, type Workload } from './types'; + +/** + * Stable portfolio order. A changed workload requires a new ID and a new series. + */ +const cases = [ + ...cheap, + ...open, + ...corridor, + ...maze, + ...rooms, + ...randomCases, + ...disconnected, + ...corners, + ...terrain, + ...representation, +]; + +export function select(suite: Suite) { + return cases + .values() + .filter((entry) => suite === 'full' || entry.quick) + .map(({ id }) => ({ id })) + .toArray(); +} + +/** + * Build fresh inputs outside timing. Numeric obstacles use the package's -1 sentinel. + */ +export function fixtures(): Workload[] { + return cases.map(({ id, name, grid, options }) => { + const queries: SearchOptions[] = [ + { + ...options, + grid: grid(), + from: [...options.from], + to: [...options.to], + }, + ]; + + for (let rotation = 1; rotation < 4; rotation++) { + queries.push(rotate(queries[rotation - 1])); + } + + return { + id, + name, + queries, + }; + }); +} + +export { type Suite, type Workload } from './types'; diff --git a/bench/fixtures/maze.ts b/bench/fixtures/maze.ts new file mode 100644 index 0000000..c475f48 --- /dev/null +++ b/bench/fixtures/maze.ts @@ -0,0 +1,83 @@ +import { type Vector } from '../../src/types'; +import { grid, random, shuffle } from '../helpers'; +import { type CaseDefinition } from './types'; + +export const cases: CaseDefinition[] = [ + { + id: 'maze.33.numeric.card-manhattan', + name: 'Maze, opposite interior corners | 33×33 | numeric tiles | four-way | Manhattan heuristic', + quick: true, + grid: () => maze(33), + options: { + from: [1, 1], + to: [31, 31], + heuristic: 'manhattan', + }, + }, + { + id: 'maze.65.numeric.card-manhattan', + name: 'Maze, opposite interior corners | 65×65 | numeric tiles | four-way | Manhattan heuristic', + grid: () => maze(65), + options: { + from: [1, 1], + to: [63, 63], + heuristic: 'manhattan', + }, + }, +]; + +/** + * Iterative DFS shuffles north, east, south, west once on entry to each cell. + */ +function maze(size: number) { + const cells = grid(size, size, -1); + const next = random(0xa57a0002); + + const directions = (): Vector[] => + shuffle( + [ + [0, -2], + [2, 0], + [0, 2], + [-2, 0], + ], + next, + ); + + const stack = [ + { + x: 1, + y: 1, + directions: directions(), + }, + ]; + + cells[1][1] = 0; + + for (let cell = stack.at(-1); cell; cell = stack.at(-1)) { + const direction = cell.directions.shift(); + + if (!direction) { + stack.pop(); + continue; + } + + const [dx, dy] = direction; + const x = cell.x + dx; + const y = cell.y + dy; + + if (x <= 0 || y <= 0 || x >= size - 1 || y >= size - 1 || cells[y][x] === 0) { + continue; + } + + cells[y][x] = 0; + cells[cell.y + dy / 2][cell.x + dx / 2] = 0; + stack.push({ + x, + y, + directions: directions(), + }); + } + + return cells; +} diff --git a/bench/fixtures/obstacles.ts b/bench/fixtures/obstacles.ts new file mode 100644 index 0000000..b28a525 --- /dev/null +++ b/bench/fixtures/obstacles.ts @@ -0,0 +1,113 @@ +import { type Vector } from '../../src/types'; +import { chebyshev, grid, random, shuffle } from '../helpers'; +import { type CaseDefinition } from './types'; + +export const randomCases: CaseDefinition[] = [ + { + id: 'random.32.p20.numeric.card-manhattan', + name: 'Random obstacles, about 20%, corner to corner | 32×32 | numeric tiles | four-way | Manhattan heuristic', + grid: () => obstacles(204), + options: { + from: [0, 0], + to: [31, 31], + heuristic: 'manhattan', + }, + }, + { + id: 'random.32.p35.numeric.card-manhattan', + name: 'Random obstacles, about 35%, corner to corner | 32×32 | numeric tiles | four-way | Manhattan heuristic', + quick: true, + grid: () => obstacles(358), + options: { + from: [0, 0], + to: [31, 31], + heuristic: 'manhattan', + }, + }, +]; + +export const disconnected: CaseDefinition[] = [ + { + id: 'disconnected.32.numeric.card-default', + name: 'Solid divider, no route | 32×32 | numeric tiles | four-way | default heuristic', + quick: true, + grid: () => divided(32), + options: { + from: [0, 0], + to: [31, 31], + }, + }, + { + id: 'disconnected.64.numeric.card-default', + name: 'Solid divider, no route | 64×64 | numeric tiles | four-way | default heuristic', + grid: () => divided(64), + options: { + from: [0, 0], + to: [63, 63], + }, + }, +]; + +export const corners: CaseDefinition[] = [ + { + id: 'corners.33.numeric.diag-chebyshev-cut', + name: 'Corner obstacles, corner to corner | 33×33 | numeric tiles | diagonal moves | Chebyshev heuristic | corner cutting allowed', + grid: cornerGrid, + options: { + from: [0, 0], + to: [32, 32], + diagonal: true, + heuristic: chebyshev, + cutCorners: true, + }, + }, + { + id: 'corners.33.numeric.diag-chebyshev-strict', + name: 'Corner obstacles, corner to corner | 33×33 | numeric tiles | diagonal moves | Chebyshev heuristic | corner cutting blocked', + quick: true, + grid: cornerGrid, + options: { + from: [0, 0], + to: [32, 32], + diagonal: true, + heuristic: chebyshev, + cutCorners: false, + }, + }, +]; + +/** + * Seeded obstacle prefixes are nested. The top row and right column stay open. + */ +function obstacles(count: number) { + const cells = grid(32); + const coordinates: Vector[] = []; + + for (let y = 1; y < 32; y++) { + for (let x = 0; x < 31; x++) { + coordinates.push([x, y]); + } + } + + shuffle(coordinates, random(0xa57a0001)); + + for (const [x, y] of coordinates.slice(0, count)) { + cells[y][x] = -1; + } + + return cells; +} + +function divided(size: number) { + const cells = grid(size); + + for (const row of cells) { + row[Math.floor(size / 2)] = -1; + } + + return cells; +} + +function cornerGrid() { + return grid(33).map((row, y) => row.map((_, x) => (x % 4 === 1 && y % 4 === 0 ? -1 : 0))); +} diff --git a/bench/fixtures/open.ts b/bench/fixtures/open.ts new file mode 100644 index 0000000..5d7f75d --- /dev/null +++ b/bench/fixtures/open.ts @@ -0,0 +1,78 @@ +import { chebyshev, grid, zero } from '../helpers'; +import { type CaseDefinition } from './types'; + +export const cases: CaseDefinition[] = [ + open({ + id: 'open.32.numeric.card-default', + name: 'Open grid, corner to corner | 32×32 | numeric tiles | four-way | default heuristic', + size: 32, + quick: true, + }), + open({ + id: 'open.64.numeric.card-default', + name: 'Open grid, corner to corner | 64×64 | numeric tiles | four-way | default heuristic', + size: 64, + }), + open({ + id: 'open.32.numeric.card-manhattan', + name: 'Open grid, corner to corner | 32×32 | numeric tiles | four-way | Manhattan heuristic', + size: 32, + quick: true, + options: { + heuristic: 'manhattan', + }, + }), + open({ + id: 'open.64.numeric.card-manhattan', + name: 'Open grid, corner to corner | 64×64 | numeric tiles | four-way | Manhattan heuristic', + size: 64, + options: { + heuristic: 'manhattan', + }, + }), + open({ + id: 'open.32.numeric.diag-default', + name: 'Open grid, corner to corner | 32×32 | numeric tiles | diagonal moves | default heuristic', + size: 32, + options: { + diagonal: true, + }, + }), + open({ + id: 'open.32.numeric.diag-chebyshev', + name: 'Open grid, corner to corner | 32×32 | numeric tiles | diagonal moves | Chebyshev heuristic', + size: 32, + quick: true, + options: { + diagonal: true, + heuristic: chebyshev, + }, + }), + open({ + id: 'open.32.numeric.card-zero', + name: 'Open grid, corner to corner | 32×32 | numeric tiles | four-way | custom zero heuristic', + size: 32, + options: { + heuristic: zero, + }, + }), +]; + +interface Options extends Pick { + size: number; + options?: Omit; +} + +function open({ id, name, size, quick, options = {} }: Options): CaseDefinition { + return { + id, + name, + quick, + grid: () => grid(size), + options: { + ...options, + from: [0, 0], + to: [size - 1, size - 1], + }, + }; +} diff --git a/bench/fixtures/rooms.ts b/bench/fixtures/rooms.ts new file mode 100644 index 0000000..e98c89a --- /dev/null +++ b/bench/fixtures/rooms.ts @@ -0,0 +1,49 @@ +import { grid } from '../helpers'; +import { type CaseDefinition } from './types'; + +export const cases: CaseDefinition[] = [ + { + id: 'rooms.33.numeric.card-manhattan', + name: 'Rooms with doors, opposite interior corners | 33×33 | numeric tiles | four-way | Manhattan heuristic', + grid: () => rooms(33), + options: { + from: [1, 1], + to: [31, 31], + heuristic: 'manhattan', + }, + }, + { + id: 'rooms.65.numeric.card-manhattan', + name: 'Rooms with doors, opposite interior corners | 65×65 | numeric tiles | four-way | Manhattan heuristic', + grid: () => rooms(65), + options: { + from: [1, 1], + to: [63, 63], + heuristic: 'manhattan', + }, + }, +]; + +/** + * Block the border and wall intersections. Each eight-cell wall segment has one + * door at offset 1 or 7, alternating by wall and segment indexes. + */ +function rooms(size: number) { + return grid(size).map((row, y) => + row.map((_, x) => { + if (x === 0 || y === 0 || x === size - 1 || y === size - 1 || (x % 8 === 0 && y % 8 === 0)) { + return -1; + } + + if (x % 8 === 0) { + return y % 8 === ((x / 8 + Math.floor(y / 8)) % 2 === 0 ? 1 : 7) ? 0 : -1; + } + + if (y % 8 === 0) { + return x % 8 === ((y / 8 + Math.floor(x / 8)) % 2 === 0 ? 1 : 7) ? 0 : -1; + } + + return 0; + }), + ); +} diff --git a/bench/fixtures/terrain.ts b/bench/fixtures/terrain.ts new file mode 100644 index 0000000..51a4f4e --- /dev/null +++ b/bench/fixtures/terrain.ts @@ -0,0 +1,60 @@ +import { chebyshev, grid } from '../helpers'; +import { type CaseDefinition } from './types'; + +export const cases: CaseDefinition[] = [ + boundary({ + id: 'terrain.33.numeric.card-step1', + name: 'Elevation boundary with one ramp | 33×33 | numeric tiles | four-way | Manhattan heuristic | elevation limit 1', + quick: true, + options: { + heuristic: 'manhattan', + stepHeight: 1, + }, + }), + boundary({ + id: 'terrain.33.numeric.card-step2', + name: 'Elevation boundary with one ramp | 33×33 | numeric tiles | four-way | Manhattan heuristic | elevation limit 2', + options: { + heuristic: 'manhattan', + stepHeight: 2, + }, + }), + boundary({ + id: 'terrain.33.numeric.diag-step1-strict', + name: 'Elevation boundary with one ramp | 33×33 | numeric tiles | diagonal moves | Chebyshev heuristic | elevation limit 1 | corner cutting blocked', + options: { + diagonal: true, + heuristic: chebyshev, + cutCorners: false, + stepHeight: 1, + }, + }), +]; + +/** + * The elevation boundary has one ramp at [16,31], away from the direct route. + */ +function terrain() { + const cells: number[][] = grid(33).map((row) => row.map((_, x) => (x < 16 ? 0 : 2))); + cells[31][16] = 1; + + return cells; +} + +interface Options extends Pick { + options: Omit; +} + +function boundary({ id, name, quick, options }: Options): CaseDefinition { + return { + id, + name, + quick, + grid: terrain, + options: { + ...options, + from: [1, 1], + to: [31, 1], + }, + }; +} diff --git a/bench/fixtures/tiles.ts b/bench/fixtures/tiles.ts new file mode 100644 index 0000000..c4f1eb2 --- /dev/null +++ b/bench/fixtures/tiles.ts @@ -0,0 +1,79 @@ +import { grid } from '../helpers'; +import { type CaseDefinition } from './types'; + +export const cheap: CaseDefinition[] = [ + { + id: 'cheap.8.numeric.default', + name: 'Adjacent target | 8×8 | numeric tiles | four-way | default heuristic', + quick: true, + grid: () => grid(8), + options: { + from: [3, 3], + to: [4, 3], + }, + }, + { + id: 'cheap.8.object-minimal.default', + name: 'Adjacent target | 8×8 | objects (elevation only) | four-way | default heuristic', + quick: true, + grid: () => grid(8).map((row) => row.map(() => ({ elevation: 0 }))), + options: { + from: [3, 3], + to: [4, 3], + }, + }, +]; + +export const representation: CaseDefinition[] = [ + { + id: 'open.32.object-full.card-default', + name: 'Open grid, corner to corner | 32×32 | full tile objects | four-way | default heuristic', + grid: () => fullTiles(32), + options: { + from: [0, 0], + to: [31, 31], + }, + }, + { + id: 'open.32.mixed.card-default', + name: 'Open grid, corner to corner | 32×32 | numeric tiles and full tile objects | four-way | default heuristic', + quick: true, + grid: () => fullTiles(32, true), + options: { + from: [0, 0], + to: [31, 31], + }, + }, + { + id: 'destination.8.object-full.default', + name: 'Adjacent target | 8×8 | full tile objects | four-way | default heuristic | invalid tile allowed as destination', + grid: () => { + const cells = fullTiles(8); + cells[3][4] = { + elevation: 0, + isLegal: false, + validAsDestination: true, + }; + + return cells; + }, + options: { + from: [3, 3], + to: [4, 3], + }, + }, +]; + +function fullTiles(size: number, mixed = false) { + return grid(size).map((row, y) => + row.map((_, x) => + mixed && (x + y) % 2 === 0 + ? 0 + : { + elevation: 0, + isLegal: true, + validAsDestination: false, + }, + ), + ); +} diff --git a/bench/fixtures/types.ts b/bench/fixtures/types.ts new file mode 100644 index 0000000..78a7fea --- /dev/null +++ b/bench/fixtures/types.ts @@ -0,0 +1,17 @@ +import { type Grid, type SearchOptions } from '../../src'; + +export interface CaseDefinition { + id: string; + name: string; + quick?: boolean; + grid: () => Grid; + options: Omit; +} + +export interface Workload { + id: string; + name: string; + queries: SearchOptions[]; +} + +export type Suite = 'quick' | 'full'; diff --git a/bench/helpers.ts b/bench/helpers.ts new file mode 100644 index 0000000..d177210 --- /dev/null +++ b/bench/helpers.ts @@ -0,0 +1,54 @@ +import { type Grid, type SearchOptions, type Vector } from '../src/types'; + +/** + * xorshift32 with unsigned output. Seeds and shuffle order define the corpus. + */ +export function random(seed: number) { + let state = seed; + + return () => { + state ^= state << 13; + state ^= state >>> 17; + state ^= state << 5; + + return (state >>> 0) / 0x1_0000_0000; + }; +} + +export function shuffle(items: T[], next: () => number) { + for (let i = items.length - 1; i > 0; i--) { + const j = Math.floor(next() * (i + 1)); + [items[i], items[j]] = [items[j], items[i]]; + } + + return items; +} + +export function grid(width: number, height = width, elevation = 0): Grid { + return Array.from({ length: height }, () => Array.from({ length: width }, () => elevation)); +} + +/** + * Rotate rectangular maps clockwise, including corridor endpoints. + */ +export function rotate(query: SearchOptions): SearchOptions { + const height = query.grid.length; + const width = query.grid[0].length; + + const cells = Array.from({ length: width }, (_, y) => + Array.from({ length: height }, (_, x) => query.grid[height - 1 - x][y]), + ); + + const point = ([x, y]: Vector): Vector => [height - 1 - y, x]; + + return { + ...query, + grid: cells, + from: point(query.from), + to: point(query.to), + }; +} + +export const chebyshev = (from: Vector, to: Vector) => Math.max(Math.abs(to[0] - from[0]), Math.abs(to[1] - from[1])); + +export const zero = () => 0; diff --git a/bench/search.bench.ts b/bench/search.bench.ts new file mode 100644 index 0000000..00897de --- /dev/null +++ b/bench/search.bench.ts @@ -0,0 +1,60 @@ +import { search as publicSearch } from '@devlsh/astar'; +import { inject, test } from 'vitest'; +import { fixtures, select, type Suite } from './fixtures'; + +declare module 'vitest' { + interface ProvidedContext { + benchmarkSuite: Suite; + benchmarkMode: string; + } +} + +const selection = inject('benchmarkSuite'); + +const mode = inject('benchmarkMode'); + +const search = publicSearch; + +const config = { + warmupTime: 250 * (mode === 'double-warmup' ? 2 : 1), + warmupIterations: selection === 'quick' ? 16 : 4, + time: 250, + iterations: 32, +}; + +const workloads = fixtures(); + +const selected = new Set(select(selection).map((entry) => entry.id)); + +for (const workload of workloads) { + if (!selected.has(workload.id)) { + continue; + } + + const { queries } = workload; + let sink = 0; + + test(workload.name, async ({ bench }) => { + const baseline = `.vitest/astar/${selection}/${workload.id}.json`; + const candidate = `.vitest/astar/${selection}/current/${workload.id}.json`; + + const current = bench( + 'current (4 searches)', + { + async: false, + writeResult: mode === 'capture' ? baseline : mode === 'compare' ? candidate : undefined, + }, + () => { + for (const query of queries) { + const result = search(query); + const end = result?.at(-1); + sink += result ? result.length + (end?.[0] ?? 0) + (end?.[1] ?? 0) + 1 : 1; + } + }, + ); + + await (mode === 'compare' + ? bench.compare(bench.from('baseline (4 searches)', baseline), current, config) + : current.run(config)); + }); +} diff --git a/docs/development.md b/docs/development.md index f4e5299..ebf84f2 100644 --- a/docs/development.md +++ b/docs/development.md @@ -100,7 +100,15 @@ Inspect manifest scripts/composition; keep related scripts/config/lock changes t [Demo instructions](../demo/AGENTS.md) own the canvas consumer, lifecycle, and deployment packaging. The demo imports library source, not package output. [demo.yml](../.github/workflows/demo.yml) owns credential-free demo validation and main-only deployment through the `production` environment. Local configuration does not prove hosted readiness or authorize deployment. Refresh this routing when the demo owners or check composition change. -[validate.yml](../.github/workflows/validate.yml) owns CI; [setup-node-pnpm](../.github/actions/setup-node-pnpm/action.yml) selects native `devEngines`/frozen installation with hook setup on cache hits. CI runs without Nix/devenv; local setup remains unchanged. Local workflows prove neither hosted protection/required checks nor OIDC readiness; refresh projections when executable owners change. +[validate.yml](../.github/workflows/validate.yml) owns ordinary CI validation. CI runs without Nix/devenv. Local setup remains unchanged. + +The PR-only [benchmark.yml](../.github/workflows/benchmark.yml) owns paired benchmark execution, separate from validation. Its benchmark job captures the event base and compares the event head on one runner with the head harness. Each revision uses its own frozen lockfile under the head-selected toolchain. + +Native reference and candidate files stay in the job workspace, with no uploads or measurement history. The **📊 compare benchmarks** job output shows the native comparison table. Capture uses the native default reporter. Comparison explicitly selects the native default and GitHub Actions reporters. The GitHub Actions reporter supplies failure annotations and a test summary, not benchmark metrics in the job summary. The ordinary validation job remains separate. Pins, permissions, and steps belong to the workflow, not this projection. + +Reserve `.scratch/` for local development, never CI. + +Local workflows do not prove hosted protection, required checks, or OIDC readiness. Refresh these projections when their executable owners change. ### Validation Selection @@ -111,6 +119,7 @@ Select cumulatively by change type and every affected interface. Repository comm - **Pathfinding** - At the public seam, exercise affected grid validation, cardinal/diagonal movement, corner cutting, elevation limits, built-in/custom heuristics, illegal destinations, and null paths. [tests/astar.test.ts](../tests/astar.test.ts) owns established search behavior checks. Use `pnpm test -- tests` and full `pnpm test`; refresh this selection when search contracts change. - **Demo** - Add [scoped checks](../demo/AGENTS.md#checks) for affected controls, terrain and endpoint edits, search feedback, resizing, and lifecycle; library checks do not replace them. - **Packaging** - `pnpm build` for changed entrypoints, output/declarations, or built consumer imports, not default static validation. +- **Benchmarks** - [CONTRIBUTING benchmarks](../CONTRIBUTING.md#benchmarks) owns native commands, fixture descriptors, capture/compare files, interpretation, and paired CI transfer limits. [bench.config.ts](../bench.config.ts) owns quick/full projects and limits benchmark discovery to root `bench/**/*.bench.ts` files. This scope excludes the nested CI base checkout without custom exclusion patterns. [bench/search.bench.ts](../bench/search.bench.ts) measures the built public export without path-correctness validation. Build current source before local benchmarks. Root typechecking includes `bench`, and `pnpm test` owns existing correctness checks. No custom runner, thresholds, correctness preflight, or production optimization belongs to benchmark work. - **Source/exports** - Typecheck/behavior plus consumer checks for imports/exports/shared types/values. - **Tooling** - Exercise accepted/rejected inputs and fixes through the tool; types do not prove diagnostics. - **Generated contracts** - Regenerate from source and verify every affected consumer, not only producer. diff --git a/docs/releasing.md b/docs/releasing.md index 878a731..f4697d1 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -29,7 +29,7 @@ Manual `workflow_dispatch` permits only `prepare`/`publish` on `main`, rejecting 2. Review against `main`, including version/changelog; complete [PR validation](development.md#pull-request-operations) and approved maintainer merge. The version change is already in the PR. 3. Dispatch `operation: publish` on `main` after merging the reviewed stable release PR. Release Please creates GitHub releases for merged pending PRs with PR creation disabled. There is no version input or custom candidate selection. 4. Inspect root-package `release_created`, `sha`, `tag_name`, and `version`. npm publication requires `release_created == 'true'` and checks out the new release's immutable `sha`. An existing GitHub release alone does not satisfy this gate. -5. [setup-node-pnpm](../.github/actions/setup-node-pnpm/action.yml) installs frozen dependencies. [release.yml](../.github/workflows/release.yml) runs `pnpm publish --access public --tag latest --provenance --no-git-checks` directly on the runner, retaining OIDC, without staging publication. `--no-git-checks` allows detached checkout. `prepack: pnpm build` builds checked-out source; installation retains `prepare` Lefthook setup. Neither lifecycle receives GitHub API/npm tokens; publication sets no user configuration. Only the publish job receives `id-token: write`. +5. [release.yml](../.github/workflows/release.yml) runs `pnpm publish --access public --tag latest --provenance --no-git-checks` directly on the runner, retaining OIDC, without staging publication. `--no-git-checks` allows detached checkout. `prepack: pnpm build` builds checked-out source; installation retains `prepare` Lefthook setup. Neither lifecycle receives GitHub API/npm tokens; publication sets no user configuration. Only the publish job receives `id-token: write`. 6. Inspect the complete run; confirm completion from live GitHub release/tag target and npm version/`latest` state. Release Please labels change during GitHub release creation, before npm publication, and do not prove completion. No post-publication registry verification runs. Metadata and provenance configuration do not verify tarball contents. Offline checks cannot prove runtime behavior, hosted settings, trust readiness, or publication success. diff --git a/package.json b/package.json index 3b27e08..660803e 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,8 @@ "access": "public" }, "scripts": { + "bench:full": "vitest bench --run --config bench.config.ts --project 'full (bench)'", + "bench:quick": "vitest bench --run --config bench.config.ts --project 'quick (bench)'", "build": "tsdown", "check": "pnpm typecheck && pnpm lint && pnpm fmt:check", "demo": "pnpm --filter @devlsh/astar-demo", diff --git a/tsconfig.json b/tsconfig.json index d064a42..8d47ad2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,10 +4,13 @@ "compilerOptions": { "module": "esnext", "moduleResolution": "bundler", + "paths": { + "@devlsh/astar": ["./src/index.ts"] + }, "allowImportingTsExtensions": true, "noEmit": true, "rootDir": ".", "types": ["node", "vitest/globals"] }, - "include": ["src", "tests", "*.config.ts"] + "include": ["src", "tests", "bench", "*.config.ts"] }