# Performance

This project treats performance work as a report-first loop. The goal is to
make hot paths measurable on a local machine without turning CI into a flaky
wall-clock gate.

## Commands

Run the benchmark runner and write `target/perf-report.json`:

~~~sh
just bench
~~~

Compare the current report with the committed baseline:

~~~sh
just perf-check
~~~

Refresh the baseline after an intentional optimization:

~~~sh
just perf-refresh-baseline
~~~

## Report Schema

`target/perf-report.json` uses schema version `2`. Each case records repeated
samples and summary statistics:

- `sample_ms`: per-sample milliseconds per measured operation
- `mean_ms`: arithmetic mean of `sample_ms`
- `median_ms`: median of `sample_ms`
- `min_ms` and `max_ms`: sample bounds
- `stddev_ms`: population standard deviation
- `observed_items`: case-specific count returned by the measured operation

The default command runs one warmup round and five measured samples. Override
the local run shape when needed:

~~~sh
just bench PERF_ITERATIONS=30 PERF_SAMPLES=7 PERF_WARMUP=2
~~~

## Benchmark Cases

| Case | Purpose |
| --- | --- |
| `api_lint_large_document` | Measures full-rule lint cost on a diagnostics-heavy generated document. |
| `api_lint_clean_large_document` | Measures full-rule lint cost on a clean generated document. |
| `api_fix_large_document` | Measures fix orchestration cost on the diagnostics-heavy document. |
| `api_lint_many_small_documents` | Measures repeated API calls for many small documents. |
| `context_build_large_document` | Measures source-preserving line and fence context construction. |
| `context_heading_index_large_document` | Measures lazy heading index construction. |
| `context_table_index_large_document` | Measures lazy table index construction. |
| `cli_check_many_small_files` | Measures CLI directory check cost for a synthetic workspace. |
| `config_validate_representative` | Measures config load and validation cost. |
| `api_rule_catalog` | Measures rule catalog construction cost. |

The generated corpus intentionally includes headings, lists, block quotes,
URLs, fenced code, tables, and trailing spaces. It is synthetic so the report
is reproducible and does not depend on local repository content.

## Baseline Policy

`just perf-check` validates the schema version, required cases, and statistic
fields. Timing deltas are printed as information, not as a hard failure. The
comparison summary uses `median_ms` because it is less sensitive to short CPU
spikes than a single sample.

When changing benchmark shape, update the baseline in the same change:

~~~sh
just perf-refresh-baseline
~~~

## Cross-Tool CLI Benchmark

The cross-tool benchmark is a manual comparison harness for `kml`, `mado`,
and `rumdl`. It is opt-in because third-party CLI installation and wall-clock
timing are not stable enough for required CI.

Run all available cross-tool cases:

~~~sh
just bench-cross-tools
~~~

Run a narrower comparison:

~~~sh
just bench-cross-tools-default
just bench-cross-tools-common
just bench-cross-tools-fix
~~~

The harness always builds `target/release/kml` first. `mado` and `rumdl` are
discovered from `PATH` unless explicit paths are supplied through
`CROSS_TOOL_ARGS`. Missing optional tools are reported as skipped cases rather
than failures:

~~~sh
just bench-cross-tools CROSS_TOOL_ARGS="--mado /path/to/mado --rumdl /path/to/rumdl"
~~~

Reports are written to:

- `target/cross-tool-benchmark.json`
- `target/cross-tool-benchmark.md`

`hyperfine` is used when it is installed. Without it, the harness uses a
small standard-library timer so the report is still available on a fresh
development machine.

The benchmark has two comparison modes:

- `default`: each tool runs with its own default rule set
- `common`: each tool receives a generated config for the common candidate
  rule subset

`fix` cases run against a temporary workspace copy on every measured sample.
The committed source corpus under `tests/fixtures/cross-tool-benchmark` is
never mutated by benchmark execution.

## Public Confidence Timing

`just public-confidence` records check, fix, fmt, and final check elapsed time
in `target/public-confidence-report.json`. This timing is evidence for
real-document-like Markdown, not a wall-clock failure threshold.

The optional `just external-katana-dogfood` target records the same timings for
KatanA `docs/**/*.md` and `assets/**/*.md` when `KATANA_CHECKOUT` is set.
Because that checkout is not part of this repository, required CI uses the
curated public fixture instead.

## Current Hot Path

CLI fix mode used to lint the original file before fix mode, call the public
`fix(content, options)` API, then lint the fixed content. Since the public API
must keep linting for callers that only have source text, the CLI now reuses
the diagnostics it already computed and calls `fix_with_results`.

The optimization is intentionally narrow:

- The public `fix(content, options)` API remains unchanged.
- CLI fix mode still lints the fixed content before deciding the exit code.
- The helper exists only to avoid duplicate original-content lint evaluation
  when diagnostics are already available.

## v0.5.0 DocumentContext Snapshot

`source-preserving-document-context` adds a shared document context and migrates
`MD001` and `MD060` to context-based evaluation. The context indexes are lazy so
line-only paths do not pay for heading or table extraction unless a migrated rule
needs them.

Local `just perf-check` snapshot after the migration:

| Case | Median |
| --- | ---: |
| `api_lint_large_document` | 9.884 ms |
| `api_lint_clean_large_document` | 6.585 ms |
| `api_fix_large_document` | 206.701 ms |
| `api_lint_many_small_documents` | 2.360 ms |
| `context_build_large_document` | 0.077 ms |
| `context_heading_index_large_document` | 1.087 ms |
| `context_table_index_large_document` | 1.133 ms |
| `cli_check_many_small_files` | 9.714 ms |

The context-specific costs are small relative to full lint execution. The
remaining `api_fix_large_document` regression is explained by multi-pass safe
fix convergence over a diagnostics-heavy synthetic corpus that now applies 4200
fixes per measured operation. `fix::apply` now builds the edited output in one
linear pass after overlap resolution, but reducing multi-pass convergence cost
requires a separate fix-planning change.

## v0.7.0 Fix Range Snapshot

`v0-7-0-performance-hotpath` removes repeated full-document scans from
`fix::apply`. The fix pipeline already had all diagnostics as line/column
ranges; applying them used to convert each range back to a byte range by
rescanning the content. The new implementation builds one line-start index per
apply call and reuses it for every candidate fix.

Short local snapshot with `PERF_ITERATIONS=10 PERF_SAMPLES=3`:

| Case | Before | After |
| --- | ---: | ---: |
| `api_fix_large_document` | 211.807 ms | 21.271 ms |

Full local baseline after the change:

| Case | Median |
| --- | ---: |
| `api_lint_large_document` | 9.760 ms |
| `api_lint_clean_large_document` | 6.286 ms |
| `api_fix_large_document` | 18.684 ms |
| `api_lint_many_small_documents` | 2.287 ms |
| `context_build_large_document` | 0.074 ms |
| `context_heading_index_large_document` | 1.029 ms |
| `context_table_index_large_document` | 1.065 ms |
| `cli_check_many_small_files` | 9.479 ms |
| `config_validate_representative` | 0.035 ms |
| `api_rule_catalog` | 0.012 ms |

The optimization is internal and preserves the public fix contract, including
multi-line ranges, virtual EOF positions, UTF-8 boundary clamping, and overlap
resolution.

## v0.12.20 Catalog Metadata Snapshot

`v0.12.20` measured the post-`v0.12.19` rule set before adding more rule or
fix coverage. The first strict run found one blocker: `api_rule_catalog`
reached 0.013 ms, or 1.47x over the committed baseline. The hot path was the
public `available_rules()` API, which rebuilt owned `RuleMeta` values from the
cached structured catalog on every call.

The fix keeps the public owned `Vec<RuleMeta>` contract and caches the exported
metadata vector internally. Callers still receive an owned vector, but the
library no longer repeats the catalog-to-metadata conversion for every call.

Local snapshot after `just perf-refresh-baseline`:

| Case | Median |
| --- | ---: |
| `api_lint_large_document` | 6.914 ms |
| `api_lint_clean_large_document` | 4.215 ms |
| `api_fix_large_document` | 20.775 ms |
| `api_lint_many_small_documents` | 2.109 ms |
| `api_fix_parser_heavy_document` | 8.237 ms |
| `context_inline_token_index_large_document` | 0.891 ms |
| `cli_check_many_small_files` | 9.314 ms |
| `cli_fix_many_small_files` | 31.765 ms |
| `cli_fmt_many_small_files` | 32.748 ms |
| `config_validate_representative` | 0.034 ms |
| `api_rule_catalog` | 0.006 ms |

Validation evidence:

- `just test` passed after the metadata cache change.
- `just perf-check-strict` passed; `api_rule_catalog` measured 0.007 ms, or 0.74x against the previous baseline.
- `just public-confidence` passed and recorded check/fix/fmt convergence in `target/public-confidence-report.json`.
- The cross-tool targets passed; `mado` and `rumdl` were not installed locally, so their cases were recorded as skipped evidence.
