Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
97 changes: 97 additions & 0 deletions .github/workflows/snapshot.yml
Original file line number Diff line number Diff line change
@@ -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"
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<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 <REF> [--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
Expand Down
10 changes: 10 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 9 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
24 changes: 14 additions & 10 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
101 changes: 101 additions & 0 deletions docs/benchmark/run-record.md
Original file line number Diff line number Diff line change
@@ -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.
Loading
Loading