diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml
new file mode 100644
index 0000000..cf30efc
--- /dev/null
+++ b/.github/workflows/benchmarks.yml
@@ -0,0 +1,154 @@
+name: GraphQL comparison
+on:
+ pull_request:
+ branches: [main]
+ paths: ['rust/**', 'csharp/**', 'scripts/**', 'tests/**', 'schema/**', 'deep/**', 'compose.yml', 'requirements.txt', 'rust-toolchain.toml', 'global.json', '.gitignore', '.github/workflows/benchmarks.yml']
+ push:
+ branches: [main]
+ paths: ['rust/**', 'csharp/**', 'scripts/**', 'tests/**', 'schema/**', 'deep/**', 'compose.yml', 'requirements.txt', 'rust-toolchain.toml', 'global.json', '.gitignore', '.github/workflows/benchmarks.yml']
+ workflow_dispatch:
+permissions:
+ contents: read
+concurrency:
+ group: gql-${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
+env:
+ DOTNET_NOLOGO: 'true'
+ DOTNET_CLI_TELEMETRY_OPTOUT: 'true'
+ CARGO_TERM_COLOR: always
+ BENCHMARK_LINKS: ${{ github.event_name == 'pull_request' && '10' || '100' }}
+ BENCHMARK_SAMPLES: '10'
+ BENCHMARK_WARMUP: '2'
+jobs:
+ test:
+ runs-on: ubuntu-24.04
+ timeout-minutes: 30
+ steps:
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+ - uses: dtolnay/rust-toolchain@89b12181fb390509a0842a86cc55eeb8eb928c1d
+ with:
+ toolchain: '1.98.0'
+ components: rustfmt, clippy
+ - uses: Swatinem/rust-cache@49a0bdc70d2e1b713ca9e2869b211fcce03d3c1c # v2
+ with:
+ workspaces: rust
+ - uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5
+ with:
+ global-json-file: global.json
+ cache: true
+ cache-dependency-path: csharp/packages.lock.json
+ - uses: actions/setup-python@7f4fc3e22c37d6ff65e88745f38bd3157c663f7c # v4
+ with:
+ python-version: '3.12'
+ cache: pip
+ - name: Python checks
+ run: |
+ pip install -r requirements.txt
+ ruff check scripts tests
+ python -m unittest discover -s tests -v
+ - name: Rust checks
+ run: |
+ cargo fmt --manifest-path rust/Cargo.toml --check
+ cargo clippy --manifest-path rust/Cargo.toml --release --all-targets --locked -- -D warnings
+ cargo test --manifest-path rust/Cargo.toml --release --locked
+ cargo build --manifest-path rust/Cargo.toml --release --locked
+ - name: C# checks
+ run: |
+ dotnet restore csharp/Doublets.Gql.csproj --locked-mode
+ dotnet format csharp/Doublets.Gql.csproj --verify-no-changes --no-restore
+ dotnet build csharp/Doublets.Gql.csproj -c Release --no-restore
+ - name: All servers return the same results
+ run: python -m scripts.run conformance
+ - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
+ if: always()
+ with:
+ name: conformance-logs
+ path: results/logs
+ benchmark:
+ needs: test
+ runs-on: ubuntu-24.04
+ timeout-minutes: 60
+ strategy:
+ fail-fast: false
+ matrix:
+ backend: [deep, rust, csharp]
+ background: ${{ fromJSON(github.event_name == 'pull_request' && '[1000]' || '[10000,100000,1000000]') }}
+ env:
+ BENCHMARK_BACKGROUND_LINKS: ${{ matrix.background }}
+ steps:
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+ - uses: dtolnay/rust-toolchain@89b12181fb390509a0842a86cc55eeb8eb928c1d
+ if: matrix.backend == 'rust'
+ with:
+ toolchain: '1.98.0'
+ - uses: Swatinem/rust-cache@49a0bdc70d2e1b713ca9e2869b211fcce03d3c1c # v2
+ if: matrix.backend == 'rust'
+ with:
+ workspaces: rust
+ - uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5
+ if: matrix.backend == 'csharp'
+ with:
+ global-json-file: global.json
+ cache: true
+ cache-dependency-path: csharp/packages.lock.json
+ - uses: actions/setup-python@7f4fc3e22c37d6ff65e88745f38bd3157c663f7c # v4
+ with:
+ python-version: '3.12'
+ - name: Build Rust server
+ if: matrix.backend == 'rust'
+ run: cargo build --manifest-path rust/Cargo.toml --release --locked
+ - name: Build C# server
+ if: matrix.backend == 'csharp'
+ run: |
+ dotnet restore csharp/Doublets.Gql.csproj --locked-mode
+ dotnet build csharp/Doublets.Gql.csproj -c Release --no-restore
+ - name: Measure shared HTTP workload
+ run: python -m scripts.run benchmark --backend '${{ matrix.backend }}'
+ - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
+ if: always()
+ with:
+ name: bench-${{ matrix.backend }}-${{ matrix.background }}
+ path: results
+ report:
+ needs: benchmark
+ runs-on: ubuntu-24.04
+ timeout-minutes: 10
+ permissions:
+ contents: write
+ steps:
+ - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
+ - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
+ with:
+ pattern: bench-*
+ merge-multiple: true
+ path: results
+ - uses: actions/setup-python@7f4fc3e22c37d6ff65e88745f38bd3157c663f7c # v4
+ with:
+ python-version: '3.12'
+ cache: pip
+ - name: Generate validated tables and charts
+ run: |
+ pip install -r requirements.txt
+ python -m scripts.report results --readme README.md --charts Docs > report.md
+ cat report.md >> "$GITHUB_STEP_SUMMARY"
+ - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
+ with:
+ name: comparison-report
+ path: |
+ README.md
+ Docs
+ - name: Commit main results
+ if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
+ run: |
+ git fetch origin main
+ if [ "$(git rev-parse HEAD)" != "$(git rev-parse origin/main)" ]; then
+ echo 'Main advanced during measurement; skipping publication of older results'
+ exit 0
+ fi
+ git config user.name LinksPlatformBencher
+ git config user.email linksplatform@gmail.com
+ git add README.md Docs
+ if ! git diff --cached --quiet; then
+ git commit -m 'Update GraphQL comparison results'
+ git push origin HEAD:main
+ fi
diff --git a/.gitignore b/.gitignore
index dfcfd56..0bad527 100644
--- a/.gitignore
+++ b/.gitignore
@@ -348,3 +348,13 @@ MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
.ionide/
+
+# Comparison build products and local measurements
+rust/target/
+csharp/bin/
+csharp/obj/
+__pycache__/
+.venv/
+/results/
+/ci-logs/
+experiments/*.log
diff --git a/README.md b/README.md
index b850c65..5d9846e 100644
--- a/README.md
+++ b/README.md
@@ -1,2 +1,148 @@
-# Comparisons.GqlServers
-Deep (PostgreSQL+Hasura) vs Doublets Rust vs Doublets C#
+# GraphQL server comparison
+
+Deep's PostgreSQL + Hasura core links storage versus Doublets Rust and Doublets C#,
+through the same GraphQL-over-HTTP workload. This repository now contains runnable
+servers, correctness tests, a benchmark driver and generated reporting.
+
+## Scope and schema
+
+[`schema/links.graphql`](schema/links.graphql) defines the executable common subset
+of the [Data.Doublets.Gql](https://github.com/linksplatform/Data.Doublets.Gql) /
+Hasura API: `links`, `insert_links`, `update_links`, `delete_links`, with `id`,
+`from_id`, `to_id`, equality, membership, range and boolean filters, ordering by
+id, limit and offset. Rust uses async-graphql + Axum over `doublets` 0.5.0;
+C# uses GraphQL.NET over `Platform.Data.Doublets` 0.18.1. Both use united volatile
+Doublets stores and push equality and id-membership filters into their native indexes.
+The adapters follow Data.Doublets.Gql's field and resolver conventions; they are
+maintained in `rust/` and `csharp/` here because its legacy servers require old
+runtimes and have unfinished resolvers. All original submodule pointers remain.
+
+The Deep baseline uses PostgreSQL 16.13 and Hasura 2.48.13, both pinned by digest
+in [`compose.yml`](compose.yml). [`deep/links.sql`](deep/links.sql) reproduces the
+core table and indexes from [Deep's links migration at
+381207d](https://github.com/deep-foundation/deeplinks/blob/381207dda313ab3c1fed0a4bfc2ddbe631750411/migrations/1616701513782-links.ts),
+with a default `type_id = 0`. This compares core links storage, without Deep's
+application triggers, permissions, values, relationships, materialized paths or
+subscriptions. Hasura exposes more schema fields than the common subset.
+
+Workloads use nonnegative, non-null endpoints and distinct `(from_id, to_id)`
+pairs. They do not benchmark duplicates, explicit ids, nullable endpoints,
+transactions or concurrency. Generated ids can be reused by Doublets and are
+monotonic in PostgreSQL; tests start with fresh stores and benchmarks use ids
+returned by each server. The comparison does not claim equivalence outside this
+shared workload domain.
+
+## Running locally
+
+Prerequisites: Docker with Compose, Rust 1.98.0, .NET SDK 10.0.112 (runtime 10.0.12), and Python
+3.12 or newer. Versions of libraries are pinned in Cargo.lock and
+packages.lock.json; reporting dependencies are in requirements.txt.
+
+```bash
+python3 -m pip install -r requirements.txt
+cargo build --manifest-path rust/Cargo.toml --release --locked
+dotnet restore csharp/Doublets.Gql.csproj --locked-mode
+dotnet build csharp/Doublets.Gql.csproj -c Release --no-restore
+
+# Unit tests and the same-response test against all three real HTTP servers.
+python3 -m unittest discover -s tests -v
+cargo test --manifest-path rust/Cargo.toml --release --locked
+python3 -m scripts.run conformance
+
+# Each command starts an empty dedicated backend and cleans up after itself.
+export BENCHMARK_BACKGROUND_LINKS=1000 BENCHMARK_LINKS=10
+export BENCHMARK_SAMPLES=10 BENCHMARK_WARMUP=2
+python3 -m scripts.run benchmark --backend deep
+python3 -m scripts.run benchmark --backend rust
+python3 -m scripts.run benchmark --backend csharp
+python3 -m scripts.report results --readme README.md --charts Docs
+```
+
+`python3 -m scripts.run` owns ports 8080, 8001 and 8002, starts only the required
+servers, saves server logs under `results/logs/`, and stops its processes and
+Compose services on completion or failure. Use a dedicated checkout and database;
+Compose teardown removes its database. The low-level driver also accepts an
+existing **empty dedicated** endpoint:
+`python3 -m scripts.workload rust http://127.0.0.1:8001/v1/graphql results/rust-1000.json`.
+It refuses to seed a nonempty store. Set `BENCHMARK_TRACE=1` for optional HTTP
+tracing (off by default; trace runs should not be used for published timings).
+Set `COMPOSE_OVERRIDE` to an optional local Compose override file if needed.
+
+| Environment variable | Default | Meaning |
+|---|---:|---|
+| `BENCHMARK_BACKGROUND_LINKS` | 1000 | B, links seeded outside timing |
+| `BENCHMARK_LINKS` | 100 | N, links affected by a mutation request |
+| `BENCHMARK_SAMPLES` | 10 | Measured requests per operation (2–1000) |
+| `BENCHMARK_WARMUP` | 2 | Untimed requests per operation (0–100) |
+| `LISTEN_ADDRESS` | Rust: `127.0.0.1:8001`; C#: `http://127.0.0.1:8002` | Adapter bind address |
+
+Require `1 ≤ N ≤ B ≤ 1,000,000` and `B + N < 1,040,384`, the capacity limit of
+Rust doublets 0.5.0 described by the [sibling comparison](https://github.com/linksplatform/Comparisons.Neo4jVSDoublets/tree/main/experiments/store-capacity).
+Servers are bounded to 4 GiB virtual memory (Rust), 2 GiB managed heap (C#),
+and 2 GiB per database container; Rust's stack limit is 32 MiB.
+
+| Operation | Shared request and dataset |
+|---|---|
+| Create | Insert N temporary pairs `(i, i)`; delete returned ids after timing |
+| Update | Set `to_id = 1` for N background ids; restore each original pair after timing |
+| Delete | Insert N temporary pairs outside timing, delete their returned ids |
+| Each All | Query and order all B background links |
+| Each Identity | Query one background id |
+| Each Concrete | Query `from_id = 1` and `to_id = 2` |
+| Each Outgoing | Query `from_id = 1` |
+| Each Incoming | Query `to_id = 2` |
+
+Background pairs are `(i, i+1)`, so each filtered query returns one row.
+Create/update/delete each send **one batched HTTP request**, not N requests.
+The driver uses one persistent HTTP connection and exactly the same documents,
+variables and settings for every server. Each sample includes client JSON
+encoding/decoding, HTTP, GraphQL parsing/validation/execution, storage and response
+serialization. Setup, seeding, warm-up, response validation and mutation cleanup are untimed. Each
+response is checked for the expected affected count, ids and endpoints before it
+can become a sample. The complete background dataset is checked again at the end. These are end-to-end
+server comparisons, not isolated storage or language-speed measurements; Hasura's
+query-plan caching and PostgreSQL durability differ from volatile Doublets.
+
+Rust uses the united store because the split store in doublets 0.5.0 hangs during
+repeated batched mutations on non-point links. A bounded regression test
+(`repeated_batch_mutations_preserve_indexes`, 5 seconds) reproduces this failure
+and verifies the chosen store. `python3 -m experiments.repeat_workload` retains
+the small HTTP reproduction (1,000 background links, 3 samples, memory/CPU limits).
+
+The conformance test checks explicit expected responses and compares all three
+servers: every query variant, membership/range/boolean filters, pagination,
+partial updates, missing ids, batched deletes and final state. The Python tests
+check workload bounds, timing boundaries and reporting validation/ratios. Rust
+also tests repeated mutations, partial updates and negative identity lookups. Local CI checks:
+
+```bash
+ruff check scripts tests
+python3 -m unittest discover -s tests -v
+cargo fmt --manifest-path rust/Cargo.toml --check
+cargo clippy --manifest-path rust/Cargo.toml --release --all-targets --locked -- -D warnings
+dotnet format csharp/Doublets.Gql.csproj --verify-no-changes --no-restore
+```
+
+## CI and results
+
+[`.github/workflows/benchmarks.yml`](.github/workflows/benchmarks.yml) checks
+formatting, lint, builds, unit tests and real three-server conformance first.
+Benchmark jobs then use a **server × B** matrix: Deep, Rust and C#, each in its
+own runner. PRs use `B = 1,000`, `N = 10`; main and manual main runs use
+`B = 10,000 / 100,000 / 1,000,000`, `N = 100`. All use 10 measured samples and
+2 warm-up requests per operation. There are path filters, branch concurrency,
+explicit job timeouts and pinned actions, toolchains and database images.
+
+Every successful run uploads raw samples, logs and a generated README with
+linear and log charts. CI commits README, charts and raw samples under
+`Docs/results/` only on main. It changes only the results markers below and
+rejects incomplete backends/operations, duplicate results, inconsistent workloads,
+missing provenance and invalid statistics. Generated-result-only commits do not
+match the workflow's path filters. Times show sample medians and standard
+deviations, ratios to Deep (`× faster`, `× slower`, `≈ same`), dependency versions,
+CPU, commit, run link and UTC date. Treat overlapping error ranges or differences
+under 5% as approximately the same.
+
+
+_No published results yet. A successful main run will commit measured tables, provenance and linear/log charts here. PR reports are available as CI artifacts._
+
diff --git a/compose.yml b/compose.yml
new file mode 100644
index 0000000..605e1d0
--- /dev/null
+++ b/compose.yml
@@ -0,0 +1,26 @@
+name: deepgql-comparison
+services:
+ postgres:
+ image: postgres:16.13-alpine@sha256:4e6e670bb069649261c9c18031f0aded7bb249a5b6664ddec29c013a89310d50
+ environment:
+ POSTGRES_PASSWORD: benchmark
+ POSTGRES_DB: deep
+ volumes:
+ - ./deep/links.sql:/docker-entrypoint-initdb.d/links.sql:ro
+ healthcheck:
+ test: [CMD-SHELL, 'pg_isready -U postgres -d deep']
+ interval: 2s
+ timeout: 5s
+ retries: 30
+ mem_limit: 2g
+ hasura:
+ image: hasura/graphql-engine:v2.48.13@sha256:224f4239b28583413b2a0ace7193c467d81f28d791dc8c65597ed11b2cd83123
+ ports: ['127.0.0.1:8080:8080']
+ environment:
+ HASURA_GRAPHQL_DATABASE_URL: postgres://postgres:benchmark@postgres:5432/deep
+ HASURA_GRAPHQL_ENABLE_CONSOLE: 'false'
+ HASURA_GRAPHQL_ENABLED_LOG_TYPES: startup
+ depends_on:
+ postgres:
+ condition: service_healthy
+ mem_limit: 2g
diff --git a/csharp/Doublets.Gql.csproj b/csharp/Doublets.Gql.csproj
new file mode 100644
index 0000000..e084aa2
--- /dev/null
+++ b/csharp/Doublets.Gql.csproj
@@ -0,0 +1,17 @@
+
+
+ net10.0
+ 10.0.12
+ Disable
+ enable
+ enable
+ true
+ true
+
+
+
+
+
+
+
+
diff --git a/csharp/Program.cs b/csharp/Program.cs
new file mode 100644
index 0000000..43ce8f9
--- /dev/null
+++ b/csharp/Program.cs
@@ -0,0 +1,172 @@
+using GraphQL;
+using GraphQL.SystemTextJson;
+using GraphQL.Types;
+using Platform.Data;
+using Platform.Data.Doublets;
+using Platform.Data.Doublets.Memory.United.Generic;
+using Platform.Memory;
+
+var builder = WebApplication.CreateBuilder(args);
+builder.Logging.ClearProviders();
+if (Environment.GetEnvironmentVariable("BENCHMARK_TRACE") == "1")
+{
+ builder.Logging.AddConsole();
+}
+builder.WebHost.UseUrls(Environment.GetEnvironmentVariable("LISTEN_ADDRESS") ?? "http://127.0.0.1:8002");
+using var store = new LinksStore();
+using var schema = Schema.For(File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "links.graphql")), config =>
+{
+ config.Types.For("query_root").FieldFor("links").Resolver = new GraphQL.Resolvers.FuncFieldResolver