diff --git a/.github/actions/benchmark-capture/action.yml b/.github/actions/benchmark-capture/action.yml new file mode 100644 index 0000000..d4d97c5 --- /dev/null +++ b/.github/actions/benchmark-capture/action.yml @@ -0,0 +1,92 @@ +name: "๐Ÿ“Š capture benchmark" +description: "Capture benchmarks for the base and head." + +inputs: + base-repository: + description: "Repository" + required: true + base-sha: + description: "Base commit SHA" + required: true + head-sha: + description: "Head commit SHA" + required: true + capture-command: + description: "Benchmark Capture command" + required: true + compare-command: + description: "Benchmark Comparison command" + required: true + base-results-path: + description: "Relative base results directory" + required: true + head-results-path: + description: "Relative head results directory" + required: true + +outputs: + artifact-id: + description: "Results Artifact ID" + value: ${{ steps.results.outputs.artifact-id }} + +runs: + using: composite + steps: + - name: "๐Ÿ“ฅ check out base" + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: ${{ inputs.base-repository }} + ref: ${{ inputs.base-sha }} + path: benchmark-base + persist-credentials: false + + - name: "โš™๏ธ set up base" + shell: bash + working-directory: benchmark-base + run: | + pnpm install --frozen-lockfile + pnpm build + + - name: "๐Ÿ“‚ stage base output" + shell: bash + run: | + mkdir -p dist + cp -R benchmark-base/dist/. dist/ + + - name: "๐Ÿ“Š capture base benchmark" + shell: bash + env: + COMMAND: ${{ inputs.capture-command }} + run: bash -eo pipefail -c "$COMMAND" + + - name: "โš™๏ธ set up head" + shell: bash + run: pnpm build + + - name: "๐Ÿ“Š compare benchmark" + shell: bash + env: + COMMAND: ${{ inputs.compare-command }} + run: bash -eo pipefail -c "$COMMAND" + + - name: "๐Ÿ“‚ collect results" + shell: bash + env: + BASE_SHA: ${{ inputs.base-sha }} + HEAD_SHA: ${{ inputs.head-sha }} + BASE_RESULTS_PATH: ${{ inputs.base-results-path }} + HEAD_RESULTS_PATH: ${{ inputs.head-results-path }} + run: | + mkdir -p benchmark-results/base benchmark-results/head + cp "$BASE_RESULTS_PATH"/*.json benchmark-results/base/ + cp "$HEAD_RESULTS_PATH"/*.json benchmark-results/head/ + node -e 'require("node:fs").writeFileSync("benchmark-results/measurement.json", JSON.stringify({base: process.env.BASE_SHA, head: process.env.HEAD_SHA}))' + + - name: "๐Ÿ“ค upload results" + id: results + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: benchmark-results-${{ github.run_attempt }} + path: benchmark-results/ + if-no-files-found: error + retention-days: 1 diff --git a/.github/actions/benchmark-report/action.yml b/.github/actions/benchmark-report/action.yml new file mode 100644 index 0000000..b253a50 --- /dev/null +++ b/.github/actions/benchmark-report/action.yml @@ -0,0 +1,41 @@ +name: "๐Ÿ“ report benchmarks" +description: "Download benchmark results and publish the report" + +inputs: + artifact-id: + description: "Results Artifact ID" + required: true + base-sha: + description: "Base commit SHA" + required: true + head-sha: + description: "Head commit SHA" + required: true + unit: + description: "Mean latency unit label" + default: "ms/operation" + operations-per-sample: + description: "Operations p/sample" + default: "1" + +runs: + using: composite + steps: + - name: "๐Ÿ“ฅ download results" + uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 + with: + artifact-ids: ${{ inputs.artifact-id }} + path: ${{ runner.temp }}/benchmark-results + + - name: "๐Ÿ“ publish results" + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + BASE_SHA: ${{ inputs.base-sha }} + HEAD_SHA: ${{ inputs.head-sha }} + RESULTS: ${{ runner.temp }}/benchmark-results + RESULT_UNIT: ${{ inputs.unit }} + OPERATIONS_PER_SAMPLE: ${{ inputs.operations-per-sample }} + with: + script: | + const { publish } = await import(`${process.env.GITHUB_WORKSPACE}/.github/scripts/benchmark-report.mjs`); + await publish({ github, context, core }); diff --git a/.github/scripts/benchmark-report.mjs b/.github/scripts/benchmark-report.mjs new file mode 100644 index 0000000..8319072 --- /dev/null +++ b/.github/scripts/benchmark-report.mjs @@ -0,0 +1,170 @@ +/* oxlint-disable typescript/no-unsafe-member-access, typescript/no-unsafe-return */ +// Native github-script API objects and parsed JSON have no SDK types here. + +import { readFile, readdir } from 'node:fs/promises'; +import path from 'node:path'; + +export async function publish({ github, context, core }) { + const operationsPerSample = Number(process.env.OPERATIONS_PER_SAMPLE ?? '1'); + const unit = process.env.RESULT_UNIT ?? 'ms/operation'; + + const source = { + pr: context.payload.pull_request.number, + run: context.runId, + number: context.runNumber, + attempt: Number(process.env.GITHUB_RUN_ATTEMPT), + base: process.env.BASE_SHA, + head: process.env.HEAD_SHA, + }; + + const repository = context.repo; + + const skip = (reason) => { + core.notice(`Benchmark report skipped: ${reason}`); + }; + + let rows; + + try { + if (!Number.isFinite(operationsPerSample) || operationsPerSample <= 0) { + skip('operations per sample must be finite and positive'); + + return; + } + + const measurement = await readJson(path.join(process.env.RESULTS, 'measurement.json')); + + if (measurement?.base !== source.base || measurement?.head !== source.head) { + skip('measurement SHAs do not match the producer base and head'); + + return; + } + + const baseDirectory = path.join(process.env.RESULTS, 'base'); + const headDirectory = path.join(process.env.RESULTS, 'head'); + const baseEntries = await readdir(baseDirectory); + const headEntries = await readdir(headDirectory); + const baseFiles = baseEntries.toSorted((a, b) => a.localeCompare(b)); + const headFiles = headEntries.toSorted((a, b) => a.localeCompare(b)); + + if ( + baseFiles.length === 0 || + baseFiles.length !== headFiles.length || + baseFiles.some((file, index) => file !== headFiles[index] || !/^[a-z0-9][a-z0-9.-]{0,95}\.json$/u.test(file)) + ) { + skip('native results must contain matching JSON files with safe workload IDs'); + + return; + } + + rows = []; + + for (const file of baseFiles) { + const baseResult = await readJson(path.join(baseDirectory, file)); + const headResult = await readJson(path.join(headDirectory, file)); + const base = Number.isFinite(baseResult?.latency?.mean) ? baseResult.latency.mean / operationsPerSample : Number.NaN; + const head = Number.isFinite(headResult?.latency?.mean) ? headResult.latency.mean / operationsPerSample : Number.NaN; + const change = (head / base - 1) * 100; + + if (!Number.isFinite(base) || base <= 0 || !Number.isFinite(head) || head <= 0 || !Number.isFinite(change)) { + skip('latency means and normalized values must be finite and positive'); + + return; + } + + rows.push( + `| \`${file.slice(0, -5)}\` | ${base.toPrecision(4)} | ${head.toPrecision(4)} | ${change >= 0 ? '+' : ''}${change.toPrecision(3)}% |`, + ); + } + } catch { + skip('native result JSON is missing, unreadable, or invalid'); + + return; + } + + const marker = ''; + + const comments = await github.paginate(github.rest.issues.listComments, { + ...repository, + issue_number: source.pr, + per_page: 100, + }); + + const matching = comments.filter( + (comment) => + comment.user?.type === 'Bot' && comment.user.login === 'github-actions[bot]' && comment.body?.startsWith(marker), + ); + + if (matching.length > 1) { + skip('multiple bot report comments exist'); + + return; + } + + const comment = matching[0]; + + if (comment) { + const previous = comment.body.match(//u); + + if (!previous) { + skip('the existing report has no trusted run identity'); + + return; + } + + if ( + previous[1] === source.head && + (Number(previous[3]) > source.number || + (Number(previous[3]) === source.number && + (Number(previous[2]) !== source.run || Number(previous[4]) >= source.attempt))) + ) { + skip('an equal or newer report already exists for this head'); + + return; + } + } + + const url = `${context.serverUrl}/${repository.owner}/${repository.repo}`; + + const body = [ + marker, + ``, + '## Benchmark Comparison', + '', + `| Workload | Base, ${unit} | PR, ${unit} | Change |`, + '| --- | ---: | ---: | ---: |', + ...rows, + '', + `[Base ${source.base}](${url}/commit/${source.base}) ยท [PR ${source.head}](${url}/commit/${source.head}) ยท [Run ${source.number}, attempt ${source.attempt}](${url}/actions/runs/${source.run}/attempts/${source.attempt})`, + ].join('\n'); + + const { data: pr } = await github.rest.pulls.get({ + ...repository, + pull_number: source.pr, + }); + + if (pr.state !== 'open' || pr.base.sha !== source.base || pr.head.sha !== source.head) { + skip('PR closed or base/head changed before publication'); + + return; + } + + // GitHub has no compare-and-swap for comments. A PR update can still race this final check and write. + await (comment + ? github.rest.issues.updateComment({ + ...repository, + comment_id: comment.id, + body, + }) + : github.rest.issues.createComment({ + ...repository, + issue_number: source.pr, + body, + })); + + await core.summary.addRaw(body).write(); +} + +async function readJson(file) { + return JSON.parse(await readFile(file, 'utf8')); +} diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index 92a44d8..e65bed0 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -15,6 +15,10 @@ jobs: name: "๐Ÿ“Š compare benchmarks" runs-on: ubuntu-24.04 timeout-minutes: 15 + outputs: + artifact-id: ${{ steps.results.outputs.artifact-id }} + base: ${{ github.event.pull_request.base.sha }} + head: ${{ github.event.pull_request.head.sha }} steps: - name: "๐Ÿ“ฅ check out head" @@ -27,32 +31,46 @@ jobs: - name: "โš™๏ธ set up node and pnpm" uses: devlsh/tools/github/setup@5a43d10d4535a7cf1368acd9313e70d417ac6235 - - name: "๐Ÿ“ฅ check out base" + - name: "๐Ÿ“Š compare benchmarks" + id: results + uses: ./.github/actions/benchmark-capture + with: + base-repository: ${{ github.event.pull_request.base.repo.full_name }} + base-sha: ${{ github.event.pull_request.base.sha }} + head-sha: ${{ github.event.pull_request.head.sha }} + capture-command: pnpm bench:full --mode capture --reporter=default + compare-command: pnpm bench:full --mode compare --reporter=default --reporter=github-actions + base-results-path: .vitest/benchmarks/full + head-results-path: .vitest/benchmarks/full/current + + report: + name: "๐Ÿ“ report benchmarks" + needs: benchmark + if: >- + github.event.pull_request.head.repo.full_name == github.repository && + needs.benchmark.outputs.artifact-id != '' && + needs.benchmark.outputs.base != '' && + needs.benchmark.outputs.head != '' + runs-on: ubuntu-24.04 + permissions: + contents: read + issues: write + steps: + - name: "๐Ÿ“ฅ check out publisher" 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 + ref: ${{ github.event.pull_request.head.sha }} persist-credentials: false + sparse-checkout: | + .github/actions/benchmark-report + .github/scripts/benchmark-report.mjs + sparse-checkout-cone-mode: 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 + - name: "๐Ÿ“ report benchmarks" + uses: ./.github/actions/benchmark-report + with: + artifact-id: ${{ needs.benchmark.outputs.artifact-id }} + base-sha: ${{ needs.benchmark.outputs.base }} + head-sha: ${{ needs.benchmark.outputs.head }} + unit: ms/search + operations-per-sample: "4" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d71583e..0c0216d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -83,7 +83,7 @@ Domain files in `bench/fixtures/` declare grid factories and search options. The Run the quick cases with an adjacent target: ```sh -pnpm bench:quick --reporter=default -t 'Adjacent target' +pnpm bench:quick -t 'Adjacent target' ``` Run `pnpm test` for the existing library correctness tests. These tests use the source public export and need no build. @@ -103,11 +103,27 @@ Capture a local reference only when you intend to create or replace it. Then dis ```sh pnpm bench:quick --mode capture -pnpm bench:quick --mode compare --reporter=default +pnpm bench:quick --mode compare ``` Use `bench:full` instead of `bench:quick` for a full reference. +To compare against a separate checkout, first build and capture the reference in that checkout: + +```sh +pnpm build +pnpm bench:quick --mode capture +pnpm bench:full --mode capture +``` + +Then enter your candidate worktree. Build its current source and compare against the reference checkout: + +```sh +pnpm build +BENCH_BASELINE=/path/to/baseline/repo pnpm bench:quick --mode compare +BENCH_BASELINE=/path/to/baseline/repo pnpm bench:full --mode compare +``` + 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. @@ -115,7 +131,7 @@ Native reference files do not record sampling settings, so comparison cannot det Check warmup sensitivity with the same unchanged build: ```sh -pnpm bench:quick --mode double-warmup --reporter=default +pnpm bench:quick --mode double-warmup ``` This mode doubles warmup time without changing measurement floors or reference files. diff --git a/bench.config.ts b/bench.config.ts index b5b1bd0..b2baffd 100644 --- a/bench.config.ts +++ b/bench.config.ts @@ -1,21 +1,31 @@ +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; 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, +export default defineConfig(({ mode }) => { + const baseline = mode === 'compare' ? process.env.BENCH_BASELINE : undefined; + + return { + 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, + benchmarkBaseline: + baseline === undefined || baseline === '' + ? null + : resolve(fileURLToPath(new URL('.', import.meta.url)), baseline, '.vitest/benchmarks'), + }, }, - }, - })), - }, -})); + })), + }, + }; +}); diff --git a/bench/baseline.ts b/bench/baseline.ts new file mode 100644 index 0000000..932c5c2 --- /dev/null +++ b/bench/baseline.ts @@ -0,0 +1,17 @@ +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { type BaselineData, type BenchFromSource } from 'vitest'; +import { type Suite } from './fixtures'; + +export function baselineFrom(directory: string, suite: Suite, id: string): BenchFromSource { + const path = join(directory, suite, `${id}.json`); + + return async () => { + try { + // SAFETY: References come from native Vitest capture, not arbitrary JSON. + return JSON.parse(await readFile(path, 'utf8')) as BaselineData; + } catch (error) { + throw new Error(`Cannot read BENCH_BASELINE reference "${path}".`, { cause: error }); + } + }; +} diff --git a/bench/search.bench.ts b/bench/search.bench.ts index 00897de..30a95ab 100644 --- a/bench/search.bench.ts +++ b/bench/search.bench.ts @@ -1,11 +1,13 @@ import { search as publicSearch } from '@devlsh/astar'; import { inject, test } from 'vitest'; +import { baselineFrom } from './baseline'; import { fixtures, select, type Suite } from './fixtures'; declare module 'vitest' { interface ProvidedContext { benchmarkSuite: Suite; benchmarkMode: string; + benchmarkBaseline: string | null; } } @@ -13,6 +15,8 @@ const selection = inject('benchmarkSuite'); const mode = inject('benchmarkMode'); +const baselineDirectory = inject('benchmarkBaseline'); + const search = publicSearch; const config = { @@ -35,8 +39,8 @@ for (const workload of workloads) { 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 baseline = `.vitest/benchmarks/${selection}/${workload.id}.json`; + const candidate = `.vitest/benchmarks/${selection}/current/${workload.id}.json`; const current = bench( 'current (4 searches)', @@ -54,7 +58,14 @@ for (const workload of workloads) { ); await (mode === 'compare' - ? bench.compare(bench.from('baseline (4 searches)', baseline), current, config) + ? bench.compare( + bench.from( + 'baseline (4 searches)', + baselineDirectory === null ? baseline : baselineFrom(baselineDirectory, selection, workload.id), + ), + current, + config, + ) : current.run(config)); }); } diff --git a/docs/development.md b/docs/development.md index 9a2653e..a9a14c0 100644 --- a/docs/development.md +++ b/docs/development.md @@ -12,7 +12,7 @@ When public behavior or scope changes, update affected usage examples in [README Select checks from [CONTRIBUTING](../CONTRIBUTING.md#checks) and [package.json](../package.json) for every affected consumer. Include behavior checks for these cases: -- For pathfinding changes, exercise affected contracts through [src/index.ts](../src/index.ts) and [tests/astar.test.ts](../tests/astar.test.ts). Include grid validation, cardinal/diagonal movement, corner cutting, elevation limits, built-in/custom heuristics, illegal destinations, and null paths as affected. +- For pathfinding changes, exercise affected contracts through [src/index.ts](../src/index.ts) and [tests/search.test.ts](../tests/search.test.ts). Include grid validation, cardinal/diagonal movement, corner cutting, elevation limits, built-in/custom heuristics, illegal destinations, and null paths as affected. - For demo changes, add the [scoped checks](../demo/AGENTS.md#checks). Library checks do not replace demo behavior and lifecycle checks. - For benchmark changes, use [CONTRIBUTING benchmarks](../CONTRIBUTING.md#benchmarks). Build current source before benchmarks. Benchmarks do not validate path correctness. - For entrypoint, output, or built consumer changes, run `pnpm build` and verify affected package imports, exports, and types. @@ -22,3 +22,21 @@ Scope automatic fixes and formatting to the authorized files. Report changed fil ### Tracker Operations For tracker work, resolve the exact hosted repository and follow [Questions And Reports](../CONTRIBUTING.md#questions-and-reports). Check current hosted labels before applying them. + +### Benchmark CI + +The PR-only [benchmark.yml](../.github/workflows/benchmark.yml) owns permissions, toolchain setup, producer outputs, benchmark commands, result paths, and the paired head harness. Validation remains separate. The [capture action](../.github/actions/benchmark-capture/action.yml) owns fixed frozen installation, builds, and base `dist` transfer into the head harness. Only capture and comparison commands are customizable. It checks out the base into `benchmark-base`. Each revision uses its own frozen lockfile under the head-selected toolchain. + +The head harness captures the event base and compares the event head on one runner. Capture uses the native default reporter. Comparison uses the native default and GitHub Actions reporters. The compare job shows the native table, failure annotations, and test summary. The capture action collects immediate JSON files from caller-selected paths into artifact directories `base` and `head`. Successful comparison produces an attempt-specific artifact with native results and measurement SHAs, one-day retention, and no measurement history. + +[bench.config.ts](../bench.config.ts) resolves `BENCH_BASELINE` as a checkout root with `.vitest/benchmarks` beneath it. [CONTRIBUTING benchmarks](../CONTRIBUTING.md#benchmarks) owns local capture and comparison procedures. Reserve `.scratch/` for local development, never CI. + +The benchmark job has read-only permissions. The report job has comment-write permission and supports same-repository PRs only. Fork PR reports are unsupported. Same-repository branch authors are trusted repository collaborators who control the workflow and reporter. The [report action](../.github/actions/benchmark-report/action.yml) downloads the exact producer artifact ID from the current run. It invokes [benchmark-report.mjs](../.github/scripts/benchmark-report.mjs) from the exact event head SHA without benchmark execution, dependency installation, or caches. + +Reports require successful benchmarks and non-empty artifact/base/head outputs. Failed-report-only reruns skip the report when those outputs are missing. Output retention for that rerun mode is not guaranteed. A full rerun produces a fresh artifact ID. + +The publisher requires non-empty native JSON sets with equal counts and the same safe workload IDs. Normalized means must be finite and positive. Measurement SHAs must match producer outputs. Before publication, the publisher confirms that the PR remains open with the measured base/head. Invalid, missing, ambiguous, detected stale, or superseded results leave the old comment unchanged. GitHub comment writes have no atomic compare-and-swap, so a PR update can race the final check and write. + +The publisher writes one table to its job summary and one bot-authored sticky comment. Rows show workload IDs, normalized mean latency, and signed `(head/base - 1) * 100` change. The report action defaults to `operations-per-sample: "1"` and `unit: "ms/operation"`. The workflow sets `operations-per-sample: "4"` and `unit: ms/search` to divide native millisecond means by four. The divisor must be finite and positive. The unit is a display label, not a time conversion. + +Positive change means slower. Results are advisory, not a performance gate or a statistical significance claim. Local workflows do not prove hosted protection, required checks, or OIDC readiness. The workflow, composite actions, and publisher own their executable contracts. Refresh this projection when those owners change. diff --git a/src/grid.ts b/src/grid.ts new file mode 100644 index 0000000..60a6d0f --- /dev/null +++ b/src/grid.ts @@ -0,0 +1,71 @@ +import { type Neighbor, type Vector } from './types'; + +export const directions = [ + [-1, 0], + [1, 0], + [0, -1], + [0, 1], + [-1, -1], + [1, 1], + [1, -1], + [-1, 1], +] as const; + +/** + * Returns cardinal neighbors first, then optional diagonals with their two corner cells. + */ +export function neighbors(vector: Vector, diagonals = false) { + const tiles: Neighbor[] = []; + + tiles.push( + [[vector[0] - 1, vector[1]], null], + [[vector[0] + 1, vector[1]], null], + [[vector[0], vector[1] - 1], null], + [[vector[0], vector[1] + 1], null], + ); + + if (diagonals) { + tiles.push( + [ + [vector[0] - 1, vector[1] - 1], + [ + [vector[0], vector[1] - 1], + [vector[0] - 1, vector[1]], + ], + ], + [ + [vector[0] + 1, vector[1] + 1], + [ + [vector[0], vector[1] + 1], + [vector[0] + 1, vector[1]], + ], + ], + [ + [vector[0] + 1, vector[1] - 1], + [ + [vector[0], vector[1] - 1], + [vector[0] + 1, vector[1]], + ], + ], + [ + [vector[0] - 1, vector[1] + 1], + [ + [vector[0], vector[1] + 1], + [vector[0] - 1, vector[1]], + ], + ], + ); + } + + return { + tiles, + total: tiles.length, + }; +} + +/** + * Retains comma-separated identity for coordinates outside the numeric grid-key domain. + */ +export function vectorId(x: number, y: number) { + return `${x},${y}`; +} diff --git a/src/index.ts b/src/index.ts index f1d6815..aafb084 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,12 +1,7 @@ -import { type BuiltinHeuristic, type Heuristic, heuristics } from './heuristics'; -import { - type Neighbor, - type OpenTile, - type ScoreOptions, - type SearchOptions, - type TileBuilderCache, - type Vector, -} from './types'; +import { directions, neighbors, vectorId } from './grid'; +import { calculatePath } from './path'; +import { asc, score } from './scoring'; +import { type CellState, type OpenTile, type SearchOptions, type TileBuilderCache, type Vector } from './types'; export function search(options: SearchOptions) { const heuristic = options.heuristic ?? 'diagonal'; @@ -15,31 +10,67 @@ export function search(options: SearchOptions) { const diagonal = options.diagonal ?? false; // Store the found path and open/closed lists. - const closed: string[] = [vectorId(options.from)]; - const end = vectorId(options.to); + const { from } = options; + const start: Vector = [from[0], from[1]]; + const { to } = options; + const destination: Vector = [to[0], to[1]]; let path: Vector[] | null = null; let open: OpenTile[] = []; + let head = 0; + + // Custom heuristics can mutate retained vectors, so their membership must remain a live scan. + // oxlint-disable-next-line anti-slop/no-runtime-typeof -- SearchOptions permits builtin names and mutable custom callbacks. + const indexed = typeof heuristic !== 'function'; + let finite = true; // Calculate grid limits. if (!Array.isArray(options.grid)) { - throw new Error('non-array grid provided'); + throw new Error('Non-array Grid provided'); } if (options.grid.length === 0 || !Array.isArray(options.grid[0])) { - throw new Error('2 dimensional grid array required'); + throw new Error('2 dimensional Grid array required'); } + const cells = new Map(); const maxX = options.grid[0].length; const maxY = options.grid.length; + const numeric = Number.isSafeInteger(maxX * maxY); + + /** + * In-grid integer coordinates have collision-free numeric keys. Other coordinates retain string identity. + * Records are local to this search and exist only for cells that it visits or reads. + */ + function cellId(vector: Vector) { + const x = vector[0]; + const y = vector[1]; + + return numeric && Number.isInteger(x) && Number.isInteger(y) && x >= 0 && y >= 0 && x < maxX && y < maxY + ? y * maxX + x + : vectorId(x, y); + } - // Helper function to determine the make-up of a Tile object, cached in-memory. - const tileCache: Record = {}; + function state(id: number | string) { + let value = cells.get(id); + + if (!value) { + value = {}; + cells.set(id, value); + } + + return value; + } + + state(cellId(start)).closed = true; + const end = cellId(destination); + + // Helper function to determine the make-up of a Tile object, cached in-memory. function tile(vector: Vector): TileBuilderCache { - const id = vectorId(vector); + const cell = state(cellId(vector)); - if (tileCache[id]) { - return tileCache[id]; + if (cell.tile) { + return cell.tile; } const rawValue = options.grid[vector[1]]?.[vector[0]]; @@ -63,24 +94,18 @@ export function search(options: SearchOptions) { }; } - tileCache[id] = value; + cell.tile = value; return value; } // Helper function to determine legality of a Vector. - function canUse([cell, neighbors]: Neighbor, origin: Vector) { + function canUse(cell: Vector, origin: Vector) { return ( // Make sure this tile is walkable. !isIllegal(cell, origin) && // Don't use closed cells. - !closed.includes(vectorId(cell)) && - // Check the neighboring cells, if diagonal movement. - // There are no neighbors to check. - (neighbors === null || - // If we can cut corners, otherwise if the corners are legal. - cutCorners || - (!isIllegal(neighbors[0], origin, 0) && !isIllegal(neighbors[1], origin, 0))) + !cells.get(cellId(cell))?.closed ); } @@ -109,25 +134,41 @@ export function search(options: SearchOptions) { ); } - // Calculates the neighbors and their scores. + /** + * Expands neighbors in grid direction order, then restores stable score order. + * Score decreases retain the preceding frontier order for ties, rather than discovery order. + */ function traverse(from: OpenTile) { - const { tiles, total } = neighbors(from[0], diagonal); + // The root can have accessor coordinates. Custom callbacks can retain and mutate any vector. + const prepared = !indexed || from[2] === null ? neighbors(from[0], diagonal).tiles : null; + const x = prepared ? 0 : from[0][0]; + const y = prepared ? 0 : from[0][1]; + const total = diagonal ? 8 : 4; + const added: OpenTile[] = []; + let decreased = false; for (let i = 0; i < total; i++) { - const neighbor = tiles[i]; - - if (!neighbor) { - continue; - } - - const name = vectorId(neighbor[0]); + const [dx, dy] = directions[i]; + const cell: Vector = prepared ? prepared[i][0] : [x + dx, y + dy]; + const name = cellId(cell); // If the tile is usable, push it to the list. - if (canUse(neighbor, from[0])) { - const existing = open.find((item) => vectorId(item[0]) === name); + if (canUse(cell, from[0])) { + if (i >= 4 && !cutCorners) { + const corners = prepared ? prepared[i][1] : null; + + if ( + isIllegal(corners ? corners[0] : [x, y + dy], from[0], 0) || + isIllegal(corners ? corners[1] : [x + dx, y], from[0], 0) + ) { + continue; + } + } + + const existing = indexed ? cells.get(name)?.open : open.find((item) => cellId(item[0]) === name); const currentScore = score({ - current: neighbor[0], + current: cell, parent: from, goal: options.to, heuristic, @@ -135,24 +176,64 @@ export function search(options: SearchOptions) { // If it is already in the open list, but this path results in a better score. if (existing && currentScore.f < existing[1].f) { - const existingName = vectorId(existing[0]); - - open = open.map((existingTile) => { - if (vectorId(existingTile[0]) === existingName) { - existingTile[1] = currentScore; - existingTile[2] = from; - } - - return existingTile; - }); + if (indexed) { + existing[1] = currentScore; + existing[2] = from; + decreased = true; + } else { + const existingName = cellId(existing[0]); + + open = open.map((existingTile) => { + if (cellId(existingTile[0]) === existingName) { + existingTile[1] = currentScore; + existingTile[2] = from; + } + + return existingTile; + }); + } } else if (!existing) { - open.push([neighbor[0], currentScore, from]); + const entry: OpenTile = [cell, currentScore, from]; + + if (indexed) { + state(name).open = entry; + added.push(entry); + } else { + open.push(entry); + } } + + finite &&= Number.isFinite(currentScore.f); } } - // Sort the new open list by F values. - open = open.toSorted((a, b) => asc(a[1].f, b[1].f)); + if (!indexed || decreased || !finite) { + // Stable sorting uses the preceding frontier order, not discovery order, after a decrease. + open = [...open.slice(head), ...added].toSorted((a, b) => asc(a[1].f, b[1].f)); + head = 0; + } else { + for (const entry of added) { + let high = open.length; + let low = head; + + while (low < high) { + const middle = Math.floor((low + high) / 2); + + if (entry[1].f < open[middle][1].f) { + high = middle; + } else { + low = middle + 1; + } + } + + open.splice(low, 0, entry); + } + + if (head > 0 && head >= open.length / 2) { + open = open.slice(head); + head = 0; + } + } } // And start traversing from the starting position. @@ -167,20 +248,24 @@ export function search(options: SearchOptions) { ]); // Traverse the open list until it is empty. - while (open.length > 0) { - const bestScore = open.shift(); + while (open.length > head) { + const bestScore = indexed ? open[head++] : open.shift(); if (bestScore) { const [vector] = bestScore; - const name = vectorId(vector); + const name = cellId(vector); // Add this to the closed list. - closed.push(name); + const cell = state(name); + cell.closed = true; + cell.open = undefined; // Check if we're at the end. if (name === end) { path = calculatePath(bestScore); open = []; + head = 0; + continue; } @@ -192,99 +277,4 @@ export function search(options: SearchOptions) { return path; } -export function calculatePath(result: OpenTile) { - let current: OpenTile | null = result; - const path: Vector[] = []; - - while (current !== null) { - path.push(current[0]); - current = current[2]; - } - - path.reverse(); - - return path; -} - -export function neighbors(vector: Vector, diagonals = false) { - const tiles: Neighbor[] = []; - - tiles.push( - [[vector[0] - 1, vector[1]], null], - [[vector[0] + 1, vector[1]], null], - [[vector[0], vector[1] - 1], null], - [[vector[0], vector[1] + 1], null], - ); - - if (diagonals) { - tiles.push( - [ - [vector[0] - 1, vector[1] - 1], - [ - [vector[0], vector[1] - 1], - [vector[0] - 1, vector[1]], - ], - ], - [ - [vector[0] + 1, vector[1] + 1], - [ - [vector[0], vector[1] + 1], - [vector[0] + 1, vector[1]], - ], - ], - [ - [vector[0] + 1, vector[1] - 1], - [ - [vector[0], vector[1] - 1], - [vector[0] + 1, vector[1]], - ], - ], - [ - [vector[0] - 1, vector[1] + 1], - [ - [vector[0], vector[1] + 1], - [vector[0] - 1, vector[1]], - ], - ], - ); - } - - return { - tiles, - total: tiles.length, - }; -} - -function resolveHeuristic(input: BuiltinHeuristic | Heuristic): Heuristic { - // oxlint-disable-next-line anti-slop/no-runtime-typeof -- The declared union supports builtin names and custom heuristic functions. - return typeof input === 'function' ? input : heuristics[input]; -} - -export function score(options: ScoreOptions) { - const g = options.parent[1].g + 1; - const h = resolveHeuristic(options.heuristic)(options.current, options.goal); - - return { - g, - h, - f: g + h, - }; -} - -export function vectorId(vector: Vector) { - return `${vector[0]},${vector[1]}`; -} - -export function asc(a: number, b: number) { - if (a > b) { - return 1; - } - - if (a < b) { - return -1; - } - - return 0; -} - -export type * from './types'; +export type { Grid, SearchOptions, Tile, TileBuilder, Vector } from './types'; diff --git a/src/path.ts b/src/path.ts new file mode 100644 index 0000000..2569c66 --- /dev/null +++ b/src/path.ts @@ -0,0 +1,18 @@ +import { type OpenTile, type Vector } from './types'; + +/** + * Follows parent links and returns the retained vectors from the origin through the result. + */ +export function calculatePath(result: OpenTile) { + let current: OpenTile | null = result; + const path: Vector[] = []; + + while (current !== null) { + path.push(current[0]); + current = current[2]; + } + + path.reverse(); + + return path; +} diff --git a/src/scoring.ts b/src/scoring.ts new file mode 100644 index 0000000..0eddfef --- /dev/null +++ b/src/scoring.ts @@ -0,0 +1,39 @@ +import { type BuiltinHeuristic, type Heuristic, heuristics } from './heuristics'; +import { type ScoreOptions } from './types'; + +/** + * Uses a custom callback unchanged or selects the named built-in heuristic. + */ +function resolveHeuristic(input: BuiltinHeuristic | Heuristic): Heuristic { + // oxlint-disable-next-line anti-slop/no-runtime-typeof -- The declared union supports builtin names and custom heuristic functions. + return typeof input === 'function' ? input : heuristics[input]; +} + +/** + * Adds one movement step to the parent cost and evaluates the heuristic against the live goal. + */ +export function score(options: ScoreOptions) { + const g = options.parent[1].g + 1; + const h = resolveHeuristic(options.heuristic)(options.current, options.goal); + + return { + g, + h, + f: g + h, + }; +} + +/** + * Compares ascending scores and treats unordered values, including NaN, as ties. + */ +export function asc(a: number, b: number) { + if (a > b) { + return 1; + } + + if (a < b) { + return -1; + } + + return 0; +} diff --git a/src/types.ts b/src/types.ts index dfbcf4d..783fb17 100644 --- a/src/types.ts +++ b/src/types.ts @@ -22,6 +22,12 @@ export interface Score { export type OpenTile = [Vector, Score, OpenTile | null]; +export interface CellState { + tile?: TileBuilderCache; + open?: OpenTile; + closed?: boolean; +} + export interface ScoreOptions { current: Vector; parent: OpenTile; diff --git a/tests/asc.test.ts b/tests/asc.test.ts index c486422..174a3ea 100644 --- a/tests/asc.test.ts +++ b/tests/asc.test.ts @@ -1,4 +1,4 @@ -import { asc } from '../src'; +import { asc } from '../src/scoring'; describe('asc', () => { test.concurrent('should rearrange in ascending order', () => { diff --git a/tests/astar.test.ts b/tests/search.test.ts similarity index 51% rename from tests/astar.test.ts rename to tests/search.test.ts index 8732709..0f886dc 100644 --- a/tests/astar.test.ts +++ b/tests/search.test.ts @@ -1,4 +1,4 @@ -import { search, type Grid } from '../src'; +import { search, type Grid, type Vector } from '../src'; import { makeGrid } from './grid'; describe('search', () => { @@ -71,6 +71,367 @@ describe('search', () => { ]); }); + describe('search compatibility', () => { + test.concurrent('retains frontier order on equal scores', () => { + expect( + search({ + grid: [ + [0, 0, 0, 0], + [0, -1, 0, 0], + [0, 0, 0, 0], + [0, 0, 0, 0], + ], + from: [0, 0], + to: [3, 3], + }), + ).toStrictEqual([ + [0, 0], + [1, 0], + [2, 0], + [2, 1], + [2, 2], + [3, 2], + [3, 3], + ]); + }); + + test.concurrent('retains the preceding frontier order after score decreases', () => { + let seed = 248; + + const grid = Array.from({ length: 12 }, () => + Array.from({ length: 12 }, () => { + seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0; + + return seed / 0x1_0000_0000 < 0.3 ? -1 : 0; + }), + ); + + grid[0][0] = 0; + grid[11][11] = 0; + + expect( + search({ + grid, + from: [0, 0], + to: [11, 11], + diagonal: true, + cutCorners: false, + heuristic: 'manhattan', + }), + ).toStrictEqual([ + [0, 0], + [1, 1], + [2, 2], + [3, 3], + [4, 4], + [4, 5], + [5, 6], + [6, 5], + [7, 4], + [8, 4], + [9, 4], + [10, 4], + [11, 4], + [11, 5], + [11, 6], + [11, 7], + [11, 8], + [11, 9], + [11, 10], + [11, 11], + ]); + }); + + test.concurrent('takes fresh snapshots in each search on mixed mutable grids', () => { + const grid: Grid = [ + [0, { elevation: 0 }, 0], + [0, 0, 0], + ]; + + expect( + search({ + grid, + from: [0, 0], + to: [2, 0], + }), + ).toStrictEqual([ + [0, 0], + [1, 0], + [2, 0], + ]); + + grid[0][1] = { + elevation: 0, + isLegal: false, + }; + + expect( + search({ + grid, + from: [0, 0], + to: [2, 0], + }), + ).toStrictEqual([ + [0, 0], + [0, 1], + [1, 1], + [2, 1], + [2, 0], + ]); + + grid[1][1] = -1; + + expect( + search({ + grid, + from: [0, 0], + to: [2, 0], + }), + ).toBeNull(); + }); + + test.concurrent('keeps lazy tile reads and reports reachable ragged cells', () => { + expect(() => + search({ + grid: [[0, 0], []], + from: [0, 0], + to: [1, 0], + }), + ).toThrow('Grid value is undefined'); + + expect( + search({ + grid: [ + [0, 0, -1], + [0, 0], + ], + from: [0, 0], + to: [1, 0], + }), + ).toStrictEqual([ + [0, 0], + [1, 0], + ]); + }); + + test.concurrent('captures endpoint identity before grid access without extra coordinate reads', () => { + const from: Vector = [0, 0]; + const to: Vector = [2, 0]; + + const reads: string[] = []; + let initialReads: string[] | undefined; + let targetX = 2; + + Object.defineProperties(from, { + 0: { + get: () => { + reads.push('from.x'); + + return 0; + }, + }, + 1: { + get: () => { + reads.push('from.y'); + + return 0; + }, + }, + }); + + Object.defineProperties(to, { + 0: { + get: () => { + reads.push('to.x'); + + return targetX; + }, + }, + 1: { + get: () => { + reads.push('to.y'); + + return 0; + }, + }, + }); + + const path = search({ + get from() { + reads.push('from'); + + return from; + }, + get to() { + reads.push('to'); + + return to; + }, + get grid() { + initialReads ??= [...reads]; + targetX = 1; + + return [[0, 0, 0]]; + }, + }); + + expect(initialReads).toStrictEqual(['from', 'from.x', 'from.y', 'to', 'to.x', 'to.y']); + expect(path).toStrictEqual([ + [0, 0], + [1, 0], + [2, 0], + ]); + }); + + test.concurrent('keeps callback vectors independent and supports reentrant searches', () => { + const retained: Vector[] = []; + let nested = false; + + const path = search({ + grid: [ + [0, 0, 0], + [0, 0, 0], + ], + from: [0, 0], + to: [2, 0], + heuristic: (current) => { + retained.push(current); + + if (!nested) { + nested = true; + expect( + search({ + grid: [[0, 0]], + from: [0, 0], + to: [1, 0], + }), + ).toStrictEqual([ + [0, 0], + [1, 0], + ]); + } + + return 0; + }, + }); + + expect(path).toStrictEqual([ + [0, 0], + [1, 0], + [2, 0], + ]); + expect(new Set(retained).size).toBe(retained.length); + expect(retained[0]).toStrictEqual([1, 0]); + expect(retained[1]).toStrictEqual([0, 1]); + }); + + test.concurrent('prepares neighbors before callbacks can change the origin', () => { + const from: Vector = [0, 0]; + const seen: Vector[] = []; + + const path = search({ + grid: [ + [0, 0, 0], + [0, 0, 0], + ], + from, + to: [2, 0], + heuristic: (current) => { + seen.push([...current]); + from[0] = 1; + + return 0; + }, + }); + + expect(path).toStrictEqual([ + [1, 0], + [1, 0], + [2, 0], + ]); + expect(seen.slice(0, 2)).toStrictEqual([ + [1, 0], + [0, 1], + ]); + }); + + test.concurrent('preserves custom mutation, exceptions, and nonfinite ordering', () => { + expect( + search({ + grid: [[0, 0, 0]], + from: [0, 0], + to: [2, 0], + heuristic: (current) => { + current[0] = 2; + + return 0; + }, + }), + ).toStrictEqual([ + [0, 0], + [2, 0], + ]); + + const failure = new Error('heuristic failure'); + + expect(() => + search({ + grid: [[0, 0]], + from: [0, 0], + to: [1, 0], + heuristic: () => { + throw failure; + }, + }), + ).toThrow(failure); + + for (const value of [Number.NaN, Infinity, -Infinity]) { + expect( + search({ + grid: [[0, 0]], + from: [0, 0], + to: [1, 0], + heuristic: () => value, + }), + ).toStrictEqual([ + [0, 0], + [1, 0], + ]); + } + }); + + test.concurrent('snapshots object tiles once at first access', () => { + let elevation = 0; + let reads = 0; + + const middle = { + get elevation() { + reads++; + + return elevation; + }, + }; + + const path = search({ + grid: [[0, middle, 0]], + from: [0, 0], + to: [2, 0], + heuristic: () => { + elevation = 10; + + return 0; + }, + }); + + expect(path).toStrictEqual([ + [0, 0], + [1, 0], + [2, 0], + ]); + expect(reads).toBe(2); + }); + }); + describe('movement', () => { test.concurrent('should pathfind vertically', () => { const path = search({