diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 22647fe..e96c26b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,11 @@ on: paths-ignore: - 'docs/**' - '**/*.md' + # Nightly perf run (full suite) and manual triggers (e.g. baseline capture + # runs). Schedule/dispatch events are unaffected by the paths-ignore above. + schedule: + - cron: "17 3 * * *" + workflow_dispatch: env: CARGO_TERM_COLOR: always @@ -85,3 +90,69 @@ jobs: - name: Dry-run publish (minimal) run: cargo publish --dry-run --no-default-features + + perf: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + # ctx score/check diff against git history; the harness also + # records the commit SHA in its results. + fetch-depth: 0 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + target/ + perf/target/ + # ImageVersion is set by hosted runners; keying on it prevents a + # target/ dir cached under one runner image (Xcode/clang paths) + # from poisoning builds on a newer image + key: ${{ runner.os }}-${{ env.ImageVersion }}-cargo-perf-${{ hashFiles('**/Cargo.lock') }} + + - name: Build ctx (perf profile, all features) + run: cargo build --profile perf --all-features + + - name: Harness unit tests + run: cargo test --manifest-path perf/Cargo.toml + + - name: Harness smoke run + env: + CTX_PERF_BIN: ${{ github.workspace }}/target/perf/ctx + run: cargo run --release --manifest-path perf/Cargo.toml -- --smoke + + # Advisory period: continue-on-error until the ubuntu-latest baseline + # lands in perf/baselines/ and run-to-run spread is confirmed <20%. + # Flip this to required (remove continue-on-error) at that point. + - name: Run perf harness + continue-on-error: true + env: + CTX_PERF_BIN: ${{ github.workspace }}/target/perf/ctx + CTX_PERF_BUDGET_SCALE: "1.5" + run: | + SUITE=pr + if [ "${{ github.event_name }}" = "schedule" ] || \ + [ "${{ github.event_name }}" = "workflow_dispatch" ] || \ + { [ "${{ github.event_name }}" = "push" ] && [ "${{ github.ref }}" = "refs/heads/main" ]; }; then + SUITE=full + fi + cargo run --release --manifest-path perf/Cargo.toml -- \ + --suite "$SUITE" \ + --baseline perf/baselines/ubuntu-latest.json \ + --json-out perf-results.json + + - name: Upload perf results + if: always() + uses: actions/upload-artifact@v4 + with: + name: perf-results + path: perf-results.json + if-no-files-found: ignore diff --git a/.github/workflows/snapshot.yml b/.github/workflows/snapshot.yml new file mode 100644 index 0000000..db8615f --- /dev/null +++ b/.github/workflows/snapshot.yml @@ -0,0 +1,97 @@ +name: snapshot + +on: + push: + branches: [main] + workflow_dispatch: + +env: + CARGO_TERM_COLOR: always + +permissions: + contents: write + +# Appends to the ctx-snapshots data branch must be serialized; queue runs +# instead of cancelling so no merge loses its partition. +concurrency: + group: ctx-snapshots + cancel-in-progress: false + +jobs: + capture: + # Skip on forks: they can't (and shouldn't) push the data branch + if: github.repository == 'agentis-tools/ctx' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + # Full history: churn metrics look back over a 90-day window + fetch-depth: 0 + + - name: Setup Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo + uses: actions/cache@v4 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + target/ + # ImageVersion is set by hosted runners; keying on it prevents a + # target/ dir cached under one runner image (Xcode/clang paths) + # from poisoning builds on a newer image + key: ${{ runner.os }}-${{ env.ImageVersion }}-cargo-snapshot-${{ hashFiles('**/Cargo.lock') }} + + - name: Build + # default features include duckdb, which `ctx snapshot` requires + run: cargo build --release + + - name: Capture snapshot + run: | + target/release/ctx index + target/release/ctx snapshot --json + + - name: Append to ctx-snapshots branch + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + + if git ls-remote --exit-code origin refs/heads/ctx-snapshots >/dev/null 2>&1; then + # Steady state: check the data branch out in a linked worktree. + # Explicit refspec because actions/checkout narrows + # remote.origin.fetch to the checked-out branch. + git fetch origin +refs/heads/ctx-snapshots:refs/remotes/origin/ctx-snapshots + git worktree add -B ctx-snapshots ../snapshots-branch origin/ctx-snapshots + else + # Bootstrap: orphan branch (no shared history with main). + # --orphan on worktree add needs git >= 2.42; ubuntu-latest + # ships >= 2.43. The new worktree starts empty. + git worktree add --orphan -b ctx-snapshots ../snapshots-branch + fi + + mkdir -p ../snapshots-branch/snapshots + cp -R .ctx/snapshots/. ../snapshots-branch/snapshots/ + + cd ../snapshots-branch + git add -A + # Idempotent: re-run on the same sha stages nothing -> succeed as no-op + if git diff --cached --quiet; then + echo "Partition for ${GITHUB_SHA} already present; nothing to push." + exit 0 + fi + git commit -m "snapshot: ${GITHUB_SHA}" + git push origin ctx-snapshots + + - name: Usage hint + run: | + { + echo "### Querying the snapshot history" + echo '```bash' + echo "git fetch origin ctx-snapshots" + echo "git worktree add ../snapshots ctx-snapshots" + echo "ctx sql --snapshots=../snapshots/snapshots \"SELECT ...\"" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" diff --git a/CHANGELOG.md b/CHANGELOG.md index fdd80b9..7612065 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- `ctx snapshot`: per-commit Parquet metric snapshots for longitudinal quality analysis -- exports HEAD's per-symbol metrics, per-file metrics (complexity, churn within `--churn-window`, default "90 days ago", and rule violations), near-duplicate pairs, and capture metadata as one partition `.ctx/snapshots/sha=/{symbols,files,dup_pairs,meta}.parquet`, every row denormalized with `commit_sha`/`committed_at` so partitions union with a single `read_parquet` glob; partitions are staged and atomically renamed into place, existing partitions are skipped unless `--force`, a dirty working tree warns on stderr, and `--json` emits a `snapshot.capture` envelope. `ctx snapshot backfill --since [--every N]` walks the first-parent range `REF..HEAD` (REF inclusive) oldest-first through temporary git worktrees to build the history -- newest commit always sampled, per-commit failures logged to stderr and skipped, `--json` emits `snapshot.backfill`. Requires the `duckdb` feature (exit 2 otherwise); see `docs/commands/snapshot.md` +- `ctx sql --snapshots[=DIR]` (default `.ctx/snapshots`): materializes the snapshot partitions as `snap.files`, `snap.symbols`, `snap.dup_pairs`, and `snap.meta` tables (loaded before the sandbox hardening, so the query connection stays read-only) for trend queries across commits; the `snap.*` column reference and canned duplication/violation/hotspot-mass trend queries are in the SQL schema reference (`ctx sql --schema`) +- Gate-evaluation logging via `CTX_GATE_LOG`: when set, `ctx score` appends one JSONL record per gate evaluation to a local log (`1`/`true` = `.ctx/gate-log.jsonl` under the repo root, any other value = custom path, unset/empty/`0` = off) with `schema_version`, `ts`, `ctx_version`, `source`, `against`, `fail_on`, the seven scorecard `metrics`, `failed_conditions`, `outcome` (`pass`/`fail`), `blocking`, and `session_id`; best-effort (IO failures warn on stderr) and never changes the command's exit code. Opt-in and local-only -- ctx ships no telemetry +- Opt-in blocking quality gate in the Claude Code Stop hook: `CTX_GATE_BLOCKING=1` makes the generated `stop.sh` exit 2 (Claude Code's blocking-stop mechanism, so the session keeps working until the failed conditions are addressed) when `ctx score --fail-on` gates fail; the default stays non-blocking, and operational errors (compat mismatch, score errors) always fail open with a stderr note +- Study scripts for longitudinal gate experiments: `scripts/rework-rate.sh` (fraction of each commit's added lines modified or deleted again within a window, via `git blame` survival at the window boundary) and `scripts/revert-rate.sh` (revert and fix-commit rates per 100 first-parent commits), plus a versioned benchmark run-record schema (`docs/benchmark/run-record.schema.json`, JSON Schema draft 2020-12, documented in `docs/benchmark/run-record.md`) for recording agent benchmark runs as JSONL +- Operational performance harness (`perf/`, a non-published companion crate): spawns a prebuilt `ctx` binary against deterministic synthetic fixtures and enforces latency budgets on the hook-path commands (incremental index 300 ms, score 2 s, check 1 s, map 500 ms, sql 500 ms on a 2,000-file fixture; cold index of a ~150k-LOC fixture 60 s; 300 MB RSS ceiling) plus a 1.20x regression gate against committed per-runner-class baselines (`perf/baselines/`; `CTX_PERF_BUDGET_SCALE` relaxes budgets to 1.5x in CI), with criterion microbenches for library hot paths; wired into CI as an advisory `perf` job and a new `[profile.perf]` build profile +- Snapshot CI workflow (`.github/workflows/snapshot.yml`): every push to `main` captures a `ctx snapshot` partition and appends it to the `ctx-snapshots` orphan data branch (serialized via a concurrency group, idempotent per sha), keeping the metric history out of the main branch while staying queryable with `ctx sql --snapshots` +- Deterministic seeded fixture generator (`ctx::fixture`, `#[doc(hidden)]`): fully parameterized synthetic repos (`FixtureSpec { seed, files, avg_loc, modules, fan_in_skew, history_commits }`) shared by the perf harness and reusable by external benchmark suites; any change to the generated bytes bumps `FIXTURE_FORMAT_VERSION`, invalidating committed perf baselines automatically + +### Changed +- The `stop.sh` harness template was revised for `CTX_GATE_BLOCKING` and the gate log; re-run `ctx harness init` after updating ctx to regenerate the hook scripts (`ctx harness doctor`'s `templates_stale` check reports when this is due) + ## [0.3.1] - 2026-07-10 ### Changed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 45793a6..77bc325 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -51,6 +51,16 @@ cargo test cargo publish --dry-run ``` +### Performance gates + +CI runs an **advisory** `perf` job on every PR: it spawns a prebuilt `ctx` binary against deterministic synthetic fixtures and checks the hook-path commands (incremental index, score, check, map, sql) against latency budgets, an RSS ceiling, and a committed baseline. Advisory means it does not block merges — but treat a red `perf` job as a real finding, not noise. + +When it fails: + +1. **Reproduce locally** following [`perf/README.md`](perf/README.md) (`cargo build --profile perf --all-features`, then run the harness with `CTX_PERF_BIN` pointing at the binary). +2. **Mind the budget scale.** Budgets are calibrated for local bare-metal runs (`CTX_PERF_BUDGET_SCALE=1.0`); CI uses `1.5` because hosted runners are slower and noisier. A scenario that passes locally but fails in CI by a hair is usually runner noise — re-run before digging. The 1.20x baseline-regression factor is deliberately *not* scaled. +3. **Baseline updates are deliberate, never automatic.** CI never writes baselines. If a slowdown is intentional (e.g. a feature genuinely does more work), capture a new baseline on the CI runner class per `perf/baselines/README.md` and commit it in its own commit with a justification. + ## IDE Configuration The repository includes `.vscode` and `.idea` in `.gitignore`. Feel free to add local IDE configs but do not commit them. diff --git a/Cargo.toml b/Cargo.toml index 4329432..9f00980 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -93,7 +93,7 @@ rustyline = { version = "17", features = ["with-file-history"] } fastembed = "5.4" # Phase 2: DuckDB for analytical queries (optional, not available on Windows) -duckdb = { version = "1.0", features = ["bundled"], optional = true } +duckdb = { version = "1.0", features = ["bundled", "parquet"], optional = true } # Phase 4: File watching for incremental updates notify = "6.1" @@ -123,3 +123,11 @@ predicates = "3" lto = true strip = true codegen-units = 1 + +# Perf-harness build: near-release optimization without fat-LTO compile cost. +# Baselines in perf/baselines/ are recorded under this profile. +[profile.perf] +inherits = "release" +lto = "thin" +codegen-units = 16 +strip = false diff --git a/docs/architecture.md b/docs/architecture.md index 3db3f97..717e87f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -524,16 +524,20 @@ pub fn stream_context( ## Performance Characteristics -### Indexing -- ~2000 files in <10 seconds -- Incremental updates: <1 second for changed files -- Memory: ~100MB for large codebases - -### Queries -- Symbol search: <10ms -- Call graph (depth 5): <50ms -- Impact analysis: <100ms -- Semantic search: ~100ms (depends on embedding count) +### Enforced latency budgets + +These are not aspirations: each number is a budget enforced by the operational perf harness ([`perf/README.md`](../perf/README.md)), which CI runs as an advisory `perf` job on every PR. Each scenario spawns a prebuilt `ctx` binary against a deterministic synthetic fixture of 2,000 files and takes the median of 5 timed runs: + +| Operation | Fixture | Budget | +|-----------|---------|--------| +| `ctx index`, incremental (1 changed file) | 2,000 files | 300 ms | +| `ctx score --against HEAD` (3 changed files) | 2,000 files | 2 s | +| `ctx check --against HEAD` (3 changed files) | 2,000 files | 1 s | +| `ctx map --budget 2000` (warm rank cache) | 2,000 files | 500 ms | +| `ctx sql` (aggregate query over `v1`) | 2,000 files | 500 ms | +| `ctx index`, cold (full suite only) | ~150k LOC | 60 s | + +Peak memory (`ru_maxrss`) across the incremental-index, score, check, and map runs must stay below **300 MB**. CI runners get a 1.5x budget allowance (`CTX_PERF_BUDGET_SCALE`) because hosted runners are slower and noisier, and medians are additionally gated at 1.20x a committed per-runner-class baseline (`perf/baselines/`). ### Storage - Symbols: ~500 bytes each (uncompressed) diff --git a/docs/benchmark/run-record.md b/docs/benchmark/run-record.md new file mode 100644 index 0000000..a37e06f --- /dev/null +++ b/docs/benchmark/run-record.md @@ -0,0 +1,101 @@ +# Benchmark run record + +Every benchmark run in the longitudinal study produces one *run record*: a +single JSON object appended to a JSON Lines file. Records are validated +against [`run-record.schema.json`](./run-record.schema.json) +(JSON Schema draft 2020-12, `$id` +`https://docs.agentis.tools/schemas/run-record-v1.json`). + +## Fields + +| Field | Type | Required | Description | +| --- | --- | --- | --- | +| `schema_version` | integer (const `1`) | yes | Version of the record schema. Always `1` for records described by this document. | +| `run_id` | string (UUID) | yes | Globally unique identifier for the run. | +| `task_id` | string | yes | Identifier of the benchmark task the run attempted. | +| `arm` | string | yes | Experiment arm. Conventional values: `control` (no gates), `gates` (advisory gates), `gates_blocking` (blocking gates). Not enum-restricted, so new arms need no schema change. | +| `run_index` | integer >= 0 | yes | Zero-based index of the run among the repeats of `(task_id, arm)`. | +| `started_at` | string (RFC 3339 date-time) | yes | When the run started. | +| `finished_at` | string (RFC 3339 date-time) | yes | When the run finished. | +| `model_version` | string | yes | Exact model identifier used by the agent. | +| `ctx_version` | string | yes | Version of ctx installed during the run. | +| `harness_mode` | `"local"` \| `"plugin"` | yes | How the ctx harness was wired into the agent. | +| `gate_config` | object | yes | Effective gate configuration; see below. | +| `gate_config.fail_on` | string or null | yes | Gate failure condition, or `null` when none is configured. | +| `gate_config.blocking` | boolean | yes | Whether gate failures block the agent (`true`) or are advisory (`false`). | +| `gate_config.gate_log_enabled` | boolean | yes | Whether gate evaluations were logged to `.ctx/gate-log.jsonl`. | +| `transcript_path` | string | yes | Path of the full agent transcript, relative to the study data root. | +| `exit_status` | `"success"` \| `"failure"` \| `"timeout"` \| `"error"` | yes | How the run ended. | +| `metrics` | object | yes | Quantitative outcomes; see below. | +| `metrics.score` | object | yes | Final `ctx score` metrics for the run's diff. Exactly the seven keys `complexity_delta`, `fan_out_delta`, `new_duplication`, `check_violations`, `symbols_added`, `symbols_removed`, `files_changed`, all numbers. | +| `metrics.gate_evaluations` | integer >= 0 | yes | Number of gate evaluations performed during the run. | +| `metrics.wall_clock_seconds` | number >= 0 | yes | Total wall-clock duration of the run in seconds. | +| `notes` | string | no | Free-form operator notes. | + +The top-level object rejects unknown keys (`additionalProperties: false`). + +## Versioning policy + +- **Additive changes** (new *optional* fields, looser descriptions) keep + `schema_version` at its current value and reuse the same `$id`. Readers + must ignore optional fields they do not know -- but note that because the + top level is closed, additive changes still require publishing an updated + schema document before writers emit the new field. +- **Breaking changes** (removing or renaming a field, changing a type, + making an optional field required) bump `schema_version` (const `2`, ...) + and publish under a new `$id` + (`https://docs.agentis.tools/schemas/run-record-v2.json`, ...). Old records + are never rewritten; analysis code dispatches on `schema_version`. + +## Example record + +```json +{ + "schema_version": 1, + "run_id": "1f0c9a2e-7b4d-4c6a-9e2f-3d8b5a1c4e70", + "task_id": "refactor-duplicates-01", + "arm": "gates", + "run_index": 3, + "started_at": "2026-07-01T14:03:12Z", + "finished_at": "2026-07-01T14:41:55Z", + "model_version": "claude-fable-5", + "ctx_version": "0.9.2", + "harness_mode": "local", + "gate_config": { + "fail_on": "regression", + "blocking": false, + "gate_log_enabled": true + }, + "transcript_path": "runs/refactor-duplicates-01/gates/run-3/transcript.jsonl", + "exit_status": "success", + "metrics": { + "score": { + "complexity_delta": -1.5, + "fan_out_delta": 0.0, + "new_duplication": 0, + "check_violations": 0, + "symbols_added": 4, + "symbols_removed": 2, + "files_changed": 3 + }, + "gate_evaluations": 12, + "wall_clock_seconds": 2323.4 + }, + "notes": "One gate evaluation flagged a transient duplication warning." +} +``` + +## Joining run records to gate logs + +When `gate_config.gate_log_enabled` is true, the harness appends one JSON +line per gate evaluation to `.ctx/gate-log.jsonl` inside the task workspace. +Gate-log lines carry their own timestamps (and, where available, a harness +session id). To attribute gate evaluations to a run: + +1. Prefer the session id when both sides record one -- it is exact. +2. Otherwise join by run window: a gate-log line belongs to the run whose + `[started_at, finished_at]` interval contains its timestamp for the same + task workspace. Runs of the same task never overlap in time on one + workspace, so the window join is unambiguous; `metrics.gate_evaluations` + should equal the number of joined lines and serves as a consistency + check. diff --git a/docs/benchmark/run-record.schema.json b/docs/benchmark/run-record.schema.json new file mode 100644 index 0000000..a2e1e89 --- /dev/null +++ b/docs/benchmark/run-record.schema.json @@ -0,0 +1,141 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://docs.agentis.tools/schemas/run-record-v1.json", + "title": "Benchmark run record", + "description": "One record per benchmark run in the ctx longitudinal study. A run is a single agent attempt at a task under one experiment arm; records are appended as JSON Lines and joined against ctx gate logs for analysis.", + "type": "object", + "additionalProperties": false, + "required": [ + "schema_version", + "run_id", + "task_id", + "arm", + "run_index", + "started_at", + "finished_at", + "model_version", + "ctx_version", + "harness_mode", + "gate_config", + "transcript_path", + "exit_status", + "metrics" + ], + "properties": { + "schema_version": { + "const": 1, + "description": "Version of this record schema. Always 1 for records validated by this schema; a breaking change bumps the version and the schema $id." + }, + "run_id": { + "type": "string", + "format": "uuid", + "description": "Globally unique identifier for this run (UUID)." + }, + "task_id": { + "type": "string", + "description": "Identifier of the benchmark task this run attempted." + }, + "arm": { + "type": "string", + "description": "Experiment arm the run belongs to. Conventional values are \"control\" (no gates), \"gates\" (advisory gates), and \"gates_blocking\" (blocking gates); the field is deliberately not enum-restricted so new arms can be added without a schema change." + }, + "run_index": { + "type": "integer", + "minimum": 0, + "description": "Zero-based index of this run among the repeated runs of (task_id, arm)." + }, + "started_at": { + "type": "string", + "format": "date-time", + "description": "RFC 3339 timestamp at which the run started." + }, + "finished_at": { + "type": "string", + "format": "date-time", + "description": "RFC 3339 timestamp at which the run finished." + }, + "model_version": { + "type": "string", + "description": "Exact model identifier used by the agent for this run." + }, + "ctx_version": { + "type": "string", + "description": "Version of ctx installed during the run." + }, + "harness_mode": { + "enum": ["local", "plugin"], + "description": "How the ctx harness was wired into the agent: \"local\" (hooks generated into the repo) or \"plugin\" (marketplace plugin)." + }, + "gate_config": { + "type": "object", + "description": "Effective gate configuration for the run.", + "required": ["fail_on", "blocking", "gate_log_enabled"], + "properties": { + "fail_on": { + "type": ["string", "null"], + "description": "Gate failure condition (e.g. a ctx check/score threshold expression), or null when no failure condition is configured." + }, + "blocking": { + "type": "boolean", + "description": "Whether gate failures block the agent (true) or are advisory only (false)." + }, + "gate_log_enabled": { + "type": "boolean", + "description": "Whether gate evaluations were appended to .ctx/gate-log.jsonl during the run." + } + } + }, + "transcript_path": { + "type": "string", + "description": "Path (relative to the study data root) of the full agent transcript for this run." + }, + "exit_status": { + "enum": ["success", "failure", "timeout", "error"], + "description": "How the run ended: \"success\" (task completed and accepted), \"failure\" (completed but not accepted), \"timeout\" (hit the wall-clock limit), \"error\" (harness or infrastructure error)." + }, + "metrics": { + "type": "object", + "description": "Quantitative outcomes of the run.", + "required": ["score", "gate_evaluations", "wall_clock_seconds"], + "properties": { + "score": { + "type": "object", + "description": "Final `ctx score --against ` metrics for the run's diff. Exactly the seven ctx score metric keys, all numeric.", + "additionalProperties": false, + "required": [ + "complexity_delta", + "fan_out_delta", + "new_duplication", + "check_violations", + "symbols_added", + "symbols_removed", + "files_changed" + ], + "properties": { + "complexity_delta": { "type": "number" }, + "fan_out_delta": { "type": "number" }, + "new_duplication": { "type": "number" }, + "check_violations": { "type": "number" }, + "symbols_added": { "type": "number" }, + "symbols_removed": { "type": "number" }, + "files_changed": { "type": "number" } + } + }, + "gate_evaluations": { + "type": "integer", + "minimum": 0, + "description": "Number of gate evaluations performed during the run." + }, + "wall_clock_seconds": { + "type": "number", + "minimum": 0, + "description": "Total wall-clock duration of the run in seconds." + } + } + }, + "notes": { + "type": "string", + "description": "Optional free-form operator notes about the run." + } + } +} diff --git a/docs/commands/harness.md b/docs/commands/harness.md index bbaf8d0..6830b18 100644 --- a/docs/commands/harness.md +++ b/docs/commands/harness.md @@ -36,7 +36,7 @@ Wires the current project's `.claude/` directory: |------|---------| | `.claude/hooks/ctx/session-start.sh` | `SessionStart` hook: prints `ctx map --budget 2000` for the model | | `.claude/hooks/ctx/post-tool-use.sh` | `PostToolUse` (Edit\|Write) hook: `ctx index`, then `ctx check --against HEAD --json` | -| `.claude/hooks/ctx/stop.sh` | `Stop` hook: `ctx score --against ` scorecard (non-blocking) | +| `.claude/hooks/ctx/stop.sh` | `Stop` hook: `ctx score --against ` scorecard (non-blocking by default; see [Blocking gates](#blocking-gates-ctx_gate_blocking)) | | `.ctx/rules.toml` | Commented starter rules file (only when absent) | | `.ctx/harness.lock` | Manifest of generated files and their checksums | @@ -70,6 +70,23 @@ ctx harness compat --require "" If the installed binary is older than the templates (exit code 3), the script prints a warning to **stderr** and exits 0 without performing its action — the session is never blocked, but you are told why. Model-bound content (map, check JSON, scorecard) goes to stdout; human-facing notices always go to stderr. +## Blocking gates (`CTX_GATE_BLOCKING`) + +The generated Stop hook runs `ctx score --against --fail-on "check_violations>0,new_duplication>0"`. By default a failed gate only prints the scorecard and a stderr note — the session stops normally. Set `CTX_GATE_BLOCKING=1` (exactly `1`) in the environment Claude Code runs in to turn gate failures into a **blocking stop**: the hook exits 2, which Claude Code treats as "keep working", so the session continues until the failed conditions in the scorecard are addressed. + +Only a genuine gate failure ever blocks. Everything else fails open: + +| `ctx score` result | `CTX_GATE_BLOCKING` | Hook exit | Effect | +|--------------------|---------------------|-----------|--------| +| Gates pass (exit 0) | any | 0 | Session stops normally | +| Gate condition fired (exit 1) | unset / anything but `1` | 0 | Non-blocking; a stderr note points at the scorecard | +| Gate condition fired (exit 1) | `1` | 2 | Blocking stop; Claude keeps working on the findings | +| Operational error (exit 2, compat mismatch, ctx not on PATH) | any | 0 | Fail open with a stderr warning | + +Pair it with `CTX_GATE_LOG` (consumed by `ctx score` itself, not the hook) to record every gate evaluation — including whether blocking mode was on — in a local JSONL log; see [ctx score — Gate logging](./score.md#gate-logging). + +> **Upgrading:** hook scripts are generated files. If your `.claude/hooks/ctx/stop.sh` predates `CTX_GATE_BLOCKING`, re-run `ctx harness init` after updating ctx to regenerate it (`ctx harness doctor`'s `templates_stale` check tells you when this is due). + ## `compat --require ` Exits 0 when the running binary satisfies the requirement, otherwise prints exactly one line to stderr and exits **3** (reserved exclusively for this subcommand). diff --git a/docs/commands/score.md b/docs/commands/score.md index 179aaa0..445c575 100644 --- a/docs/commands/score.md +++ b/docs/commands/score.md @@ -126,6 +126,42 @@ ctx score --against main --json } ``` +## Gate logging + +Set the `CTX_GATE_LOG` environment variable to make every `ctx score` run append one record describing the gate evaluation to a local log. This is how you build a paper trail of gate outcomes over time (e.g. from the Claude Code Stop hook) without changing what the command does. Opt-in and local-only — ctx ships no telemetry. + +| `CTX_GATE_LOG` value | Effect | +|----------------------|--------| +| unset, empty, or `0` | Logging disabled (the default) | +| `1` or `true` | Append to `.ctx/gate-log.jsonl` under the repo root | +| anything else | Treated as the log path (joined to the repo root when relative) | + +The log is **JSON Lines**: one complete JSON object per line, one line appended per evaluation. It is **not** the standard `--json` envelope — no `command`/`data` wrapper — and it is written regardless of whether `--json` is passed: + +```json +{"schema_version":1,"ts":"2026-07-10T19:25:43.893434Z","ctx_version":"0.3.0","source":"score","against":"HEAD","fail_on":"new_duplication>0","metrics":{"check_violations":0,"complexity_delta":0,"fan_out_delta":0,"files_changed":0,"new_duplication":0,"symbols_added":0,"symbols_removed":0},"failed_conditions":[],"outcome":"pass","blocking":false,"session_id":null} +``` + +| Field | Description | +|-------|-------------| +| `schema_version` | Version of the line format (currently `1`) | +| `ts` | Evaluation time, RFC 3339 UTC | +| `ctx_version` | The ctx version that evaluated the gate | +| `source` | The command that evaluated the gate (`"score"`) | +| `against` | The git reference the score was computed against | +| `fail_on` | The raw `--fail-on` expression, or `null` when none was given | +| `metrics` | The same seven-key metrics object the `--json` payload emits under `metrics` | +| `failed_conditions` | Rendered `--fail-on` conditions that fired (empty on pass) | +| `outcome` | `"pass"` or `"fail"` | +| `blocking` | Whether blocking mode was requested (`CTX_GATE_BLOCKING=1`; see [ctx harness](./harness.md)) | +| `session_id` | Claude Code session id (`CLAUDE_SESSION_ID`), or `null` | + +Logging is best-effort: an IO failure prints a warning to stderr and **never changes the command's exit code**. Query the log with standard JSONL tooling: + +```bash +jq -r 'select(.outcome == "fail") | [.ts, .failed_conditions[]] | @tsv' .ctx/gate-log.jsonl +``` + ## Caveats - **Fan-in approximation:** the baseline side is parsed in isolation, so cross-file callers are unknowable there. Fan-in is therefore counted as *same-file* callers on **both** sides, keeping the delta comparable. This is surfaced as a note in every run. diff --git a/docs/commands/snapshot.md b/docs/commands/snapshot.md new file mode 100644 index 0000000..1102c17 --- /dev/null +++ b/docs/commands/snapshot.md @@ -0,0 +1,219 @@ +# ctx snapshot + +Capture per-commit Parquet metric snapshots for longitudinal quality analysis. + +## Synopsis + +```bash +ctx snapshot [--force] [--churn-window ] [--json] +ctx snapshot backfill --since [--every ] [--churn-window ] [--json] +``` + +## Description + +Point-in-time commands like [`ctx score`](./score.md) answer "did *this change* make the code better or worse?". `ctx snapshot` answers the longitudinal version: **is the codebase trending better or worse over weeks and months?** Each run exports the current commit's per-file and per-symbol metrics, near-duplicate pairs, and capture metadata as one Parquet partition: + +```text +.ctx/snapshots/sha=/symbols.parquet per-symbol metrics +.ctx/snapshots/sha=/files.parquet per-file metrics + churn + violations +.ctx/snapshots/sha=/dup_pairs.parquet near-duplicate function pairs +.ctx/snapshots/sha=/meta.parquet capture metadata (1 row) +``` + +Accumulate partitions over time (one per commit), then query them with [`ctx sql --snapshots`](#querying-snapshots-with-ctx-sql) to plot duplication, violation, and hotspot trends across the history. + +A bare `ctx snapshot` captures HEAD: the index is refreshed incrementally first (same as `ctx score`), then the four Parquet files are written to a staging directory and moved into place with an atomic rename — readers never observe a half-written partition. If a partition for HEAD's sha already exists the command is a no-op (exit 0); `--force` rewrites it. When the working tree is dirty, a stderr warning notes that the snapshot is labeled with HEAD's sha but reflects the working tree. + +Snapshot capture requires the `duckdb` feature (on by default); builds without it exit 2. + +## Options + +| Option | Description | Default | +|--------|-------------|---------| +| `--force` | Overwrite an existing partition for HEAD | false | +| `--churn-window ` | How far back to count per-file churn (a `git log --since` date spec) | `"90 days ago"` | +| `--json` | Machine-readable output (global flag) | false | + +`backfill` adds: + +| Option | Description | Default | +|--------|-------------|---------| +| `--since ` | Starting commit/ref — the walk covers `REF..HEAD` first-parent, plus `REF` itself when it names a commit | required | +| `--every ` | Sample every Nth commit; sampling counts backwards from HEAD so the newest commit is always included | 1 | + +## Partition contents + +Every row of every table is denormalized with the partition stamp, so partitions union cleanly across commits with a single `read_parquet('.ctx/snapshots/*/*.parquet')` glob per table: + +| Column | Type | Description | +|--------|------|-------------| +| `commit_sha` | VARCHAR | Full sha of the snapshotted commit | +| `committed_at` | TIMESTAMP | Committer date of that commit, normalized to UTC | + +### `symbols.parquet` — one row per symbol + +Stamp columns plus `id`, `name`, `qualified_name`, `kind`, `file`, `line_start`, `line_end`, `is_public`, `complexity`, `fan_in`, `fan_out` — the same columns (and types) as the `v1.symbols` SQL view, minus `doc`. + +### `files.parquet` — one row per file + +Stamp columns plus: + +| Column | Type | Description | +|--------|------|-------------| +| `path` | VARCHAR | File path | +| `language` | VARCHAR | Detected language | +| `symbol_count` | BIGINT | Symbols defined in the file | +| `total_complexity` | DOUBLE | Sum of symbol complexity for the file | +| `max_complexity` | BIGINT | Highest single-symbol complexity in the file | +| `churn_commits` | INTEGER | Commits touching the file within the churn window | +| `violation_count` | INTEGER | Architecture-rule violations in the file (0 when `.ctx/rules.toml` is absent) | + +### `dup_pairs.parquet` — one row per near-duplicate pair + +Stamp columns plus: + +| Column | Type | Description | +|--------|------|-------------| +| `file_a` / `file_b` | VARCHAR | Files of the two symbols | +| `symbol_a` / `symbol_b` | VARCHAR | Names of the two symbols | +| `similarity` | DOUBLE | Verified token similarity (0–1) | +| `token_count_a` / `token_count_b` | BIGINT | Normalized token counts | + +Pairs use the same detector and thresholds as [`ctx score`](./score.md)'s `new_duplication` (Jaccard >= 0.85, >= 50 tokens). + +### `meta.parquet` — one row per partition + +Stamp columns plus: + +| Column | Type | Description | +|--------|------|-------------| +| `captured_at` | VARCHAR | RFC 3339 time the snapshot was captured | +| `ctx_version` | VARCHAR | ctx version that wrote the partition | +| `snapshot_schema_version` | INTEGER | Snapshot Parquet schema version (currently 1) | +| `capture_mode` | VARCHAR | `live` (bare `ctx snapshot`) or `backfill` | + +## Backfilling history + +`ctx snapshot backfill --since ` captures partitions for historical commits so trend queries have a past to look at. It walks the **first-parent** range `REF..HEAD` oldest-first (including `REF` itself when it resolves to a commit), checks each missing commit out into a temporary `git worktree`, snapshots it into *this* repository's `.ctx/snapshots/`, and removes the worktree again — your working tree is never touched. + +```bash +ctx snapshot backfill --since v0.1.0 # every first-parent commit since v0.1.0 +ctx snapshot backfill --since main~200 --every 5 # sample every 5th commit +``` + +Caveats: + +- **First-parent only.** Commits that live on merged side branches are not walked; a squash/merge-based history is covered completely, a fast-forward-heavy one is not. +- **The churn window's lower bound is relative to now.** `--churn-window` is a `git log --since` date spec, and git resolves relative specs like `"90 days ago"` against the wall clock — even in backfill mode, where only the *upper* bound is anchored to each commit's date. For old commits, a relative window therefore spans more than 90 days of their history; pass an absolute date if that matters to your analysis. +- **Per-commit failures are skipped, not fatal.** A commit that fails to index or export is logged to stderr and the walk continues; the final report covers the captured and already-existing partitions only. Existing partitions are always skipped (no `--force` in backfill mode). + +## JSON output + +`ctx snapshot --json` emits the standard envelope with command `snapshot.capture`: + +```json +{ + "command": "snapshot.capture", + "ctx_version": "0.3.0", + "data": { + "commit_sha": "86258796aa7f19c06d310f6abce6c5f56465e316", + "committed_at": "2026-07-10T21:25:27+02:00", + "dup_pairs": 0, + "files": 2, + "partition_dir": ".ctx/snapshots/sha=86258796aa7f19c06d310f6abce6c5f56465e316", + "skipped_existing": false, + "symbols": 3, + "violations": 0 + }, + "generated_at": "2026-07-10T19:25:28.09532Z" +} +``` + +`ctx snapshot backfill --json` emits `snapshot.backfill` with per-partition reports: + +```json +{ + "command": "snapshot.backfill", + "ctx_version": "0.3.0", + "data": { + "captured": 1, + "since": "3019df548fc417c7b6b06bef7defb74a0c01ba78", + "skipped_existing": 1, + "snapshots": [ + { + "commit_sha": "3019df548fc417c7b6b06bef7defb74a0c01ba78", + "committed_at": "2026-07-10T21:25:27+02:00", + "dup_pairs": 0, + "files": 1, + "partition_dir": ".ctx/snapshots/sha=3019df548fc417c7b6b06bef7defb74a0c01ba78", + "skipped_existing": false, + "symbols": 2, + "violations": 0 + }, + { + "commit_sha": "86258796aa7f19c06d310f6abce6c5f56465e316", + "committed_at": "2026-07-10T21:25:27+02:00", + "dup_pairs": 0, + "files": 0, + "partition_dir": ".ctx/snapshots/sha=86258796aa7f19c06d310f6abce6c5f56465e316", + "skipped_existing": true, + "symbols": 0, + "violations": 0 + } + ] + }, + "generated_at": "2026-07-10T19:25:28.670878Z" +} +``` + +When `skipped_existing` is true the partition was left untouched and its row counts are reported as zero — they are not re-read from the existing Parquet files. See [JSON Output](../json-output.md). + +## Querying snapshots with `ctx sql` + +`ctx sql --snapshots[=DIR]` (default `DIR` is `.ctx/snapshots`) loads the partitions as `snap.files`, `snap.symbols`, `snap.dup_pairs`, and `snap.meta` tables alongside the usual `v1` views. For example, the violation trend across all snapshotted commits: + +```bash +ctx sql --snapshots "SELECT commit_sha, min(committed_at) AS committed_at, + sum(violation_count) AS violations +FROM snap.files +GROUP BY commit_sha +ORDER BY committed_at;" +``` + +The `snap.*` column reference and more canned trend queries (duplication trend, hotspot mass) live in the SQL schema reference — run `ctx sql --schema`, or see [SQL Schema (v1)](https://docs.agentis.tools/sql-schema) on the docs site. + +## Snapshots in CI (data branch) + +To build the trend history automatically, capture one partition per merge to your default branch and append it to an orphan *data branch*, keeping metric history out of your main history. ctx's own repository does this in `.github/workflows/snapshot.yml`: on every push to `main` it runs `ctx index && ctx snapshot --json`, checks the `ctx-snapshots` orphan branch out into a linked worktree, copies the new partition in, and pushes (runs are serialized via a concurrency group, and re-runs on the same sha are no-ops). Analyze the accumulated history from any machine: + +```bash +git fetch origin ctx-snapshots +git worktree add ../snapshots ctx-snapshots +ctx sql --snapshots=../snapshots/snapshots "SELECT ..." +``` + +## Exit Codes + +| Code | Meaning | +|------|---------| +| 0 | Snapshot written, or the partition already existed | +| 2 | Operational error (not a git repo, build without the `duckdb` feature, IO failure) | + +## Examples + +```bash +ctx snapshot # snapshot HEAD (skip if it exists) +ctx snapshot --force # rewrite the HEAD partition +ctx snapshot --json # machine-readable report +ctx snapshot --churn-window "180 days ago" # wider churn window +ctx snapshot backfill --since v0.1.0 # snapshot v0.1.0..HEAD (first-parent) +ctx snapshot backfill --since main~20 --every 5 +ctx sql --snapshots "SELECT commit_sha, count(*) FROM snap.dup_pairs GROUP BY commit_sha" +``` + +## See Also + +- [ctx score](./score.md) — the point-in-time quality delta the snapshots accumulate over history +- [ctx duplicates](./duplicates.md) — the near-duplicate detector behind `dup_pairs.parquet` +- [ctx check](./check.md) — the architecture rules behind `violation_count` +- [JSON Output](../json-output.md) diff --git a/docs/json-output.md b/docs/json-output.md index 81a19df..ab498d2 100644 --- a/docs/json-output.md +++ b/docs/json-output.md @@ -511,6 +511,65 @@ Exit codes follow the suite convention: 0 = no violations, 1 = at least one viol Exit codes follow the suite convention: 0 = clean or informational, 1 = at least one `--fail-on` condition met, 2 = operational error (not a git repo, bad reference, malformed `--fail-on`, invalid rules file). +Separately from `--json`, setting the `CTX_GATE_LOG` environment variable makes every `ctx score` run append one **JSONL** record (a bare JSON object per line — *not* this envelope) describing the gate evaluation to a local log, default `.ctx/gate-log.jsonl`. See [ctx score — Gate logging](commands/score.md#gate-logging). + +### `snapshot.capture` + +`ctx snapshot [--force] [--churn-window SPEC] --json` + +```json +{ + "commit_sha": "86258796aa7f19c06d310f6abce6c5f56465e316", + "committed_at": "2026-07-10T21:25:27+02:00", + "partition_dir": ".ctx/snapshots/sha=86258796aa7f19c06d310f6abce6c5f56465e316", + "files": 2, + "symbols": 3, + "dup_pairs": 0, + "violations": 0, + "skipped_existing": false +} +``` + +One report per captured partition. `commit_sha` and `committed_at` (the committer date, strict ISO 8601) identify the snapshotted commit; `partition_dir` is where the four Parquet files were written. `files`, `symbols`, and `dup_pairs` are the row counts of the corresponding Parquet tables; `violations` is the total architecture-rule violation count (0 when `.ctx/rules.toml` is absent). When a partition for the commit already exists and `--force` was not given, `skipped_existing` is `true` and the counts are reported as zero — they are not re-read from the existing files. + +Exit codes: 0 = snapshot written or partition already existed, 2 = operational error (not a git repo, build without the `duckdb` feature, IO failure). + +### `snapshot.backfill` + +`ctx snapshot backfill --since REF [--every N] [--churn-window SPEC] --json` + +```json +{ + "since": "3019df548fc417c7b6b06bef7defb74a0c01ba78", + "captured": 1, + "skipped_existing": 1, + "snapshots": [ + { + "commit_sha": "3019df548fc417c7b6b06bef7defb74a0c01ba78", + "committed_at": "2026-07-10T21:25:27+02:00", + "partition_dir": ".ctx/snapshots/sha=3019df548fc417c7b6b06bef7defb74a0c01ba78", + "files": 1, + "symbols": 2, + "dup_pairs": 0, + "violations": 0, + "skipped_existing": false + }, + { + "commit_sha": "86258796aa7f19c06d310f6abce6c5f56465e316", + "committed_at": "2026-07-10T21:25:27+02:00", + "partition_dir": ".ctx/snapshots/sha=86258796aa7f19c06d310f6abce6c5f56465e316", + "files": 0, + "symbols": 0, + "dup_pairs": 0, + "violations": 0, + "skipped_existing": true + } + ] +} +``` + +`since` is the `--since` argument as given. `snapshots` carries one `snapshot.capture`-shaped report per partition, oldest first; `captured` and `skipped_existing` are the counts of new vs. already-existing partitions among them. Commits that failed to capture are logged to stderr and **do not appear** in `snapshots` — the walk continues past them, and the exit code stays 0. + ### `harness.init` `ctx harness init [--mode local|plugin] [--force] --json` diff --git a/docs/website/docs/architecture.md b/docs/website/docs/architecture.md index a26667c..a616f37 100644 --- a/docs/website/docs/architecture.md +++ b/docs/website/docs/architecture.md @@ -533,16 +533,20 @@ pub fn stream_context( ## Performance Characteristics -### Indexing -- ~2000 files in less than 10 seconds -- Incremental updates: under 1 second for changed files -- Memory: ~100MB for large codebases - -### Queries -- Symbol search: under 10ms -- Call graph (depth 5): under 50ms -- Impact analysis: under 100ms -- Semantic search: ~100ms (depends on embedding count) +### Enforced latency budgets + +These are not aspirations: each number is a budget enforced by the operational perf harness ([`perf/README.md`](https://github.com/agentis-tools/ctx/blob/main/perf/README.md)), which CI runs as an advisory `perf` job on every PR. Each scenario spawns a prebuilt `ctx` binary against a deterministic synthetic fixture of 2,000 files and takes the median of 5 timed runs: + +| Operation | Fixture | Budget | +|-----------|---------|--------| +| `ctx index`, incremental (1 changed file) | 2,000 files | 300 ms | +| `ctx score --against HEAD` (3 changed files) | 2,000 files | 2 s | +| `ctx check --against HEAD` (3 changed files) | 2,000 files | 1 s | +| `ctx map --budget 2000` (warm rank cache) | 2,000 files | 500 ms | +| `ctx sql` (aggregate query over `v1`) | 2,000 files | 500 ms | +| `ctx index`, cold (full suite only) | ~150k LOC | 60 s | + +Peak memory (`ru_maxrss`) across the incremental-index, score, check, and map runs must stay below **300 MB**. CI runners get a 1.5x budget allowance (`CTX_PERF_BUDGET_SCALE`) because hosted runners are slower and noisier, and medians are additionally gated at 1.20x a committed per-runner-class baseline (`perf/baselines/`). ### Storage - Symbols: ~500 bytes each (uncompressed) diff --git a/docs/website/docs/commands/harness.md b/docs/website/docs/commands/harness.md index bcefc85..27121b4 100644 --- a/docs/website/docs/commands/harness.md +++ b/docs/website/docs/commands/harness.md @@ -42,7 +42,7 @@ Wires the current project's `.claude/` directory: |------|---------| | `.claude/hooks/ctx/session-start.sh` | `SessionStart` hook: prints `ctx map --budget 2000` for the model | | `.claude/hooks/ctx/post-tool-use.sh` | `PostToolUse` (Edit\|Write) hook: `ctx index`, then `ctx check --against HEAD --json` | -| `.claude/hooks/ctx/stop.sh` | `Stop` hook: `ctx score --against ` scorecard (non-blocking) | +| `.claude/hooks/ctx/stop.sh` | `Stop` hook: `ctx score --against ` scorecard (non-blocking by default; see [Blocking gates](#blocking-gates-ctx_gate_blocking)) | | `.ctx/rules.toml` | Commented starter rules file (only when absent) | | `.ctx/harness.lock` | Manifest of generated files and their checksums | @@ -76,6 +76,23 @@ ctx harness compat --require "" If the installed binary is older than the templates (exit code 3), the script prints a warning to **stderr** and exits 0 without performing its action — the session is never blocked, but you are told why. Model-bound content (map, check JSON, scorecard) goes to stdout; human-facing notices always go to stderr. +## Blocking gates (`CTX_GATE_BLOCKING`) + +The generated Stop hook runs `ctx score --against --fail-on "check_violations>0,new_duplication>0"`. By default a failed gate only prints the scorecard and a stderr note — the session stops normally. Set `CTX_GATE_BLOCKING=1` (exactly `1`) in the environment Claude Code runs in to turn gate failures into a **blocking stop**: the hook exits 2, which Claude Code treats as "keep working", so the session continues until the failed conditions in the scorecard are addressed. + +Only a genuine gate failure ever blocks. Everything else fails open: + +| `ctx score` result | `CTX_GATE_BLOCKING` | Hook exit | Effect | +|--------------------|---------------------|-----------|--------| +| Gates pass (exit 0) | any | 0 | Session stops normally | +| Gate condition fired (exit 1) | unset / anything but `1` | 0 | Non-blocking; a stderr note points at the scorecard | +| Gate condition fired (exit 1) | `1` | 2 | Blocking stop; Claude keeps working on the findings | +| Operational error (exit 2, compat mismatch, ctx not on PATH) | any | 0 | Fail open with a stderr warning | + +Pair it with `CTX_GATE_LOG` (consumed by `ctx score` itself, not the hook) to record every gate evaluation — including whether blocking mode was on — in a local JSONL log; see [ctx score — Gate logging](./score.md#gate-logging). + +> **Upgrading:** hook scripts are generated files. If your `.claude/hooks/ctx/stop.sh` predates `CTX_GATE_BLOCKING`, re-run `ctx harness init` after updating ctx to regenerate it (`ctx harness doctor`'s `templates_stale` check tells you when this is due). + ## `compat --require ` Exits 0 when the running binary satisfies the requirement, otherwise prints exactly one line to stderr and exits **3** (reserved exclusively for this subcommand). diff --git a/docs/website/docs/commands/score.md b/docs/website/docs/commands/score.md index 51521c9..f90a6dd 100644 --- a/docs/website/docs/commands/score.md +++ b/docs/website/docs/commands/score.md @@ -132,6 +132,42 @@ ctx score --against main --json } ``` +## Gate logging + +Set the `CTX_GATE_LOG` environment variable to make every `ctx score` run append one record describing the gate evaluation to a local log. This is how you build a paper trail of gate outcomes over time (e.g. from the Claude Code Stop hook) without changing what the command does. Opt-in and local-only — ctx ships no telemetry. + +| `CTX_GATE_LOG` value | Effect | +|----------------------|--------| +| unset, empty, or `0` | Logging disabled (the default) | +| `1` or `true` | Append to `.ctx/gate-log.jsonl` under the repo root | +| anything else | Treated as the log path (joined to the repo root when relative) | + +The log is **JSON Lines**: one complete JSON object per line, one line appended per evaluation. It is **not** the standard `--json` envelope — no `command`/`data` wrapper — and it is written regardless of whether `--json` is passed: + +```json +{"schema_version":1,"ts":"2026-07-10T19:25:43.893434Z","ctx_version":"0.3.0","source":"score","against":"HEAD","fail_on":"new_duplication>0","metrics":{"check_violations":0,"complexity_delta":0,"fan_out_delta":0,"files_changed":0,"new_duplication":0,"symbols_added":0,"symbols_removed":0},"failed_conditions":[],"outcome":"pass","blocking":false,"session_id":null} +``` + +| Field | Description | +|-------|-------------| +| `schema_version` | Version of the line format (currently `1`) | +| `ts` | Evaluation time, RFC 3339 UTC | +| `ctx_version` | The ctx version that evaluated the gate | +| `source` | The command that evaluated the gate (`"score"`) | +| `against` | The git reference the score was computed against | +| `fail_on` | The raw `--fail-on` expression, or `null` when none was given | +| `metrics` | The same seven-key metrics object the `--json` payload emits under `metrics` | +| `failed_conditions` | Rendered `--fail-on` conditions that fired (empty on pass) | +| `outcome` | `"pass"` or `"fail"` | +| `blocking` | Whether blocking mode was requested (`CTX_GATE_BLOCKING=1`; see [ctx harness](./harness.md)) | +| `session_id` | Claude Code session id (`CLAUDE_SESSION_ID`), or `null` | + +Logging is best-effort: an IO failure prints a warning to stderr and **never changes the command's exit code**. Query the log with standard JSONL tooling: + +```bash +jq -r 'select(.outcome == "fail") | [.ts, .failed_conditions[]] | @tsv' .ctx/gate-log.jsonl +``` + ## Caveats - **Fan-in approximation:** the baseline side is parsed in isolation, so cross-file callers are unknowable there. Fan-in is therefore counted as *same-file* callers on **both** sides, keeping the delta comparable. This is surfaced as a note in every run. diff --git a/docs/website/docs/commands/snapshot.md b/docs/website/docs/commands/snapshot.md new file mode 100644 index 0000000..e5dd27a --- /dev/null +++ b/docs/website/docs/commands/snapshot.md @@ -0,0 +1,225 @@ +--- +id: snapshot +title: ctx snapshot +sidebar_position: 14 +--- + +# ctx snapshot + +Capture per-commit Parquet metric snapshots for longitudinal quality analysis. + +## Synopsis + +```bash +ctx snapshot [--force] [--churn-window ] [--json] +ctx snapshot backfill --since [--every ] [--churn-window ] [--json] +``` + +## Description + +Point-in-time commands like [`ctx score`](./score.md) answer "did *this change* make the code better or worse?". `ctx snapshot` answers the longitudinal version: **is the codebase trending better or worse over weeks and months?** Each run exports the current commit's per-file and per-symbol metrics, near-duplicate pairs, and capture metadata as one Parquet partition: + +```text +.ctx/snapshots/sha=/symbols.parquet per-symbol metrics +.ctx/snapshots/sha=/files.parquet per-file metrics + churn + violations +.ctx/snapshots/sha=/dup_pairs.parquet near-duplicate function pairs +.ctx/snapshots/sha=/meta.parquet capture metadata (1 row) +``` + +Accumulate partitions over time (one per commit), then query them with [`ctx sql --snapshots`](#querying-snapshots-with-ctx-sql) to plot duplication, violation, and hotspot trends across the history. + +A bare `ctx snapshot` captures HEAD: the index is refreshed incrementally first (same as `ctx score`), then the four Parquet files are written to a staging directory and moved into place with an atomic rename — readers never observe a half-written partition. If a partition for HEAD's sha already exists the command is a no-op (exit 0); `--force` rewrites it. When the working tree is dirty, a stderr warning notes that the snapshot is labeled with HEAD's sha but reflects the working tree. + +Snapshot capture requires the `duckdb` feature (on by default); builds without it exit 2. + +## Options + +| Option | Description | Default | +|--------|-------------|---------| +| `--force` | Overwrite an existing partition for HEAD | false | +| `--churn-window ` | How far back to count per-file churn (a `git log --since` date spec) | `"90 days ago"` | +| `--json` | Machine-readable output (global flag) | false | + +`backfill` adds: + +| Option | Description | Default | +|--------|-------------|---------| +| `--since ` | Starting commit/ref — the walk covers `REF..HEAD` first-parent, plus `REF` itself when it names a commit | required | +| `--every ` | Sample every Nth commit; sampling counts backwards from HEAD so the newest commit is always included | 1 | + +## Partition contents + +Every row of every table is denormalized with the partition stamp, so partitions union cleanly across commits with a single `read_parquet('.ctx/snapshots/*/*.parquet')` glob per table: + +| Column | Type | Description | +|--------|------|-------------| +| `commit_sha` | VARCHAR | Full sha of the snapshotted commit | +| `committed_at` | TIMESTAMP | Committer date of that commit, normalized to UTC | + +### `symbols.parquet` — one row per symbol + +Stamp columns plus `id`, `name`, `qualified_name`, `kind`, `file`, `line_start`, `line_end`, `is_public`, `complexity`, `fan_in`, `fan_out` — the same columns (and types) as the `v1.symbols` SQL view, minus `doc`. + +### `files.parquet` — one row per file + +Stamp columns plus: + +| Column | Type | Description | +|--------|------|-------------| +| `path` | VARCHAR | File path | +| `language` | VARCHAR | Detected language | +| `symbol_count` | BIGINT | Symbols defined in the file | +| `total_complexity` | DOUBLE | Sum of symbol complexity for the file | +| `max_complexity` | BIGINT | Highest single-symbol complexity in the file | +| `churn_commits` | INTEGER | Commits touching the file within the churn window | +| `violation_count` | INTEGER | Architecture-rule violations in the file (0 when `.ctx/rules.toml` is absent) | + +### `dup_pairs.parquet` — one row per near-duplicate pair + +Stamp columns plus: + +| Column | Type | Description | +|--------|------|-------------| +| `file_a` / `file_b` | VARCHAR | Files of the two symbols | +| `symbol_a` / `symbol_b` | VARCHAR | Names of the two symbols | +| `similarity` | DOUBLE | Verified token similarity (0–1) | +| `token_count_a` / `token_count_b` | BIGINT | Normalized token counts | + +Pairs use the same detector and thresholds as [`ctx score`](./score.md)'s `new_duplication` (Jaccard >= 0.85, >= 50 tokens). + +### `meta.parquet` — one row per partition + +Stamp columns plus: + +| Column | Type | Description | +|--------|------|-------------| +| `captured_at` | VARCHAR | RFC 3339 time the snapshot was captured | +| `ctx_version` | VARCHAR | ctx version that wrote the partition | +| `snapshot_schema_version` | INTEGER | Snapshot Parquet schema version (currently 1) | +| `capture_mode` | VARCHAR | `live` (bare `ctx snapshot`) or `backfill` | + +## Backfilling history + +`ctx snapshot backfill --since ` captures partitions for historical commits so trend queries have a past to look at. It walks the **first-parent** range `REF..HEAD` oldest-first (including `REF` itself when it resolves to a commit), checks each missing commit out into a temporary `git worktree`, snapshots it into *this* repository's `.ctx/snapshots/`, and removes the worktree again — your working tree is never touched. + +```bash +ctx snapshot backfill --since v0.1.0 # every first-parent commit since v0.1.0 +ctx snapshot backfill --since main~200 --every 5 # sample every 5th commit +``` + +Caveats: + +- **First-parent only.** Commits that live on merged side branches are not walked; a squash/merge-based history is covered completely, a fast-forward-heavy one is not. +- **The churn window's lower bound is relative to now.** `--churn-window` is a `git log --since` date spec, and git resolves relative specs like `"90 days ago"` against the wall clock — even in backfill mode, where only the *upper* bound is anchored to each commit's date. For old commits, a relative window therefore spans more than 90 days of their history; pass an absolute date if that matters to your analysis. +- **Per-commit failures are skipped, not fatal.** A commit that fails to index or export is logged to stderr and the walk continues; the final report covers the captured and already-existing partitions only. Existing partitions are always skipped (no `--force` in backfill mode). + +## JSON output + +`ctx snapshot --json` emits the standard envelope with command `snapshot.capture`: + +```json +{ + "command": "snapshot.capture", + "ctx_version": "0.3.0", + "data": { + "commit_sha": "86258796aa7f19c06d310f6abce6c5f56465e316", + "committed_at": "2026-07-10T21:25:27+02:00", + "dup_pairs": 0, + "files": 2, + "partition_dir": ".ctx/snapshots/sha=86258796aa7f19c06d310f6abce6c5f56465e316", + "skipped_existing": false, + "symbols": 3, + "violations": 0 + }, + "generated_at": "2026-07-10T19:25:28.09532Z" +} +``` + +`ctx snapshot backfill --json` emits `snapshot.backfill` with per-partition reports: + +```json +{ + "command": "snapshot.backfill", + "ctx_version": "0.3.0", + "data": { + "captured": 1, + "since": "3019df548fc417c7b6b06bef7defb74a0c01ba78", + "skipped_existing": 1, + "snapshots": [ + { + "commit_sha": "3019df548fc417c7b6b06bef7defb74a0c01ba78", + "committed_at": "2026-07-10T21:25:27+02:00", + "dup_pairs": 0, + "files": 1, + "partition_dir": ".ctx/snapshots/sha=3019df548fc417c7b6b06bef7defb74a0c01ba78", + "skipped_existing": false, + "symbols": 2, + "violations": 0 + }, + { + "commit_sha": "86258796aa7f19c06d310f6abce6c5f56465e316", + "committed_at": "2026-07-10T21:25:27+02:00", + "dup_pairs": 0, + "files": 0, + "partition_dir": ".ctx/snapshots/sha=86258796aa7f19c06d310f6abce6c5f56465e316", + "skipped_existing": true, + "symbols": 0, + "violations": 0 + } + ] + }, + "generated_at": "2026-07-10T19:25:28.670878Z" +} +``` + +When `skipped_existing` is true the partition was left untouched and its row counts are reported as zero — they are not re-read from the existing Parquet files. See [JSON Output](../json-output.md). + +## Querying snapshots with `ctx sql` + +`ctx sql --snapshots[=DIR]` (default `DIR` is `.ctx/snapshots`) loads the partitions as `snap.files`, `snap.symbols`, `snap.dup_pairs`, and `snap.meta` tables alongside the usual `v1` views. For example, the violation trend across all snapshotted commits: + +```bash +ctx sql --snapshots "SELECT commit_sha, min(committed_at) AS committed_at, + sum(violation_count) AS violations +FROM snap.files +GROUP BY commit_sha +ORDER BY committed_at;" +``` + +The `snap.*` column reference and more canned trend queries (duplication trend, hotspot mass) live in the SQL schema reference — run `ctx sql --schema`, or see the [SQL Schema (v1)](../sql-schema.md) reference. + +## Snapshots in CI (data branch) + +To build the trend history automatically, capture one partition per merge to your default branch and append it to an orphan *data branch*, keeping metric history out of your main history. ctx's own repository does this in `.github/workflows/snapshot.yml`: on every push to `main` it runs `ctx index && ctx snapshot --json`, checks the `ctx-snapshots` orphan branch out into a linked worktree, copies the new partition in, and pushes (runs are serialized via a concurrency group, and re-runs on the same sha are no-ops). Analyze the accumulated history from any machine: + +```bash +git fetch origin ctx-snapshots +git worktree add ../snapshots ctx-snapshots +ctx sql --snapshots=../snapshots/snapshots "SELECT ..." +``` + +## Exit Codes + +| Code | Meaning | +|------|---------| +| 0 | Snapshot written, or the partition already existed | +| 2 | Operational error (not a git repo, build without the `duckdb` feature, IO failure) | + +## Examples + +```bash +ctx snapshot # snapshot HEAD (skip if it exists) +ctx snapshot --force # rewrite the HEAD partition +ctx snapshot --json # machine-readable report +ctx snapshot --churn-window "180 days ago" # wider churn window +ctx snapshot backfill --since v0.1.0 # snapshot v0.1.0..HEAD (first-parent) +ctx snapshot backfill --since main~20 --every 5 +ctx sql --snapshots "SELECT commit_sha, count(*) FROM snap.dup_pairs GROUP BY commit_sha" +``` + +## See Also + +- [ctx score](./score.md) — the point-in-time quality delta the snapshots accumulate over history +- [ctx duplicates](./duplicates.md) — the near-duplicate detector behind `dup_pairs.parquet` +- [ctx check](./check.md) — the architecture rules behind `violation_count` +- [JSON Output](../json-output.md) diff --git a/docs/website/docs/json-output.md b/docs/website/docs/json-output.md index 5ba7c94..141484a 100644 --- a/docs/website/docs/json-output.md +++ b/docs/website/docs/json-output.md @@ -395,6 +395,65 @@ Exit codes follow the suite convention: 0 = no violations, 1 = at least one viol Exit codes follow the suite convention: 0 = clean or informational, 1 = at least one `--fail-on` condition met, 2 = operational error (not a git repo, bad reference, malformed `--fail-on`, invalid rules file). +Separately from `--json`, setting the `CTX_GATE_LOG` environment variable makes every `ctx score` run append one **JSONL** record (a bare JSON object per line — *not* this envelope) describing the gate evaluation to a local log, default `.ctx/gate-log.jsonl`. See [ctx score — Gate logging](commands/score.md#gate-logging). + +### `snapshot.capture` + +`ctx snapshot [--force] [--churn-window SPEC] --json` + +```json +{ + "commit_sha": "86258796aa7f19c06d310f6abce6c5f56465e316", + "committed_at": "2026-07-10T21:25:27+02:00", + "partition_dir": ".ctx/snapshots/sha=86258796aa7f19c06d310f6abce6c5f56465e316", + "files": 2, + "symbols": 3, + "dup_pairs": 0, + "violations": 0, + "skipped_existing": false +} +``` + +One report per captured partition. `commit_sha` and `committed_at` (the committer date, strict ISO 8601) identify the snapshotted commit; `partition_dir` is where the four Parquet files were written. `files`, `symbols`, and `dup_pairs` are the row counts of the corresponding Parquet tables; `violations` is the total architecture-rule violation count (0 when `.ctx/rules.toml` is absent). When a partition for the commit already exists and `--force` was not given, `skipped_existing` is `true` and the counts are reported as zero — they are not re-read from the existing files. + +Exit codes: 0 = snapshot written or partition already existed, 2 = operational error (not a git repo, build without the `duckdb` feature, IO failure). + +### `snapshot.backfill` + +`ctx snapshot backfill --since REF [--every N] [--churn-window SPEC] --json` + +```json +{ + "since": "3019df548fc417c7b6b06bef7defb74a0c01ba78", + "captured": 1, + "skipped_existing": 1, + "snapshots": [ + { + "commit_sha": "3019df548fc417c7b6b06bef7defb74a0c01ba78", + "committed_at": "2026-07-10T21:25:27+02:00", + "partition_dir": ".ctx/snapshots/sha=3019df548fc417c7b6b06bef7defb74a0c01ba78", + "files": 1, + "symbols": 2, + "dup_pairs": 0, + "violations": 0, + "skipped_existing": false + }, + { + "commit_sha": "86258796aa7f19c06d310f6abce6c5f56465e316", + "committed_at": "2026-07-10T21:25:27+02:00", + "partition_dir": ".ctx/snapshots/sha=86258796aa7f19c06d310f6abce6c5f56465e316", + "files": 0, + "symbols": 0, + "dup_pairs": 0, + "violations": 0, + "skipped_existing": true + } + ] +} +``` + +`since` is the `--since` argument as given. `snapshots` carries one `snapshot.capture`-shaped report per partition, oldest first; `captured` and `skipped_existing` are the counts of new vs. already-existing partitions among them. Commits that failed to capture are logged to stderr and **do not appear** in `snapshots` — the walk continues past them, and the exit code stays 0. + ### `harness.init` `ctx harness init [--mode local|plugin] [--force] --json` diff --git a/docs/website/docs/sql-schema.md b/docs/website/docs/sql-schema.md index a601809..0d1b1a9 100644 --- a/docs/website/docs/sql-schema.md +++ b/docs/website/docs/sql-schema.md @@ -98,3 +98,78 @@ FROM v1.symbols WHERE kind IN ('function', 'method') AND is_public AND fan_in = 0 ORDER BY file, name; ``` + +## Snapshot tables (`snap.*`) — only with `--snapshots` + +`ctx sql --snapshots[=DIR]` (default `DIR` is `.ctx/snapshots`) additionally +loads the Parquet snapshot partitions written by `ctx snapshot` as in-memory +tables in the `snap` schema. These tables exist **only** when `--snapshots` +is passed; without it, any `snap.*` reference is an error. Every row is +denormalized with the partition stamp: + +| Column | Type | Description | +|----------------|-----------|------------------------------------------------| +| `commit_sha` | VARCHAR | Full sha of the snapshotted commit | +| `committed_at` | TIMESTAMP | Committer date of that commit, normalized to UTC | + +### `snap.files` — one row per file per commit + +Stamp columns plus: + +| Column | Type | Description | +|--------------------|---------|-----------------------------------------------| +| `path` | VARCHAR | File path | +| `language` | VARCHAR | Detected language | +| `symbol_count` | BIGINT | Symbols defined in the file | +| `total_complexity` | DOUBLE | Sum of symbol complexity for the file | +| `max_complexity` | BIGINT | Highest single-symbol complexity in the file | +| `churn_commits` | INTEGER | Commits touching the file in the churn window | +| `violation_count` | INTEGER | Architecture-rule violations in the file | + +### `snap.symbols` — one row per symbol per commit + +Stamp columns plus the `v1.symbols` columns `id`, `name`, `qualified_name`, +`kind`, `file`, `line_start`, `line_end`, `is_public`, `complexity`, +`fan_in`, and `fan_out` (same types as in `v1.symbols`; no `doc`). + +### `snap.dup_pairs` — one row per near-duplicate pair per commit + +Stamp columns plus: + +| Column | Type | Description | +|-----------------|---------|----------------------------------------| +| `file_a` | VARCHAR | File of the first symbol | +| `symbol_a` | VARCHAR | Name of the first symbol | +| `file_b` | VARCHAR | File of the second symbol | +| `symbol_b` | VARCHAR | Name of the second symbol | +| `similarity` | DOUBLE | Verified token similarity (0–1) | +| `token_count_a` | BIGINT | Normalized token count of the first | +| `token_count_b` | BIGINT | Normalized token count of the second | + +### `snap.meta` — one row per partition + +Stamp columns plus: + +| Column | Type | Description | +|---------------------------|---------|-------------------------------------------| +| `captured_at` | VARCHAR | RFC 3339 time the snapshot was captured | +| `ctx_version` | VARCHAR | ctx version that wrote the partition | +| `snapshot_schema_version` | INTEGER | Snapshot Parquet schema version | +| `capture_mode` | VARCHAR | `live` or `backfill` | + +### Trend queries + +```sql +-- Duplication trend +SELECT commit_sha, min(committed_at) AS committed_at, count(*) AS dup_pairs FROM snap.dup_pairs GROUP BY commit_sha ORDER BY committed_at; +``` + +```sql +-- Violation trend +SELECT commit_sha, min(committed_at) AS committed_at, sum(violation_count) AS violations FROM snap.files GROUP BY commit_sha ORDER BY committed_at; +``` + +```sql +-- Hotspot mass (top-decile complexity by commit) +WITH ranked AS (SELECT commit_sha, committed_at, churn_commits * total_complexity AS mass, percent_rank() OVER (PARTITION BY commit_sha ORDER BY total_complexity) AS pr FROM snap.files) SELECT commit_sha, min(committed_at) AS committed_at, sum(mass) AS hotspot_mass FROM ranked WHERE pr >= 0.9 GROUP BY commit_sha ORDER BY committed_at; +``` diff --git a/docs/website/sidebars.ts b/docs/website/sidebars.ts index 0684b8d..1d71f77 100644 --- a/docs/website/sidebars.ts +++ b/docs/website/sidebars.ts @@ -26,6 +26,7 @@ const sidebars: SidebarsConfig = { 'commands/hotspots', 'commands/duplicates', 'commands/sql', + 'commands/snapshot', ], }, { diff --git a/perf/.gitignore b/perf/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/perf/.gitignore @@ -0,0 +1 @@ +/target diff --git a/perf/Cargo.lock b/perf/Cargo.lock new file mode 100644 index 0000000..2af75ca --- /dev/null +++ b/perf/Cargo.lock @@ -0,0 +1,4581 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "agentis-ctx" +version = "0.3.0" +dependencies = [ + "clap", + "dirs", + "fastembed", + "flate2", + "globset", + "ignore", + "notify", + "notify-debouncer-mini", + "rayon", + "reqwest", + "rusqlite", + "rustyline", + "semver", + "serde", + "serde_json", + "sha2", + "solang-parser", + "sqlite-vec", + "tar", + "thiserror 1.0.69", + "tiktoken-rs", + "time", + "tokio", + "toml", + "tree-sitter", + "tree-sitter-go", + "tree-sitter-javascript", + "tree-sitter-python", + "tree-sitter-rust", + "tree-sitter-typescript", + "zerocopy 0.7.35", + "zip", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "serde", + "version_check", + "zerocopy 0.8.54", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "aligned" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee4508988c62edf04abd8d92897fca0c2995d907ce1dfeaf369dac3716a40685" +dependencies = [ + "as-slice", +] + +[[package]] +name = "aligned-vec" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc890384c8602f339876ded803c97ad529f3842aba97f6392b3dba0dd171769b" +dependencies = [ + "equator", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + +[[package]] +name = "arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +dependencies = [ + "derive_arbitrary", +] + +[[package]] +name = "arg_enum_proc_macro" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "as-slice" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "516b6b4f0e40d50dcda9365d53964ec74560ad4284da2e7fc97122cd83174516" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "ascii-canvas" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8824ecca2e851cec16968d54a01dd372ef8f95b244fb84b84e70128be347c3c6" +dependencies = [ + "term", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "av-scenechange" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f321d77c20e19b92c39e7471cf986812cbb46659d2af674adc4331ef3f18394" +dependencies = [ + "aligned", + "anyhow", + "arg_enum_proc_macro", + "arrayvec", + "log", + "num-rational", + "num-traits", + "pastey", + "rayon", + "thiserror 2.0.18", + "v_frame", + "y4m", +] + +[[package]] +name = "av1-grain" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cfddb07216410377231960af4fcab838eaa12e013417781b78bd95ee22077f8" +dependencies = [ + "anyhow", + "arrayvec", + "log", + "nom 8.0.0", + "num-rational", + "v_frame", +] + +[[package]] +name = "avif-serialize" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7178fe5f7d460b13895ebb9dcb28a3a6216d2df2574a0806cb51b555d297f38" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + +[[package]] +name = "bit_field" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "bitstream-io" +version = "4.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7eff00be299a18769011411c9def0d827e8f2d7bf0c3dbf53633147a8867fd1f" +dependencies = [ + "no_std_io2", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bstr" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cee35f73844aa3014bb606320a6c1f010249dbdf43342fe54b5a4f6a8ed4b79" +dependencies = [ + "memchr", + "regex-automata", + "serde_core", +] + +[[package]] +name = "built" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c0e531d93d39c34eef561e929e8a7f86d77a5af08aac4f6d6e39976c51858e9" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cc" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "066fce287b1d4eafef758e89e09d724a24808a9196fe9756b8ca90e86d0719a2" +dependencies = [ + "jobserver", + "libc", + "once_cell", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "compact_str" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "serde", + "static_assertions", +] + +[[package]] +name = "console" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c" +dependencies = [ + "encode_unicode", + "libc", + "unicode-width", + "windows-sys 0.61.2", +] + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]] +name = "cookie_store" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b2c103cf610ec6cae3da84a766285b42fd16aad564758459e6ecf128c75206" +dependencies = [ + "cookie", + "document-features", + "idna", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "criterion" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" +dependencies = [ + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "is-terminal", + "itertools 0.10.5", + "num-traits", + "once_cell", + "oorandom", + "plotters", + "rayon", + "regex", + "serde", + "serde_derive", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" +dependencies = [ + "cast", + "itertools 0.10.5", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "ctx-perf" +version = "0.0.0" +dependencies = [ + "agentis-ctx", + "criterion", + "libc", + "serde", + "serde_json", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "dary_heap" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b1e3a325bc115f096c8b77bbf027a7c2592230e70be2d985be950d3d5e60ebe" +dependencies = [ + "serde", +] + +[[package]] +name = "der" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69dedd701da44b0536442edf09c81a64b0ab97a7a4a5e3d1971f00027cbc63d" +dependencies = [ + "pem-rfc7468", + "zeroize", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "derive_arbitrary" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-next" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" +dependencies = [ + "cfg-if", + "dirs-sys-next", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.5.2", + "windows-sys 0.61.2", +] + +[[package]] +name = "dirs-sys-next" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" +dependencies = [ + "libc", + "redox_users 0.4.6", + "winapi", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "ena" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabffdaee24bd1bf95c5ef7cec31260444317e72ea56c4c91750e8b7ee58d5f1" +dependencies = [ + "log", +] + +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "endian-type" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d" + +[[package]] +name = "equator" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4711b213838dfee0117e3be6ac926007d7f433d7bbe33595975d4190cb07e6fc" +dependencies = [ + "equator-macro", +] + +[[package]] +name = "equator-macro" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "error-code" +version = "3.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59" + +[[package]] +name = "esaxx-rs" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d817e038c30374a4bcb22f94d0a8a0e216958d4c3dcde369b1439fec4bdda6e6" + +[[package]] +name = "exr" +version = "1.74.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6be87932f10230a4339ab394edd8e4611fcb72553d8295b4d52ea55249b3daa5" +dependencies = [ + "bit_field", + "half", + "lebe", + "miniz_oxide", + "num-complex", + "pulp", + "rayon-core", + "smallvec", + "zune-inflate", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fancy-regex" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "531e46835a22af56d1e3b66f04844bed63158bc094a628bec1d321d9b4c44bf2" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "fastembed" +version = "5.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "545e4fb17fc48768ff36c2a3854aa5b0b809d0ed595ab5530fa8ac94f31bd0ea" +dependencies = [ + "anyhow", + "hf-hub", + "image", + "ndarray", + "ort", + "safetensors", + "serde", + "serde_json", + "tokenizers", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + +[[package]] +name = "fd-lock" +version = "4.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" +dependencies = [ + "cfg-if", + "rustix", + "windows-sys 0.59.0", +] + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + +[[package]] +name = "fixedbitset" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce7134b9999ecaf8bcd65542e436736ef32ddca1b3e06094cb6ec5755203b80" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fsevent-sys" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" +dependencies = [ + "libc", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy 0.8.54", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", + "serde", + "serde_core", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hf-hub" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef3982638978efa195ff11b305f51f1f22f4f0a6cabee7af79b383ebee6a213" +dependencies = [ + "dirs", + "http", + "indicatif", + "libc", + "log", + "native-tls", + "rand 0.9.4", + "reqwest", + "serde", + "serde_json", + "thiserror 2.0.18", + "ureq", + "windows-sys 0.61.2", +] + +[[package]] +name = "hmac-sha256" +version = "1.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec9d92d097f4749b64e8cc33d924d9f40a2d4eb91402b458014b781f5733d60f" + +[[package]] +name = "home" +version = "0.5.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "ignore" +version = "0.4.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2adf14691c72bcfc1058740436a35bdd3ae9c07d1a941ef00b749e9ea16aefa7" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "color_quant", + "exr", + "gif", + "image-webp", + "moxcms", + "num-traits", + "png", + "qoi", + "ravif", + "rayon", + "rgb", + "tiff", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "imgref" +version = "1.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89194689a993ab15268672e99e7b0e19da2da3268ac682e8f02d29d4d1434cd7" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "indicatif" +version = "0.18.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9433806cd6b4ec1aba79c021c7e4c58fb4c3b9977c085062e611ac929998fb0c" +dependencies = [ + "console", + "portable-atomic", + "unicode-width", + "unit-prefix", + "web-time", +] + +[[package]] +name = "inotify" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff" +dependencies = [ + "bitflags 1.3.2", + "inotify-sys", + "libc", +] + +[[package]] +name = "inotify-sys" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" +dependencies = [ + "libc", +] + +[[package]] +name = "interpolate_name" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is-terminal" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1c173a5686ce8bfa551b3563d0c2170bf24ca44da99c7ca4bfdab5418c3fe57" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "kqueue" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5" +dependencies = [ + "kqueue-sys", + "libc", +] + +[[package]] +name = "kqueue-sys" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" +dependencies = [ + "bitflags 2.13.0", + "libc", +] + +[[package]] +name = "lalrpop" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55cb077ad656299f160924eb2912aa147d7339ea7d69e1b5517326fdcec3c1ca" +dependencies = [ + "ascii-canvas", + "bit-set", + "ena", + "itertools 0.11.0", + "lalrpop-util", + "petgraph", + "regex", + "regex-syntax", + "string_cache", + "term", + "tiny-keccak", + "unicode-xid", + "walkdir", +] + +[[package]] +name = "lalrpop-util" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507460a910eb7b32ee961886ff48539633b788a36b65692b95f225b844c82553" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "lebe" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libfuzzer-sys" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" +dependencies = [ + "arbitrary", + "cc", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +dependencies = [ + "libc", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b694a822684ddb75df4d657029161431bcb4a85c1856952f845b76912bc6fec" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "loop9" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" +dependencies = [ + "imgref", +] + +[[package]] +name = "lzma-rust2" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e20f57f9918e5bd7bc58c22cdd70a6afc7375d4dd9683af5f2b34bd3d2bba619" + +[[package]] +name = "macro_rules_attribute" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65049d7923698040cd0b1ddcced9b0eb14dd22c5f86ae59c3740eab64a676520" +dependencies = [ + "macro_rules_attribute-proc_macro", + "paste", +] + +[[package]] +name = "macro_rules_attribute-proc_macro" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "670fdfda89751bc4a84ac13eaa63e205cf0fd22b4c9a5fbfa085b63c1f1d3a30" + +[[package]] +name = "matrixmultiply" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a06de3016e9fae57a36fd14dba131fccf49f74b40b7fbdb472f96e361ec71a08" +dependencies = [ + "autocfg", + "rawpointer", +] + +[[package]] +name = "maybe-rayon" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea1f30cedd69f0a2954655f7188c6a834246d2bcf1e315e2ac40c4b24dc9519" +dependencies = [ + "cfg-if", + "rayon", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.48.0", +] + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "monostate" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3341a273f6c9d5bef1908f17b7267bbab0e95c9bf69a0d4dcf8e9e1b2c76ef67" +dependencies = [ + "monostate-impl", + "serde", + "serde_core", +] + +[[package]] +name = "monostate-impl" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4db6d5580af57bf992f59068d4ea26fd518574ff48d7639b255a36f9de6e7e9" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "ndarray" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d" +dependencies = [ + "matrixmultiply", + "num-complex", + "num-integer", + "num-traits", + "portable-atomic", + "portable-atomic-util", + "rawpointer", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nibble_vec" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a5d83df9f36fe23f0c3648c6bbb8b0298bb5f1939c8f2704431371f4b84d43" +dependencies = [ + "smallvec", +] + +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags 2.13.0", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "no_std_io2" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003" +dependencies = [ + "memchr", +] + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nom" +version = "8.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405" +dependencies = [ + "memchr", +] + +[[package]] +name = "noop_proc_macro" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" + +[[package]] +name = "notify" +version = "6.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d" +dependencies = [ + "bitflags 2.13.0", + "crossbeam-channel", + "filetime", + "fsevent-sys", + "inotify", + "kqueue", + "libc", + "log", + "mio 0.8.11", + "walkdir", + "windows-sys 0.48.0", +] + +[[package]] +name = "notify-debouncer-mini" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d40b221972a1fc5ef4d858a2f671fb34c75983eb385463dff3780eeff6a9d43" +dependencies = [ + "crossbeam-channel", + "log", + "notify", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "bytemuck", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "onig" +version = "6.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc3cbf698f9438986c11a880c90a6d04b9de27575afd28bbf45b154b6c709e2" +dependencies = [ + "bitflags 2.13.0", + "libc", + "once_cell", + "onig_sys", +] + +[[package]] +name = "onig_sys" +version = "69.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e68317604e77e53b85896388e1a803c1d21b74c899ec9e5e1112db90735edd7" +dependencies = [ + "cc", + "pkg-config", +] + +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + +[[package]] +name = "openssl" +version = "0.10.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" +dependencies = [ + "bitflags 2.13.0", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ort" +version = "2.0.0-rc.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7de3af33d24a745ffb8fab904b13478438d1cd52868e6f17735ef6e1f8bf133" +dependencies = [ + "ndarray", + "ort-sys", + "smallvec", + "tracing", + "ureq", +] + +[[package]] +name = "ort-sys" +version = "2.0.0-rc.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7b497d21a8b6fbb4b5a544f8fadb77e801a09ae0add9e411d31c6f89e3c1e90" +dependencies = [ + "hmac-sha256", + "lzma-rust2", + "ureq", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pastey" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" + +[[package]] +name = "pem-rfc7468" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" +dependencies = [ + "base64ct", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "petgraph" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" +dependencies = [ + "fixedbitset", + "indexmap", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand 0.8.6", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.0", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy 0.8.54", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" +dependencies = [ + "profiling-procmacros", +] + +[[package]] +name = "profiling-procmacros" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "pulp" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046aa45b989642ec2e4717c8e72d677b13edd831a4d3b6cf37d9a3e54912496a" +dependencies = [ + "bytemuck", + "cfg-if", + "libm", + "num-complex", + "paste", + "pulp-wasm-simd-flag", + "raw-cpuid", + "reborrow", + "version_check", +] + +[[package]] +name = "pulp-wasm-simd-flag" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d8f70e07b9c3962945a74e59ca1c511bba65b6419468acc217c457d93f3c740" + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "radix_trie" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c069c179fcdc6a2fe24d8d18305cf085fdbd4f922c041943e203685d6a1c58fd" +dependencies = [ + "endian-type", + "nibble_vec", +] + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rav1e" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43b6dd56e85d9483277cde964fd1bdb0428de4fec5ebba7540995639a21cb32b" +dependencies = [ + "aligned-vec", + "arbitrary", + "arg_enum_proc_macro", + "arrayvec", + "av-scenechange", + "av1-grain", + "bitstream-io", + "built", + "cfg-if", + "interpolate_name", + "itertools 0.14.0", + "libc", + "libfuzzer-sys", + "log", + "maybe-rayon", + "new_debug_unreachable", + "noop_proc_macro", + "num-derive", + "num-traits", + "paste", + "profiling", + "rand 0.9.4", + "rand_chacha", + "simd_helpers", + "thiserror 2.0.18", + "v_frame", + "wasm-bindgen", +] + +[[package]] +name = "ravif" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e52310197d971b0f5be7fe6b57530dcd27beb35c1b013f29d66c1ad73fbbcc45" +dependencies = [ + "avif-serialize", + "imgref", + "loop9", + "quick-error", + "rav1e", + "rayon", + "rgb", +] + +[[package]] +name = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags 2.13.0", +] + +[[package]] +name = "rawpointer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-cond" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2964d0cf57a3e7a06e8183d14a8b527195c706b7983549cd5462d5aa3747438f" +dependencies = [ + "either", + "itertools 0.14.0", + "rayon", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "reborrow" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03251193000f4bd3b042892be858ee50e8b3719f2b08e5833ac4353724632430" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.0", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + +[[package]] +name = "regex" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a0e75113e14dc5acb068cd0786884f214f1312650a3d36d269f5c4f3cdee8a2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f388202e4b80542a0921078cc23b6333bcf1409c1e3f86404cae4766a6131db" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "encoding_rs", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "rgb" +version = "0.8.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" + +[[package]] +name = "ring" +version = "0.17.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e75ec5e92c4d8aede845126adc388046234541629e76029599ed35a003c7ed24" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rusqlite" +version = "0.32.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cdbe9230a57259b37f7257d0aff38b8c9dbda3513edba2105e59b130189d82f" +dependencies = [ + "bitflags 2.13.0", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.0", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +dependencies = [ + "log", + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "rustyline" +version = "17.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e902948a25149d50edc1a8e0141aad50f54e22ba83ff988cf8f7c9ef07f50564" +dependencies = [ + "bitflags 2.13.0", + "cfg-if", + "clipboard-win", + "fd-lock", + "home", + "libc", + "log", + "memchr", + "nix", + "radix_trie", + "unicode-segmentation", + "unicode-width", + "utf8parse", + "windows-sys 0.60.2", +] + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "safetensors" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79b079b829cb27a1c3c374341345ed2e8b2c0c839034522cee576c140bd7f846" +dependencies = [ + "hashbrown 0.16.1", + "libc", + "serde", + "serde_json", + "tempfile", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.0", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "simd_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95890f873bec569a0362c235787f3aca6e1e887302ba4840839bcc6459c42da6" +dependencies = [ + "quote", +] + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "socks" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0c3dbbd9ae980613c6dd8e28a9407b50509d3803b57624d5dfe8315218cd58b" +dependencies = [ + "byteorder", + "libc", + "winapi", +] + +[[package]] +name = "solang-parser" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35faca4f360b90b96d197fbddccbe46eee057b23efba741dfa7261aab693b35d" +dependencies = [ + "itertools 0.12.1", + "lalrpop", + "lalrpop-util", + "phf", + "thiserror 1.0.69", + "unicode-xid", +] + +[[package]] +name = "spm_precompiled" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5851699c4033c63636f7ea4cf7b7c1f1bf06d0cc03cfb42e711de5a5c46cf326" +dependencies = [ + "base64 0.13.1", + "nom 7.1.3", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "sqlite-vec" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0ba424237a9a5db2f6071f193319e2b6a32f7f3961debb2fbbfe67067abce3f" +dependencies = [ + "cc", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.13.0", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "term" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c59df8ac95d96ff9bede18eb7300b0fda5e5d8d90960e76f8e14ae765eedbf1f" +dependencies = [ + "dirs-next", + "rustversion", + "winapi", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + +[[package]] +name = "tiktoken-rs" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a19830747d9034cd9da43a60eaa8e552dfda7712424aebf187b7a60126bae0d" +dependencies = [ + "anyhow", + "base64 0.22.1", + "bstr", + "fancy-regex", + "lazy_static", + "regex", + "rustc-hash", +] + +[[package]] +name = "time" +version = "0.3.53" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "tokenizers" +version = "0.22.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b238e22d44a15349529690fb07bd645cf58149a1b1e44d6cb5bd1641ff1a6223" +dependencies = [ + "ahash", + "aho-corasick", + "compact_str", + "dary_heap", + "derive_builder", + "esaxx-rs", + "getrandom 0.3.4", + "itertools 0.14.0", + "log", + "macro_rules_attribute", + "monostate", + "onig", + "paste", + "rand 0.9.4", + "rayon", + "rayon-cond", + "regex", + "regex-syntax", + "serde", + "serde_json", + "spm_precompiled", + "thiserror 2.0.18", + "unicode-normalization-alignments", + "unicode-segmentation", + "unicode_categories", +] + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio 1.2.1", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.0", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tree-sitter" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e747b1f9b7b931ed39a548c1fae149101497de3c1fc8d9e18c62c1a66c683d3d" +dependencies = [ + "cc", + "regex", +] + +[[package]] +name = "tree-sitter-go" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad6d11f19441b961af2fda7f12f5d0dac325f6d6de83836a1d3750018cc5114" +dependencies = [ + "cc", + "tree-sitter", +] + +[[package]] +name = "tree-sitter-javascript" +version = "0.20.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d015c02ea98b62c806f7329ff71c383286dfc3a7a7da0cc484f6e42922f73c2c" +dependencies = [ + "cc", + "tree-sitter", +] + +[[package]] +name = "tree-sitter-python" +version = "0.20.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c93b1b1fbd0d399db3445f51fd3058e43d0b4dcff62ddbdb46e66550978aa5" +dependencies = [ + "cc", + "tree-sitter", +] + +[[package]] +name = "tree-sitter-rust" +version = "0.20.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0832309b0b2b6d33760ce5c0e818cb47e1d72b468516bfe4134408926fa7594" +dependencies = [ + "cc", + "tree-sitter", +] + +[[package]] +name = "tree-sitter-typescript" +version = "0.20.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8bc1d2c24276a48ef097a71b56888ac9db63717e8f8d0b324668a27fd619670" +dependencies = [ + "cc", + "tree-sitter", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-normalization-alignments" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43f613e4fa046e69818dd287fdc4bc78175ff20331479dab6e1b0f98d57062de" +dependencies = [ + "smallvec", +] + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "unicode_categories" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" + +[[package]] +name = "unit-prefix" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81e544489bf3d8ef66c953931f56617f423cd4b5494be343d9b9d3dda037b9a3" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "ureq" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" +dependencies = [ + "base64 0.22.1", + "cookie_store", + "der", + "flate2", + "log", + "native-tls", + "percent-encoding", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "socks", + "ureq-proto", + "utf8-zero", + "webpki-root-certs", + "webpki-roots", +] + +[[package]] +name = "ureq-proto" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" +dependencies = [ + "base64 0.22.1", + "http", + "httparse", + "log", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8-zero" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "v_frame" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "666b7727c8875d6ab5db9533418d7c764233ac9c0cff1d469aec8fa127597be2" +dependencies = [ + "aligned-vec", + "num-traits", + "wasm-bindgen", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webpki-roots" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + +[[package]] +name = "y4m" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9b4fd18abc82b8136838da5d50bae7bdea537c574d8dc1a34ed098d6c166f0" +dependencies = [ + "byteorder", + "zerocopy-derive 0.7.35", +] + +[[package]] +name = "zerocopy" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" +dependencies = [ + "zerocopy-derive 0.8.54", +] + +[[package]] +name = "zerocopy-derive" +version = "0.7.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zip" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb2a05c7c36fde6c09b08576c9f7fb4cda705990f73b58fe011abf7dfb24168b" +dependencies = [ + "arbitrary", + "crc32fast", + "flate2", + "indexmap", + "memchr", + "zopfli", +] + +[[package]] +name = "zlib-rs" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b142a20ec14a91d5bc708c1dc21b080c550113d8aa77afa29635673a65dd02c5" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/perf/Cargo.toml b/perf/Cargo.toml new file mode 100644 index 0000000..aa0a15e --- /dev/null +++ b/perf/Cargo.toml @@ -0,0 +1,30 @@ +# Companion perf-harness crate. Deliberately NOT a workspace member of the +# root crate: the empty [workspace] table below (the cargo-fuzz pattern) +# keeps it out of any parent workspace resolution, and the root package's +# `include` list keeps perf/ out of the published crate. +[package] +name = "ctx-perf" +version = "0.0.0" +edition = "2021" +publish = false + +[workspace] + +[[bin]] +name = "perf-harness" +path = "src/main.rs" + +[dependencies] +# The harness only needs the fixture generator from the library; analytics +# (duckdb) is not required, so build the dependency lean. +agentis-ctx = { path = "..", default-features = false } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +libc = "0.2" + +[dev-dependencies] +criterion = "0.5" + +[[bench]] +name = "hotpaths" +harness = false diff --git a/perf/README.md b/perf/README.md new file mode 100644 index 0000000..0629d8f --- /dev/null +++ b/perf/README.md @@ -0,0 +1,97 @@ +# ctx perf harness + +Operational performance harness: spawns a prebuilt `ctx` binary against +deterministic synthetic repos (`ctx::fixture`), times the hook-path commands, +and enforces latency budgets plus a baseline regression gate in CI +(`.github/workflows/ci.yml`, job `perf`). + +This is a companion crate (`publish = false`, its own `[workspace]`), not a +workspace member; it never ships in the published `agentis-ctx` package. + +## Running locally + +```bash +# 1. Build the binary under the perf profile (thin LTO; see root Cargo.toml). +# --all-features so `ctx sql` (duckdb) is measurable; without duckdb the +# sql scenario is skipped, not failed. +cargo build --profile perf --all-features + +# 2. Point the harness at it and run the PR suite. +CTX_PERF_BIN="$PWD/target/perf/ctx" \ + cargo run --release --manifest-path perf/Cargo.toml -- --suite pr + +# Fast self-test of the harness itself (tiny fixture, report-only): +CTX_PERF_BIN="$PWD/target/perf/ctx" \ + cargo run --manifest-path perf/Cargo.toml -- --smoke +``` + +Options: `--suite pr|full`, `--baseline `, `--write-baseline`, +`--json-out `, `--smoke`. + +## Scenarios and budgets + +Protocol per scenario: 2 warmup runs discarded (1 for cold-150k), then the +median of N=5 timed runs (N=3 for cold-150k, N=2 with `--smoke`). Timing wraps +spawn→reap of the ctx process; `ru_maxrss` comes from `wait4(2)`. The child +environment is scrubbed of `CTX_GATE_*` and the passive update check is +neutralized (`CTX_NO_UPDATE_CHECK=1` plus an unroutable `CTX_UPDATE_BASE_URL`). +For `score`/`check` the index is *not* refreshed between runs — both commands +refresh internally and the budget deliberately includes that, matching the +Claude Code hook path. + +| scenario | fixture | command | budget (ms) | +|---|---|---|---:| +| index_cold_2k | repo_2k | `ctx index` (cold, `.ctx/` deleted per run) | — (informational) | +| index_incremental_1 | repo_2k | `ctx index` after 1 changed file | 300 | +| score_3_changed | repo_2k | `ctx score --against HEAD`, 3 changed files | 2000 | +| check_against_head | repo_2k | `ctx check --against HEAD`, 3 changed files | 1000 | +| map_cached | repo_2k | `ctx map --budget 2000` (rank cache warm) | 500 | +| sql_v1_query | repo_2k | `ctx sql "SELECT kind, count(*) …"` | 500 | +| index_cold_150k | repo_150k_loc | `ctx index` (cold) — **full suite only** | 60000 | + +Memory gate: max `ru_maxrss` across the `index_incremental_1`, +`score_3_changed`, `check_against_head`, and `map_cached` runs must stay +below **300 MB × scale**. + +`ctx score`/`ctx check` exit 1 on findings by design; the harness treats exit +codes 0 and 1 as success and only 2+/signals as scenario failures. + +## Gates and the budget-scale policy + +A scenario fails when any of these hold: + +- `median > baseline_median × 1.20` (only if a baseline entry exists; + missing entry ⇒ warn-pass), +- `median > budget × CTX_PERF_BUDGET_SCALE` (skipped for budget-less rows), +- `max_rss > 300 MB × CTX_PERF_BUDGET_SCALE` (RSS-gated rows only). + +`CTX_PERF_BUDGET_SCALE` defaults to **1.0** (local, bare-metal numbers); CI +sets **1.5** because hosted runners are slower and noisier. The regression +factor (1.20) is deliberately *not* scaled — baselines are captured on the +same runner class that enforces them. + +## Baselines + +`perf/baselines/.json` — see `perf/baselines/README.md` for the +capture/update procedure. Short version: run `--suite full --write-baseline` +on the CI runner class, confirm run-to-run spread is well under 20%, and +commit the file explicitly with a justification. CI never writes baselines. + +## Criterion microbenches (informational) + +`benches/hotpaths.rs` benches library hot paths (incremental no-change +`Indexer::index`, `Database::find_symbols`, `rank::compute_and_cache`) on a +tiny fixture. Not part of the CI gate: + +```bash +cargo bench --manifest-path perf/Cargo.toml +``` + +## Extending / ctx-bench + +The fixture generator is seeded and fully parameterized +(`ctx::fixture::FixtureSpec { seed, files, avg_loc, modules, fan_in_skew, +history_commits }`) and ships in `agentis-ctx` as `#[doc(hidden)]` API, so an +external ctx-bench suite can generate the exact same trees. Any change to the +generated bytes bumps `FIXTURE_FORMAT_VERSION`, which invalidates committed +baselines automatically. diff --git a/perf/baselines/README.md b/perf/baselines/README.md new file mode 100644 index 0000000..c9a7031 --- /dev/null +++ b/perf/baselines/README.md @@ -0,0 +1,44 @@ +# Perf baselines + +Baseline files pin the per-scenario medians the CI regression gate compares +against (`median > baseline * 1.20` fails). Baselines are **runner-class +specific** — a number captured on a MacBook is meaningless for +`ubuntu-latest` — so each file is named after the runner class it was +captured on (`ubuntu-latest.json`). + +There is intentionally **no baseline committed yet**: the harness was +developed on macOS, and fabricating Linux numbers would make the gate either +useless or flaky. Until a baseline lands, the regression gate warn-passes +(missing entry => warn) and only the absolute budgets and the RSS gate apply. + +## Capturing / updating `ubuntu-latest.json` + +1. On an `ubuntu-latest` runner (easiest: trigger the `perf` CI job via + `workflow_dispatch` and replicate its steps in a shell, or use a local + machine of the same class): + + ```bash + cargo build --profile perf --all-features + CTX_PERF_BIN="$PWD/target/perf/ctx" \ + CTX_PERF_BUDGET_SCALE=1.5 \ + cargo run --release --manifest-path perf/Cargo.toml -- \ + --suite full \ + --baseline perf/baselines/ubuntu-latest.json \ + --write-baseline + ``` + +2. Sanity-check the spread first: run the suite twice and confirm the + medians agree within ~10% before trusting a capture. + +3. Commit the file **explicitly and on its own**, with a justification in the + commit message (what changed to warrant new numbers: hardware class, + fixture format bump, intentional perf change). + +Never let CI write baselines (`--write-baseline` is for capture runs only), +and never edit the numbers by hand. + +## Invalidation + +The file embeds `fixture_format_version`; when `ctx::fixture` changes its +generated bytes the version bumps and the harness ignores the stale baseline +(with a warning) until it is recaptured. diff --git a/perf/benches/hotpaths.rs b/perf/benches/hotpaths.rs new file mode 100644 index 0000000..96d5700 --- /dev/null +++ b/perf/benches/hotpaths.rs @@ -0,0 +1,61 @@ +//! Informational criterion microbenches over the ctx library hot paths. +//! +//! These are NOT part of the CI gate (the gate times the real binary via the +//! perf-harness); they exist for local, iterative optimization work: +//! +//! cargo bench --manifest-path perf/Cargo.toml +//! +//! Compiled with `default-features = false` on agentis-ctx, so only +//! duckdb-free paths are benched: the incremental no-change `Indexer::index` +//! pass, `Database::find_symbols` (FTS lookup), and +//! `rank::compute_and_cache` (all three are public library API; nothing had +//! to be skipped for visibility). + +use std::path::PathBuf; + +use criterion::{criterion_group, criterion_main, Criterion}; +use ctx::fixture::{self, FixtureSpec}; +use ctx::index::{open_database, Indexer}; +use ctx::rank; +use ctx::walker::WalkerConfig; + +/// Build a tiny fixture repo and index it once; benches then reuse it. +fn setup_fixture() -> PathBuf { + let dir = std::env::temp_dir().join(format!("ctx-perf-bench-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + fixture::generate(&FixtureSpec::tiny(), &dir).expect("fixture generation"); + let mut indexer = + Indexer::with_config(&dir, false, WalkerConfig::default()).expect("indexer setup"); + indexer.index().expect("initial index"); + dir +} + +fn bench_hotpaths(c: &mut Criterion) { + let dir = setup_fixture(); + + // (a) Incremental index() with nothing changed: the fixed cost every + // hook-path invocation pays before doing real work. + let mut indexer = + Indexer::with_config(&dir, false, WalkerConfig::default()).expect("indexer setup"); + c.bench_function("indexer_incremental_no_change", |b| { + b.iter(|| indexer.index().expect("incremental index")) + }); + drop(indexer); + + // (b) Symbol FTS lookup. + let db = open_database(&dir).expect("open database"); + c.bench_function("db_find_symbols", |b| { + b.iter(|| db.find_symbols("f_0001", 10).expect("find_symbols")) + }); + + // (c) PageRank compute + cache write. + c.bench_function("rank_compute_and_cache", |b| { + b.iter(|| rank::compute_and_cache(&db).expect("rank compute")) + }); + + drop(db); + let _ = std::fs::remove_dir_all(&dir); +} + +criterion_group!(benches, bench_hotpaths); +criterion_main!(benches); diff --git a/perf/src/compare.rs b/perf/src/compare.rs new file mode 100644 index 0000000..0cca336 --- /dev/null +++ b/perf/src/compare.rs @@ -0,0 +1,243 @@ +//! Gate evaluation: compare scenario medians against the committed baseline +//! and the absolute latency budgets, with a runner-class scale factor. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +/// A scenario fails against the baseline when its median exceeds +/// `baseline * REGRESSION_FACTOR`. +pub const REGRESSION_FACTOR: f64 = 1.20; + +/// Max resident set (MB) allowed for the RSS-gated scenarios, before scaling. +pub const RSS_BUDGET_MB: f64 = 300.0; + +// ============================================================================ +// Baseline file model (perf/baselines/.json) +// ============================================================================ + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Baseline { + pub meta: BaselineMeta, + pub scenarios: BTreeMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct BaselineMeta { + /// Cargo profile the ctx binary was built with (always "perf"). + pub profile: String, + /// `ctx::fixture::FIXTURE_FORMAT_VERSION` at capture time; a mismatch + /// makes the baseline incomparable and it is ignored with a warning. + pub fixture_format_version: u32, + pub budget_scale_at_capture: f64, + pub commit: String, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)] +pub struct BaselineEntry { + pub median_ms: f64, + pub max_rss_kb: u64, +} + +// ============================================================================ +// Gate decision +// ============================================================================ + +/// Outcome of gating one scenario. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Gate { + Pass, + /// Passed, but with caveats (e.g. no baseline entry to compare against). + Warn(Vec), + Fail(Vec), +} + +impl Gate { + pub fn label(&self) -> &'static str { + match self { + Gate::Pass => "pass", + Gate::Warn(_) => "warn", + Gate::Fail(_) => "fail", + } + } + + pub fn notes(&self) -> &[String] { + match self { + Gate::Pass => &[], + Gate::Warn(notes) | Gate::Fail(notes) => notes, + } + } +} + +/// Evaluate one scenario: +/// +/// - baseline regression: `median_ms > baseline.median_ms * 1.20` +/// (only when a baseline entry exists; a missing entry is a warn-pass); +/// - absolute budget: `median_ms > budget_ms * scale` +/// (skipped for budget-less, informational scenarios); +/// - memory: `max_rss_kb > 300 MB * scale` for RSS-gated scenarios. +pub fn evaluate( + median_ms: f64, + max_rss_kb: u64, + budget_ms: Option, + rss_gated: bool, + baseline: Option<&BaselineEntry>, + scale: f64, +) -> Gate { + let mut failures = Vec::new(); + let mut warnings = Vec::new(); + + match baseline { + Some(entry) => { + let limit = entry.median_ms * REGRESSION_FACTOR; + if median_ms > limit { + failures.push(format!( + "regression: median {median_ms:.1} ms > baseline {:.1} ms * {REGRESSION_FACTOR} = {limit:.1} ms", + entry.median_ms + )); + } + } + None => warnings.push("no baseline entry; regression gate skipped".to_string()), + } + + if let Some(budget) = budget_ms { + let limit = budget * scale; + if median_ms > limit { + failures.push(format!( + "budget: median {median_ms:.1} ms > {budget:.0} ms * scale {scale} = {limit:.1} ms" + )); + } + } + + if rss_gated { + let rss_mb = max_rss_kb as f64 / 1024.0; + let limit = RSS_BUDGET_MB * scale; + if rss_mb > limit { + failures.push(format!( + "rss: {rss_mb:.1} MB > {RSS_BUDGET_MB:.0} MB * scale {scale} = {limit:.1} MB" + )); + } + } + + if !failures.is_empty() { + Gate::Fail(failures) + } else if !warnings.is_empty() { + Gate::Warn(warnings) + } else { + Gate::Pass + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn is_fail(gate: &Gate) -> bool { + matches!(gate, Gate::Fail(_)) + } + + fn baseline(median_ms: f64) -> BaselineEntry { + BaselineEntry { + median_ms, + max_rss_kb: 100 * 1024, + } + } + + #[test] + fn passes_within_baseline_and_budget() { + let gate = evaluate( + 100.0, + 50 * 1024, + Some(300.0), + true, + Some(&baseline(95.0)), + 1.0, + ); + assert_eq!(gate, Gate::Pass); + } + + #[test] + fn fails_on_baseline_regression() { + // 121 > 100 * 1.20 + let gate = evaluate(121.0, 0, Some(300.0), false, Some(&baseline(100.0)), 1.0); + assert!(is_fail(&gate)); + assert!(gate.notes()[0].contains("regression")); + // Exactly at the limit passes. + let gate = evaluate(120.0, 0, Some(300.0), false, Some(&baseline(100.0)), 1.0); + assert_eq!(gate, Gate::Pass); + } + + #[test] + fn fails_on_budget_overrun() { + let gate = evaluate(301.0, 0, Some(300.0), false, Some(&baseline(300.0)), 1.0); + assert!(is_fail(&gate)); + assert!(gate.notes()[0].contains("budget")); + } + + #[test] + fn missing_baseline_is_warn_pass() { + let gate = evaluate(100.0, 0, Some(300.0), false, None, 1.0); + assert_eq!(gate.label(), "warn"); + assert!(!is_fail(&gate)); + assert!(gate.notes()[0].contains("no baseline")); + } + + #[test] + fn budget_less_scenario_only_gates_on_baseline() { + // No budget, no baseline, huge median: warn-pass, never budget-fail. + let gate = evaluate(1_000_000.0, 0, None, false, None, 1.0); + assert_eq!(gate.label(), "warn"); + } + + #[test] + fn fails_on_rss_when_gated() { + let rss_kb = 301 * 1024; // 301 MB + let gate = evaluate(1.0, rss_kb, None, true, Some(&baseline(100.0)), 1.0); + assert!(is_fail(&gate)); + assert!(gate.notes()[0].contains("rss")); + // Same RSS is fine when the scenario is not RSS-gated. + let gate = evaluate(1.0, rss_kb, None, false, Some(&baseline(100.0)), 1.0); + assert_eq!(gate, Gate::Pass); + } + + #[test] + fn scale_applies_to_budget_and_rss() { + // 400 <= 300 * 1.5, and 400 MB <= 300 MB * 1.5. + let gate = evaluate( + 400.0, + 400 * 1024, + Some(300.0), + true, + Some(&baseline(400.0)), + 1.5, + ); + assert_eq!(gate, Gate::Pass); + // ...but the regression factor is NOT scaled. + let gate = evaluate(400.0, 0, Some(300.0), false, Some(&baseline(300.0)), 1.5); + assert!(is_fail(&gate), "regression gate ignores budget scale"); + } + + #[test] + fn baseline_json_round_trip() { + let mut scenarios = BTreeMap::new(); + scenarios.insert( + "map_cached".to_string(), + BaselineEntry { + median_ms: 123.4, + max_rss_kb: 56789, + }, + ); + let baseline = Baseline { + meta: BaselineMeta { + profile: "perf".to_string(), + fixture_format_version: 1, + budget_scale_at_capture: 1.5, + commit: "abc123".to_string(), + }, + scenarios, + }; + let json = serde_json::to_string_pretty(&baseline).unwrap(); + let parsed: Baseline = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, baseline); + } +} diff --git a/perf/src/main.rs b/perf/src/main.rs new file mode 100644 index 0000000..7a9b57f --- /dev/null +++ b/perf/src/main.rs @@ -0,0 +1,434 @@ +//! perf-harness: operational performance harness for the ctx binary. +//! +//! Spawns a prebuilt ctx binary (env `CTX_PERF_BIN`) against deterministic +//! synthetic fixtures, times each scenario, and gates medians against the +//! committed baseline and the absolute latency budgets. See perf/README.md. +//! +//! Exit codes: 0 = all gates pass (or report-only mode), 1 = at least one +//! scenario failed a gate or errored, 2 = harness operational error. + +mod compare; +mod report; +mod runner; +mod scenarios; + +use std::collections::BTreeMap; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::process::ExitCode; + +use compare::{Baseline, BaselineEntry, BaselineMeta}; +use report::{Results, ResultsMeta, ScenarioResult}; +use scenarios::{Scenario, ScenarioOutcome}; + +const USAGE: &str = "\ +perf-harness: latency-budget harness for the ctx binary + +USAGE: + perf-harness [OPTIONS] + +OPTIONS: + --suite pr|full Scenario suite (default: pr; full adds index_cold_150k) + --baseline Baseline JSON to compare against (and target of --write-baseline) + --write-baseline Overwrite the baseline file from this run (never in CI) + --json-out Write full results JSON to + --smoke Tiny fixtures, N=2, report-only: the harness's own E2E test + +ENVIRONMENT: + CTX_PERF_BIN Path to the ctx binary to measure (required) + CTX_PERF_BUDGET_SCALE Budget/RSS scale factor (default 1.0; 1.5 on hosted CI) +"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Suite { + Pr, + Full, +} + +#[derive(Debug)] +struct Args { + suite: Suite, + baseline: Option, + write_baseline: bool, + json_out: Option, + smoke: bool, +} + +fn parse_args(argv: &[String]) -> Result { + let mut args = Args { + suite: Suite::Pr, + baseline: None, + write_baseline: false, + json_out: None, + smoke: false, + }; + let mut it = argv.iter(); + while let Some(arg) = it.next() { + match arg.as_str() { + "--suite" => { + args.suite = match it.next().map(String::as_str) { + Some("pr") => Suite::Pr, + Some("full") => Suite::Full, + other => return Err(format!("--suite expects 'pr' or 'full', got {other:?}")), + }; + } + "--baseline" => { + let path = it.next().ok_or("--baseline expects a path")?; + args.baseline = Some(PathBuf::from(path)); + } + "--write-baseline" => args.write_baseline = true, + "--json-out" => { + let path = it.next().ok_or("--json-out expects a path")?; + args.json_out = Some(PathBuf::from(path)); + } + "--smoke" => args.smoke = true, + "--help" | "-h" => return Err(String::new()), + other => return Err(format!("unknown argument: {other}")), + } + } + if args.write_baseline && args.baseline.is_none() { + return Err("--write-baseline requires --baseline ".to_string()); + } + Ok(args) +} + +fn main() -> ExitCode { + let argv: Vec = std::env::args().skip(1).collect(); + let args = match parse_args(&argv) { + Ok(args) => args, + Err(msg) => { + if !msg.is_empty() { + eprintln!("error: {msg}\n"); + } + eprint!("{USAGE}"); + return ExitCode::from(2); + } + }; + match run(&args) { + Ok(any_failed) => { + if any_failed { + ExitCode::from(1) + } else { + ExitCode::SUCCESS + } + } + Err(msg) => { + eprintln!("error: {msg}"); + ExitCode::from(2) + } + } +} + +fn run(args: &Args) -> Result { + let ctx_bin = resolve_ctx_bin()?; + let scale = budget_scale()?; + let baseline = load_baseline(args)?; + + let selected: Vec = scenarios::all() + .into_iter() + .filter(|s| args.suite == Suite::Full || !s.full_only) + .collect(); + + let work_dir = std::env::temp_dir().join(format!("ctx-perf-{}", std::process::id())); + std::fs::create_dir_all(&work_dir).map_err(|e| format!("cannot create work dir: {e}"))?; + + let mut results = BTreeMap::new(); + let mut any_failed = false; + + for scenario in &selected { + let outcome = scenarios::run_scenario(scenario, &ctx_bin, &work_dir, args.smoke)?; + let baseline_entry = baseline + .as_ref() + .and_then(|b| b.scenarios.get(scenario.name)) + .copied(); + let result = evaluate_outcome(scenario, outcome, baseline_entry, scale, args.smoke); + if result.status == "fail" { + any_failed = true; + } + results.insert(scenario.name.to_string(), result); + } + + // Best-effort cleanup: the 150k fixture is sizable. + let _ = std::fs::remove_dir_all(&work_dir); + + let results = Results { + meta: ResultsMeta { + profile: "perf".to_string(), + fixture_format_version: ctx::fixture::FIXTURE_FORMAT_VERSION, + budget_scale: scale, + commit: detect_commit(), + os: std::env::consts::OS.to_string(), + suite: match args.suite { + Suite::Pr => "pr".to_string(), + Suite::Full => "full".to_string(), + }, + smoke: args.smoke, + }, + scenarios: results, + }; + + let table = report::markdown_table(&results); + println!("{table}"); + append_step_summary(&table); + + if let Some(path) = &args.json_out { + let json = + serde_json::to_string_pretty(&results).map_err(|e| format!("results JSON: {e}"))?; + std::fs::write(path, json + "\n") + .map_err(|e| format!("cannot write {}: {e}", path.display()))?; + eprintln!("results written to {}", path.display()); + } + + if args.write_baseline { + if args.smoke { + return Err("refusing to write a baseline from a --smoke run".to_string()); + } + let path = args.baseline.as_ref().expect("checked in parse_args"); + write_baseline(path, &results, scale)?; + eprintln!("baseline written to {}", path.display()); + } + + Ok(any_failed) +} + +/// Fold a scenario outcome and its gate decision into a report row. +fn evaluate_outcome( + scenario: &Scenario, + outcome: ScenarioOutcome, + baseline: Option, + scale: f64, + smoke: bool, +) -> ScenarioResult { + match outcome { + ScenarioOutcome::Skipped(reason) => ScenarioResult { + status: "skip".to_string(), + median_ms: None, + min_ms: None, + runs_ms: vec![], + max_rss_kb: None, + budget_ms: scenario.budget_ms, + baseline_median_ms: None, + notes: vec![reason], + }, + ScenarioOutcome::Failed(reason) => ScenarioResult { + status: "fail".to_string(), + median_ms: None, + min_ms: None, + runs_ms: vec![], + max_rss_kb: None, + budget_ms: scenario.budget_ms, + baseline_median_ms: None, + notes: vec![reason], + }, + ScenarioOutcome::Measured { + runs_ms, + max_rss_kb, + } => { + let median_ms = report::median(&runs_ms); + let min_ms = runs_ms.iter().cloned().fold(f64::INFINITY, f64::min); + if smoke { + // Smoke runs report only; budgets and baselines are not + // meaningful against the tiny fixture. + return ScenarioResult { + status: "info".to_string(), + median_ms: Some(median_ms), + min_ms: Some(min_ms), + runs_ms, + max_rss_kb: Some(max_rss_kb), + budget_ms: None, + baseline_median_ms: None, + notes: vec!["smoke: report only".to_string()], + }; + } + let gate = compare::evaluate( + median_ms, + max_rss_kb, + scenario.budget_ms, + scenario.rss_gated, + baseline.as_ref(), + scale, + ); + ScenarioResult { + status: gate.label().to_string(), + median_ms: Some(median_ms), + min_ms: Some(min_ms), + runs_ms, + max_rss_kb: Some(max_rss_kb), + budget_ms: scenario.budget_ms, + baseline_median_ms: baseline.map(|b| b.median_ms), + notes: gate.notes().to_vec(), + } + } + } +} + +fn resolve_ctx_bin() -> Result { + let raw = std::env::var("CTX_PERF_BIN").map_err(|_| { + "CTX_PERF_BIN is not set. Build the binary first:\n \ + cargo build --profile perf --all-features\n\ + then point CTX_PERF_BIN at target/perf/ctx (absolute path)." + .to_string() + })?; + let path = PathBuf::from(&raw); + if !path.is_file() { + return Err(format!( + "CTX_PERF_BIN points at '{raw}', which is not an existing file" + )); + } + Ok(path) +} + +fn budget_scale() -> Result { + match std::env::var("CTX_PERF_BUDGET_SCALE") { + Err(_) => Ok(1.0), + Ok(raw) => { + let scale: f64 = raw + .parse() + .map_err(|_| format!("CTX_PERF_BUDGET_SCALE='{raw}' is not a number"))?; + if !scale.is_finite() || scale <= 0.0 { + return Err(format!("CTX_PERF_BUDGET_SCALE must be positive, got {raw}")); + } + Ok(scale) + } + } +} + +/// Load the baseline if a path was given and the file exists. A missing file +/// or a fixture-format mismatch degrades to "no baseline" with a warning +/// (every scenario then warn-passes its regression gate). +fn load_baseline(args: &Args) -> Result, String> { + let Some(path) = &args.baseline else { + return Ok(None); + }; + if !path.exists() { + eprintln!( + "warning: baseline file {} does not exist; regression gates are warn-pass \ + (see perf/baselines/README.md for the capture procedure)", + path.display() + ); + return Ok(None); + } + let text = std::fs::read_to_string(path) + .map_err(|e| format!("cannot read baseline {}: {e}", path.display()))?; + let baseline: Baseline = serde_json::from_str(&text) + .map_err(|e| format!("baseline {} is not valid: {e}", path.display()))?; + if baseline.meta.fixture_format_version != ctx::fixture::FIXTURE_FORMAT_VERSION { + eprintln!( + "warning: baseline fixture_format_version {} != current {}; ignoring baseline \ + (recapture it per perf/baselines/README.md)", + baseline.meta.fixture_format_version, + ctx::fixture::FIXTURE_FORMAT_VERSION + ); + return Ok(None); + } + Ok(Some(baseline)) +} + +fn write_baseline(path: &Path, results: &Results, scale: f64) -> Result<(), String> { + let mut scenarios = BTreeMap::new(); + for (name, r) in &results.scenarios { + if let (Some(median_ms), Some(max_rss_kb)) = (r.median_ms, r.max_rss_kb) { + scenarios.insert( + name.clone(), + BaselineEntry { + median_ms, + max_rss_kb, + }, + ); + } + } + let baseline = Baseline { + meta: BaselineMeta { + profile: "perf".to_string(), + fixture_format_version: ctx::fixture::FIXTURE_FORMAT_VERSION, + budget_scale_at_capture: scale, + commit: detect_commit(), + }, + scenarios, + }; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| format!("cannot create {}: {e}", parent.display()))?; + } + let json = + serde_json::to_string_pretty(&baseline).map_err(|e| format!("baseline JSON: {e}"))?; + std::fs::write(path, json + "\n").map_err(|e| format!("cannot write {}: {e}", path.display())) +} + +fn detect_commit() -> String { + if let Ok(sha) = std::env::var("GITHUB_SHA") { + if !sha.is_empty() { + return sha; + } + } + std::process::Command::new("git") + .args(["rev-parse", "HEAD"]) + .output() + .ok() + .filter(|o| o.status.success()) + .and_then(|o| String::from_utf8(o.stdout).ok()) + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "unknown".to_string()) +} + +/// Append the report to the GitHub Actions job summary when available. +fn append_step_summary(table: &str) { + let Ok(path) = std::env::var("GITHUB_STEP_SUMMARY") else { + return; + }; + let opened = std::fs::OpenOptions::new().append(true).open(&path); + if let Ok(mut file) = opened { + let _ = writeln!(file, "## ctx perf harness\n\n{table}"); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse(args: &[&str]) -> Result { + parse_args(&args.iter().map(|s| s.to_string()).collect::>()) + } + + #[test] + fn parse_defaults() { + let args = parse(&[]).unwrap(); + assert_eq!(args.suite, Suite::Pr); + assert!(args.baseline.is_none()); + assert!(!args.write_baseline); + assert!(args.json_out.is_none()); + assert!(!args.smoke); + } + + #[test] + fn parse_full_invocation() { + let args = parse(&[ + "--suite", + "full", + "--baseline", + "perf/baselines/ubuntu-latest.json", + "--json-out", + "out.json", + "--smoke", + ]) + .unwrap(); + assert_eq!(args.suite, Suite::Full); + assert_eq!( + args.baseline.as_deref(), + Some(Path::new("perf/baselines/ubuntu-latest.json")) + ); + assert_eq!(args.json_out.as_deref(), Some(Path::new("out.json"))); + assert!(args.smoke); + } + + #[test] + fn parse_rejects_bad_input() { + assert!(parse(&["--suite", "nightly"]).is_err()); + assert!(parse(&["--frobnicate"]).is_err()); + assert!( + parse(&["--write-baseline"]).is_err(), + "--write-baseline requires --baseline" + ); + } +} diff --git a/perf/src/report.rs b/perf/src/report.rs new file mode 100644 index 0000000..47f2210 --- /dev/null +++ b/perf/src/report.rs @@ -0,0 +1,175 @@ +//! Result aggregation, JSON output, and the markdown report table. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +/// Median of a non-empty slice. Even lengths average the two middle values. +pub fn median(samples: &[f64]) -> f64 { + assert!(!samples.is_empty(), "median of an empty sample set"); + let mut sorted = samples.to_vec(); + sorted.sort_by(|a, b| a.partial_cmp(b).expect("no NaN timings")); + let n = sorted.len(); + if n % 2 == 1 { + sorted[n / 2] + } else { + (sorted[n / 2 - 1] + sorted[n / 2]) / 2.0 + } +} + +// ============================================================================ +// Results model (--json-out) +// ============================================================================ + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Results { + pub meta: ResultsMeta, + pub scenarios: BTreeMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ResultsMeta { + pub profile: String, + pub fixture_format_version: u32, + pub budget_scale: f64, + pub commit: String, + pub os: String, + pub suite: String, + pub smoke: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct ScenarioResult { + /// `pass`, `warn`, `fail`, `skip`, or `info` (smoke / unenforced). + pub status: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub median_ms: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub min_ms: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub runs_ms: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_rss_kb: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub budget_ms: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub baseline_median_ms: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub notes: Vec, +} + +// ============================================================================ +// Markdown table +// ============================================================================ + +fn fmt_ms(value: Option) -> String { + match value { + Some(v) => format!("{v:.1}"), + None => "-".to_string(), + } +} + +fn fmt_rss_mb(value: Option) -> String { + match value { + Some(kb) => format!("{:.1}", kb as f64 / 1024.0), + None => "-".to_string(), + } +} + +/// Render the report as a markdown table (scenarios in name order). +pub fn markdown_table(results: &Results) -> String { + let mut out = String::new(); + out.push_str( + "| scenario | median (ms) | min (ms) | budget (ms) | baseline (ms) | rss (MB) | status |\n", + ); + out.push_str("|---|---:|---:|---:|---:|---:|---|\n"); + for (name, r) in &results.scenarios { + let status = if r.notes.is_empty() { + r.status.clone() + } else { + format!("{} ({})", r.status, r.notes.join("; ")) + }; + out.push_str(&format!( + "| {name} | {} | {} | {} | {} | {} | {status} |\n", + fmt_ms(r.median_ms), + fmt_ms(r.min_ms), + fmt_ms(r.budget_ms), + fmt_ms(r.baseline_median_ms), + fmt_rss_mb(r.max_rss_kb), + )); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn median_odd_even_single() { + assert_eq!(median(&[3.0]), 3.0); + assert_eq!(median(&[5.0, 1.0, 3.0]), 3.0); + assert_eq!(median(&[4.0, 1.0, 3.0, 2.0]), 2.5); + } + + fn sample_results() -> Results { + let mut scenarios = BTreeMap::new(); + scenarios.insert( + "map_cached".to_string(), + ScenarioResult { + status: "pass".to_string(), + median_ms: Some(123.45), + min_ms: Some(120.0), + runs_ms: vec![120.0, 123.45, 130.0], + max_rss_kb: Some(51200), + budget_ms: Some(500.0), + baseline_median_ms: Some(110.0), + notes: vec![], + }, + ); + scenarios.insert( + "sql_v1_query".to_string(), + ScenarioResult { + status: "skip".to_string(), + median_ms: None, + min_ms: None, + runs_ms: vec![], + max_rss_kb: None, + budget_ms: Some(500.0), + baseline_median_ms: None, + notes: vec!["ctx binary built without the duckdb feature".to_string()], + }, + ); + Results { + meta: ResultsMeta { + profile: "perf".to_string(), + fixture_format_version: 1, + budget_scale: 1.0, + commit: "abc123".to_string(), + os: "linux".to_string(), + suite: "pr".to_string(), + smoke: false, + }, + scenarios, + } + } + + #[test] + fn results_json_round_trip() { + let results = sample_results(); + let json = serde_json::to_string_pretty(&results).unwrap(); + let parsed: Results = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, results); + } + + #[test] + fn markdown_table_snapshot() { + let expected = "\ +| scenario | median (ms) | min (ms) | budget (ms) | baseline (ms) | rss (MB) | status | +|---|---:|---:|---:|---:|---:|---| +| map_cached | 123.5 | 120.0 | 500.0 | 110.0 | 50.0 | pass | +| sql_v1_query | - | - | 500.0 | - | - | skip (ctx binary built without the duckdb feature) | +"; + assert_eq!(markdown_table(&sample_results()), expected); + } +} diff --git a/perf/src/runner.rs b/perf/src/runner.rs new file mode 100644 index 0000000..c4662cd --- /dev/null +++ b/perf/src/runner.rs @@ -0,0 +1,146 @@ +//! Process execution: spawn the ctx binary, time wall clock around +//! spawn -> reap, and collect `ru_maxrss` via `libc::wait4`. + +use std::io::Read; +use std::path::Path; +use std::process::{Command, Stdio}; +use std::time::Instant; + +/// One timed (or preparatory) invocation of the ctx binary. +#[derive(Debug)] +pub struct RunResult { + pub wall_ms: f64, + pub max_rss_kb: u64, + /// Exit code; a signal death is reported as `128 + signo`. + pub exit_code: i32, + pub stderr: String, +} + +impl RunResult { + /// ctx's exit-code convention: 0 = clean, 1 = findings, 2+ = operational + /// error. `ctx score`/`ctx check` may legitimately exit 1, so both 0 and + /// 1 count as success for timing purposes. + pub fn is_success(&self) -> bool { + self.exit_code == 0 || self.exit_code == 1 + } +} + +/// Run `bin args...` with `cwd` as the working directory and return timing, +/// exit code, max RSS, and captured stderr (stdout is discarded). +pub fn run_once(bin: &Path, args: &[&str], cwd: &Path) -> std::io::Result { + let mut cmd = Command::new(bin); + cmd.args(args) + .current_dir(cwd) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()); + scrub_env(&mut cmd); + + let start = Instant::now(); + let mut child = cmd.spawn()?; + let mut stderr_pipe = child.stderr.take().expect("stderr was piped"); + // Drain stderr on a thread so a chatty child can never fill the pipe + // and deadlock against our wait4. + let reader = std::thread::spawn(move || { + let mut buf = String::new(); + let _ = stderr_pipe.read_to_string(&mut buf); + buf + }); + + let (exit_code, max_rss_kb) = wait4_reap(child.id() as libc::pid_t)?; + let wall_ms = start.elapsed().as_secs_f64() * 1000.0; + let stderr = reader.join().unwrap_or_default(); + // `child` was already reaped by wait4; dropping the handle is harmless + // (std's Child does not wait or kill on drop). + Ok(RunResult { + wall_ms, + max_rss_kb, + exit_code, + stderr, + }) +} + +/// Reap `pid` with `wait4(2)` to obtain its rusage. Returns +/// `(exit_code, max_rss_kb)`. +fn wait4_reap(pid: libc::pid_t) -> std::io::Result<(i32, u64)> { + let mut status: libc::c_int = 0; + // SAFETY: rusage is plain-old-data; an all-zero value is valid. + let mut rusage: libc::rusage = unsafe { std::mem::zeroed() }; + // SAFETY: pid refers to a child we spawned and have not reaped yet; + // both out-pointers are valid for the duration of the call. + let rc = unsafe { libc::wait4(pid, &mut status, 0, &mut rusage) }; + if rc == -1 { + return Err(std::io::Error::last_os_error()); + } + let exit_code = if libc::WIFEXITED(status) { + libc::WEXITSTATUS(status) + } else if libc::WIFSIGNALED(status) { + 128 + libc::WTERMSIG(status) + } else { + -1 + }; + Ok((exit_code, normalize_maxrss(rusage.ru_maxrss))) +} + +/// `ru_maxrss` is kilobytes on Linux but bytes on macOS; normalize to KB. +fn normalize_maxrss(raw: libc::c_long) -> u64 { + let raw = raw.max(0) as u64; + if cfg!(target_os = "macos") { + raw / 1024 + } else { + raw + } +} + +/// Scrub the child environment: +/// +/// - drop every `CTX_GATE_*` variable so gate logging / gate config from the +/// invoking shell cannot alter what the timed command does; +/// - neutralize the passive update check. Per `src/update.rs`, +/// `CTX_NO_UPDATE_CHECK` (non-empty) suppresses the check outright — no +/// network call at all — and a non-TTY stderr (our pipe) suppresses it too. +/// As a final belt-and-suspenders measure the API base URL is pointed at an +/// unroutable localhost port so even a code regression in the suppression +/// logic cannot reach the network during timed runs. +fn scrub_env(cmd: &mut Command) { + for (key, _) in std::env::vars_os() { + if key.to_string_lossy().starts_with("CTX_GATE_") { + cmd.env_remove(&key); + } + } + cmd.env_remove("CTX_UPDATE_FORCE_TTY"); + cmd.env("CTX_NO_UPDATE_CHECK", "1"); + cmd.env("CTX_UPDATE_BASE_URL", "http://127.0.0.1:9"); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn normalize_maxrss_clamps_negative() { + assert_eq!(normalize_maxrss(-5), 0); + } + + #[test] + fn run_once_times_a_real_process() { + // `true` exits 0 instantly; enough to prove spawn/reap/rusage works. + let result = run_once(Path::new("/usr/bin/true"), &[], Path::new("/")).unwrap(); + assert_eq!(result.exit_code, 0); + assert!(result.is_success()); + assert!(result.wall_ms >= 0.0); + } + + #[test] + fn run_once_captures_exit_code_and_stderr() { + let result = run_once( + Path::new("/bin/sh"), + &["-c", "echo oops >&2; exit 2"], + Path::new("/"), + ) + .unwrap(); + assert_eq!(result.exit_code, 2); + assert!(!result.is_success()); + assert!(result.stderr.contains("oops")); + } +} diff --git a/perf/src/scenarios.rs b/perf/src/scenarios.rs new file mode 100644 index 0000000..96bb22e --- /dev/null +++ b/perf/src/scenarios.rs @@ -0,0 +1,429 @@ +//! Scenario table and the per-scenario execution protocol. + +use std::path::{Path, PathBuf}; + +use ctx::fixture::{self, FixtureSpec}; + +use crate::runner::{self, RunResult}; + +/// The v1.* SQL smoke query (matches the published examples). +pub const SQL_QUERY: &str = + "SELECT kind, count(*) FROM v1.symbols GROUP BY kind ORDER BY 2 DESC LIMIT 10"; + +/// Minimal-but-real rules file for `check_against_head`: one layer pair with +/// a forbidden edge (the fixture's zipf call graph guarantees findings, which +/// exit 1 — a success for timing purposes) plus a limit rule so metric +/// evaluation is exercised too. +const RULES_TOML: &str = r#"version = 1 + +[layers] +core = ["src/m00/**"] +api = ["src/m01/**"] + +[[rules.forbidden]] +from = "api" +to = "core" +reason = "perf fixture rule" + +[[rules.limit]] +metric = "complexity" +scope = "symbol" +max = 100 +"#; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FixtureKind { + Repo2k, + Repo150k, +} + +/// What to do before each run (warmup and timed alike). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PerRun { + Nothing, + /// Delete `.ctx/` so the next `ctx index` is cold. + DeleteCtx, + /// Rewrite `n` files with `fixture::apply_change_set`, round = run index, + /// so every run performs identical fresh work. + ApplyChangeSet(usize), +} + +#[derive(Debug, Clone)] +pub struct Scenario { + pub name: &'static str, + pub fixture: FixtureKind, + /// Run `ctx index` once during preparation. + pub prepare_index: bool, + /// Run `ctx map --budget 2000` once during preparation to warm the + /// PageRank cache (reused while the index is unchanged). + pub prepare_warm_map: bool, + /// Write `.ctx/rules.toml` during preparation (for `ctx check`). + pub prepare_rules: bool, + pub per_run: PerRun, + pub args: &'static [&'static str], + /// Absolute latency budget in ms; `None` = informational (no budget gate). + pub budget_ms: Option, + /// Included in the max-RSS gate (< 300 MB * scale)? + pub rss_gated: bool, + /// Only run in `--suite full`. + pub full_only: bool, +} + +/// The full scenario table (budgets from perf/README.md). +pub fn all() -> Vec { + vec![ + Scenario { + // Cold 2k indexing is not in the published budget table; + // reported for information and gated only against the baseline. + name: "index_cold_2k", + fixture: FixtureKind::Repo2k, + prepare_index: false, + prepare_warm_map: false, + prepare_rules: false, + per_run: PerRun::DeleteCtx, + args: &["index"], + budget_ms: None, + rss_gated: false, + full_only: false, + }, + Scenario { + name: "index_incremental_1", + fixture: FixtureKind::Repo2k, + prepare_index: true, + prepare_warm_map: false, + prepare_rules: false, + per_run: PerRun::ApplyChangeSet(1), + args: &["index"], + budget_ms: Some(300.0), + rss_gated: true, + full_only: false, + }, + Scenario { + name: "score_3_changed", + fixture: FixtureKind::Repo2k, + prepare_index: true, + prepare_warm_map: true, + prepare_rules: false, + per_run: PerRun::ApplyChangeSet(3), + args: &["score", "--against", "HEAD"], + budget_ms: Some(2000.0), + rss_gated: true, + full_only: false, + }, + Scenario { + name: "check_against_head", + fixture: FixtureKind::Repo2k, + prepare_index: true, + prepare_warm_map: true, + prepare_rules: true, + per_run: PerRun::ApplyChangeSet(3), + args: &["check", "--against", "HEAD"], + budget_ms: Some(1000.0), + rss_gated: true, + full_only: false, + }, + Scenario { + name: "map_cached", + fixture: FixtureKind::Repo2k, + prepare_index: true, + prepare_warm_map: true, + prepare_rules: false, + per_run: PerRun::Nothing, + args: &["map", "--budget", "2000"], + budget_ms: Some(500.0), + rss_gated: true, + full_only: false, + }, + Scenario { + name: "sql_v1_query", + fixture: FixtureKind::Repo2k, + prepare_index: true, + prepare_warm_map: false, + prepare_rules: false, + per_run: PerRun::Nothing, + args: &["sql", SQL_QUERY], + budget_ms: Some(500.0), + rss_gated: false, + full_only: false, + }, + Scenario { + name: "index_cold_150k", + fixture: FixtureKind::Repo150k, + prepare_index: false, + prepare_warm_map: false, + prepare_rules: false, + per_run: PerRun::DeleteCtx, + args: &["index"], + budget_ms: Some(60_000.0), + rss_gated: false, + full_only: true, + }, + ] +} + +impl Scenario { + /// Fixture spec for this scenario. `--smoke` substitutes the tiny spec + /// everywhere so the harness's own E2E test finishes in seconds. + pub fn spec(&self, smoke: bool) -> FixtureSpec { + if smoke { + return FixtureSpec::tiny(); + } + match self.fixture { + FixtureKind::Repo2k => FixtureSpec::repo_2k(), + FixtureKind::Repo150k => FixtureSpec::repo_150k_loc(), + } + } + + /// Discarded warmup runs before timing starts. + pub fn warmups(&self, smoke: bool) -> u32 { + if smoke || self.fixture == FixtureKind::Repo150k { + 1 + } else { + 2 + } + } + + /// Timed runs (median is taken over these). + pub fn timed_runs(&self, smoke: bool) -> u32 { + if smoke { + 2 + } else if self.fixture == FixtureKind::Repo150k { + 3 + } else { + 5 + } + } +} + +/// Result of executing one scenario's full protocol. +#[derive(Debug)] +pub enum ScenarioOutcome { + Measured { + /// Wall-clock ms per timed run, in run order. + runs_ms: Vec, + /// Max `ru_maxrss` (KB) across the timed runs. + max_rss_kb: u64, + }, + /// Scenario cannot run with this ctx binary (e.g. built without the + /// duckdb feature); reported but never failed. + Skipped(String), + /// The command errored (exit >= 2 or signal): a scenario failure. + Failed(String), +} + +/// Execute a scenario: generate its fixture under `work_dir`, run the +/// prepare-once steps, then warmups and timed runs with per-run preparation. +pub fn run_scenario( + scenario: &Scenario, + ctx_bin: &Path, + work_dir: &Path, + smoke: bool, +) -> Result { + let spec = scenario.spec(smoke); + let repo = work_dir.join(scenario.name); + eprintln!( + "[{}] generating fixture ({} files)...", + scenario.name, spec.files + ); + fixture::generate(&spec, &repo).map_err(|e| format!("fixture generation failed: {e}"))?; + + // Prepare-once steps (untimed). + if scenario.prepare_index { + prepare_step(scenario.name, ctx_bin, &["index"], &repo)?; + } + if scenario.prepare_warm_map { + prepare_step(scenario.name, ctx_bin, &["map", "--budget", "2000"], &repo)?; + } + if scenario.prepare_rules { + std::fs::create_dir_all(repo.join(".ctx")) + .map_err(|e| format!("cannot create .ctx: {e}"))?; + std::fs::write(repo.join(".ctx").join("rules.toml"), RULES_TOML) + .map_err(|e| format!("cannot write rules.toml: {e}"))?; + } + + // NOTE: for score/check we deliberately do NOT re-index between runs. + // Both commands refresh the index internally, and the published budgets + // include that refresh — this matches how the Claude Code hook path + // invokes them (dirty tree, possibly stale index). + let warmups = scenario.warmups(smoke); + let timed = scenario.timed_runs(smoke); + let mut runs_ms = Vec::with_capacity(timed as usize); + let mut max_rss_kb = 0u64; + // Change-set rounds run through warmups and timed runs on one counter so + // every run rewrites a fresh, deterministic file selection. + let mut round: u32 = 0; + + for i in 0..(warmups + timed) { + prepare_run(scenario, &spec, &repo, &mut round)?; + let result = runner::run_once(ctx_bin, scenario.args, &repo) + .map_err(|e| format!("failed to spawn {}: {e}", ctx_bin.display()))?; + + if let Some(reason) = skip_reason(&result) { + eprintln!("[{}] skipped: {reason}", scenario.name); + return Ok(ScenarioOutcome::Skipped(reason)); + } + if !result.is_success() { + return Ok(ScenarioOutcome::Failed(format!( + "ctx {} exited with code {}: {}", + scenario.args.join(" "), + result.exit_code, + snippet(&result.stderr) + ))); + } + + let is_warmup = i < warmups; + eprintln!( + "[{}] {} {:.1} ms (rss {} MB)", + scenario.name, + if is_warmup { "warmup" } else { "run " }, + result.wall_ms, + result.max_rss_kb / 1024 + ); + if !is_warmup { + runs_ms.push(result.wall_ms); + max_rss_kb = max_rss_kb.max(result.max_rss_kb); + } + } + + Ok(ScenarioOutcome::Measured { + runs_ms, + max_rss_kb, + }) +} + +/// Per-run preparation so every run does identical fresh work. +fn prepare_run( + scenario: &Scenario, + spec: &FixtureSpec, + repo: &Path, + round: &mut u32, +) -> Result<(), String> { + match scenario.per_run { + PerRun::Nothing => Ok(()), + PerRun::DeleteCtx => { + let ctx_dir = repo.join(".ctx"); + if ctx_dir.exists() { + std::fs::remove_dir_all(&ctx_dir) + .map_err(|e| format!("cannot delete .ctx: {e}"))?; + } + Ok(()) + } + PerRun::ApplyChangeSet(n) => { + // Smoke fixtures are tiny; never ask for more files than exist. + let n = n.min(spec.files); + let _changed: Vec = fixture::apply_change_set(spec, repo, n, *round) + .map_err(|e| format!("apply_change_set failed: {e}"))?; + *round += 1; + Ok(()) + } + } +} + +/// Run an untimed preparation command; exit 0/1 are both fine. +fn prepare_step(name: &str, ctx_bin: &Path, args: &[&str], repo: &Path) -> Result<(), String> { + eprintln!("[{name}] prepare: ctx {}", args.join(" ")); + let result = runner::run_once(ctx_bin, args, repo) + .map_err(|e| format!("failed to spawn {}: {e}", ctx_bin.display()))?; + if !result.is_success() { + return Err(format!( + "prepare step 'ctx {}' exited with code {}: {}", + args.join(" "), + result.exit_code, + snippet(&result.stderr) + )); + } + Ok(()) +} + +/// A ctx binary built without the duckdb feature exits 2 with a distinctive +/// message on `ctx sql`; treat that as a skip, not a failure, so local runs +/// of a stub binary don't hard-fail. +fn skip_reason(result: &RunResult) -> Option { + if result.exit_code == 2 && result.stderr.contains("requires the duckdb feature") { + Some("ctx binary built without the duckdb feature".to_string()) + } else { + None + } +} + +fn snippet(stderr: &str) -> String { + let trimmed = stderr.trim(); + if trimmed.len() > 400 { + format!("{}...", &trimmed[..400]) + } else { + trimmed.to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn table_matches_published_budgets() { + let scenarios = all(); + let get = |name: &str| { + scenarios + .iter() + .find(|s| s.name == name) + .unwrap_or_else(|| panic!("missing scenario {name}")) + }; + assert_eq!(get("index_cold_2k").budget_ms, None); + assert_eq!(get("index_incremental_1").budget_ms, Some(300.0)); + assert_eq!(get("score_3_changed").budget_ms, Some(2000.0)); + assert_eq!(get("check_against_head").budget_ms, Some(1000.0)); + assert_eq!(get("map_cached").budget_ms, Some(500.0)); + assert_eq!(get("sql_v1_query").budget_ms, Some(500.0)); + let cold = get("index_cold_150k"); + assert_eq!(cold.budget_ms, Some(60_000.0)); + assert!(cold.full_only); + // RSS gate covers exactly score/check/map/index_incremental. + let gated: Vec<&str> = scenarios + .iter() + .filter(|s| s.rss_gated) + .map(|s| s.name) + .collect(); + assert_eq!( + gated, + [ + "index_incremental_1", + "score_3_changed", + "check_against_head", + "map_cached" + ] + ); + } + + #[test] + fn run_counts_follow_protocol() { + let scenarios = all(); + for s in &scenarios { + if s.fixture == FixtureKind::Repo150k { + assert_eq!( + (s.warmups(false), s.timed_runs(false)), + (1, 3), + "{}", + s.name + ); + } else { + assert_eq!( + (s.warmups(false), s.timed_runs(false)), + (2, 5), + "{}", + s.name + ); + } + assert_eq!(s.timed_runs(true), 2, "smoke uses N=2 for {}", s.name); + assert_eq!(s.spec(true).files, FixtureSpec::tiny().files); + } + } + + #[test] + fn rules_toml_parses_as_toml() { + // Cheap structural sanity check without depending on the toml crate: + // the ctx side validates for real; here we pin the key sections. + assert!(RULES_TOML.contains("[layers]")); + assert!(RULES_TOML.contains("[[rules.forbidden]]")); + assert!(RULES_TOML.contains("[[rules.limit]]")); + } +} diff --git a/scripts/revert-rate.sh b/scripts/revert-rate.sh new file mode 100755 index 0000000..d1db090 --- /dev/null +++ b/scripts/revert-rate.sh @@ -0,0 +1,150 @@ +#!/bin/sh +# revert-rate.sh -- revert and fix-commit rates for a git repository. +# +# Purpose +# Longitudinal-study signal for "the change shipped but had to be undone +# or patched": counts revert commits and fix commits over the first-parent, +# non-merge history and reports them per 100 commits. +# +# Classification (a commit counts once; revert takes precedence over fix) +# revert: subject matches ^Revert " +# OR body contains This reverts commit +# fix: subject matches one of these conservative anchored patterns, +# case-insensitively (this is the exact, complete list): +# ^fix[(:! ] +# ^fix$ +# ^hotfix[(:! ] +# ^bugfix[(:! ] +# +# Commit set +# First-parent, non-merge commits of HEAD in [--since, --until]. +# +# Limitations +# * Only the first-parent chain is inspected; reverts that live on merged +# side branches are not seen. +# * Message-based classification only: reverts done by hand without the +# conventional message, or fixes with other subject styles, are missed. +# +# Output +# TSV on stdout: a header line +# total_commitsrevertsfixesreverts_per_100fixes_per_100 +# followed by one data row (rates to 2 decimals; 0.00 when the commit set +# is empty). Errors and usage go to stderr; exit 1 on bad usage, +# 0 otherwise. +# +# Usage +# revert-rate.sh [--since DATE] [--until DATE] [REPO_DIR] +# +# REPO_DIR defaults to `.`. The script never writes inside the target +# repository; every git command runs via `git -C "$REPO_DIR"`. + +set -eu + +usage() { + echo "usage: revert-rate.sh [--since DATE] [--until DATE] [REPO_DIR]" >&2 +} + +SINCE="" +UNTIL="" +REPO_DIR="" + +while [ $# -gt 0 ]; do + case "$1" in + --since) + if [ $# -lt 2 ]; then usage; exit 1; fi + SINCE=$2 + shift 2 + ;; + --until) + if [ $# -lt 2 ]; then usage; exit 1; fi + UNTIL=$2 + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + -*) + echo "revert-rate.sh: unknown option: $1" >&2 + usage + exit 1 + ;; + *) + if [ -n "$REPO_DIR" ]; then + echo "revert-rate.sh: too many arguments" >&2 + usage + exit 1 + fi + REPO_DIR=$1 + shift + ;; + esac +done + +if [ -z "$REPO_DIR" ]; then + REPO_DIR=. +fi + +if ! git -C "$REPO_DIR" rev-parse --git-dir >/dev/null 2>&1; then + echo "revert-rate.sh: not a git repository: $REPO_DIR" >&2 + exit 1 +fi + +g() { + git -C "$REPO_DIR" "$@" +} + +TOTAL=0 +REVERTS=0 +FIXES=0 + +COMMITS="" +if g rev-parse -q --verify 'HEAD^{commit}' >/dev/null 2>&1; then + set -- + if [ -n "$SINCE" ]; then + set -- "$@" --since="$SINCE" + fi + if [ -n "$UNTIL" ]; then + set -- "$@" --until="$UNTIL" + fi + # Capture into a variable so a rev-list failure aborts under set -e. + COMMITS=$(g rev-list --first-parent --no-merges "$@" HEAD) +fi + +for C in $COMMITS; do + TOTAL=$((TOTAL + 1)) + SUBJECT=$(g show -s --format=%s "$C") + BODY=$(g show -s --format=%b "$C") + + IS_REVERT=0 + case "$SUBJECT" in + 'Revert "'*) IS_REVERT=1 ;; + esac + if [ "$IS_REVERT" -eq 0 ]; then + case "$BODY" in + *'This reverts commit '*) IS_REVERT=1 ;; + esac + fi + + if [ "$IS_REVERT" -eq 1 ]; then + REVERTS=$((REVERTS + 1)) + continue + fi + + # Case-insensitive anchored fix patterns (see header for the list). + LSUBJECT=$(printf '%s' "$SUBJECT" | tr '[:upper:]' '[:lower:]') + case "$LSUBJECT" in + fix|'fix('*|'fix:'*|'fix!'*|'fix '*) FIXES=$((FIXES + 1)) ;; + 'hotfix('*|'hotfix:'*|'hotfix!'*|'hotfix '*) FIXES=$((FIXES + 1)) ;; + 'bugfix('*|'bugfix:'*|'bugfix!'*|'bugfix '*) FIXES=$((FIXES + 1)) ;; + esac +done + +RATES=$(awk -v t="$TOTAL" -v r="$REVERTS" -v f="$FIXES" 'BEGIN { + if (t > 0) printf "%.2f\t%.2f", r * 100 / t, f * 100 / t + else printf "0.00\t0.00" +}') + +printf 'total_commits\treverts\tfixes\treverts_per_100\tfixes_per_100\n' +printf '%s\t%s\t%s\t%s\n' "$TOTAL" "$REVERTS" "$FIXES" "$RATES" +exit 0 diff --git a/scripts/rework-rate.sh b/scripts/rework-rate.sh new file mode 100755 index 0000000..a6e66b3 --- /dev/null +++ b/scripts/rework-rate.sh @@ -0,0 +1,227 @@ +#!/bin/sh +# rework-rate.sh -- N-day rework rate for a git repository. +# +# Purpose +# Longitudinal-study proxy for "the change worked but wasn't right": for +# each qualifying commit C, measure what fraction of the lines C added +# were modified or deleted again within a fixed window (default 30 days). +# +# Definition +# added(C) = sum of added-line counts from `git diff --numstat C^ C` +# (binary entries, where the count is `-`, are skipped). +# For a root commit (no parent) the diff base is the git +# empty-tree object. +# boundary(C) = the last first-parent commit whose committer date is +# <= date(C) + window. Window arithmetic is done in pure +# awk/shell on `%ct` epoch seconds, so no `date` binary is +# needed and there is no GNU vs BSD `date` portability +# issue (BSD date uses `-v+30d`, GNU date uses `-d`; we +# avoid both). +# surviving(C) = for each file F touched by C, if F still exists at +# boundary(C): the number of lines that +# `git blame -w --line-porcelain boundary(C) -- F` +# attributes to C. Files deleted by boundary(C) +# contribute 0. +# rework(C) = (added(C) - surviving(C)) / added(C) +# +# Commit set +# First-parent, non-merge commits of HEAD in [--since, --until]. +# Excluded from the set: +# * commits whose committer date is younger than window-days before +# the repository's NEWEST first-parent commit date -- their window +# is incomplete. The reference point is the newest commit's date, +# not the wall clock, so results are reproducible on a static clone. +# * formatting-only commits: commits for which +# `git diff -w --ignore-blank-lines C^ C` produces no output. +# * commits with added(C) == 0 (no denominator). +# +# Limitations +# * Renames are not followed: a file renamed inside the window is seen +# as deleted, so its lines count as reworked. +# * Only the first-parent chain is measured; work merged in via side +# branches is attributed to nothing (merge commits are skipped). +# * Lines whose only later change is whitespace are still counted as +# surviving (blame runs with -w), but a mixed commit's own +# whitespace-only line edits are counted in added(C) (plain +# --numstat) while blame -w attributes those lines to an earlier +# commit, slightly overstating rework for such commits. +# * Paths that git quotes in --numstat output (embedded tabs, quotes, +# control characters) may fail the existence check and be treated as +# deleted. `core.quotePath` is disabled so plain non-ASCII names work. +# +# Output +# TSV on stdout: a header line +# commitaddedsurvivingrework_fraction +# then one row per qualifying commit (fraction to 4 decimals), then a +# final aggregate line: +# # aggregate +# An empty commit set (e.g. an empty repository, or every commit +# excluded) still prints the header plus a zero aggregate and exits 0. +# Errors and usage go to stderr; exit 1 on bad usage, 0 otherwise. +# +# Usage +# rework-rate.sh [--window-days N] [--since DATE] [--until DATE] [REPO_DIR] +# +# REPO_DIR defaults to `.`. The script never writes inside the target +# repository; every git command runs via `git -C "$REPO_DIR"`. + +set -eu + +usage() { + echo "usage: rework-rate.sh [--window-days N] [--since DATE] [--until DATE] [REPO_DIR]" >&2 +} + +WINDOW_DAYS=30 +SINCE="" +UNTIL="" +REPO_DIR="" + +while [ $# -gt 0 ]; do + case "$1" in + --window-days) + if [ $# -lt 2 ]; then usage; exit 1; fi + WINDOW_DAYS=$2 + shift 2 + ;; + --since) + if [ $# -lt 2 ]; then usage; exit 1; fi + SINCE=$2 + shift 2 + ;; + --until) + if [ $# -lt 2 ]; then usage; exit 1; fi + UNTIL=$2 + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + -*) + echo "rework-rate.sh: unknown option: $1" >&2 + usage + exit 1 + ;; + *) + if [ -n "$REPO_DIR" ]; then + echo "rework-rate.sh: too many arguments" >&2 + usage + exit 1 + fi + REPO_DIR=$1 + shift + ;; + esac +done + +if [ -z "$REPO_DIR" ]; then + REPO_DIR=. +fi + +case "$WINDOW_DAYS" in + ''|*[!0-9]*) + echo "rework-rate.sh: --window-days must be a non-negative integer" >&2 + exit 1 + ;; +esac + +if ! git -C "$REPO_DIR" rev-parse --git-dir >/dev/null 2>&1; then + echo "rework-rate.sh: not a git repository: $REPO_DIR" >&2 + exit 1 +fi + +# Every git invocation goes through this wrapper: read-only, repo-targeted, +# and with path quoting disabled so plain UTF-8 paths round-trip. +g() { + git -C "$REPO_DIR" -c core.quotePath=false "$@" +} + +printf 'commit\tadded\tsurviving\trework_fraction\n' + +TOTAL_ADDED=0 +TOTAL_SURVIVING=0 + +emit_aggregate() { + AGG=$(awk -v a="$TOTAL_ADDED" -v s="$TOTAL_SURVIVING" \ + 'BEGIN { if (a > 0) printf "%.4f", (a - s) / a; else printf "%.4f", 0 }') + printf '# aggregate\t%s\t%s\t%s\n' "$TOTAL_ADDED" "$TOTAL_SURVIVING" "$AGG" +} + +# Empty repository: header plus zero aggregate. +if ! g rev-parse -q --verify 'HEAD^{commit}' >/dev/null 2>&1; then + emit_aggregate + exit 0 +fi + +EMPTY_TREE=$(g hash-object -t tree /dev/null) +NEWEST_CT=$(g log -1 --first-parent --format=%ct HEAD) +WINDOW_SECS=$((WINDOW_DAYS * 86400)) +CUTOFF=$((NEWEST_CT - WINDOW_SECS)) + +# Build the rev-list argument vector (avoid `[ -n ] &&` which trips set -e). +set -- +if [ -n "$SINCE" ]; then + set -- "$@" --since="$SINCE" +fi +if [ -n "$UNTIL" ]; then + set -- "$@" --until="$UNTIL" +fi + +# Capture into a variable so a rev-list failure aborts under set -e. +COMMITS=$(g rev-list --first-parent --no-merges "$@" HEAD) + +for C in $COMMITS; do + CT=$(g show -s --format=%ct "$C") + + # Incomplete window: committer date younger than window-days before the + # newest first-parent commit date. + if [ "$CT" -gt "$CUTOFF" ]; then + continue + fi + + # Root commits have no parent; diff against the empty tree instead. + if g rev-parse -q --verify "$C^" >/dev/null 2>&1; then + BASE="$C^" + else + BASE=$EMPTY_TREE + fi + + # Skip formatting-only commits (whitespace / blank-line changes only). + if g diff --quiet -w --ignore-blank-lines "$BASE" "$C"; then + continue + fi + + ADDED=$(g diff --no-renames --numstat "$BASE" "$C" \ + | awk -F'\t' '$1 != "-" { a += $1 } END { printf "%d", a }') + if [ "$ADDED" -eq 0 ]; then + continue + fi + + # Boundary commit: last first-parent commit dated <= date(C) + window. + # Pure epoch arithmetic; git parses "@" natively. + END_TS=$((CT + WINDOW_SECS)) + B=$(g rev-list -1 --first-parent --before="@$END_TS" HEAD) + + SURVIVING=$( + g diff --no-renames --numstat "$BASE" "$C" \ + | awk -F'\t' '$1 != "-" && $2 != "-"' \ + | cut -f3- \ + | while IFS= read -r F; do + if g cat-file -e "$B:$F" 2>/dev/null; then + g blame -w --line-porcelain "$B" -- "$F" \ + | awk -v c="$C" '$1 == c { n++ } END { printf "%d\n", n + 0 }' + fi + done \ + | awk '{ s += $1 } END { printf "%d", s + 0 }' + ) + + FRACTION=$(awk -v a="$ADDED" -v s="$SURVIVING" \ + 'BEGIN { printf "%.4f", (a - s) / a }') + printf '%s\t%s\t%s\t%s\n' "$C" "$ADDED" "$SURVIVING" "$FRACTION" + + TOTAL_ADDED=$((TOTAL_ADDED + ADDED)) + TOTAL_SURVIVING=$((TOTAL_SURVIVING + SURVIVING)) +done + +emit_aggregate +exit 0 diff --git a/src/analytics/duckdb_impl.rs b/src/analytics/duckdb_impl.rs index bc3e9ca..9d97f36 100644 --- a/src/analytics/duckdb_impl.rs +++ b/src/analytics/duckdb_impl.rs @@ -529,30 +529,48 @@ impl Analytics { // ======================================================================== /// Open DuckDB for the `ctx sql` command: attach the SQLite index read-only, - /// build the public `v1` view layer, then lock the engine down so untrusted + /// build the public `v1` view layer, optionally materialize snapshot + /// partitions as `snap.*` tables, then lock the engine down so untrusted /// user SQL cannot touch the filesystem, load extensions, or re-enable any /// of that. Safety is enforced entirely by engine configuration — never by /// inspecting the SQL text. - pub fn open_sql_sandbox(root: &Path) -> Result { - let ctx_dir = root.join(CTX_DIR); - let sqlite_path = ctx_dir.join(DB_FILE); - - let conn = Connection::open_in_memory()?; - - // Attach the SQLite index read-only (single-quote-escape the path). - let path_str = sqlite_path.display().to_string(); - let escaped_path = path_str.replace('\'', "''"); - conn.execute( - &format!("ATTACH '{}' AS code (TYPE sqlite, READ_ONLY)", escaped_path), - [], - )?; - - let analytics = Self { conn }; - - // Build the public `v1` contract views BEFORE hardening — creating views - // and reading the attached DB must happen while access is still allowed. - let index_root = root.display().to_string(); - analytics.create_public_schema_v1(env!("CARGO_PKG_VERSION"), &index_root)?; + /// + /// `snapshots`, when set, is a directory of `sha=/` Parquet + /// partitions written by `ctx snapshot`; each partition's four files are + /// loaded into in-memory tables `snap.files`, `snap.symbols`, + /// `snap.dup_pairs`, and `snap.meta`. + pub fn open_sql_sandbox(root: &Path, snapshots: Option<&Path>) -> crate::error::Result { + // Attach the index and build the public `v1` contract views BEFORE + // hardening — creating views and reading the attached DB must happen + // while access is still allowed. + let analytics = Self::open_with_public_schema(root)?; + + // Load snapshot partitions BEFORE hardening, as MATERIALIZED tables + // (CREATE TABLE ... AS), not views. This ordering is load-bearing: + // `enable_external_access = false` below disables all filesystem reads + // at query time, so a lazy view over read_parquet() would fail on its + // first use — the Parquet data must be fully read into memory now. + if let Some(dir) = snapshots { + if !Self::has_snapshot_partitions(dir) { + return Err(crate::error::CtxError::Other(format!( + "no snapshots found under {}; run 'ctx snapshot' first", + dir.display() + ))); + } + // Single-quote-escape the glob path, like the ATTACH path above. + let escaped_dir = dir.display().to_string().replace('\'', "''"); + analytics.conn.execute_batch("CREATE SCHEMA snap;")?; + for table in ["files", "symbols", "dup_pairs", "meta"] { + // `hive_partitioning = false`: every row already carries + // `commit_sha`; don't add a duplicate `sha` column from the + // partition directory name. + analytics.conn.execute_batch(&format!( + "CREATE TABLE snap.{table} AS \ + SELECT * FROM read_parquet('{escaped_dir}/sha=*/{table}.parquet', \ + union_by_name = true, hive_partitioning = false);" + ))?; + } + } // Engine-level hardening (order matters: memory + external-access first, // then lock configuration, which blocks any further `SET`). @@ -579,6 +597,63 @@ impl Analytics { Ok(analytics) } + /// Whether `dir` contains at least one `sha=/` snapshot partition. + fn has_snapshot_partitions(dir: &Path) -> bool { + std::fs::read_dir(dir) + .map(|entries| { + entries.filter_map(|e| e.ok()).any(|e| { + e.file_name().to_string_lossy().starts_with("sha=") && e.path().is_dir() + }) + }) + .unwrap_or(false) + } + + /// Open DuckDB for snapshot export (`ctx snapshot`): attach the SQLite + /// index read-only and build the same public `v1` view layer as + /// [`Analytics::open_sql_sandbox`], but with **no** engine hardening + /// (`enable_external_access` stays on so `COPY ... TO ... (FORMAT + /// PARQUET)` can write partition files). + /// + /// This is a trusted internal path: it only ever executes SQL composed by + /// ctx itself and is never fed user SQL. Anything user-facing must go + /// through the hardened [`Analytics::open_sql_sandbox`] instead. + pub fn open_export(root: &Path) -> Result { + Self::open_with_public_schema(root) + } + + /// Shared constructor for [`Analytics::open_sql_sandbox`] and + /// [`Analytics::open_export`]: in-memory DuckDB with the SQLite index + /// attached read-only as `code` and the public `v1` views created. + /// Performs no hardening — callers decide the trust level. + fn open_with_public_schema(root: &Path) -> Result { + let ctx_dir = root.join(CTX_DIR); + let sqlite_path = ctx_dir.join(DB_FILE); + + let conn = Connection::open_in_memory()?; + + // Attach the SQLite index read-only (single-quote-escape the path). + let path_str = sqlite_path.display().to_string(); + let escaped_path = path_str.replace('\'', "''"); + conn.execute( + &format!("ATTACH '{}' AS code (TYPE sqlite, READ_ONLY)", escaped_path), + [], + )?; + + let analytics = Self { conn }; + + let index_root = root.display().to_string(); + analytics.create_public_schema_v1(env!("CARGO_PKG_VERSION"), &index_root)?; + + Ok(analytics) + } + + /// Raw connection access for trusted internal callers (snapshot export + /// runs ctx-composed DDL/COPY and uses `duckdb::Appender` directly). + /// Never expose this to user SQL. + pub(crate) fn connection(&self) -> &Connection { + &self.conn + } + /// Create the versioned public schema `v1` — the stable query surface. /// /// These views (not the physical `code.*` tables) are the contract. Column @@ -816,6 +891,22 @@ fn value_ref_to_json(v: ValueRef<'_>) -> serde_json::Value { .unwrap_or(J::Null), ValueRef::Text(bytes) => J::String(String::from_utf8_lossy(bytes).into_owned()), ValueRef::Blob(bytes) => J::String(format!("", bytes.len())), + ValueRef::Timestamp(unit, raw) => { + use duckdb::types::TimeUnit; + let nanos = match unit { + TimeUnit::Second => (raw as i128) * 1_000_000_000, + TimeUnit::Millisecond => (raw as i128) * 1_000_000, + TimeUnit::Microsecond => (raw as i128) * 1_000, + TimeUnit::Nanosecond => raw as i128, + }; + let formatted = time::OffsetDateTime::from_unix_timestamp_nanos(nanos) + .ok() + .and_then(|dt| { + dt.format(&time::format_description::well_known::Rfc3339) + .ok() + }); + J::String(formatted.unwrap_or_else(|| format!("Timestamp({unit:?}, {raw})"))) + } other => J::String(format!("{:?}", other)), } } diff --git a/src/cli.rs b/src/cli.rs index 32332c1..cfc18d8 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -164,6 +164,7 @@ pub enum Command { ctx sql --json "SELECT kind, COUNT(*) n FROM v1.symbols GROUP BY kind" ctx sql --fail-on-rows --file .ctx/gates/no-utils-imports.sql ctx sql --schema # print the v1 schema reference and exit + ctx sql --snapshots "SELECT commit_sha, count(*) FROM snap.dup_pairs GROUP BY commit_sha" The query surface is the versioned `v1` schema; anything outside `v1.*` is internal and unstable. Access is read-only and engine-hardened. @@ -200,6 +201,19 @@ internal and unstable. Access is read-only and engine-hardened. /// Print the public schema reference and exit #[arg(long)] schema: bool, + + /// Load snapshot partitions from DIR (default .ctx/snapshots) as + /// in-memory tables snap.files / snap.symbols / snap.dup_pairs / + /// snap.meta for trend queries (see `ctx snapshot`). Pass a custom + /// dir with `--snapshots=DIR`. + #[arg( + long, + value_name = "DIR", + num_args = 0..=1, + require_equals = true, + default_missing_value = ".ctx/snapshots" + )] + snapshots: Option, }, /// Search for symbols using semantic or text search @@ -689,6 +703,44 @@ EXAMPLES: fail_on: Option, }, + /// Capture per-commit Parquet metric snapshots (.ctx/snapshots/sha=/) + /// + /// Refreshes the index incrementally, then exports per-symbol and + /// per-file metrics, near-duplicate pairs, and metadata for HEAD as one + /// Parquet partition, for longitudinal quality analysis. Requires a git + /// repository; snapshot capture requires the duckdb feature. + /// + /// Exit codes: 0 = success (including "partition already exists"), + /// 2 = operational error (not a git repo, missing duckdb feature, IO). + #[command(after_help = r#"PARTITION LAYOUT: + .ctx/snapshots/sha=/symbols.parquet per-symbol metrics + .ctx/snapshots/sha=/files.parquet per-file metrics + churn + violations + .ctx/snapshots/sha=/dup_pairs.parquet near-duplicate function pairs + .ctx/snapshots/sha=/meta.parquet capture metadata (1 row) + + Every row carries commit_sha and committed_at, so tables union cleanly + across partitions with a read_parquet glob. + +EXAMPLES: + ctx snapshot # snapshot HEAD (skip if it exists) + ctx snapshot --force # rewrite the HEAD partition + ctx snapshot --json # machine-readable report + ctx snapshot backfill --since v0.1.0 # snapshot v0.1.0..HEAD (first-parent) + ctx snapshot backfill --since main~20 --every 5 +"#)] + Snapshot { + #[command(subcommand)] + cmd: Option, + + /// Overwrite an existing partition for HEAD + #[arg(long)] + force: bool, + + /// How far back to count per-file churn (git --since date spec) + #[arg(long, value_name = "SPEC", default_value = "90 days ago")] + churn_window: String, + }, + /// Package ctx as an AI coding harness integration (Claude Code) /// /// `init` scaffolds hook scripts, settings, and (in plugin mode) a full @@ -797,6 +849,32 @@ pub enum HarnessMode { Plugin, } +#[derive(Subcommand, Debug)] +pub enum SnapshotCommand { + /// Backfill snapshots for historical commits (first-parent walk) + /// + /// Walks `--since `..HEAD along the first parent, oldest first + /// (including REF itself when it names a commit). Each missing commit is + /// checked out into a temporary git worktree, snapshotted into this + /// repository's .ctx/snapshots/, and the worktree is removed again; the + /// working tree is never touched. Existing partitions are skipped. + /// Per-commit failures are logged to stderr and do not abort the run. + Backfill { + /// Starting commit/ref (inclusive when it resolves to a commit) + #[arg(long, value_name = "REF")] + since: String, + + /// Sample every Nth commit (the newest commit is always included) + #[arg(long, value_name = "N", default_value = "1")] + every: usize, + + /// How far back to count per-file churn (git --since date spec); + /// the upper bound is anchored to each commit's date + #[arg(long, value_name = "SPEC", default_value = "90 days ago")] + churn_window: String, + }, +} + #[derive(Subcommand, Debug)] pub enum HarnessCommand { /// Generate integration files from templates embedded in this binary diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 99babb3..f1839d9 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -21,6 +21,7 @@ pub mod search; pub mod self_update; pub mod similar; pub mod smart_cmd; +pub mod snapshot; pub mod sql; pub mod symbol; @@ -44,6 +45,7 @@ pub use search::run_search; pub use self_update::{run_self_update, run_version}; pub use similar::run_similar; pub use smart_cmd::run_smart; +pub use snapshot::run_snapshot; pub use sql::{run_sql, SqlArgs}; pub use symbol::{run_explain, run_source}; diff --git a/src/commands/score.rs b/src/commands/score.rs index 168a890..eaa3815 100644 --- a/src/commands/score.rs +++ b/src/commands/score.rs @@ -8,7 +8,8 @@ use std::path::Path; use ctx::error::Result; use ctx::exit::Outcome; -use ctx::score::{self, FailCondition, ScoreReport}; +use ctx::gatelog; +use ctx::score::{self, FailCondition, Metrics, ScoreReport}; /// Run `ctx score` in the current directory. pub fn run_score(against: &str, fail_on: Option<&str>, json: bool) -> Result { @@ -38,6 +39,32 @@ fn run_score_in( let failed = score::failed_conditions(&conditions, &report.metrics); + // Opt-in gate log (CTX_GATE_LOG): append one record per evaluation. + // Best-effort -- an IO failure warns once on stderr and never changes + // the command's exit outcome. + if let Some(log_path) = gatelog::gate_log_target(root) { + let record = gatelog::GateRecord { + schema_version: gatelog::GATE_LOG_SCHEMA_VERSION, + ts: gatelog::now_rfc3339(), + ctx_version: env!("CARGO_PKG_VERSION").to_string(), + source: "score".to_string(), + against: against.to_string(), + fail_on: fail_on.map(str::to_string), + metrics: metrics_value(&report.metrics), + failed_conditions: failed.iter().map(|c| c.to_string()).collect(), + outcome: if failed.is_empty() { "pass" } else { "fail" }.to_string(), + blocking: gatelog::blocking_enabled(), + session_id: gatelog::session_id(), + }; + if let Err(err) = gatelog::append(&log_path, &record) { + eprintln!( + "warning: could not append gate log {}: {}", + log_path.display(), + err + ); + } + } + if json_mode { ctx::json::emit("score", score_data(&report, &failed))?; } else { @@ -63,6 +90,20 @@ fn run_score_in( // Output // ============================================================================ +/// The seven-key `metrics` object shared by the `--json` payload and the +/// gate log (see docs/json-output.md, `score`). +fn metrics_value(m: &Metrics) -> serde_json::Value { + serde_json::json!({ + "complexity_delta": m.complexity_delta, + "fan_out_delta": m.fan_out_delta, + "new_duplication": m.new_duplication, + "check_violations": m.check_violations, + "symbols_added": m.symbols_added, + "symbols_removed": m.symbols_removed, + "files_changed": m.files_changed, + }) +} + /// The `data` payload for `--json` mode (see docs/json-output.md, `score`). fn score_data(report: &ScoreReport, failed: &[FailCondition]) -> serde_json::Value { let m = &report.metrics; @@ -85,15 +126,7 @@ fn score_data(report: &ScoreReport, failed: &[FailCondition]) -> serde_json::Val serde_json::json!({ "against": report.against, "files_changed": m.files_changed, - "metrics": { - "complexity_delta": m.complexity_delta, - "fan_out_delta": m.fan_out_delta, - "new_duplication": m.new_duplication, - "check_violations": m.check_violations, - "symbols_added": m.symbols_added, - "symbols_removed": m.symbols_removed, - "files_changed": m.files_changed, - }, + "metrics": metrics_value(m), "check_violations_note": report.check_violations_note, "per_file": per_file, "failed_conditions": failed.iter().map(|c| c.to_string()).collect::>(), diff --git a/src/commands/snapshot.rs b/src/commands/snapshot.rs new file mode 100644 index 0000000..2eea902 --- /dev/null +++ b/src/commands/snapshot.rs @@ -0,0 +1,125 @@ +//! `ctx snapshot` -- per-commit Parquet metric snapshot CLI. +//! +//! Thin wrapper around the [`ctx::snapshot`] engine: bare `ctx snapshot` +//! captures HEAD into `.ctx/snapshots/sha=/`; `ctx snapshot backfill` +//! captures historical commits via temporary git worktrees. +//! +//! Exit codes: 0 = success (including "partition already exists"), +//! 2 = operational error (not a git repo, stub build, IO failure). + +use ctx::error::Result; +use ctx::exit::Outcome; +#[cfg(feature = "duckdb")] +use ctx::snapshot::{BackfillOptions, CaptureMode, CaptureOptions, SnapshotReport, SNAPSHOTS_DIR}; + +use crate::cli::SnapshotCommand; + +/// Run `ctx snapshot [backfill]` in the current directory. +// The flag arguments are only read on the `duckdb` execution path. +#[cfg_attr(not(feature = "duckdb"), allow(unused_variables))] +pub fn run_snapshot( + cmd: Option, + force: bool, + churn_window: &str, + json: bool, +) -> Result { + #[cfg(not(feature = "duckdb"))] + { + Err(ctx::error::CtxError::Other( + "ctx snapshot requires the duckdb feature; reinstall with default features".into(), + )) + } + + #[cfg(feature = "duckdb")] + { + let root = std::env::current_dir()?; + let out_dir = root.join(SNAPSHOTS_DIR); + match cmd { + None => { + // A live snapshot is labeled with HEAD's sha but reflects the + // working tree; warn when the two can disagree. + if ctx::gitutil::is_dirty_in(&root).unwrap_or(false) { + eprintln!( + "warning: working tree is dirty; the snapshot is labeled with \ + HEAD's sha but reflects the working tree" + ); + } + let report = ctx::snapshot::capture( + &root, + &CaptureOptions { + out_dir, + churn_window: churn_window.to_string(), + capture_mode: CaptureMode::Live, + force, + }, + )?; + if json { + ctx::json::emit("snapshot.capture", serde_json::to_value(&report)?)?; + } else { + print_report(&report); + } + Ok(Outcome::Clean) + } + Some(SnapshotCommand::Backfill { + since, + every, + churn_window, + }) => { + let reports = ctx::snapshot::backfill( + &root, + &BackfillOptions { + since: since.clone(), + every, + churn_window, + out_dir, + }, + )?; + if json { + let captured = reports.iter().filter(|r| !r.skipped_existing).count(); + let skipped = reports.len() - captured; + ctx::json::emit( + "snapshot.backfill", + serde_json::json!({ + "since": since, + "captured": captured, + "skipped_existing": skipped, + "snapshots": serde_json::to_value(&reports)?, + }), + )?; + } else { + for report in &reports { + print_report(report); + } + println!( + "backfilled {} snapshot{}", + reports.len(), + if reports.len() == 1 { "" } else { "s" } + ); + } + Ok(Outcome::Clean) + } + } + } +} + +/// One concise human line per partition. +#[cfg(feature = "duckdb")] +fn print_report(report: &SnapshotReport) { + let short = &report.commit_sha[..12.min(report.commit_sha.len())]; + if report.skipped_existing { + println!( + "snapshot {} -> {} (exists, skipped; use --force to rewrite)", + short, report.partition_dir + ); + } else { + println!( + "snapshot {} -> {} (files {}, symbols {}, dup_pairs {}, violations {})", + short, + report.partition_dir, + report.files, + report.symbols, + report.dup_pairs, + report.violations + ); + } +} diff --git a/src/commands/sql.rs b/src/commands/sql.rs index 7a9d149..94d9145 100644 --- a/src/commands/sql.rs +++ b/src/commands/sql.rs @@ -30,6 +30,9 @@ pub struct SqlArgs { pub timeout: u64, pub fail_on_rows: bool, pub schema: bool, + /// Snapshot partition directory to load as `snap.*` tables (relative + /// paths resolve against the project root). + pub snapshots: Option, } /// Run `ctx sql`. @@ -226,7 +229,17 @@ mod engine { return Err(CtxError::Other("no SQL statement found".into())); } - let analytics = Analytics::open_sql_sandbox(&root)?; + // Resolve `--snapshots` against the project root (like the index path + // above); absolute directories are taken as-is. + let snapshots_dir = args.snapshots.as_ref().map(|dir| { + if dir.is_absolute() { + dir.clone() + } else { + root.join(dir) + } + }); + + let analytics = Analytics::open_sql_sandbox(&root, snapshots_dir.as_deref())?; // Timeout watchdog: interrupt the in-flight query if it overruns. let timed_out = Arc::new(AtomicBool::new(false)); diff --git a/src/commands/sql_schema.md b/src/commands/sql_schema.md index 0483518..58e99c3 100644 --- a/src/commands/sql_schema.md +++ b/src/commands/sql_schema.md @@ -92,3 +92,78 @@ FROM v1.symbols WHERE kind IN ('function', 'method') AND is_public AND fan_in = 0 ORDER BY file, name; ``` + +## Snapshot tables (`snap.*`) — only with `--snapshots` + +`ctx sql --snapshots[=DIR]` (default `DIR` is `.ctx/snapshots`) additionally +loads the Parquet snapshot partitions written by `ctx snapshot` as in-memory +tables in the `snap` schema. These tables exist **only** when `--snapshots` +is passed; without it, any `snap.*` reference is an error. Every row is +denormalized with the partition stamp: + +| Column | Type | Description | +|----------------|-----------|------------------------------------------------| +| `commit_sha` | VARCHAR | Full sha of the snapshotted commit | +| `committed_at` | TIMESTAMP | Committer date of that commit, normalized to UTC | + +### `snap.files` — one row per file per commit + +Stamp columns plus: + +| Column | Type | Description | +|--------------------|---------|-----------------------------------------------| +| `path` | VARCHAR | File path | +| `language` | VARCHAR | Detected language | +| `symbol_count` | BIGINT | Symbols defined in the file | +| `total_complexity` | DOUBLE | Sum of symbol complexity for the file | +| `max_complexity` | BIGINT | Highest single-symbol complexity in the file | +| `churn_commits` | INTEGER | Commits touching the file in the churn window | +| `violation_count` | INTEGER | Architecture-rule violations in the file | + +### `snap.symbols` — one row per symbol per commit + +Stamp columns plus the `v1.symbols` columns `id`, `name`, `qualified_name`, +`kind`, `file`, `line_start`, `line_end`, `is_public`, `complexity`, +`fan_in`, and `fan_out` (same types as in `v1.symbols`; no `doc`). + +### `snap.dup_pairs` — one row per near-duplicate pair per commit + +Stamp columns plus: + +| Column | Type | Description | +|-----------------|---------|----------------------------------------| +| `file_a` | VARCHAR | File of the first symbol | +| `symbol_a` | VARCHAR | Name of the first symbol | +| `file_b` | VARCHAR | File of the second symbol | +| `symbol_b` | VARCHAR | Name of the second symbol | +| `similarity` | DOUBLE | Verified token similarity (0–1) | +| `token_count_a` | BIGINT | Normalized token count of the first | +| `token_count_b` | BIGINT | Normalized token count of the second | + +### `snap.meta` — one row per partition + +Stamp columns plus: + +| Column | Type | Description | +|---------------------------|---------|-------------------------------------------| +| `captured_at` | VARCHAR | RFC 3339 time the snapshot was captured | +| `ctx_version` | VARCHAR | ctx version that wrote the partition | +| `snapshot_schema_version` | INTEGER | Snapshot Parquet schema version | +| `capture_mode` | VARCHAR | `live` or `backfill` | + +### Trend queries + +```sql +-- Duplication trend +SELECT commit_sha, min(committed_at) AS committed_at, count(*) AS dup_pairs FROM snap.dup_pairs GROUP BY commit_sha ORDER BY committed_at; +``` + +```sql +-- Violation trend +SELECT commit_sha, min(committed_at) AS committed_at, sum(violation_count) AS violations FROM snap.files GROUP BY commit_sha ORDER BY committed_at; +``` + +```sql +-- Hotspot mass (top-decile complexity by commit) +WITH ranked AS (SELECT commit_sha, committed_at, churn_commits * total_complexity AS mass, percent_rank() OVER (PARTITION BY commit_sha ORDER BY total_complexity) AS pr FROM snap.files) SELECT commit_sha, min(committed_at) AS committed_at, sum(mass) AS hotspot_mass FROM ranked WHERE pr >= 0.9 GROUP BY commit_sha ORDER BY committed_at; +``` diff --git a/src/fixture.rs b/src/fixture.rs new file mode 100644 index 0000000..fd0ad81 --- /dev/null +++ b/src/fixture.rs @@ -0,0 +1,592 @@ +//! Deterministic synthetic-repo generator. +//! +//! Produces seeded, byte-reproducible Rust codebases with cross-file call +//! graphs and synthesized git history, used by the performance harness in +//! `perf/` and reusable by the external ctx-bench suite. +//! +//! # Determinism contract +//! +//! The same [`FixtureSpec`] always produces a byte-identical tree at every +//! commit, and — because commits use a fixed identity, fixed dates, and fixed +//! messages — identical commit SHAs. Generation never depends on wall-clock +//! time, environment, platform, or hash-map iteration order; randomness comes +//! from an inline SplitMix64 PRNG and the only floating-point operations are +//! IEEE 754 basic ops (`+`, `*`, `/`, `sqrt`), which are exactly specified. +//! Any change that alters the generated bytes MUST bump +//! [`FIXTURE_FORMAT_VERSION`]. +//! +//! # Shape of the output +//! +//! Files live at `src/m{module:02}/f{file:04}.rs` and parse individually as +//! Rust, but the tree is deliberately not a compilable cargo project — the +//! ctx indexer only needs parseable `.rs` files. Function names are globally +//! unique (`f_{file:04}_{k}`), so the bare-identifier calls the generator +//! emits resolve to unique symbols during edge resolution and materialize as +//! cross-file call edges in the index. Callee files are chosen with a +//! zipf-skewed popularity distribution ([`FixtureSpec::fan_in_skew`]), giving +//! a few very-high-fan-in files and a long tail. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +use crate::error::{CtxError, Result}; +use crate::testutil::GitRepo; + +/// Version of the generated byte format. Bump on ANY change to the bytes +/// produced by [`generate`] or [`apply_change_set`] for a given spec. +pub const FIXTURE_FORMAT_VERSION: u32 = 1; + +/// 2024-01-01T00:00:00Z; the initial commit date. Commit `i` (0 = initial) +/// is dated `BASE_UNIX_TIME + i` days. +const BASE_UNIX_TIME: i64 = 1_704_067_200; + +// Independent PRNG stream tags, so draws for one purpose never perturb +// another (e.g. rewriting a body must not change call targets). +const STREAM_POPULARITY: u64 = 0x504f_5055_4c41_5249; +const STREAM_STRUCTURE: u64 = 0x5354_5255_4354_5552; +const STREAM_BODY: u64 = 0x424f_4459_424f_4459; +const STREAM_FN_COUNT: u64 = 0x464e_434f_554e_5400; +const STREAM_HISTORY: u64 = 0x4849_5354_4f52_5900; +const STREAM_CHANGESET: u64 = 0x4348_414e_4745_5345; + +// Content-salt namespaces: initial tree, history commit `c`, changeset +// round `r`. They must never collide so every rewrite changes bytes. +const SALT_INITIAL: u64 = 0; +const SALT_HISTORY_BASE: u64 = 1; +const SALT_CHANGESET_BASE: u64 = 1 << 32; + +/// Approximate lines a file spends outside function bodies (header, consts, +/// struct + impl). Used to derive the per-file function count from `avg_loc`. +const FILE_OVERHEAD_LOC: usize = 16; +/// Approximate lines per generated function (signature to closing brace). +const FN_BODY_LOC: usize = 12; + +/// Parameters for a synthetic repository. +#[derive(Debug, Clone)] +pub struct FixtureSpec { + /// Master seed; every derived PRNG stream mixes this in. + pub seed: u64, + /// Total number of generated `.rs` files. + pub files: usize, + /// Approximate lines per file (drives the per-file function count). + pub avg_loc: usize, + /// Number of top-level directories `src/m00..m{modules-1}`. + pub modules: usize, + /// Zipf-ish exponent for callee-file popularity (e.g. `1.1`). Higher + /// values concentrate fan-in on fewer files. + pub fan_in_skew: f64, + /// Synthesized mutation commits after the initial one. + pub history_commits: usize, +} + +impl FixtureSpec { + /// 2,000 files, ~50 lines each, 20 modules, 3 history commits. + pub fn repo_2k() -> Self { + FixtureSpec { + seed: 0xC7C5_2000, + files: 2000, + avg_loc: 50, + modules: 20, + fan_in_skew: 1.1, + history_commits: 3, + } + } + + /// ~1,500 files x ~100 LOC, roughly 150k lines total. + pub fn repo_150k_loc() -> Self { + FixtureSpec { + seed: 0xC7C5_150C, + files: 1500, + avg_loc: 100, + modules: 15, + fan_in_skew: 1.1, + history_commits: 3, + } + } + + /// ~20 files; small enough for smoke tests that index the result. + pub fn tiny() -> Self { + FixtureSpec { + seed: 0xC7C5_0011, + files: 20, + avg_loc: 40, + modules: 3, + fan_in_skew: 1.1, + history_commits: 2, + } + } +} + +/// Generate the repository (git init + initial commit + history commits) at +/// `root`, which must be empty or nonexistent. +/// +/// Deterministic: the same spec produces a byte-identical tree at every +/// commit and identical commit SHAs (fixed identity and dates). +pub fn generate(spec: &FixtureSpec, root: &Path) -> Result<()> { + validate(spec)?; + if root.exists() && std::fs::read_dir(root)?.next().is_some() { + return Err(CtxError::Other(format!( + "fixture root '{}' is not empty", + root.display() + ))); + } + + let repo = GitRepo::init(root); + // Line endings must reach the object store verbatim, or blob (and thus + // commit) hashes would vary with the host's autocrlf configuration. + run_git(root, &["config", "core.autocrlf", "false"])?; + + std::fs::write(root.join("README.md"), readme(spec))?; + let pop = Popularity::new(spec); + for file_idx in 0..spec.files { + write_source_file(spec, &pop, root, file_idx, SALT_INITIAL)?; + } + repo.commit_all_with_date("fixture: initial tree", &commit_date(0)); + + let churn = (spec.files / 64).max(1); + for c in 0..spec.history_commits { + let selected = select_files(spec, STREAM_HISTORY, c as u64, churn); + for &file_idx in &selected { + write_source_file(spec, &pop, root, file_idx, SALT_HISTORY_BASE + c as u64)?; + } + repo.commit_all_with_date( + &format!("fixture: churn commit {}", c + 1), + &commit_date(c as i64 + 1), + ); + } + Ok(()) +} + +/// Deterministically rewrite `n_files` existing files, leaving the working +/// tree dirty (no commit, no staging). Selection and content depend on +/// `(spec.seed, round)`, so each perf round performs identical fresh work. +/// +/// Returns the rewritten paths (rooted at `root`), sorted by file index. +pub fn apply_change_set( + spec: &FixtureSpec, + root: &Path, + n_files: usize, + round: u32, +) -> Result> { + validate(spec)?; + if n_files > spec.files { + return Err(CtxError::Other(format!( + "cannot rewrite {} files: spec has only {}", + n_files, spec.files + ))); + } + + let pop = Popularity::new(spec); + let selected = select_files(spec, STREAM_CHANGESET, round as u64, n_files); + let mut paths = Vec::with_capacity(selected.len()); + for &file_idx in &selected { + write_source_file( + spec, + &pop, + root, + file_idx, + SALT_CHANGESET_BASE + round as u64, + )?; + paths.push(root.join(rel_path(spec, file_idx))); + } + Ok(paths) +} + +// ============================================================================ +// PRNG (SplitMix64) and deterministic math +// ============================================================================ + +/// Inline SplitMix64. Small, seedable, and identical on every platform. +struct Rng(u64); + +impl Rng { + fn new(seed: u64) -> Rng { + Rng(seed) + } + + fn next_u64(&mut self) -> u64 { + self.0 = self.0.wrapping_add(0x9E37_79B9_7F4A_7C15); + let mut z = self.0; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) + } + + /// Uniform in `0..n` (`n > 0`). Modulo bias is irrelevant here. + fn next_range(&mut self, n: u64) -> u64 { + self.next_u64() % n + } + + /// Uniform in `[0, 1)` with 53 bits of precision. + fn next_f64(&mut self) -> f64 { + (self.next_u64() >> 11) as f64 * (1.0 / (1u64 << 53) as f64) + } +} + +/// Combine two seed values into an independent stream seed. +fn mix(a: u64, b: u64) -> u64 { + Rng::new(a ^ b.rotate_left(32)).next_u64() +} + +fn mix3(a: u64, b: u64, c: u64) -> u64 { + mix(mix(a, b), c) +} + +/// `x^p` for `x > 0`, `p >= 0`, bit-identical on every platform: built only +/// from IEEE 754 multiplication and `sqrt`, both correctly rounded by spec +/// (`f64::powf` is libm-dependent and must not be used on generation paths). +/// The fractional exponent uses 24 binary digits, far more precision than a +/// popularity weight needs. +fn det_pow(x: f64, p: f64) -> f64 { + let int_part = p as u64; + let mut result = 1.0; + let mut base = x; + let mut n = int_part; + while n > 0 { + if n & 1 == 1 { + result *= base; + } + base *= base; + n >>= 1; + } + let mut frac = p - int_part as f64; + let mut root = x; + for _ in 0..24 { + root = root.sqrt(); + frac *= 2.0; + if frac >= 1.0 { + result *= root; + frac -= 1.0; + } + } + result +} + +// ============================================================================ +// Popularity (zipf-skewed callee-file selection) +// ============================================================================ + +/// Precomputed zipf-ish popularity over file indices: rank `r` has weight +/// `(r + 1)^-fan_in_skew`, and ranks are mapped to file indices through a +/// seeded permutation so hot files are scattered across modules. +struct Popularity { + rank_to_file: Vec, + cdf: Vec, +} + +impl Popularity { + fn new(spec: &FixtureSpec) -> Popularity { + let mut rng = Rng::new(mix(spec.seed, STREAM_POPULARITY)); + let rank_to_file = permutation(spec.files, &mut rng); + let mut cdf = Vec::with_capacity(spec.files); + let mut total = 0.0; + for rank in 0..spec.files { + total += 1.0 / det_pow((rank + 1) as f64, spec.fan_in_skew); + cdf.push(total); + } + Popularity { rank_to_file, cdf } + } + + /// Draw a file index with zipf-skewed probability. + fn sample(&self, rng: &mut Rng) -> usize { + let total = *self.cdf.last().expect("cdf is non-empty"); + let u = rng.next_f64() * total; + let rank = self + .cdf + .partition_point(|&c| c <= u) + .min(self.cdf.len() - 1); + self.rank_to_file[rank] + } +} + +/// Fisher-Yates permutation of `0..n` driven by `rng`. +fn permutation(n: usize, rng: &mut Rng) -> Vec { + let mut v: Vec = (0..n).collect(); + for i in (1..n).rev() { + let j = rng.next_range(i as u64 + 1) as usize; + v.swap(i, j); + } + v +} + +/// The first `n` entries of a `(seed, stream, round)`-seeded permutation of +/// all file indices, sorted ascending for deterministic write order. +fn select_files(spec: &FixtureSpec, stream: u64, round: u64, n: usize) -> Vec { + let mut rng = Rng::new(mix3(spec.seed, stream, round)); + let mut selected = permutation(spec.files, &mut rng); + selected.truncate(n); + selected.sort_unstable(); + selected +} + +// ============================================================================ +// Source generation +// ============================================================================ + +/// Module index for a file: contiguous blocks of files per module. +fn module_of(spec: &FixtureSpec, file_idx: usize) -> usize { + file_idx * spec.modules / spec.files +} + +/// Repo-relative path of a generated file. +fn rel_path(spec: &FixtureSpec, file_idx: usize) -> String { + format!("src/m{:02}/f{:04}.rs", module_of(spec, file_idx), file_idx) +} + +/// Number of functions in a file: a pure function of `(seed, file_idx)` so +/// callers can size call targets without generating the callee, and so +/// rewrites (salts) never change the symbol set or call graph. +fn fn_count(spec: &FixtureSpec, file_idx: usize) -> usize { + let mut rng = Rng::new(mix3(spec.seed, STREAM_FN_COUNT, file_idx as u64)); + let base = (spec.avg_loc.saturating_sub(FILE_OVERHEAD_LOC) / FN_BODY_LOC).max(2); + base + rng.next_range(2) as usize +} + +/// A small positive literal for generated arithmetic. +fn lit(rng: &mut Rng) -> i64 { + (rng.next_u64() & 0xFFFF) as i64 + 1 +} + +/// Render one file. The structural stream (function count, branch shapes, +/// call targets) depends only on `(seed, file_idx)`; the body stream mixes in +/// `salt`, so rewrites change literals but never the call graph. The header +/// embeds `salt` so every rewrite is guaranteed to change bytes. +fn file_source(spec: &FixtureSpec, pop: &Popularity, file_idx: usize, salt: u64) -> String { + let module = module_of(spec, file_idx); + let n_fns = fn_count(spec, file_idx); + let mut structure = Rng::new(mix3(spec.seed, STREAM_STRUCTURE, file_idx as u64)); + let mut body = Rng::new(mix3(spec.seed, mix(STREAM_BODY, salt), file_idx as u64)); + + let mut out = String::with_capacity(spec.avg_loc * 48 + 256); + out.push_str(&format!( + "//! Fixture file {file_idx:04} in module m{module:02} \ + (format v{FIXTURE_FORMAT_VERSION}, salt {salt}).\n" + )); + out.push_str("//! Machine-generated by ctx::fixture; do not edit.\n\n"); + + let n_consts = 2 + structure.next_range(2) as usize; + for j in 0..n_consts { + out.push_str(&format!( + "pub const C_{file_idx:04}_{j}: i64 = {};\n", + lit(&mut body) + )); + } + out.push('\n'); + + out.push_str(&format!( + "pub struct S{file_idx:04} {{\n pub a: i64,\n pub b: i64,\n}}\n\n" + )); + out.push_str(&format!("impl S{file_idx:04} {{\n")); + out.push_str(&format!( + " pub fn m_{file_idx:04}_0(&self, x: i64) -> i64 {{\n" + )); + out.push_str(&format!( + " let t = self.a.wrapping_mul(x).wrapping_add({});\n", + lit(&mut body) + )); + out.push_str(" if t % 2 == 0 { t } else { t.wrapping_neg() }\n"); + out.push_str(" }\n}\n"); + + for k in 0..n_fns { + out.push('\n'); + push_function(spec, pop, &mut out, file_idx, k, &mut structure, &mut body); + } + out +} + +/// Render one `fn f_{file:04}_{k}` with varied branching and 1-4 cross-file +/// calls by bare identifier. +fn push_function( + spec: &FixtureSpec, + pop: &Popularity, + out: &mut String, + file_idx: usize, + k: usize, + structure: &mut Rng, + body: &mut Rng, +) { + out.push_str(&format!("pub fn f_{file_idx:04}_{k}(a: i64) -> i64 {{\n")); + out.push_str(&format!(" let mut acc = a ^ {};\n", lit(body))); + out.push_str(&format!(" let step = {};\n", lit(body))); + + // 0..=3 control-flow blocks so cyclomatic complexity varies per function. + let n_branches = structure.next_range(4); + for _ in 0..n_branches { + match structure.next_range(3) { + 0 => { + let d = 2 + structure.next_range(5); + out.push_str(&format!(" if acc % {d} == 0 {{\n")); + out.push_str(&format!(" acc = acc.wrapping_mul({});\n", lit(body))); + out.push_str(" } else {\n"); + out.push_str(&format!(" acc = acc.wrapping_sub({});\n", lit(body))); + out.push_str(" }\n"); + } + 1 => { + let n = 1 + structure.next_range(6); + out.push_str(&format!(" for i in 0..{n}i64 {{\n")); + out.push_str(&format!( + " acc = acc.wrapping_add(i * {});\n", + lit(body) + )); + out.push_str(" }\n"); + } + _ => { + out.push_str(" match acc % 4 {\n"); + out.push_str(&format!( + " 0 => acc = acc.wrapping_add({}),\n", + lit(body) + )); + out.push_str(&format!( + " 1 => acc = acc.wrapping_sub({}),\n", + lit(body) + )); + out.push_str(&format!(" 2 => acc ^= {},\n", lit(body))); + out.push_str(" _ => acc = acc.wrapping_mul(3),\n"); + out.push_str(" }\n"); + } + } + } + + // 1..=4 cross-file calls; callee files follow the zipf popularity. + let n_calls = 1 + structure.next_range(4); + for _ in 0..n_calls { + let mut callee = pop.sample(structure); + if callee == file_idx { + callee = (callee + 1) % spec.files; + } + let j = structure.next_range(fn_count(spec, callee) as u64); + out.push_str(&format!( + " acc = acc.wrapping_add(f_{callee:04}_{j}(acc));\n" + )); + } + out.push_str(" acc.wrapping_add(step)\n}\n"); +} + +/// Write one generated file (creating parent directories). +fn write_source_file( + spec: &FixtureSpec, + pop: &Popularity, + root: &Path, + file_idx: usize, + salt: u64, +) -> Result<()> { + let path = root.join(rel_path(spec, file_idx)); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write(&path, file_source(spec, pop, file_idx, salt))?; + Ok(()) +} + +fn readme(spec: &FixtureSpec) -> String { + format!( + "# ctx synthetic fixture\n\n\ + Machine-generated Rust tree for ctx benchmarks \ + (format v{FIXTURE_FORMAT_VERSION}). Files parse individually but the \ + tree is not a compilable cargo project.\n\n\ + - seed: {}\n\ + - files: {}\n\ + - avg_loc: {}\n\ + - modules: {}\n\ + - fan_in_skew: {}\n\ + - history_commits: {}\n", + spec.seed, spec.files, spec.avg_loc, spec.modules, spec.fan_in_skew, spec.history_commits, + ) +} + +// ============================================================================ +// Git plumbing +// ============================================================================ + +/// Commit date for commit `i` (0 = initial), in git's internal +/// ` ` format. +fn commit_date(i: i64) -> String { + format!("{} +0000", BASE_UNIX_TIME + i * 86_400) +} + +/// Run a git command in `root`, mapping failure to [`CtxError::Git`]. +fn run_git(root: &Path, args: &[&str]) -> Result<()> { + let output = Command::new("git").args(args).current_dir(root).output()?; + if !output.status.success() { + return Err(CtxError::git(format!( + "git {:?} failed: {}", + args, + String::from_utf8_lossy(&output.stderr) + ))); + } + Ok(()) +} + +fn validate(spec: &FixtureSpec) -> Result<()> { + if spec.files < 2 { + return Err(CtxError::Other( + "fixture spec needs at least 2 files for cross-file calls".to_string(), + )); + } + if spec.modules == 0 || spec.modules > spec.files { + return Err(CtxError::Other(format!( + "fixture spec needs 1..=files modules, got {}", + spec.modules + ))); + } + if !spec.fan_in_skew.is_finite() || spec.fan_in_skew < 0.0 { + return Err(CtxError::Other(format!( + "fan_in_skew must be finite and non-negative, got {}", + spec.fan_in_skew + ))); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn splitmix_stream_is_stable() { + // Pin the PRNG output so an accidental algorithm change is caught + // even before the (slower) tree-level determinism tests run. + let mut rng = Rng::new(42); + assert_eq!(rng.next_u64(), 13679457532755275413); + assert_eq!(rng.next_u64(), 2949826092126892291); + } + + #[test] + fn det_pow_matches_expected_values() { + assert_eq!(det_pow(2.0, 2.0), 4.0); + assert_eq!(det_pow(4.0, 0.5), 2.0); + // ~3^1.1 within the 24-bit fractional-exponent precision. + assert!((det_pow(3.0, 1.1) - 3.348_369_522_101_714).abs() < 1e-6); + } + + #[test] + fn file_source_is_salt_sensitive_but_graph_stable() { + let spec = FixtureSpec::tiny(); + let pop = Popularity::new(&spec); + let a = file_source(&spec, &pop, 3, 0); + let b = file_source(&spec, &pop, 3, 7); + assert_ne!(a, b, "different salt must change bytes"); + + // Same function set and same call targets regardless of salt. + let calls = |s: &str| -> Vec { + s.lines() + .filter(|l| l.contains("(acc));")) + .map(|l| l.trim().to_string()) + .collect() + }; + assert_eq!(calls(&a), calls(&b)); + } + + #[test] + fn selection_is_deterministic_per_round() { + let spec = FixtureSpec::tiny(); + assert_eq!( + select_files(&spec, STREAM_CHANGESET, 0, 5), + select_files(&spec, STREAM_CHANGESET, 0, 5) + ); + assert_ne!( + select_files(&spec, STREAM_CHANGESET, 0, 5), + select_files(&spec, STREAM_CHANGESET, 1, 5) + ); + } +} diff --git a/src/gatelog.rs b/src/gatelog.rs new file mode 100644 index 0000000..020819d --- /dev/null +++ b/src/gatelog.rs @@ -0,0 +1,198 @@ +//! Gate-evaluation logging. +//! +//! When the `CTX_GATE_LOG` environment variable is set, `ctx score` appends +//! one JSON line per gate evaluation to a local log file (default +//! `.ctx/gate-log.jsonl`). Opt-in, local-only; ctx ships no telemetry. +//! +//! `CTX_GATE_LOG` values: +//! +//! - unset, empty, or `0` -- logging disabled +//! - `1` or `true` -- log to [`DEFAULT_GATE_LOG_PATH`] under the repo root +//! - anything else -- treated as the log path (joined to the repo root when +//! relative) + +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use serde::Serialize; +use time::format_description::well_known::Rfc3339; +use time::OffsetDateTime; + +use crate::error::Result; + +/// Version of the [`GateRecord`] line format. +pub const GATE_LOG_SCHEMA_VERSION: u32 = 1; + +/// Default log location (relative to the repo root) for `CTX_GATE_LOG=1`. +pub const DEFAULT_GATE_LOG_PATH: &str = ".ctx/gate-log.jsonl"; + +/// One gate evaluation, serialized as a single JSONL line. +#[derive(Debug, Serialize)] +pub struct GateRecord { + /// Line format version ([`GATE_LOG_SCHEMA_VERSION`]). + pub schema_version: u32, + /// Evaluation time, RFC3339 UTC (same shape as the JSON envelope's + /// `generated_at`; see [`crate::json`]). + pub ts: String, + /// The ctx version that evaluated the gate. + pub ctx_version: String, + /// The command that evaluated the gate (`"score"`). + pub source: String, + /// The git reference the score was computed against. + pub against: String, + /// The raw `--fail-on` expression, if any. + pub fail_on: Option, + /// The scorecard metrics: the same seven-key object the `--json` + /// payload emits under `metrics`. + pub metrics: serde_json::Value, + /// Rendered `--fail-on` conditions that fired (empty on pass). + pub failed_conditions: Vec, + /// `"pass"` or `"fail"`. + pub outcome: String, + /// Whether blocking mode was requested (`CTX_GATE_BLOCKING=1`). + pub blocking: bool, + /// Claude Code session id (`CLAUDE_SESSION_ID`), if present. + pub session_id: Option, +} + +/// The gate log path selected by `CTX_GATE_LOG`, or `None` when logging is +/// disabled. +pub fn gate_log_target(root: &Path) -> Option { + resolve(std::env::var("CTX_GATE_LOG").ok().as_deref(), root) +} + +/// Core `CTX_GATE_LOG` value resolution (env-free, so tests can drive it +/// without mutating process env). +fn resolve(value: Option<&str>, root: &Path) -> Option { + match value? { + "" | "0" => None, + "1" | "true" => Some(root.join(DEFAULT_GATE_LOG_PATH)), + path => { + let path = Path::new(path); + if path.is_absolute() { + Some(path.to_path_buf()) + } else { + Some(root.join(path)) + } + } + } +} + +/// Whether blocking mode is requested (`CTX_GATE_BLOCKING=1`, exactly). +pub fn blocking_enabled() -> bool { + std::env::var("CTX_GATE_BLOCKING").as_deref() == Ok("1") +} + +/// The Claude Code session id (`CLAUDE_SESSION_ID`), if present. +pub fn session_id() -> Option { + std::env::var("CLAUDE_SESSION_ID").ok() +} + +/// Current UTC time as an RFC3339 string. +pub fn now_rfc3339() -> String { + OffsetDateTime::now_utc() + .format(&Rfc3339) + .unwrap_or_default() +} + +/// Append one record to the log at `path` as a single JSONL line. +/// +/// Creates parent directories as needed. The record plus trailing newline +/// go out in one write call, so concurrent appenders on the same local log +/// do not interleave within a line. +pub fn append(path: &Path, record: &GateRecord) -> Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let mut line = serde_json::to_string(record)?; + line.push('\n'); + let mut file = fs::OpenOptions::new() + .create(true) + .append(true) + .open(path)?; + file.write_all(line.as_bytes())?; + Ok(()) +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn sample_record(outcome: &str) -> GateRecord { + GateRecord { + schema_version: GATE_LOG_SCHEMA_VERSION, + ts: now_rfc3339(), + ctx_version: env!("CARGO_PKG_VERSION").to_string(), + source: "score".to_string(), + against: "main".to_string(), + fail_on: Some("new_duplication>0".to_string()), + metrics: serde_json::json!({"new_duplication": 1}), + failed_conditions: vec!["new_duplication > 0".to_string()], + outcome: outcome.to_string(), + blocking: false, + session_id: None, + } + } + + #[test] + fn test_resolve_disabled_values() { + let root = Path::new("/repo"); + assert_eq!(resolve(None, root), None); + assert_eq!(resolve(Some(""), root), None); + assert_eq!(resolve(Some("0"), root), None); + } + + #[test] + fn test_resolve_default_path_values() { + let root = Path::new("/repo"); + let default = root.join(DEFAULT_GATE_LOG_PATH); + assert_eq!(resolve(Some("1"), root), Some(default.clone())); + assert_eq!(resolve(Some("true"), root), Some(default)); + } + + #[test] + fn test_resolve_custom_paths() { + let root = Path::new("/repo"); + // Relative values are joined to the root. + assert_eq!( + resolve(Some("logs/gates.jsonl"), root), + Some(root.join("logs/gates.jsonl")) + ); + // Absolute values are used as-is. + assert_eq!( + resolve(Some("/var/log/gates.jsonl"), root), + Some(PathBuf::from("/var/log/gates.jsonl")) + ); + } + + #[test] + fn test_append_creates_dirs_and_accumulates_lines() { + let temp = TempDir::new().unwrap(); + let path = temp.path().join("nested/dir/gate-log.jsonl"); + + append(&path, &sample_record("fail")).unwrap(); + append(&path, &sample_record("pass")).unwrap(); + + let content = fs::read_to_string(&path).unwrap(); + assert!(content.ends_with('\n'), "content: {content:?}"); + let lines: Vec<&str> = content.lines().collect(); + assert_eq!(lines.len(), 2, "content: {content:?}"); + + // Every line is one valid JSON object with the expected shape. + for (line, outcome) in lines.iter().zip(["fail", "pass"]) { + let doc: serde_json::Value = serde_json::from_str(line).unwrap(); + assert_eq!(doc["schema_version"], GATE_LOG_SCHEMA_VERSION); + assert_eq!(doc["source"], "score"); + assert_eq!(doc["outcome"], outcome); + // ts round-trips as RFC3339. + let ts = doc["ts"].as_str().unwrap(); + assert!(OffsetDateTime::parse(ts, &Rfc3339).is_ok(), "ts: {ts}"); + } + } +} diff --git a/src/gitutil.rs b/src/gitutil.rs index b10b7c8..22c5137 100644 --- a/src/gitutil.rs +++ b/src/gitutil.rs @@ -135,6 +135,86 @@ fn churn_since_in(dir: &Path, since: &str) -> Result> { Ok(churn) } +/// Count how many commits touched each file since `since`, optionally +/// anchored with `--until=` (any `git log` date spec). +/// +/// Like [`churn_since`] but dir-explicit and with an optional upper bound, +/// so historical snapshots can measure churn as of a commit's date instead +/// of wall-clock now. +pub fn churn_between_in( + dir: &Path, + since: &str, + until: Option<&str>, +) -> Result> { + if !is_git_repo_in(dir) { + return Err(CtxError::NotGitRepo); + } + + let prefix = repo_prefix_in(dir)?; + let since_arg = format!("--since={}", since); + let until_arg = until.map(|u| format!("--until={}", u)); + let mut args = vec!["log", &since_arg]; + if let Some(ref until_arg) = until_arg { + args.push(until_arg); + } + args.extend(["--format=", "--name-only", "--no-renames", "--", "."]); + let output = run_git(dir, &args)?; + let log = stdout_or_err(output, None)?; + + let mut churn = HashMap::new(); + for (path, count) in parse_name_only_log(&log) { + if let Some(local) = strip_repo_prefix(&path, &prefix) { + *churn.entry(local).or_insert(0) += count; + } + } + Ok(churn) +} + +/// The current HEAD commit as `(full sha, committer date)`. +/// +/// The committer date is strict ISO 8601 (`git log --format=%cI`, e.g. +/// `2026-07-09T12:00:00+02:00`). +pub fn head_commit_in(dir: &Path) -> Result<(String, String)> { + if !is_git_repo_in(dir) { + return Err(CtxError::NotGitRepo); + } + + let output = run_git(dir, &["log", "-1", "--format=%H%x00%cI"])?; + let stdout = stdout_or_err(output, Some("HEAD"))?; + let line = stdout.trim(); + let (sha, date) = line + .split_once('\0') + .ok_or_else(|| CtxError::git(format!("unexpected `git log -1` output: {:?}", line)))?; + Ok((sha.to_string(), date.to_string())) +} + +/// First-parent commit shas in `range` (e.g. `abc123..HEAD`), oldest first. +pub fn rev_list_first_parent_in(dir: &Path, range: &str) -> Result> { + if !is_git_repo_in(dir) { + return Err(CtxError::NotGitRepo); + } + + let output = run_git(dir, &["rev-list", "--first-parent", "--reverse", range])?; + let stdout = stdout_or_err(output, Some(range))?; + Ok(stdout + .lines() + .map(|l| l.trim().to_string()) + .filter(|l| !l.is_empty()) + .collect()) +} + +/// Whether the working tree has uncommitted changes (staged, unstaged, or +/// untracked), per `git status --porcelain`. +pub fn is_dirty_in(dir: &Path) -> Result { + if !is_git_repo_in(dir) { + return Err(CtxError::NotGitRepo); + } + + let output = run_git(dir, &["status", "--porcelain"])?; + let stdout = stdout_or_err(output, None)?; + Ok(stdout.lines().any(|l| !l.trim().is_empty())) +} + /// Dir-explicit variant of [`show_file`], for commands and tests that /// operate on an explicit project root instead of the process cwd. pub fn show_file_in(dir: &Path, reference: &str, path: &str) -> Result> { @@ -332,10 +412,12 @@ mod tests { } #[test] - fn test_changed_files_not_a_repo() { + fn test_not_a_repo_errors() { let dir = tempfile::tempdir().unwrap(); let err = changed_files_against_in(dir.path(), "main").unwrap_err(); assert!(matches!(err, CtxError::NotGitRepo)); + let err = churn_since_in(dir.path(), "1 week ago").unwrap_err(); + assert!(matches!(err, CtxError::NotGitRepo)); } #[test] @@ -352,12 +434,91 @@ mod tests { } #[test] - fn test_churn_since_not_a_repo() { + fn test_churn_between() { let dir = tempfile::tempdir().unwrap(); - let err = churn_since_in(dir.path(), "1 week ago").unwrap_err(); + let repo = GitRepo::init(dir.path()); + repo.write("src/a.rs", "v1"); + repo.commit_all_with_date("one", "2020-01-01T12:00:00 +0000"); + repo.write("src/a.rs", "v2"); + repo.commit_all_with_date("two", "2021-01-01T12:00:00 +0000"); + repo.write("src/b.rs", "v1"); + repo.commit_all_with_date("three", "2022-01-01T12:00:00 +0000"); + + // Unbounded: all three commits count. + let churn = churn_between_in(&repo.root, "2000-01-01", None).unwrap(); + assert_eq!(churn.get("src/a.rs"), Some(&2)); + assert_eq!(churn.get("src/b.rs"), Some(&1)); + + // Anchored at mid-2021: the 2022 commit is excluded. + let churn = churn_between_in(&repo.root, "2000-01-01", Some("2021-06-01")).unwrap(); + assert_eq!(churn.get("src/a.rs"), Some(&2)); + assert_eq!(churn.get("src/b.rs"), None); + } + + #[test] + fn test_churn_between_not_a_repo() { + let dir = tempfile::tempdir().unwrap(); + let err = churn_between_in(dir.path(), "1 week ago", None).unwrap_err(); + assert!(matches!(err, CtxError::NotGitRepo)); + } + + #[test] + fn test_head_commit() { + let dir = tempfile::tempdir().unwrap(); + let repo = GitRepo::init(dir.path()); + repo.write("a.rs", "fn a() {}"); + repo.commit_all_with_date("initial", "2020-01-02T03:04:05 +0000"); + + let (sha, date) = head_commit_in(&repo.root).unwrap(); + assert_eq!(sha.len(), 40, "expected a full sha: {}", sha); + assert!(sha.chars().all(|c| c.is_ascii_hexdigit())); + assert!(date.starts_with("2020-01-02T03:04:05"), "date: {}", date); + + // Not a repo -> error. + let empty = tempfile::tempdir().unwrap(); + let err = head_commit_in(empty.path()).unwrap_err(); assert!(matches!(err, CtxError::NotGitRepo)); } + #[test] + fn test_rev_list_first_parent() { + let dir = tempfile::tempdir().unwrap(); + let repo = GitRepo::init(dir.path()); + repo.commit_file("a.rs", "v1", "one"); + let (first, _) = head_commit_in(&repo.root).unwrap(); + repo.commit_file("a.rs", "v2", "two"); + repo.commit_file("a.rs", "v3", "three"); + let (head, _) = head_commit_in(&repo.root).unwrap(); + + let shas = rev_list_first_parent_in(&repo.root, &format!("{}..HEAD", first)).unwrap(); + assert_eq!(shas.len(), 2, "expected the two commits after the first"); + assert_eq!(shas.last(), Some(&head), "oldest-first order"); + + let err = rev_list_first_parent_in(&repo.root, "no-such..HEAD").unwrap_err(); + assert!(matches!( + err, + CtxError::InvalidRevision(_) | CtxError::Git(_) + )); + } + + #[test] + fn test_is_dirty() { + let dir = tempfile::tempdir().unwrap(); + let repo = GitRepo::init(dir.path()); + repo.commit_file("a.rs", "fn a() {}", "initial"); + assert!(!is_dirty_in(&repo.root).unwrap()); + + // Untracked file counts as dirty. + repo.write("b.rs", "fn b() {}"); + assert!(is_dirty_in(&repo.root).unwrap()); + + // Modified tracked file counts as dirty. + std::fs::remove_file(repo.root.join("b.rs")).unwrap(); + assert!(!is_dirty_in(&repo.root).unwrap()); + repo.write("a.rs", "fn a() { /* changed */ }"); + assert!(is_dirty_in(&repo.root).unwrap()); + } + #[test] fn test_show_file() { let dir = tempfile::tempdir().unwrap(); diff --git a/src/harness/templates/stop.sh b/src/harness/templates/stop.sh index 1ac035d..c6a1f7d 100644 --- a/src/harness/templates/stop.sh +++ b/src/harness/templates/stop.sh @@ -1,7 +1,8 @@ #!/bin/sh # Stop hook: print a quality scorecard for the session's changes. -# Non-blocking in v1: the scorecard goes to stdout, findings go to stderr, -# and the hook always exits 0. +# Non-blocking by default: the scorecard goes to stdout, findings go to +# stderr, and the hook exits 0. Set CTX_GATE_BLOCKING=1 to turn gate +# failures into a blocking stop (exit 2). set -u # Compat guard: if this binary is older than the templates that generated @@ -20,8 +21,23 @@ fi cat > /dev/null 2>&1 || true # Quality scorecard vs the default branch (stdout). Exit 1 means a gate -# condition fired; report it (stderr) but never block the session. -ctx score --against {{DEFAULT_BRANCH}} --fail-on "check_violations>0,new_duplication>0" || { +# condition fired. +# +# Environment knobs: +# CTX_GATE_BLOCKING=1 -- turn gate failures into exit 2, Claude Code's +# blocking-stop mechanism (the session keeps going +# until the findings are addressed). +# CTX_GATE_LOG -- consumed by `ctx score` itself (not this script): +# when set, each gate evaluation is appended to a +# local JSONL log (default .ctx/gate-log.jsonl). +ctx score --against {{DEFAULT_BRANCH}} --fail-on "check_violations>0,new_duplication>0" +score_status=$? +if [ "$score_status" -eq 1 ]; then + if [ "${CTX_GATE_BLOCKING:-0}" = "1" ]; then + echo "ctx hooks: quality gates failed (blocking mode); fix the failed conditions in the scorecard above before stopping." >&2 + exit 2 + fi echo "ctx hooks: quality gates reported findings (non-blocking); see the scorecard above." >&2 -} +fi +# Any other non-zero status is an operational error: fail open. exit 0 diff --git a/src/lib.rs b/src/lib.rs index 94ce97f..8d6fa28 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -158,6 +158,12 @@ pub mod rules; // Quality scorecard (ctx score) pub mod score; +// Gate-evaluation logging (CTX_GATE_LOG JSONL records from ctx score) +pub mod gatelog; + +// Per-commit Parquet metric snapshots (ctx snapshot) +pub mod snapshot; + // Harness packaging (ctx harness): Claude Code hooks, plugin scaffolding, // version compatibility guard, and integration diagnostics pub mod harness; @@ -175,6 +181,11 @@ pub mod utils; #[doc(hidden)] pub mod testutil; +// Deterministic synthetic-repo generator for performance fixtures and the +// external ctx-bench suite (ships in the crate; not part of the supported API) +#[doc(hidden)] +pub mod fixture; + // Internal modules (not part of public API) pub(crate) mod default_ignores; diff --git a/src/main.rs b/src/main.rs index a6ac30e..3b83726 100644 --- a/src/main.rs +++ b/src/main.rs @@ -105,6 +105,7 @@ fn run(args: Args) -> Result { timeout, fail_on_rows, schema, + snapshots, }) => { // `ctx sql` owns its exit code (0 clean / 1 with --fail-on-rows / 2 error), // so it returns an Outcome directly like the other quality commands. @@ -117,6 +118,7 @@ fn run(args: Args) -> Result { timeout, fail_on_rows, schema, + snapshots, }); } Some(Command::Search { @@ -296,6 +298,15 @@ fn run(args: Args) -> Result { categories, incremental, }) => commands::run_audit(&output_format, min_score, categories, incremental), + Some(Command::Snapshot { + cmd, + force, + churn_window, + }) => { + // Snapshot command: returns its own Outcome (always Clean on + // success; stub builds and git/IO failures map to exit 2). + return commands::run_snapshot(cmd, force, &churn_window, json); + } Some(Command::Harness { cmd }) => { // Harness command: returns its own Outcome (doctor exits 1 on // problems; compat exits 3 on version mismatch). diff --git a/src/mcp/server.rs b/src/mcp/server.rs index 51c8926..8b6d9c2 100644 --- a/src/mcp/server.rs +++ b/src/mcp/server.rs @@ -242,10 +242,4 @@ pub fn greet() -> String { // Version is populated from CARGO_PKG_VERSION assert!(!info.server_info.version.is_empty()); } - - #[test] - fn test_server_compiles() { - // Basic sanity check that the module compiles correctly - assert!(true); - } } diff --git a/src/snapshot.rs b/src/snapshot.rs new file mode 100644 index 0000000..8a31376 --- /dev/null +++ b/src/snapshot.rs @@ -0,0 +1,783 @@ +//! Per-commit metric snapshots (`ctx snapshot`). +//! +//! Exports one Parquet partition per commit (`.ctx/snapshots/sha=/`) +//! with per-file and per-symbol metrics, near-duplicate pairs, and metadata, +//! for longitudinal quality analysis via `ctx sql --snapshots`. +//! +//! Each partition contains four files — `symbols.parquet`, `files.parquet`, +//! `dup_pairs.parquet`, and `meta.parquet` — every row denormalized with +//! `commit_sha` and `committed_at` so partitions can be unioned with a single +//! `read_parquet('.ctx/snapshots/*/*.parquet')` glob per table. +//! +//! Partitions are written to a `sha=.tmp` staging directory and moved +//! into place with an atomic rename, so readers never observe a half-written +//! snapshot. The capture core requires the `duckdb` feature; option and +//! report types are available in every build so the CLI surface stays stable. + +use std::path::PathBuf; + +use serde::Serialize; + +/// Version of the snapshot Parquet schema, recorded in `meta.parquet`. +pub const SNAPSHOT_SCHEMA_VERSION: u32 = 1; + +/// Default output directory for snapshot partitions, relative to the +/// project root. +pub const SNAPSHOTS_DIR: &str = ".ctx/snapshots"; + +/// How a snapshot was captured, recorded as `capture_mode` in `meta.parquet`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CaptureMode { + /// Captured from the working tree at HEAD (`ctx snapshot`). Churn is + /// measured up to now. + Live, + /// Captured from a historical commit checked out in a temporary worktree + /// (`ctx snapshot backfill`). Churn is anchored with + /// `--until=` so it reflects that commit's point in time. + Backfill, +} + +impl CaptureMode { + /// The string recorded in `meta.parquet` (`"live"` / `"backfill"`). + pub fn as_str(self) -> &'static str { + match self { + CaptureMode::Live => "live", + CaptureMode::Backfill => "backfill", + } + } +} + +/// Options for a single snapshot capture. +#[derive(Debug, Clone)] +pub struct CaptureOptions { + /// Directory that holds the `sha=/` partitions. + pub out_dir: PathBuf, + /// Churn lower bound (a `git log --since` date spec, e.g. `"90 days + /// ago"`). Relative specs are resolved against wall-clock now by git, + /// even in backfill mode — only the *upper* bound is anchored to the + /// commit date there. + pub churn_window: String, + /// Live (HEAD + working tree) or backfill (historical worktree). + pub capture_mode: CaptureMode, + /// Overwrite an existing partition for the same commit. + pub force: bool, +} + +/// The result of one snapshot capture. +/// +/// When `skipped_existing` is true the partition was left untouched and the +/// row counts (`files`, `symbols`, `dup_pairs`, `violations`) are reported +/// as zero — they are not re-read from the existing Parquet files. +#[derive(Debug, Clone, Serialize)] +pub struct SnapshotReport { + /// Full sha of the snapshotted commit. + pub commit_sha: String, + /// Committer date of the commit (strict ISO 8601, as `git log --format=%cI`). + pub committed_at: String, + /// The partition directory the snapshot lives in. + pub partition_dir: String, + /// Rows written to `files.parquet`. + pub files: usize, + /// Rows written to `symbols.parquet`. + pub symbols: usize, + /// Rows written to `dup_pairs.parquet`. + pub dup_pairs: usize, + /// Total architecture-rule violations (0 when `.ctx/rules.toml` is absent). + pub violations: i64, + /// True when the partition already existed and was not rewritten. + pub skipped_existing: bool, +} + +/// Options for `ctx snapshot backfill`. +#[derive(Debug, Clone)] +pub struct BackfillOptions { + /// Starting commit/ref. The walk covers the first-parent range + /// `since..HEAD` **plus `since` itself** when it resolves to a commit, + /// so `--since ` snapshots that commit too. + pub since: String, + /// Sample every Nth commit (1 = every commit). Sampling counts backwards + /// from HEAD so the newest commit is always included. + pub every: usize, + /// Churn lower bound, passed through to [`CaptureOptions::churn_window`]. + pub churn_window: String, + /// Snapshot directory of the *original* repository (partitions for + /// historical commits land there, not inside the temporary worktrees). + pub out_dir: PathBuf, +} + +/// The partition directory name for a commit (`sha=`). +pub fn partition_dir_name(sha: &str) -> String { + format!("sha={}", sha) +} + +#[cfg(feature = "duckdb")] +pub use engine::{backfill, capture}; + +#[cfg(feature = "duckdb")] +mod engine { + use std::collections::HashMap; + use std::fs; + use std::path::{Path, PathBuf}; + use std::process::Command; + + use time::format_description::well_known::Rfc3339; + use time::OffsetDateTime; + + use super::*; + use crate::analytics::Analytics; + use crate::check; + use crate::error::{CtxError, Result}; + use crate::fingerprint::{self, DuplicatePair}; + use crate::gitutil; + use crate::index::Indexer; + use crate::rules; + use crate::score::{DUP_MIN_TOKENS, DUP_THRESHOLD}; + use crate::walker::WalkerConfig; + + /// Capture a snapshot of the repository at `root` (labeled with HEAD's + /// sha) into `opts.out_dir/sha=/`. + /// + /// The index is refreshed incrementally first (same as `ctx score`), so + /// the snapshot reflects the working tree — for a live capture of a + /// dirty tree the caller should warn that the label and the content can + /// disagree. + pub fn capture(root: &Path, opts: &CaptureOptions) -> Result { + if !gitutil::is_git_repo_in(root) { + return Err(CtxError::NotGitRepo); + } + let (sha, committed_at) = gitutil::head_commit_in(root)?; + + let partition = opts.out_dir.join(partition_dir_name(&sha)); + if partition.exists() && !opts.force { + return Ok(skipped_report(&sha, &committed_at, &partition)); + } + + // Incremental index refresh: only changed files are re-parsed; the + // existing database is never cleared (mirrors score::compute_score). + let mut indexer = Indexer::with_config(root, false, WalkerConfig::default())?; + indexer.index()?; + let db = indexer.db; + + // Rust-side metrics: near-duplicates, rule violations, git churn. + let pairs = fingerprint::find_near_duplicates(&db, DUP_THRESHOLD, DUP_MIN_TOKENS, None)?; + let (violations_total, violations_by_file) = violation_counts(root)?; + let until = match opts.capture_mode { + CaptureMode::Backfill => Some(committed_at.as_str()), + CaptureMode::Live => None, + }; + let churn = gitutil::churn_between_in(root, &opts.churn_window, until)?; + + // Trusted, unhardened DuckDB connection: attaches the index and the + // v1 views, then COPYs to Parquet. Never fed user SQL. + let analytics = Analytics::open_export(root)?; + let conn = analytics.connection(); + load_side_tables(conn, &churn, &violations_by_file, &pairs)?; + + // Stage into `sha=.tmp`, then atomically rename into place. + let staging = opts + .out_dir + .join(format!("{}.tmp", partition_dir_name(&sha))); + let guard = StagingGuard::new(&staging)?; + write_parquet_files(conn, &staging, &sha, &committed_at, opts.capture_mode)?; + + if partition.exists() { + // Only reachable with --force; replace the old partition. + fs::remove_dir_all(&partition)?; + } + fs::rename(&staging, &partition)?; + guard.defuse(); + + let count = |sql: &str| -> Result { + let n: i64 = conn.query_row(sql, [], |row| row.get(0))?; + Ok(n.max(0) as usize) + }; + Ok(SnapshotReport { + commit_sha: sha, + committed_at, + partition_dir: partition.display().to_string(), + files: count("SELECT COUNT(*) FROM v1.files")?, + symbols: count("SELECT COUNT(*) FROM v1.symbols")?, + dup_pairs: pairs.len(), + violations: violations_total, + skipped_existing: false, + }) + } + + /// Backfill snapshots for historical commits. + /// + /// Walks the first-parent range `since..HEAD` oldest-first, **including + /// `since` itself** when it resolves to a commit (so `--since ` + /// covers that commit). After `--every N` sampling (newest always kept), + /// each missing commit is checked out into a temporary `git worktree`, + /// captured with [`CaptureMode::Backfill`] into the original repo's + /// snapshot directory, and the worktree is removed again (an RAII guard + /// cleans up even on panics or per-commit errors). + /// + /// Per-commit failures are logged to stderr and skipped; the returned + /// reports cover the captured and already-existing partitions only. + pub fn backfill(root: &Path, opts: &BackfillOptions) -> Result> { + if !gitutil::is_git_repo_in(root) { + return Err(CtxError::NotGitRepo); + } + + let mut shas = gitutil::rev_list_first_parent_in(root, &format!("{}..HEAD", opts.since))?; + if let Some(since_sha) = resolve_commit(root, &opts.since) { + if !shas.contains(&since_sha) { + shas.insert(0, since_sha); + } + } + let sampled = sample_every(shas, opts.every.max(1)); + + // Parent directory for the temporary worktrees. + let worktrees_root = + std::env::temp_dir().join(format!("ctx-backfill-{}", std::process::id())); + fs::create_dir_all(&worktrees_root)?; + + let total = sampled.len(); + let mut reports = Vec::new(); + let mut failed = 0usize; + for (i, sha) in sampled.iter().enumerate() { + let short = &sha[..12.min(sha.len())]; + let partition = opts.out_dir.join(partition_dir_name(sha)); + if partition.exists() { + eprintln!( + "[{}/{}] {}: partition exists, skipping", + i + 1, + total, + short + ); + let committed_at = commit_date(root, sha).unwrap_or_default(); + reports.push(skipped_report(sha, &committed_at, &partition)); + continue; + } + + eprintln!("[{}/{}] {}: capturing…", i + 1, total, short); + match capture_commit(root, sha, &worktrees_root, opts) { + Ok(report) => reports.push(report), + Err(e) => { + failed += 1; + eprintln!("[{}/{}] {}: failed: {}", i + 1, total, short, e); + } + } + } + // Best-effort: the per-sha guards removed their worktrees already. + let _ = fs::remove_dir(&worktrees_root); + + if failed > 0 { + eprintln!( + "backfill: {} succeeded, {} failed of {} commits", + reports.len(), + failed, + total + ); + } + Ok(reports) + } + + /// Capture one historical commit via a temporary detached worktree. + fn capture_commit( + root: &Path, + sha: &str, + worktrees_root: &Path, + opts: &BackfillOptions, + ) -> Result { + let wt_path = worktrees_root.join(sha); + let output = Command::new("git") + .args(["worktree", "add", "--detach"]) + .arg(&wt_path) + .arg(sha) + .current_dir(root) + .output()?; + if !output.status.success() { + return Err(CtxError::git( + String::from_utf8_lossy(&output.stderr).trim().to_string(), + )); + } + let _guard = WorktreeGuard { + repo: root.to_path_buf(), + path: wt_path.clone(), + }; + + capture( + &wt_path, + &CaptureOptions { + out_dir: opts.out_dir.clone(), + churn_window: opts.churn_window.clone(), + capture_mode: CaptureMode::Backfill, + force: false, + }, + ) + } + + // ======================================================================== + // Parquet export + // ======================================================================== + + /// Load churn, per-file violation counts, and duplicate pairs into + /// side tables in the in-memory DuckDB so the COPY queries can join them. + fn load_side_tables( + conn: &duckdb::Connection, + churn: &HashMap, + violations_by_file: &HashMap, + pairs: &[DuplicatePair], + ) -> Result<()> { + conn.execute_batch( + r#" + CREATE TABLE churn (path VARCHAR, churn_commits INTEGER); + CREATE TABLE violations (path VARCHAR, violation_count INTEGER); + CREATE TABLE dup_pairs ( + file_a VARCHAR, + symbol_a VARCHAR, + file_b VARCHAR, + symbol_b VARCHAR, + similarity DOUBLE, + token_count_a BIGINT, + token_count_b BIGINT + ); + "#, + )?; + + let mut app = conn.appender("churn")?; + for (path, commits) in churn { + app.append_row(duckdb::params![path, *commits])?; + } + app.flush()?; + + let mut app = conn.appender("violations")?; + for (path, count) in violations_by_file { + app.append_row(duckdb::params![path, *count])?; + } + app.flush()?; + + let mut app = conn.appender("dup_pairs")?; + for pair in pairs { + app.append_row(duckdb::params![ + pair.a.file_path, + pair.a.name, + pair.b.file_path, + pair.b.name, + pair.similarity, + pair.token_count_a, + pair.token_count_b, + ])?; + } + app.flush()?; + + Ok(()) + } + + /// COPY the four snapshot tables into `staging` as Parquet, every row + /// denormalized with `commit_sha` and `committed_at`. + fn write_parquet_files( + conn: &duckdb::Connection, + staging: &Path, + sha: &str, + committed_at: &str, + mode: CaptureMode, + ) -> Result<()> { + // `committed_at` becomes a Parquet TIMESTAMP; normalize the ISO 8601 + // committer date (which carries a UTC offset) to a naive UTC literal. + let committed_utc = to_utc_timestamp_literal(committed_at)?; + let captured_at = OffsetDateTime::now_utc() + .format(&Rfc3339) + .unwrap_or_default(); + + // Columns shared by every row of every table. `sha` is git-produced + // hex and the timestamps are generated above, but escape defensively + // like the rest of this module does for paths. + let stamp = format!( + "'{}' AS commit_sha, TIMESTAMP '{}' AS committed_at", + sql_escape(sha), + sql_escape(&committed_utc) + ); + + copy_to_parquet( + conn, + &format!( + "SELECT {stamp}, + id, name, qualified_name, kind, file, + line_start, line_end, is_public, + complexity, fan_in, fan_out + FROM v1.symbols" + ), + &staging.join("symbols.parquet"), + )?; + + copy_to_parquet( + conn, + &format!( + "SELECT {stamp}, + f.path, f.language, f.symbol_count, f.total_complexity, + COALESCE(mx.max_complexity, 0) AS max_complexity, + COALESCE(c.churn_commits, 0) AS churn_commits, + COALESCE(v.violation_count, 0) AS violation_count + FROM v1.files f + LEFT JOIN ( + SELECT file, MAX(complexity) AS max_complexity + FROM v1.symbols GROUP BY file + ) mx ON mx.file = f.path + LEFT JOIN churn c ON c.path = f.path + LEFT JOIN violations v ON v.path = f.path" + ), + &staging.join("files.parquet"), + )?; + + copy_to_parquet( + conn, + &format!( + "SELECT {stamp}, + file_a, symbol_a, file_b, symbol_b, + similarity, token_count_a, token_count_b + FROM dup_pairs" + ), + &staging.join("dup_pairs.parquet"), + )?; + + copy_to_parquet( + conn, + &format!( + "SELECT {stamp}, + '{captured_at}' AS captured_at, + '{version}' AS ctx_version, + CAST({schema} AS INTEGER) AS snapshot_schema_version, + '{mode}' AS capture_mode", + captured_at = sql_escape(&captured_at), + version = sql_escape(env!("CARGO_PKG_VERSION")), + schema = SNAPSHOT_SCHEMA_VERSION, + mode = mode.as_str(), + ), + &staging.join("meta.parquet"), + )?; + + Ok(()) + } + + /// Run `COPY (