FEAT-024 Phase 0: Benchmark Datasets, Executable Commands and Capacity Baseline¶
Purpose¶
The materialized project statistics technical plan requires a separate documentation-only Phase 0 completion review that names "every calculation/consumer, method-level mutation owner, stable profile formula, fixed benchmark dataset and executable benchmark command" before Phase 1 may be authorized (see Phase 0). This document supplies the last two of those five: the fixed benchmark datasets and the executable benchmark commands, together with the capacity model that the plan's provisional bounds must be replaced by or confirmed against.
It is scoped to measurement design only. It defines no product code, authorizes no implementation, and records no environment work. Every measurement whose subject is projection code that does not exist yet is explicitly marked as executing in Phase 1 against dark code; only the authoritative-baseline and capacity-arithmetic measurements are Phase 0 work.
The bounds and gates restated here are drawn from the plan's
bounded study-state profile distributions,
ProjectStatisticsSummary and ProjectStatisticsScope,
ProjectStatisticsCheckpointSet, reference pages and observations,
source ordering and measured contention,
transaction and idempotency rules,
delta and checkpoint storage reclamation,
required telemetry and
common performance gates sections. Where this document
proposes a different number, the plan value is retained until the proposal is measured and approved.
The calculations being benchmarked are the ones already catalogued by the parallel Phase 0 read-only passes; this document does not re-derive that catalogue and refers to each calculation by its entry point.
Inventory of existing harnesses¶
Assembled read-only against /home/chris/workspace/syrf/main on 2026-09-01. Every path below was
verified to exist.
| Asset | Path | What it gives Phase 0 | Gap |
|---|---|---|---|
| Testcontainers MongoDB fixture (standalone) | src/libs/testing/SyRF.Testing.Common/Fixtures/MongoDbTestFixture.cs |
A disposable mongo:8.0 container per xUnit collection, CSUUID serializer already registered, CreateContext() returning a wired MongoContext, GetCleanCollection<T>() |
Standalone mongod: cannot serve multi-document transactions, so it cannot host the concurrency benchmark |
| Testcontainers MongoDB fixture (replica set) | src/libs/testing/SyRF.Testing.Common/Fixtures/MongoDbReplicaSetTestFixture.cs |
A mongo:8.0 single-node replica set (WithReplicaSet("rs0")) that can start sessions and transactions; MongoDbReplicaSetTestCollection defined in the same file |
Aligned to mongo:8.0 to match the standalone fixture, so B1 and B2 cannot straddle a server version; its only consumer, src/services/api/SyRF.API.Endpoint.Tests/SubmitAnnotationSessionServiceTests.cs, passes 15/15 on the new image |
| Shared-runner container policy | src/testing/DockerTestContainerPolicy.cs |
Owner labelling and CPU/memory/PID caps applied by both fixtures via WithCreateParameterModifier; keys CI ownership off SYRF_TEST_JOB_KEY |
Caps are fixed (2 vCPU, 2 GiB, 256 PIDs) and are applied whenever SYRF_TEST_JOB_KEY is set, so CI timings are not comparable with unlimited local timings |
| Integration lane | .github/workflows/ci-cd.yml (test-dotnet-integration, --filter "Category=Integration", --maxcpucount:1, DOTNET_PROCESSOR_COUNT: "4") |
An existing, reviewed, self-hosted lane that already runs Testcontainers MongoDB work on main |
Runs the whole solution's Category=Integration set; a benchmark tagged Integration would run on every main push inside a pinned 25-minute budget |
| PR unit lanes | .github/workflows/pr-tests.yml (--filter "Category!=Integration") |
Proof that any trait other than Category=Integration is executed on every PR |
A benchmark tagged Category=Benchmark would be picked up by the PR unit lanes unless those filters are amended |
| Domain test builders | src/libs/testing/SyRF.Testing.Common/Builders/StudyBuilder.cs, ProjectBuilder.cs, InvestigatorBuilder.cs |
Fluent, deterministic construction of Study, Project and Investigator with agreement thresholds and schema version |
No builder covers screening decisions, annotation sessions, reconciliation sessions, stages or memberships; StudyBuilder seeds _randomId from Random.Shared, which is not reproducible |
| Deterministic seed generator | src/services/project-management/SyRF.ProjectManagement.Endpoint/Seeding/DatabaseSeeder.cs with DataSources/BogusDataSource.cs (Randomizer.Seed = new Random(seed), default 42) and Builders/AnnotationHierarchyBuilder.cs |
The only existing generator that produces screening decisions with deliberate reviewer disagreement and reconciliation, plus annotation sessions through ReviewSubmissionService.AddScreening / AddSessionData; fixed GUIDs in src/libs/project-management/SyRF.ProjectManagement.Core/Seeding/SeedDataConstants.cs |
Fixed scale (five canonical projects, 15-30 studies each), hard-coded reviewer counts, runs only as an IRunAtInit inside the Project Management host behind SYRF_SEED_DATA_ENABLED / SYRF_SEED_DATA_MODE; not parameterisable and not callable from a test |
| Realistic study text | src/libs/project-management/SyRF.ProjectManagement.Core/Seeding/SampleStudies.json (embedded resource) |
Synthetic, non-clinical bibliographic records for realistic document sizes | Only a handful of records; needs cycling plus a deterministic suffix to reach dataset scale |
| Repository integration test project | src/libs/project-management/SyRF.ProjectManagement.Mongo.Data.Tests/ with MongoDbTestCollection.cs |
Already references SyRF.Testing.Common and SyRF.ProjectManagement.Mongo.Data, already binds MongoDbTestFixture, already has Category=Integration siblings elsewhere in the solution |
Contains no test of StudyStatsQuery or the reviewer-stats fan-out; nothing measures duration or round trips |
| Existing stats unit tests | src/libs/project-management/SyRF.ProjectManagement.Core.Tests/Services/ReviewStatsQueryServiceTests.cs, src/libs/project-management/SyRF.ProjectManagement.Core.Tests/ProjectManagementServiceTests.cs |
Service-level behaviour (including the CalculatingInclusionInfo guard) |
Mock-based; no MongoDB is exercised, so they establish no timing or facet baseline |
| Embedded golden-fixture precedent | src/libs/project-management/SyRF.ProjectManagement.Core.Tests/SyRF.ProjectManagement.Core.Tests.csproj (<EmbeddedResource Include="Fixtures/Ris/*.ris" />) |
An accepted in-repo convention for checked-in expectation files loaded by tests | No JSON golden-file loader or update mode exists |
| E2E fixtures | e2e/fixtures/factory.fixture.ts, e2e/fixtures/db.fixture.ts, e2e/helpers/csuuid.ts, e2e/fixtures/data/search-10-references.csv |
CSUUID encoding in TypeScript and a direct database fixture, useful if a consumer-level shadow run is later wanted | Playwright-scoped, per-test unique names, 10-reference dataset; unsuitable as a statistics benchmark corpus |
| Local dev MongoDB | docker-compose.dev.yml (mongo:8, --replSet rs0, database syrf_local) |
A transaction-capable local MongoDB for manual mongosh timing and explain work |
Empty unless separately seeded |
| Prod-shaped snapshot | scripts/mongo-seed-from-atlas-backup.sh; snapshot described in CLAUDE.md and docs/architecture/mongodb-reference.md |
Would give prod-shaped document sizes and per-project study distributions for read-only explain and timing |
Verified unavailable on 2026-09-01: the mongodb-syrf-local MCP server returned "The configured connection string is not valid", and docker ps -a lists no snapshot container. No production-shaped distribution could be measured for this document |
| BenchmarkDotNet | not present | — | A repository-wide search for BenchmarkDotNet returned no matches; there is no benchmark project, no [Benchmark] attribute and no benchmark configuration anywhere |
| Load/perf tooling | not present | — | Searches for k6, NBomber, artillery and autocannon returned no matching project, package or configuration |
| Timed Mongo scripts | not present | — | scripts/ and docs/scripts/ contain no timed-query or explain script; the only mongosh uses are the dev-compose health check and e2e/scripts/setup.sh |
The net position: the container, serialization, resource-policy and CI-lane plumbing all exist and are reusable unchanged. Everything that turns them into a measurement — data generation at scale, timing, round-trip counting, expectation freezing and BSON sizing — does not exist and is enumerated in Harness gaps.
Fixed benchmark datasets¶
All datasets are synthetic and deterministic. No dataset is derived from, seeded by, or compared
against production content: titles and abstracts come from the checked-in
SampleStudies.json records cycled with a deterministic index suffix, investigator names are the
Alpha/Beta/Gamma/Delta style placeholders already used by SeedDataConstants, and no real
project, person or record identifier appears in any dataset, golden file or result artifact.
Each dataset is fully described by its ID, its seed and the parameters in the table below. Given the
same generator version, ID and seed, the generated corpus must be byte-identical; the generator must
therefore derive every GUID from a deterministic function of (datasetId, seed, entityKind, ordinal)
rather than from Guid.NewGuid(), and must not use Random.Shared.
The existing builders and mutation methods cannot satisfy that requirement. Three concrete
non-determinism sources were verified against main, and each defeats byte-identical regeneration:
StudyBuilder.Build()calls the publicStudyconstructor, which delegates to the private constructor withGuid.NewGuid()(src/libs/project-management/SyRF.ProjectManagement.Core/Model/StudyAggregate/Study.cs:29), andStudyBuilderitself seeds_systematicSearchId,_referenceFileIdand_projectIdfromGuid.NewGuid()(src/libs/testing/SyRF.Testing.Common/Builders/StudyBuilder.cs:23-25).ReviewSubmissionService.AddScreeningreachesScreeningInfo.ScreenStudy, which constructs aScreeningwhose parameterless base call isGuid.NewGuid()(src/libs/project-management/SyRF.ProjectManagement.Core/Model/StudyAggregate/Screening.cs:19).Screeningexposes no identifier-taking overload.AddSessionDatareachesExtractionInfo.AddAnnotations, which constructs anAnnotationSessionstampingCreatedAtUtc,ReservedAtUtcand — for a completed session —CompletedAtUtcfromDateTime.UtcNow(src/libs/project-management/SyRF.ProjectManagement.Core/Model/StudyAggregate/AnnotationSession.cs:19-23). The session identifier is caller-supplied and can be made deterministic; the timestamps cannot.
Only Study already has a deterministic escape hatch: the public factory Study.CreateWithId
(Study.cs:89), and H-1 uses it rather than StudyBuilder.Build(). Avoiding the two mutation
methods is not, however, sufficient, and is not what H-1 does. The non-determinism they carry is not
theirs: ReviewSubmissionService.AddScreening (ReviewSubmissionService.cs:54) is a three-line
wrapper that guards active membership and delegates to Study.AddScreening (Study.cs:194), which
delegates to ScreeningInfo.ScreenStudy (ScreeningInfo.cs:111) — and that is where the Screening
is constructed. The same holds for sessions, where the AnnotationSession is constructed inside
ExtractionInfo.AddAnnotations (ExtractionInfo.cs:181). Bypassing the service reaches the same
constructors; only bypassing the aggregate methods themselves would avoid them, and those methods are
what maintain ScreeningInfo.IncludedCount, ScreeningInfo.AgreementMeasure and
ExtractionInfo.SessionTallies — precisely the derived fields every measured calculation reads. A
harness that reimplemented them would produce a corpus that no longer matched production, which is a
worse failure than a non-deterministic one because it would be invisible in the measurements.
H-1 therefore builds through the real domain methods and settles what they inject afterwards, on the
freshly constructed, unpersisted corpus: identifiers to a value derived from
(datasetId, seed, entityKind, key), with every reference to a rewritten identifier rewritten with
it, and timestamps to one fixed instant, since no measured calculation reads a document's age and a
per-entity derived time would only add noise. An absent timestamp stays absent, because whether a
session has a completion time is state rather than a clock reading. Both normalizations are recorded
in every results artifact. The result is not argued from that reasoning but verified: a unit test
serializes two independently generated corpora with the production BsonClassMaps and compares the
persisted bytes, and two separate process runs of the full baseline recorded the same corpus
fingerprint. This is a harness-design constraint, not a product change: no production constructor is
altered by it.
Dataset parameters¶
| Parameter | stats-small-v1 |
stats-medium-v1 |
stats-large-v1 |
stats-tally-natural-v1 |
stats-concurrency-v1 |
|---|---|---|---|---|---|
| Dataset ID | PS-DS-01 |
PS-DS-02 |
PS-DS-03 |
PS-DS-04a |
PS-DS-05 |
| Seed | 20260901 |
20260902 |
20260903 |
20260904 |
20260905 |
| Projects (target) | 1 | 1 | 1 | 1 | 1 |
| Projects (unrelated siblings) | 1 | 2 | 4 | 0 | 1 |
| Studies in target project | 300 | 5,000 | 25,000 | 7,381 | 400 |
| Studies per sibling project | 50 | 200 | 500 | — | 50 |
| Stages | 3 (1 screening, 2 annotation) | 5 (2 screening, 3 annotation) | 8 (3 screening, 5 annotation) | 1 screening | 2 (1 screening, 1 annotation) |
| Stages with reconciliation enabled | 1 | 2 | 3 | 0 | 1 |
| Reviewers (project members) | 4 | 10 | 25 | 120 | 10 |
| Project administrators | 1 | 1 | 2 | 1 | 1 |
| Systematic searches | 1 | 3 | 8 | 1 | 1 |
| Reference files per search | 1 | 2 | 3 | 1 | 1 |
| Screening decisions per study | 0-3 | 0-5 | 0-6 | 0-120, by construction | 0-2 pre-seeded |
| Studies with conflicting include/exclude across reviewers | 12% | 15% | 15% | 96.7% (7,140/7,381, fixed by construction — see below) | 10% |
| Studies with zero screening decisions | 20% | 15% | 12% | 0% | 30% |
| Annotation sessions per study per annotation stage | 0-2 | 0-3 | 0-3 | 0 | 0-3 |
| Completed vs in-progress session split | 60/40 | 55/45 | 50/50 | — | 50/50 |
Studies reaching the reconciliation eligibility threshold (NCCS >= 2) per reconciliation stage |
at least 20% | at least 20% | at least 20% | — | at least 20% |
| Reconciliation sessions | started on 8% of eligible studies, completed on 5% | started 10%, completed 6% | started 10%, completed 6% | 0 | started 5%, completed 2% |
| Annotation questions | 14 | 40 | 120 | 0 | 8 |
Distinct (NumberScreened, IncludedCount) tally keys |
10 | 21 | 28 | 7,381 | 6 |
Approximate generated pmStudy documents |
350 | 5,400 | 27,000 | 7,381 | 450 |
| Approximate embedded screening decisions | 450 | 12,500 | 75,000 | 590,480 | 400 |
| Intended use | smoke, golden-file authoring, developer loop | primary gate baseline | worst supported read scale | tally-cardinality and capacity | write concurrency (Phase 1) |
Three constraints on the table above follow from the authoritative code rather than from choice.
Reconciliation eligibility is NCCS >= 2, so a dataset needs at least two completed candidate
sessions on any study it expects to reconcile. The threshold is minNumberSessions, hardcoded to
2 in
src/libs/project-management/SyRF.ProjectManagement.Mongo.Data/StudyStats.cs:313, and every
reconciliation counter in SessionedGroupStage (StudyStats.cs:287) is gated on
NumberOfCompletedCandidateSessions >= minNumberSessions. An earlier revision of this table gave
PS-DS-05 a maximum of one annotation session per study per stage, which makes the eligible set
empty: no study can reach NCCS >= 2, so "started on 5% of eligible studies" describes 5% of nothing
and the dataset cannot produce the partial reconciliation availability, started and completed coverage
the invariants table claims for it. PS-DS-05 therefore now generates 0-3 sessions per study per
annotation stage, and every dataset with a reconciliation stage guarantees an explicit floor of
eligible studies. The generator implements that floor by position rather than by a random draw — a
declared share of studies is given two completed, non-reconciliation sessions outright on every
reconciliation stage — so the realized share is exactly the declared one at any dataset size and stays
stable across runs. The floor is a floor: studies below the threshold still exist, or the "available"
and "full" classifications would have nothing to separate. MinimumReconciliationEligibleShare in
ProjectStatisticsDatasetSpec carries the value, and a unit test asserts both halves.
Question versions are not a parameter, because the current model has no question version.
An earlier revision specified two versions per question for PS-DS-02 and three for PS-DS-03. No
generator can produce them: AnnotationQuestion.Update
(src/libs/project-management/SyRF.ProjectManagement.Core/Model/ProjectAggregate/AnnotationQuestion.cs:218)
mutates a definition in place with no history, AQVersion/QuestionSetVersion/SessionVersion have
zero matches in src, and the authoritative tally groups by ExtractionInfo.Annotations.QuestionId
alone (src/libs/project-management/SyRF.ProjectManagement.Mongo.Data/Repositories/StudyRepository.cs:951-956).
That also contradicted
D12,
which requires the version component to stay reserved-but-unpopulated until a versioning model exists.
The row has been removed: every dataset is defined at the current (ProjectId, QuestionId) grain, and
version coverage is deferred to a future harness revision that follows a question-versioning model.
stats-tally-natural-v1's conflict rate is not a free parameter either. An earlier revision of
this table gave PS-DS-04a a 22% conflicting-decision rate, which the generator ignored. The
dataset's defining property is that it covers every reachable (NumberScreened, IncludedCount) key at
least once, and with 7,381 studies and 7,381 reachable keys that means exactly one study per key. A
key is conflicting whenever 0 < IncludedCount < NumberScreened, which is true of 7,140 of them, so
full key coverage fixes the rate at 96.7%. Lowering it to a naturalistic figure would need many more
studies than the dataset's stated size and would change what the dataset is for. The table now
publishes the measured value; ProjectStatisticsDatasetSpec carries the same figure and a unit test
pins the count at 7,140.
stats-large-v1 additionally accepts an opt-in --scale 4 multiplier producing 100,000 studies in the
target project. That variant is not part of any CI lane; it exists for manual, out-of-lane runs,
because the integration lane's timeout is pinned at 25 minutes with a four-processor cap in
.github/workflows/ci-cd.yml and a 100,000-study generation plus aggregation does not fit inside it.
The adversarial tally datasets¶
The screening tally is the plan's only explicitly data-dependent dynamic-key structure
(screeningTallyCounts[(NumberScreened, IncludedCount)]). Its natural key space is exactly
determined by the maximum number of screening decisions any one study carries: with
0 <= IncludedCount <= NumberScreened <= K, the number of distinct keys is (K + 1)(K + 2) / 2.
Maximum decisions on one study (K) |
Distinct reachable tally keys |
|---|---|
| 10 | 66 |
| 25 | 351 |
| 50 | 1,326 |
| 89 | 4,095 |
| 120 | 7,381 |
| 126 | 8,128 |
| 127 | 8,256 |
| 315 | 50,086 |
Two consequences follow, and both must be validated rather than assumed:
PS-DS-04a(stats-tally-natural-v1) is the largest key set reachable from real screening decisions with a bounded reviewer population. It setsK = 120and generates at least one study for every one of the 7,381 reachable keys, which requires roughly 590,480 embedded screening decisions. It is the dataset that exercises the tally guard against real source data. On its own it does not exercise the proposed 8,192-key screening soft limit: 7,381 keys sits below that limit, so a run overPS-DS-04aalone can neither admit nor refuse at the boundary and cannot support calling 8,192 a measured limit.PS-DS-04c(stats-tally-natural-under-soft-limit-v1, seed20260907) andPS-DS-04d(stats-tally-natural-over-soft-limit-v1, seed20260908) straddle that boundary with natural source data. The reachable key count is monotonic inK, and 8,192 falls betweenK = 126(8,128 keys) andK = 127(8,256 keys), so those are the only two adjacent natural cases.PS-DS-04csetsK = 126and generates one study for every one of the 8,128 reachable keys, which requires 126 reviewers and roughly 683,000 embedded screening decisions;PS-DS-04dsetsK = 127for 8,256 keys, 127 reviewers and roughly 699,000 decisions. C2 runs all three and asserts that the soft-limit admission decision flips exactly once, betweenPS-DS-04candPS-DS-04d.PS-DS-04b(stats-tally-synthetic-ceiling-v1, seed20260906) has no source studies at all. It is a pure encoding fixture that constructs candidate scope documents holding 1,000, 10,000, 25,000, 50,000 and 50,001 dynamic tally keys directly, so the encoded-BSON admission guard and the key ceiling can be exercised at and just beyond the plan's limit without generating an unrepresentable corpus. Reaching 50,000 keys from genuine screening decisions would require a single study screened by more than 315 reviewers and on the order of 10.5 million decisions, which no supported deployment produces.
Invariants each dataset must exercise¶
| Invariant (plan source) | PS-DS-01 |
PS-DS-02 |
PS-DS-03 |
PS-DS-04a |
PS-DS-04b |
PS-DS-05 |
|---|---|---|---|---|---|---|
| Valid mixed include/exclude decisions from different reviewers are preserved and tallied, never labelled impossible | yes | yes | yes | yes | n/a | yes |
| Sufficiency, started and overscreening classifications all populated | yes | yes | yes | yes | n/a | partial |
| Studies with no screening decision at all | yes | yes | yes | no | n/a | yes |
| Fixed included/unexcluded and excluded annotation class buckets both non-empty | yes | yes | yes | n/a | n/a | yes |
| No-session, in-progress and completed annotation states all present per stage | yes | yes | yes | n/a | n/a | yes |
| Domain reconciliation availability, started, in-progress and completed states all present | yes | yes | yes | n/a | n/a | yes |
| Per-reviewer membership scope non-empty for every reviewer | yes | yes | yes | yes | n/a | yes |
Question scope populated at the current (ProjectId, QuestionId) grain, version component reserved and unpopulated |
yes | yes | yes | n/a | n/a | yes |
| Multiple systematic searches contributing to project totals | no | yes | yes | no | n/a | no |
| Sibling projects present, to prove per-project scoping of every count | yes | yes | yes | no | n/a | yes |
| Dynamic tally-key cardinality at or beyond the plan ceiling | no | no | no | approach | at and beyond | no |
PS-DS-04c and PS-DS-04d are omitted from the table above because they exercise exactly one
invariant between them: natural tally-key cardinality immediately below and immediately above the
proposed 8,192-key screening soft limit. They carry no annotation, reconciliation or question coverage.
Executable benchmark commands¶
Commands are given for a checkout root of /home/chris/workspace/syrf/main or an equivalent worktree.
The Status column states honestly whether the command runs against the repository as it stands today
or depends on a harness that does not yet exist; harness IDs resolve in
Harness gaps.
(a) Current authoritative baselines: p50/p95 and query/facet counts¶
The plan requires that "Phase 0 records current p50/p95 and query/facet counts using fixed datasets"
(common performance gates). The subjects are the
authoritative calculations already catalogued: the 15-facet $facet aggregation in
StudyStatsQuery.GetFullProjectStatsAsync (src/libs/project-management/SyRF.ProjectManagement.Mongo.Data/StudyStats.cs),
the CountDocumentsAsync fan-outs StudyRepository.GetReviewerStatsForStageAsync and
GetReviewerStatsForProjectAsync, the assignment-path fan-out
StudyRepository.GetStudyReviewStatus, the question tally
StudyRepository.GetAnnotationQuestionAnswerTally, and the systematic-search population sum reached
through SystematicSearchRepository.GetAllSystematicSearchesLinkedToProject. All five are measured;
none is declared in scope and left unmeasured.
Two of them are stage-scoped, and both read the stage before they query: GetReviewerStatsForStageAsync
branches on review mode and GetStudyReviewStatus reads the stage's selection mode and session
target. A single number taken from whichever stage came first would therefore be a baseline for one
review mode only, so each is measured once on a screening stage and once on an annotation stage,
recorded as separate series. PS-DS-04a has no annotation stage and records the screening series
alone.
| # | Measurement | Command | Status |
|---|---|---|---|
| A1 | Confirm the existing Testcontainers MongoDB path still works before any measurement | dotnet test src/libs/testing/SyRF.Testing.Common.Tests/SyRF.Testing.Common.Tests.csproj --filter "Category=Integration" |
Runnable today |
| A2 | Generate a dataset into a disposable container and report the corpus it wrote | SYRF_STATS_DATASET=PS-DS-02 dotnet test src/libs/project-management/SyRF.ProjectManagement.Mongo.Data.Tests/SyRF.ProjectManagement.Mongo.Data.Tests.csproj --filter "FullyQualifiedName~ProjectStatisticsDatasetGenerator" |
Needs harness (H-1) |
| A3 | p50/p95 of GetFullProjectStatsAsync for one dataset |
SYRF_STATS_DATASET=PS-DS-02 SYRF_STATS_ITERATIONS=100 SYRF_STATS_WARMUP=10 dotnet test src/libs/project-management/SyRF.ProjectManagement.Mongo.Data.Tests/SyRF.ProjectManagement.Mongo.Data.Tests.csproj --filter "FullyQualifiedName~ProjectStatisticsAuthoritativeBaseline.FullProjectStats" |
Needs harness (H-1, H-2, H-7) |
| A4 | p50/p95 of the CountDocumentsAsync fan-outs: GetReviewerStatsForStageAsync (measured once per review mode, because its screening and annotation branches issue different count queries), GetReviewerStatsForProjectAsync, and the assignment-path GetStudyReviewStatus (also per review mode) |
as A3 with --filter "FullyQualifiedName~ProjectStatisticsAuthoritativeBaseline.ReviewerStats", which selects both the reviewer-stats and the assignment-path measurement |
Needs harness (H-1, H-2, H-7) |
| A5 | p50/p95 of the question tally and the search-population sum | Two invocations. Question tally: SYRF_STATS_DATASET=PS-DS-02 SYRF_STATS_ITERATIONS=100 SYRF_STATS_WARMUP=10 dotnet test src/libs/project-management/SyRF.ProjectManagement.Mongo.Data.Tests/SyRF.ProjectManagement.Mongo.Data.Tests.csproj --filter "FullyQualifiedName~ProjectStatisticsAuthoritativeBaseline.QuestionTally". Search population: the same command with --filter "FullyQualifiedName~ProjectStatisticsAuthoritativeBaseline.SearchPopulation" |
Needs harness (H-1, H-2, H-7) |
| A6 | Round-trip and facet counts per calculation per dataset | SYRF_STATS_DATASET=PS-DS-02 SYRF_STATS_COUNT_ROUNDTRIPS=1 dotnet test src/libs/project-management/SyRF.ProjectManagement.Mongo.Data.Tests/SyRF.ProjectManagement.Mongo.Data.Tests.csproj --filter "FullyQualifiedName~ProjectStatisticsAuthoritativeBaseline" (counts are taken from a CommandStartedEvent subscriber on the driver's ClusterConfigurator, and the facet count from the number of $facet sub-pipelines in the emitted command) |
Needs harness (H-3) |
| A7 | Full baseline sweep across every dataset, emitting the results artifact | for d in PS-DS-01 PS-DS-02 PS-DS-03 PS-DS-04a; do SYRF_STATS_DATASET=$d SYRF_STATS_COUNT_ROUNDTRIPS=1 dotnet test src/libs/project-management/SyRF.ProjectManagement.Mongo.Data.Tests/SyRF.ProjectManagement.Mongo.Data.Tests.csproj --filter "FullyQualifiedName~ProjectStatisticsAuthoritativeBaseline"; done |
Needs harness (H-1, H-2, H-3, H-7) |
| A8 | Server-side plan and execution shape for the $facet pipeline, independent of the .NET client |
Start docker compose -f docker-compose.dev.yml up -d mongodb, seed the dev database, then run a script of the form below |
Runnable today with manual transcription |
Command A8 has no pipeline artifact to consume: the 15-facet pipeline is constructed in C# and is not
exported anywhere, so it must be transcribed by hand from StudyStats.cs into the script. That makes
A8 usable today for an explain sanity check, but it is not a substitute for A3, and its number must
never be recorded as the gate baseline.
// phase0-facet-explain.js — read-only. Run with:
// mongosh "mongodb://localhost:27017/syrf_local" --quiet --file phase0-facet-explain.js
const pipeline = [
{ $match: { ProjectId: BinData(3, "<dataset-project-guid-base64>") } },
// ... transcribed $facet stage from StudyStats.cs ...
];
const explained = db.pmStudy.explain("executionStats").aggregate(pipeline);
printjson({
totalDocsExamined: explained.executionStats && explained.executionStats.totalDocsExamined,
executionTimeMillis: explained.executionStats && explained.executionStats.executionTimeMillis
});
const samples = [];
for (let i = 0; i < 30; i++) {
const t0 = Date.now();
db.pmStudy.aggregate(pipeline).toArray();
samples.push(Date.now() - t0);
}
samples.sort((a, b) => a - b);
printjson({ p50: samples[Math.floor(samples.length * 0.5)], p95: samples[Math.floor(samples.length * 0.95)] });
The deterministic in-repo seed data can be produced today, at its own fixed scale, without any new harness:
SYRF_SEED_DATA_ENABLED=true \
ASPNETCORE_ENVIRONMENT=Development \
dotnet run --project src/services/project-management/SyRF.ProjectManagement.Endpoint/SyRF.ProjectManagement.Endpoint.csproj
That produces the five canonical seed projects, including deliberate reviewer disagreement and reconciliation, but at 15-30 studies each. It is a useful shape check and a reference implementation for the generator; it is not one of the fixed benchmark datasets.
(b) Write concurrency: ½/5/10 reviewers — protocol defined now, executes in Phase 1¶
The plan requires simultaneous-write benchmarks by 1, 2, 5 and 10 reviewers "to both the same Study and different Studies", measuring "p50/p95 transaction duration, retry/unknown-commit count, write-conflict rate, summary-document bytes and authoritative fallback frequency" (source ordering and measured contention). Four of those five metrics have no subject until the transactional projection path exists, so the protocol is fixed here and executes in Phase 1 against dark code. Only the source-mutation arm — today's screening submit with no projection writes — can be measured in Phase 0, and it is worth measuring then, because it is the denominator of the plan's "supported source-mutation p95 regression must remain below 10%" gate.
Protocol:
- Fixture:
MongoDbReplicaSetTestFixture(transactions are required; the standalone fixture cannot serve them), with its image aligned tomongo:8.0before B1 is run so that B1 and B2 share a server version — see D5. DatasetPS-DS-05. - Arms: the cross product of writer count
Nin{1, 2, 5, 10}and target in{same-Study, distinct-Studies}— eight arms. - Each writer performs the supported screening-submit mutation in a loop; writers start on a barrier so the overlap is real rather than staggered.
- Each arm runs 20 discarded warm-up iterations per writer, then 200 recorded iterations per writer.
- Recorded per arm: transaction duration p50 and p95; retry count; unknown-commit count; write-conflict count and rate (conflicts divided by committed transactions); the project summary document size measured as the encoded length of the persisted document; and the count of authoritative fallbacks observed during the arm.
- Phase 0 baseline arm: the same eight arms run against today's source mutation with no projection writes, giving the pre-change source-mutation p50/p95 for the regression gate.
Proposed pass criteria, to be confirmed rather than assumed in Phase 1: bounded retries means p95 retries per committed transaction at most 1 and maximum at most 5 in every arm.
Hotness of the shared project summary is decided by comparing each B2 arm against its matching B1 arm, never by comparing two B2 arms against each other. Comparing B2's distinct-Studies arm against B2's same-Study arm cannot detect a hot summary: the same-Study arm contends on both the source Study and the shared summary, while the distinct-Studies arm contends only on the summary, so the same-Study conflict rate is normally at least as high even when the summary is the bottleneck — a criterion that requires distinct-Studies to exceed same-Study would approve the shared document in exactly the case it is meant to catch. The correct signal is the arm-for-arm delta introduced by the projection write: the summary is declared hot, and striping or splitting is justified, when the 10-writer distinct-Studies B2 arm shows a materially higher write-conflict rate than the 10-writer distinct-Studies B1 arm, since B1 has no shared summary to contend on and the two arms are otherwise identical. The same-Study pair is read as a control: a comparable B2-over-B1 rise there is consistent with per-Study contention rather than with the summary.
| # | Measurement | Command | Status |
|---|---|---|---|
| B1 | Source-mutation-only concurrency baseline, all eight arms | SYRF_STATS_DATASET=PS-DS-05 SYRF_STATS_WRITERS=1,2,5,10 dotnet test src/libs/project-management/SyRF.ProjectManagement.Mongo.Data.Tests/SyRF.ProjectManagement.Mongo.Data.Tests.csproj --filter "FullyQualifiedName~ProjectStatisticsConcurrency.SourceMutationBaseline" |
Needs harness (H-1, H-6) |
| B2 | Full projection concurrency benchmark, all eight arms | SYRF_STATS_DATASET=PS-DS-05 SYRF_STATS_WRITERS=1,2,5,10 dotnet test src/libs/project-management/SyRF.ProjectManagement.Mongo.Data.Tests/SyRF.ProjectManagement.Mongo.Data.Tests.csproj --filter "FullyQualifiedName~ProjectStatisticsConcurrency.TransactionalProjection" |
Executes in Phase 1 (needs H-6 plus the Phase 1 projection path) |
© Capacity tests¶
| # | Measurement | Command | Status |
|---|---|---|---|
| C1 | Conservative encoded-BSON upper bound versus actual encoded size, per key count, at 1,000 / 10,000 / 25,000 / 50,000 / 50,001 keys | SYRF_STATS_DATASET=PS-DS-04b dotnet test src/libs/project-management/SyRF.ProjectManagement.Mongo.Data.Tests/SyRF.ProjectManagement.Mongo.Data.Tests.csproj --filter "FullyQualifiedName~ProjectStatisticsCapacity.ScopeDocumentCeiling" |
Needs harness (H-5) |
| C2 | Natural tally-key cardinality reached from real screening decisions, at and across the proposed soft limit | SYRF_STATS_DATASET=PS-DS-04a,PS-DS-04c,PS-DS-04d dotnet test src/libs/project-management/SyRF.ProjectManagement.Mongo.Data.Tests/SyRF.ProjectManagement.Mongo.Data.Tests.csproj --filter "FullyQualifiedName~ProjectStatisticsCapacity.NaturalTallyCardinality" |
Needs harness (H-1, H-5) |
| C3 | Point-path admission bound: canonical delta-record BSON size and document/move counts for the worst supported single mutation | dotnet test src/libs/project-management/SyRF.ProjectManagement.Mongo.Data.Tests/SyRF.ProjectManagement.Mongo.Data.Tests.csproj --filter "FullyQualifiedName~ProjectStatisticsCapacity.PointPathAdmission" |
Needs harness (H-5); the move and document counts become directly measurable only in Phase 1 |
| C4 | Checkpoint reference-page bound: encoded size of a 500-reference page and the reference count that first reaches 256 KiB | dotnet test src/libs/project-management/SyRF.ProjectManagement.Mongo.Data.Tests/SyRF.ProjectManagement.Mongo.Data.Tests.csproj --filter "FullyQualifiedName~ProjectStatisticsCapacity.ReferencePageBound" |
Needs harness (H-5) |
| C5 | Checkpoint publication bound: pages and bytes for a full checkpoint over each dataset's scope inventory | SYRF_STATS_DATASET=PS-DS-03 dotnet test src/libs/project-management/SyRF.ProjectManagement.Mongo.Data.Tests/SyRF.ProjectManagement.Mongo.Data.Tests.csproj --filter "FullyQualifiedName~ProjectStatisticsCapacity.CheckpointPublicationBound" |
Needs harness (H-5); executes in Phase 1 for real observation payloads |
How the conservative encoded-BSON upper bound is computed and validated. The bound is computed statically from the document's declared shape, never from a sampled document, so it can be evaluated before the write is attempted:
upperBound(doc) = 5 // BSON int32 length prefix + terminator
+ fixedFieldsUpperBound // catalogue-declared fixed fields, max encodings
+ 5 + len("screeningTallyCounts") + 2 // embedded sub-document header and name
+ keyCount * (1 + maxKeyNameLength + 1 + 8)
with every counter treated as an Int64 (8 bytes) even when it would currently encode as an Int32, and
maxKeyNameLength taken from the catalogue's declared key grammar rather than from the keys present.
The key grammar is s{NumberScreened}i{IncludedCount}, and maxKeyNameLength must be sized for the
widest legal value of each component, not for the widest value a fixture happens to produce. No
domain invariant bounds either component to three digits: AgreementMeasure.NumberScreened is an
int (src/libs/project-management/SyRF.ProjectManagement.Core/Model/ProjectAggregate/AgreementMeasure.cs:23)
and IncludedCount is derived from the same non-negative int domain, so the widest legal
representation is ten digits per component. The 50,000-key ceiling bounds only the number of observed
cells, not their numeric values — a sparse tally holding s10000i5000 is 11 characters wide while
sitting far below the ceiling. Sizing for the legal maximum, maxKeyNameLength = 22
(s + 10 digits + i + 10 digits), so each key costs at most 32 bytes and 50,000 keys cost at most
1,600,000 bytes, roughly 1.53 MiB. C1's fixtures use short keys and therefore cannot prove the bound;
they can only show that a real encoding stays under it, which is why the bound is computed from the
grammar and asserted against actual encoded length rather than derived from the fixtures.
Validation is empirical and two-sided. For each key count in C1 the test builds the real
BsonDocument, takes its actual encoded length, asserts actual <= upperBound, and records the ratio
so that an over-loose bound is visible rather than merely safe. It then asserts the guard's decision
matches the plan: at 50,000 keys the write is admitted; at 50,001 it is refused, no MongoDB update is
attempted, the screening family is marked Stale with typed StatisticsScopeCapacityExceeded, and the
source commits through the source-only receipt path. The arithmetic above predicts that the key
ceiling binds roughly five times sooner than the byte ceiling for this key shape — at 32 bytes per key
the byte ceiling would not be reached until around 262,000 keys — and C1 exists to confirm or refute that prediction
rather than to assume it.
Capacity model¶
| Bound | Plan value | Phase 0 measured or proposed value | How measured | Consequence when exceeded (per the plan) | Phase 0 recommends lowering? |
|---|---|---|---|---|---|
| Current scope document encoded BSON | no greater than 8 MiB | retain 8 MiB pending C1 | C1: conservative static upper bound versus actual BsonDocument encoded length |
Marks the screening family Stale with typed StatisticsScopeCapacityExceeded, commits the source through the source-only receipt/invalidation path, and makes the project ineligible for materialized screening serving until an approved bounded representation or a lower-cardinality state permits rebuild |
No |
| Dynamic keys per current scope document | no greater than 50,000 | retain 50,000 as the hard ceiling; add a proposed per-family soft limit of 8,192 for the screening tally, to be confirmed by C2 | C1 and C2: reachable key count is (K + 1)(K + 2) / 2 for maximum decisions K; C2 measures what real data reaches at K = 120 (PS-DS-04a, 7,381 keys), K = 126 (PS-DS-04c, 8,128) and K = 127 (PS-DS-04d, 8,256), so the boundary is crossed rather than approached |
Same typed StatisticsScopeCapacityExceeded path as above |
Not the hard ceiling; yes for a family-level soft limit, because 50,000 keys need a study screened by more than 315 reviewers and are unreachable in practice |
| Immutable observation document encoded BSON | no greater than 8 MiB, counted against the build's reserved byte budget | retain 8 MiB pending C5 | C5: encode real observation payloads for each dataset | Records typed CheckpointCapacityExceeded unavailability for that build; no insert is attempted; current serving is unaffected |
No |
| Point-path signed moves per transaction | at most 500 | propose 128 after C3 confirms the worst supported mutation | C3: enumerate moves analytically from the mutation-ownership matrix in Phase 0; measure directly from the canonicalizer in Phase 1 | Not an authoritative-save error: acquire the bounded all-family operation ownership and fences first, commit the source under that operation, and finish through the staged publication/rebuild protocol | Proposed, but only on Phase 1 measurement; retain 500 until then |
| Point-path projection documents per transaction | at most 100 | propose 32 after C3 | as C3 | as above | Proposed, subject to the same Phase 1 measurement |
| Point-path canonical delta-record BSON | at most 256 KiB | retain 256 KiB pending C3 | C3: encode a synthetic worst-case delta record at the proposed move and document counts | as above | No |
| Checkpoint reference-page references | initially 500, Phase 0 capacity tests may lower | retain 500 pending C4 | C4: encode a 500-reference page and find the reference count that first reaches 256 KiB | Records typed CheckpointCapacityExceeded unavailability; current serving unaffected |
No |
| Checkpoint reference-page bytes | initially 256 KiB, Phase 0 capacity tests may lower | retain 256 KiB pending C4 | as C4 | as above | No |
| Checkpoint publication pages | initial hard ceiling 256; Phase 0 must replace with a measured equal-or-lower limit before production | must be set by C5 before production; no value is proposed without that measurement | C5: pages produced for each dataset's full scope inventory, extrapolated to the largest supported project | as above | Undetermined — the plan obliges a measured equal-or-lower value, and C5 is the blocking measurement |
| Checkpoint publication bytes | initial hard ceiling 64 MiB, same obligation | as above | as C5 | as above | Undetermined, same obligation |
| Exact delta ledger age | 90 days per project (Phase 1 provisional, must be replaced with measured limits before production activation) | no Phase 0 value; the arrival-rate input requires the timed workload W-1 below, which does not yet exist | W-1: a timed mutation workload with a declared duration and operation mix, run against a fixed dataset, counting statistics-affecting mutations per project per elapsed day. Not derivable from a dataset generation profile or a restored snapshot — see the note below | Materialized writes stop and affected reads fall back to authoritative calculation; source writes may continue without detailed delta rows only while the minimal source-operation receipt and the required notification slot can commit | Not in Phase 0; Phase 0's obligation is to define W-1, not to supply a number |
| Exact delta ledger rows | 100,000 rows per project, same provisional status | as above | as above | as above | Not in Phase 0 |
| Retained range seals | at most 64 per project, by merging adjacent seals | retain 64 | Not a Phase 0 measurement; it is a fixed structural bound, not data-dependent | Adjacent seals merge | No |
W-1: the timed workload the ledger-retention bounds require¶
The 90-day and 100,000-row delta-ledger limits are expressed per project per unit of time, so replacing them needs a mutation arrival rate, and an arrival rate needs a duration. Neither source this document previously named can supply one:
- The fixed datasets describe a final corpus, not a schedule.
PS-DS-01throughPS-DS-05specify how many studies, decisions and sessions exist when generation finishes; they do not specify over what elapsed period those mutations arrived, nor in what order or mix. Counting the rows a generation profile produces yields a total, and a total divided by an undefined interval is not a rate. - A restored snapshot exposes current documents, not history. The aggregates carry
Audit.LastModifiedand the audit stamps written byOnSaving, which record only the most recent write. An in-place overwrite (Screening.ChangeScreeningDecision,AnnotationQuestion.Update) leaves no prior value and no prior timestamp, so a snapshot cannot be counted to recover how many statistics-affecting mutations a project received per day.
Phase 0's honest position is therefore that it cannot replace the provisional 90-day/100,000-row limits, and this document does not claim to. What Phase 0 owes instead is the definition of the measurement that can, recorded here as harness gap W-1 and scheduled for Phase 1:
| Element | Requirement |
|---|---|
| Duration | A declared wall-clock or simulated-clock window, minimum 24 simulated hours per arm, with the simulated-to-wall-clock ratio recorded in the results artifact |
| Operation mix | Explicit per-operation weights over the mutation families of the mutation-ownership matrix — at minimum screening submit/rescreen, annotation session save and complete, reconciliation submit, slot claim and release, membership change and stage-settings change |
| Arrival model | A deterministic arrival schedule derived from (datasetId, seed), not a live load generator, so a rerun reproduces the same schedule |
| Base corpus | One of the fixed datasets, unchanged, as the starting state; W-1 mutates it rather than generating it |
| Output | Statistics-affecting mutations per project per simulated day, split by family, plus the resulting delta-row count and the age at which the oldest retained row falls out |
Until W-1 executes, the 90-day and 100,000-row values stand as the plan's provisional limits, and any
statement that Phase 0 has measured them would be false. An alternative admissible input would be a
durable historical mutation-event trace — an append-only source that records each statistics-affecting
mutation with its timestamp — but no such trace exists on main, and creating one is product work
outside this programme.
Performance gate baselines¶
The gates are restated verbatim in intent from common performance gates. Each row names the dataset and command that produce its baseline number and the telemetry instrument that will record it in Phase 1 and later. Instrument names are proposals consistent with required telemetry and must be confirmed when the instrumentation is written; every one of them is free of project, membership, investigator and question identifiers, as the plan requires.
| Gate | Baseline dataset(s) | Baseline command(s) | Metric that records it |
|---|---|---|---|
| Materialized response p95 at least 20% lower than its authoritative equivalent | PS-DS-02 primary, PS-DS-03 confirming |
A3, A4, A5 for the authoritative side; the materialized side executes in Phase 1 | syrf.projectstatistics.authoritative.aggregation.duration and syrf.projectstatistics.materialized.read.duration, both histograms supporting p50 and p95 |
| The consumer executes at least 80% fewer authoritative statistics aggregations during a representative shadow or pilot run | PS-DS-02 |
A6 for the pre-change aggregation count per consumer interaction | syrf.projectstatistics.authoritative.aggregation.count |
| Supported source-mutation p95 regression below 10% | PS-DS-05 |
B1 gives the pre-change source-mutation p50/p95; B2 gives the post-change value | syrf.projectstatistics.source.mutation.duration |
| ½/5/10-reviewer concurrency shows bounded transaction retries and write conflicts, and identifies whether the small project summary is actually hot | PS-DS-05 |
B1 then B2, all eight arms | syrf.projectstatistics.transaction.retry.count, syrf.projectstatistics.transaction.unknown_commit.count, syrf.projectstatistics.transaction.write_conflict.count, syrf.projectstatistics.scope.bytes |
| Materialized block and storage growth remains within the Phase 0 capacity model | PS-DS-03, PS-DS-04a, PS-DS-04b |
C1, C2, C4, C5 | syrf.projectstatistics.scope.bytes, syrf.projectstatistics.checkpoint.page.bytes, syrf.projectstatistics.delta.rows |
| Parity or fallback instrumentation adds no unbounded-cardinality series | all datasets | A7 plus a label-cardinality assertion in the results emitter (H-7) that fails if any recorded series carries a project, membership, investigator or question identifier | syrf.projectstatistics.fallback.count, labelled only by bounded reason |
The plan also preserves an earlier screening-candidate measurement — 1,000 studies, legacy p50/p95 of
75.29/99.06 ms against candidate p50/p95 of 69.63/77.47 ms — explicitly as promising local evidence
rather than a pass. That measurement used neither of the datasets defined here and is not comparable
with them; it must not be reused as a gate baseline, and the controlled proof must be rerun on
PS-DS-02.
Parity fixtures¶
Parity fixtures are golden files: the authoritative calculation's output for a fixed dataset, frozen once and thereafter compared exactly. They are the corpus for the plan's correctness gate of "100% exact parity for integer counters/tallies over its representative corpus", including "query-level fixtures against every applicable current StudyStats facet".
Location convention, following the existing embedded-fixture precedent in
src/libs/project-management/SyRF.ProjectManagement.Core.Tests/SyRF.ProjectManagement.Core.Tests.csproj
(<EmbeddedResource Include="Fixtures/Ris/*.ris" />):
src/libs/project-management/SyRF.ProjectManagement.Mongo.Data.Tests/
Fixtures/
ProjectStatistics/
PS-DS-01/
PF-SCR-PROJ-01.json
PF-SCR-MEMB-01.json
...
PS-DS-02/
...
Freezing rule. On the first run for a (fixtureId, datasetId) pair, the runner executes the
authoritative calculation and writes the golden file only when SYRF_STATS_FREEZE_GOLDEN=1 is set.
CI lanes never set it. Any later run compares and fails on mismatch; a golden file is regenerated only
by an explicit, reviewed commit that states which behaviour changed. A missing golden file in a run
without the variable is a failure, not an invitation to create one.
| Fixture ID | Family | Drawn from | Frozen expectation | Comparison rule |
|---|---|---|---|---|
PF-SCR-PROJ-01 |
Project screening | PS-DS-01, PS-DS-02 |
ProjectScreeningStats including the full ScreeningGroupedCount tally, serialised with keys in ascending (NumberScreened, IncludedCount) order |
Exact integer equality on every counter and every tally key; the key set must match exactly, with no key omitted for being zero |
PF-SCR-PROJ-02 |
Project screening, conflict preservation | PS-DS-02 |
The subset of tally keys produced by studies carrying both include and exclude decisions from different reviewers | Exact; the fixture exists specifically to fail if a conflicting combination is ever collapsed or dropped |
PF-SCR-MEMB-01 |
Membership screening | PS-DS-01, PS-DS-02 |
One MembershipScreeningStats per membership, ordered by membership ordinal within the dataset |
Exact integers; the fixture must also assert that no peer reviewer's decisions appear in another reviewer's payload |
PF-ANN-STAGE-01 |
Stage annotation | PS-DS-01, PS-DS-02 |
One StageAnnotationStats per stage, both included/unexcluded and excluded class buckets present |
Exact integers |
PF-ANN-MEMB-01 |
Membership-stage annotation | PS-DS-02 |
One membership-stage payload per membership and stage, covering all three ExcludedSessionStatsGrouping behaviours |
Exact integers |
PF-RVW-SCR-01 |
Reviewer screening | PS-DS-01, PS-DS-02 |
ReviewerScreeningStats screened, available, unavailable and total for every reviewer |
Exact integers; recorded against the membership-screening fixture in the same run so that the plan's aliasing question is answered with evidence |
PF-RVW-ANN-01 |
Reviewer annotation | PS-DS-02 |
ReviewerAnnotationStats per reviewer and stage, including the derived unavailable sum and maximum-in-progress flag |
Exact integers; the boolean flag compared exactly |
PF-Q-TALLY-01 |
Question answers | PS-DS-02 |
Per-question distinct-study count and annotation count, ordered by question identity ordinal, carrying the question definition identifier only; the version component is frozen as explicitly unpopulated, because no question version exists to freeze (D12) | Exact integers |
PF-SRCH-POP-01 |
Search and population | PS-DS-02 |
Per-search recorded study counts and their project-level sum | Exact integers |
PF-DOMREC-01 |
Domain reconciliation | PS-DS-01, PS-DS-02 |
Reconciliation availability, started, in-progress and completed counts per stage | Exact integers |
PF-TALLY-CARD-01 |
Capacity | PS-DS-04a |
The exact set of reachable tally keys and the resulting key count | Exact set equality, so a change in the tally key grammar cannot pass silently |
Tolerance and rounding. Every value in every fixture above is an integer counter or tally and is compared with exact equality; no tolerance is permitted, matching the plan's catalogue rule. The only non-integer value in the current calculation surface is the per-study absolute agreement ratio. Where a ratio must be frozen, it is stored as an explicit numerator and denominator pair of integers and compared exactly; a decimal is stored only where the consumer genuinely receives a decimal, in which case it is rounded half away from zero to six decimal places and compared as a decimal string, and the rounding rule is recorded in the fixture file itself. No implicit tolerance is ever introduced.
Harness gaps¶
Everything below is read-only measurement code. None of it is product code, none of it ships in a service, and none of it may write to any environment other than a disposable Testcontainers instance.
| ID | Missing capability | Minimal read-only harness | Consumed by |
|---|---|---|---|
| H-1 | Parameterised deterministic dataset generator | A class in SyRF.ProjectManagement.Mongo.Data.Tests that takes a dataset ID and seed and derives every GUID and every timestamp from (datasetId, seed, entityKind, ordinal). Study roots are materialized via Study.CreateWithId rather than StudyBuilder.Build(); screenings and sessions go through the real domain methods, because the identifiers and timestamps they inject are minted inside the aggregate rather than in the service wrapper, and the aggregate is what maintains the derived fields the calculations read. What those methods inject is then normalized on the unpersisted corpus and the normalization recorded in the results artifact (see the determinism note above), with a unit test comparing two generated corpora byte for byte. It then inserts them into a fixture-provided database |
A2-A7, B1, B2, C2 |
| H-2 | Duration sampling and percentile reporting | A small recorder that runs a delegate for a configured warm-up and iteration count, collects elapsed ticks, and reports p50, p95, min, max and count. No new package is needed | A3-A5, A7, B1, B2 |
| H-3 | Round-trip and facet counting | A driver ClusterConfigurator subscribing to CommandStartedEvent, counting commands per measured operation and extracting the number of $facet sub-pipelines from the emitted aggregate command |
A6, A7 |
| H-4 | Golden-file parity runner | A loader for embedded Fixtures/ProjectStatistics/** JSON, a canonical deterministic serializer with stable ordering, an exact comparer, and the SYRF_STATS_FREEZE_GOLDEN write mode |
all PF-* fixtures |
| H-5 | Encoded-BSON capacity sizer | A helper computing the conservative static upper bound described above, comparing it against BsonDocument actual encoded length, and asserting the admission decision at and just past each ceiling |
C1-C5 |
| H-6 | Concurrency driver | A barrier-synchronised multi-writer runner over MongoDbReplicaSetTestFixture, classifying MongoDB command exceptions into write conflict, transient transaction error and unknown commit, and recording per-arm summaries |
B1, B2 |
| W-1 | Timed mutation workload for ledger-retention sizing | The duration, operation mix, arrival model, base corpus and outputs specified in W-1. Executes in Phase 1; without it the 90-day and 100,000-row delta-ledger limits cannot be replaced | delta ledger age and rows |
| H-7 | Results emitter | A writer producing one machine-readable JSON artifact per run under a run-scoped output directory, containing dataset ID, seed, host and container image, iteration counts, and every recorded metric, plus a label-cardinality assertion that fails if any series carries a project, membership, investigator or question identifier | A3-A7, B1, B2, C1-C5 |
Counting the commands in this document: three are runnable against the repository as it stands today
(the existing integration-fixture check A1, the manual explain and timing script A8, and the
in-repo deterministic seeder invocation), and thirteen depend on one or more of the seven harness gaps
above. Two of the thirteen — B2 and the observation-payload portion of C5 — additionally require the
Phase 1 projection path and are marked as executing in Phase 1.
Unresolved evidence-backed decisions¶
D1. Where the harness lives¶
Evidence. A repository-wide search for BenchmarkDotNet returns no matches, and there is no
benchmark project of any kind. src/libs/project-management/SyRF.ProjectManagement.Mongo.Data.Tests
already references both SyRF.ProjectManagement.Mongo.Data and SyRF.Testing.Common and already
binds the shared MongoDB fixture through its own MongoDbTestCollection.cs.
Impact. A new project changes syrf.sln, the solution filters, the dependency map and every CI
filter that enumerates projects; an existing project does not.
Options. (a) Add a SyRF.ProjectStatistics.Benchmarks project with BenchmarkDotNet. (b) Add the
harness as additional test classes inside the existing SyRF.ProjectManagement.Mongo.Data.Tests.
© Write standalone shell and mongosh scripts under scripts/.
Recommended default: (b). It reuses the fixture, container policy, CSUUID configuration and lane plumbing unchanged, and needs no solution or dependency-map change. BenchmarkDotNet's strengths are in-process microbenchmarking; these measurements are dominated by MongoDB round trips, so a simple percentile recorder (H-2) is sufficient and avoids a new dependency.
D2. Trait and CI-filter collision¶
Evidence. The PR unit lanes run dotnet test syrf.sln --filter "Category!=Integration"
(.github/workflows/pr-tests.yml), and the main integration lane runs
dotnet test syrf.sln --filter "Category=Integration" with a pinned 25-minute timeout
(.github/workflows/ci-cd.yml). A benchmark tagged Category=Benchmark is therefore executed by
every PR; a benchmark tagged Category=Integration is executed on every push to main.
Impact. Either tagging silently adds many minutes of container and aggregation work to a lane
sized for something else, and the integration lane's budget is pinned exactly by
.github/scripts/test-main-dotnet-integration-runner-routing.sh, so an overrun is a CI failure rather
than a slow run.
Options. (a) Category=Benchmark plus amending both lane filters to exclude it, which requires
updating the pinned routing contracts. (b) Category=Integration plus a second trait and an amended
main filter. © No trait; gate execution on an environment variable so the tests skip by default.
Recommended default: © now, (a) later. An environment-variable skip (SYRF_STATS_DATASET unset
means skip) needs no workflow change at all, which keeps Phase 0 documentation-only and touches no
pinned contract. If the benchmarks later need to run on a schedule, promote to (a) in the same PR that
updates the routing contract.
D3. Whether local prod-snapshot timings are admissible as baselines¶
Evidence. The snapshot is described in CLAUDE.md and docs/architecture/mongodb-reference.md as
a 2026-02-21 restore held in a standalone local container. On 2026-09-01 it was verified unavailable:
the mongodb-syrf-local MCP server returned "The configured connection string is not valid", and
docker ps -a listed no snapshot container. No production-shaped study-count distribution could
therefore be measured for this document, which is why stats-large-v1 is sized by judgement rather
than by evidence.
Impact. Snapshot timings would be prod-shaped but are single-host, single-restore, uncontrolled for cache state, and cannot be regenerated identically by a reviewer. Using them as gate baselines would make the 20% and 10% thresholds unreproducible.
Options. (a) Admit snapshot timings as gate baselines. (b) Exclude them entirely. © Admit them as advisory shape checks recorded separately from gate baselines.
Recommended default: ©. The synthetic datasets are the normative baseline for every gate. If the snapshot container is restored, run A8 against it read-only to check that the synthetic datasets' document sizes and per-project study counts are in the right range, and record the result in a clearly separate advisory section that no gate cites. Any such run stays read-only and records only aggregate shape — never a project identifier, title, or person.
D4. Whether any plan bound should be lowered now¶
Evidence. The reachable screening tally key count is exactly (K + 1)(K + 2) / 2 for a maximum of
K decisions on one study, so 50,000 keys require K >= 315. The conservative encoding arithmetic
puts 50,000 keys at roughly 1.53 MiB, about one fifth of the 8 MiB document ceiling. Neither figure
has yet been validated by an executed test, because C1 needs harness H-5.
Impact. Lowering a hard ceiling on unvalidated arithmetic risks rejecting writes that the real encoding would have admitted; leaving an unreachable ceiling in place risks the guard never firing in any test that uses real data.
Options. (a) Lower the dynamic-key ceiling now. (b) Change nothing in Phase 0. © Keep the 50,000 hard ceiling and add a measured per-family soft limit that is reachable in tests.
Recommended default: ©. Retain 8 MiB and 50,000 as the hard admission ceiling, and propose a
screening-family soft limit of 8,192 keys — comfortably above the 7,381 that 120 reviewers can
produce, and low enough to be reached by natural source data. PS-DS-04a alone cannot exercise it,
because 7,381 keys never reaches the limit; the soft limit is therefore proposed rather than measured
until C2 runs PS-DS-04c (K = 126, 8,128 keys, admitted) and PS-DS-04d (K = 127, 8,256 keys,
refused) either side of it. Confirm or revise both after C1 and C2 execute.
Make no change to the 500-move, 100-document, 256 KiB point-path bounds or the 500-reference,
256 KiB page bounds in Phase 0: the proposals of 128 moves and 32 documents in the capacity model are
recorded as candidates and must not be adopted until Phase 1 can measure the real canonicalizer.
D5. Which fixture hosts which measurement¶
Evidence. MongoDbTestFixture runs mongo:8.0 standalone and cannot serve transactions;
MongoDbReplicaSetTestFixture ran mongo:7.0 as a single-node replica set and can. The repository
test project that owns the aggregation under test binds the standalone fixture.
Impact. Running read baselines on one server version and write benchmarks on another makes the source-mutation regression gate compare across MongoDB versions.
Options. (a) Move everything to the replica-set fixture. (b) Split by need and accept the version
skew. © Split by need and align the replica-set fixture's image to mongo:8.0.
Recommended default: ©. Phase 0's authoritative read baselines need no transactions and should
run on the standalone mongo:8.0 fixture that already hosts the code under test. The concurrency
protocol needs the replica set — and that is exactly where deferring the image alignment to Phase 1
breaks the gate. B1 runs on the replica-set fixture in Phase 0; if that fixture is still mongo:7.0
and is upgraded to mongo:8.0 before B2 runs in Phase 1, the <10% source-mutation regression gate
compares a MongoDB 7 baseline against a MongoDB 8 measurement, which is the comparison this decision
exists to prevent. Recording the tags makes the mismatch visible; it does not make the two numbers
comparable.
Resolved, and done. MongoDbReplicaSetTestFixture is aligned to mongo:8.0 in this branch, ahead
of any B1 run, so both arms of every gate share a server version and the contingency this decision
described — re-running B1 in Phase 1 and discarding the 7.0 numbers — does not arise. It was a
one-line image change; the fixture's only consumer,
src/services/api/SyRF.API.Endpoint.Tests/SubmitAnnotationSessionServiceTests.cs, was run against the
new image and passes 15/15 with the replica set started and transactions exercised, so no dependency
on 7.0 behaviour exists. Every results artifact still records the image tag and the reported server
version, so a cross-version comparison cannot be made by accident even after a future bump. This
alignment is a fixture change, not a product change.
D6. How results are recorded and reviewed¶
Evidence. No results artifact convention exists; the integration lane already uploads .trx and
coverage artifacts, but neither carries custom metrics.
Impact. Without a machine-readable artifact, gate evidence exists only in scrollback, and the plan's requirement to compare a post-change p95 against a recorded baseline cannot be audited.
Options. (a) Prose only, appended to this document. (b) A JSON artifact per run plus a summary table appended here. © Push metrics to an observability backend.
Recommended default: (b). H-7 emits one JSON file per run capturing dataset ID, seed, container image, server version, host CPU count, iteration counts and every metric; a short human-readable summary table is appended to this document when each baseline is executed. Option © belongs with the Phase 1 telemetry work, not with the Phase 0 baseline.
Results¶
Harness status¶
Harnesses H-1 (deterministic dataset generator), H-2 (duration sampling and percentile reporting),
H-3 (round-trip and facet counting) and H-7 (results emitter) are implemented, in
src/libs/project-management/SyRF.ProjectManagement.Mongo.Data.Tests/ProjectStatistics/, per
decision D1's recommended default (b). Commands A2-A7 therefore run today. H-4 (golden-file parity
runner), H-5 (encoded-BSON capacity sizer) and H-6 (concurrency driver) are not implemented, so the
PF-* fixtures, C1-C5 and B1/B2 remain unexecuted; PS-DS-04b has no generator because it is a
pure encoding fixture consumed only by H-5.
Following decision D2's recommended default ©, the harness carries no xUnit trait and skips
unless SYRF_STATS_DATASET is set. No workflow, filter or pinned routing contract was changed, and
an ordinary dotnet test run reports these six tests as skipped. The generator's own unit tests do
run in the ordinary lanes.
The byte-identity contract stated under Fixed benchmark datasets is
enforced rather than asserted. Deriving the generator's own identifiers from
(datasetId, seed, entityKind, ordinal) is not sufficient on its own: the domain aggregates mint
identifiers of their own (a join request, a screening decision) and stamp wall-clock timestamps
(Entity.DateTimeCreated, Project.CreationDate, Audit.Created/LastModified, a session's
CreatedAtUtc/ReservedAtUtc/CompletedAtUtc), so two runs persisted different BSON while
reporting the same fingerprint. DeterministicCorpusNormalizer now rewrites those identifiers from
the dataset seed, repoints every reference to a rewritten one, and freezes every timestamp to one
fixed instant while leaving absent timestamps absent — a session's completion time is state, not a
clock reading. A unit test serializes two independently generated corpora with the production
BsonClassMaps and compares the persisted bytes, so the contract is proved on the corpus that is
actually written rather than on the subset the fingerprint happens to cover.
PS-DS-04c and PS-DS-04d are generatable: both specs exist, and unit tests generate each corpus in
full and assert it reaches 8,128 and 8,256 distinct tally keys respectively, which is what makes them
the pair that straddles the proposed 8,192-key soft limit. C2 itself still cannot run — it needs H-5
(the encoded-BSON capacity sizer), which is not implemented, and its command passes the three dataset
IDs as one comma-separated value, which the single-valued SYRF_STATS_DATASET contract does not yet
parse. Both belong with H-5 rather than here; the datasets they consume are ready.
There is no option to leave a seeded container resident after a run. MongoDbTestFixture owns the
container, disposes it with the collection, and enables neither Testcontainers reuse nor an opt-out
from the resource reaper, so the earlier SYRF_STATS_KEEP_CONTAINER flag could only record a request
it was unable to honour; it has been removed from the harness and from command A2. To inspect a
corpus, point a client at the connection string command A2 prints while the run is still in progress,
or generate it into a container you started yourself.
Measured authoritative baselines¶
One real run of A3-A6, executed on 2026-09-02 against the generator as it stands at the head of this
branch — that is, after the reconciliation eligibility floor, which raises the annotation-session
counts below relative to any earlier recorded run. Every number below is measured, not estimated, and
every one of them is reproduced by the JSON artifacts checked in beside this document at
evidence/phase0-baselines/ — one file per dataset per measurement:
The artifacts, not the tables below, are the auditable record; the tables are the human-readable
summary of the same run. Each file carries the container image, server version, host CPU count,
whether the container resource caps were applied, the iteration counts, the generated corpus's row
counts, the corpus fingerprint, and every recorded duration and round-trip series. They contain no
identifiers and no clinical content: the corpus is synthetic, and the results emitter refuses to
write a metric name carrying an entity identifier. A run emits them under the test project's output
directory (bin/<configuration>/net10.0/benchmark-results/, or SYRF_STATS_RESULTS_DIR when set);
these copies are that run's output, renamed to a stable file name so a later run can be diffed
against them.
Environment. Local developer workstation, Ubuntu 22.04 (kernel 6.8.0-124), .NET 10.0.0,
48 host CPUs, Docker 29.6.0. MongoDB mongo:8.0, reported server version 8.0.28, standalone,
started by MongoDbTestFixture (decision D5's Phase 0 placement). SYRF_TEST_JOB_KEY was unset, so
DockerTestContainerPolicy's 2 vCPU / 2 GiB / 256 PID caps were not applied. These are therefore
unconstrained local numbers and are not comparable with a capped CI run; the inventory table
already records that caveat.
Corpus actually generated.
| Dataset | Studies (total / target project) | Embedded screening decisions | Annotation sessions | Distinct tally keys |
|---|---|---|---|---|
PS-DS-01 |
350 / 300 | 568 | 682 | 10 |
PS-DS-02 |
5,400 / 5,000 | 14,119 | 24,509 | 21 |
Both tally-key counts equal the value the dataset table declares. The screening-decision counts are
higher than the table's approximations (450 and 12,500) because the generator guarantees coverage of
every reachable (NumberScreened, IncludedCount) key before it fills the zero-screening and
conflicting-decision quotas; the table's figures were approximations, and these are the measured
truth for this generator version.
Timings. 100 measured iterations after 10 warm-up iterations, nearest-rank percentiles, milliseconds.
| Command | Measurement | Dataset | p50 | p95 | min | max |
|---|---|---|---|---|---|---|
| A3 | GetFullProjectStatsAsync |
PS-DS-01 |
69.11 | 85.73 | 44.20 | 100.27 |
| A3 | GetFullProjectStatsAsync |
PS-DS-02 |
968.30 | 1413.97 | 880.14 | 1507.51 |
| A4 | GetReviewerStatsForStageAsync, screening stage |
PS-DS-01 |
12.46 | 16.58 | 6.77 | 21.34 |
| A4 | GetReviewerStatsForStageAsync, screening stage |
PS-DS-02 |
41.79 | 51.13 | 33.77 | 54.72 |
| A4 | GetReviewerStatsForStageAsync, annotation stage |
PS-DS-01 |
26.46 | 32.94 | 17.46 | 36.64 |
| A4 | GetReviewerStatsForStageAsync, annotation stage |
PS-DS-02 |
60.36 | 68.66 | 42.29 | 74.33 |
| A4 | GetReviewerStatsForProjectAsync |
PS-DS-01 |
50.19 | 60.29 | 35.89 | 80.48 |
| A4 | GetReviewerStatsForProjectAsync |
PS-DS-02 |
143.87 | 162.53 | 115.85 | 174.95 |
| A4 | GetStudyReviewStatus, screening stage |
PS-DS-01 |
15.66 | 19.38 | 6.54 | 21.20 |
| A4 | GetStudyReviewStatus, screening stage |
PS-DS-02 |
37.19 | 45.45 | 27.02 | 49.44 |
| A4 | GetStudyReviewStatus, annotation stage |
PS-DS-01 |
14.35 | 18.99 | 7.03 | 25.24 |
| A4 | GetStudyReviewStatus, annotation stage |
PS-DS-02 |
30.02 | 40.52 | 24.66 | 44.42 |
| A5 | GetAnnotationQuestionAnswerTally |
PS-DS-01 |
5.79 | 8.05 | 4.12 | 16.01 |
| A5 | GetAnnotationQuestionAnswerTally |
PS-DS-02 |
79.49 | 113.04 | 72.19 | 126.03 |
| A5 | search-population sum | PS-DS-01 |
1.27 | 1.89 | 0.91 | 1.98 |
| A5 | search-population sum | PS-DS-02 |
1.54 | 1.93 | 0.85 | 2.11 |
Round trips and facets (A6). Counted from a CommandStartedEvent subscriber on the driver's
ClusterConfigurator, one un-recorded call first so connection establishment is not counted.
| Measurement | Dataset | Driver round trips | $facet sub-pipelines |
|---|---|---|---|
GetFullProjectStatsAsync |
PS-DS-01, PS-DS-02 |
1 | 15 |
GetReviewerStatsForStageAsync, screening stage |
PS-DS-01, PS-DS-02 |
4 | 0 |
GetReviewerStatsForStageAsync, annotation stage |
PS-DS-01, PS-DS-02 |
8 | 0 |
GetReviewerStatsForProjectAsync |
PS-DS-01 (3 stages) |
25 | 0 |
GetReviewerStatsForProjectAsync |
PS-DS-02 (5 stages) |
39 | 0 |
GetStudyReviewStatus, either review mode |
PS-DS-01, PS-DS-02 |
4 | 0 |
GetAnnotationQuestionAnswerTally |
PS-DS-01, PS-DS-02 |
1 | 0 |
| search-population sum | PS-DS-01, PS-DS-02 |
1 | 0 |
The measured 15 sub-pipelines confirm the catalogue's description of the full-project statistics
query as a single 15-facet $facet. The reviewer-stats fan-outs are the opposite shape: one
countDocuments per counted predicate, which the driver emits as an aggregate command, so the
project-scoped call grows linearly with stage count — 25 round trips over three stages and 39 over
five. That is the concrete pre-change figure the plan's "at least 80% fewer authoritative statistics
aggregations" gate must be measured against.
Measuring the stage-scoped call once per review mode was not a formality: the annotation branch
issues eight round trips where the screening branch issues four, and is correspondingly slower at
both scales. A single stage-scoped number would have understated the annotation path by a factor of
two. GetStudyReviewStatus issues four counts in both review modes, so its round-trip cost does not
vary with review mode even though its duration does.
What these numbers do and do not establish¶
- They are the authoritative-side baseline for the first two performance gates on
PS-DS-01andPS-DS-02.PS-DS-03's confirming run, and every gate whose subject is projection code, remain unexecuted because that code does not exist. - They were taken on an uncapped local host. Before any gate is judged, the same commands must be
rerun in the environment that will host the comparison, and both runs' recorded
containerImage,serverVersion,hostProcessorCountandcontainerResourceCapsAppliedfields must match. - Each recorded artifact carries the corpus fingerprint of the run that produced it. A later run
whose fingerprint differs is measuring a different corpus, and its numbers are not comparable with
these however similar they look. Every
PS-DS-01artifact above recordsF11E12925C4D4F512244F702F58A8BEA97185C558349BA8670FA7BD76406DB95and everyPS-DS-02artifactD0D56518026AAE4659B141C95E6F7D5380FA4084E20A2A618F565BA41F4A56EA. Earlier runs of the same commands in separate processes agreed with each other on the value for the generator version they ran, which is the reproducibility contract holding across processes rather than only within one; the value changed here because the reconciliation eligibility floor changed the corpus, which is exactly what a fingerprint is for.