From 87635122c75054df213913070c0a5571826c6488 Mon Sep 17 00:00:00 2001 From: Nick Cipollina Date: Wed, 5 Aug 2026 16:35:15 -0400 Subject: [PATCH 1/5] docs(docs): consolidate architecture/roadmap docs and redesign benchmark strategy Milestone 8 Phase 5: absorb docs/architecture.md, docs/performance.md, docs/design-principles.md, and docs/manifesto.md's real content into the architecture/ hierarchy (design-principles, current/*, decision-log), tombstone all five pre-existing legacy pages (public-api.md included) so existing ADR links stay resolvable, write real roadmap/ content, and retire the "(legacy)" mkdocs.yml nav entries. Also adds ADR-0034: a full redesign of the benchmark suite's strategy, replacing the milestone-by-milestone accretion in benchmarks/Compono.Benchmarks with an audience-driven category taxonomy (Implementation Strategies, Consumer Scenarios, External Comparison, Feature Overhead, Scalability, Source Generation), a reused representative-model set, fair-comparison rules (handwritten construction as the theoretical ceiling, cached vs. uncached reflection as separate honest baselines), and explicit reporting rules - implementation of the suite itself is tracked as PLAN-0008 Phase 5 Part B, not yet done. Co-Authored-By: Claude Sonnet 5 --- ...4-benchmark-suite-strategy-and-redesign.md | 367 ++++++ docs/adr/README.md | 1 + docs/architecture.md | 1149 +---------------- .../current/deterministic-seeding.md | 78 +- .../current/generated-plans-and-discovery.md | 87 +- docs/architecture/current/performance.md | 320 ++++- .../architecture/current/provider-pipeline.md | 137 +- .../architecture/current/source-generation.md | 91 +- docs/architecture/decision-log.md | 53 +- docs/architecture/design-principles.md | 113 +- docs/architecture/index.md | 35 +- docs/design-principles.md | 31 +- docs/documentation-architecture.md | 39 +- docs/manifesto.md | 155 +-- docs/packages/compono.md | 4 +- docs/performance.md | 334 +---- ...002-milestone-2-core-composition-engine.md | 6 +- docs/plans/0008-milestone-8-public-preview.md | 138 +- docs/public-api.md | 706 +--------- docs/roadmap/future-packages.md | 44 +- docs/roadmap/index.md | 30 +- docs/roadmap/proposed-adrs.md | 19 +- mkdocs.yml | 5 - 23 files changed, 1510 insertions(+), 2432 deletions(-) create mode 100644 docs/adr/0034-benchmark-suite-strategy-and-redesign.md diff --git a/docs/adr/0034-benchmark-suite-strategy-and-redesign.md b/docs/adr/0034-benchmark-suite-strategy-and-redesign.md new file mode 100644 index 0000000..1d42902 --- /dev/null +++ b/docs/adr/0034-benchmark-suite-strategy-and-redesign.md @@ -0,0 +1,367 @@ +# [ADR-0034] Benchmark Suite Strategy and Redesign + +**Status:** Accepted + +**Date:** 2026-08-05 + +**Decision Makers:** Nick Cipollina (solo, confirmed through direct discussion) + +## Context + +`benchmarks/Compono.Benchmarks` grew organically across Milestones 1–2: +`ArchitectureBenchmarks`/`EcosystemBenchmarks` exist to answer one +Milestone 1 exit criterion (does generated construction beat a +reflection baseline, for a single flat `Leaf` type); +`ResolutionArchitectureBenchmarks`/`ResolutionEcosystemBenchmarks`/ +`ResolutionBenchmarks` repeat that shape once Milestone 2 made nested +composition real, against a `Customer`/`Address` graph invented for that +milestone's own Execution Flow example; `DeepGraphBenchmarks` exists +solely to trigger `CompositionTraceBuffer`'s `Array.Resize` path, a +one-off PR #13 review artifact. Each class answers a real question that +mattered *at the time it was written*, but the result is eight files with +no shared model set, no consistent categorization, no source-generator +build-time coverage, no consumer-scenario coverage (a real `[Compose]` +row, a profile with providers active), and no CI job running any of it — +a milestone-by-milestone accretion, not a designed suite. This became +visible while reviewing Milestone 8 Phase 5's plan to simply *document* +these existing benchmarks under `architecture/current/performance.md`: +documenting an undesigned suite would publish that lack of design as if +it were intentional. + +This ADR replaces that suite's questions and structure from first +principles, discussed and confirmed directly rather than designed in +isolation: build/source-generator benchmarks stay in the same +BenchmarkDotNet project (in-process `GeneratorDriver` invocations, not a +separate toolchain); the suite is structured to make a future CI +regression gate straightforward, but no such gate is stood up in this +phase — that is deliberately deferred, separate scope. + +## Decision Drivers + +- The suite should answer engineering questions first, and marketing/ + documentation questions second — a benchmark that only exists to + produce a favorable number for `README.md` is a benchmark that + shouldn't exist. +- Every benchmark must have a stated purpose; a benchmark that no longer + answers a meaningful question should be deleted, not kept out of + inertia. +- AutoFixture comparisons are one data point among several audiences, + not the suite's organizing principle — this repo already carries an + explicit non-goal against comparative marketing claims + ([ADR-0030 Amendment 2](0030-compono-documentation-architecture.md#amendment-2-2026-08-04-resolving-milestone-8s-remaining-open-items)'s + benchmark-claims policy), and the suite's *shape* should reflect that, + not just the prose around it. +- A result unfavorable to Compono (cached reflection wins a scenario, + AutoFixture wins a scenario) must be published and explained, not + dropped or reframed — honesty is a harder bar than reproducibility + alone. +- The suite must remain a permanent engineering asset — reused models, + stable benchmark names, and a structure a future CI regression gate + can adopt without a second redesign. +- Reflection, as a baseline, must represent what a competent hand-rolled + alternative would actually do (caching reflection metadata), not a + naive strawman that makes Compono look better than a fair comparison + would — the same "baseline parity" lesson PR #13 review already + learned the hard way for randomness cost, generalized into a + permanent rule rather than a one-off fix. + +## Considered Options + +1. **Incrementally patch the existing 8 files** — add a few new + benchmark classes (build-time, consumer scenario) alongside what + already exists, leaving `ArchitectureBenchmarks`/`EcosystemBenchmarks`/ + `ResolutionArchitectureBenchmarks`/etc. in place. +2. **A flat, larger benchmark list** — design new categories of + questions (as below) but keep every benchmark as an independent, + uncategorized class in one folder, matching the project's current + physical layout. +3. **A fully redesigned suite**, organized into explicit categories by + audience and question, built on one reused model set, with explicit + fair-comparison rules, replacing every existing benchmark class + rather than layering on top of them. + +## Decision Outcome + +Chosen option: **3 — a fully redesigned suite**, replacing all 8 existing +benchmark files. Patching (Option 1) would leave the actual defect (no +coherent question set, milestone-specific models) untouched — the new +categories would sit next to old ones answering superseded milestone +questions, which is exactly the accretion problem this ADR exists to +fix. A flat list of new categories (Option 2) fixes the *question* gap +but not the *model* gap — without one reused representative model set, +each new category would reinvent its own types the same way `Leaf` and +`Customer`/`Address` were invented, and the suite would still lack a +structure a future contributor could extend predictably. + +### Suite philosophy + +The suite exists to answer engineering questions first, and marketing/ +documentation questions second. Every benchmark class states the +question it answers (in its own summary XML doc, matching this repo's +existing documentation convention) — a benchmark that stops answering a +meaningful question gets deleted in the PR that makes it meaningless, not +left to accumulate. A result unfavorable to Compono is published exactly +like a favorable one: isolated, explained, and left for a maintainer to +judge whether it's worth optimizing — never dropped, hidden, or reframed. + +### What this suite is (and isn't) + +`BenchmarkDotNet` benchmarks answer narrow, **comparative engineering +questions** — "does approach A cost more than approach B, for this one +operation, isolated from everything else" — under an artificially clean +environment (JIT-warmed, GC-isolated, single-operation iteration). +They are **not**: + +- A substitute for full-application performance testing (an app's actual + hot path includes far more than one composition call). +- A scalability or load test (no concurrency, no sustained throughput, + no resource contention — see Scalability below for what this suite + *can* say about growth, which is still single-threaded and isolated). +- A guarantee about any specific consumer's real-world numbers — every + published result is a data point from one environment (disclosed in + full, per Reporting Rules below), not a promise. + +This section exists so a reader of the published results (maintainer or +consumer) calibrates expectations correctly, rather than reading a +microbenchmark's nanosecond figure as "how fast my test suite will run." + +### Representative models + +`Models/` holds the canonical representative types every category draws +from, instead of each category inventing its own (the mistake this ADR +corrects) — the common language the rest of this ADR's categories and +rules are defined in terms of: `SimplePoco` (flat, no dependencies — +replaces `Leaf`), `MediumAggregate` (one nested composable dependency + +every built-in kind + a collection member — replaces `Customer`/ +`Address`), `DeepGraph` (an N-level chain — replaces `DeepLevel1`- +`DeepLevel8`), `LargeCollection` (a model with a large collection +member, for Scalability), `SharedValueGraph` (a sibling-parameter shape +for Consumer Scenarios' shared-value case), and `ProviderBackedModel` +(an interface-typed member NSubstitute can satisfy, plus a +convention-matching `string` member Bogus can satisfy). A category adds +a new model only when none of these already represents its question — +not as a matter of course. + +### Benchmark categories + +Six benchmark categories (folders), each answering a distinct question +for a distinct audience, drawing on the representative models above, +plus cross-cutting Fair Comparison and Reporting rules that apply to all +of them: + +1. **Implementation strategies** (`ImplementationStrategies/`) — + maintainer-facing: how close does generated composition come to + handwritten construction? What does runtime reflection actually cost, + cached and uncached? Did an implementation change regress + performance? These compare **implementation techniques for the same + job**, not competing frameworks, ordered against a single theoretical + upper bound: + + ```text + Handwritten construction (theoretical ceiling — hand-authored, no + abstraction cost at all) + ↓ + Generated composition (Compono's actual mechanism — the number + that matters: how close to the ceiling?) + ↓ + Cached reflection (a competent hand-rolled alternative that + memoizes constructor/member metadata — + the fair "should Compono use reflection + instead" comparison) + ↓ + Uncached reflection (the naive case — shows what caching alone + buys, kept as its own baseline rather + than folded into "reflection") + ``` + + Handwritten construction is the **theoretical upper bound**, not just + another baseline — generated composition is always evaluated against + how close it gets to that ceiling, not against reflection as if + reflection were the target. Reflection (cached and uncached) is a + second, independent comparison this category also answers: is + Compono's own generated approach actually justified over a realistic + reflection-based alternative? Expression-tree-based construction is a + future candidate for this same ordering if ever explored, not built + now. +2. **Consumer scenarios** (`ConsumerScenarios/`) — "what performance + should a user expect in realistic applications?" A simple POCO, a + medium aggregate, a deep object graph, large collections, shared + values, a `Compono.Bogus`-enabled profile, a `Compono.NSubstitute`- + enabled profile — realistic usage, not isolated mechanism cost. This + is the category most likely to surface in public documentation. +3. **External comparison** (`ExternalComparison/`) — AutoFixture belongs + here, as one comparison point answering "what should a developer + expect when migrating," not the suite's center. Equivalent object + graphs, equivalent work, both directions published honestly: if + AutoFixture wins a scenario, that result ships too. +4. **Feature overhead** (`FeatureOverhead/`) — isolates the incremental + cost of one Compono feature at a time via additive layering: generated + composition alone → + shared values → + member rules → + type rules → + + providers → + `UseBogus()` → + `UseNSubstitute()`. Answers "how + expensive is this one feature, on its own?" +5. **Scalability** (`Scalability/`) — performance as complexity grows: + `CreateMany` at 1/10/100/1000, shallow vs. deep graphs, growing + collection sizes. Exists to catch **algorithmic** regressions + (super-linear growth), not just constant-factor ones — this is where + `DeepGraphBenchmarks`' original question (does a deep enough graph + trigger `CompositionTraceBuffer`'s resize path) actually lives now, + generalized into a real shallow-vs-deep comparison instead of a + one-off artifact. Still single-threaded and isolated per "What this + suite is (and isn't)" above — not a substitute for a real load test. +6. **Source generation** (`SourceGeneration/`) — a separate concern from + runtime performance: clean vs. incremental generation cost, across a + matrix of composable-type counts, measured in-process via Roslyn's + `GeneratorDriver`/`CSharpGeneratorDriver` — same BenchmarkDotNet + project, not a separate timing harness (confirmed directly: staying + in one project keeps one report format and one reproduction story, + and BenchmarkDotNet can benchmark arbitrary in-process code, including + a generator driver run). Primarily serves maintainers. + +### Fair comparison rules + +1. **Baseline parity.** Any non-Compono baseline (handwritten, reflection, + AutoFixture) does equivalent real work to what Compono actually does + for that model — same output shape, same randomness cost where + randomness is part of the comparison — never a placeholder or + simplified alternative. (This is the existing `ReflectionComposer` + lesson from PR #13 review, generalized from a one-off fix into a + permanent rule every new baseline is held to.) +2. **Reflection means two baselines, not one.** "Reflection" is always + reported as **cached** (a realistic hand-rolled composer that + memoizes `ConstructorInfo`/member metadata per type) and **uncached** + (naive, re-reflecting every call) — never conflated into a single + "reflection" number. This is one of the strongest design decisions in + this suite: comparing against a *competent* reflection implementation, + not a strawman, is what makes a result honest. If cached reflection + legitimately wins a scenario, that result is published exactly like + any other — it's valuable information about whether Compono's + generated-code overhead is actually worth paying in that shape. +3. **Handwritten construction is the ceiling, not a baseline among + equals.** Every Implementation Strategies comparison reports Generated + against Handwritten first (how close to the theoretical floor?), then + against Cached/Uncached Reflection second (is Compono's approach + justified over the realistic alternative?) — never presented as if + beating reflection were the goal on its own. +4. **Comparisons stay inside their category.** A number from one + category (e.g. Implementation Strategies' isolated construction- + dispatch cost) is never directly compared against a number from a + different category (e.g. a Consumer Scenario's end-to-end cost) — + only benchmarks on the same model, in the same category, from the + same run are compared to each other. +5. **Honest publication.** A result unfavorable to Compono is published + exactly like a favorable one — isolated, explained, left for a + maintainer to judge whether it's worth optimizing. +6. **Every benchmark states its question**, in its own class-level XML + doc summary — a project convention (checked in review), not just + aspirational text. + +### Reporting rules + +Every published benchmark reports the same fixed set of columns — no +future documentation gets to cherry-pick a single favorable metric out +of a richer result set: + +- **Mean** +- **Error** and **StdDev** (BenchmarkDotNet's own noise-characterization + columns — a Mean without them is not a trustworthy number) +- **Ratio** against the category's designated baseline, where the + category has one (Implementation Strategies' Handwritten/Generated/ + Cached/Uncached Reflection rows; External Comparison's AutoFixture + rows) — omitted only where no baseline is meaningful (e.g. Scalability's + intrinsic `CreateMany` batch-size comparison, which is already a ratio + against its own `count=1` case) +- **Allocated bytes**, and **Gen0**/**Gen1**/**Gen2** collection counts + where BenchmarkDotNet reports them as nonzero — `[MemoryDiagnoser]` is + mandatory on every benchmark class in every category, full stop, not a + per-class judgment call. Memory behavior matters as much as throughput + for test infrastructure that runs constantly. +- **Full environment disclosure** on every published result — .NET + version, architecture, OS, Release configuration, BenchmarkDotNet job — + already existing practice, generalized here into a permanent rule + every category follows, not just the ones written so far. + +A page that reports Mean alone (or Mean and Allocated alone) is not +meeting this bar, even if every other rule in this ADR is followed. + +### Regression-detection readiness, not automation + +Per direct discussion: this phase structures the suite so a future CI +regression gate is straightforward — stable, permanent benchmark class/ +method names (renaming breaks historical comparability), and full +BenchmarkDotNet result artifacts (`*-report-github.md`/`.csv`/`.html`) +produced per category — but does **not** stand up that gate now. A real +CI job comparing against a stored baseline needs a runner with +consistent hardware, baseline storage/versioning, and a noise-tolerance +threshold policy this repo's CI doesn't have yet — that's separate, +future scope (see `docs/roadmap/future-packages.md`'s sibling page, +`docs/roadmap/post-mvp.md`, for where a concrete future candidate like +this gets tracked once there's real evidence it's needed). + +### Positive Consequences + +- One coherent question set per audience (maintainers, consumers, + migrators), instead of milestone-specific artifacts a new reader has + to reverse-engineer the history of to understand. +- A reused model set makes adding a new benchmark cheap and consistent, + rather than inventing a new bespoke type each time. +- The cached-vs-uncached reflection split, evaluated against handwritten + construction as the explicit ceiling, answers the implementation- + strategy question ("should Compono reach for reflection anywhere, and + how close does generated code get to the theoretical best case") + more honestly than the old suite's single naive baseline with no + stated ceiling at all. +- Public performance documentation can be capability-oriented (what does + a consumer actually experience) without either fabricating numbers or + drowning the reader in maintainer-facing internals. + +### Negative Consequences + +- A full rewrite discards the old suite's git history for each + individual benchmark number (mitigated: the historical figures already + published in ADRs/plans stay exactly as recorded — this ADR doesn't + retroactively invalidate past `Accepted` decisions that cited them, + only replaces the suite going forward). +- More benchmark classes overall (six categories plus baselines/models) + means more to keep green and update when the engine's shape changes — + accepted, since the alternative (the old accreted suite) already had + this cost without the benefit of a coherent structure. +- No automated regression gate yet — a real regression could still land + undetected until someone manually reruns the suite. Accepted per the + direct discussion above: standing up that gate is separate, future + scope, not blocked on but also not solved by this ADR. + +## Pros and Cons of the Options + +### Option 1: Incrementally patch the existing 8 files + +- Good, because it's the smallest diff. +- Bad, because it doesn't remove the actual defect (milestone-specific + models, no categorization) — new, well-designed benchmarks would sit + next to old ones answering superseded questions. + +### Option 2: Flat list of new categories, no reused model set + +- Good, because it fixes the question-coverage gap (build-time, consumer + scenarios) without a full rewrite. +- Bad, because each new category would still invent its own one-off + types, reproducing the exact problem (`Leaf`, `Customer`/`Address`, + `DeepLevel1`-`8`) this ADR exists to fix. + +### Option 3: Fully redesigned suite (chosen) + +- Good, because it fixes both the question gap and the model gap at + once, and leaves a structure a future contributor can extend + predictably. +- Bad, because it's the largest diff and discards the old suite's + benchmark-by-benchmark continuity (mitigated above). + +## Links + +- [PLAN-0008](../plans/0008-milestone-8-public-preview.md) Phase 5 — the + plan this ADR's implementation is tracked under. +- [ADR-0030 Amendment 2](0030-compono-documentation-architecture.md#amendment-2-2026-08-04-resolving-milestone-8s-remaining-open-items) — + the benchmark-claims policy this ADR's public-documentation direction + and "AutoFixture is one comparison point, not the center" framing + implement. +- `benchmarks/Compono.Benchmarks/` — the existing suite this ADR + replaces. diff --git a/docs/adr/README.md b/docs/adr/README.md index d7462f7..408564b 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -95,3 +95,4 @@ the mechanics: numbering, status, and the index. | [0031](0031-public-preview-release-and-versioning-policy.md) | Public Preview Release and Versioning Policy | Accepted | | [0032](0032-api-reference-documentation-toolchain.md) | API Reference Documentation Toolchain | Accepted | | [0033](0033-public-preview-samples-strategy.md) | Public Preview Samples Strategy | Accepted | +| [0034](0034-benchmark-suite-strategy-and-redesign.md) | Benchmark Suite Strategy and Redesign | Accepted | diff --git a/docs/architecture.md b/docs/architecture.md index 182ccb9..8930e53 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,1131 +1,18 @@ -# Compono Architecture - -## Overview - -Compono is a modular test composition framework. - -The architecture is centered on a `CompositionContext`. - -The context represents one active composition operation and coordinates: - -- The deterministic seed -- Random streams -- Scope and shared instances -- Registrations -- Profiles -- Providers -- Generated composition plans -- The current request path -- Diagnostics -- Cancellation and runtime state - -The context is the internal center of the system even when the public API exposes simpler concepts such as `Composer`, `Create()`, or a test-framework attribute. - -## Architectural Shape - -```text -Consumer API - | - v -Composer / Test Framework Integration - | - v -CompositionContext - | - +--> Explicit Values - +--> Shared Scope - +--> Registrations - +--> Profile Rules - +--> Semantic Value Providers - +--> Test Double Providers - +--> Built-in Value Providers - +--> Generated Composition Plans - | - v -Composed Result or Diagnostic Failure -``` - -## Composition Context - -A composition context should contain all state required to resolve a graph without relying on mutable global configuration. - -Conceptually: - -```csharp -public interface ICompositionContext -{ - T Resolve(in CompositionRequestDescriptor descriptor); - - // Descriptor-less overload (shipped, Milestone 3 Phase 1) - the manual-resolve - // entry point a registration/configuration-rule factory calls to compose its - // own nested dependencies, distinct from the descriptor-based overload above - // generated code uses. See ADR-0019's Registrations and Service Injection - // section below. - T Resolve(); - - // Milestone 6 Phase 0 (implemented, PLAN-0006) - an on-demand, path-derived - // deterministic seed a public provider or registration/rule factory can use for - // its own randomness (e.g. Compono.Bogus's Faker/Randomizer), without exposing - // the engine's own internal IRandomSource or path representation. See ADR-0026. - int DeriveSeed(); -} -``` - -`ICompositionContext` is `public` — generated plan code (in the consumer's -own assembly) calls it directly, so it has to be. Everything the context -*owns* (seed, scope, path, random source, active construction frames, -provider pipeline) is deliberately not exposed as properties on this -interface — the context is internal state generated code never touches -directly, per -[ADR-0010](adr/0010-composition-request-pipeline-and-diagnostics-tracing.md). -Resolution is synchronous: every provider planned for the MVP is -in-memory/CPU-bound, and Milestone 1 already shipped a synchronous -`ICompositionPlan.Compose`. A genuinely async provider need, if one -ever arises, gets its own distinct opt-in contract rather than reworking -this one. - -### Context lifetime - -A new root context should normally be created for: - -- A call to `Create()` -- A call to `CreateMany()` -- One xUnit theory row -- One explicit composition scope - -Nested requests should derive child contexts or child paths without losing the root seed, scope, or diagnostics. - -## Composition Requests - -Every value is resolved from a rich request rather than only a `Type` — -but generated code never constructs that rich request directly. Per -[ADR-0010](adr/0010-composition-request-pipeline-and-diagnostics-tracing.md), -there are two distinct shapes: - -- **`CompositionRequestDescriptor`** (`public`) — the small, compact, - compile-time-constructible value generated plan code actually passes. - A plain `readonly struct`, not a `record struct` — equality, - `Deconstruct`, and record-style formatting aren't part of its contract, - per [ADR-0010](adr/0010-composition-request-pipeline-and-diagnostics-tracing.md)'s - second amendment: - ```csharp - public readonly struct CompositionRequestDescriptor - { - public CompositionRequestDescriptor( - CompositionRequestKind kind, // ConstructorParameter | RequiredMember | - // CollectionElement | DictionaryKey | DictionaryValue - int ordinal, // stable identity - see Deterministic Randomness, below - string name, // diagnostic display only, never identity - Type? declaringType, // Milestone 3 - see Configuration Rules, below - Nullability nullability); - - public CompositionRequestKind Kind { get; } - public int Ordinal { get; } - public string Name { get; } - public Type? DeclaringType { get; } - public Nullability Nullability { get; } - } - ``` - `DeclaringType` ([ADR-0020](adr/0020-composition-configuration-rules.md), - implemented, Milestone 3 Phase 3 — an additive extension to this - `Accepted` descriptor shape, not a change to ADR-0010's own text) is the - type whose constructor/required-member declares this parameter/member — - generator-emitted alongside `Ordinal`/`Name`, meaningful only for - `ConstructorParameter`/`RequiredMember` requests. **Nullable**, not a - sentinel value: `null` for any other request kind (a collection element/ - dictionary key/value, or a `ManualResolve` invocation), which have no - declaring type to report at all. It exists so a - configuration rule can match by declaring type + member name directly - off the request, rather than inferring the declaring type from path - state — see Configuration Rules, below. -- **`CompositionRequest`** (`internal`) — the richer record the context - expands a descriptor into, by appending a `PathSegment` - ([ADR-0012](adr/0012-composition-path-identity-and-deterministic-random-forking.md)) - derived from the descriptor to its own current path. This is what the - internal provider pipeline actually operates on; generated code never - sees or builds one. - -Only fields with a real Milestone 2 consumer exist today — `RequestedType`, -`Nullability`, the descriptor's `Kind`/`Name` (folded into the request's -path segment), `Path`, `IsShared`. `DeclaringType` is Milestone 3's first -addition to this set (above). `CustomAttributes`, generic context, -requested lifetime, semantic hints, and "whether a test double is -acceptable" are still deliberately not modeled — they get added once a -later milestone (xUnit inline values, Bogus hints, NSubstitute -eligibility) has an actual consumer for them, not speculatively now. - -[ADR-0021](adr/0021-row-composition-entry-point-for-test-framework-integrations.md) -(implemented, Milestone 4 Phase 0 — see -[PLAN-0004](plans/0004-milestone-4-xunit-integration.md)) adds a seventh -`CompositionRequestKind`, `TestParameter` (and a matching `PathSegment.TestParameter`, -the seventh `PathSegment` kind — the five original structured segments -plus `ManualResolve` made six), for a value a test-framework integration -is composing directly for one of -the *test method's own* parameters — as distinct from a constructor -parameter or required member a *generated plan* is filling in. It reuses -`DeclaringType` for the type whose method declares the parameter (a test -class, for `Compono.XunitV3`), extending that field's existing contract to a -third kind of declaring construct rather than adding a new field. - -Generated plans avoid requiring runtime reflection merely to construct -this metadata — the descriptor is a plain, compiler-emittable value, and -path/type expansion happens entirely inside the context. - -## Resolution Pipeline - -The default resolution order is: - -1. Explicit values -2. Shared or scoped values -3. Exact registrations -4. Configuration rules -5. Semantic value providers -6. Test-double providers -7. Built-in value providers -8. Generated object composition plans -9. Diagnostic failure - -This precedence is part of the product contract, and stage *order* is -fixed — not configurable, by users or by providers reordering themselves. -But per -[ADR-0010](adr/0010-composition-request-pipeline-and-diagnostics-tracing.md), -not every stage is the same *kind* of thing: - -| # | Stage | Kind | -|---|---|---| -| 1 | Explicit values | Context-owned deterministic check (no consumer until a later milestone's inline-value API exists) | -| 2 | Shared or scoped values | Context-owned deterministic check against the scope. Milestone 2/3 shipped this gated by the *current* request's own `IsShared` flag on both the read and write side; [ADR-0021](adr/0021-row-composition-entry-point-for-test-framework-integrations.md) (implemented, Milestone 4 Phase 0) changed the **read** side to an unconditional scope check (any request, `IsShared` or not, sees an already-shared value for its type) while leaving the **write** side unchanged (only an `IsShared` request ever populates scope) — required for a Milestone 4 `[Shared]` test parameter's value to reach an *ordinary*, unmarked nested constructor parameter of the same type. | -| 3 | Exact registrations | **Hybrid**, per [ADR-0019](adr/0019-registrations-and-service-provider-injection.md) (implemented, Milestone 3 Phase 1): a context-owned deterministic lookup against the exact-registration table, then — only on a miss, if a consumer called `UseServiceProvider(...)` — a fallback `IServiceProvider.GetService(typeof(T))` call. Milestone 2 shipped this stage as internal-only with no public builder; Milestone 3 Phase 1 shipped its real public shape (`builder.Register(...)`, `builder.UseServiceProvider(...)`). | -| 4 | Configuration rules | Ordered `ICompositionProvider` collection, per [ADR-0020](adr/0020-composition-configuration-rules.md) (implemented, Milestone 3 Phase 3). Renamed from "profile rules" (`PipelineStage.ConfigurationRule`): populated by type/member value rules compiled from `builder.For()...`, whether reached directly or via a profile's `Configure` — a profile is a reusable application mechanism over this stage, not its owner ([ADR-0018](adr/0018-composition-profiles.md)). Collection-size configuration does **not** populate this stage — see Configuration Rules, below. | -| 5 | Semantic value providers | Ordered `ICompositionProvider` collection. The public registration surface (`builder.AddSemanticProvider(ICompositionValueProvider)`) is implemented since Milestone 5 Phase 0 ([ADR-0024](adr/0024-public-provider-extensibility-model.md)). `Compono.Bogus` ([ADR-0027](adr/0027-compono-bogus-package-design.md), **implemented, PLAN-0006 Phase 1**) is this stage's first real registrant via `UseBogus()` — `BogusMemberNameProvider` matches `string`-typed member-name conventions. | -| 6 | Test-double providers | Ordered `ICompositionProvider` collection. The public registration surface (`builder.AddTestDoubleProvider(ICompositionValueProvider)`) is implemented ([ADR-0024](adr/0024-public-provider-extensibility-model.md)), and `Compono.NSubstitute` ([ADR-0025](adr/0025-compono-nsubstitute-package-design.md), implemented and test-covered — see [PLAN-0005](plans/0005-milestone-5-nsubstitute-integration.md)) is a real registrant via `UseNSubstitute()`. Still empty by default in any composition that doesn't opt into it — registration is per-`Composer`, not automatic just because the package is referenced. | -| 7 | Built-in value providers | **Hybrid** (ADR-0014): an ordered `ICompositionProvider` collection (primitive/simple types, enums, nullable value types), populated internally by `Compono` itself, tried first — followed by a context-owned deterministic dispatch through `CollectionPlanCache` for the five built-in collection shapes (array, `List`, `IReadOnlyList`, `HashSet`, `Dictionary`), the same closed-generic-field-read mechanism stage 8 uses, since `ICompositionProvider` can't itself construct a generic collection without reflection | -| 8 | Generated composition plans | Context-owned deterministic dispatch via `PlanCache` — **not** an `ICompositionProvider` (see Source-Generated Composition Plans, below) | -| 9 | Diagnostic failure | Context-owned terminal stage | - -Stages 4/5/6/7 each hold an actual ordered collection of providers, and -every one of their public registration surfaces is implemented today -(`.For()` for stage 4, since Milestone 3; `AddSemanticProvider`/ -`AddTestDoubleProvider` for stages 5/6, since Milestone 5 Phase 0 — -[ADR-0024](adr/0024-public-provider-extensibility-model.md)). Only stage 7 -has anything registered *unconditionally* (`BuiltInProviders.Default`); -every other stage is opt-in, populated only when a consumer actually does -something — calls `.For()` (stage 4), calls `UseNSubstitute()` -(`Compono.NSubstitute`, implemented, stage 6 — [ADR-0025](adr/0025-compono-nsubstitute-package-design.md)), -or calls `AddSemanticProvider`/`AddTestDoubleProvider` directly with a -hand-written provider (either stage), or calls `UseBogus()` -(`Compono.Bogus`, implemented, stage 5 — [ADR-0027](adr/0027-compono-bogus-package-design.md), -PLAN-0006 Phase 1). Provider order -*within* an extensible stage is registration order; stage 7 alone already -holds three real providers (`PrimitiveValueProvider`, `EnumValueProvider`, -`NullableValueProvider` — `BuiltInProviders.Default`), so "no stage has -more than one provider" is not actually true today, a stale claim -corrected during PR #13 review. No *richer* ordering rule (priority, -specificity, or similar) exists yet because none has been needed: -`PrimitiveValueProvider`/`EnumValueProvider`/`NullableValueProvider` -claim disjoint type sets, so plain registration order has never had two -providers genuinely compete for the same request — a richer rule becomes -a real question only once two providers could plausibly both claim the -same type differently. Stage 7's `CollectionPlanCache` dispatch is -tried only after -its ordered provider collection has already declined, so a registration, -profile rule, semantic provider, or test-double provider (stages 1–6) -still gets first refusal over a collection request — collections stay -ordinary pipeline requests, per ADR-0013. - -## Providers - -Providers satisfy composition requests within one of the extensible -pipeline stages above (4/5/6/7) — the context-owned stages (1/2/3/8/9) -are not providers and don't implement this interface. - -A provider is independently replaceable and reports whether it: - -- Did not apply (`NotHandled`) -- Successfully composed a value (`Success`) - -Conceptually: - -```csharp -internal interface ICompositionProvider -{ - CompositionResult TryCompose( - CompositionRequest request, - ICompositionContext context); -} -``` - -Ordinary providers **cannot** report `Failure` — the type only gives them -`NotHandled`/`Success` to return. `Failure` is reserved for the -context-owned authoritative stages (an exact registration whose factory -throws, generated-plan dispatch when a plan exists but fails or a -recursion cycle is detected) — the rule, per -[ADR-0010](adr/0010-composition-request-pipeline-and-diagnostics-tracing.md): -`Failure` means "authoritative ownership was established, but resolution -could not complete," never a stronger form of `NotHandled`. This is what -stops a provider that merely can't produce *this* particular request from -accidentally blocking a later stage (or a generated plan) that could -have. This avoids exception-driven provider selection and preserves -meaningful failures. - -### Public providers (stages 5/6) - -`ICompositionProvider` above is `Compono`-internal — stages 4/7 (registered -type/member rules, built-in providers) are implemented entirely inside the -core package and never exposed for an outside package to author its own. -Stages 5/6 (semantic values, test doubles) are different: they exist -specifically for an integration package to contribute open-ended, -pattern-matching logic ("any interface type"), which means the contract a -provider author implements has to be public, small, and decoupled from -`Compono`'s internal request/pipeline plumbing. Resolved by -[ADR-0024](adr/0024-public-provider-extensibility-model.md), implemented -(PLAN-0005 Phase 0): - -```csharp -public interface ICompositionValueProvider -{ - CompositionProviderResult TryProvide( - in CompositionProviderRequest request, - ICompositionContext context); -} -``` - -`CompositionProviderRequest` (`RequestedType`/`DeclaringType`/`Name`/ -`Nullability`) and `CompositionProviderResult` (`NotHandled`/`Handled(value)`) -are their own public types, decoupled from the internal -`CompositionRequest`/`CompositionResult` pair above — no path, no -shared-scope flag, no pipeline plumbing a provider author has no legitimate -use for. `CompositionBuilder.AddSemanticProvider`/`AddTestDoubleProvider` -register a public provider into stage 5/6 respectively, in registration -order; internally, each is wrapped in a `PublicProviderAdapter : -ICompositionProvider` — an adapter, not a second provider contract — so the -rest of the pipeline (dispatch, tracing, diagnostics identity via -`ICompositionProvider.ProviderType`) treats a public provider exactly like an -internal one, with diagnostics naming the real wrapped provider's type, never -the adapter. A public provider may call `context.Resolve()` -(descriptor-less) to compose part of its value from a nested request, exactly -as an internal provider already may; a thrown exception from `TryProvide` -propagates uncaught, per this ADR's Provider Failure Semantics — same -"exceptions signal a bug" principle as everywhere else in this pipeline, not -a stronger contract than an internal provider gets. -`Compono.NSubstitute`'s `NSubstituteProvider` (registered via -`AddTestDoubleProvider`) is the first real consumer of this contract. - -## Source-Generated Composition Plans - -Source generation is the preferred construction strategy. - -For a constructible type, the generator should emit a plan that: - -- Selects the constructor -- Requests constructor arguments -- Invokes the constructor directly -- Assigns required or configured members -- Preserves nullability and member context -- Produces diagnostic metadata -- Registers the plan with the runtime - -Conceptually: - -```csharp -internal sealed class CustomerCompositionPlan - : ICompositionPlan -{ - public Customer Compose(ICompositionContext context) - { - var firstName = context.Resolve( - new CompositionRequestDescriptor( - CompositionRequestKind.ConstructorParameter, - 0, - "firstName", - Nullability.NotNullable)); - - var lastName = context.Resolve( - new CompositionRequestDescriptor( - CompositionRequestKind.ConstructorParameter, - 1, - "lastName", - Nullability.NotNullable)); - - return new Customer(firstName, lastName); - } -} -``` - -Generated code only ever calls `context.Resolve(descriptor)` per -member — it never constructs a `CompositionRequest`, touches -`CompositionPath`, or manages recursion state directly. The context owns -all of that internally -([ADR-0010](adr/0010-composition-request-pipeline-and-diagnostics-tracing.md)), -which is what makes incorrect path propagation structurally difficult -rather than merely documented against. The final generated code may use -lower-level APIs for performance. - -### Discovery and Dispatch - -How the generator decides a type needs the plan above, and how -`Create()` reaches it without reflection, is -[ADR-0004](adr/0004-composition-plan-discovery-and-dispatch.md): discovery -walks `Create()`/`CreateMany()` call sites and their types' -transitive constructor parameters, with `[Composable]` as an opt-in marker -for a type with no local call site — applied directly to a type this -compilation owns, or at assembly level -(`[assembly: Composable(typeof(SomeType))]`) for a type in a referenced -assembly that can't be annotated directly. Both forms are equivalent -plan-generation requests, deduplicated alongside call-site discovery. -"Registers the plan with the runtime" -above means a generated module initializer populates a closed-generic -static field (`PlanCache.Instance = ...`) that `Create()` -reads directly — not a `typeof(T)`-keyed dictionary lookup. - -A generated plan never redispatches into itself directly — each -`context.Resolve(descriptor)` call it makes is a fresh pipeline -evaluation for whatever type that member actually is, not a recursive -call back into the same plan. A genuinely self-referencing type (e.g. a -`Node` with a `Node` property) only becomes a problem if nothing earlier -in the pipeline (an explicit value, a shared value, a registration) -terminates it before generated-plan dispatch is reached a second time for -the same type while the first invocation is still on the stack — see -Recursion Detection, below. - -### Generator responsibilities - -The generator should identify: - -- Accessible constructors -- Primary constructors -- Required members -- Init-only members -- Nullability metadata -- Unsupported types -- Ambiguous construction paths -- Cyclic compile-time dependencies where detectable - -### Runtime responsibilities - -The runtime should: - -- Execute generated plans -- Resolve provider-backed values -- Manage scopes -- Manage deterministic random streams -- Track the composition path -- Produce runtime diagnostics - -## Runtime Reflection Policy - -The reflection policy is intentionally undecided. - -Candidate approaches: - -### Generated plans required - -Composition fails when no generated plan exists. - -Advantages: - -- Predictable performance -- Strong trimming and AOT characteristics -- Simple runtime model - -Tradeoffs: - -- External or dynamically discovered types may require explicit support -- Some test scenarios may be less convenient - -### Automatic reflection fallback - -The runtime reflects when no generated plan exists. - -Advantages: - -- High compatibility -- Lower migration friction - -Tradeoffs: - -- More complex runtime -- Weaker AOT guarantees -- Performance becomes less predictable -- Reflection can hide source-generation gaps - -### Opt-in compatibility package or mode - -Reflection support is isolated from the default runtime. - -Advantages: - -- Keeps the core architecture clean -- Allows compatibility where necessary -- Makes performance tradeoffs explicit - -This is the current leading compromise, but it is not yet an accepted decision. - -## Scopes and Shared Values - -A composition scope stores values that should be reused during an active composition. - -Examples: - -- A repository parameter shared with the system under test -- A fake clock reused throughout an object graph -- A substitute reused by multiple dependencies - -Scope semantics must be explicit. - -Resolved for Milestone 2 by -[ADR-0011](adr/0011-composition-scope-shared-values-and-recursion-detection.md): -one scope per root composition operation (one `Create()` call, or one -item of a `CreateMany()` call — each item gets its own independent -scope, not a scope shared across the batch). Sharing is type-keyed only -for Milestone 2; name/qualifier-based sharing is deferred until a -Milestone 4 `[Shared]`-attribute use case needs it. A broader "test case" -or "user-created scope" lifetime is deferred until Milestone 4 has a -concrete consumer to design against, rather than building the general -menu of possible lifetimes below speculatively: - -Milestone 4 is that concrete consumer: -[ADR-0021](adr/0021-row-composition-entry-point-for-test-framework-integrations.md) -(implemented, Milestone 4 Phase 0) adds `Composer.CreateRow(Type)` and a -public `CompositionRow`, one per theory row, whose `CompositionScope` is -exactly this same per-root-operation scope — still type-keyed only, still -one instance per root operation, no new lifetime concept. What changes is -*when a request consults it* — see the Resolution Pipeline table's stage-2 -row above. - -- Request -- Composition graph (Milestone 2's chosen lifetime) -- Test case -- User-created scope - -The MVP should begin with one clear shared lifetime rather than a general-purpose dependency injection lifetime system. - -### Recursion Detection - -A repeated *type* appearing twice in a graph (two sibling properties of -the same type, or the same type reachable via two different paths) is -ordinary graph shape, not a cycle. A genuine cycle is a type whose -*construction* is still actively in progress when it's requested again. -[ADR-0011](adr/0011-composition-scope-shared-values-and-recursion-detection.md) -keeps these deliberately separate: `CompositionPath` (below) records -every request edge for diagnostics and random forking, while a distinct -internal **active-construction-frame** stack is pushed only around -structural construction (generated-plan dispatch, stage 8) and checked -only there — after explicit values, shared/scoped values, and exact -registrations have already had a chance to terminate the graph. A -self-referencing type resolved by a registered or shared instance never -touches the recursion mechanism at all; only an actual in-progress -construction cycle does, and the resulting diagnostic reports the chain -of active frames — the request edges that formed the cycle — not just a -list of repeated types. - -## Immutable Configuration Model - -Resolved by [ADR-0017](adr/0017-immutable-composer-configuration-and-builder-model.md). -**The builder/configuration split, `WithSeed`, `Register`, `UseServiceProvider`, -`AddProfile`, the `.For()` rule DSL, and `WithCollectionSize` are all implemented -(Milestone 3 Phases 0-3, [PLAN-0003](plans/0003-milestone-3-profiles-and-configuration.md)).** -`CompositionBuilder` is a mutable -accumulator that exists only for the duration of the `Composer.Create(builder => -...)` callback; when the callback returns, its accumulated state is validated and -frozen into an internal `CompositionConfiguration` — a `Composer` holds exactly one, -reused across every `Create()`/`CreateMany()` call it ever serves, with no -mutable state on that hot path at all. Every scalar configuration verb — `WithSeed`, -`UseServiceProvider`, `WithCollectionSize`'s global default — may be set at most once -per configuration, the same fail-fast rule keyed configuration (registrations, -profile provenance, and type/member rules) follows: a second call is a build-time -conflict, never last-wins, so a scalar's effective value never depends on -`AddProfile` call order. - -Two distinct failure moments both surface from `Composer.Create(...)`, but aren't -the same mechanism: a profile cycle ([ADR-0018](adr/0018-composition-profiles.md), -implemented, Milestone 3 Phase 2) is detected **eagerly**, during -`AddProfile` itself, and throws immediately with exactly one error naming the cycle -— configuration stops right there, nothing further is aggregated. Every other -conflict (duplicate registrations, duplicate scalars, duplicate type/member rules) -is detected by `Build()`'s single validation pass, which runs -only after the whole `configure(builder)` callback has already returned -successfully, and aggregates every conflict found across the complete accumulated -state into one `CompositionConfigurationException` — distinct from the per-value -`CompositionException` a running `Create()` call can throw. That exception -carries a structured, inspectable list of errors (kind, affected type/member, -contributing sources) that its rendered message is derived from, not the other way -around — a test (or a consumer) can assert on the structured data directly rather -than parsing message text. - -## Profiles - -Resolved by [ADR-0018](adr/0018-composition-profiles.md) (implemented, Milestone 3 -Phase 2). A profile is `ICompositionProfile` — one method, -`void Configure(CompositionBuilder builder)` — not an abstract base class: profiles -define behavior, not shared implementation, matching the repo's composition-over- -inheritance preference and AutoFixture's `ICustomization` prior art. - -```csharp -public sealed class ApplicationTestProfile : ICompositionProfile -{ - public void Configure(CompositionBuilder builder) - { - builder - .UseNSubstitute() - .UseBogus(options => options.Locale = "en_US") - .Register(_ => new FakeClock(...)); - } -} -``` - -`builder.AddProfile()` (where `TProfile : ICompositionProfile, new()`) or -`builder.AddProfile(ICompositionProfile profile)` runs `Configure` **immediately**, -synchronously, against the same shared `CompositionBuilder` a direct call would use -— a profile is a named, reusable grouping over the builder's ordinary surface, not a -distinct configuration mechanism. Multiple profiles combine purely through call -order (`AddProfile().AddProfile()`); there is no separate merge step. A -profile calling `AddProfile` for another profile already being applied -(`ProfileA → ProfileB → ProfileA`) is a build-time -`CompositionConfigurationException` naming the cycle — detected via a type-keyed -stack pushed around `Configure`, mirroring the shape of the engine's own -active-construction-frame recursion check. Every accumulated registration/rule -entry retains its full source chain (direct, or the nested profile types that -applied it) so a conflict or cycle diagnostic can always name where each entry -actually came from. - -## Registrations and Service Injection - -Resolved by [ADR-0019](adr/0019-registrations-and-service-provider-injection.md) -(implemented, Milestone 3 Phase 1). `builder.Register(Func -factory)` (plus a `Register(Func factory)` convenience overload) populates -pipeline stage 3's exact-registration table. A duplicate registration for the same -type — from any combination of direct calls and profiles — is a build-time -`CompositionConfigurationException` naming every conflicting source; there is no -last-wins override in Milestone 3. - -`ICompositionContext` gains a second, descriptor-less `Resolve()` overload -alongside the existing descriptor-based one, for hand-written registration/ -configuration-rule factories that have no generated-plan position to describe. Each -factory invocation gets its own manual-resolve invocation frame (pushed before the -factory runs, popped in `finally` after it returns or throws); every descriptor-less -`Resolve()` call made during that invocation shares and advances that frame's -counter (`PathSegment.ManualResolve`, a call-sequence ordinal, never the requested -type), while a nested factory invocation gets its own independent frame — defined in -[ADR-0019](adr/0019-registrations-and-service-provider-injection.md), verified -against [ADR-0012](adr/0012-composition-path-identity-and-deterministic-random-forking.md)'s -existing reproducibility contract without editing that ADR's `Accepted` text. - -Service injection — this milestone's headline new capability — is a fallback *inside* -stage 3, not a new pipeline stage or a new public extensibility surface: -`builder.UseServiceProvider(IServiceProvider provider)` stores the BCL's own -`System.IServiceProvider` (no new package dependency for core `Compono`, since it -ships in `System`, not a NuGet package). On a stage-3 registration miss, the -configured provider is consulted before falling through to stage 4; `null` means -unresolved, an exception the provider throws is authoritative (surfaced as -`CompositionException` with the original preserved as `InnerException`, never -swallowed), and a non-null result is checked assignable to the requested type before -use. `Compono` never creates or disposes a scope — the caller owns the provider and -its lifetime entirely. A richer `Microsoft.Extensions.DependencyInjection`-specific -integration (`IServiceCollection` auto-registration, scoping, keyed services) is -explicitly out of scope for core — a future optional package, not designed yet, -would build on `UseServiceProvider` the same way `Compono.NSubstitute`/ -`Compono.Bogus` build on core's other extension points. - -## Configuration Rules - -Resolved by [ADR-0020](adr/0020-composition-configuration-rules.md) (implemented, -Milestone 3 Phase 3). Two structurally different mechanisms share one public -`builder.For()` DSL: - -- **Type and member value rules** (`.For().Use(...)`, - `.For().Member(x => x.Y).Use(...)`) compile into small, internal, - Compono-authored `ICompositionProvider` implementations registered into stage 4 — - a user never implements `ICompositionProvider` directly. A member rule's matching - identity is `(declaring type, member name)`, matched directly against the - incoming request's `DeclaringType`/`Name` (Composition Requests, above, and - [ADR-0020](adr/0020-composition-configuration-rules.md)) — never inferred from - path state — with the rule's own key - captured from the member-access expression at the point `.Member(...)` is called; - a type rule matches any request for exactly that type (no assignability matching - in Milestone 3). Member rules take precedence over type rules for the same - effective request — specificity-based, not call-order-based. Two rules claiming - the identical key is a build-time `CompositionConfigurationException`, the same as - a duplicate registration. -- **Collection-size configuration** (`builder.WithCollectionSize(n)`, - `.For().Member(x => x.Y).WithCollectionSize(n)`) is **not** a stage-4 rule — - it's immutable policy on `CompositionConfiguration`, queried directly by stage 7's - collection dispatch. A single, **parameterless** `ICompositionContext.ResolveCollectionSize()` - method (not a descriptor-taking overload — a generated collection plan's - `Compose(ICompositionContext)` has no descriptor to pass; the context reads the - current member's declaring type/name off the already-expanded internal request - it's still resolving — the same `DeclaringType` field member value-rule matching - uses, correctly base-aware for an inherited required member, not a - separately-derived parent-path-node type) — used identically by root-level and - member-scoped collection plans — replaces - [ADR-0013](adr/0013-collection-generation-semantics.md)'s previously-hardcoded - default of `3`. This parameterizes ADR-0013's constant without reopening its - retry/uniqueness/ordering semantics. - -## Deterministic Randomness - -The root context owns the seed. - -Random sources should be forkable by stable keys: - -```text -root seed -└── test parameter: command - └── Customer - └── Email -``` - -This reduces accidental changes when unrelated members are added elsewhere in a graph. - -Resolved by [ADR-0012](adr/0012-composition-path-identity-and-deterministic-random-forking.md): -`CompositionPath` is a chain of structured `PathSegment`s — not just -types — so two constructor parameters or members of the same type -(`Customer(string FirstName, string LastName)`) fork independently -instead of colliding on an identical key. Forking hashes the structured -segment data directly (a per-kind tag plus its `Ordinal`/index — a -constructor parameter's position in the selected constructor, or a -required member's generator-assigned declaration-order index; never -`Name`, which exists on the segment for diagnostic display only) via -FNV-1a, never a formatted display string, which is what makes the fork -key collision-free by construction rather than by careful -string-escaping. This is a reproducibility *contract*, not an -implementation detail: renaming a constructor parameter or required -member (with no reordering) never changes its derived value — only -reordering does. -That structured state feeds a small Compono-owned PRNG (not -`System.Random`), so the byte-for-byte output sequence is something -Compono controls rather than an inherited BCL implementation detail. The -stability guarantee is explicit: the same seed produces the same output -for a given `Compono` package version — cross-version stability across a -`Compono` upgrade is not promised. - -`CreateMany(count)` derives each item's independent root seed by -forking the batch's root seed through a stable `"CreateMany"` key, then -by the item's index — so item `i`'s output depends only on the batch root -and `i`, never on `count`: items 0–2 of `CreateMany(3)` and -`CreateMany(10)` (same root seed) are byte-for-byte identical. - -## Diagnostics - -Diagnostics should track: - -- Root request -- Current request path -- Provider decisions -- Selected plan -- Constructor selection -- Scope reuse -- Registration matches -- Seed -- Failure reason -- Suggested remediation - -Example: - -```text -Unable to compose CreateOrderHandler. - -CreateOrderHandler -└── IOrderProcessor processor - └── OrderValidator validator - └── IRuleProvider rules - -No registration, semantic provider, test-double provider, -built-in provider, or generated plan could satisfy IRuleProvider. - -Seed: 8492173 -``` - -Per [ADR-0010](adr/0010-composition-request-pipeline-and-diagnostics-tracing.md), -this level of detail is designed to cost as little as possible on the -normal successful path — "near-zero-allocation on success, not -zero-cost," in the ADR's own words: a context-owned, reusable, -array-backed trace buffer (`CompositionTraceBuffer`) records a compact -struct (`ProviderAttempt`: stage, provider type, outcome — no strings, no -per-append allocation) per stage attempt, and rewinds on success instead -of retaining anything. Only a failing request materializes its slice of -that buffer into the durable `CompositionDiagnostic` above -(`exception.Diagnostic`, `docs/public-api.md`'s Diagnostics API), before -the buffer unwinds further. `ProviderAttempt.Provider` is the concrete -`ICompositionProvider` type that made the attempt (`null` for a -context-owned stage, which isn't a provider instance at all) — -[ADR-0016](adr/0016-provider-identity-restored-in-provider-attempt.md) -restores this identity field after -[ADR-0015](adr/0015-provider-identity-deferred-in-provider-attempt.md) -deferred it on a premise (no stage has more than one provider) that was -already false for stage 7's three built-in providers. - -`CompositionTraceBuffer` itself is not literally zero-allocation, though: -its backing `ProviderAttempt[32]` array is allocated once per root -`CompositionContext`, measured directly at **~536 B per instance** (32 -entries; capacity bumped from an original 16 after a second PR #13 -review round, and each entry's own size roughly doubled after a fourth -round restored `ProviderAttempt.Provider` — -[ADR-0016](adr/0016-provider-identity-restored-in-provider-attempt.md)) -— see this page's Open Architectural Decisions entry below for the -precise breakdown and why pooling it entirely is deferred rather than -fixed as a same-PR change. - -Confirmed via `Compono.Benchmarks`' `ResolutionBenchmarks` (Milestone 2 -Phase 4, full `DefaultJob` numbers in [`docs/performance.md`](performance.md)): -composing the `Customer`/`Address` representative graph allocates ~2.71 KB -total (of which the trace buffer's ~536 B is ~20%) regardless of the -trace buffer's presence, and `CreateMany(count)` scales linearly with -`count` (10.18× allocation at `count=10`, 101.48× at `count=100`, against -a `count=1` baseline) — no super-linear growth from checkpoint/rewind -bookkeeping. That graph is only 2 levels deep, though — never deep enough -to trigger `CompositionTraceBuffer`'s own growth path (each active -ancestor frame retains ~6 entries until its own child returns, so a -32-entry buffer holds ~5 levels before resizing); `docs/performance.md`'s -"Deep graph result" measures an 8-level-deep graph that does trigger a -real `Array.Resize`, rather than only benchmarking the shallow case. No -fallback to shallow diagnostics was needed. - -## Package Boundaries - -### Compono - -Owns: - -- Composition context -- Runtime engine -- Requests and results -- Provider contracts -- Scopes -- Profiles -- Registrations -- Deterministic random -- Built-in providers -- Diagnostics -- Generated-plan contracts - -### Compono.Generators - -Resolved by [ADR-0003](adr/0003-generator-package-distribution.md): never -published to NuGet on its own — its compiled output is packed directly into -the `Compono` nupkg as an analyzer dependency, so from a consumer's point of -view it doesn't exist as a separate package at all. - -Owns: - -- Incremental source generator -- Generated plan registration -- Compile-time diagnostics - -### Compono.XunitV3 - -Design: [ADR-0021](adr/0021-row-composition-entry-point-for-test-framework-integrations.md) -(the `CompositionRow` entry point this package builds on, owned by core -`Compono`), [ADR-0022](adr/0022-compono-xunit-package-design.md) (this -package itself), [ADR-0023](adr/0023-rename-compono-xunit-to-compono-xunitv3.md) -(the `Compono.Xunit` → `Compono.XunitV3` rename). Implemented - see -[PLAN-0004](plans/0004-milestone-4-xunit-integration.md) for the phase-by-phase -account. The one compile-time gap tracked in that plan's Open Items (an -interface/abstract/delegate-typed `[Compose]`-attributed parameter reported -CMP0003 unconditionally) is resolved by -[PLAN-0005](plans/0005-milestone-5-nsubstitute-integration.md) Phase 2, see -[ADR-0024's Amendment 2](adr/0024-public-provider-extensibility-model.md). - -Owns: - -- xUnit v3 data integration -- Per-row composition contexts -- Inline value precedence -- Parameter attributes -- Seed reporting -- Profile selection - -### Compono.NSubstitute - -Design: [ADR-0024](adr/0024-public-provider-extensibility-model.md) (the -public provider extension point this package builds on, owned by core -`Compono`), [ADR-0025](adr/0025-compono-nsubstitute-package-design.md) (this -package itself). Implemented and test-covered/end-to-end verified - see -[PLAN-0005](plans/0005-milestone-5-nsubstitute-integration.md) for the -phase-by-phase account. - -Owns: - -- `NSubstituteProvider` — the stage-6 test-double provider (registered via - `AddTestDoubleProvider`), composing an interface, delegate, or (when - configured) unsealed abstract-class request as a real - `Substitute.For(Type[], object[])` value -- `NSubstituteOptions` — `SubstituteAbstractClasses` -- `CompositionBuilderExtensions.UseNSubstitute()`/`UseNSubstitute(Action)` - -Contributes no diagnostics of its own — an unsubstitutable request (a sealed -concrete class) falls through `NotHandled` to later pipeline stages exactly -like any other stage-6 decline, so it still composes normally at stage 8 if a -generated plan exists for it. Only when nothing later in the pipeline can -satisfy the request either does it reach the engine's existing stage-9 -"nothing could satisfy this" diagnostic, naming the type and path — never a -package-specific one (ADR-0025's Diagnostics section). - -### Compono.Bogus - -Design: [ADR-0026](adr/0026-deterministic-seed-derivation-for-providers.md) (the -core `ICompositionContext.DeriveSeed()` capability this package builds on, owned -by core `Compono` — **implemented, PLAN-0006 Phase 0**), [ADR-0027](adr/0027-compono-bogus-package-design.md) -(this package itself — **implemented, PLAN-0006 Phase 1**; build-verified only, -test coverage/end-to-end verification still pending Phase 3), [ADR-0028](adr/0028-configurable-bogus-member-name-conventions.md) -(configurable member-name conventions — aliases and custom exact-name -conventions on top of ADR-0027's fixed allowlist; a new ADR, not an amendment -to ADR-0027 — **implemented, PLAN-0006 Phase 2**; build-verified only, test -coverage still pending Phase 3) — see -[PLAN-0006](plans/0006-milestone-6-bogus-integration.md) for the phase-by-phase -account. - -Owns: - -- `BogusMemberNameProvider` — the stage-5 semantic value provider (registered - via `AddSemanticProvider`), matching an exact-match, `string`-typed lookup - merging the conservative built-in allowlist (`FirstName`/`Email`/etc., - ADR-0027) with any consumer-configured aliases/custom conventions - (ADR-0028) -- `BogusOptions` — `Locale`, `EnableMemberNameConventions`, - `AddAlias(string, BogusConvention)`/`AddConvention(string, Func)` - (ADR-0028) — both validated eagerly against the same call's own already- - configured entries and the built-in allowlist, scoped to a single - `UseBogus(...)` call (no cross-call/cross-profile detection, see - ADR-0028's Non-Goals) -- `BogusConvention` — a closed public enum identifying each built-in - convention, for `AddAlias`'s own target parameter (ADR-0028) -- `CompositionBuilderExtensions.UseBogus()`/`UseBogus(Action)`/ - `UseBogus(Action>)`/`UseBogus(string, Action>)` - — the last two are purely ergonomic sugar over the existing `Register` - registration mechanism (stage 3): no hidden pipeline stage, no special - runtime behavior of their own -- `MemberRuleExtensions.UseBogus(Func, string)` — sugar over the - existing `.For().Member(...).Use(...)` stage-4 rule mechanism - -Correlated values are satisfied by Bogus's own `Faker` (the whole-object -`UseBogus()` model above), not a separate Compono-native member-dependency -mechanism — `.DependsOn(...)` is explicitly deferred (ADR-0027). Coexists with -`Compono.NSubstitute` with zero reference between the two packages in either -direction: `BogusMemberNameProvider` only ever claims `string`-typed members, -`NSubstituteProvider` only ever claims interface/delegate/abstract-class -requests — disjoint by construction, unaffected by ADR-0028 (aliases/custom -conventions are `string`-only too). - -## Package Dependency Diagram - -```text - Compono.Generators - (netstandard2.0, IsPackable=false, - never independently published — see - ADR-0003) - | - | ProjectReference, - | OutputItemType="Analyzer" - v - Compono - (core engine, no - dependency on any - integration package; - packs Compono.Generators' - output into its own nupkg - under analyzers/dotnet/cs) - ^ - | - +------------------+------------------+ - | | | - Compono.XunitV3 Compono.NSubstitute Compono.Bogus - | | | - xunit.v3 NSubstitute Bogus -``` - -- `Compono` depends on nothing else *published* in this diagram — every - arrow from an integration package points *into* it, never out, per the - "core package must not know about integrations" rule - (`design-decisions.md` rule 3). Its build-time-only relationship to - `Compono.Generators` is a different kind of dependency (analyzer, not a - normal reference) and doesn't violate that rule — see - [ADR-0003](adr/0003-generator-package-distribution.md). -- Each integration package depends on `Compono` plus exactly one - third-party library (`xunit.v3`, `NSubstitute`, or `Bogus`). Integration - packages don't depend on each other. -- `Compono.Generators` is never published to NuGet on its own — its - compiled output is packed directly into the `Compono` nupkg, so from a - *consumer's* point of view it doesn't exist as a separate dependency at - all ([ADR-0003](adr/0003-generator-package-distribution.md)). - -## Open Architectural Decisions - -- ~~Runtime reflection policy~~ — default direction resolved by - [ADR-0001](adr/0001-source-generation-first.md); the exact opt-in - mechanism for a future compatibility mode is still open. -- ~~Whether generated plans are required for external types~~ — resolved - by [ADR-0004](adr/0004-composition-plan-discovery-and-dispatch.md): - external/library types are fully supported via the `PlanCache` - registry dispatch mechanism, not required to be `partial` or - Compono-owned. -- ~~Sync versus async provider contracts~~ — resolved by - [ADR-0010](adr/0010-composition-request-pipeline-and-diagnostics-tracing.md) - (carried forward from the now-superseded ADR-0007): synchronous, with - any future async need getting a distinct opt-in contract. -- ~~Public versus internal visibility of the core engine types~~ — - resolved by - [ADR-0010](adr/0010-composition-request-pipeline-and-diagnostics-tracing.md): - `CompositionRequest`, `ICompositionProvider`, `CompositionResult`, and - `IRandomSource` are `internal` in Milestone 2; `CompositionRequestDescriptor`, - `CompositionRequestKind`, and `ICompositionContext` are `public`, since - they're the generated-code call surface every plan crosses the assembly - boundary to use. [ADR-0014](adr/0014-generator-emitted-collection-plans.md) - extends that same surface for generated collection plans specifically: - `CollectionPlanCache` and `UniqueValueResolver` are also `public` for - the identical reason, not a discretionary API design choice. -- Public versus internal use of `Type` -- ~~Exact profile model~~ — resolved by - [ADR-0018](adr/0018-composition-profiles.md): `ICompositionProfile` interface - (not an abstract base class), eager in-order application, type-keyed cycle - detection. -- ~~Public provider extensibility (how integration packages contribute open-ended - pattern-matching logic to stages 5/6)~~ — evaluated and explicitly deferred to - Milestone 5 during the Milestone 3 design review, for the reasons this bullet - originally gave (type/member value rules compile into internal, - Compono-authored providers per [ADR-0020](adr/0020-composition-configuration-rules.md); - service injection folds into stage 3 per - [ADR-0019](adr/0019-registrations-and-service-provider-injection.md); neither - needed a public contract). **Resolved by - [ADR-0024](adr/0024-public-provider-extensibility-model.md)** (core contract: - `ICompositionValueProvider`, `CompositionBuilder.AddSemanticProvider`/ - `AddTestDoubleProvider`, compiled into stage 5/6 the same way ADR-0020's rules - compile into stage 4 — **implemented, PLAN-0005 Phase 0**) **and - [ADR-0025](adr/0025-compono-nsubstitute-package-design.md)** (`Compono.NSubstitute`, - the first real consumer of that contract — **implemented and test-covered/ - end-to-end verified, PLAN-0005**, all phases done). Both the core extension - point and its first real consumer are shipped for both stages: stage 6 in the - Resolution Pipeline table above is populated whenever a consumer calls - `UseNSubstitute()`; stage 5 is populated whenever a consumer calls - `UseBogus()` (`Compono.Bogus`, [ADR-0027](adr/0027-compono-bogus-package-design.md), - built on [ADR-0026](adr/0026-deterministic-seed-derivation-for-providers.md)'s - `ICompositionContext.DeriveSeed()` capability — **implemented, PLAN-0006 - Phase 1**, test coverage/end-to-end verification still pending Phase 3). - Tracked by [PLAN-0006](plans/0006-milestone-6-bogus-integration.md). -- **Richer `Microsoft.Extensions.DependencyInjection` integration** (`IServiceCollection` - auto-registration, per-composition scoping, keyed services) — explicitly out of - scope for `Compono` core per - [ADR-0019](adr/0019-registrations-and-service-provider-injection.md); core only - supports the BCL's own `System.IServiceProvider` via `UseServiceProvider(...)`. A - future optional package could build MEDI-specific ergonomics on top of that - extension point, but isn't designed here — no concrete requirement has motivated - one yet. -- ~~Scope lifetime model~~ — resolved for Milestone 2 by - [ADR-0011](adr/0011-composition-scope-shared-values-and-recursion-detection.md) - (carried forward from the now-superseded ADR-0008): one scope per root - composition operation, type-keyed sharing only. -- ~~Recursion detection timing~~ — resolved by - [ADR-0011](adr/0011-composition-scope-shared-values-and-recursion-detection.md): - checked only immediately before generated-plan dispatch, via a distinct - active-construction-frame stack, after explicit/shared/registration - stages have had a chance to terminate the graph. -- ~~Constructor selection rules~~ — resolved by - [ADR-0002](adr/0002-constructor-selection-algorithm.md). -- ~~Composition path identity for random forking~~ — resolved by - [ADR-0012](adr/0012-composition-path-identity-and-deterministic-random-forking.md): - structured `PathSegment`s (constructor parameter/member name, - collection index, dictionary key/value role), not a type-only chain. -- ~~Stability guarantees for deterministic output~~ — resolved by - [ADR-0012](adr/0012-composition-path-identity-and-deterministic-random-forking.md) - (carried forward from the now-superseded ADR-0009): same seed/same - output within a `Compono` version, not guaranteed across versions. -- ~~`CreateMany` seed derivation~~ — resolved by - [ADR-0012](adr/0012-composition-path-identity-and-deterministic-random-forking.md): - each item forks from the batch root seed by a stable `"CreateMany"` + - index key, stable regardless of the requested `count`. -- ~~Collection generation semantics (default size, key uniqueness, - ordering guarantees)~~ — resolved by - [ADR-0013](adr/0013-collection-generation-semantics.md). -- ~~Whether source-generation contracts live in `Compono` or - `Compono.Generators`~~ — resolved by - [ADR-0003](adr/0003-generator-package-distribution.md). -- **Cross-assembly plan-cache collision** — `PlanCache` (ADR-0004) and - `CollectionPlanCache` (ADR-0014) both register via - an unconditional `Instance = new ...Plan()` in a generated module - initializer; if two different consuming assemblies loaded into the same - process both discover a generated plan for the exact same closed type - (most plausible for `CollectionPlanCache`, since a BCL collection - type like `List
` is exactly the kind of type two independently - compiled assemblies could both legitimately reach if they share a - library type), whichever assembly's module initializer runs last wins - silently — module initializer order across assemblies isn't something - either `PlanCache` or `CollectionPlanCache` controls or detects. - Flagged during PR #11 review as a `CollectionPlanCache` concern, but - it's actually a `PlanCache`-level property unchanged since Milestone - 1 (ADR-0004) that `CollectionPlanCache` deliberately mirrors, not a - new defect Milestone 2 introduced — deferred as a class-of-problem - design question (assembly-qualified keys? last-wins-with-a-diagnostic? - something else?) affecting both caches uniformly, not patched narrowly - into just the newer one. Revisit if/when a real multi-assembly - collision is actually hit — no design has been chosen yet. -- **`CollectionPlanCache` rooting a collectible `AssemblyLoadContext`** - — flagged during PR #11 review. For an ordinary composable type - (`PlanCache`), if `Customer` is defined in a collectible ALC, - the CLR ties the closed generic instantiation `PlanCache` - itself to that same collectible context (a closed generic's home - context is the narrowest context spanned by its generic definition and - all of its type arguments), so the static field disappears when the ALC - unloads — no external root survives it. `CollectionPlanCache` breaks - this for a collection whose type arguments are *entirely* BCL types - (`List`, `Dictionary`): every type composing - that closed `T` lives in the non-collectible default context, so - `CollectionPlanCache>`'s instantiation also lives there — but - its generated `[ModuleInitializer]`, running from the collectible - consumer assembly, still stores an instance of a plan class *defined in - that consumer assembly* into it. The default-context static field then - permanently roots the consumer assembly (and its whole ALC), the same - leak class the `EnumValueProvider` cache fix (`ConditionalWeakTable` in place of `ConcurrentDictionary`) closed - elsewhere. That fix doesn't transfer here: `CollectionPlanCache.Instance` - is a plain closed-generic static field precisely so stage 7 dispatch is - one direct field read (ADR-0004's zero-overhead dispatch), not a - `Type`-keyed lookup; any weak-reference indirection able to key off the - consumer assembly/ALC instead of `T` would reintroduce a per-resolve - lookup on every collection, undoing the reason `CollectionPlanCache` - mirrors `PlanCache`'s shape in the first place. Deferred, consistent - with the cross-assembly-collision item above: this only manifests for a - collectible `AssemblyLoadContext` unloading a consumer assembly that - composes a BCL-only-typed collection, which neither `docs/mvp.md`'s - scope nor Compono's primary xUnit-test-runner consumer currently - exercises. Revisit alongside the collision item if collectible-ALC - hosting becomes an actual target — no design has been chosen yet. -- **`CompositionTraceBuffer`'s own array allocation, per root operation** - — flagged during PR #13 review: `CompositionTraceBuffer`'s backing - `ProviderAttempt[]` array (allocated eagerly in its constructor, one - instance per `CompositionContext`) is a genuine, unconditional - allocation on every `Create()`/`CreateMany()` item, not literally - zero — measured directly (isolated from the rest of a real composition) - at ~536 B per `CompositionTraceBuffer` instance (32-entry initial - capacity), ~20% of a real `Customer`/`Address` representative - composition's ~2.71 KB total (`ResolutionBenchmarks`, `docs/performance.md`) - — consistent with [ADR-0010](adr/0010-composition-request-pipeline-and-diagnostics-tracing.md)'s - explicit "near-zero-allocation on success, not zero-cost" framing, not - a violation of it, but real enough that this page and `docs/performance.md` - now state the precise figure instead of an unqualified "allocation-free" - claim. (The jump from an earlier-measured ~280 B is itself real, not a - re-measurement artifact — a fourth PR #13 review round restored - `ProviderAttempt.Provider`, per - [ADR-0016](adr/0016-provider-identity-restored-in-provider-attempt.md), - roughly doubling each trace entry's size; `docs/performance.md` records - that as an accepted tradeoff, not a regression.) - - A second PR #13 review round found the *growth* path is real too, not - just the fixed initial allocation: each active ancestor frame dispatching - through stage 8 or a collection plan retains ~6 trace entries (5 declined - stages plus a `CompositionAttemptOutcome.Pending` marker) until its own - child returns, so a composable-type chain more than ~5 levels deep - exceeds a 32-entry buffer and triggers a real `Array.Resize` — the - shallow, 2-level-deep `Customer` graph the original benchmark used never - exercised this at all. Fixed in two parts: the initial capacity was - bumped from 16 to 32 (covering `docs/architecture.md`'s own 4-level - Diagnostics example without resizing), and `docs/performance.md`'s - `DeepGraphBenchmarks` now measures an 8-level-deep chain that does - trigger a resize, so the growth cost is a real recorded number, not an - assumed-away one. - - A true zero-allocation design would still need the buffer pooled/reused - across root operations (`CompositionContext` isn't currently pooled or - reset-and-reused at all) — a real architecture change, not a - same-PR-sized fix. Deferred: revisit if a future benchmark shows this - mattering at a scale `docs/mvp.md`'s scope actually exercises — no - design has been chosen yet. +# Compono Architecture (moved) + +This page's content has moved to the docs site's **Architecture** section, +per [ADR-0030 Amendment 2](adr/0030-compono-documentation-architecture.md#amendment-2-2026-08-04-resolving-milestone-8s-remaining-open-items)'s +"one canonical home" principle — this file is a tombstone, not deleted +outright, because ADRs that link to it by path must stay resolvable +([ADR-0030](adr/0030-compono-documentation-architecture.md), and this +repo's own ADR-immutability rule). + +See: + +- [architecture/index.md](architecture/index.md) — overview +- [architecture/current/source-generation.md](architecture/current/source-generation.md) +- [architecture/current/generated-plans-and-discovery.md](architecture/current/generated-plans-and-discovery.md) +- [architecture/current/provider-pipeline.md](architecture/current/provider-pipeline.md) +- [architecture/current/deterministic-seeding.md](architecture/current/deterministic-seeding.md) +- [architecture/current/performance.md](architecture/current/performance.md) +- [architecture/decision-log.md](architecture/decision-log.md) diff --git a/docs/architecture/current/deterministic-seeding.md b/docs/architecture/current/deterministic-seeding.md index f3b3f61..f56d8f4 100644 --- a/docs/architecture/current/deterministic-seeding.md +++ b/docs/architecture/current/deterministic-seeding.md @@ -1,10 +1,76 @@ # Deterministic Seeding -> **Status:** Skeleton — placeholder created by Milestone 7 Phase 5's -> documentation skeleton (`docs/documentation-architecture.md`). Written in -> Milestone 8 ([PLAN-0008](../../plans/0008-milestone-8-public-preview.md)). +Resolved by [ADR-0012](../../adr/0012-composition-path-identity-and-deterministic-random-forking.md) +and [ADR-0026](../../adr/0026-deterministic-seed-derivation-for-providers.md). +[Concepts: Determinism and Seeding](../../concepts/determinism-and-seeding.md) +covers what "deterministic by design" means for a test author; this page +is the derivation algorithm itself. -The derivation algorithm itself (ADR-0012/ADR-0026) - the internals behind Concepts' conceptual page. +## Path-derived forking -See [Documentation Architecture](../../documentation-architecture.md) for this page's full audience, -contents, and relationship to the rest of the site. +The root context owns the seed. Random sources are forkable by stable +keys: + +```text +root seed +└── test parameter: command + └── Customer + └── Email +``` + +`CompositionPath` is a chain of structured `PathSegment`s — not just +types — so two constructor parameters or members of the same type +(`Customer(string FirstName, string LastName)`) fork independently +instead of colliding on an identical key. Forking hashes the structured +segment data directly (a per-kind tag plus its `Ordinal`/index — a +constructor parameter's position in the selected constructor, or a +required member's generator-assigned declaration-order index; never +`Name`, which exists on the segment for diagnostic display only) via +FNV-1a, never a formatted display string — this is what makes the fork +key collision-free by construction rather than by careful +string-escaping. This is a reproducibility *contract*, not an +implementation detail: renaming a constructor parameter or required +member (with no reordering) never changes its derived value — only +reordering does. + +That structured state feeds a small Compono-owned PRNG (not +`System.Random`), so the byte-for-byte output sequence is something +Compono controls rather than an inherited BCL implementation detail. The +stability guarantee is explicit: the same seed produces the same output +for a given `Compono` package version — cross-version stability across a +`Compono` upgrade is not promised. + +## `CreateMany` seed derivation + +`CreateMany(count)` derives each item's independent root seed by +forking the batch's root seed through a stable `"CreateMany"` key, then +by the item's index — so item `i`'s output depends only on the batch root +and `i`, never on `count`. Items 0–2 of `CreateMany(3)` and +`CreateMany(10)` (same root seed) are byte-for-byte identical. + +## `DeriveSeed()` for providers and registration factories + +A provider or registration/configuration-rule factory that needs its own +deterministic randomness — `Compono.Bogus`'s `BogusMemberNameProvider`, or +its `UseBogus(...)`/`UseBogus(...)` sugar — calls +`context.DeriveSeed()`: an on-demand, path-derived `int` a public +provider or factory can use for its own randomness, without exposing the +engine's own internal `IRandomSource` or path representation. It reuses +the same path-hash mechanism above internally, so it's just as +reproducible as the engine's own built-in resolution. + +## Failure reporting + +A composition failure's message ends with `Seed: {value}`, matching the +[Provider Pipeline](provider-pipeline.md#diagnostics)'s Diagnostics +example — a successful row does not surface its seed anywhere by default, +to keep passing-test output unchanged. +`CompositionException.WithSeedInMessage(original, seed)` is a static +factory for the one case where a `CompositionException` has no +`Diagnostic` to render a seed line from on its own (e.g. a generated +`HashSet`/`Dictionary` collection plan's unique-value-exhaustion +failure): it returns a copy of `original` whose `Message` has the seed +appended directly, with `original` preserved as `InnerException`. +`Compono.XunitV3`'s own `[Compose]` binding algorithm uses this to +guarantee every composition failure's message carries a pasteable seed, +not only ones that happen to have a `Diagnostic`. diff --git a/docs/architecture/current/generated-plans-and-discovery.md b/docs/architecture/current/generated-plans-and-discovery.md index e7f5ce6..d9a6f02 100644 --- a/docs/architecture/current/generated-plans-and-discovery.md +++ b/docs/architecture/current/generated-plans-and-discovery.md @@ -1,10 +1,85 @@ # Generated Plans and Discovery -> **Status:** Skeleton — placeholder created by Milestone 7 Phase 5's -> documentation skeleton (`docs/documentation-architecture.md`). Written in -> Milestone 8 ([PLAN-0008](../../plans/0008-milestone-8-public-preview.md)). +Resolved by [ADR-0004](../../adr/0004-composition-plan-discovery-and-dispatch.md). +This page covers how the generator decides a type needs a plan (see +[Source Generation](source-generation.md) for what a plan itself looks +like), and how `Create()` reaches it without reflection. -What a generated plan is, how it's discovered and dispatched (ADR-0004). +## Discovery -See [Documentation Architecture](../../documentation-architecture.md) for this page's full audience, -contents, and relationship to the rest of the site. +Discovery walks `Create()`/`CreateMany()` call sites and their +types' transitive constructor parameters. `[Composable]` is an opt-in +marker for a type with no local call site — applied directly to a type +this compilation owns, or at assembly level +(`[assembly: Composable(typeof(SomeType))]`) for a type in a referenced +assembly that can't be annotated directly. Both forms are equivalent +plan-generation requests, deduplicated alongside call-site discovery. + +## Dispatch + +"Registering the plan with the runtime" means a generated module +initializer populates a closed-generic static field +(`PlanCache.Instance = ...`) that `Create()` reads directly — +**not** a `typeof(T)`-keyed dictionary lookup. This is a zero-overhead +dispatch mechanism: no hashing, no lookup, just a direct field read. +[ADR-0014](../../adr/0014-generator-emitted-collection-plans.md) extends +the same mechanism to the five built-in collection shapes (array, +`List`, `IReadOnlyList`, `HashSet`, `Dictionary`) +via a parallel `CollectionPlanCache`. + +A generated plan never redispatches into itself directly — each +`context.Resolve(descriptor)` call it makes is a fresh pipeline +evaluation for whatever type that member actually is, not a recursive +call back into the same plan. A genuinely self-referencing type (e.g. a +`Node` with a `Node` property) only becomes a problem if nothing earlier +in the pipeline (an explicit value, a shared value, a registration) +terminates it before generated-plan dispatch is reached a second time for +the same type while the first invocation is still on the stack — see +[The Provider Pipeline](provider-pipeline.md#recursion-detection). + +## Open questions + +**Cross-assembly plan-cache collision.** `PlanCache` and +`CollectionPlanCache` both register via an unconditional +`Instance = new ...Plan()` in a generated module initializer. If two +different consuming assemblies loaded into the same process both +discover a generated plan for the exact same closed type — most +plausible for `CollectionPlanCache`, since a BCL collection type like +`List
` is exactly the kind of type two independently compiled +assemblies could both legitimately reach if they share a library type — +whichever assembly's module initializer runs last wins silently: module +initializer order across assemblies isn't something either cache +controls or detects. This is a `PlanCache`-level property unchanged +since [ADR-0004](../../adr/0004-composition-plan-discovery-and-dispatch.md), +not a new defect `CollectionPlanCache` introduced — deferred as a +class-of-problem design question (assembly-qualified keys? last-wins- +with-a-diagnostic? something else?) affecting both caches uniformly, not +patched narrowly into just the newer one. Revisit if/when a real +multi-assembly collision is actually hit — no design has been chosen yet. + +**`CollectionPlanCache` rooting a collectible `AssemblyLoadContext`.** +For an ordinary composable type (`PlanCache`), if `Customer` is +defined in a collectible ALC, the CLR ties the closed generic +instantiation `PlanCache` itself to that same collectible +context (a closed generic's home context is the narrowest context +spanned by its generic definition and all of its type arguments), so the +static field disappears when the ALC unloads — no external root survives +it. `CollectionPlanCache` breaks this for a collection whose type +arguments are *entirely* BCL types (`List`, +`Dictionary`): every type composing that closed `T` +lives in the non-collectible default context, so +`CollectionPlanCache>`'s instantiation also lives there — but +its generated `[ModuleInitializer]`, running from the collectible +consumer assembly, still stores an instance of a plan class *defined in +that consumer assembly* into it. The default-context static field then +permanently roots the consumer assembly (and its whole ALC) for the +process's lifetime. Any weak-reference indirection able to key off the +consumer assembly/ALC instead of `T` would reintroduce a per-resolve +lookup on every collection, undoing the reason `CollectionPlanCache` +mirrors `PlanCache`'s shape in the first place. Deferred, consistent +with the collision item above: this only manifests for a collectible +`AssemblyLoadContext` unloading a consumer assembly that composes a +BCL-only-typed collection, which neither `docs/mvp.md`'s scope nor +Compono's primary xUnit-test-runner consumer currently exercises. Revisit +alongside the collision item if collectible-ALC hosting becomes an actual +target — no design has been chosen yet. diff --git a/docs/architecture/current/performance.md b/docs/architecture/current/performance.md index 08e92a2..8d54594 100644 --- a/docs/architecture/current/performance.md +++ b/docs/architecture/current/performance.md @@ -1,10 +1,318 @@ # Performance -> **Status:** Skeleton — placeholder created by Milestone 7 Phase 5's -> documentation skeleton (`docs/documentation-architecture.md`). Written in -> Milestone 8 ([PLAN-0008](../../plans/0008-milestone-8-public-preview.md)). +Compono's source generator exists to avoid reflection-based construction +at runtime (see [Design Principles](../design-principles.md) and +[ADR-0001](../../adr/0001-source-generation-first.md)). +`benchmarks/Compono.Benchmarks` (a `BenchmarkDotNet` project) makes that a +measured claim rather than an assumption, per Milestone 1's explicit +"benchmark harness comparing generated construction with reflection +baselines" exit criteria (`docs/mvp.md`). Per +[ADR-0030 Amendment 2](../../adr/0030-compono-documentation-architecture.md#amendment-2-2026-08-04-resolving-milestone-8s-remaining-open-items)'s +benchmark-claims policy, this page's methodology, environment disclosure, +and comparisons stay fully published and reproducible — the root +`README.md` and every package's description make claims only about +Compono itself, never a comparative headline like "faster than +AutoFixture." -Methodology, caveats, and how to reproduce Compono's benchmark results. Moves here from `docs/performance.md`. +## What's measured, and what isn't yet -See [Documentation Architecture](../../documentation-architecture.md) for this page's full audience, -contents, and relationship to the rest of the site. +Milestone 1 only implements direct constructor invocation end-to-end for a +type whose constructor takes no arguments - +`ICompositionContext.Resolve()` is a placeholder that throws +`NotSupportedException` for any real value resolution +(`src/Compono/Composer.cs`). Concretely: a type with a +constructor parameter - even a parameter whose type has its own generated +plan - can't be composed end-to-end yet, because generated code resolves +every constructor argument through `context.Resolve()` +(`src/Compono.Generators/Templates/CompositionPlan.scriban`), and +dispatching that call to the matching plan is Milestone 2's provider +resolution pipeline, not built yet. + +So today's benchmark compares construction of a single flat, parameterless +type (`Leaf`) across two groups, kept deliberately separate so ecosystem +numbers can't be mistaken for the architecture's success criterion: + +- **`ArchitectureBenchmarks`** - answers "does generated construction + outperform a comparable reflection-based implementation?", the question + Milestone 1's architecture actually needs to prove: + - **Direct** - `new Leaf()`, the theoretical floor. + - **Generated** - `composer.Create()`, dispatching to a + source-generated `ICompositionPlan` via `PlanCache`. + - **Reflection** - `typeof(Leaf).GetConstructors().Single()` + + `ConstructorInfo.Invoke([])`, the direct alternative Compono's + generator replaces. +- **`EcosystemBenchmarks`** - answers a different question: "how does + Compono compare with AutoFixture, the established framework developers + reach for today?" AutoFixture does substantially more runtime work and + has different goals (randomized value generation, unexercised here since + `Leaf` has no properties to fill), so this is a recognizable reference + point users will ask about, not a target Compono is trying to "beat" - + Compono is not an AutoFixture replacement. + - **Generated** - same as above, repeated as this group's baseline. + - **AutoFixture** - `new Fixture().Create()`. + +This expanded once Milestone 2 made nested/primitive composition real - +see "Milestone 2 Phase 4: resolution-pipeline result" below for the +representative-graph comparison this section couldn't measure honestly +until then. + +## Baseline result + +Recorded at Milestone 1 Phase 4's completion, Apple M3 Max, .NET 10.0.3 +arm64 RyuJIT, Release configuration, `BenchmarkDotNet` `DefaultJob`: + +**Architecture benchmark** + +| Method | Mean | Allocated | +|------------|----------:|----------:| +| Direct | 2.309 ns | 24 B | +| Generated | 3.309 ns | 24 B | +| Reflection | 19.769 ns | 56 B | + +Generated construction ran **~6.0x faster** than the reflection baseline +and allocated the same as direct construction - within ~1 ns of the +theoretical floor. + +**Ecosystem comparison** + +| Method | Mean | Allocated | +|-------------|-------------:|----------:| +| Generated | 2.474 ns | 24 B | +| AutoFixture | 1,523.054 ns | 4,440 B | + +Generated construction ran **~616x faster** and allocated **~0.5%** as +much as AutoFixture. This gap is expected, not the point - AutoFixture is +doing real randomized-value-generation work that this flat, parameterless +type never exercises. Take it as a recognizable reference point, not +evidence the architecture benchmark above doesn't already establish on +its own. + +Numbers will shift as the composition engine grows past Milestone 1's +placeholder context - re-run and update this page rather than treating it +as a permanent result. + +## Milestone 2 Phase 4: resolution-pipeline result + +`ArchitectureBenchmarks`/`EcosystemBenchmarks` above only ever measured +generated *construction* dispatch versus reflection for a flat, +parameterless type - nothing exercised the real resolution pipeline +(provider dispatch, deterministic random forking, collection generation, +the diagnostics trace buffer) until Milestone 2 made it real. Three new +benchmark classes close that gap with the `Customer`/`Address` +representative graph from `docs/plans/0002-milestone-2-core-composition-engine.md`'s +Execution Flow section (a nested composable type, every Phase 2 built-in +kind via `string`, and a `List` collection member), run through +the real generator (`benchmarks/Compono.Benchmarks/ResolutionBenchmarkTypes.cs`), +mirroring `ArchitectureBenchmarks`/`EcosystemBenchmarks`' split so +ecosystem numbers stay separate from the architecture question: + +- **`ResolutionArchitectureBenchmarks`** - `Direct`/`Generated`/`Reflection`, + same shape as `ArchitectureBenchmarks` above. `Direct` stays the + theoretical floor (no fields to fill, same as `Leaf`), but `Reflection` + here does comparable real work to `Generated`: + `ReflectionComposer.ComposeRecursive()` fills every field with a + genuinely random value (an 8-character alphanumeric string, a + 3-element collection - Compono's own defaults), not a fixed + placeholder. The one remaining, deliberate asymmetry: `Reflection`'s + randomness is ordinary `Random.Shared`, not Compono's deterministic, + seed-forked `IRandomSource` - reproducibility is a Compono product + feature (see [Deterministic Seeding](deterministic-seeding.md)), not a + cost every random-value generator has to pay, so `Generated`'s cost + below includes work `Reflection`'s doesn't. +- **`ResolutionEcosystemBenchmarks`** - `Generated`/`AutoFixture`, same + shape as `EcosystemBenchmarks` above, except this time `Customer` + actually has fields for AutoFixture's real randomized-value-generation + work to fill (unlike `Leaf`). +- **`ResolutionBenchmarks`** - `Create`/`CreateMany` only, no external + baseline; the comparison here is intrinsic (`CreateMany`'s cost at + `count=10`/`count=100` against its own `count=1` baseline), the scaling + question `docs/plans/0002-...`'s Phase 4 benchmark task asks. This is + also the benchmark gate [ADR-0010](../../adr/0010-composition-request-pipeline-and-diagnostics-tracing.md) + reserved for the diagnostics trace buffer: confirm it's actually + allocation-free on the success path, and fall back to shallow + diagnostics by default if it measurably harms the hot path. + +Recorded at Milestone 2 Phase 4's completion (updated after a second PR #13 +review round - see below), Apple M3 Max, .NET 10.0.3 arm64 RyuJIT, Release +configuration, `BenchmarkDotNet` `DefaultJob`: + +**Resolution architecture benchmark** + +| Method | Mean | Allocated | +|------------|----------:|----------:| +| Direct | 14.17 ns | 160 B | +| Generated | 837.50 ns | 2,776 B | +| Reflection | 403.08 ns | 832 B | + +Generated resolution ran ~59.1x slower than `Direct`'s theoretical-floor +hardcoded construction and allocated ~17.3x as much - the real cost of +provider dispatch, random forking, collection generation, and diagnostics +tracing for this graph, not just constructor invocation. Against a +reflection baseline doing genuinely comparable work (real random values, +not placeholders), `Generated` ran ~2.1x slower and allocated ~3.3x as +much as `Reflection` - a real, honest gap. This is the number worth +discussing if the question is "why not just use reflection": Compono's +overhead over a comparable hand-rolled reflective composer is real, and +this table is where to look for it, not the `Direct` comparison above +(which was never a fair alternative to begin with - nobody ships +hardcoded test data). + +**Resolution ecosystem comparison** + +| Method | Mean | Allocated | +|-------------|-------------:|----------:| +| Generated | 832.50 ns | 2.71 KB | +| AutoFixture | 76,314.30 ns | 99.21 KB | + +Generated construction ran **~91.7x faster** and allocated **~2.7%** as +much as AutoFixture - this time with `Customer` actually giving +AutoFixture real randomized-value-generation work to do, unlike `Leaf`. + +**Resolution pipeline (`Create`/`CreateMany`)** + +| Method | Count | Mean | Allocated | Alloc Ratio | +|------------|------:|-------------:|-----------:|------------:| +| Create | 1 | 848.7 ns | 2.71 KB | 1.00 | +| CreateMany | 1 | 883.0 ns | 2.84 KB | 1.05 | +| Create | 10 | 853.6 ns | 2.71 KB | 1.00 | +| CreateMany | 10 | 8,898.3 ns | 27.59 KB | 10.18 | +| Create | 100 | 826.2 ns | 2.71 KB | 1.00 | +| CreateMany | 100 | 87,801.8 ns | 275.10 KB | 101.48 | + +`Create()` allocates ~2.71 KB per call regardless of +`CreateMany`'s batch size - a single root operation's cost, unaffected by +how many other independent items get composed around it in the same +process. `CreateMany(count)` scales linearly with `count` (10.18× +allocation at `count=10`, 101.48× at `count=100`, against the `count=1` +baseline) - no super-linear growth from the checkpoint/rewind trace +buffer's bookkeeping, `IRandomSource`'s per-item seed forking, or scope +allocation. + +### PR #13 review: what changed the numbers above + +A round of review found (and fixed) real issues, not just documentation +gaps: + +- **`CompositionRequest` converted from a class to a `readonly record + struct`.** It's never stored beyond the synchronous `ResolveCore` call + that builds it, so the heap allocation was pure waste. Measured directly + (constructed and consumed the same way `ResolveCore` does, passed by + `in`, not boxed): **40 B → 0 B**, **14.9 ns → 5.5 ns**. This is what + moved every table above from their original numbers (`Generated` was + 2,792 B/873.0 ns in an earlier version of this page) to the ones shown + now. +- **A generic root type rendered in raw CLR form in the diagnostic + heading and failure message** (`Unable to compose List\`1.` instead of + `List`) - fixed by reusing `CompositionPath.FriendlyTypeName` + at both call sites (`CompositionDiagnostic.ToString()`'s heading, and + the stage-9 failure message). +- **An ancestor's in-flight generated-plan/collection-plan dispatch was + silently absent from the trace** when a descendant failed - fixed by + recording a new `CompositionAttemptOutcome.Pending` immediately before + `Compose` runs, rewound away on success but surviving (correctly) when + a descendant's failure means the ancestor's own `Success` never gets + recorded either. +- **Stage 7's three built-in providers collapsed into one trace entry.** + `BuiltInProviders.Default` genuinely has three providers registered + (`PrimitiveValueProvider`, `EnumValueProvider`, `NullableValueProvider`) + — now each provider's own decline is recorded as it's tried, so a + failing request's trace shows the real number of attempts made, not a + collapsed count of 1. +- **`FriendlyTypeName` didn't handle arrays of generic types.** + `Type.IsGenericType` is `false` for an array type itself, so + `List[]` fell straight through to the raw CLR form + (`List\`1[]`). Fixed by checking `IsArray` first and recursing into + `GetElementType()`. + +**Isolating the trace buffer's own share** of `Create()`'s +2.71 KB - measured directly via `GC.GetAllocatedBytesForCurrentThread()` +around `new CompositionTraceBuffer()` in isolation, 1,000,000 iterations, +Release, .NET 10.0.3 arm64 (re-measured after bumping the buffer's initial +capacity from 16 to 32, and again after +[ADR-0016](../../adr/0016-provider-identity-restored-in-provider-attempt.md) +widened `ProviderAttempt` - see below): + +| What | Bytes/instance | +|-----------------------------------------------------------|---------------:| +| `CompositionTraceBuffer` alone (its `ProviderAttempt[32]`) | ~536 B | +| Bare `new CompositionContext()` (scope + active-frames + trace, no resolution) | ~780 B | + +Still real, not literally zero (ADR-0010's "near-zero-allocation on +success, not zero-cost," not a stronger claim than that) - and **not +bounded at that number for every graph**: see below. + +### Deep graph result + +`ResolutionBenchmarks`' `Customer`/`Address` graph is only 2 levels deep - +never enough to exercise `CompositionTraceBuffer`'s growth path, since +each active ancestor frame dispatching through stage 8/a collection plan +retains ~6 entries (5 declined stages + a `Pending` marker) until its own +child returns. `DeepGraphBenchmarks` composes an 8-level-deep chain of +single-field composable types (`DeepLevel1` through `DeepLevel8`) instead +- deep enough (~48 entries at its deepest point) to exceed the buffer's +32-entry initial capacity and trigger a real `Array.Resize`: + +| Method | Mean | Allocated | +|--------|---------:|----------:| +| Create | 846.1 ns | 3.37 KB | + +That's a genuinely deeper graph allocating *more* than the shallower, +wider `Customer` graph (2.71 KB) despite composing fewer total leaf values +(8 strings vs. `Customer`'s 7 strings + a 3-element list) - the resize is +a real, measurable contributor, not a theoretical one. The fix isn't to +pretend resizing can't happen (any growable array-backed collection +resizes eventually); it's to size the initial capacity for a typical +graph (bumped 16 → 32, covering ~5 levels of nesting without resizing - +see [The Provider Pipeline](provider-pipeline.md#diagnostics)'s own +4-level Diagnostics example) and be honest that deeper graphs still pay a +real, amortized `Array.Resize` cost, which this table now measures rather +than assumes away. + +**No fallback to shallow diagnostics was needed** - the +allocation-free-on-success trace buffer design shipped as scoped, with +the growth-path caveat above now documented rather than glossed over. + +### Provider identity restored: a real (accepted) allocation increase + +[ADR-0016](../../adr/0016-provider-identity-restored-in-provider-attempt.md) +reverses [ADR-0015](../../adr/0015-provider-identity-deferred-in-provider-attempt.md)'s +deferral: `ProviderAttempt` gained a `Type? Provider` field so a failing +request's trace can tell stage 7's three real built-in providers apart, +not just count that three attempts happened. Widening the struct +(`PipelineStage` + `Type?` + `CompositionAttemptOutcome`) roughly doubles +each trace entry's size, which is exactly what moved +`CompositionTraceBuffer`'s own isolated cost from ~280 B to ~536 B per +instance, and `Create()`'s total from 2.46 KB to 2.71 KB (+256 B, +~10%) - fully attributable to the wider struct, confirmed by the isolated +`CompositionTraceBuffer` measurement moving by almost exactly the same +256 B on its own. This is a real, accepted cost, not an oversight: three +indistinguishable `(BuiltInProvider, NotHandled)` trace entries were a +genuine diagnostic-quality gap, and paying ~10% more allocation on the +failure-adjacent bookkeeping path for a working "which provider" answer +is the tradeoff ADR-0016 accepts explicitly, not a regression to chase +back down. + +## Reproducing + +``` +dotnet run -c Release --project benchmarks/Compono.Benchmarks -f net10.0 +``` + +`-c Release` is required - `BenchmarkDotNet` refuses to run a Debug build. +Add `-- --filter "*Resolution*" "*DeepGraph*"` to run only the Milestone 2 +resolution-pipeline benchmarks (the full suite, including +`ArchitectureBenchmarks`/`EcosystemBenchmarks`, takes several minutes). + +## Open questions + +**`CompositionTraceBuffer`'s own array allocation, per root operation** is +a genuine, unconditional allocation on every `Create()`/`CreateMany()` +item, not literally zero — consistent with ADR-0010's explicit +"near-zero-allocation on success, not zero-cost" framing, but real enough +that this page states the precise figure instead of an unqualified +"allocation-free" claim. A true zero-allocation design would still need +the buffer pooled/reused across root operations +(`CompositionContext` isn't currently pooled or reset-and-reused at all) +— a real architecture change, not a same-PR-sized fix. Deferred: revisit +if a future benchmark shows this mattering at a scale `docs/mvp.md`'s +scope actually exercises — no design has been chosen yet. diff --git a/docs/architecture/current/provider-pipeline.md b/docs/architecture/current/provider-pipeline.md index fc899df..861c754 100644 --- a/docs/architecture/current/provider-pipeline.md +++ b/docs/architecture/current/provider-pipeline.md @@ -1,10 +1,135 @@ # The Provider Pipeline -> **Status:** Skeleton — placeholder created by Milestone 7 Phase 5's -> documentation skeleton (`docs/documentation-architecture.md`). Written in -> Milestone 8 ([PLAN-0008](../../plans/0008-milestone-8-public-preview.md)). +Resolved by [ADR-0010](../../adr/0010-composition-request-pipeline-and-diagnostics-tracing.md). +[Concepts: Providers](../../concepts/providers.md) covers what a provider +is conceptually; this page answers "what order do providers execute," +at full depth. -The actual stage order and how providers compose (ADR-0010) - what order providers execute, at full depth. +## Resolution order -See [Documentation Architecture](../../documentation-architecture.md) for this page's full audience, -contents, and relationship to the rest of the site. +The default resolution order is fixed — not configurable, by users or by +providers reordering themselves: + +| # | Stage | Kind | +|---|---|---| +| 1 | Explicit values | Context-owned deterministic check | +| 2 | Shared or scoped values | Context-owned deterministic check against the scope. Any request (`[Shared]` or not) sees an already-shared value for its type on read; only an `IsShared` request ever populates scope on write. | +| 3 | Exact registrations | **Hybrid**: a context-owned deterministic lookup against the exact-registration table, then — only on a miss, if `UseServiceProvider(...)` was configured — a fallback `IServiceProvider.GetService(typeof(T))` call. | +| 4 | Configuration rules | Ordered `ICompositionProvider` collection populated by type/member value rules compiled from `builder.For()...`, whether reached directly or via a profile. | +| 5 | Semantic value providers | Ordered `ICompositionProvider` collection. Public registration surface: `builder.AddSemanticProvider(ICompositionValueProvider)`. `Compono.Bogus`'s `BogusMemberNameProvider` is this stage's first real registrant. | +| 6 | Test-double providers | Ordered `ICompositionProvider` collection. Public registration surface: `builder.AddTestDoubleProvider(ICompositionValueProvider)`. `Compono.NSubstitute`'s `NSubstituteProvider` is a real registrant. | +| 7 | Built-in value providers | **Hybrid**: an ordered provider collection (primitives, enums, nullable value types) tried first, followed by a context-owned deterministic dispatch through `CollectionPlanCache` for the five built-in collection shapes. | +| 8 | Generated composition plans | Context-owned deterministic dispatch via `PlanCache` — **not** an `ICompositionProvider`; see [Generated Plans and Discovery](generated-plans-and-discovery.md). | +| 9 | Diagnostic failure | Context-owned terminal stage | + +Only stage 7 has anything registered *unconditionally* +(`BuiltInProviders.Default`); every other stage is opt-in, populated only +when a consumer actually calls `.For()` (stage 4), `UseNSubstitute()` +(stage 6), `UseBogus()` (stage 5), or registers a hand-written provider +directly. Provider order *within* an extensible stage is registration +order — stage 7 alone holds three real providers +(`PrimitiveValueProvider`, `EnumValueProvider`, `NullableValueProvider`), +so "no stage has more than one provider" isn't true today. No *richer* +ordering rule (priority, specificity) exists yet because these three +providers claim disjoint type sets — a richer rule becomes a real +question only once two providers could plausibly both claim the same +type differently. + +## Providers + +Providers satisfy composition requests within one of the extensible +pipeline stages above (4/5/6/7) — the context-owned stages (1/2/3/8/9) +are not providers. A provider reports whether it did not apply +(`NotHandled`) or successfully composed a value (`Success`); ordinary +providers **cannot** report `Failure` — that's reserved for the +context-owned authoritative stages (an exact registration whose factory +throws, or generated-plan dispatch when a plan exists but fails or a +recursion cycle is detected). The rule: `Failure` means "authoritative +ownership was established, but resolution could not complete," never a +stronger form of `NotHandled` — this is what stops a provider that merely +can't produce *this* particular request from accidentally blocking a +later stage that could have. + +**Public providers (stages 5/6).** Stages 4/7 are implemented entirely +inside `Compono` and never exposed for an outside package to author its +own. Stages 5/6 exist specifically for an integration package to +contribute open-ended, pattern-matching logic ("any interface type"), +resolved by +[ADR-0024](../../adr/0024-public-provider-extensibility-model.md): + +```csharp +public interface ICompositionValueProvider +{ + CompositionProviderResult TryProvide( + in CompositionProviderRequest request, + ICompositionContext context); +} +``` + +`CompositionProviderRequest`/`CompositionProviderResult` are decoupled +from the internal `CompositionRequest`/`CompositionResult` pair — no +path, no shared-scope flag, no pipeline plumbing a provider author has no +legitimate use for. Internally, each public provider is wrapped in a +`PublicProviderAdapter : ICompositionProvider`, so the rest of the +pipeline treats it exactly like an internal one, with diagnostics naming +the real wrapped provider's type. A thrown exception from `TryProvide` +propagates uncaught — same "exceptions signal a bug" principle as +everywhere else in this pipeline. + +## Recursion detection + +A repeated *type* appearing twice in a graph (two sibling properties of +the same type, or the same type reachable via two different paths) is +ordinary graph shape, not a cycle. A genuine cycle is a type whose +*construction* is still actively in progress when it's requested again. +Resolved by +[ADR-0011](../../adr/0011-composition-scope-shared-values-and-recursion-detection.md): +`CompositionPath` records every request edge for diagnostics and random +forking, while a distinct internal active-construction-frame stack is +pushed only around structural construction (generated-plan dispatch, +stage 8) and checked only there — after explicit values, shared/scoped +values, and exact registrations have already had a chance to terminate +the graph. A self-referencing type resolved by a registered or shared +instance never touches the recursion mechanism at all; only an actual +in-progress construction cycle does, and the resulting diagnostic reports +the chain of active frames that formed the cycle, not just a list of +repeated types. + +## Diagnostics + +Diagnostics track the root request, current request path, provider +decisions, selected plan, constructor selection, scope reuse, +registration matches, seed, failure reason, and suggested remediation: + +```text +Unable to compose CreateOrderHandler. + +CreateOrderHandler +└── IOrderProcessor processor + └── OrderValidator validator + └── IRuleProvider rules + +No registration, semantic provider, test-double provider, +built-in provider, or generated plan could satisfy IRuleProvider. + +Seed: 8492173 +``` + +This is designed to cost as little as possible on the normal successful +path — "near-zero-allocation on success, not zero-cost": a context-owned, +reusable, array-backed trace buffer (`CompositionTraceBuffer`) records a +compact struct (`ProviderAttempt`: stage, provider type, outcome — no +strings, no per-append allocation) per stage attempt, and rewinds on +success instead of retaining anything. Only a failing request +materializes its slice of that buffer into the durable +`CompositionDiagnostic` (`exception.Diagnostic`) before the buffer +unwinds further. `ProviderAttempt.Provider` is the concrete +`ICompositionProvider` type that made the attempt (`null` for a +context-owned stage, which isn't a provider instance at all) — see +[Performance](performance.md) for the measured allocation cost of this +mechanism. + +## Open questions + +**Public versus internal use of `Type`** in provider-facing contracts +remains an open design question, not yet resolved by an ADR. diff --git a/docs/architecture/current/source-generation.md b/docs/architecture/current/source-generation.md index 112a4eb..5184d97 100644 --- a/docs/architecture/current/source-generation.md +++ b/docs/architecture/current/source-generation.md @@ -1,10 +1,89 @@ # Source Generation -> **Status:** Skeleton — placeholder created by Milestone 7 Phase 5's -> documentation skeleton (`docs/documentation-architecture.md`). Written in -> Milestone 8 ([PLAN-0008](../../plans/0008-milestone-8-public-preview.md)). +Source generation is Compono's preferred construction strategy, resolved +by [ADR-0001](../../adr/0001-source-generation-first.md) — generated +composition plans are the expected execution path, not an optimization +layered on top of a reflection-based default. See +[Design Principles](../design-principles.md) for why this matters +philosophically; this page is *how* it actually works. -Why generated-first, not reflection-first (ADR-0001). +## What the generator does -See [Documentation Architecture](../../documentation-architecture.md) for this page's full audience, -contents, and relationship to the rest of the site. +For a constructible type, the generator identifies accessible +constructors, primary constructors, required members, init-only members, +nullability metadata, unsupported types, ambiguous construction paths, and +cyclic compile-time dependencies where detectable — then emits a plan +that selects the constructor, requests each argument, invokes the +constructor directly, assigns required/configured members, and preserves +nullability and member context. + +A generated plan looks like this, conceptually: + +```csharp +internal sealed class CustomerCompositionPlan + : ICompositionPlan +{ + public Customer Compose(ICompositionContext context) + { + var firstName = context.Resolve( + new CompositionRequestDescriptor( + CompositionRequestKind.ConstructorParameter, + 0, + "firstName", + Nullability.NotNullable)); + + var lastName = context.Resolve( + new CompositionRequestDescriptor( + CompositionRequestKind.ConstructorParameter, + 1, + "lastName", + Nullability.NotNullable)); + + return new Customer(firstName, lastName); + } +} +``` + +Generated code only ever calls `context.Resolve(descriptor)` per +member — it never constructs a `CompositionRequest`, touches +`CompositionPath`, or manages recursion state directly. The context owns +all of that internally (see [The Provider Pipeline](provider-pipeline.md)), +which is what makes incorrect path propagation structurally difficult +rather than merely documented against. + +At runtime, the engine executes generated plans, resolves provider-backed +values, manages scopes, manages deterministic random streams, tracks the +composition path, and produces diagnostics — see +[The Provider Pipeline](provider-pipeline.md) and +[Deterministic Seeding](deterministic-seeding.md) for those pieces in +depth, and [Generated Plans and Discovery](generated-plans-and-discovery.md) +for how a plan is actually found and dispatched to. + +## Runtime reflection policy + +Runtime reflection is intentionally **not** part of the default +architecture, and this remains an open decision — the exact opt-in +mechanism for a future compatibility mode is still undecided. Three +candidate approaches: + +**Generated plans required.** Composition fails when no generated plan +exists. Predictable performance, strong trimming/AOT characteristics, a +simple runtime model — but external or dynamically discovered types may +need explicit support, and some test scenarios may be less convenient. + +**Automatic reflection fallback.** The runtime reflects when no generated +plan exists. High compatibility and lower migration friction — but a more +complex runtime, weaker AOT guarantees, less predictable performance, and +reflection can hide real source-generation gaps instead of surfacing +them. + +**Opt-in compatibility package or mode.** Reflection support is isolated +from the default runtime. Keeps the core architecture clean, allows +compatibility where necessary, and makes performance tradeoffs explicit — +this is the current leading compromise, but it is **not yet an accepted +decision**. + +Whichever direction is chosen, reflection must never silently become the +fallback path — an explicit, compiler-visible opt-in (an MSBuild property +or a dedicated compatibility package) is the baseline requirement any of +the three candidates above already satisfies. diff --git a/docs/architecture/decision-log.md b/docs/architecture/decision-log.md index 0350ade..fbdb989 100644 --- a/docs/architecture/decision-log.md +++ b/docs/architecture/decision-log.md @@ -1,10 +1,49 @@ # Historical Decision Log -> **Status:** Skeleton — placeholder created by Milestone 7 Phase 5's -> documentation skeleton (`docs/documentation-architecture.md`). Written in -> Milestone 8 ([PLAN-0008](../plans/0008-milestone-8-public-preview.md)). +The public-facing index into [`docs/adr/`](../adr/README.md): every +`Accepted`/`Superseded` Architecture Decision Record, one line each, for a +reader who wants the full paper trail behind Compono's design. This is +**not** a duplicate of `docs/adr/README.md` (the engineering-process +index, which also tracks `Proposed` ADRs) — a status-filtered view of +those still under discussion lives in +[Roadmap: Proposed ADRs](../roadmap/proposed-adrs.md) instead. As this log +grows across years of decisions, it stays a pure historical record — +readers wanting "how it works today" belong in +[Current Architecture](current/source-generation.md), not here. -The public-facing index into `docs/adr/`: every Accepted/Superseded ADR, one line each, for a reader who wants the full paper trail. - -See [Documentation Architecture](../documentation-architecture.md) for this page's full audience, -contents, and relationship to the rest of the site. +| ADR | Title | Status | +|---|---|---| +| [0001](../adr/0001-source-generation-first.md) | Source Generation First — generated composition plans, not reflection, are the default execution model | Accepted | +| [0002](../adr/0002-constructor-selection-algorithm.md) | Constructor Selection Algorithm | Accepted | +| [0003](../adr/0003-generator-package-distribution.md) | Generator Package Distribution — `Compono.Generators` packs into `Compono`'s own nupkg, never published independently | Accepted | +| [0004](../adr/0004-composition-plan-discovery-and-dispatch.md) | Composition Plan Discovery and Dispatch | Accepted | +| [0005](../adr/0005-generator-implementation-conventions.md) | Source Generator Implementation Conventions | Accepted | +| [0006](../adr/0006-required-members-and-nullability-metadata.md) | Required Members and Nullability Metadata | Accepted | +| [0007](../adr/0007-composition-request-and-provider-pipeline.md) | Composition Request and Provider Pipeline | Superseded by [ADR-0010](../adr/0010-composition-request-pipeline-and-diagnostics-tracing.md) | +| [0008](../adr/0008-composition-scope-shared-values-and-recursion-detection.md) | Composition Scope, Shared Values, and Recursion Detection | Superseded by [ADR-0011](../adr/0011-composition-scope-shared-values-and-recursion-detection.md) | +| [0009](../adr/0009-deterministic-seed-and-forkable-random-source.md) | Deterministic Seed and Forkable Random Source | Superseded by [ADR-0012](../adr/0012-composition-path-identity-and-deterministic-random-forking.md) | +| [0010](../adr/0010-composition-request-pipeline-and-diagnostics-tracing.md) | Composition Request, Provider Pipeline, Failure Semantics, and Diagnostics Tracing | Accepted | +| [0011](../adr/0011-composition-scope-shared-values-and-recursion-detection.md) | Composition Scope, Shared Values, and Recursion Detection | Accepted | +| [0012](../adr/0012-composition-path-identity-and-deterministic-random-forking.md) | Composition Path Identity, Deterministic Random Forking, and CreateMany Seed Derivation | Accepted | +| [0013](../adr/0013-collection-generation-semantics.md) | Collection Generation Semantics | Accepted | +| [0014](../adr/0014-generator-emitted-collection-plans.md) | Generator-Emitted Collection Plans Replace the Reflection-Based Dispatch Bridge | Accepted | +| [0015](../adr/0015-provider-identity-deferred-in-provider-attempt.md) | Provider Identity Deferred in `ProviderAttempt` | Superseded by [ADR-0016](../adr/0016-provider-identity-restored-in-provider-attempt.md) | +| [0016](../adr/0016-provider-identity-restored-in-provider-attempt.md) | Provider Identity Restored in `ProviderAttempt` | Accepted | +| [0017](../adr/0017-immutable-composer-configuration-and-builder-model.md) | Immutable Composer Configuration and Builder Model | Accepted | +| [0018](../adr/0018-composition-profiles.md) | Composition Profiles — `ICompositionProfile`, eager in-order application | Accepted | +| [0019](../adr/0019-registrations-and-service-provider-injection.md) | Registrations and Service Provider Injection | Accepted | +| [0020](../adr/0020-composition-configuration-rules.md) | Composition Configuration Rules — type/member value rules and collection-size policy | Accepted | +| [0021](../adr/0021-row-composition-entry-point-for-test-framework-integrations.md) | Row Composition Entry Point for Test-Framework Integrations | Accepted | +| [0022](../adr/0022-compono-xunit-package-design.md) | Compono.Xunit Package Design | Accepted | +| [0023](../adr/0023-rename-compono-xunit-to-compono-xunitv3.md) | Rename Compono.Xunit to Compono.XunitV3 | Accepted | +| [0024](../adr/0024-public-provider-extensibility-model.md) | Public Provider Extensibility Model — `ICompositionValueProvider` for stages 5/6 | Accepted | +| [0025](../adr/0025-compono-nsubstitute-package-design.md) | Compono.NSubstitute Package Design | Accepted | +| [0026](../adr/0026-deterministic-seed-derivation-for-providers.md) | Deterministic Seed Derivation for Providers and Registration Factories | Accepted | +| [0027](../adr/0027-compono-bogus-package-design.md) | Compono.Bogus Package Design | Accepted | +| [0028](../adr/0028-configurable-bogus-member-name-conventions.md) | Configurable Bogus Member-Name Conventions | Accepted | +| [0029](../adr/0029-milestone-7-dogfooding-strategy-and-capability-gap-decision-framework.md) | Milestone 7 Dogfooding Strategy and Capability-Gap Decision Framework | Accepted | +| [0030](../adr/0030-compono-documentation-architecture.md) | Compono Documentation Architecture | Accepted | +| [0031](../adr/0031-public-preview-release-and-versioning-policy.md) | Public Preview Release and Versioning Policy | Accepted | +| [0032](../adr/0032-api-reference-documentation-toolchain.md) | API Reference Documentation Toolchain | Accepted | +| [0033](../adr/0033-public-preview-samples-strategy.md) | Public Preview Samples Strategy | Accepted | +| [0034](../adr/0034-benchmark-suite-strategy-and-redesign.md) | Benchmark Suite Strategy and Redesign — replaces the accreted benchmark suite with a categorized, audience-driven design | Accepted | diff --git a/docs/architecture/design-principles.md b/docs/architecture/design-principles.md index c4fc065..71432fe 100644 --- a/docs/architecture/design-principles.md +++ b/docs/architecture/design-principles.md @@ -1,10 +1,111 @@ # Design Principles -> **Status:** Skeleton — placeholder created by Milestone 7 Phase 5's -> documentation skeleton (`docs/documentation-architecture.md`). Written in -> Milestone 8 ([PLAN-0008](../plans/0008-milestone-8-public-preview.md)). +This page is Compono's current, evolving statement of what it believes — +revised as the project's philosophy actually evolves, not a historical +snapshot. It absorbs and retires `docs/design-principles.md` and +`docs/manifesto.md`'s original content, per +[ADR-0030 Amendment 2](../adr/0030-compono-documentation-architecture.md#amendment-2-2026-08-04-resolving-milestone-8s-remaining-open-items). +For *how* these beliefs actually shape the running system today, see +[Current Architecture](current/source-generation.md); for the sequence of +decisions that got here, see the [Historical Decision Log](decision-log.md). -Current, evolving: what Compono believes (composition over object generation, predictability over magic, source-generated by default, deterministic by design). +## Why Compono exists -See [Documentation Architecture](../documentation-architecture.md) for this page's full audience, -contents, and relationship to the rest of the site. +Test composition in .NET deserves a design grounded in the capabilities +and expectations of modern .NET, not just automatic object creation. +AutoFixture demonstrated the value of automatic object creation, +declarative test data, shared instances, and test-framework integration — +and enabled a style of testing many teams came to depend on. But the +platform has changed: records, primary constructors, required members, +nullable reference types, source generators, trimming, Native AOT, modern +test frameworks, better compile-time analysis, and stronger expectations +around determinism and diagnostics are all part of modern .NET in a way +they weren't when AutoFixture's design took shape. + +Compono asks a fresh question: if a test composition framework were +designed for modern .NET today, what should it look like? It is not +intended to reproduce AutoFixture feature-for-feature — it's a new +product focused on composing complete test environments, not just +generating objects. See [Migrating from AutoFixture](../migrating-from-autofixture.md) +for what that means concretely for an existing test suite. + +## What test composition means + +Object generation is only one part of preparing a test. A test may +require a system under test, constructor dependencies, shared instances, +test doubles, anonymous values, realistic semantic data, reusable project +conventions, framework-specific parameter binding, and deterministic +reproduction of failures. Compono treats those needs as parts of a single +composition problem: tests declare what they require, and Compono +determines how those requirements are satisfied. See +[The Composition Model](../concepts/composition-model.md) for what this +means concretely from a test author's point of view. + +## Guiding principles + +- **Composition over object generation.** Compono is not primarily a + fake-data generator or object factory — its purpose is to coordinate + every contributor involved in preparing a test (constructor + dependencies, shared instances, test doubles, semantic data), not just + fill in field values. +- **Predictability over magic.** Convenience should not come at the cost + of understanding. Resolution order is deterministic and documented (see + [The Provider Pipeline](current/provider-pipeline.md)); when composition + fails, Compono explains what was requested, why, which providers were + considered, which was selected, where resolution failed, and how the + failure can be reproduced. +- **Source-generated by default.** Compono discovers object construction + metadata at compile time and generates composition plans wherever + possible ([Source Generation](current/source-generation.md), resolved by + [ADR-0001](../adr/0001-source-generation-first.md)) — runtime execution + focuses on executing known plans rather than repeatedly inspecting types + through reflection. Runtime reflection is not part of the default + architecture; if ever supported, it requires an explicit opt-in (a + compiler-visible MSBuild property, or a dedicated compatibility + package/mode) and must never silently become the fallback path — a + still-open decision, tracked in Current Architecture. +- **Deterministic by design.** A composition is reproducible from its seed + and configuration; a failed test reports enough information to recreate + the generated values and object graph (see + [Determinism and Seeding](current/deterministic-seeding.md)). +- **Modular architecture.** The core `Compono` package must not depend on + any test framework (xUnit, NUnit, MSTest) or test-double/data library + (NSubstitute, Moq, FakeItEasy, Bogus) — those capabilities live in + integration packages built on stable, public extension contracts (see + [Package Guides](../packages/index.md)). +- **Modern .NET first.** Compono prefers a small, coherent design for + modern .NET over broad compatibility with legacy runtimes. +- **Performance is a feature.** Test infrastructure runs constantly: + composition startup time, allocations, generated-plan execution, and + test discovery overhead all matter, and are measured and protected + rather than treated as a later optimization (see + [Performance](current/performance.md)). +- **Diagnostics are a feature.** Composition failures should be + understandable without stepping through framework internals — a useful + failure is better than a clever fallback. + +## What Compono should avoid becoming + +- An AutoFixture compatibility layer +- A monolithic testing toolkit +- A service locator hidden inside tests +- A reflection-heavy runtime +- A collection of unrelated convenience APIs +- A system whose behavior depends on mutable global state +- A framework where providers silently override one another +- A source of random, irreproducible test failures +- A feature-complete wrapper over every third-party integration + +## Product priorities + +When design goals compete, Compono prioritizes, in order: a coherent test +composition model; predictable behavior and diagnostics; performance +through source-generated execution; broad compatibility and convenience. +This order may be refined as the project evolves, but convenience should +not override clarity or architectural integrity. + +## The north star + +> Test authors declare what a test needs. Compono satisfies those needs +> through explicit, deterministic, replaceable composition providers +> operating within a composition context. diff --git a/docs/architecture/index.md b/docs/architecture/index.md index c8085f5..e5f02ae 100644 --- a/docs/architecture/index.md +++ b/docs/architecture/index.md @@ -1,10 +1,33 @@ # Architecture -> **Status:** Skeleton — placeholder created by Milestone 7 Phase 5's -> documentation skeleton (`docs/documentation-architecture.md`). Written in -> Milestone 8 ([PLAN-0008](../plans/0008-milestone-8-public-preview.md)). +**Audience:** three related but distinct readers, kept visibly separate +rather than merged into one undifferentiated "Architecture" page: most +readers only need Current Architecture; contributors additionally want +the Historical Decision Log; anyone evaluating Compono's philosophy wants +Design Principles. -Why Compono exists and how it works internally - tradeoffs and rejected alternatives, split into Design Principles, Current Architecture, and the Historical Decision Log. +This section explains *why* Compono exists and how it works internally — +tradeoffs and rejected alternatives, not just a description of the +current shape. It assumes [Concepts](../concepts/index.md)'s mental model +already; every page here cross-links to the ADR(s) that made the +underlying decision rather than re-deriving the reasoning. -See [Documentation Architecture](../documentation-architecture.md) for this page's full audience, -contents, and relationship to the rest of the site. +- **[Design Principles](design-principles.md)** — current, evolving: what + Compono believes (composition over object generation, predictability + over magic, source-generated by default, deterministic by design). +- **Current Architecture** — how it works today: + [Source Generation](current/source-generation.md), + [Generated Plans and Discovery](current/generated-plans-and-discovery.md), + [The Provider Pipeline](current/provider-pipeline.md), + [Deterministic Seeding](current/deterministic-seeding.md), and + [Performance](current/performance.md). +- **[Historical Decision Log](decision-log.md)** — the public-facing + index into every `Accepted`/`Superseded` ADR, for a reader who wants the + full paper trail. + +This is `docs/architecture.md`, `docs/performance.md`, +`docs/design-principles.md`, and `docs/manifesto.md`'s real content, +consolidated here per +[ADR-0030 Amendment 2](../adr/0030-compono-documentation-architecture.md#amendment-2-2026-08-04-resolving-milestone-8s-remaining-open-items)'s +"one canonical home" principle — those four pages are now short +tombstones pointing back here. diff --git a/docs/design-principles.md b/docs/design-principles.md index 8a2db62..932aff1 100644 --- a/docs/design-principles.md +++ b/docs/design-principles.md @@ -1,26 +1,7 @@ -# Compono Design Principles +# Compono Design Principles (moved) -## Default Philosophy - -Compono is designed for modern .NET. - -Source generation is the default execution model. Generated composition plans are the expected execution path. - -## Reflection Policy - -Runtime reflection is not part of the default architecture. - -If supported in the future, it must require an explicit opt-in by the consuming project (for example via a compiler-visible MSBuild property or dedicated compatibility package). Reflection should never silently become the fallback path. - -This mirrors the philosophy used by AlexaVoxCraft's interception support: modern behavior is the default; compatibility is intentional. - -## Guiding Principles - -- Composition over object generation -- Predictability over magic -- Source-generated first -- Deterministic by default -- Diagnostics are a feature -- Performance is a feature -- Modular architecture -- Modern .NET first +This page's content has moved to +[architecture/design-principles.md](architecture/design-principles.md), +per [ADR-0030 Amendment 2](adr/0030-compono-documentation-architecture.md#amendment-2-2026-08-04-resolving-milestone-8s-remaining-open-items)'s +"one canonical home" principle — this file is a tombstone, not deleted +outright, because ADRs that link to it by path must stay resolvable. diff --git a/docs/documentation-architecture.md b/docs/documentation-architecture.md index 3d67f5d..13c19ed 100644 --- a/docs/documentation-architecture.md +++ b/docs/documentation-architecture.md @@ -171,15 +171,18 @@ docs/ └── contributing.md # not yet created (Milestone 8 scope) ``` -`docs/manifesto.md` and `docs/public-api.md` exist today but are **not** -part of this canonical tree — per +`docs/manifesto.md`, `docs/public-api.md`, `docs/architecture.md`, +`docs/design-principles.md`, and `docs/performance.md` existed before +this hierarchy but are **not** part of this canonical tree — per [ADR-0030 Amendment 2](adr/0030-compono-documentation-architecture.md#amendment-2-2026-08-04-resolving-milestone-8s-remaining-open-items), -both are retired once their content is fully redistributed into -`architecture/design-principles.md`/`architecture/` + `reference/` -(Milestone 8 Phase 5), not kept as a permanent third location for the -same material. Shown struck through in the Open Items list below rather -than left in the tree, so this diagram matches what Phase 7's -`mkdocs.yml` nav check actually verifies against. +all five are retired as of Milestone 8 Phase 5: their content is fully +redistributed into `architecture/` (mostly `design-principles.md` and +`current/`) and their `mkdocs.yml` "(legacy)" nav entries removed, each +original replaced with a short redirect/tombstone stub rather than kept +as a permanent third location for the same material. Shown struck +through in the Open Items list below rather than left in the tree, so +this diagram matches what Phase 7's `mkdocs.yml` nav check actually +verifies against. Internal, non-public artifacts that stay exactly where they are and do **not** join the site nav: `docs/adr/` (source of truth @@ -436,11 +439,14 @@ rather than merged into one undifferentiated "Architecture" page (Amendment 1): most users only need **Current Architecture**; contributors additionally want the **Historical Decision Log**; anyone evaluating Compono's philosophy wants **Design Principles**. -**Status:** skeleton exists for all 8 pages in the structure below -(placeholder only); separately, `docs/architecture.md`, `docs/performance.md`, -`docs/design-principles.md`, `docs/manifesto.md`, `docs/public-api.md` -have real content today but pre-date this hierarchy — consolidating that -real content into the skeleton below is Milestone 8 work. +**Status:** real content for all 8 pages, written in +[PLAN-0008](plans/0008-milestone-8-public-preview.md) Phase 5 — +`docs/architecture.md`, `docs/performance.md`, `docs/design-principles.md`, +and `docs/manifesto.md`'s pre-hierarchy content is now consolidated here; +those originals (plus `docs/public-api.md`, whose content was already +redistributed into Concepts/How-to Guides/Package Guides/Reference during +Phases 2-3) are short tombstone stubs, per +[ADR-0030 Amendment 2](adr/0030-compono-documentation-architecture.md#amendment-2-2026-08-04-resolving-milestone-8s-remaining-open-items). **Purpose:** why Compono exists and how it works internally — tradeoffs and rejected alternatives, not just a description of the current shape. **Contents:** three explicitly separate parts: @@ -520,9 +526,10 @@ sections, not a job Reference should grow into filling. ## 12. Roadmap **Audience:** anyone asking "is X available, experimental, or planned?" -**Status:** skeleton exists for `index.md`/`proposed-adrs.md`/ -`future-packages.md` (placeholder only). `docs/roadmap/post-mvp.md` is -real content, produced by +**Status:** real content for `index.md`/`proposed-adrs.md`/ +`future-packages.md`, written in +[PLAN-0008](plans/0008-milestone-8-public-preview.md) Phase 5. +`docs/roadmap/post-mvp.md` is real content, produced earlier by [PLAN-0007](plans/0007-milestone-7-dogfooding.md) Phase 3 — currently stating a zero-roadmap-candidate outcome and pointing to `docs/research/0001-autofixture-comparison.md` for the full per-finding diff --git a/docs/manifesto.md b/docs/manifesto.md index 270774f..b7d2efa 100644 --- a/docs/manifesto.md +++ b/docs/manifesto.md @@ -1,150 +1,7 @@ -# Compono Manifesto +# Compono Manifesto (moved) -## Why Compono Exists - -Compono exists because test composition in .NET deserves a design grounded in the capabilities and expectations of modern .NET. - -AutoFixture demonstrated the value of automatic object creation, declarative test data, shared instances, and test-framework integration. It also enabled a style of testing that many teams came to depend on. - -But the platform has changed. - -Modern .NET now includes: - -- Records -- Primary constructors -- Required members -- Nullable reference types -- Source generators -- Trimming -- Native AOT -- Modern test frameworks -- Better compile-time analysis -- Stronger expectations around determinism and diagnostics - -Compono asks a fresh question: - -> If a test composition framework were designed for modern .NET today, what should it look like? - -Compono is not intended to reproduce AutoFixture feature-for-feature. It is a new product focused on composing complete test environments. - -## What Test Composition Means - -Object generation is only one part of preparing a test. - -A test may require: - -- A system under test -- Constructor dependencies -- Shared instances -- Test doubles -- Anonymous values -- Realistic semantic data -- Reusable project conventions -- Framework-specific parameter binding -- Deterministic reproduction of failures - -Compono treats those needs as parts of a single composition problem. - -Tests declare what they require. Compono determines how those requirements are satisfied. - -## Core Principles - -### Composition over generation - -Compono is not primarily a fake-data generator or object factory. - -Its purpose is to coordinate every contributor involved in preparing a test. - -### Predictability over magic - -Convenience should not come at the cost of understanding. - -Resolution order must be deterministic and documented. When composition fails, Compono should explain: - -- What was requested -- Why it was requested -- Which providers were considered -- Which provider was selected -- Where resolution failed -- How the failure can be reproduced - -### Source-generated first - -Compono should discover object construction metadata at compile time and generate composition plans wherever possible. - -Runtime execution should focus on executing known plans rather than repeatedly inspecting types. - -Runtime reflection remains an explicit architectural decision. It may eventually be: - -- Disallowed -- Supported as a compatibility fallback -- Available only through an opt-in package or mode - -Until that decision is finalized, reflection must not become an accidental dependency of the core design. - -### Deterministic by default - -A composition should be reproducible from its seed and configuration. - -A failed test should report enough information to recreate the generated values and object graph. - -### Modular by design - -The core package must not depend on: - -- xUnit -- NUnit -- MSTest -- NSubstitute -- Moq -- FakeItEasy -- Bogus - -Those capabilities belong in integration packages built on stable extension contracts. - -### Modern .NET first - -Compono should prefer a small, coherent design for modern .NET over broad compatibility with legacy runtimes. - -### Performance is a feature - -Test infrastructure runs constantly. - -Composition startup time, allocations, generated-plan execution, and test discovery overhead all matter. - -Performance should be measured and protected rather than treated as a later optimization. - -### Diagnostics are a product feature - -Composition failures should be understandable without stepping through framework internals. - -A useful failure is better than a clever fallback. - -## What Compono Should Avoid Becoming - -Compono should not become: - -- An AutoFixture compatibility layer -- A monolithic testing toolkit -- A service locator hidden inside tests -- A reflection-heavy runtime -- A collection of unrelated convenience APIs -- A system whose behavior depends on mutable global state -- A framework where providers silently override one another -- A source of random, irreproducible test failures -- A feature-complete wrapper over every third-party integration - -## Product Priorities - -When design goals compete, Compono should prioritize: - -1. A coherent test composition model -2. Predictable behavior and diagnostics -3. Performance through source-generated execution -4. Broad compatibility and convenience - -This order may be refined as the project evolves, but convenience should not override clarity or architectural integrity. - -## The North Star - -> Test authors declare what a test needs. Compono satisfies those needs through explicit, deterministic, replaceable composition providers operating within a composition context. +This page's content has moved to +[architecture/design-principles.md](architecture/design-principles.md), +per [ADR-0030 Amendment 2](adr/0030-compono-documentation-architecture.md#amendment-2-2026-08-04-resolving-milestone-8s-remaining-open-items)'s +"one canonical home" principle — this file is a tombstone, not deleted +outright, because ADRs that link to it by path must stay resolvable. diff --git a/docs/packages/compono.md b/docs/packages/compono.md index bfec69c..3f604a6 100644 --- a/docs/packages/compono.md +++ b/docs/packages/compono.md @@ -4,8 +4,8 @@ The core composition engine — `Composer`, the resolution pipeline, and the source generator. Every other Compono package depends on this one; this one depends on nothing else in the ecosystem — the core package never references or knows about an integration package (see -[Public API: Configuration](../public-api.md#configuration), "the core -package must not know those methods exist"). +[Design Principles](../architecture/design-principles.md#guiding-principles), +"modular architecture"). ## When to install diff --git a/docs/performance.md b/docs/performance.md index 030172f..ed5aeff 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -1,329 +1,7 @@ -# Performance +# Performance (moved) -Compono's source generator exists to avoid reflection-based construction -at runtime (`docs/manifesto.md`, `docs/adr/0001-source-generation-first.md`). -`benchmarks/Compono.Benchmarks` (a `BenchmarkDotNet` project) makes that a -measured claim rather than an assumption, per Milestone 1's explicit -"benchmark harness comparing generated construction with reflection -baselines" exit criteria (`docs/mvp.md`). - -## What's measured, and what isn't yet - -Milestone 1 only implements direct constructor invocation end-to-end for a -type whose constructor takes no arguments - -`ICompositionContext.Resolve()` is a placeholder that throws -`NotSupportedException` for any real value resolution -(`src/Compono/Composer.cs`). Concretely: a type with a -constructor parameter - even a parameter whose type has its own generated -plan - can't be composed end-to-end yet, because generated code resolves -every constructor argument through `context.Resolve()` -(`src/Compono.Generators/Templates/CompositionPlan.scriban`), and -dispatching that call to the matching plan is Milestone 2's provider -resolution pipeline, not built yet. - -So today's benchmark compares construction of a single flat, parameterless -type (`Leaf`) across two groups, kept deliberately separate so ecosystem -numbers can't be mistaken for the architecture's success criterion: - -- **`ArchitectureBenchmarks`** - answers "does generated construction - outperform a comparable reflection-based implementation?", the question - Milestone 1's architecture actually needs to prove: - - **Direct** - `new Leaf()`, the theoretical floor. - - **Generated** - `composer.Create()`, dispatching to a - source-generated `ICompositionPlan` via `PlanCache`. - - **Reflection** - `typeof(Leaf).GetConstructors().Single()` + - `ConstructorInfo.Invoke([])`, the direct alternative Compono's - generator replaces. -- **`EcosystemBenchmarks`** - answers a different question: "how does - Compono compare with AutoFixture, the established framework developers - reach for today?" AutoFixture does substantially more runtime work and - has different goals (randomized value generation, unexercised here since - `Leaf` has no properties to fill), so this is a recognizable reference - point users will ask about, not a target Compono is trying to "beat" - - Compono is not an AutoFixture replacement. - - **Generated** - same as above, repeated as this group's baseline. - - **AutoFixture** - `new Fixture().Create()`. - -This expanded once Milestone 2 made nested/primitive composition real - -see "Milestone 2 Phase 4: resolution-pipeline result" below for the -representative-graph comparison this section couldn't measure honestly -until then. - -## Baseline result - -Recorded at Milestone 1 Phase 4's completion, Apple M3 Max, .NET 10.0.3 -arm64 RyuJIT, Release configuration, `BenchmarkDotNet` `DefaultJob`: - -**Architecture benchmark** - -| Method | Mean | Allocated | -|------------|----------:|----------:| -| Direct | 2.309 ns | 24 B | -| Generated | 3.309 ns | 24 B | -| Reflection | 19.769 ns | 56 B | - -Generated construction ran **~6.0x faster** than the reflection baseline -and allocated the same as direct construction - within ~1 ns of the -theoretical floor. - -**Ecosystem comparison** - -| Method | Mean | Allocated | -|-------------|-------------:|----------:| -| Generated | 2.474 ns | 24 B | -| AutoFixture | 1,523.054 ns | 4,440 B | - -Generated construction ran **~616x faster** and allocated **~0.5%** as -much as AutoFixture. This gap is expected, not the point - AutoFixture is -doing real randomized-value-generation work that this flat, parameterless -type never exercises. Take it as a recognizable reference point, not -evidence the architecture benchmark above doesn't already establish on -its own. - -Numbers will shift as the composition engine grows past Milestone 1's -placeholder context - re-run and update this page rather than treating it -as a permanent result. - -## Milestone 2 Phase 4: resolution-pipeline result - -`ArchitectureBenchmarks`/`EcosystemBenchmarks` above only ever measured -generated *construction* dispatch versus reflection for a flat, -parameterless type - nothing exercised the real resolution pipeline -(provider dispatch, deterministic random forking, collection generation, -the diagnostics trace buffer) until Milestone 2 made it real. Three new -benchmark classes close that gap with the `Customer`/`Address` -representative graph from `docs/plans/0002-milestone-2-core-composition-engine.md`'s -Execution Flow section (a nested composable type, every Phase 2 built-in -kind via `string`, and a `List` collection member), run through -the real generator (`benchmarks/Compono.Benchmarks/ResolutionBenchmarkTypes.cs`), -mirroring `ArchitectureBenchmarks`/`EcosystemBenchmarks`' split so -ecosystem numbers stay separate from the architecture question: - -- **`ResolutionArchitectureBenchmarks`** - `Direct`/`Generated`/`Reflection`, - same shape as `ArchitectureBenchmarks` above. `Direct` stays the - theoretical floor (no fields to fill, same as `Leaf`), but `Reflection` - here does comparable real work to `Generated`: - `ReflectionComposer.ComposeRecursive()` fills every field with a - genuinely random value (an 8-character alphanumeric string, a - 3-element collection - Compono's own defaults), not a fixed - placeholder. An earlier version of this baseline used fixed - placeholders, which made `Reflection` faster than `Generated` for - doing categorically less work rather than because reflective dispatch - actually beats source-generated dispatch - a misleading comparison - caught in PR #13 review and fixed by rewriting the baseline to do real - value generation. The one remaining, deliberate asymmetry: - `Reflection`'s randomness is ordinary `Random.Shared`, not Compono's - deterministic, seed-forked `IRandomSource` - reproducibility is a - Compono product feature (`README.md`'s "Deterministic by design"), not - a cost every random-value generator has to pay, so `Generated`'s cost - below includes work `Reflection`'s doesn't. -- **`ResolutionEcosystemBenchmarks`** - `Generated`/`AutoFixture`, same - shape as `EcosystemBenchmarks` above, except this time `Customer` - actually has fields for AutoFixture's real randomized-value-generation - work to fill (unlike `Leaf`). -- **`ResolutionBenchmarks`** - `Create`/`CreateMany` only, no external - baseline; the comparison here is intrinsic (`CreateMany`'s cost at - `count=10`/`count=100` against its own `count=1` baseline), the scaling - question `docs/plans/0002-...`'s Phase 4 benchmark task asks. This is - also the benchmark gate [ADR-0010](adr/0010-composition-request-pipeline-and-diagnostics-tracing.md) - reserved for the diagnostics trace buffer: confirm it's actually - allocation-free on the success path, and fall back to shallow - diagnostics by default if it measurably harms the hot path. - -Recorded at Milestone 2 Phase 4's completion (updated after a second PR #13 -review round - see below), Apple M3 Max, .NET 10.0.3 arm64 RyuJIT, Release -configuration, `BenchmarkDotNet` `DefaultJob`: - -**Resolution architecture benchmark** - -| Method | Mean | Allocated | -|------------|----------:|----------:| -| Direct | 14.17 ns | 160 B | -| Generated | 837.50 ns | 2,776 B | -| Reflection | 403.08 ns | 832 B | - -Generated resolution ran ~59.1x slower than `Direct`'s theoretical-floor -hardcoded construction and allocated ~17.3x as much - the real cost of -provider dispatch, random forking, collection generation, and diagnostics -tracing for this graph, not just constructor invocation. Against a -reflection baseline doing genuinely comparable work (real random values, -not placeholders), `Generated` ran ~2.1x slower and allocated ~3.3x as -much as `Reflection` - a real, honest gap. This is the number worth -discussing if the question is "why not just use reflection": Compono's -overhead over a comparable hand-rolled reflective composer is real, and -this table is where to look for it, not the `Direct` comparison above -(which was never a fair alternative to begin with - nobody ships -hardcoded test data). - -**Resolution ecosystem comparison** - -| Method | Mean | Allocated | -|-------------|-------------:|----------:| -| Generated | 832.50 ns | 2.71 KB | -| AutoFixture | 76,314.30 ns | 99.21 KB | - -Generated construction ran **~91.7x faster** and allocated **~2.7%** as -much as AutoFixture - this time with `Customer` actually giving -AutoFixture real randomized-value-generation work to do, unlike `Leaf`. - -**Resolution pipeline (`Create`/`CreateMany`)** - -| Method | Count | Mean | Allocated | Alloc Ratio | -|------------|------:|-------------:|-----------:|------------:| -| Create | 1 | 848.7 ns | 2.71 KB | 1.00 | -| CreateMany | 1 | 883.0 ns | 2.84 KB | 1.05 | -| Create | 10 | 853.6 ns | 2.71 KB | 1.00 | -| CreateMany | 10 | 8,898.3 ns | 27.59 KB | 10.18 | -| Create | 100 | 826.2 ns | 2.71 KB | 1.00 | -| CreateMany | 100 | 87,801.8 ns | 275.10 KB | 101.48 | - -`Create()` allocates ~2.71 KB per call regardless of -`CreateMany`'s batch size - a single root operation's cost, unaffected by -how many other independent items get composed around it in the same -process. `CreateMany(count)` scales linearly with `count` (10.18× -allocation at `count=10`, 101.48× at `count=100`, against the `count=1` -baseline) - no super-linear growth from the checkpoint/rewind trace -buffer's bookkeeping, `IRandomSource`'s per-item seed forking, or scope -allocation. - -### A second PR #13 review round: real fixes, not just documentation - -A follow-up review pass found three more real issues (all fixed) beyond -the trace-buffer-isolation point already addressed above, plus a -separately-requested optimization: - -- **`CompositionRequest` converted from a class to a `readonly record - struct`.** It's never stored beyond the synchronous `ResolveCore` call - that builds it, so the heap allocation was pure waste. Measured directly - (constructed and consumed the same way `ResolveCore` does, passed by - `in`, not boxed): **40 B → 0 B**, **14.9 ns → 5.5 ns**. This is what - moved every table above from their original numbers (`Generated` was - 2,792 B/873.0 ns in the first version of this page) to the ones shown - now. -- **A generic root type rendered in raw CLR form in the diagnostic - heading and failure message** (`Unable to compose List\`1.` instead of - `List`) - the same class of bug `CompositionPath.FriendlyTypeName` - was added to fix for the path tree, just missed at two more call sites - (`CompositionDiagnostic.ToString()`'s heading, and the stage-9 failure - `Message` text). Fixed by reusing the same helper at both sites. -- **An ancestor's in-flight generated-plan/collection-plan dispatch was - silently absent from the trace** when a descendant failed - `Success` - was (correctly, per the earlier fix) only ever recorded *after* - `Compose` returned, but that meant an ancestor still waiting on a - failing descendant never got *any* entry for its own stage-8/collection - dispatch. Fixed by recording a new `CompositionAttemptOutcome.Pending` - immediately before `Compose` runs - rewound away on success exactly like - every other entry, but surviving (correctly) when a descendant's failure - means the ancestor's own `Success` never gets recorded either. -- **The trace buffer's growth path wasn't measured** - only the shallow - `Customer` graph (2 levels deep) had been benchmarked, which never gets - deep enough to trigger a resize. See "Deep graph result" below for a - graph that does. - -### A third PR #13 review round: two more real gaps, no benchmark impact - -A third pass found two more real issues - both fixed, neither changing -any number in this page, since they're trace-fidelity/formatting -corrections, not allocation or timing changes (re-measured to confirm: -every table above is unchanged within normal run-to-run noise). - -- **Stage 7's three built-in providers collapsed into one trace entry.** - `BuiltInProviders.Default` genuinely has three providers registered - today (`PrimitiveValueProvider`, `EnumValueProvider`, - `NullableValueProvider`) - directly contradicting a claim this page's - own earlier review-round text (and `docs/architecture.md`, now also - fixed) had made: "no stage has more than one competing provider yet." - That claim was simply wrong for stage 7. `TryProviders` used to let its - caller record a single aggregate `NotHandled` for the whole stage - regardless of how many providers actually declined - now each - provider's own decline is recorded as it's tried, so a failing - request's trace shows the real number of attempts made, not a - collapsed count of 1. -- **`FriendlyTypeName` didn't handle arrays of generic types.** `Type.IsGenericType` - is `false` for an array type itself (array-ness and generic-ness are - orthogonal in reflection), so `List[]` fell straight through - to the raw CLR form (`List\`1[]`) despite `FriendlyTypeName` existing - specifically to prevent that. Fixed by checking `IsArray` first and - recursing into `GetElementType()`. - -**Isolating the trace buffer's own share** of `Create()`'s -2.71 KB - measured directly via `GC.GetAllocatedBytesForCurrentThread()` -around `new CompositionTraceBuffer()` in isolation, 1,000,000 iterations, -Release, .NET 10.0.3 arm64 (re-measured after bumping the buffer's initial -capacity from 16 to 32, and again after -[ADR-0016](adr/0016-provider-identity-restored-in-provider-attempt.md) -widened `ProviderAttempt` - see below): - -| What | Bytes/instance | -|-----------------------------------------------------------|---------------:| -| `CompositionTraceBuffer` alone (its `ProviderAttempt[32]`) | ~536 B | -| Bare `new CompositionContext()` (scope + active-frames + trace, no resolution) | ~780 B | - -Still real, not literally zero (`ADR-0010`'s "near-zero-allocation on -success, not zero-cost," not a stronger claim than that) - and, per the -second review round, **not bounded at that number for every graph**: see -below. A true zero-allocation trace buffer would need pooling/reuse across -root operations - `docs/architecture.md`'s Open Architectural Decisions -tracks this as a deferred item, not a same-PR fix. - -### Deep graph result - -`ResolutionBenchmarks`' `Customer`/`Address` graph is only 2 levels deep - -never enough to exercise `CompositionTraceBuffer`'s growth path, since -each active ancestor frame dispatching through stage 8/a collection plan -retains ~6 entries (5 declined stages + a `Pending` marker) until its own -child returns. `DeepGraphBenchmarks` composes an 8-level-deep chain of -single-field composable types (`DeepLevel1` through `DeepLevel8`) instead -- deep enough (~48 entries at its deepest point) to exceed the buffer's -32-entry initial capacity and trigger a real `Array.Resize`: - -| Method | Mean | Allocated | -|--------|---------:|----------:| -| Create | 846.1 ns | 3.37 KB | - -That's a genuinely deeper graph allocating *more* than the shallower, -wider `Customer` graph (2.71 KB) despite composing fewer total leaf values -(8 strings vs. `Customer`'s 7 strings + a 3-element list) - the resize is -a real, measurable contributor, not a theoretical one. The fix isn't to -pretend resizing can't happen (any growable array-backed collection -resizes eventually); it's to size the initial capacity for a typical -graph (bumped 16 → 32, covering ~5 levels of nesting without resizing - -`docs/architecture.md`'s own Diagnostics example is 4 levels) and be -honest that deeper graphs still pay a real, amortized `Array.Resize` cost, -which this table now measures rather than assumes away. - -**No fallback to shallow diagnostics was needed** - the -allocation-free-on-success trace buffer design shipped as scoped, with -the growth-path caveat above now documented rather than glossed over. - -### A fourth PR #13 review round: provider identity restored, a real (accepted) allocation increase - -Unlike the third round, this one *does* move every number above - -honestly, not glossed over. [ADR-0016](adr/0016-provider-identity-restored-in-provider-attempt.md) -reverses [ADR-0015](adr/0015-provider-identity-deferred-in-provider-attempt.md)'s -deferral: `ProviderAttempt` gained a `Type? Provider` field so a failing -request's trace can tell stage 7's three real built-in providers apart, -not just count that three attempts happened. Widening the struct -(`PipelineStage` + `Type?` + `CompositionAttemptOutcome`) roughly doubles -each trace entry's size, which is exactly what moved -`CompositionTraceBuffer`'s own isolated cost from ~280 B to ~536 B per -instance, and `Create()`'s total from 2.46 KB to 2.71 KB (+256 B, -~10%) - fully attributable to the wider struct, confirmed by the isolated -`CompositionTraceBuffer` measurement moving by almost exactly the same -256 B on its own. This is a real, accepted cost, not an oversight: three -indistinguishable `(BuiltInProvider, NotHandled)` trace entries were a -genuine diagnostic-quality gap, and paying ~10% more allocation on the -failure-adjacent bookkeeping path for a working "which provider" answer -is the tradeoff ADR-0016 accepts explicitly, not a regression to chase -back down. - -## Reproducing - -``` -dotnet run -c Release --project benchmarks/Compono.Benchmarks -f net10.0 -``` - -`-c Release` is required - `BenchmarkDotNet` refuses to run a Debug build. -Add `-- --filter "*Resolution*" "*DeepGraph*"` to run only the Milestone 2 -resolution-pipeline benchmarks (the full suite, including -`ArchitectureBenchmarks`/`EcosystemBenchmarks`, takes several minutes). +This page's content has moved to +[architecture/current/performance.md](architecture/current/performance.md), +per [ADR-0030 Amendment 2](adr/0030-compono-documentation-architecture.md#amendment-2-2026-08-04-resolving-milestone-8s-remaining-open-items)'s +"one canonical home" principle — this file is a tombstone, not deleted +outright, because ADRs that link to it by path must stay resolvable. diff --git a/docs/plans/0002-milestone-2-core-composition-engine.md b/docs/plans/0002-milestone-2-core-composition-engine.md index f68feb0..66bd3b1 100644 --- a/docs/plans/0002-milestone-2-core-composition-engine.md +++ b/docs/plans/0002-milestone-2-core-composition-engine.md @@ -1370,9 +1370,9 @@ ADR-0014. The task list below reflects the corrected shape. class' XML doc `` and `docs/performance.md`, the mirror image of the AutoFixture caveat. Full tables and reproduction steps recorded permanently in - [`docs/performance.md`](../performance.md#milestone-2-phase-4-resolution-pipeline-result) - (`docs/architecture.md`'s Diagnostics section links there too, rather - than duplicating the table). + [`architecture/current/performance.md`](../architecture/current/performance.md#milestone-2-phase-4-resolution-pipeline-result) + (`architecture/current/provider-pipeline.md`'s Diagnostics section links + there too, rather than duplicating the table). - A PR #13 review round (Codex) found three real issues, all fixed in the same PR. **Trace buffer's own allocation was asserted, not measured (P1).** The Phase 4 benchmark result above reported total allocation diff --git a/docs/plans/0008-milestone-8-public-preview.md b/docs/plans/0008-milestone-8-public-preview.md index 6635f4e..5c87f1c 100644 --- a/docs/plans/0008-milestone-8-public-preview.md +++ b/docs/plans/0008-milestone-8-public-preview.md @@ -444,28 +444,33 @@ packages). `reusing-configuration.md`, `performance-recommendations.md`, `deterministic-and-non-brittle-tests.md`. -## Phase 5: Architecture consolidation and legacy retirement +## Phase 5: Architecture consolidation, legacy retirement, and benchmark suite redesign -**Status:** Not Started +**Status:** In Progress -Executes ADR-0030 Amendment 2's "one canonical home" principle. Sequenced -after Phase 2 (Concepts must exist for Architecture pages to cross-link -back to) — the last phase touching `docs/architecture.md`/ -`docs/performance.md`/`docs/design-principles.md`/`docs/manifesto.md`/ -`docs/public-api.md`'s real pre-existing content, so it can safely -consume and then retire them. +Executes ADR-0030 Amendment 2's "one canonical home" principle for the +architecture/roadmap documentation, and — added after this phase's +doc-consolidation work was already done, per direct review of the +existing benchmark suite — [ADR-0034](../adr/0034-benchmark-suite-strategy-and-redesign.md)'s +full benchmark-suite redesign. Sequenced after Phase 2 (Concepts must +exist for Architecture pages to cross-link back to) — the last phase +touching `docs/architecture.md`/`docs/performance.md`/ +`docs/design-principles.md`/`docs/manifesto.md`/`docs/public-api.md`'s +real pre-existing content, so it can safely consume and then retire +them. -- [ ] `architecture/index.md`, `architecture/design-principles.md` +### Part A: Architecture and roadmap documentation (done) + +- [x] `architecture/index.md`, `architecture/design-principles.md` (absorbs `docs/design-principles.md`/`docs/manifesto.md`'s content). -- [ ] `architecture/current/source-generation.md`, +- [x] `architecture/current/source-generation.md`, `generated-plans-and-discovery.md`, `provider-pipeline.md`, - `deterministic-seeding.md`, `performance.md` (moves - `docs/performance.md`'s real methodology/results, publishing them - per ADR-0030 Amendment 2's benchmark-claims policy). -- [ ] `architecture/decision-log.md` (public-facing index into - `docs/adr/`). -- [ ] Retire **all five** pre-existing legacy pages this phase + `deterministic-seeding.md` (real content, migrated from + `docs/architecture.md`). +- [x] `architecture/decision-log.md` (public-facing index into + `docs/adr/`, including [ADR-0034](../adr/0034-benchmark-suite-strategy-and-redesign.md)). +- [x] Retire **all five** pre-existing legacy pages this phase consolidates — `docs/public-api.md`, `docs/manifesto.md`, `docs/architecture.md`, `docs/design-principles.md`, and `docs/performance.md` — from navigation and canonical-content @@ -483,10 +488,89 @@ consume and then retire them. plan can't touch. (An earlier version of this task only tombstoned the first two — the same problem applies identically to the other three, since they're excluded from the canonical tree the same way.) -- [ ] `roadmap/index.md`, `roadmap/proposed-adrs.md`, +- [x] `roadmap/index.md`, `roadmap/proposed-adrs.md`, `roadmap/future-packages.md` (`roadmap/post-mvp.md` already real content from PLAN-0007 Phase 3 — just needs its nav confirmed). +### Part B: Benchmark suite redesign (per ADR-0034 — not started) + +`architecture/current/performance.md` currently still documents the +**old** benchmark suite (migrated as-is from `docs/performance.md` during +Part A, before the redesign decision below was made) — it gets +overwritten by this Part's last task, not before. Sequencing matters: +design and implement the new suite first, produce real results from it, +*then* write the page — this phase does not narrate a redesign still in +progress. + +- [ ] Design the benchmark strategy from first principles: the question + set, audiences, and category taxonomy — done as + [ADR-0034](../adr/0034-benchmark-suite-strategy-and-redesign.md) + (`Accepted`). No further design task here; this line just records + that the ADR is this sub-phase's starting point, not a step still + to do. +- [ ] Define and implement the reused `Models/` set (`SimplePoco`, + `MediumAggregate`, `DeepGraph`, `LargeCollection`, + `SharedValueGraph`, `ProviderBackedModel`) and `Baselines/` + (`HandwrittenComposer`, `CachedReflectionComposer`, + `UncachedReflectionComposer`, `AutoFixtureComposer`) — replacing + `BenchmarkTypes.cs`, `ResolutionBenchmarkTypes.cs`, + `ReflectionComposer.cs`, `AutoFixtureComposer.cs`. +- [ ] Implement `ImplementationStrategies/` (construction-technique + benchmarks: handwritten construction as the theoretical ceiling, + generated composition, cached reflection, uncached reflection — + in that order, parameterized across the model set) — replaces + `ArchitectureBenchmarks.cs` and `ResolutionArchitectureBenchmarks.cs`. +- [ ] Implement `ConsumerScenarios/` (simple POCO, medium aggregate, deep + graph, large collection, shared value, Bogus-enabled, + NSubstitute-enabled) — new; no prior equivalent existed. +- [ ] Implement `ExternalComparison/` (AutoFixture, equivalent work + across the same model set, both-directions honest reporting) — + replaces `EcosystemBenchmarks.cs` and + `ResolutionEcosystemBenchmarks.cs`. +- [ ] Implement `FeatureOverhead/` (additive layering: generated only → + + shared values → + member rules → + type rules → + providers → + + `UseBogus()` → + `UseNSubstitute()`) — new; no prior equivalent + existed. +- [ ] Implement `Scalability/` (`CreateMany` at 1/10/100/1000, shallow + vs. deep graphs, growing collection sizes) — replaces + `ResolutionBenchmarks.cs` and `DeepGraphBenchmarks.cs`'s + construction, generalizing the latter's one-off trace-buffer-resize + question into a real shallow-vs-deep comparison. +- [ ] Implement `SourceGeneration/` (in-process `GeneratorDriver` + benchmarks: clean vs. incremental generation, across a + composable-type-count matrix) — new; no prior equivalent existed. + Requires `Compono.Generators` as an ordinary (non-analyzer) + `ProjectReference` in addition to its existing analyzer reference, + so benchmark code can drive it directly. +- [ ] Add `Compono.NSubstitute`/`Compono.Bogus` `ProjectReference`s to + `Compono.Benchmarks.csproj` (needed by `ConsumerScenarios/` and + `FeatureOverhead/`'s provider-enabled cases; not needed previously + since no prior benchmark exercised either package). +- [ ] Delete `ArchitectureBenchmarks.cs`, `EcosystemBenchmarks.cs`, + `ResolutionArchitectureBenchmarks.cs`, + `ResolutionEcosystemBenchmarks.cs`, `ResolutionBenchmarks.cs`, + `DeepGraphBenchmarks.cs`, `BenchmarkTypes.cs`, + `ResolutionBenchmarkTypes.cs` once every question they answered has + a home in the categories above — no benchmark class straddles the + old and new structure once this task is done. +- [ ] Run the full redesigned suite (`dotnet run -c Release --project + benchmarks/Compono.Benchmarks -f net10.0`), Release, and record + real results — replacing every number currently in + `architecture/current/performance.md`, not narrating the old + suite's history. +- [ ] Rewrite `architecture/current/performance.md` as capability- + oriented public documentation per ADR-0034's public-documentation + direction: what Compono optimizes for, what the benchmarks measure, + methodology, representative Consumer Scenario/External Comparison + results, memory/allocation characteristics, scaling behavior, + reproducibility, and a link to the full BenchmarkDotNet artifacts — + not a historical narrative of Milestone 1/2/PR-review optimization + work. Implementation Strategies/Feature Overhead/Source Generation + results stay primarily engineering documentation (linked, not + necessarily reproduced in full on this page) per ADR-0034's "mostly + engineering documentation rather than front-page marketing" + direction for implementation-strategy benchmarks. + ## Phase 6: Contributor and repository readiness **Status:** Not Started @@ -833,6 +917,15 @@ individually resolved, not left ambiguous): - `Directory.Build.props` — package-validation and tags/release-notes properties added, shared across all five packages (Phase 0). - `Compono.slnx` — both new sample projects added (Phase 4). +- `docs/adr/0034-benchmark-suite-strategy-and-redesign.md` — new (Phase + 5 Part B): the benchmark-suite redesign decision. +- `benchmarks/Compono.Benchmarks/` — fully restructured (Phase 5 Part + B) into `Models/`, `Baselines/`, `ImplementationStrategies/`, + `ConsumerScenarios/`, `ExternalComparison/`, `FeatureOverhead/`, + `Scalability/`, `SourceGeneration/`, replacing all 8 existing + benchmark files per ADR-0034. `Compono.Benchmarks.csproj` gains + `ProjectReference`s to `Compono.NSubstitute`/`Compono.Bogus` and a + non-analyzer `ProjectReference` to `Compono.Generators`. - `docs/documentation-architecture.md` — Open Items section already updated to reflect all six resolutions as part of this design pass; further updated in place as content lands and stub statuses flip to @@ -848,8 +941,15 @@ buildable projects with, where practical, their own tests demonstrating the pattern they showcase, matching `testing.md`'s bar for any real code this plan produces. Package-readiness changes (Phase 0) are verified by the new CI gates themselves (package validation, contents inspection, -local-feed restore) rather than a separate hand-run test plan. Phase 8's -acceptance checklist is this plan's actual end-to-end verification. +local-feed restore) rather than a separate hand-run test plan. The +redesigned benchmark suite (Phase 5 Part B) is verified by actually +running it (`dotnet run -c Release`) and confirming every category +produces real, sane results before `architecture/current/performance.md` +is rewritten from them — a `BenchmarkDotNet` project has no pass/fail +test suite of its own; its correctness is verified by inspection of its +results, not by `testing.md`'s xUnit-based test conventions, which don't +apply to `benchmarks/`. Phase 8's acceptance checklist is this plan's +actual end-to-end verification. ## Notes diff --git a/docs/public-api.md b/docs/public-api.md index f7b6165..67ba1e1 100644 --- a/docs/public-api.md +++ b/docs/public-api.md @@ -1,687 +1,19 @@ -# Compono Public API Design - -## Purpose - -This document describes the intended developer experience. - -It is not a final API specification. Examples are design targets used to evaluate whether the underlying architecture remains approachable. - -## API Goals - -The public API should be: - -- Easy to discover -- Small enough to learn -- Explicit about configuration -- Consistent between programmatic and test-framework usage -- Friendly to source generation -- Deterministic -- Free of mutable global state - -## Programmatic Composition - -Basic creation should be simple: - -```csharp -var composer = Composer.Create(); - -var customer = composer.Create(); -var customers = composer.CreateMany(3); -``` - -Configuration uses the same root type via a builder callback (shipped, Milestone 3 -Phase 0 — [ADR-0017](adr/0017-immutable-composer-configuration-and-builder-model.md)). -`WithSeed`, `Register`, `UseServiceProvider`, `AddProfile`, `WithCollectionSize`, -and the `.For()` rule DSL below are all shipped (Phase 0/1/2/3 — -[ADR-0019](adr/0019-registrations-and-service-provider-injection.md), -[ADR-0018](adr/0018-composition-profiles.md), -[ADR-0020](adr/0020-composition-configuration-rules.md)): - -```csharp -var composer = Composer.Create(builder => builder - .WithSeed(4219) - .Register(_ => new FakeClock()) - .UseServiceProvider(app.Services) - .AddProfile()); -``` - -A registration factory can call `ICompositionContext.Resolve()` (no -descriptor) to compose its own nested dependencies manually, distinct from the -descriptor-based overload generated code uses — see -[ADR-0019](adr/0019-registrations-and-service-provider-injection.md). - -`Composer` is the settled root type name — `Composer.Create()` (no configuration) and -`Composer.Create(builder => ...)` (explicit configuration) are the same method, -the latter with an empty callback for the former. - -## Configuration - -Configuration should read as a description of composition behavior: - -```csharp -var composer = Composer.Create(builder => builder - .WithSeed(4219) - .WithCollectionSize(3) - .Register(_ => new FakeClock()) - .AddProfile()); -``` - -Integrations should add themselves through extension methods: - -```csharp -var composer = Composer.Create(builder => builder - .UseNSubstitute() - .UseBogus()); -``` - -The core package must not know those methods exist. - -Service injection uses the BCL's own `System.IServiceProvider` — no core dependency -on `Microsoft.Extensions.DependencyInjection` or any container package: - -```csharp -var composer = Composer.Create(builder => builder - .UseServiceProvider(app.Services)); -``` - -An exact `Register(...)` always wins over the configured `IServiceProvider`; a -container miss (`null`) falls through to profile/type/member rules. See -[ADR-0019](adr/0019-registrations-and-service-provider-injection.md) for full -fallback semantics. - -## Profiles - -Profiles should make project-wide conventions reusable. A profile implements -`ICompositionProfile` — an interface, not a base class, per -[ADR-0018](adr/0018-composition-profiles.md): - -```csharp -public sealed class ApplicationTestProfile : ICompositionProfile -{ - public void Configure(CompositionBuilder builder) - { - builder - .UseNSubstitute() - .UseBogus(options => options.Locale = "en_US") - .Register(_ => - new FakeClock( - new DateTimeOffset( - 2026, 1, 1, 0, 0, 0, - TimeSpan.Zero))); - } -} -``` - -Profile composition should be supported: - -```csharp -builder - .AddProfile() - .AddProfile(); -``` - -Profiles apply eagerly, in call order — that order *is* the precedence rule. A -conflicting registration or rule (from any combination of direct calls and -profiles) is a build-time `CompositionConfigurationException` naming every -conflicting source, not a silent override; a profile that (transitively) adds -itself is a build-time cycle diagnostic, not a stack overflow. - -## Row Composition (Test-Framework Integrations) - -Resolved by [ADR-0021](adr/0021-row-composition-entry-point-for-test-framework-integrations.md) -(`Accepted`, implemented — Milestone 4 Phase 0). A test-framework -integration that needs to compose several *sibling* top-level values in -one shared scope — e.g. one xUnit theory row's own method parameters — -uses `Composer.CreateRow`, not `Create()`/`CreateMany()`, which each -start a brand-new, independent scope per call: - -```csharp -var composer = Composer.Create(); -var row = composer.CreateRow(typeof(OrderServiceTests)); - -var repository = row.ResolveShared(repositoryDescriptor); -var service = row.Resolve(serviceDescriptor); -``` - -`CompositionRow` is the only public surface a test-framework integration -uses to reach the engine this way — `Compono` core's own -`CompositionContext` stays `internal`. It implements -`ICompositionContext`, so a composed value's own nested requests (a -generated plan's constructor parameters) are unaffected — generated code -always programs against `ICompositionContext`, never `CompositionRow` -directly. - -- `Resolve(descriptor)`/`ResolveCollectionSize()` — ordinary - composition, forwarded straight to the wrapped context; no different - from `Create()`'s own resolution. -- `Resolve()` (the descriptor-less overload `CompositionRow` only - carries to satisfy `ICompositionContext`'s full interface shape) is - **not** a usable direct row-composition entry point — it forwards to - the manual-resolve seam meant for a registration/configuration-rule - factory's own `context.Resolve()` calls, which throws - `InvalidOperationException` unless such a factory is actively being - invoked. A `CompositionRow`-holding caller can never satisfy that - condition (factories are always invoked with the raw internal context, - never a `CompositionRow`), so calling this overload directly on a row - always throws. -- `ResolveShared(descriptor)` — composes `TValue` and additionally - stores the result into this row's shared scope: a later request for the - same type in this row — including one made by a nested generated plan, - e.g. a SUT's own constructor parameter — transparently reuses it instead - of composing an independent value. This is the mechanism `[Shared]` - parameters (see xUnit v3 Experience, below) are built on. -- `ShareExplicit(descriptor, value)` — stores an already-known - value (an inline theory argument) directly into the row's shared scope, - with no pipeline dispatch or random fork consumed. -- `Seed` — this row's deterministic root seed, an `int` matching - `WithSeed(int)`'s own contract exactly, so a seed read here is always - pasteable back into `WithSeed(...)`/`[Compose(Seed = ...)]` to reproduce - the same row. -- Only one shared value per type is allowed per row — a second - `ResolveShared`/`ShareExplicit` call for a type already shared in this - row throws a `CompositionException` naming the type, rather than - silently overwriting or reusing the first value. - -## xUnit v3 Experience - -Resolved by [ADR-0021](adr/0021-row-composition-entry-point-for-test-framework-integrations.md)/ -[ADR-0022](adr/0022-compono-xunit-package-design.md) (`Accepted`, implemented — -see [PLAN-0004](plans/0004-milestone-4-xunit-integration.md)). The one gap that -remained open past Phase 4 — an interface/abstract/delegate-typed -`[Compose]`-attributed parameter reported CMP0003 unconditionally, even when a -profile registration or runtime provider would satisfy it — is resolved by -[PLAN-0005](plans/0005-milestone-5-nsubstitute-integration.md) Phase 2, see -[ADR-0024's Amendment 2](adr/0024-public-provider-extensibility-model.md). -`[Compose]`/`[Compose]` implement `Xunit.v3.DataAttribute` -directly; composition happens once per theory row, at execution time (not -discovery time — composed values, especially a future substitute or any -other non-serializable reference type, aren't safely enumerable before a -test actually runs). - -A composed theory should be concise: - -```csharp -[Theory] -[Compose] -public async Task Saves_order( - [Shared] IOrderRepository repository, - CreateOrderHandler handler, - CreateOrder command) -{ - await handler.Handle(command); - - await repository.Received(1) - .SaveAsync( - Arg.Any(), - Arg.Any()); -} -``` - -Profile selection (method-level only for Milestone 4 — a profile that -itself needs to combine several others already can, via its own -`Configure` calling `AddProfile` again, per the existing Profiles section -above): - -```csharp -[Theory] -[Compose] -public void Creates_customer(Customer customer) -{ -} -``` - -Inline values take precedence over composed ones — supplied directly on -`[Compose(...)]`'s own constructor, strictly positional from the first -parameter, rather than a separate attribute: - -```csharp -[Theory] -[Compose("alice@example.com")] -public void Accepts_email( - string email, - Customer customer) -{ -} -``` - -`email` is not composed; `customer` is. Combining `[Compose]` with an -ordinary `[InlineData]`/`[MemberData]` attribute on the same method is not -supported — xUnit treats each data attribute as an independent row -source rather than merging their values, so inline values belong on -`[Compose(...)]` itself. - -## Shared Values - -A shared parameter should be reused for compatible requests later in the same test composition: - -```csharp -[Theory] -[Compose] -public void Uses_same_repository( - [Shared] IRepository repository, - OrderService service) -{ -} -``` - -The repository injected into `OrderService` must be the same instance as the parameter. - -The word `Shared` is currently preferred over `Frozen` because it describes lifetime semantics more directly. - -Resolved by ADR-0022: - -- Sharing is type-based only, matching Milestone 2's own scope semantics — - no name/qualifier-based sharing yet, no concrete consumer for it. -- Declaration order does not gate which parameters are eligible to be - `[Shared]` — every `[Shared]` parameter composes (or, if inline-supplied, - is stored) before every non-shared parameter, regardless of where it - sits in the parameter list. Order *does* matter among `[Shared]` - parameters themselves: one may depend on an earlier-declared `[Shared]` - sibling, never a later one. -- Two `[Shared]` parameters of the same type is a clear, pre-composition - failure naming both parameters, not a silent last-wins. -- A shared value cannot be declared without exposing it as a test - parameter in Milestone 4 — every shared value is visible in the - method's own signature; a hidden/injected-only shared value isn't - designed here. - -## Registrations - -Exact registrations, resolved by -[ADR-0019](adr/0019-registrations-and-service-provider-injection.md) — a factory -receives the same public `ICompositionContext` generated code uses, via a new -descriptor-less `Resolve()` overload, plus a no-dependency convenience form: - -```csharp -builder.Register( - _ => new FakeClock()); - -builder.Register( - () => new FakeClock()); -``` - -Registering the same type twice — direct call, profile, or any combination — is a -build-time `CompositionConfigurationException`, not a last-wins override. - -Open generic registrations may be added later: - -```csharp -builder.RegisterOpenGeneric( - typeof(IRepository<>), - typeof(FakeRepository<>)); -``` - -Open generic registration is not required for the MVP. - -## Type and Member Rules - -Explicit domain configuration should be possible without creating custom providers. -Resolved by [ADR-0020](adr/0020-composition-configuration-rules.md): a **member** -rule scopes to one member of one declaring type; a **type** rule (no `.Member(...)` -call) matches any request for exactly that type, and yields to a member rule when -both could apply to the same request. - -```csharp -// Member rule -builder.For() - .Member(x => x.Status) - .Use(CustomerStatus.Active); - -// Type rule -builder.For() - .Use(_ => new SystemClock()); -``` - -Generated semantic data — via `Compono.Bogus`'s own `UseBogus(...)` sugar over -`.Use(context => ...)` (there is no `context.Semantic` accessor; see Bogus -Integration, below, and [ADR-0027](adr/0027-compono-bogus-package-design.md)): - -```csharp -builder.For() - .Member(x => x.Email) - .UseBogus(faker => faker.Internet.Email()); -``` - -Collection size is configured the same way but is **not** a type/member rule -internally — it's queried configuration policy stage 7's collection machinery -reads directly, not a value a provider produces (ADR-0020): - -```csharp -builder.WithCollectionSize(3); // global default -builder.For() - .Member(x => x.PastOrders) - .WithCollectionSize(5); // member-scoped override -``` - -Type/member matching is exact (no assignability) for the MVP; two rules claiming the -identical key is a build-time conflict, the same as a duplicate registration. - -## Bogus Integration - -Design: [ADR-0027](adr/0027-compono-bogus-package-design.md) — **implemented, -PLAN-0006 Phase 1** (build-verified; test coverage/end-to-end verification -pending Phase 3), [ADR-0028](adr/0028-configurable-bogus-member-name-conventions.md) -— **implemented, PLAN-0006 Phase 2** (build-verified; test coverage pending -Phase 3), built on [ADR-0026](adr/0026-deterministic-seed-derivation-for-providers.md)'s -`ICompositionContext.DeriveSeed()` (**implemented, PLAN-0006 Phase 0** — see -Deterministic Reproduction, below). -`Compono.Bogus` has three independent customization models, not one, and a real -profile typically uses more than one at once: - -- **`UseBogus()`** — project-wide conventions: most `FirstName`/`Email`/etc. - members across the graph should just look realistic, with zero per-type setup. -- **`.Member(...).UseBogus(faker => ...)`** — a handful of members need - something the convention allowlist doesn't (or shouldn't) guess. -- **`UseBogus()`** — a type's values are meaningfully correlated with each - other (an email derived from a name), so the whole object is more naturally - "Bogus owns this type" than several independent member rules. - -**Basic activation** — enables the conservative member-name convention provider -(stage 5) by default: - -```csharp -builder.UseBogus(); -``` - -Locale and the convention provider's own on/off switch: - -```csharp -builder.UseBogus(options => -{ - options.Locale = "en_US"; - options.EnableMemberNameConventions = false; // opt out, keep explicit rules only -}); -``` - -**Configurable conventions** — design: [ADR-0028](adr/0028-configurable-bogus-member-name-conventions.md) -— **implemented, PLAN-0006 Phase 2** (build-verified; test coverage pending -Phase 3). A domain that doesn't use the built-in allowlist's exact names, or -that has its own package-wide semantic name, can extend `UseBogus()` without -repeating a member-level rule at every call site: - -```csharp -builder.UseBogus(options => -{ - // An additional exact name that reuses a built-in generator. - options.AddAlias("GivenName", BogusConvention.FirstName); - options.AddAlias("Surname", BogusConvention.LastName); - options.AddAlias("Zip", BogusConvention.PostalCode); - - // A custom exact-name convention, backed by a user callback. - options.AddConvention("Sku", faker => faker.Commerce.Ean13()); -}); -``` - -Both are exact, case-sensitive matches, merged with the built-in allowlist -into a single lookup — a name can only ever map to one generator. `AddAlias`/ -`AddConvention` perform eager validation when called: a null name throws -`ArgumentNullException` (matching this repo's own `ArgumentNullException.ThrowIfNull` -guard convention); an empty/whitespace name, or any duplicate or collision -with a built-in name, an existing alias, or an existing custom convention, -throws `ArgumentException` — immediately from the call that introduced it, -not deferred, and not silently overwritten. -Custom conventions are `string`-only (a non-`string` package-wide value needs -the member-level `.Member(...).UseBogus(faker => ...)` sugar below instead). -Replacing or removing a built-in convention isn't supported. - -`EnableMemberNameConventions = false` is still all-or-nothing: it disables -the entire provider, including any aliases/custom conventions configured in -the same call — there's no way yet to keep custom conventions active while -turning off only the built-in guesses. - -**This validation is scoped to one `UseBogus(...)` call** — a second, separate -call (e.g. from a different profile) that defines a colliding alias/custom -name is **not** detected; each call registers its own independent provider, -and ordinary pipeline registration-order/first-match-wins semantics decide -which one applies (ADR-0028's Non-Goals). Centralize Bogus configuration into -one `UseBogus(...)` call — typically inside one reusable profile — to avoid -relying on that fallback. - -**Explicit member rules** (stage 4, sugar over the existing `.For().Member(...).Use(...)` -mechanism — no `context.Semantic` accessor, no core change beyond `DeriveSeed()`): - -```csharp -builder.For() - .Member(x => x.FirstName) - .UseBogus(faker => faker.Name.FirstName()); -``` - -Always wins over the convention provider for the same member, since stage 4 -runs before stage 5 unconditionally. - -**Whole-object generation** (purely ergonomic sugar over the existing -`Register` registration mechanism — no hidden pipeline stage, no special -runtime behavior of its own, same duplicate-registration conflict rule as any -other `Register` call): - -```csharp -builder.UseBogus(faker => faker - .RuleFor(x => x.FirstName, f => f.Name.FirstName()) - .RuleFor(x => x.LastName, f => f.Name.LastName()) - .RuleFor(x => x.Email, (f, x) => f.Internet.Email(x.FirstName, x.LastName))); -``` - -`configureFaker` is `Action>`, not a `Func` — it configures the -instance in place; `RuleFor`'s own fluent chaining is convenient here but not -load-bearing, since nothing needs to be returned back to Compono. - -Correlated values (`Email` derived from `FirstName`/`LastName` above) are -satisfied entirely by Bogus's own `Faker.RuleFor((faker, instance) => ...)` — -there is no separate Compono-native `.DependsOn(...)` member-dependency -mechanism; it was evaluated during Milestone 6's design review and explicitly -deferred (ADR-0027) in favor of `Faker`, which already solves this problem -natively. - -`UseBogus()` is intentionally independent of `UseBogus()` — it never -requires `UseBogus()` to have been called, and never reads its -`BogusOptions.Locale`. `UseBogus()` is purely ergonomic sugar over the -existing `Register` registration mechanism (stage 3, an ordinary exact -registration); `UseBogus()` activates the stage-5 semantic provider. They're -different pipeline stages solving different problems — a consumer can call -`UseBogus(...)` alone, with `UseBogus()` never called at all, and it -works exactly the same. Pass the locale explicitly (named argument recommended -for readability) if it should match `UseBogus()`'s own: - -```csharp -builder - .UseBogus(options => options.Locale = "fr") - .UseBogus( - locale: "fr", - configureFaker: faker => faker.RuleFor(x => x.FirstName, f => f.Name.FirstName())); -``` - -`locale` is a plain `string`, not an options type — deliberate: it's the only -per-registration setting `Faker`'s own constructor takes today, and adding a -one-property options type would be speculative surface for a second option -nothing currently needs (ADR-0027). - -Coexistence with `Compono.NSubstitute`: both packages can be activated in the -same profile, in either order, with no reference between them. Bogus's -convention provider only ever claims `string`-typed members; NSubstitute's -provider only ever claims interface/delegate/(optionally) abstract-class -requests — disjoint by construction, per ADR-0027's Coexistence section. - -## Provider Extensibility - -Implemented, per [ADR-0024](adr/0024-public-provider-extensibility-model.md) — -PLAN-0005 Phase 0, a Milestone 5 deliverable (see -[PLAN-0005](plans/0005-milestone-5-nsubstitute-integration.md) for phase status). -An integration package (or a consumer's own code) contributes open-ended, -pattern-matching composition logic to pipeline stage 5 (semantic value providers) -or stage 6 (test-double providers) — the cases a closed-set `.For()` rule can't -express, like "any interface type": - -```csharp -public interface ICompositionValueProvider -{ - CompositionProviderResult TryProvide(in CompositionProviderRequest request, ICompositionContext context); -} - -builder.AddSemanticProvider(new MySemanticProvider()); -builder.AddTestDoubleProvider(new MyTestDoubleProvider()); -``` - -`CompositionProviderRequest` exposes `RequestedType`/`DeclaringType`/`Name`/ -`Nullability` — enough to match "any interface" (`RequestedType.IsInterface`) or -"any member literally named `Email`" (`Name == "Email"`) without exposing the -engine's own internal request/path types. A provider returns -`CompositionProviderResult.NotHandled` for anything it doesn't apply to (so a later -provider or pipeline stage still gets a chance) or `CompositionProviderResult.Handled(value)`. -`Compono.NSubstitute`'s `UseNSubstitute()` (below) is the first real consumer of -this contract; `Compono.Bogus`'s `UseBogus()` (below) is the second, registered -via `AddSemanticProvider` instead of `AddTestDoubleProvider`. - -## NSubstitute Integration - -Implemented and test-covered/end-to-end verified (PLAN-0005 Phase 2) per -[ADR-0025](adr/0025-compono-nsubstitute-package-design.md), built on the Provider -Extensibility contract above — `Compono.NSubstitute`'s `NSubstituteProvider`/ -`NSubstituteOptions`/`UseNSubstitute()` are real, tested code, verified end-to-end -against a real packaged xUnit v3 consumer (`Compono.XunitV3.SampleTests`) running -this milestone's own Goal scenario: a `[Shared]` interface theory parameter -composed as a real substitute and reused by a nested constructor parameter of the -same type. - -Activation: - -```csharp -builder.UseNSubstitute(); -``` - -Default behavior: - -- Compose interfaces as substitutes -- Compose delegate types as substitutes -- Optionally compose abstract classes (on by default; `SubstituteAbstractClasses`) -- Reuse substitutes through shared scope (falls out of the engine's existing - `[Shared]`/scope mechanism — `Compono.NSubstitute` contributes no code toward - this specifically) -- Avoid automatic recursive member configuration in the MVP — a composed - substitute is exactly what `Substitute.For()` would produce; its `Returns`/ - `Received` configuration stays the consumer's own test-body concern - -NSubstitute-specific configuration belongs in the integration package: - -```csharp -builder.UseNSubstitute(options => -{ - options.SubstituteAbstractClasses = false; -}); -``` - -## Deterministic Reproduction - -Explicit seed: - -```csharp -var composer = Composer.Create(builder => - builder.WithSeed(8492173)); -``` - -xUnit: - -```csharp -[Theory] -[Compose(Seed = 8492173)] -public void Reproduces_failure(Order order) -{ -} -``` - -Confirmed viable against xUnit v3's real extensibility surface (ADR-0022). - -A provider or registration/configuration-rule factory that needs its own -deterministic randomness (`Compono.Bogus`'s `BogusMemberNameProvider`, or the -`UseBogus(...)`/`UseBogus(...)` sugar) calls `context.DeriveSeed()` — an -`int` derived from the composer's root seed and the request currently being -resolved, reusing the same path-hash [ADR-0012](adr/0012-composition-path-identity-and-deterministic-random-forking.md) -already uses internally, without exposing `IRandomSource` or path internals. -Resolved by [ADR-0026](adr/0026-deterministic-seed-derivation-for-providers.md) — -**implemented, PLAN-0006 Phase 0**. - -A composition failure's message ends with `Seed: {value}`, matching -`docs/architecture.md`'s existing Diagnostics example — a successful row -does not surface its seed anywhere by default, to keep passing-test output -unchanged. - -## Diagnostics API - -A standard composition exception should expose structured diagnostics: - -```csharp -catch (CompositionException exception) -{ - Console.WriteLine(exception.Diagnostic); -} -``` - -`exception.Diagnostic` (when present) already renders its own `Seed: {value}` -line via `ToString()`, but not every `CompositionException` has a -`Diagnostic` - a plain-message one (e.g. a generated `HashSet`/ -`Dictionary` collection plan's unique-value-exhaustion failure) does not. -`CompositionException.WithSeedInMessage(original, seed)` is a static -factory for exactly this case - it returns a copy of `original` whose -`Message` has the seed appended directly, regardless of whether -`Diagnostic` is present: - -```csharp -try -{ - composer.Create(); -} -catch (CompositionException exception) -{ - throw CompositionException.WithSeedInMessage(exception, mySeed); -} -``` - -The returned exception's `Diagnostic` is copied through from `original` -unchanged (`null` stays `null`), and `original` itself becomes its -`InnerException` - never discarded. `Compono.XunitV3`'s own `[Compose]` -binding algorithm (ADR-0022) uses this to guarantee every composition -failure's `Message` carries a pasteable seed, not only ones that happen to -have a `Diagnostic`. - -Potential debugging API: - -```csharp -var explanation = composer.Explain(); -``` - -This is a post-MVP possibility. - -## Naming Vocabulary - -Preferred concepts: - -- Composer: long-lived immutable configuration and public entry point -- CompositionBuilder: mutable configuration accumulator, live only during - `Composer.Create(builder => ...)` -- Composition: the process of satisfying a request -- CompositionContext: active runtime state -- CompositionRequest: one requested value -- CompositionPlan: generated construction logic -- CompositionScope: shared-instance lifetime -- ICompositionProfile: reusable configuration, applied by name -- CompositionProvider: extension point (internal, engine-owned) -- ICompositionValueProvider: the public extension point stages 5/6 expose to - integration packages — see Provider Extensibility, resolved by - [ADR-0024](adr/0024-public-provider-extensibility-model.md) -- Shared: reuse within a scope -- `DeriveSeed()`: on-demand, path-derived deterministic seed a provider or - factory calls for its own randomness — see Deterministic Reproduction, - resolved by [ADR-0026](adr/0026-deterministic-seed-derivation-for-providers.md) -- `BogusConvention`: the fixed, closed set of Compono.Bogus's built-in - member-name conventions — see Bogus Integration, resolved by - [ADR-0028](adr/0028-configurable-bogus-member-name-conventions.md) - -## API Design Rules - -- Avoid `object`-based public pipelines where practical -- Avoid service-locator-style APIs in test bodies -- Avoid mutable global configuration -- Avoid exposing source-generator implementation details -- Prefer explicit extension methods for integrations -- Prefer immutable configuration after composer creation -- Prefer one obvious way to perform common operations -- Do not reproduce AutoFixture terminology solely for familiarity +# Compono Public API Design (moved) + +This page's content has been redistributed across the docs site as each +replacement page was written during +[PLAN-0008](plans/0008-milestone-8-public-preview.md), per +[ADR-0030 Amendment 2](adr/0030-compono-documentation-architecture.md#amendment-2-2026-08-04-resolving-milestone-8s-remaining-open-items)'s +"one canonical home" principle — this file is a tombstone, not deleted +outright, because ADRs that link to it by path must stay resolvable. + +See: + +- [Concepts](concepts/index.md) — the composition model, profiles, + registrations and rules, shared values, providers, determinism and + seeding, collections +- [How-to Guides](how-to/index.md) — task-oriented usage of the same API +- [Package Guides](packages/index.md) — `Compono.XunitV3`, + `Compono.NSubstitute`, and `Compono.Bogus`'s own integration surfaces +- [Reference](reference/index.md) — generated API reference, diagnostics, + glossary diff --git a/docs/roadmap/future-packages.md b/docs/roadmap/future-packages.md index 6376a9f..7ebb6cc 100644 --- a/docs/roadmap/future-packages.md +++ b/docs/roadmap/future-packages.md @@ -1,10 +1,42 @@ # Future Packages -> **Status:** Skeleton — placeholder created by Milestone 7 Phase 5's -> documentation skeleton (`docs/documentation-architecture.md`). Written in -> Milestone 8 ([PLAN-0008](../plans/0008-milestone-8-public-preview.md)). +Compono's MVP package set — `Compono`, `Compono.Generators`, +`Compono.XunitV3`, `Compono.NSubstitute`, and `Compono.Bogus` — is fully +shipped as of this milestone (see [Package Guides](../packages/index.md)). +No additional package is currently committed via an `Accepted` ADR. -Placeholder entries for roadmapped-but-unshipped integration packages. +## Natural candidates, not yet designed -See [Documentation Architecture](../documentation-architecture.md) for this page's full audience, -contents, and relationship to the rest of the site. +The core `Compono` package is deliberately free of any dependency on a +specific test framework or test-double/data library +([Design Principles](../architecture/design-principles.md)'s "modular +architecture" principle) — each of those integrations lives in its own +package, built on the same public extension points +(`AddSemanticProvider`/`AddTestDoubleProvider`, per +[ADR-0024](../adr/0024-public-provider-extensibility-model.md)) that +`Compono.NSubstitute`/`Compono.Bogus` already use. That pattern makes the +natural shape of a future package predictable, even though none of the +following is designed or committed yet: + +- Additional test-framework integrations (e.g. NUnit, MSTest), following + `Compono.XunitV3`'s `CompositionRow`-based model + ([ADR-0021](../adr/0021-row-composition-entry-point-for-test-framework-integrations.md)). +- Additional test-double integrations (e.g. Moq, FakeItEasy), following + `Compono.NSubstitute`'s stage-6 provider model + ([ADR-0025](../adr/0025-compono-nsubstitute-package-design.md)). +- A richer `Microsoft.Extensions.DependencyInjection` integration + (`IServiceCollection` auto-registration, per-composition scoping, keyed + services) on top of `UseServiceProvider(...)`'s existing BCL-only + `IServiceProvider` support — explicitly out of scope for core per + [ADR-0019](../adr/0019-registrations-and-service-provider-injection.md). +- A reflection-based compatibility mode or package, for the still-open + runtime-reflection question tracked in + [Source Generation](../architecture/current/source-generation.md). + +Any of these becomes real roadmap content the moment real demand and a +concrete design exist — see [Post-MVP](post-mvp.md) for the +evidence-backed process that promotes a candidate out of this page and +into an ADR, per +[ADR-0029](../adr/0029-milestone-7-dogfooding-strategy-and-capability-gap-decision-framework.md). +A future package gets its own [Package Guide](../packages/index.md) entry +the moment it ships. diff --git a/docs/roadmap/index.md b/docs/roadmap/index.md index 97d4954..53541bb 100644 --- a/docs/roadmap/index.md +++ b/docs/roadmap/index.md @@ -1,10 +1,28 @@ # Roadmap -> **Status:** Skeleton — placeholder created by Milestone 7 Phase 5's -> documentation skeleton (`docs/documentation-architecture.md`). Written in -> Milestone 8 ([PLAN-0008](../plans/0008-milestone-8-public-preview.md)). +**Audience:** anyone asking "is X available, experimental, or planned?" +**Purpose:** the single, indexed home for everything not fully available +today. -The today/experimental/planned framing - the single, indexed home for everything not fully available today. +- **Today.** Compono's shipped package set — + [`Compono`](../packages/compono.md), [`Compono.XunitV3`](../packages/compono-xunitv3.md), + [`Compono.NSubstitute`](../packages/compono-nsubstitute.md), and + [`Compono.Bogus`](../packages/compono-bogus.md) — covers the full MVP + package set (`docs/mvp.md`'s "MVP Package Set"). If a capability isn't + documented in [Concepts](../concepts/index.md), [How-to Guides](../how-to/index.md), + or a [Package Guide](../packages/index.md), it isn't available yet — see + below for where it might be headed. +- **Experimental / under discussion.** [Proposed ADRs](proposed-adrs.md) — + design decisions still being discussed, not yet `Accepted`. +- **Planned / candidate.** [Post-MVP roadmap](post-mvp.md) — evidence-backed + capability gaps surfaced by real dogfooding, and + [Future Packages](future-packages.md) — integration packages beyond the + shipped set that could follow the same pattern + [`Compono.NSubstitute`](../packages/compono-nsubstitute.md)/[`Compono.Bogus`](../packages/compono-bogus.md) + already established, but aren't committed yet. -See [Documentation Architecture](../documentation-architecture.md) for this page's full audience, -contents, and relationship to the rest of the site. +Each of these is deliberately a status-filtered *index*, not new +narrative content of its own — every candidate/proposal it lists is +backed by a real ADR, research finding, or explicit non-goal recorded +elsewhere, per this repo's evidence-over-prediction bias +([ADR-0029](../adr/0029-milestone-7-dogfooding-strategy-and-capability-gap-decision-framework.md)). diff --git a/docs/roadmap/proposed-adrs.md b/docs/roadmap/proposed-adrs.md index 1b15385..42caf87 100644 --- a/docs/roadmap/proposed-adrs.md +++ b/docs/roadmap/proposed-adrs.md @@ -1,10 +1,17 @@ # Proposed ADRs -> **Status:** Skeleton — placeholder created by Milestone 7 Phase 5's -> documentation skeleton (`docs/documentation-architecture.md`). Written in -> Milestone 8 ([PLAN-0008](../plans/0008-milestone-8-public-preview.md)). +A status-filtered view of [`docs/adr/README.md`](../adr/README.md): every +ADR that's `Proposed`, or `Accepted` but not yet implemented. -A status-filtered view of `docs/adr/README.md`: every ADR that's Proposed, or Accepted but not yet implemented. +## Current state: no proposed ADRs -See [Documentation Architecture](../documentation-architecture.md) for this page's full audience, -contents, and relationship to the rest of the site. +Every ADR recorded in [`docs/adr/README.md`](../adr/README.md) is +currently `Accepted`, `Superseded`, or (for the two decisions later +revisions replaced) implicitly retired by their successor — none are +sitting in `Proposed` status, and every `Accepted` ADR has a shipped +implementation. See the [Historical Decision Log](../architecture/decision-log.md) +for the full list. + +This page will list any ADR a future design pass proposes but hasn't yet +accepted, so a reader can see what's actively under discussion before it +becomes a committed decision. diff --git a/mkdocs.yml b/mkdocs.yml index d056d81..68a6877 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -179,11 +179,6 @@ nav: - Proposed ADRs: roadmap/proposed-adrs.md - Future Packages: roadmap/future-packages.md - Documentation Architecture: documentation-architecture.md - - Manifesto (legacy): manifesto.md - - Architecture Overview (legacy): architecture.md - - Design Principles (legacy): design-principles.md - - Public API (legacy): public-api.md - - Performance (legacy): performance.md # Social links extra: From f07d5ccce06592539cd33f4cea2a9bf62bb4f130 Mon Sep 17 00:00:00 2001 From: Nick Cipollina Date: Wed, 5 Aug 2026 23:02:03 -0400 Subject: [PATCH 2/5] feat(benchmarks): implement redesigned suite, remove Implementation Strategies, fix UseBogus() overhead Implements ADR-0034's benchmark redesign: Models/, Baselines/, and five category folders (ConsumerScenarios, ExternalComparison, FeatureOverhead, Scalability, SourceGeneration), replacing all 8 pre-redesign benchmark files. The Implementation Strategies category was implemented and then removed via an ADR-0034 Amendment - it compared different systems doing different amounts of work (bare construction vs. a full resolution pipeline), so a result from it couldn't be attributed to a cause or guide an optimization decision. Running the redesigned suite surfaced a real finding: UseBogus() cost ~865x a plain member rule, because BogusMemberNameProvider constructed a new Bogus.Faker on every resolution. Fixed via ADR-0027's Amendment: one Faker cached per thread (ThreadLocal, trackAllValues: false), reseeded per request - safe under concurrent access because a thread-local instance is never touched by more than one thread, distinct from the shared-Faker alternative ADR-0027 already considered and rejected. New coverage: a 200-way concurrency test and a convention-throws-mid-generate test. The fix cut UseBogus()'s isolated overhead from ~865x to ~6.3x and its full-profile cost from 903.4 us / 2,229 KB to 5.5 us / 7.0 KB. architecture/current/performance.md is rewritten from one full suite run (DefaultJob, all 5 categories) with a clarity pass: explicit AutoFixture-relative-to-Compono ratio direction, precise DefaultJob methodology wording, an explicit "equivalent work" definition, a note connecting the Consumer Scenario and Feature Overhead UseBogus() numbers' different scope, and consistent x formatting throughout. Co-Authored-By: Claude Sonnet 5 --- .../ArchitectureBenchmarks.cs | 28 - .../Compono.Benchmarks/AutoFixtureComposer.cs | 17 - .../Baselines/AutoFixtureComposer.cs | 18 + .../Compono.Benchmarks/BenchmarkTypes.cs | 19 - .../Compono.Benchmarks.csproj | 26 +- .../ProviderEnabledBenchmarks.cs | 27 + .../RepresentativeModelBenchmarks.cs | 33 ++ .../SharedValueBenchmarks.cs | 38 ++ .../Compono.Benchmarks/DeepGraphBenchmarks.cs | 46 -- .../Compono.Benchmarks/EcosystemBenchmarks.cs | 26 - .../MediumAggregateComparisonBenchmarks.cs | 26 + .../SimplePocoComparisonBenchmarks.cs | 25 + .../BogusOverheadBenchmarks.cs | 30 + .../ConfigurationOverheadBenchmarks.cs | 51 ++ .../NSubstituteOverheadBenchmarks.cs | 31 ++ .../SharingOverheadBenchmarks.cs | 53 ++ .../Compono.Benchmarks/Models/DeepGraph.cs | 31 ++ .../Models/LargeCollection.cs | 8 + .../Models/MediumAggregate.cs | 14 + .../Models/ProviderBackedModel.cs | 27 + .../Models/SharedValueGraph.cs | 18 + .../Compono.Benchmarks/Models/SimplePoco.cs | 8 + .../Compono.Benchmarks/ReflectionComposer.cs | 85 --- .../ResolutionArchitectureBenchmarks.cs | 48 -- .../ResolutionBenchmarkTypes.cs | 13 - .../ResolutionBenchmarks.cs | 33 -- .../ResolutionEcosystemBenchmarks.cs | 24 - .../Scalability/BatchScalingBenchmarks.cs | 28 + .../CollectionSizeScalingBenchmarks.cs | 29 + .../GraphDepthScalingBenchmarks.cs | 26 + .../GeneratorDriverBenchmarks.cs | 115 ++++ docs/adr/0027-compono-bogus-package-design.md | 55 ++ ...4-benchmark-suite-strategy-and-redesign.md | 78 +++ docs/architecture/current/performance.md | 519 +++++++----------- .../performance-recommendations.md | 4 +- docs/plans/0008-milestone-8-public-preview.md | 222 +++++--- src/Compono.Bogus/BogusMemberNameProvider.cs | 28 +- src/Compono.Bogus/BogusOptions.cs | 11 +- .../Compono.Generators.csproj | 3 + .../BogusMemberNameProviderTests.cs | 41 ++ test/Compono.Bogus.Tests/DeterminismTests.cs | 39 ++ 41 files changed, 1275 insertions(+), 726 deletions(-) delete mode 100644 benchmarks/Compono.Benchmarks/ArchitectureBenchmarks.cs delete mode 100644 benchmarks/Compono.Benchmarks/AutoFixtureComposer.cs create mode 100644 benchmarks/Compono.Benchmarks/Baselines/AutoFixtureComposer.cs delete mode 100644 benchmarks/Compono.Benchmarks/BenchmarkTypes.cs create mode 100644 benchmarks/Compono.Benchmarks/ConsumerScenarios/ProviderEnabledBenchmarks.cs create mode 100644 benchmarks/Compono.Benchmarks/ConsumerScenarios/RepresentativeModelBenchmarks.cs create mode 100644 benchmarks/Compono.Benchmarks/ConsumerScenarios/SharedValueBenchmarks.cs delete mode 100644 benchmarks/Compono.Benchmarks/DeepGraphBenchmarks.cs delete mode 100644 benchmarks/Compono.Benchmarks/EcosystemBenchmarks.cs create mode 100644 benchmarks/Compono.Benchmarks/ExternalComparison/MediumAggregateComparisonBenchmarks.cs create mode 100644 benchmarks/Compono.Benchmarks/ExternalComparison/SimplePocoComparisonBenchmarks.cs create mode 100644 benchmarks/Compono.Benchmarks/FeatureOverhead/BogusOverheadBenchmarks.cs create mode 100644 benchmarks/Compono.Benchmarks/FeatureOverhead/ConfigurationOverheadBenchmarks.cs create mode 100644 benchmarks/Compono.Benchmarks/FeatureOverhead/NSubstituteOverheadBenchmarks.cs create mode 100644 benchmarks/Compono.Benchmarks/FeatureOverhead/SharingOverheadBenchmarks.cs create mode 100644 benchmarks/Compono.Benchmarks/Models/DeepGraph.cs create mode 100644 benchmarks/Compono.Benchmarks/Models/LargeCollection.cs create mode 100644 benchmarks/Compono.Benchmarks/Models/MediumAggregate.cs create mode 100644 benchmarks/Compono.Benchmarks/Models/ProviderBackedModel.cs create mode 100644 benchmarks/Compono.Benchmarks/Models/SharedValueGraph.cs create mode 100644 benchmarks/Compono.Benchmarks/Models/SimplePoco.cs delete mode 100644 benchmarks/Compono.Benchmarks/ReflectionComposer.cs delete mode 100644 benchmarks/Compono.Benchmarks/ResolutionArchitectureBenchmarks.cs delete mode 100644 benchmarks/Compono.Benchmarks/ResolutionBenchmarkTypes.cs delete mode 100644 benchmarks/Compono.Benchmarks/ResolutionBenchmarks.cs delete mode 100644 benchmarks/Compono.Benchmarks/ResolutionEcosystemBenchmarks.cs create mode 100644 benchmarks/Compono.Benchmarks/Scalability/BatchScalingBenchmarks.cs create mode 100644 benchmarks/Compono.Benchmarks/Scalability/CollectionSizeScalingBenchmarks.cs create mode 100644 benchmarks/Compono.Benchmarks/Scalability/GraphDepthScalingBenchmarks.cs create mode 100644 benchmarks/Compono.Benchmarks/SourceGeneration/GeneratorDriverBenchmarks.cs diff --git a/benchmarks/Compono.Benchmarks/ArchitectureBenchmarks.cs b/benchmarks/Compono.Benchmarks/ArchitectureBenchmarks.cs deleted file mode 100644 index 75428cc..0000000 --- a/benchmarks/Compono.Benchmarks/ArchitectureBenchmarks.cs +++ /dev/null @@ -1,28 +0,0 @@ -using BenchmarkDotNet.Attributes; - -namespace Compono.Benchmarks; - -/// -/// Does generated construction () outperform a comparable -/// reflection-based implementation - the architectural question behind Milestone 1 -/// (docs/mvp.md's explicit benchmark ask). is the theoretical floor -/// bare new Leaf() gives, so 's overhead against it is visible on -/// its own terms, not just relative to reflection. -/// -[MemoryDiagnoser] -public class ArchitectureBenchmarks -{ - private readonly Composer _composer = Composer.Create(); - - /// Constructs directly - the theoretical floor. - [Benchmark(Baseline = true)] - public Leaf Direct() => new(); - - /// Constructs via its generated . - [Benchmark] - public Leaf Generated() => _composer.Create(); - - /// Constructs via reflection. - [Benchmark] - public Leaf Reflection() => ReflectionComposer.Compose(); -} diff --git a/benchmarks/Compono.Benchmarks/AutoFixtureComposer.cs b/benchmarks/Compono.Benchmarks/AutoFixtureComposer.cs deleted file mode 100644 index 4acfd57..0000000 --- a/benchmarks/Compono.Benchmarks/AutoFixtureComposer.cs +++ /dev/null @@ -1,17 +0,0 @@ -using AutoFixture; - -namespace Compono.Benchmarks; - -/// -/// Ecosystem-comparison reference point - the established framework developers reach for today -/// to construct test data, benchmarked as a recognizable baseline rather than a target Compono -/// is trying to "beat" (docs/performance.md). -/// -public sealed class AutoFixtureComposer -{ - private readonly Fixture _fixture = new(); - - /// Constructs an instance of via AutoFixture. - /// The type to construct. - public T Compose() => _fixture.Create(); -} diff --git a/benchmarks/Compono.Benchmarks/Baselines/AutoFixtureComposer.cs b/benchmarks/Compono.Benchmarks/Baselines/AutoFixtureComposer.cs new file mode 100644 index 0000000..4bd8c15 --- /dev/null +++ b/benchmarks/Compono.Benchmarks/Baselines/AutoFixtureComposer.cs @@ -0,0 +1,18 @@ +using AutoFixture; + +namespace Compono.Benchmarks.Baselines; + +/// +/// External-comparison reference point, per ADR-0034: AutoFixture is one comparison point +/// answering "what should a developer expect when migrating," not the suite's center. Stock +/// Fixture, no customization - an honest out-of-the-box comparison, not tuned to +/// artificially favor either library. +/// +public sealed class AutoFixtureComposer +{ + private readonly Fixture _fixture = new(); + + /// Constructs an instance of via AutoFixture. + /// The type to construct. + public T Compose() => _fixture.Create(); +} diff --git a/benchmarks/Compono.Benchmarks/BenchmarkTypes.cs b/benchmarks/Compono.Benchmarks/BenchmarkTypes.cs deleted file mode 100644 index 0b66917..0000000 --- a/benchmarks/Compono.Benchmarks/BenchmarkTypes.cs +++ /dev/null @@ -1,19 +0,0 @@ -namespace Compono.Benchmarks; - -/// -/// A flat, parameterless representative type - the only shape -/// can fully execute in Milestone 1 today. -/// -/// -/// A type with a constructor parameter (even one that's itself composable, like -/// docs/mvp.md's Customer(Address HomeAddress) exit-criteria example) still throws -/// at Compose time: every constructor argument in -/// generated code is resolved via context.Resolve<TParam>() -/// (src/Compono.Generators/Templates/CompositionPlan.scriban), and Milestone 1's -/// placeholder (src/Compono/Composer.cs) always throws -/// there regardless of whether TParam has its own generated plan - dispatching a -/// Resolve<TParam>() call to the matching PlanCache<TParam> is the real -/// provider-resolution pipeline, which is Milestone 2 scope. So a parameterless type is the only -/// shape this benchmark can honestly measure end-to-end until then. -/// -public sealed record Leaf; diff --git a/benchmarks/Compono.Benchmarks/Compono.Benchmarks.csproj b/benchmarks/Compono.Benchmarks/Compono.Benchmarks.csproj index 2b530f4..e5de239 100644 --- a/benchmarks/Compono.Benchmarks/Compono.Benchmarks.csproj +++ b/benchmarks/Compono.Benchmarks/Compono.Benchmarks.csproj @@ -28,14 +28,32 @@ + + + + + + + + - + + OutputItemType="Analyzer" /> + + + diff --git a/benchmarks/Compono.Benchmarks/ConsumerScenarios/ProviderEnabledBenchmarks.cs b/benchmarks/Compono.Benchmarks/ConsumerScenarios/ProviderEnabledBenchmarks.cs new file mode 100644 index 0000000..6fdb3a6 --- /dev/null +++ b/benchmarks/Compono.Benchmarks/ConsumerScenarios/ProviderEnabledBenchmarks.cs @@ -0,0 +1,27 @@ +using BenchmarkDotNet.Attributes; +using Compono.Benchmarks.Models; + +namespace Compono.Benchmarks.ConsumerScenarios; + +/// +/// What performance should a user expect from a profile with a package provider active - a +/// Compono.Bogus-enabled profile composing (whose +/// FirstName/LastName members match Bogus's built-in convention allowlist), and a +/// Compono.NSubstitute-enabled profile composing (whose +/// member NSubstitute's stage-6 provider satisfies) - realistic usage, not +/// the isolated marginal cost FeatureOverhead/ProviderOverheadBenchmarks measures. +/// +[MemoryDiagnoser] +public class ProviderEnabledBenchmarks +{ + private readonly Composer _bogusComposer = Composer.Create(builder => builder.UseBogus()); + private readonly Composer _nsubstituteComposer = Composer.Create(builder => builder.UseNSubstitute()); + + /// Composes with UseBogus() active. + [Benchmark] + public MediumAggregate BogusEnabledScenario() => _bogusComposer.Create(); + + /// Composes with UseNSubstitute() active. + [Benchmark] + public ProviderBackedModel NSubstituteEnabledScenario() => _nsubstituteComposer.Create(); +} diff --git a/benchmarks/Compono.Benchmarks/ConsumerScenarios/RepresentativeModelBenchmarks.cs b/benchmarks/Compono.Benchmarks/ConsumerScenarios/RepresentativeModelBenchmarks.cs new file mode 100644 index 0000000..4c0ae54 --- /dev/null +++ b/benchmarks/Compono.Benchmarks/ConsumerScenarios/RepresentativeModelBenchmarks.cs @@ -0,0 +1,33 @@ +using BenchmarkDotNet.Attributes; +using Compono.Benchmarks.Models; + +namespace Compono.Benchmarks.ConsumerScenarios; + +/// +/// What performance should a user expect composing each of ADR-0034's representative models in a +/// realistic application - no comparison baseline, just the absolute cost a consumer actually +/// pays for Create<T>() against each shape. This is the category most likely to +/// surface in public documentation, per ADR-0034. +/// +[MemoryDiagnoser] +public class RepresentativeModelBenchmarks +{ + private readonly Composer _composer = Composer.Create(); + private readonly Composer _largeCollectionComposer = Composer.Create(builder => builder.WithCollectionSize(100)); + + /// Composes the flat model. + [Benchmark] + public SimplePoco SimplePocoScenario() => _composer.Create(); + + /// Composes the moderately-nested model. + [Benchmark] + public MediumAggregate MediumAggregateScenario() => _composer.Create(); + + /// Composes the 8-level-deep model. + [Benchmark] + public DeepGraph DeepGraphScenario() => _composer.Create(); + + /// Composes with a 100-element collection size. + [Benchmark] + public LargeCollection LargeCollectionScenario() => _largeCollectionComposer.Create(); +} diff --git a/benchmarks/Compono.Benchmarks/ConsumerScenarios/SharedValueBenchmarks.cs b/benchmarks/Compono.Benchmarks/ConsumerScenarios/SharedValueBenchmarks.cs new file mode 100644 index 0000000..34cc11d --- /dev/null +++ b/benchmarks/Compono.Benchmarks/ConsumerScenarios/SharedValueBenchmarks.cs @@ -0,0 +1,38 @@ +using BenchmarkDotNet.Attributes; +using Compono.Benchmarks.Models; + +namespace Compono.Benchmarks.ConsumerScenarios; + +/// +/// What performance should a user expect from a shared value reused across sibling row +/// parameters - the mechanism Compono.XunitV3's [Shared] attribute builds on, per +/// ADR-0021. Composed via Composer.CreateRow/ResolveShared directly (the same public +/// core API a test-framework integration uses) rather than depending on +/// Compono.XunitV3 itself. +/// +[MemoryDiagnoser] +public class SharedValueBenchmarks +{ + private readonly Composer _composer = Composer.Create(); + + /// + /// Composes one row value plus two sibling consumers, both of + /// which nest it as an ordinary constructor parameter - the row's shared instance is + /// transparently reused by both, never composed independently. + /// + [Benchmark] + public (ConsumerOne One, ConsumerTwo Two) SharedContextAcrossRow() + { + var row = _composer.CreateRow(typeof(SharedValueBenchmarks)); + + row.ResolveShared(new CompositionRequestDescriptor( + CompositionRequestKind.TestParameter, 0, "context", typeof(SharedValueBenchmarks), Nullability.NotNullable)); + + var one = row.Resolve(new CompositionRequestDescriptor( + CompositionRequestKind.TestParameter, 1, "one", typeof(SharedValueBenchmarks), Nullability.NotNullable)); + var two = row.Resolve(new CompositionRequestDescriptor( + CompositionRequestKind.TestParameter, 2, "two", typeof(SharedValueBenchmarks), Nullability.NotNullable)); + + return (one, two); + } +} diff --git a/benchmarks/Compono.Benchmarks/DeepGraphBenchmarks.cs b/benchmarks/Compono.Benchmarks/DeepGraphBenchmarks.cs deleted file mode 100644 index ca48096..0000000 --- a/benchmarks/Compono.Benchmarks/DeepGraphBenchmarks.cs +++ /dev/null @@ -1,46 +0,0 @@ -using BenchmarkDotNet.Attributes; - -namespace Compono.Benchmarks; - -/// An 8-level-deep chain of composable types - purely to exercise 's growth path. -public sealed record DeepLevel8(string Value); - -/// See 's remarks. -public sealed record DeepLevel7(DeepLevel8 Child); - -/// See 's remarks. -public sealed record DeepLevel6(DeepLevel7 Child); - -/// See 's remarks. -public sealed record DeepLevel5(DeepLevel6 Child); - -/// See 's remarks. -public sealed record DeepLevel4(DeepLevel5 Child); - -/// See 's remarks. -public sealed record DeepLevel3(DeepLevel4 Child); - -/// See 's remarks. -public sealed record DeepLevel2(DeepLevel3 Child); - -/// See 's remarks. -public sealed record DeepLevel1(DeepLevel2 Child); - -/// -/// Measures a genuinely deep composable-type chain (8 nested generated-plan dispatches), not just -/// ' shallow / graph - -/// a PR #13 review point: each active ancestor frame dispatching through stage 8 retains ~6 trace -/// entries until its own child returns (5 declined stages + a Pending marker), so this -/// 8-level chain (~48 entries at its deepest point) exceeds 's -/// 32-entry initial capacity and triggers a real Array.Resize - unlike the shallow -/// Customer graph, which never gets deep enough to. -/// -[MemoryDiagnoser] -public class DeepGraphBenchmarks -{ - private readonly Composer _composer = Composer.Create(); - - /// Composes the 8-level-deep chain. - [Benchmark] - public DeepLevel1 Create() => _composer.Create(); -} diff --git a/benchmarks/Compono.Benchmarks/EcosystemBenchmarks.cs b/benchmarks/Compono.Benchmarks/EcosystemBenchmarks.cs deleted file mode 100644 index f8a2de0..0000000 --- a/benchmarks/Compono.Benchmarks/EcosystemBenchmarks.cs +++ /dev/null @@ -1,26 +0,0 @@ -using BenchmarkDotNet.Attributes; - -namespace Compono.Benchmarks; - -/// -/// How does Compono compare with AutoFixture, the established framework developers reach for -/// today to construct test data - a separate question from , -/// which validates the architecture on its own terms. AutoFixture does substantially more -/// runtime work and has different goals (randomized value generation, kept here unexercised -/// since has no properties to fill), so this is a recognizable reference -/// point, not the success criterion for Milestone 1 (docs/performance.md). -/// -[MemoryDiagnoser] -public class EcosystemBenchmarks -{ - private readonly Composer _composer = Composer.Create(); - private readonly AutoFixtureComposer _autoFixture = new(); - - /// Constructs via its generated . - [Benchmark(Baseline = true)] - public Leaf Generated() => _composer.Create(); - - /// Constructs via AutoFixture. - [Benchmark] - public Leaf AutoFixture() => _autoFixture.Compose(); -} diff --git a/benchmarks/Compono.Benchmarks/ExternalComparison/MediumAggregateComparisonBenchmarks.cs b/benchmarks/Compono.Benchmarks/ExternalComparison/MediumAggregateComparisonBenchmarks.cs new file mode 100644 index 0000000..72d6641 --- /dev/null +++ b/benchmarks/Compono.Benchmarks/ExternalComparison/MediumAggregateComparisonBenchmarks.cs @@ -0,0 +1,26 @@ +using BenchmarkDotNet.Attributes; +using Compono.Benchmarks.Baselines; +using Compono.Benchmarks.Models; + +namespace Compono.Benchmarks.ExternalComparison; + +/// +/// What should a developer expect when migrating from AutoFixture, for the moderately-nested +/// model - the nested-graph counterpart to +/// , giving AutoFixture real randomized-value- +/// generation work to do (unlike the flat model). +/// +[MemoryDiagnoser] +public class MediumAggregateComparisonBenchmarks +{ + private readonly Composer _composer = Composer.Create(); + private readonly AutoFixtureComposer _autoFixture = new(); + + /// Composes through the real resolution pipeline. + [Benchmark(Baseline = true)] + public MediumAggregate Generated() => _composer.Create(); + + /// Constructs via AutoFixture. + [Benchmark] + public MediumAggregate AutoFixture() => _autoFixture.Compose(); +} diff --git a/benchmarks/Compono.Benchmarks/ExternalComparison/SimplePocoComparisonBenchmarks.cs b/benchmarks/Compono.Benchmarks/ExternalComparison/SimplePocoComparisonBenchmarks.cs new file mode 100644 index 0000000..731fc80 --- /dev/null +++ b/benchmarks/Compono.Benchmarks/ExternalComparison/SimplePocoComparisonBenchmarks.cs @@ -0,0 +1,25 @@ +using BenchmarkDotNet.Attributes; +using Compono.Benchmarks.Baselines; +using Compono.Benchmarks.Models; + +namespace Compono.Benchmarks.ExternalComparison; + +/// +/// What should a developer expect when migrating from AutoFixture, for the flat +/// model - per ADR-0034, AutoFixture is one comparison point, not the +/// suite's center. Equivalent object graph, equivalent work - published honestly either way. +/// +[MemoryDiagnoser] +public class SimplePocoComparisonBenchmarks +{ + private readonly Composer _composer = Composer.Create(); + private readonly AutoFixtureComposer _autoFixture = new(); + + /// Composes through the real resolution pipeline. + [Benchmark(Baseline = true)] + public SimplePoco Generated() => _composer.Create(); + + /// Constructs via AutoFixture. + [Benchmark] + public SimplePoco AutoFixture() => _autoFixture.Compose(); +} diff --git a/benchmarks/Compono.Benchmarks/FeatureOverhead/BogusOverheadBenchmarks.cs b/benchmarks/Compono.Benchmarks/FeatureOverhead/BogusOverheadBenchmarks.cs new file mode 100644 index 0000000..7b8c9ca --- /dev/null +++ b/benchmarks/Compono.Benchmarks/FeatureOverhead/BogusOverheadBenchmarks.cs @@ -0,0 +1,30 @@ +using BenchmarkDotNet.Attributes; +using Compono.Benchmarks.Models; + +namespace Compono.Benchmarks.FeatureOverhead; + +/// +/// How expensive is UseBogus(), on its own? Both arms pin +/// identically (a plain registration - unrelated to what's being measured) and vary only how +/// is resolved: a stage-4 member rule (the baseline) vs. +/// Compono.Bogus's stage-5 convention provider. +/// +[MemoryDiagnoser] +public class BogusOverheadBenchmarks +{ + private readonly Composer _memberRule = Composer.Create(builder => builder + .Register(_ => new FixedClock()) + .For().Member(x => x.Email).Use("fixed@example.com")); + + private readonly Composer _bogus = Composer.Create(builder => builder + .Register(_ => new FixedClock()) + .UseBogus()); + + /// Resolves Email via a stage-4 member rule - the baseline is measured against. + [Benchmark(Baseline = true)] + public ProviderBackedModel EmailViaMemberRule() => _memberRule.Create(); + + /// Resolves Email via Compono.Bogus's stage-5 convention provider. + [Benchmark] + public ProviderBackedModel EmailViaBogus() => _bogus.Create(); +} diff --git a/benchmarks/Compono.Benchmarks/FeatureOverhead/ConfigurationOverheadBenchmarks.cs b/benchmarks/Compono.Benchmarks/FeatureOverhead/ConfigurationOverheadBenchmarks.cs new file mode 100644 index 0000000..c3ad61e --- /dev/null +++ b/benchmarks/Compono.Benchmarks/FeatureOverhead/ConfigurationOverheadBenchmarks.cs @@ -0,0 +1,51 @@ +using BenchmarkDotNet.Attributes; +using Compono.Benchmarks.Models; + +namespace Compono.Benchmarks.FeatureOverhead; + +/// +/// Isolates the incremental cost of one Compono configuration mechanism at a time, per ADR-0034 - +/// each step composes the same model, adding exactly one mechanism +/// on top of 's plain composer. Every mechanism here can decline +/// without breaking composition (a member/type rule that doesn't match, a semantic provider that +/// declines) - each step's marginal cost is real, not required for the graph to compose at all. +/// +[MemoryDiagnoser] +public class ConfigurationOverheadBenchmarks +{ + private readonly Composer _generatedOnly = Composer.Create(); + + private readonly Composer _plusMemberRule = Composer.Create(builder => builder + .For().Member(x => x.FirstName).Use("Fixed")); + + private readonly Composer _plusTypeRule = Composer.Create(builder => builder + .For
().Use(_ => new Address("Fixed St", "Fixed City"))); + + private readonly Composer _plusCustomProvider = Composer.Create(builder => builder + .AddSemanticProvider(new FirstNameProvider())); + + /// Composes with no configuration - the floor this class' other benchmarks are compared against. + [Benchmark(Baseline = true)] + public MediumAggregate GeneratedOnly() => _generatedOnly.Create(); + + /// Composes with one stage-4 member rule active. + [Benchmark] + public MediumAggregate PlusMemberRule() => _plusMemberRule.Create(); + + /// Composes with one stage-4 type rule (on the nested ) active. + [Benchmark] + public MediumAggregate PlusTypeRule() => _plusTypeRule.Create(); + + /// Composes with one stage-5 semantic provider active. + [Benchmark] + public MediumAggregate PlusCustomProvider() => _plusCustomProvider.Create(); + + /// A minimal stage-5 provider claiming only members literally named FirstName - exercises the public provider extension point's own dispatch cost. + private sealed class FirstNameProvider : ICompositionValueProvider + { + public CompositionProviderResult TryProvide(in CompositionProviderRequest request, ICompositionContext context) => + request.Name == "FirstName" + ? CompositionProviderResult.Handled("Provided") + : CompositionProviderResult.NotHandled; + } +} diff --git a/benchmarks/Compono.Benchmarks/FeatureOverhead/NSubstituteOverheadBenchmarks.cs b/benchmarks/Compono.Benchmarks/FeatureOverhead/NSubstituteOverheadBenchmarks.cs new file mode 100644 index 0000000..dfe16fe --- /dev/null +++ b/benchmarks/Compono.Benchmarks/FeatureOverhead/NSubstituteOverheadBenchmarks.cs @@ -0,0 +1,31 @@ +using BenchmarkDotNet.Attributes; +using Compono.Benchmarks.Models; + +namespace Compono.Benchmarks.FeatureOverhead; + +/// +/// How expensive is UseNSubstitute(), on its own? Both arms pin +/// identically (a plain member rule - unrelated to what's +/// being measured) and vary only how is resolved: a +/// stage-3 exact registration (the cheapest possible alternative, and the baseline) vs. +/// Compono.NSubstitute's stage-6 test-double provider. +/// +[MemoryDiagnoser] +public class NSubstituteOverheadBenchmarks +{ + private readonly Composer _registration = Composer.Create(builder => builder + .Register(_ => new FixedClock()) + .For().Member(x => x.Email).Use("fixed@example.com")); + + private readonly Composer _nsubstitute = Composer.Create(builder => builder + .UseNSubstitute() + .For().Member(x => x.Email).Use("fixed@example.com")); + + /// Resolves Clock via a stage-3 exact registration - the baseline is measured against. + [Benchmark(Baseline = true)] + public ProviderBackedModel ClockViaRegistration() => _registration.Create(); + + /// Resolves Clock via Compono.NSubstitute's stage-6 test-double provider. + [Benchmark] + public ProviderBackedModel ClockViaNSubstitute() => _nsubstitute.Create(); +} diff --git a/benchmarks/Compono.Benchmarks/FeatureOverhead/SharingOverheadBenchmarks.cs b/benchmarks/Compono.Benchmarks/FeatureOverhead/SharingOverheadBenchmarks.cs new file mode 100644 index 0000000..23e6fca --- /dev/null +++ b/benchmarks/Compono.Benchmarks/FeatureOverhead/SharingOverheadBenchmarks.cs @@ -0,0 +1,53 @@ +using BenchmarkDotNet.Attributes; +using Compono.Benchmarks.Models; + +namespace Compono.Benchmarks.FeatureOverhead; + +/// +/// Isolates the marginal cost of [Shared]'s underlying mechanism +/// (CreateRow/ResolveShared) against composing the same two sibling values with no +/// sharing at all - both arms go through CreateRow, so the only difference is whether +/// sharing is actually invoked, per ADR-0034's "isolate one feature at a time" principle. +/// +[MemoryDiagnoser] +public class SharingOverheadBenchmarks +{ + private readonly Composer _composer = Composer.Create(); + + /// + /// Composes two sibling row values, each independently composing its own + /// - no sharing established. + /// + [Benchmark(Baseline = true)] + public (ConsumerOne One, ConsumerTwo Two) WithoutSharing() + { + var row = _composer.CreateRow(typeof(SharingOverheadBenchmarks)); + + var one = row.Resolve(new CompositionRequestDescriptor( + CompositionRequestKind.TestParameter, 0, "one", typeof(SharingOverheadBenchmarks), Nullability.NotNullable)); + var two = row.Resolve(new CompositionRequestDescriptor( + CompositionRequestKind.TestParameter, 1, "two", typeof(SharingOverheadBenchmarks), Nullability.NotNullable)); + + return (one, two); + } + + /// + /// Composes one shared row value, then two sibling consumers that + /// both reuse it - the same shape as , with sharing added. + /// + [Benchmark] + public (ConsumerOne One, ConsumerTwo Two) WithSharing() + { + var row = _composer.CreateRow(typeof(SharingOverheadBenchmarks)); + + row.ResolveShared(new CompositionRequestDescriptor( + CompositionRequestKind.TestParameter, 0, "context", typeof(SharingOverheadBenchmarks), Nullability.NotNullable)); + + var one = row.Resolve(new CompositionRequestDescriptor( + CompositionRequestKind.TestParameter, 1, "one", typeof(SharingOverheadBenchmarks), Nullability.NotNullable)); + var two = row.Resolve(new CompositionRequestDescriptor( + CompositionRequestKind.TestParameter, 2, "two", typeof(SharingOverheadBenchmarks), Nullability.NotNullable)); + + return (one, two); + } +} diff --git a/benchmarks/Compono.Benchmarks/Models/DeepGraph.cs b/benchmarks/Compono.Benchmarks/Models/DeepGraph.cs new file mode 100644 index 0000000..0a88c90 --- /dev/null +++ b/benchmarks/Compono.Benchmarks/Models/DeepGraph.cs @@ -0,0 +1,31 @@ +namespace Compono.Benchmarks.Models; + +/// An 8-level-deep chain of composable types, per ADR-0034's Scalability category (shallow vs. deep graphs). See 's remarks. +public sealed record DeepLevel8(string Value); + +/// See 's remarks. +public sealed record DeepLevel7(DeepLevel8 Child); + +/// See 's remarks. +public sealed record DeepLevel6(DeepLevel7 Child); + +/// See 's remarks. +public sealed record DeepLevel5(DeepLevel6 Child); + +/// See 's remarks. +public sealed record DeepLevel4(DeepLevel5 Child); + +/// See 's remarks. +public sealed record DeepLevel3(DeepLevel4 Child); + +/// See 's remarks. +public sealed record DeepLevel2(DeepLevel3 Child); + +/// +/// The deep-graph representative model's root, per ADR-0034: an 8-level chain of single-field +/// composable types, deep enough (~48 trace entries at its deepest point) to exceed +/// CompositionTraceBuffer's 32-entry initial capacity and trigger a real +/// Array.Resize - unlike 's shallow, 2-level graph. Replaces +/// the old suite's DeepLevel1-DeepLevel8 one-off benchmark types. +/// +public sealed record DeepGraph(DeepLevel2 Child); diff --git a/benchmarks/Compono.Benchmarks/Models/LargeCollection.cs b/benchmarks/Compono.Benchmarks/Models/LargeCollection.cs new file mode 100644 index 0000000..a2d8a29 --- /dev/null +++ b/benchmarks/Compono.Benchmarks/Models/LargeCollection.cs @@ -0,0 +1,8 @@ +namespace Compono.Benchmarks.Models; + +/// +/// A model whose only member is a collection - per ADR-0034's Scalability category, its actual +/// element count is controlled at composition time via WithCollectionSize(...), not fixed +/// on the type itself, so one model serves every collection-size data point in the matrix. +/// +public sealed record LargeCollection(List Items); diff --git a/benchmarks/Compono.Benchmarks/Models/MediumAggregate.cs b/benchmarks/Compono.Benchmarks/Models/MediumAggregate.cs new file mode 100644 index 0000000..f3026b6 --- /dev/null +++ b/benchmarks/Compono.Benchmarks/Models/MediumAggregate.cs @@ -0,0 +1,14 @@ +namespace Compono.Benchmarks.Models; + +/// +/// A nested composable dependency of - see that type's remarks. +/// +public sealed record Address(string Street, string City); + +/// +/// The canonical "representative graph" model, per ADR-0034: one nested composable dependency +/// (), every built-in kind via string, and a collection member - +/// reused across every ADR-0034 category that needs a realistic, moderately-nested type, instead +/// of each category inventing its own. Replaces the old suite's Customer/Address. +/// +public sealed record MediumAggregate(string FirstName, string LastName, Address HomeAddress, List Tags); diff --git a/benchmarks/Compono.Benchmarks/Models/ProviderBackedModel.cs b/benchmarks/Compono.Benchmarks/Models/ProviderBackedModel.cs new file mode 100644 index 0000000..35173a5 --- /dev/null +++ b/benchmarks/Compono.Benchmarks/Models/ProviderBackedModel.cs @@ -0,0 +1,27 @@ +namespace Compono.Benchmarks.Models; + +/// An interface-typed dependency Compono.NSubstitute can satisfy - see 's remarks. +public interface IClock +{ + /// The current instant, per this clock. + DateTimeOffset Now { get; } +} + +/// A fixed, non-substitute - the baseline registration ADR-0034's provider-overhead comparisons measure against, not a real system clock. +public sealed class FixedClock : IClock +{ + private static readonly DateTimeOffset FixedInstant = new(2026, 1, 1, 0, 0, 0, TimeSpan.Zero); + + /// + public DateTimeOffset Now => FixedInstant; +} + +/// +/// The provider-eligible representative model, per ADR-0034: an interface-typed member +/// () Compono.NSubstitute's stage-6 provider can satisfy, and a +/// string member named Email - matching Compono.Bogus's built-in +/// member-name-convention allowlist - its stage-5 provider can satisfy. Reused by +/// ConsumerScenarios' Bogus/NSubstitute-enabled cases and FeatureOverhead's provider-overhead +/// comparisons, so both categories draw on the same model rather than inventing their own. +/// +public sealed record ProviderBackedModel(IClock Clock, string Email); diff --git a/benchmarks/Compono.Benchmarks/Models/SharedValueGraph.cs b/benchmarks/Compono.Benchmarks/Models/SharedValueGraph.cs new file mode 100644 index 0000000..064d03d --- /dev/null +++ b/benchmarks/Compono.Benchmarks/Models/SharedValueGraph.cs @@ -0,0 +1,18 @@ +namespace Compono.Benchmarks.Models; + +/// A value shared across / in one composition row - see 's remarks. +public sealed record SharedContext(string CorrelationId); + +/// +/// One of two sibling row values in ADR-0034's shared-value representative shape - each nests +/// as an ordinary, unmarked constructor parameter. Composed via +/// Composer.CreateRow/ResolveShared (mirroring Compono.XunitV3's +/// [Shared] mechanism at the core API level, without depending on that package): the row's +/// shared instance is transparently reused by both consumers' nested +/// constructor parameter, per ADR-0021's unconditional-read-side scope check. Replaces the old +/// suite's complete lack of shared-value coverage. +/// +public sealed record ConsumerOne(SharedContext Context, string Label); + +/// See 's remarks. +public sealed record ConsumerTwo(SharedContext Context, int Sequence); diff --git a/benchmarks/Compono.Benchmarks/Models/SimplePoco.cs b/benchmarks/Compono.Benchmarks/Models/SimplePoco.cs new file mode 100644 index 0000000..9154948 --- /dev/null +++ b/benchmarks/Compono.Benchmarks/Models/SimplePoco.cs @@ -0,0 +1,8 @@ +namespace Compono.Benchmarks.Models; + +/// +/// A flat, parameterless-dependency type - the simplest possible composition target, reused +/// across every ADR-0034 category that needs a "floor" model (no nested composable dependency, +/// no collection). Replaces the old suite's Leaf. +/// +public sealed record SimplePoco(string Name, int Count, bool IsActive); diff --git a/benchmarks/Compono.Benchmarks/ReflectionComposer.cs b/benchmarks/Compono.Benchmarks/ReflectionComposer.cs deleted file mode 100644 index c444741..0000000 --- a/benchmarks/Compono.Benchmarks/ReflectionComposer.cs +++ /dev/null @@ -1,85 +0,0 @@ -using System.Collections; - -namespace Compono.Benchmarks; - -/// -/// A minimal reflection-based construction baseline - what -/// replaces with source generation. -/// -public static class ReflectionComposer -{ - // Matches Compono's own defaults exactly, so this baseline does comparable real work rather - // than a cheaper strawman: PrimitiveValueProvider.StringLength (src/Compono/Providers) and - // ADR-0013's default collection size. - private const string Alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; - private const int StringLength = 8; - private const int CollectionSize = 3; - - /// - /// Constructs an instance of via its parameterless constructor, - /// found and invoked through reflection rather than generated code. - /// - /// The type to construct. - public static T Compose() - { - var constructor = typeof(T).GetConstructors().Single(); - - return (T)constructor.Invoke([]); - } - - /// - /// Constructs an instance of by walking its constructor's parameters - /// recursively through reflection, filling every leaf field with a genuinely random value - /// (an 8-character alphanumeric string, a 3-element collection - Compono's own defaults) rather - /// than a fixed placeholder. This is the actual reflection-based alternative someone would write - /// by hand for ' representative graph, not a dispatch-cost-only - /// strawman: an earlier version of this method used fixed placeholder values, which made it - /// faster than for doing categorically less work, not because - /// reflective dispatch beats source-generated dispatch (PR #13 review). Deliberately narrow: - /// only the shapes / actually use (string, - /// List<T>, and a type with a single public constructor) - this is a benchmark - /// baseline, not a general reflection-based composer, and its randomness is ordinary - /// , not Compono's deterministic, seed-forked - /// - reproducibility isn't a property this baseline needs. - /// - /// The type to construct. - public static T ComposeRecursive() => (T)ComposeValue(typeof(T))!; - - private static object? ComposeValue(Type type) - { - if (type == typeof(string)) - return NextString(); - - if (type.IsEnum) - { - var values = Enum.GetValues(type); - return values.GetValue(Random.Shared.Next(values.Length)); - } - - if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(List<>)) - { - var elementType = type.GetGenericArguments()[0]; - var list = (IList)Activator.CreateInstance(type)!; - for (var i = 0; i < CollectionSize; i++) - list.Add(ComposeValue(elementType)); - - return list; - } - - var constructor = type.GetConstructors().Single(); - var arguments = constructor.GetParameters() - .Select(parameter => ComposeValue(parameter.ParameterType)) - .ToArray(); - - return constructor.Invoke(arguments); - } - - private static string NextString() - { - Span chars = stackalloc char[StringLength]; - for (var i = 0; i < StringLength; i++) - chars[i] = Alphabet[Random.Shared.Next(Alphabet.Length)]; - - return new string(chars); - } -} diff --git a/benchmarks/Compono.Benchmarks/ResolutionArchitectureBenchmarks.cs b/benchmarks/Compono.Benchmarks/ResolutionArchitectureBenchmarks.cs deleted file mode 100644 index b72ea33..0000000 --- a/benchmarks/Compono.Benchmarks/ResolutionArchitectureBenchmarks.cs +++ /dev/null @@ -1,48 +0,0 @@ -using BenchmarkDotNet.Attributes; - -namespace Compono.Benchmarks; - -/// -/// The nested-graph counterpart to : what does the real -/// resolution pipeline (provider dispatch, deterministic random forking, collection generation, -/// the diagnostics trace buffer) cost against 's theoretical floor and -/// 's hand-rolled alternative, for a representative graph rather than a -/// flat, property-less type. -/// -/// -/// stays the theoretical floor (no fields to fill, same as -/// ' Leaf), but here does -/// comparable real work to : -/// fills every field with a genuinely random value (an 8-character alphanumeric string, a -/// 3-element collection - Compono's own defaults), not a fixed placeholder. An earlier version of -/// this baseline used fixed placeholders, which made faster than -/// for doing categorically less work rather than because reflective -/// dispatch actually beats source-generated dispatch - a misleading comparison caught in PR #13 -/// review, fixed by rewriting to do real -/// value generation. The one remaining, deliberate asymmetry: 's randomness -/// is ordinary , not Compono's deterministic, seed-forked -/// - reproducibility is a Compono product feature -/// (README.md's "Deterministic by design"), not a cost every random-value generator has to -/// pay, so 's cost here includes work 's doesn't. -/// -[MemoryDiagnoser] -public class ResolutionArchitectureBenchmarks -{ - private readonly Composer _composer = Composer.Create(); - - /// Constructs directly - the theoretical floor. - [Benchmark(Baseline = true)] - public Customer Direct() => new("first", "last", new Address("street", "city"), ["tag1", "tag2", "tag3"]); - - /// Composes through the real resolution pipeline. - [Benchmark] - public Customer Generated() => _composer.Create(); - - /// - /// Constructs via a recursive reflection-based composer that fills every - /// field with a genuinely random value - see this class' remarks for the one remaining, - /// deliberate asymmetry (ordinary randomness, not Compono's deterministic seed-forking). - /// - [Benchmark] - public Customer Reflection() => ReflectionComposer.ComposeRecursive(); -} diff --git a/benchmarks/Compono.Benchmarks/ResolutionBenchmarkTypes.cs b/benchmarks/Compono.Benchmarks/ResolutionBenchmarkTypes.cs deleted file mode 100644 index 9a4cff0..0000000 --- a/benchmarks/Compono.Benchmarks/ResolutionBenchmarkTypes.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace Compono.Benchmarks; - -/// -/// The nested-composable-type + built-in + collection shape -/// docs/plans/0002-milestone-2-core-composition-engine.md's Execution Flow section and -/// Phase 4 benchmark task use as "a representative graph" - the first point in that plan a type -/// exists that's worth benchmarking resolution against, rather than just construction dispatch -/// ('s ). -/// -public sealed record Address(string Street, string City); - -/// See 's remarks. -public sealed record Customer(string FirstName, string LastName, Address HomeAddress, List Tags); diff --git a/benchmarks/Compono.Benchmarks/ResolutionBenchmarks.cs b/benchmarks/Compono.Benchmarks/ResolutionBenchmarks.cs deleted file mode 100644 index 49e52ed..0000000 --- a/benchmarks/Compono.Benchmarks/ResolutionBenchmarks.cs +++ /dev/null @@ -1,33 +0,0 @@ -using BenchmarkDotNet.Attributes; - -namespace Compono.Benchmarks; - -/// -/// CreateMany<T>(count)'s scaling behavior across a few batch sizes, against its own -/// Create<T>() baseline () - no external comparison here, since the -/// question this benchmark answers ("does allocation grow linearly or super-linearly with the -/// batch size?", per docs/plans/0002-milestone-2-core-composition-engine.md's Phase 4 -/// benchmark task) has no equivalent in `new()`/reflection/AutoFixture. For the representative-graph -/// comparison against those baselines, see and -/// - both use the same / -/// graph (nested composable type, every Phase 2 built-in kind via -/// string, a List<string> collection member) this class' own -/// composes. -/// -[MemoryDiagnoser] -public class ResolutionBenchmarks -{ - private readonly Composer _composer = Composer.Create(); - - /// The batch size is benchmarked at. - [Params(1, 10, 100)] - public int Count { get; set; } - - /// Composes one through the real resolution pipeline. - [Benchmark(Baseline = true)] - public Customer Create() => _composer.Create(); - - /// Composes independent instances. - [Benchmark] - public IReadOnlyList CreateMany() => _composer.CreateMany(Count); -} diff --git a/benchmarks/Compono.Benchmarks/ResolutionEcosystemBenchmarks.cs b/benchmarks/Compono.Benchmarks/ResolutionEcosystemBenchmarks.cs deleted file mode 100644 index 3e0a926..0000000 --- a/benchmarks/Compono.Benchmarks/ResolutionEcosystemBenchmarks.cs +++ /dev/null @@ -1,24 +0,0 @@ -using BenchmarkDotNet.Attributes; - -namespace Compono.Benchmarks; - -/// -/// The nested-graph counterpart to : how does Compono compare -/// with AutoFixture once there's an actual representative graph to fill (nested composable type, -/// a collection member), rather than ' flat, property-less -/// , which never exercised AutoFixture's real value-generation work. -/// -[MemoryDiagnoser] -public class ResolutionEcosystemBenchmarks -{ - private readonly Composer _composer = Composer.Create(); - private readonly AutoFixtureComposer _autoFixture = new(); - - /// Composes through the real resolution pipeline. - [Benchmark(Baseline = true)] - public Customer Generated() => _composer.Create(); - - /// Constructs via AutoFixture. - [Benchmark] - public Customer AutoFixture() => _autoFixture.Compose(); -} diff --git a/benchmarks/Compono.Benchmarks/Scalability/BatchScalingBenchmarks.cs b/benchmarks/Compono.Benchmarks/Scalability/BatchScalingBenchmarks.cs new file mode 100644 index 0000000..0a25a70 --- /dev/null +++ b/benchmarks/Compono.Benchmarks/Scalability/BatchScalingBenchmarks.cs @@ -0,0 +1,28 @@ +using BenchmarkDotNet.Attributes; +using Compono.Benchmarks.Models; + +namespace Compono.Benchmarks.Scalability; + +/// +/// CreateMany<T>(count)'s scaling behavior across a batch-size matrix, against its +/// own Create<T>() baseline - exists to catch algorithmic (super-linear) regressions +/// in the checkpoint/rewind trace buffer, per-item seed forking, or scope allocation, not just +/// constant-factor ones, per ADR-0034. +/// +[MemoryDiagnoser] +public class BatchScalingBenchmarks +{ + private readonly Composer _composer = Composer.Create(); + + /// The batch size is benchmarked at. + [Params(1, 10, 100, 1000)] + public int Count { get; set; } + + /// Composes one through the real resolution pipeline. + [Benchmark(Baseline = true)] + public MediumAggregate Create() => _composer.Create(); + + /// Composes independent instances. + [Benchmark] + public IReadOnlyList CreateMany() => _composer.CreateMany(Count); +} diff --git a/benchmarks/Compono.Benchmarks/Scalability/CollectionSizeScalingBenchmarks.cs b/benchmarks/Compono.Benchmarks/Scalability/CollectionSizeScalingBenchmarks.cs new file mode 100644 index 0000000..4cdd371 --- /dev/null +++ b/benchmarks/Compono.Benchmarks/Scalability/CollectionSizeScalingBenchmarks.cs @@ -0,0 +1,29 @@ +using BenchmarkDotNet.Attributes; +using Compono.Benchmarks.Models; + +namespace Compono.Benchmarks.Scalability; + +/// +/// Composition cost as a single collection member's size grows, per ADR-0034 - catches +/// algorithmic regressions in stage 7's collection dispatch (CollectionPlanCache<T>) +/// that a fixed, small collection size would never surface. +/// +[MemoryDiagnoser] +public class CollectionSizeScalingBenchmarks +{ + // Assigned in GlobalSetup, which BenchmarkDotNet guarantees runs (once per Params value) + // before any [Benchmark] method executes. + private Composer _composer = null!; + + /// ' element count for this run. + [Params(3, 10, 50, 200)] + public int CollectionSize { get; set; } + + /// Builds a composer configured for this run's - kept out of the timed benchmark method. + [GlobalSetup] + public void Setup() => _composer = Composer.Create(builder => builder.WithCollectionSize(CollectionSize)); + + /// Composes at this run's . + [Benchmark] + public LargeCollection Create() => _composer.Create(); +} diff --git a/benchmarks/Compono.Benchmarks/Scalability/GraphDepthScalingBenchmarks.cs b/benchmarks/Compono.Benchmarks/Scalability/GraphDepthScalingBenchmarks.cs new file mode 100644 index 0000000..99d51b8 --- /dev/null +++ b/benchmarks/Compono.Benchmarks/Scalability/GraphDepthScalingBenchmarks.cs @@ -0,0 +1,26 @@ +using BenchmarkDotNet.Attributes; +using Compono.Benchmarks.Models; + +namespace Compono.Benchmarks.Scalability; + +/// +/// Shallow vs. deep graph composition cost, per ADR-0034 - generalizes the old suite's one-off +/// DeepGraphBenchmarks (which only ever exercised in isolation) +/// into a real comparison against 's shallow, 2-level shape. +/// 's 8-level chain is deep enough (~48 trace entries at its deepest point) +/// to exceed CompositionTraceBuffer's 32-entry initial capacity and trigger a real +/// Array.Resize, unlike . +/// +[MemoryDiagnoser] +public class GraphDepthScalingBenchmarks +{ + private readonly Composer _composer = Composer.Create(); + + /// Composes the shallow, 2-level graph. + [Benchmark(Baseline = true)] + public MediumAggregate Shallow() => _composer.Create(); + + /// Composes the 8-level-deep chain. + [Benchmark] + public DeepGraph Deep() => _composer.Create(); +} diff --git a/benchmarks/Compono.Benchmarks/SourceGeneration/GeneratorDriverBenchmarks.cs b/benchmarks/Compono.Benchmarks/SourceGeneration/GeneratorDriverBenchmarks.cs new file mode 100644 index 0000000..4dcefeb --- /dev/null +++ b/benchmarks/Compono.Benchmarks/SourceGeneration/GeneratorDriverBenchmarks.cs @@ -0,0 +1,115 @@ +using System.Text; +using BenchmarkDotNet.Attributes; +using Basic.Reference.Assemblies; +using Compono.Generators; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CSharp; + +namespace Compono.Benchmarks.SourceGeneration; + +/// +/// Clean vs. incremental generation cost, across a composable-type-count matrix, per ADR-0034 - a +/// separate concern from every other category's runtime performance, measured in-process via +/// Roslyn's directly (the same way +/// Compono.Generators.Tests drives it), not a separate timing harness. Primarily serves +/// maintainers. +/// +[MemoryDiagnoser] +public class GeneratorDriverBenchmarks +{ + // Assigned in GlobalSetup, which BenchmarkDotNet guarantees runs (once per Params value) + // before any [Benchmark] method executes. + private Compilation _baseCompilation = null!; + private Compilation _touchedCompilation = null!; + private GeneratorDriver _warmDriver = null!; + + /// How many Composer.Create<T>() call sites (and matching composable types) the compilation contains for this run. + [Params(1, 10, 50)] + public int TypeCount { get; set; } + + /// + /// Builds this run's base and touched compilations, and runs the generator once against the + /// base compilation to fully warm 's incremental cache - kept out of + /// both timed benchmark methods. + /// + [GlobalSetup] + public void Setup() + { + _baseCompilation = BuildCompilation(TypeCount, touched: false); + + // Derived from _baseCompilation via ReplaceSyntaxTree, not built as an independent + // CSharpCompilation.Create(...) call - Roslyn's incremental generator cache is keyed off + // compilation/tree identity, so two separately-constructed compilations look entirely + // unrelated to the driver (forcing a full recompute, not a real incremental diff) even if + // their source text is nearly identical. + var touchedTree = BuildSyntaxTree(TypeCount, touched: true); + _touchedCompilation = _baseCompilation.ReplaceSyntaxTree(_baseCompilation.SyntaxTrees.Single(), touchedTree); + + var driver = CSharpGeneratorDriver.Create(new ComponoIncrementalGenerator().AsSourceGenerator()); + _warmDriver = driver.RunGenerators(_baseCompilation); + } + + /// A brand-new driver against the base compilation - no incremental cache to reuse, models a first/cold build. + [Benchmark(Baseline = true)] + public GeneratorDriver CleanGeneration() + { + var driver = CSharpGeneratorDriver.Create(new ComponoIncrementalGenerator().AsSourceGenerator()); + return driver.RunGenerators(_baseCompilation); + } + + /// The already-warmed driver against a trivially touched compilation (an unrelated trailing comment, no call site changed) - models an incremental rebuild after a small, unrelated source edit. + [Benchmark] + public GeneratorDriver IncrementalGeneration() => _warmDriver.RunGenerators(_touchedCompilation); + + private static Compilation BuildCompilation(int typeCount, bool touched) + { + var syntaxTree = BuildSyntaxTree(typeCount, touched); + + List references = + [ +#if NET11_0_OR_GREATER + .. Net110.References.All, +#elif NET10_0_OR_GREATER + .. Net100.References.All, +#endif + MetadataReference.CreateFromFile(typeof(Composer).Assembly.Location), + ]; + + var compilationOptions = new CSharpCompilationOptions( + OutputKind.DynamicallyLinkedLibrary, + nullableContextOptions: NullableContextOptions.Enable); + + return CSharpCompilation.Create("SourceGenerationBenchmarkAssembly", [syntaxTree], references, compilationOptions); + } + + private static SyntaxTree BuildSyntaxTree(int typeCount, bool touched) + { + var parseOptions = CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.CSharp14); + return CSharpSyntaxTree.ParseText(BuildSource(typeCount, touched), parseOptions, "GeneratedTypes.cs"); + } + + private static string BuildSource(int typeCount, bool touched) + { + var source = new StringBuilder(); + source.AppendLine("namespace Compono.Benchmarks.SourceGeneration.Generated;"); + source.AppendLine(); + + for (var i = 0; i < typeCount; i++) + source.AppendLine($"public sealed record GeneratedType{i}(string Value);"); + + source.AppendLine(); + source.AppendLine("public static class EntryPoint"); + source.AppendLine("{"); + source.AppendLine(" public static void ComposeAll(global::Compono.Composer composer)"); + source.AppendLine(" {"); + for (var i = 0; i < typeCount; i++) + source.AppendLine($" composer.Create();"); + source.AppendLine(" }"); + source.AppendLine("}"); + + if (touched) + source.AppendLine("// incremental-touch marker - no call site changed"); + + return source.ToString(); + } +} diff --git a/docs/adr/0027-compono-bogus-package-design.md b/docs/adr/0027-compono-bogus-package-design.md index 95d47c9..1fa9ffe 100644 --- a/docs/adr/0027-compono-bogus-package-design.md +++ b/docs/adr/0027-compono-bogus-package-design.md @@ -552,3 +552,58 @@ callback, asserted deterministic for the same seed). shared in either direction - [ADR-0026](0026-deterministic-seed-derivation-for-providers.md) — the `DeriveSeed()` capability every model in this ADR builds its determinism on + +## Amendment (2026-08-05): `BogusMemberNameProvider` reuses a per-thread `Faker`, not a fresh one per request + +[ADR-0034](0034-benchmark-suite-strategy-and-redesign.md)'s benchmark +suite measured `BogusMemberNameProvider`'s real cost: constructing a +fresh `Faker` per handled request costs ~865x a plain member rule +(291.5 μs vs. 337 ns, isolated single-member measurement) — Bogus's +`Faker` constructor building out its full set of category generators +(`Name`, `Internet`, `Address`, `Phone`, `Company`, ...) is genuinely +expensive, independent of anything Compono does around it. Fixed by +caching one `Faker` per thread (`ThreadLocal`) instead of +constructing one per request, reseeding its `Random` immediately before +every use. + +**This is not the cached-`Faker` alternative this ADR already +considered and rejected** (see the "Caching a configured `Faker` +across requests was considered and deliberately rejected" paragraph +above) — that paragraph is about `Faker` (Model 3's whole-object +`UseBogus()` registration), a different, more stateful type +(`RuleFor`/`RuleSets`/`FinishWith` bindings) than `BogusMemberNameProvider`'s +plain, untyped `Faker`, and this Amendment doesn't touch `Faker` or +`UseBogus()` at all — that code, and the rejection reasoning behind +it, stand exactly as originally written. The rejected alternative was +also a **globally shared** instance: one `Faker`/`Faker` touched by +every request, from every thread, with no guarantee Bogus tolerates +concurrent `Generate()` calls on it — exactly the hazard this ADR's +original text names. A `ThreadLocal` is structurally different, +not just a smaller version of the same risk: each thread gets its own +private instance that no other thread can ever observe or mutate, so +"does Bogus tolerate concurrent access to one instance" never becomes a +question this code has to answer — concurrent access to a single +instance is impossible by construction, not merely avoided by +convention. Reuse *within* one thread is sequential by definition (a +thread executes one call at a time), and `BogusMemberNameProvider`'s +convention generators (`f => f.Name.FirstName()`, etc.) don't retain +state across calls beyond what `.Random` drives — unlike `Faker`, +there's no `RuleFor` binding or generation-in-progress state for a +later call to observe. `Compono.Bogus.Tests.DeterminismTests`' +`AddingAnUnrelatedBogusBackedMember_DoesNotPerturbAnExistingOnesValue` +already exercises two sequential resolutions against what the fix makes +the same reused `Faker` instance and continues to pass unmodified — the +existing regression coverage for this exact "does an unrelated resolve +leak into a later one" question caught nothing, which is itself evidence +the reuse is safe, not just an assumption. A new concurrency test +(`DeterminismTests.TryProvide_ProducesCorrectValues_WhenCalledConcurrently_OnOneSharedProviderInstance`) +adds coverage this ADR's original text never had: many concurrent +resolutions against one shared `BogusMemberNameProvider` instance, each +compared against an independently-computed single-threaded reference +value for the same seed. + +A future contributor extending this same caching approach to `Faker` +(Model 3) should not assume this Amendment already clears the way — the +original rejection paragraph's concern (shared mutable rule-evaluation +state, not just constructor cost) is a different, harder problem than +what this Amendment solves, and remains unaddressed. diff --git a/docs/adr/0034-benchmark-suite-strategy-and-redesign.md b/docs/adr/0034-benchmark-suite-strategy-and-redesign.md index 1d42902..46c6470 100644 --- a/docs/adr/0034-benchmark-suite-strategy-and-redesign.md +++ b/docs/adr/0034-benchmark-suite-strategy-and-redesign.md @@ -365,3 +365,81 @@ this gets tracked once there's real evidence it's needed). implement. - `benchmarks/Compono.Benchmarks/` — the existing suite this ADR replaces. + +## Amendment (2026-08-05): Implementation Strategies removed — it compares different systems, not one variable + +Implemented, then reconsidered after direct review of the actual +published results (see +[architecture/current/performance.md](../architecture/current/performance.md)'s +history): the Implementation Strategies category (Handwritten/Generated/ +Cached-Reflection/Uncached-Reflection) is **removed**, not just +re-framed. This is a correction to this ADR's original category +taxonomy, not a reversal of the suite redesign itself — the reused +model set, the remaining five categories, the fair comparison rules, and +the reporting rules all stand exactly as originally decided. + +**Why removal, not better framing.** A prior documentation pass already +tried reframing this category's results (explaining that `Generated` +measures the whole pipeline, not construction) rather than removing it. +That reframing was accurate but didn't fix the underlying problem: the +category still doesn't isolate one variable. `Handwritten`, `Cached`/ +`UncachedReflection`, and `Generated` are not three implementations of +the same job — they're three systems doing different, non-comparable +amounts of work (bare construction; construction plus real value +generation; a full multi-stage resolution pipeline with provider +dispatch, diagnostics tracing, deterministic random forking, and an +extensibility surface that in an unconfigured `Composer` is guaranteed +to miss on every stage before the one that actually produces a value). +A result like "reflection is 3x faster than generated" doesn't identify +*why* — the gap could be diagnostics, provider dispatch, deterministic +randomness, the trace buffer, or any other pipeline behavior, and the +benchmark itself gives no way to attribute it to one of those. It's +interesting, but it doesn't guide an engineering decision, which this +ADR's own Decision Drivers already required ("every benchmark must have +a stated purpose; a benchmark that no longer answers a meaningful +question should be deleted"). + +**Revised philosophy.** Every benchmark in this suite must now satisfy +at least one of four concrete goals: + +1. It measures the runtime cost of a specific Compono feature. +2. It measures Compono's scalability. +3. It measures Compono's build-time (source-generation) cost. +4. It measures the migration experience from AutoFixture. + +A benchmark that can't be tied to one of these shouldn't exist. This +sharpens (not reverses) the original "engineering questions first, +marketing second" philosophy: "engineering question" now means one of +the four goals above, not any comparison between implementations that +happens to be interesting. + +**AutoFixture is the only remaining external comparison, and stays for a +different reason than Implementation Strategies did.** External +Comparison survives this amendment because it satisfies goal 4 directly: +AutoFixture solves the same problem Compono does (composing object +graphs, generating values, handling nested objects, extensibility), so +"what happens if I replace AutoFixture with Compono" is a real adoption +question with an actionable answer, not an unattributable one — unlike +Handwritten/reflection, which were never alternatives a real consumer +chooses between. + +**What's removed:** +- The `ImplementationStrategies/` benchmark category (`SimplePocoConstructionBenchmarks.cs`, + `MediumAggregateConstructionBenchmarks.cs`). +- `Baselines/HandwrittenComposer.cs`, `Baselines/CachedReflectionComposer.cs`, + `Baselines/UncachedReflectionComposer.cs` — dead once + `ImplementationStrategies/` is gone; no other category ever referenced + them. `Baselines/AutoFixtureComposer.cs` stays (External Comparison's + only remaining consumer). +- The "Implementation strategies" section of + `architecture/current/performance.md`, and every rule text above that + named it specifically (the rules themselves — baseline parity, honest + publication, full environment disclosure, etc. — still apply to every + remaining category; only the Implementation-Strategies-specific + wording is superseded by this Amendment). + +**What's unchanged**: `Models/`, `Baselines/AutoFixtureComposer.cs`, +`ConsumerScenarios/`, `ExternalComparison/`, `FeatureOverhead/`, +`Scalability/`, `SourceGeneration/`, every fair comparison and reporting +rule not specific to Implementation Strategies, and the regression- +detection-readiness decision. Five categories remain, not six. diff --git a/docs/architecture/current/performance.md b/docs/architecture/current/performance.md index 8d54594..cc0b341 100644 --- a/docs/architecture/current/performance.md +++ b/docs/architecture/current/performance.md @@ -1,296 +1,210 @@ # Performance -Compono's source generator exists to avoid reflection-based construction -at runtime (see [Design Principles](../design-principles.md) and -[ADR-0001](../../adr/0001-source-generation-first.md)). -`benchmarks/Compono.Benchmarks` (a `BenchmarkDotNet` project) makes that a -measured claim rather than an assumption, per Milestone 1's explicit -"benchmark harness comparing generated construction with reflection -baselines" exit criteria (`docs/mvp.md`). Per +This page documents what Compono's benchmark suite measures and what the +results actually say, per [ADR-0034](../../adr/0034-benchmark-suite-strategy-and-redesign.md)'s +redesigned suite — capability-oriented, not a history of milestone-by- +milestone optimization work. Per [ADR-0030 Amendment 2](../../adr/0030-compono-documentation-architecture.md#amendment-2-2026-08-04-resolving-milestone-8s-remaining-open-items)'s -benchmark-claims policy, this page's methodology, environment disclosure, -and comparisons stay fully published and reproducible — the root -`README.md` and every package's description make claims only about -Compono itself, never a comparative headline like "faster than -AutoFixture." - -## What's measured, and what isn't yet - -Milestone 1 only implements direct constructor invocation end-to-end for a -type whose constructor takes no arguments - -`ICompositionContext.Resolve()` is a placeholder that throws -`NotSupportedException` for any real value resolution -(`src/Compono/Composer.cs`). Concretely: a type with a -constructor parameter - even a parameter whose type has its own generated -plan - can't be composed end-to-end yet, because generated code resolves -every constructor argument through `context.Resolve()` -(`src/Compono.Generators/Templates/CompositionPlan.scriban`), and -dispatching that call to the matching plan is Milestone 2's provider -resolution pipeline, not built yet. - -So today's benchmark compares construction of a single flat, parameterless -type (`Leaf`) across two groups, kept deliberately separate so ecosystem -numbers can't be mistaken for the architecture's success criterion: - -- **`ArchitectureBenchmarks`** - answers "does generated construction - outperform a comparable reflection-based implementation?", the question - Milestone 1's architecture actually needs to prove: - - **Direct** - `new Leaf()`, the theoretical floor. - - **Generated** - `composer.Create()`, dispatching to a - source-generated `ICompositionPlan` via `PlanCache`. - - **Reflection** - `typeof(Leaf).GetConstructors().Single()` + - `ConstructorInfo.Invoke([])`, the direct alternative Compono's - generator replaces. -- **`EcosystemBenchmarks`** - answers a different question: "how does - Compono compare with AutoFixture, the established framework developers - reach for today?" AutoFixture does substantially more runtime work and - has different goals (randomized value generation, unexercised here since - `Leaf` has no properties to fill), so this is a recognizable reference - point users will ask about, not a target Compono is trying to "beat" - - Compono is not an AutoFixture replacement. - - **Generated** - same as above, repeated as this group's baseline. - - **AutoFixture** - `new Fixture().Create()`. - -This expanded once Milestone 2 made nested/primitive composition real - -see "Milestone 2 Phase 4: resolution-pipeline result" below for the -representative-graph comparison this section couldn't measure honestly -until then. - -## Baseline result - -Recorded at Milestone 1 Phase 4's completion, Apple M3 Max, .NET 10.0.3 -arm64 RyuJIT, Release configuration, `BenchmarkDotNet` `DefaultJob`: - -**Architecture benchmark** - -| Method | Mean | Allocated | -|------------|----------:|----------:| -| Direct | 2.309 ns | 24 B | -| Generated | 3.309 ns | 24 B | -| Reflection | 19.769 ns | 56 B | - -Generated construction ran **~6.0x faster** than the reflection baseline -and allocated the same as direct construction - within ~1 ns of the -theoretical floor. - -**Ecosystem comparison** - -| Method | Mean | Allocated | -|-------------|-------------:|----------:| -| Generated | 2.474 ns | 24 B | -| AutoFixture | 1,523.054 ns | 4,440 B | - -Generated construction ran **~616x faster** and allocated **~0.5%** as -much as AutoFixture. This gap is expected, not the point - AutoFixture is -doing real randomized-value-generation work that this flat, parameterless -type never exercises. Take it as a recognizable reference point, not -evidence the architecture benchmark above doesn't already establish on -its own. - -Numbers will shift as the composition engine grows past Milestone 1's -placeholder context - re-run and update this page rather than treating it -as a permanent result. - -## Milestone 2 Phase 4: resolution-pipeline result - -`ArchitectureBenchmarks`/`EcosystemBenchmarks` above only ever measured -generated *construction* dispatch versus reflection for a flat, -parameterless type - nothing exercised the real resolution pipeline -(provider dispatch, deterministic random forking, collection generation, -the diagnostics trace buffer) until Milestone 2 made it real. Three new -benchmark classes close that gap with the `Customer`/`Address` -representative graph from `docs/plans/0002-milestone-2-core-composition-engine.md`'s -Execution Flow section (a nested composable type, every Phase 2 built-in -kind via `string`, and a `List` collection member), run through -the real generator (`benchmarks/Compono.Benchmarks/ResolutionBenchmarkTypes.cs`), -mirroring `ArchitectureBenchmarks`/`EcosystemBenchmarks`' split so -ecosystem numbers stay separate from the architecture question: - -- **`ResolutionArchitectureBenchmarks`** - `Direct`/`Generated`/`Reflection`, - same shape as `ArchitectureBenchmarks` above. `Direct` stays the - theoretical floor (no fields to fill, same as `Leaf`), but `Reflection` - here does comparable real work to `Generated`: - `ReflectionComposer.ComposeRecursive()` fills every field with a - genuinely random value (an 8-character alphanumeric string, a - 3-element collection - Compono's own defaults), not a fixed - placeholder. The one remaining, deliberate asymmetry: `Reflection`'s - randomness is ordinary `Random.Shared`, not Compono's deterministic, - seed-forked `IRandomSource` - reproducibility is a Compono product - feature (see [Deterministic Seeding](deterministic-seeding.md)), not a - cost every random-value generator has to pay, so `Generated`'s cost - below includes work `Reflection`'s doesn't. -- **`ResolutionEcosystemBenchmarks`** - `Generated`/`AutoFixture`, same - shape as `EcosystemBenchmarks` above, except this time `Customer` - actually has fields for AutoFixture's real randomized-value-generation - work to fill (unlike `Leaf`). -- **`ResolutionBenchmarks`** - `Create`/`CreateMany` only, no external - baseline; the comparison here is intrinsic (`CreateMany`'s cost at - `count=10`/`count=100` against its own `count=1` baseline), the scaling - question `docs/plans/0002-...`'s Phase 4 benchmark task asks. This is - also the benchmark gate [ADR-0010](../../adr/0010-composition-request-pipeline-and-diagnostics-tracing.md) - reserved for the diagnostics trace buffer: confirm it's actually - allocation-free on the success path, and fall back to shallow - diagnostics by default if it measurably harms the hot path. - -Recorded at Milestone 2 Phase 4's completion (updated after a second PR #13 -review round - see below), Apple M3 Max, .NET 10.0.3 arm64 RyuJIT, Release -configuration, `BenchmarkDotNet` `DefaultJob`: - -**Resolution architecture benchmark** - -| Method | Mean | Allocated | -|------------|----------:|----------:| -| Direct | 14.17 ns | 160 B | -| Generated | 837.50 ns | 2,776 B | -| Reflection | 403.08 ns | 832 B | - -Generated resolution ran ~59.1x slower than `Direct`'s theoretical-floor -hardcoded construction and allocated ~17.3x as much - the real cost of -provider dispatch, random forking, collection generation, and diagnostics -tracing for this graph, not just constructor invocation. Against a -reflection baseline doing genuinely comparable work (real random values, -not placeholders), `Generated` ran ~2.1x slower and allocated ~3.3x as -much as `Reflection` - a real, honest gap. This is the number worth -discussing if the question is "why not just use reflection": Compono's -overhead over a comparable hand-rolled reflective composer is real, and -this table is where to look for it, not the `Direct` comparison above -(which was never a fair alternative to begin with - nobody ships -hardcoded test data). - -**Resolution ecosystem comparison** - -| Method | Mean | Allocated | -|-------------|-------------:|----------:| -| Generated | 832.50 ns | 2.71 KB | -| AutoFixture | 76,314.30 ns | 99.21 KB | - -Generated construction ran **~91.7x faster** and allocated **~2.7%** as -much as AutoFixture - this time with `Customer` actually giving -AutoFixture real randomized-value-generation work to do, unlike `Leaf`. - -**Resolution pipeline (`Create`/`CreateMany`)** - -| Method | Count | Mean | Allocated | Alloc Ratio | -|------------|------:|-------------:|-----------:|------------:| -| Create | 1 | 848.7 ns | 2.71 KB | 1.00 | -| CreateMany | 1 | 883.0 ns | 2.84 KB | 1.05 | -| Create | 10 | 853.6 ns | 2.71 KB | 1.00 | -| CreateMany | 10 | 8,898.3 ns | 27.59 KB | 10.18 | -| Create | 100 | 826.2 ns | 2.71 KB | 1.00 | -| CreateMany | 100 | 87,801.8 ns | 275.10 KB | 101.48 | - -`Create()` allocates ~2.71 KB per call regardless of -`CreateMany`'s batch size - a single root operation's cost, unaffected by -how many other independent items get composed around it in the same -process. `CreateMany(count)` scales linearly with `count` (10.18× -allocation at `count=10`, 101.48× at `count=100`, against the `count=1` -baseline) - no super-linear growth from the checkpoint/rewind trace -buffer's bookkeeping, `IRandomSource`'s per-item seed forking, or scope -allocation. - -### PR #13 review: what changed the numbers above - -A round of review found (and fixed) real issues, not just documentation -gaps: - -- **`CompositionRequest` converted from a class to a `readonly record - struct`.** It's never stored beyond the synchronous `ResolveCore` call - that builds it, so the heap allocation was pure waste. Measured directly - (constructed and consumed the same way `ResolveCore` does, passed by - `in`, not boxed): **40 B → 0 B**, **14.9 ns → 5.5 ns**. This is what - moved every table above from their original numbers (`Generated` was - 2,792 B/873.0 ns in an earlier version of this page) to the ones shown - now. -- **A generic root type rendered in raw CLR form in the diagnostic - heading and failure message** (`Unable to compose List\`1.` instead of - `List`) - fixed by reusing `CompositionPath.FriendlyTypeName` - at both call sites (`CompositionDiagnostic.ToString()`'s heading, and - the stage-9 failure message). -- **An ancestor's in-flight generated-plan/collection-plan dispatch was - silently absent from the trace** when a descendant failed - fixed by - recording a new `CompositionAttemptOutcome.Pending` immediately before - `Compose` runs, rewound away on success but surviving (correctly) when - a descendant's failure means the ancestor's own `Success` never gets - recorded either. -- **Stage 7's three built-in providers collapsed into one trace entry.** - `BuiltInProviders.Default` genuinely has three providers registered - (`PrimitiveValueProvider`, `EnumValueProvider`, `NullableValueProvider`) - — now each provider's own decline is recorded as it's tried, so a - failing request's trace shows the real number of attempts made, not a - collapsed count of 1. -- **`FriendlyTypeName` didn't handle arrays of generic types.** - `Type.IsGenericType` is `false` for an array type itself, so - `List[]` fell straight through to the raw CLR form - (`List\`1[]`). Fixed by checking `IsArray` first and recursing into - `GetElementType()`. - -**Isolating the trace buffer's own share** of `Create()`'s -2.71 KB - measured directly via `GC.GetAllocatedBytesForCurrentThread()` -around `new CompositionTraceBuffer()` in isolation, 1,000,000 iterations, -Release, .NET 10.0.3 arm64 (re-measured after bumping the buffer's initial -capacity from 16 to 32, and again after -[ADR-0016](../../adr/0016-provider-identity-restored-in-provider-attempt.md) -widened `ProviderAttempt` - see below): - -| What | Bytes/instance | -|-----------------------------------------------------------|---------------:| -| `CompositionTraceBuffer` alone (its `ProviderAttempt[32]`) | ~536 B | -| Bare `new CompositionContext()` (scope + active-frames + trace, no resolution) | ~780 B | - -Still real, not literally zero (ADR-0010's "near-zero-allocation on -success, not zero-cost," not a stronger claim than that) - and **not -bounded at that number for every graph**: see below. - -### Deep graph result - -`ResolutionBenchmarks`' `Customer`/`Address` graph is only 2 levels deep - -never enough to exercise `CompositionTraceBuffer`'s growth path, since -each active ancestor frame dispatching through stage 8/a collection plan -retains ~6 entries (5 declined stages + a `Pending` marker) until its own -child returns. `DeepGraphBenchmarks` composes an 8-level-deep chain of -single-field composable types (`DeepLevel1` through `DeepLevel8`) instead -- deep enough (~48 entries at its deepest point) to exceed the buffer's -32-entry initial capacity and trigger a real `Array.Resize`: - -| Method | Mean | Allocated | -|--------|---------:|----------:| -| Create | 846.1 ns | 3.37 KB | - -That's a genuinely deeper graph allocating *more* than the shallower, -wider `Customer` graph (2.71 KB) despite composing fewer total leaf values -(8 strings vs. `Customer`'s 7 strings + a 3-element list) - the resize is -a real, measurable contributor, not a theoretical one. The fix isn't to -pretend resizing can't happen (any growable array-backed collection -resizes eventually); it's to size the initial capacity for a typical -graph (bumped 16 → 32, covering ~5 levels of nesting without resizing - -see [The Provider Pipeline](provider-pipeline.md#diagnostics)'s own -4-level Diagnostics example) and be honest that deeper graphs still pay a -real, amortized `Array.Resize` cost, which this table now measures rather -than assumes away. - -**No fallback to shallow diagnostics was needed** - the -allocation-free-on-success trace buffer design shipped as scoped, with -the growth-path caveat above now documented rather than glossed over. - -### Provider identity restored: a real (accepted) allocation increase - -[ADR-0016](../../adr/0016-provider-identity-restored-in-provider-attempt.md) -reverses [ADR-0015](../../adr/0015-provider-identity-deferred-in-provider-attempt.md)'s -deferral: `ProviderAttempt` gained a `Type? Provider` field so a failing -request's trace can tell stage 7's three real built-in providers apart, -not just count that three attempts happened. Widening the struct -(`PipelineStage` + `Type?` + `CompositionAttemptOutcome`) roughly doubles -each trace entry's size, which is exactly what moved -`CompositionTraceBuffer`'s own isolated cost from ~280 B to ~536 B per -instance, and `Create()`'s total from 2.46 KB to 2.71 KB (+256 B, -~10%) - fully attributable to the wider struct, confirmed by the isolated -`CompositionTraceBuffer` measurement moving by almost exactly the same -256 B on its own. This is a real, accepted cost, not an oversight: three -indistinguishable `(BuiltInProvider, NotHandled)` trace entries were a -genuine diagnostic-quality gap, and paying ~10% more allocation on the -failure-adjacent bookkeeping path for a working "which provider" answer -is the tradeoff ADR-0016 accepts explicitly, not a regression to chase -back down. +benchmark-claims policy, the numbers below are published so a reader can +independently evaluate them — this page makes claims only about Compono +itself, never a comparative headline like "faster than AutoFixture." + +Every current table on this page — Consumer Scenarios, External +Comparison, Feature Overhead, Scalability, Source Generation — comes from +one full suite run (see Methodology below); none mixes results from +different runs. The one exception is explicitly marked: the Feature +Overhead section's `UseBogus()` discussion cites historical, pre-fix +figures alongside the current ones, because that history is the record +of a real engineering finding, not a live result. + +## What Compono optimizes for + +Compono is source-generated by default +([Design Principles](../design-principles.md), +[ADR-0001](../../adr/0001-source-generation-first.md)) so that composing +a type at runtime dispatches to generated code rather than reflecting +over it. That buys predictable, generated-path performance and minimal +allocations on the construction-dispatch path itself — but the full +resolution pipeline (provider dispatch, deterministic random forking, +collection generation, diagnostics tracing) is real, non-zero work on +top of that dispatch, and this page reports both honestly, per +[ADR-0034](../../adr/0034-benchmark-suite-strategy-and-redesign.md)'s +"engineering questions first" philosophy. + +## What the benchmarks measure (and don't) + +`BenchmarkDotNet` benchmarks answer narrow, comparative engineering +questions — "does approach A cost more than approach B, for one +operation, isolated from everything else" — under an artificially clean, +JIT-warmed, GC-isolated environment. They are **not** a substitute for +full-application performance testing, a scalability/load test, or a +guarantee about any specific consumer's real-world numbers. Every +benchmark in this suite ties to one of four goals, per +[ADR-0034](../../adr/0034-benchmark-suite-strategy-and-redesign.md)'s +Amendment: the runtime cost of a specific Compono feature, Compono's +scalability, Compono's build-time cost, or the migration experience from +AutoFixture — a benchmark that can't be tied to one of those doesn't +belong in the suite. Five categories, each answering a different +question for a different audience — full detail in [ADR-0034](../../adr/0034-benchmark-suite-strategy-and-redesign.md): + +- **Consumer Scenarios** — what performance should a user expect + composing each representative model in a realistic application? +- **External Comparison** — what should a developer expect migrating + from AutoFixture? +- **Feature Overhead** — how expensive is one specific mechanism + (a member rule, a type rule, a custom provider, `UseBogus()`, + `UseNSubstitute()`, `[Shared]`'s underlying row-sharing mechanism), + isolated from everything else? +- **Scalability** — does cost grow linearly or super-linearly as batch + size, graph depth, or collection size increases? +- **Source Generation** — clean vs. incremental generator cost, a + build-time concern entirely separate from runtime performance. + +## Methodology + +Recorded with `BenchmarkDotNet` v0.15.8, Apple M3 Max, macOS Tahoe 26.6, +.NET 10.0.3 arm64 RyuJIT, Release configuration, `DefaultJob`. Concretely, +`DefaultJob` means: a pilot stage that determines how many iterations a +run needs, a warmup phase, then a set of measured iterations — each +launched in its own isolated, managed process, not measured in-process +alongside the benchmark harness. Every table below reports Mean, Error, +and StdDev at minimum, plus Allocated wherever `[MemoryDiagnoser]` +reports it (every benchmark class in the suite); a Ratio column is +included wherever the category has a designated baseline. Full detail — +every category's complete result set, `Gen0`/`Gen1` collection counts, +raw CSV/HTML exports — is in `benchmarks/Compono.Benchmarks`'s +`BenchmarkDotNet.Artifacts/results/` after a real run (see Reproducing, +below); this page reproduces the results most relevant to a consumer, +not every number. + +## Consumer-facing results + +**Representative models** (`Composer.Create()`, no comparison +baseline — the absolute cost a consumer actually pays): + +| Model | Mean | Allocated | +|---|---:|---:| +| `SimplePoco` (flat, no dependencies) | 377.6 ns | 1.70 KB | +| `MediumAggregate` (nested dependency + collection) | 991.6 ns | 2.88 KB | +| `DeepGraph` (8-level chain) | 1,046.3 ns | 3.54 KB | +| `LargeCollection` (100-element collection) | 8,780.9 ns | 20.86 KB | + +**Migrating from AutoFixture.** "Equivalent work" means both frameworks +compose the same object graph shape, fill the same number of fields, and +(for Compono's side) use its own real default value-generation cost — an +8-character string, a 3-element collection — not a stripped-down or +otherwise favorable graph for either side. Every ratio below is +**AutoFixture relative to Compono** (Compono is always the baseline, +`1.00×`): + +| Model | Compono (baseline) | AutoFixture | AutoFixture vs. Compono | +|---|---:|---:|---:| +| `SimplePoco` | 374.8 ns / 1.70 KB | 23,122.4 ns / 29.95 KB | 61.7× slower, 17.6× more allocation | +| `MediumAggregate` | 980.9 ns / 2.88 KB | 74,677.7 ns / 99.21 KB | 76.1× slower, 34.4× more allocation | + +AutoFixture is doing substantially more runtime work here (reflection- +based construction plus its own randomized-value-generation pipeline) — +this is a recognizable reference point for what migrating changes, not a +target Compono is trying to "beat." + +**Provider-enabled profiles** (`Composer.Create()` with a package's +provider active): + +| Scenario | Mean | Allocated | +|---|---:|---:| +| `UseNSubstitute()` (composing an interface member) | 1.266 μs | 7.05 KB | +| `UseBogus()` (composing two convention-matching `string` members) | 5.481 μs | 7.04 KB | + +`UseBogus()`'s cost here reflects a fix applied after an earlier run of +this suite found it substantially higher — see Feature Overhead below +for the full account. The two numbers aren't measuring the same scope: +this row is the full profile (two Bogus-backed members plus the rest of +the `MediumAggregate` graph); Feature Overhead's `UseBogus()` row below +isolates a single member's marginal cost against its cheapest +alternative. Same underlying mechanism, different scope. + +## Feature overhead + +Isolates one mechanism's marginal cost at a time (full detail: [ADR-0034](../../adr/0034-benchmark-suite-strategy-and-redesign.md)): + +| Mechanism | Baseline | With feature | Ratio | +|---|---:|---:|---:| +| Member rule (vs. generated-only) | 985.4 ns | 1,305.3 ns | 1.33× | +| Type rule (vs. generated-only) | 985.4 ns | 968.7 ns | 0.98× (noise) | +| Custom `ICompositionValueProvider` (vs. generated-only) | 985.4 ns | 1,324.7 ns | 1.34× | +| `[Shared]`'s row-sharing mechanism (vs. no sharing) | 830.5 ns | 746.7 ns | 0.90× (sharing is cheaper — avoids composing a second independent value) | +| `UseNSubstitute()` (vs. a plain registration) | 325.7 ns | 1,194.1 ns | 3.67× | +| `UseBogus()` (vs. a plain member rule) | 342.0 ns | 2,156.5 ns | 6.31× | + +**`UseBogus()`: a finding, a root cause, a fix, a confirmation.** An +earlier run of this benchmark measured `UseBogus()` at ~865× a plain +member rule (291.5 μs, isolated single-member measurement) — reported in +full per +[ADR-0034](../../adr/0034-benchmark-suite-strategy-and-redesign.md)'s +publication rule, since an unfavorable result gets published exactly +like a favorable one. Investigation traced the cost to +`BogusMemberNameProvider` constructing a new `Bogus.Faker` instance on +every resolution (`src/Compono.Bogus/BogusMemberNameProvider.cs`) — +`Faker` construction, which builds out its full set of category +generators, is genuinely expensive. The implementation was changed to +cache one `Faker` per thread instead of constructing one per request, +reseeding its `Random` immediately before every use — see +[ADR-0027's Amendment](../../adr/0027-compono-bogus-package-design.md#amendment-2026-08-05-bogusmembernameprovider-reuses-a-per-thread-faker-not-a-fresh-one-per-request) +for why per-thread reuse doesn't reintroduce the concurrent-access hazard +a shared instance would, and what regression coverage backs it. The +benchmark above, rerun after the fix, confirms the result: ~865× +dropped to ~6.31× (this table), and the full-profile Consumer Scenario +cost dropped from 903.4 μs / 2,229.31 KB to 5.481 μs / 7.04 KB (Consumer- +facing results, above). + +## Scalability + +**Batch scaling** (`CreateMany(count)` against its own `Create()` +baseline): + +| Count | `CreateMany` Mean | Ratio vs. `Create` | +|---:|---:|---:| +| 1 | 1,045.3 ns | 1.06× | +| 10 | 10,314.6 ns | 10.34× | +| 100 | 102,902.9 ns | 101.96× | +| 1,000 | 1,180,622.5 ns | 1,184.57× | + +Scaling is linear through 100 items; at 1,000 items the ratio (1,184.57× +against a 1,000× input-size increase) shows a modest, real super-linear +component — `Gen1` collections start appearing at this scale (0 at +`count=10`, ~180 at `count=1,000`) where they don't at smaller batches, +consistent with GC promotion pressure rather than an algorithmic +regression in the composition pipeline itself. + +**Graph depth** (shallow `MediumAggregate` vs. deep, 8-level `DeepGraph`): +1.008 μs vs. 1.040 μs (1.03×) — a real but modest cost from the deeper +chain's diagnostics-trace-buffer growth (see +[The Provider Pipeline](provider-pipeline.md#diagnostics)), not a +concern at this depth. + +**Collection size** (`WithCollectionSize(n)`, 3 to 200 elements): +517.4 ns at 3 elements, 17,143.4 ns at 200 — sub-linear relative to the +66.7× size increase (a ~33.1× time increase), since a fixed per-`Create` +dispatch cost is amortized across more elements at larger sizes. + +## Source generation + +Clean vs. incremental generator cost, in-process via Roslyn's +`GeneratorDriver` (a maintainer-facing, build-time concern, unrelated to +every result above): + +| TypeCount | Clean Mean | Incremental Mean | Ratio | +|---:|---:|---:|---:| +| 1 | 55.92 μs | 25.48 μs | 0.46× | +| 10 | 230.35 μs | 83.44 μs | 0.36× | +| 50 | 1,065.10 μs | 350.55 μs | 0.33× | + +Incremental generation is consistently faster and allocates +substantially less (0.09×–0.25× of clean generation's allocation) across +every type count measured — confirming the generator's incremental +caching genuinely avoids re-processing unrelated syntax on a small, +unrelated source edit, rather than silently falling back to a full +recompute. ## Reproducing @@ -298,21 +212,10 @@ back down. dotnet run -c Release --project benchmarks/Compono.Benchmarks -f net10.0 ``` -`-c Release` is required - `BenchmarkDotNet` refuses to run a Debug build. -Add `-- --filter "*Resolution*" "*DeepGraph*"` to run only the Milestone 2 -resolution-pipeline benchmarks (the full suite, including -`ArchitectureBenchmarks`/`EcosystemBenchmarks`, takes several minutes). - -## Open questions - -**`CompositionTraceBuffer`'s own array allocation, per root operation** is -a genuine, unconditional allocation on every `Create()`/`CreateMany()` -item, not literally zero — consistent with ADR-0010's explicit -"near-zero-allocation on success, not zero-cost" framing, but real enough -that this page states the precise figure instead of an unqualified -"allocation-free" claim. A true zero-allocation design would still need -the buffer pooled/reused across root operations -(`CompositionContext` isn't currently pooled or reset-and-reused at all) -— a real architecture change, not a same-PR-sized fix. Deferred: revisit -if a future benchmark shows this mattering at a scale `docs/mvp.md`'s -scope actually exercises — no design has been chosen yet. +`-c Release` is required — `BenchmarkDotNet` refuses to run a Debug +build. Add `-- --filter "*ClassName*"` to run one category at a time (the +full suite, across every category's parameter matrix, takes on the order +of 15 minutes). Full per-category results (every method, every parameter +value, `Gen0`/`Gen1`/`Gen2`, raw CSV/HTML) are written to +`BenchmarkDotNet.Artifacts/results/` relative to the working directory +`dotnet run` was invoked from. diff --git a/docs/best-practices/performance-recommendations.md b/docs/best-practices/performance-recommendations.md index 3dc739f..b531b53 100644 --- a/docs/best-practices/performance-recommendations.md +++ b/docs/best-practices/performance-recommendations.md @@ -1,7 +1,7 @@ # Performance Recommendations Practical guidance — what to actually do. For the methodology and measured -numbers behind these recommendations, see [Performance](../performance.md). +numbers behind these recommendations, see [Performance](../architecture/current/performance.md). ## Let source generation do its job @@ -48,6 +48,6 @@ applies to your own suite. ## Next - The measured numbers and methodology behind "generated construction - avoids reflection overhead" → [Performance](../performance.md). + avoids reflection overhead" → [Performance](../architecture/current/performance.md). - Collection composition in depth → [Collections](../concepts/collections.md). - Applies at scale → [Large Test Suites](large-test-suites.md). diff --git a/docs/plans/0008-milestone-8-public-preview.md b/docs/plans/0008-milestone-8-public-preview.md index 5c87f1c..6ddf2ce 100644 --- a/docs/plans/0008-milestone-8-public-preview.md +++ b/docs/plans/0008-milestone-8-public-preview.md @@ -446,7 +446,7 @@ packages). ## Phase 5: Architecture consolidation, legacy retirement, and benchmark suite redesign -**Status:** In Progress +**Status:** Done Executes ADR-0030 Amendment 2's "one canonical home" principle for the architecture/roadmap documentation, and — added after this phase's @@ -502,74 +502,163 @@ design and implement the new suite first, produce real results from it, *then* write the page — this phase does not narrate a redesign still in progress. -- [ ] Design the benchmark strategy from first principles: the question +- [x] Design the benchmark strategy from first principles: the question set, audiences, and category taxonomy — done as [ADR-0034](../adr/0034-benchmark-suite-strategy-and-redesign.md) - (`Accepted`). No further design task here; this line just records - that the ADR is this sub-phase's starting point, not a step still - to do. -- [ ] Define and implement the reused `Models/` set (`SimplePoco`, - `MediumAggregate`, `DeepGraph`, `LargeCollection`, - `SharedValueGraph`, `ProviderBackedModel`) and `Baselines/` - (`HandwrittenComposer`, `CachedReflectionComposer`, - `UncachedReflectionComposer`, `AutoFixtureComposer`) — replacing + (`Accepted`). +- [x] Define and implement the reused `Models/` set (`SimplePoco`, + `MediumAggregate`/`Address`, `DeepGraph` (`DeepLevel2`-`8`), + `LargeCollection`, `SharedValueGraph` (`SharedContext`/ + `ConsumerOne`/`ConsumerTwo`), `ProviderBackedModel` (`IClock`/ + `FixedClock`)) and `Baselines/` (`AutoFixtureComposer`) — replaced `BenchmarkTypes.cs`, `ResolutionBenchmarkTypes.cs`, `ReflectionComposer.cs`, `AutoFixtureComposer.cs`. -- [ ] Implement `ImplementationStrategies/` (construction-technique - benchmarks: handwritten construction as the theoretical ceiling, - generated composition, cached reflection, uncached reflection — - in that order, parameterized across the model set) — replaces - `ArchitectureBenchmarks.cs` and `ResolutionArchitectureBenchmarks.cs`. -- [ ] Implement `ConsumerScenarios/` (simple POCO, medium aggregate, deep - graph, large collection, shared value, Bogus-enabled, - NSubstitute-enabled) — new; no prior equivalent existed. -- [ ] Implement `ExternalComparison/` (AutoFixture, equivalent work - across the same model set, both-directions honest reporting) — - replaces `EcosystemBenchmarks.cs` and - `ResolutionEcosystemBenchmarks.cs`. -- [ ] Implement `FeatureOverhead/` (additive layering: generated only → - + shared values → + member rules → + type rules → + providers → - + `UseBogus()` → + `UseNSubstitute()`) — new; no prior equivalent - existed. -- [ ] Implement `Scalability/` (`CreateMany` at 1/10/100/1000, shallow - vs. deep graphs, growing collection sizes) — replaces - `ResolutionBenchmarks.cs` and `DeepGraphBenchmarks.cs`'s - construction, generalizing the latter's one-off trace-buffer-resize - question into a real shallow-vs-deep comparison. -- [ ] Implement `SourceGeneration/` (in-process `GeneratorDriver` - benchmarks: clean vs. incremental generation, across a - composable-type-count matrix) — new; no prior equivalent existed. - Requires `Compono.Generators` as an ordinary (non-analyzer) - `ProjectReference` in addition to its existing analyzer reference, - so benchmark code can drive it directly. -- [ ] Add `Compono.NSubstitute`/`Compono.Bogus` `ProjectReference`s to +- [x] ~~Implement `ImplementationStrategies/`~~ — implemented + (`SimplePocoConstructionBenchmarks.cs`/ + `MediumAggregateConstructionBenchmarks.cs`, handwritten-as-ceiling/ + generated/cached-reflection/uncached-reflection), then **removed** + per [ADR-0034's Amendment](../adr/0034-benchmark-suite-strategy-and-redesign.md#amendment-2026-08-05-implementation-strategies-removed--it-compares-different-systems-not-one-variable): + the category compared different systems doing different amounts of + work (bare construction vs. a full resolution pipeline), not one + isolated variable, so a result from it couldn't be attributed to a + specific cause or guide an optimization decision. Its `Baselines/` + (`HandwrittenComposer`, `CachedReflectionComposer`, + `UncachedReflectionComposer`) were deleted alongside it — no other + category referenced them. `ArchitectureBenchmarks.cs`/ + `ResolutionArchitectureBenchmarks.cs` (the old files this category + was meant to replace) are still correctly deleted below; their + question just doesn't get a new home. +- [x] Implement `ConsumerScenarios/` — `RepresentativeModelBenchmarks.cs` + (simple POCO, medium aggregate, deep graph, large collection), + `SharedValueBenchmarks.cs` (`CreateRow`/`ResolveShared`), + `ProviderEnabledBenchmarks.cs` (Bogus-enabled, NSubstitute-enabled) + — new; no prior equivalent existed. +- [x] Implement `ExternalComparison/` — split into + `SimplePocoComparisonBenchmarks.cs`/ + `MediumAggregateComparisonBenchmarks.cs` (one file per model, so + each class's `[Benchmark(Baseline = true)]` ratio stays meaningful + — a single class covering both models would compute every ratio + against one model's baseline) — replaces `EcosystemBenchmarks.cs` + and `ResolutionEcosystemBenchmarks.cs`. +- [x] Implement `FeatureOverhead/` — diverged from the plan's literal + additive chain (a single `Composer` can't coherently stack a + `CreateRow`-based sharing step onto a `Create()`-based rule + step): `ConfigurationOverheadBenchmarks.cs` (generated-only vs. + +member-rule vs. +type-rule vs. +custom-provider, all on + `MediumAggregate`), `SharingOverheadBenchmarks.cs` (row without + sharing vs. with sharing, both via `CreateRow`), + `BogusOverheadBenchmarks.cs`/`NSubstituteOverheadBenchmarks.cs` + (pairwise: the cheapest alternative mechanism vs. the package + provider, isolating exactly one member's resolution) — new; no + prior equivalent existed. +- [x] Implement `Scalability/` — `BatchScalingBenchmarks.cs` + (`CreateMany` at 1/10/100/1000), `GraphDepthScalingBenchmarks.cs` + (shallow `MediumAggregate` vs. deep `DeepGraph`), + `CollectionSizeScalingBenchmarks.cs` (3/10/50/200-element + collections) — replaces `ResolutionBenchmarks.cs` and + `DeepGraphBenchmarks.cs`'s construction, generalizing the latter's + one-off trace-buffer-resize question into a real shallow-vs-deep + comparison. +- [x] Implement `SourceGeneration/` — `GeneratorDriverBenchmarks.cs` + (in-process `GeneratorDriver`, clean vs. incremental generation + across a 1/10/50 composable-type-count matrix) — new; no prior + equivalent existed. The incremental compilation is derived from + the clean one via `Compilation.ReplaceSyntaxTree` (not built as an + independently-parsed `CSharpCompilation`), since Roslyn's + incremental-generator cache is keyed on compilation/tree identity + — two separately-constructed compilations look entirely unrelated + to the driver even with near-identical source text, which would + silently defeat the whole point of the incremental-vs-clean + comparison. `Compono.Generators` changed from analyzer-only + (`ReferenceOutputAssembly="false"`) to *also* a normal compile + reference on the same `ProjectReference` item (not a second, + separate reference to the same project), plus + `InternalsVisibleTo="Compono.Benchmarks"` added to + `Compono.Generators.csproj` so this class can construct + `ComponoIncrementalGenerator` directly, matching + `Compono.Generators.Tests`' own pattern. +- [x] Add `Compono.NSubstitute`/`Compono.Bogus` `ProjectReference`s to `Compono.Benchmarks.csproj` (needed by `ConsumerScenarios/` and `FeatureOverhead/`'s provider-enabled cases; not needed previously - since no prior benchmark exercised either package). -- [ ] Delete `ArchitectureBenchmarks.cs`, `EcosystemBenchmarks.cs`, + since no prior benchmark exercised either package). Also added + `Basic.Reference.Assemblies.Net100`/`Net110` (conditional on + `$(TargetFramework)`), needed to build a real reference-assembly + set for `SourceGeneration/`'s in-process compilations, matching + `Compono.Generators.Tests`' own pattern. +- [x] Delete `ArchitectureBenchmarks.cs`, `EcosystemBenchmarks.cs`, `ResolutionArchitectureBenchmarks.cs`, `ResolutionEcosystemBenchmarks.cs`, `ResolutionBenchmarks.cs`, `DeepGraphBenchmarks.cs`, `BenchmarkTypes.cs`, `ResolutionBenchmarkTypes.cs` once every question they answered has a home in the categories above — no benchmark class straddles the old and new structure once this task is done. -- [ ] Run the full redesigned suite (`dotnet run -c Release --project - benchmarks/Compono.Benchmarks -f net10.0`), Release, and record - real results — replacing every number currently in - `architecture/current/performance.md`, not narrating the old - suite's history. -- [ ] Rewrite `architecture/current/performance.md` as capability- +- [x] Build and smoke-test the redesigned suite: `dotnet build + Compono.slnx -c Release` (0 warnings, 0 errors) and `dotnet run -c + Release --project benchmarks/Compono.Benchmarks -f net10.0 -- + --job Dry --filter '*'` (all 49 benchmarks executed without + throwing) — confirms the suite is structurally correct. This is + not the statistically real run the next task produces; `--job Dry` + is one cold iteration per benchmark, fast enough to smoke-test but + not meant to be reported as a real result. +- [x] Run the full redesigned suite (`dotnet run -c Release --project + benchmarks/Compono.Benchmarks -f net10.0 -- --filter '*'`), + `DefaultJob` (not `--job Dry`), and record real results — all 15 + classes produced real Mean/Error/StdDev/Allocated/`Gen0`/`Gen1` + results (Apple M3 Max, macOS Tahoe 26.6, .NET 10.0.3 arm64 RyuJIT, + `BenchmarkDotNet` v0.15.8; total run ~15 minutes). One real, + published finding the run surfaced: `UseBogus()` costs ~865x a + plain member rule (291.5 μs vs. 337 ns, isolated) because + `BogusMemberNameProvider` constructs a new `Bogus.Faker` per + resolution — root-caused, not investigated further or fixed here, + per ADR-0034's "publish an unfavorable result, don't hide it" rule. +- [x] Rewrite `architecture/current/performance.md` as capability- oriented public documentation per ADR-0034's public-documentation - direction: what Compono optimizes for, what the benchmarks measure, - methodology, representative Consumer Scenario/External Comparison - results, memory/allocation characteristics, scaling behavior, - reproducibility, and a link to the full BenchmarkDotNet artifacts — - not a historical narrative of Milestone 1/2/PR-review optimization - work. Implementation Strategies/Feature Overhead/Source Generation - results stay primarily engineering documentation (linked, not - necessarily reproduced in full on this page) per ADR-0034's "mostly - engineering documentation rather than front-page marketing" - direction for implementation-strategy benchmarks. + direction: what Compono optimizes for, what the benchmarks measure + (and don't), methodology, representative Consumer Scenario/External + Comparison results, the `UseBogus()` finding above, scaling + behavior, reproducibility, and how to get the full per-category + artifacts — not a historical narrative of Milestone 1/2/PR-review + optimization work. Implementation Strategies/Feature Overhead/ + Source Generation results are summarized (not every parameter + value reproduced) per ADR-0034's "mostly engineering documentation + rather than front-page marketing" direction for implementation- + strategy benchmarks. Also fixed + `best-practices/performance-recommendations.md`'s two links, which + pointed at the now-tombstoned `docs/performance.md` instead of this + page. +- [x] Removed the Implementation Strategies category entirely, per + [ADR-0034's Amendment](../adr/0034-benchmark-suite-strategy-and-redesign.md#amendment-2026-08-05-implementation-strategies-removed--it-compares-different-systems-not-one-variable) — + deleted `ImplementationStrategies/` and its dedicated `Baselines/` + classes (`HandwrittenComposer`, `CachedReflectionComposer`, + `UncachedReflectionComposer`), removed the "Implementation + strategies" section from `architecture/current/performance.md`, + and updated its category-count/overview text (six categories → five). + The five remaining categories, the reused model set, and every + fair-comparison/reporting rule not specific to Implementation + Strategies are unchanged. +- [x] Fixed the `UseBogus()` finding above, per + [ADR-0027's Amendment](../adr/0027-compono-bogus-package-design.md#amendment-2026-08-05-bogusmembernameprovider-reuses-a-per-thread-faker-not-a-fresh-one-per-request): + `BogusMemberNameProvider` now caches one `Faker` per thread + (`ThreadLocal`, `trackAllValues: false`) instead of + constructing one per request, reseeding `Random` immediately before + every use — safe under concurrent access because a thread-local + instance is never touched by more than one thread, distinct from + the shared-`Faker` alternative ADR-0027 already considered and + rejected. New regression coverage: a concurrency test (200 + concurrent resolutions on one shared provider instance, each + checked against an independent single-threaded reference value) + and a "convention throws mid-generate" test. Re-ran the full suite + afterward (`--filter '*'`, `DefaultJob`, same environment) and + rewrote `architecture/current/performance.md` from that single + fresh run — `UseBogus()`'s isolated overhead dropped from ~865× to + ~6.31×, and the full-profile Consumer Scenario cost from + 903.4 μs / 2,229.31 KB to 5.481 μs / 7.04 KB. The page also picked + up a clarity pass: explicit AutoFixture-relative-to-Compono ratio + direction, precise `DefaultJob` methodology wording (pilot/warmup/ + measured iterations, not "statistically significant"), an explicit + "equivalent work" definition, a note connecting the Consumer + Scenario and Feature Overhead `UseBogus()` numbers' different + scope, and consistent `×` formatting throughout. ## Phase 6: Contributor and repository readiness @@ -920,12 +1009,21 @@ individually resolved, not left ambiguous): - `docs/adr/0034-benchmark-suite-strategy-and-redesign.md` — new (Phase 5 Part B): the benchmark-suite redesign decision. - `benchmarks/Compono.Benchmarks/` — fully restructured (Phase 5 Part - B) into `Models/`, `Baselines/`, `ImplementationStrategies/`, - `ConsumerScenarios/`, `ExternalComparison/`, `FeatureOverhead/`, - `Scalability/`, `SourceGeneration/`, replacing all 8 existing - benchmark files per ADR-0034. `Compono.Benchmarks.csproj` gains - `ProjectReference`s to `Compono.NSubstitute`/`Compono.Bogus` and a - non-analyzer `ProjectReference` to `Compono.Generators`. + B, done) into `Models/`, `Baselines/`, `ConsumerScenarios/`, + `ExternalComparison/`, `FeatureOverhead/`, `Scalability/`, + `SourceGeneration/` (five categories — `ImplementationStrategies/` was + implemented, then removed per + [ADR-0034's Amendment](../adr/0034-benchmark-suite-strategy-and-redesign.md#amendment-2026-08-05-implementation-strategies-removed--it-compares-different-systems-not-one-variable)), + replacing all 8 existing benchmark files per ADR-0034. + `Compono.Benchmarks.csproj` gains + `ProjectReference`s to `Compono.NSubstitute`/`Compono.Bogus`, its + existing `Compono.Generators` reference is now also a normal compile + reference (not analyzer-only), and it gains conditional + `Basic.Reference.Assemblies.Net100`/`Net110` package references. +- `src/Compono.Generators/Compono.Generators.csproj` — gains + `InternalsVisibleTo="Compono.Benchmarks"` (Phase 5 Part B, done), so + `SourceGeneration/GeneratorDriverBenchmarks.cs` can construct + `ComponoIncrementalGenerator` directly. - `docs/documentation-architecture.md` — Open Items section already updated to reflect all six resolutions as part of this design pass; further updated in place as content lands and stub statuses flip to diff --git a/src/Compono.Bogus/BogusMemberNameProvider.cs b/src/Compono.Bogus/BogusMemberNameProvider.cs index 1cf873e..4c35084 100644 --- a/src/Compono.Bogus/BogusMemberNameProvider.cs +++ b/src/Compono.Bogus/BogusMemberNameProvider.cs @@ -12,8 +12,6 @@ namespace Compono; /// public sealed class BogusMemberNameProvider : ICompositionValueProvider { - private readonly string _locale; - // Immutable, built once - a FrozenDictionary, not a plain Dictionary, since this is a fixed, // read-only lookup for the lifetime of this provider instance. Exact match, case-sensitive, // against CompositionProviderRequest.Name only - no substring/prefix/fuzzy matching, and no @@ -21,6 +19,14 @@ public sealed class BogusMemberNameProvider : ICompositionValueProvider // callout) is deliberately absent from the built-in allowlist this always contains at minimum. private readonly FrozenDictionary> _conventions; + // One Faker per thread, reused across every request that thread handles, reseeded immediately + // before each use - never a request-lifetime instance (constructing Faker's full set of + // category generators is genuinely expensive, per ADR-0034's benchmark suite), and never a + // single instance shared across threads (the exact hazard ADR-0027's Amendment distinguishes + // this from - see that Amendment for why per-thread reuse doesn't reintroduce the + // shared-mutable-state race a package-lifetime-shared Faker would). + private readonly ThreadLocal _threadFaker; + /// Creates a using . /// The Bogus locale each handled request's own uses. public BogusMemberNameProvider(string locale) @@ -37,11 +43,13 @@ public BogusMemberNameProvider(string locale) // BogusConventions.ByName and only ever adds to it via the validated AddAlias/AddConvention path. internal BogusMemberNameProvider(string locale, IReadOnlyDictionary> conventions) { - ArgumentNullException.ThrowIfNull(locale); + ArgumentException.ThrowIfNullOrWhiteSpace(locale); ArgumentNullException.ThrowIfNull(conventions); - _locale = locale; _conventions = conventions as FrozenDictionary> ?? conventions.ToFrozenDictionary(); + // trackAllValues: false - nothing here ever enumerates ThreadLocal.Values across + // threads, so there's no reason to pay for that bookkeeping. + _threadFaker = new ThreadLocal(() => new Faker(locale), trackAllValues: false); } /// @@ -58,11 +66,13 @@ public CompositionProviderResult TryProvide(in CompositionProviderRequest reques if (!_conventions.TryGetValue(name, out var generate)) return CompositionProviderResult.NotHandled; - // A fresh Faker/Randomizer per handled request, seeded from context.DeriveSeed() - never a - // package-lifetime-shared instance. This is what keeps every produced value both - // deterministic (same seed, same request path -> same value) and safe under concurrent - // composition (no instance is ever touched from more than one request). - var faker = new Faker(_locale) { Random = new Randomizer(context.DeriveSeed()) }; + // This thread's own Faker, reseeded from context.DeriveSeed() before every use - see + // _threadFaker's declaration comment and ADR-0027's Amendment for why reuse is safe here. + // ThreadLocal.Value is annotated T? in the BCL, but _threadFaker was constructed with a + // valueFactory (never the parameterless constructor), so every access - first or + // subsequent, on any thread - is guaranteed non-null by ThreadLocal's own contract. + var faker = _threadFaker.Value!; + faker.Random = new Randomizer(context.DeriveSeed()); return CompositionProviderResult.Handled(generate(faker)); } } diff --git a/src/Compono.Bogus/BogusOptions.cs b/src/Compono.Bogus/BogusOptions.cs index 28dde44..4d3b7c4 100644 --- a/src/Compono.Bogus/BogusOptions.cs +++ b/src/Compono.Bogus/BogusOptions.cs @@ -62,14 +62,21 @@ public void AddAlias(string aliasName, BogusConvention target) /// /// Adds a custom exact-name convention: a member named exactly - /// resolves to 's result, called against a request-local, + /// resolves to 's result, called against a /// context.DeriveSeed()-seeded - the same determinism contract every /// other value in this package follows. Validated and applied eagerly, immediately, against this /// instance's own accumulated state - not deferred to /// UseBogus(...) returning, and not detected across separate UseBogus(...) calls. /// /// The exact member name to match. - /// Produces this member's value from a seeded . + /// + /// Produces this member's value from a seeded . The + /// instance is reused across later, unrelated requests on the same thread (per + /// 's own performance design) - always freshly reseeded + /// before is called, so read its randomness normally, but never + /// retain the instance past this call or rely on any state it carries beyond + /// Faker.Random. + /// /// /// or is . /// diff --git a/src/Compono.Generators/Compono.Generators.csproj b/src/Compono.Generators/Compono.Generators.csproj index 148cae3..7ac22e2 100644 --- a/src/Compono.Generators/Compono.Generators.csproj +++ b/src/Compono.Generators/Compono.Generators.csproj @@ -45,6 +45,9 @@ + + diff --git a/test/Compono.Bogus.Tests/BogusMemberNameProviderTests.cs b/test/Compono.Bogus.Tests/BogusMemberNameProviderTests.cs index 34411a0..cc766ae 100644 --- a/test/Compono.Bogus.Tests/BogusMemberNameProviderTests.cs +++ b/test/Compono.Bogus.Tests/BogusMemberNameProviderTests.cs @@ -1,3 +1,5 @@ +using Bogus; + namespace Compono.Bogus.Tests; /// @@ -78,6 +80,45 @@ public void UnlistedName_Declines() withProvider.Should().Be(withoutProvider); } + [Fact] + public void ConventionThrows_DoesNotCorruptASubsequentRequestOnTheSameThread() + { + // A custom convention that throws, injected via the internal (locale, conventions) + // overload - BogusMemberNameProvider now reuses one Faker per thread across requests + // (ADR-0027's Amendment), so this is new to worry about: does an exception mid-generate + // leave that shared Faker in a state that corrupts the *next* request on the same + // thread/provider instance? faker.Random is reset unconditionally before every generate(...) + // call, so it shouldn't - this proves that, rather than assuming it. + var conventions = new Dictionary> + { + ["Broken"] = _ => throw new InvalidOperationException("Custom convention failure."), + ["Email"] = f => f.Internet.Email(), + }; + var provider = new BogusMemberNameProvider("en", conventions); + + var brokenDescriptor = new CompositionRequestDescriptor( + CompositionRequestKind.ConstructorParameter, ordinal: 0, "Broken", declaringType: null, Nullability.NotNullable); + var emailDescriptor = new CompositionRequestDescriptor( + CompositionRequestKind.ConstructorParameter, ordinal: 1, "Email", declaringType: null, Nullability.NotNullable); + + var act = () => Composer.Create(builder => builder.WithSeed(4219).AddSemanticProvider(provider)) + .CreateRow(typeof(BogusMemberNameProviderTests)) + .Resolve(brokenDescriptor); + act.Should().Throw(); + + // Same provider instance (so the same thread's cached Faker) resolves a later, unrelated + // request after the throw - must match an independently-computed reference value exactly, + // not throw again or silently return a corrupted/reused draw. + var afterThrow = Composer.Create(builder => builder.WithSeed(4219).AddSemanticProvider(provider)) + .CreateRow(typeof(BogusMemberNameProviderTests)) + .Resolve(emailDescriptor); + var expected = Composer.Create(builder => builder.WithSeed(4219).AddSemanticProvider(new BogusMemberNameProvider("en", conventions))) + .CreateRow(typeof(BogusMemberNameProviderTests)) + .Resolve(emailDescriptor); + + afterThrow.Should().Be(expected); + } + private static TValue Resolve(in CompositionRequestDescriptor descriptor, bool addProvider) { var composer = Composer.Create(builder => diff --git a/test/Compono.Bogus.Tests/DeterminismTests.cs b/test/Compono.Bogus.Tests/DeterminismTests.cs index 5a47363..94dfca4 100644 --- a/test/Compono.Bogus.Tests/DeterminismTests.cs +++ b/test/Compono.Bogus.Tests/DeterminismTests.cs @@ -1,3 +1,5 @@ +using System.Collections.Concurrent; + namespace Compono.Bogus.Tests; /// @@ -87,6 +89,43 @@ public void CreateMany_ProducesIndependentlySeededItems_ForAUseBogusOfTRegistere five.Take(3).Should().Equal(three); } + [Fact] + public async Task TryProvide_ProducesCorrectValues_WhenCalledConcurrently_OnOneSharedProviderInstance() + { + // BogusMemberNameProvider caches one Faker per thread (ADR-0027's Amendment) instead of + // constructing one per request - this proves that reuse is actually safe under real + // concurrent access, not just assumed safe because no test caught a problem. One provider + // instance, shared across every concurrent composition below (the scenario ADR-0027's + // original "package-lifetime-shared instance" concern is about), driven by + // Parallel.ForEachAsync so calls genuinely overlap across the thread pool rather than + // running sequentially one at a time. + var provider = new BogusMemberNameProvider("en"); + var results = new ConcurrentBag<(int Seed, string Value)>(); + + await Parallel.ForEachAsync(Enumerable.Range(0, 200), async (seed, _) => + { + var value = Composer.Create(builder => builder.WithSeed(seed).AddSemanticProvider(provider)) + .CreateRow(typeof(DeterminismTests)) + .Resolve(EmailDescriptor); + + results.Add((seed, value)); + await Task.CompletedTask; + }); + + // Each seed's concurrently-produced value must match what a fresh, independent, + // single-threaded resolution for that same seed produces - proving no cross-call state + // leaked between threads sharing the reused Faker, not just that nothing threw. + results.Should().HaveCount(200); + foreach (var (seed, value) in results) + { + var expected = Composer.Create(builder => builder.WithSeed(seed).AddSemanticProvider(new BogusMemberNameProvider("en"))) + .CreateRow(typeof(DeterminismTests)) + .Resolve(EmailDescriptor); + + value.Should().Be(expected); + } + } + private static CompositionRequestDescriptor EmailDescriptor => new(CompositionRequestKind.ConstructorParameter, ordinal: 0, "Email", declaringType: null, Nullability.NotNullable); From 359594e99f4526e06d3d06391e82414944ffc788 Mon Sep 17 00:00:00 2001 From: Nick Cipollina Date: Wed, 5 Aug 2026 23:09:07 -0400 Subject: [PATCH 3/5] docs: regenerate API reference for BogusOptions.AddConvention's updated doc comment The reused-Faker contract added to AddConvention's XML doc comment changed the generated API reference; the drift-detection CI gate caught it. Regenerated via .github/scripts/generate-api-reference.sh. Co-Authored-By: Claude Sonnet 5 --- ...Convention(string,System.Func_Bogus.Faker,string_).md | 9 +++++++-- docs/reference/api/Compono.Bogus/Compono.BogusOptions.md | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/docs/reference/api/Compono.Bogus/Compono.BogusOptions.AddConvention(string,System.Func_Bogus.Faker,string_).md b/docs/reference/api/Compono.Bogus/Compono.BogusOptions.AddConvention(string,System.Func_Bogus.Faker,string_).md index 6572950..01e4725 100644 --- a/docs/reference/api/Compono.Bogus/Compono.BogusOptions.AddConvention(string,System.Func_Bogus.Faker,string_).md +++ b/docs/reference/api/Compono.Bogus/Compono.BogusOptions.AddConvention(string,System.Func_Bogus.Faker,string_).md @@ -4,7 +4,7 @@ ## BogusOptions\.AddConvention\(string, Func\\) Method Adds a custom exact\-name convention: a member named exactly [memberName](Compono.BogusOptions.AddConvention(string,System.Func_Bogus.Faker,string_).md#Compono.BogusOptions.AddConvention(string,System.Func_Bogus.Faker,string_).memberName 'Compono\.BogusOptions\.AddConvention\(string, System\.Func\\)\.memberName') -resolves to [generate](Compono.BogusOptions.AddConvention(string,System.Func_Bogus.Faker,string_).md#Compono.BogusOptions.AddConvention(string,System.Func_Bogus.Faker,string_).generate 'Compono\.BogusOptions\.AddConvention\(string, System\.Func\\)\.generate')'s result, called against a request\-local, +resolves to [generate](Compono.BogusOptions.AddConvention(string,System.Func_Bogus.Faker,string_).md#Compono.BogusOptions.AddConvention(string,System.Func_Bogus.Faker,string_).generate 'Compono\.BogusOptions\.AddConvention\(string, System\.Func\\)\.generate')'s result, called against a `context.DeriveSeed()`\-seeded `Bogus.Faker` \- the same determinism contract every other value in this package follows\. Validated and applied eagerly, immediately, against this [BogusOptions](Compono.BogusOptions.md 'Compono\.BogusOptions') instance's own accumulated state \- not deferred to @@ -25,7 +25,12 @@ The exact member name to match\. `generate` [System\.Func<](https://learn.microsoft.com/en-us/dotnet/api/system.func-2 'System\.Func\`2')`Bogus.Faker`[,](https://learn.microsoft.com/en-us/dotnet/api/system.func-2 'System\.Func\`2')[System\.String](https://learn.microsoft.com/en-us/dotnet/api/system.string 'System\.String')[>](https://learn.microsoft.com/en-us/dotnet/api/system.func-2 'System\.Func\`2') -Produces this member's value from a seeded `Bogus.Faker`\. +Produces this member's value from a seeded `Bogus.Faker`\. The `Bogus.Faker` +instance is reused across later, unrelated requests on the same thread \(per +[BogusMemberNameProvider](Compono.BogusMemberNameProvider.md 'Compono\.BogusMemberNameProvider')'s own performance design\) \- always freshly reseeded +before [generate](Compono.BogusOptions.AddConvention(string,System.Func_Bogus.Faker,string_).md#Compono.BogusOptions.AddConvention(string,System.Func_Bogus.Faker,string_).generate 'Compono\.BogusOptions\.AddConvention\(string, System\.Func\\)\.generate') is called, so read its randomness normally, but never +retain the instance past this call or rely on any state it carries beyond +`Faker.Random`\. #### Exceptions diff --git a/docs/reference/api/Compono.Bogus/Compono.BogusOptions.md b/docs/reference/api/Compono.Bogus/Compono.BogusOptions.md index cfa0f89..826c3cf 100644 --- a/docs/reference/api/Compono.Bogus/Compono.BogusOptions.md +++ b/docs/reference/api/Compono.Bogus/Compono.BogusOptions.md @@ -22,4 +22,4 @@ Inheritance [System\.Object](https://learn.microsoft.com/en-us/dotnet/api/system | Methods | | | :--- | :--- | | [AddAlias\(string, BogusConvention\)](Compono.BogusOptions.AddAlias(string,Compono.BogusConvention).md 'Compono\.BogusOptions\.AddAlias\(string, Compono\.BogusConvention\)') | Adds an additional exact member name that resolves to the same generator as a built\-in [BogusConvention](Compono.BogusConvention.md 'Compono\.BogusConvention') \- e\.g\. `AddAlias("GivenName", BogusConvention.FirstName)` lets a domain that calls first names "GivenName" still get realistic values from `UseBogus()` alone\. Validated and applied eagerly, immediately, against this [BogusOptions](Compono.BogusOptions.md 'Compono\.BogusOptions') instance's own accumulated state \- not deferred to `UseBogus(...)` returning, and not detected across separate `UseBogus(...)` calls\. | -| [AddConvention\(string, Func<Faker,string>\)](Compono.BogusOptions.AddConvention(string,System.Func_Bogus.Faker,string_).md 'Compono\.BogusOptions\.AddConvention\(string, System\.Func\\)') | Adds a custom exact\-name convention: a member named exactly [memberName](Compono.BogusOptions.AddConvention(string,System.Func_Bogus.Faker,string_).md#Compono.BogusOptions.AddConvention(string,System.Func_Bogus.Faker,string_).memberName 'Compono\.BogusOptions\.AddConvention\(string, System\.Func\\)\.memberName') resolves to [generate](Compono.BogusOptions.AddConvention(string,System.Func_Bogus.Faker,string_).md#Compono.BogusOptions.AddConvention(string,System.Func_Bogus.Faker,string_).generate 'Compono\.BogusOptions\.AddConvention\(string, System\.Func\\)\.generate')'s result, called against a request\-local, `context.DeriveSeed()`\-seeded `Bogus.Faker` \- the same determinism contract every other value in this package follows\. Validated and applied eagerly, immediately, against this [BogusOptions](Compono.BogusOptions.md 'Compono\.BogusOptions') instance's own accumulated state \- not deferred to `UseBogus(...)` returning, and not detected across separate `UseBogus(...)` calls\. | +| [AddConvention\(string, Func<Faker,string>\)](Compono.BogusOptions.AddConvention(string,System.Func_Bogus.Faker,string_).md 'Compono\.BogusOptions\.AddConvention\(string, System\.Func\\)') | Adds a custom exact\-name convention: a member named exactly [memberName](Compono.BogusOptions.AddConvention(string,System.Func_Bogus.Faker,string_).md#Compono.BogusOptions.AddConvention(string,System.Func_Bogus.Faker,string_).memberName 'Compono\.BogusOptions\.AddConvention\(string, System\.Func\\)\.memberName') resolves to [generate](Compono.BogusOptions.AddConvention(string,System.Func_Bogus.Faker,string_).md#Compono.BogusOptions.AddConvention(string,System.Func_Bogus.Faker,string_).generate 'Compono\.BogusOptions\.AddConvention\(string, System\.Func\\)\.generate')'s result, called against a `context.DeriveSeed()`\-seeded `Bogus.Faker` \- the same determinism contract every other value in this package follows\. Validated and applied eagerly, immediately, against this [BogusOptions](Compono.BogusOptions.md 'Compono\.BogusOptions') instance's own accumulated state \- not deferred to `UseBogus(...)` returning, and not detected across separate `UseBogus(...)` calls\. | From 0d69cc16b3fd212ccfa44c52528eba2749d94842 Mon Sep 17 00:00:00 2001 From: Nick Cipollina Date: Thu, 6 Aug 2026 08:51:56 -0400 Subject: [PATCH 4/5] fix: address PR review findings - Faker isolation, benchmark validity, doc links Real defects found by adversarial review, all fixed: - BogusMemberNameProvider's thread-local Faker reuse was incomplete: a custom AddConvention delegate could mutate Faker state (DateTimeReference, a sub-generator, any of ~20 other public settable properties) that would leak into a later, unrelated built-in-convention request on the same thread - reseeding Random alone didn't restore isolation. Fixed by reusing the per-thread Faker only for built-in/alias conventions (BogusConventions.IsBuiltIn, reference-equality against the ten built-in delegates); a custom delegate now gets its own single-use Faker, matching its pre-optimization behavior. New regression test mutates DateTimeReference in a custom convention and proves it doesn't perturb a later built-in request. - The concurrency test used Parallel.ForEachAsync over a fully-synchronous body, which could pass serially by scheduler luck rather than proving genuine thread overlap. Replaced with real Thread + Barrier so all workers release simultaneously. - GeneratorDriverBenchmarks' "incremental" tree was built via a fresh ParseText call, not derived from the base tree via WithChangedText, so it measured a wholesale reparse under an incremental label. Fixed to derive it via an append-only WithChangedText edit, so unaffected nodes keep their base-tree identity. - GraphDepthScalingBenchmarks compared MediumAggregate (seven strings + a collection) against DeepGraph (one string), conflating depth with total value-generation work. Fixed to compare DeepLevel8 (depth 1, one string) against DeepGraph (depth 8, same leaf shape) - the isolated result is 4.47x the mean and 2.65x the allocation for 8x the depth. - architecture/current/performance.md violated ADR-0034's own Reporting Rules by omitting Error/StdDev/Gen0/Gen1 from most tables. Rewritten with the full mandatory column set on every table, from one fresh full suite run. - Fixed a broken double-hyphen MkDocs anchor (3 occurrences), a link to a heading removed from the rewritten performance page, and nine living docs still linking to tombstoned legacy pages instead of their canonical replacements (including docs/index.md's stale "~6.1x faster" claim tied to a deleted benchmark class). Reworded future-packages.md's claim that Compono.Generators is a fifth installable package - it's an embedded analyzer, not independently installable. One reviewer claim (returning GeneratorDriver from a benchmark method breaks standard net10 dry runs via missing Compono.Generators restore assets) was investigated directly from a fully clean bin/obj state, across both --job Dry and DefaultJob - did not reproduce, left as-is. Co-Authored-By: Claude Sonnet 5 --- .../GraphDepthScalingBenchmarks.cs | 24 ++- .../GeneratorDriverBenchmarks.cs | 43 ++-- docs/architecture/current/performance.md | 201 +++++++++++------- docs/concepts/collections.md | 2 +- docs/concepts/composition-model.md | 5 +- docs/concepts/determinism-and-seeding.md | 2 +- docs/concepts/index.md | 2 +- docs/concepts/providers.md | 9 +- docs/concepts/registrations-and-rules.md | 4 +- docs/getting-started/learning-paths.md | 7 +- docs/how-to/register-a-type.md | 2 +- docs/index.md | 17 +- ...002-milestone-2-core-composition-engine.md | 11 +- docs/plans/0008-milestone-8-public-preview.md | 80 ++++++- ...rovider.BogusMemberNameProvider(string).md | 2 +- ...string,System.Func_Bogus.Faker,string_).md | 14 +- docs/roadmap/future-packages.md | 14 +- src/Compono.Bogus/BogusConventions.cs | 15 ++ src/Compono.Bogus/BogusMemberNameProvider.cs | 35 +-- src/Compono.Bogus/BogusOptions.cs | 14 +- test/Compono.Bogus.Tests/DeterminismTests.cs | 88 ++++++-- 21 files changed, 404 insertions(+), 187 deletions(-) diff --git a/benchmarks/Compono.Benchmarks/Scalability/GraphDepthScalingBenchmarks.cs b/benchmarks/Compono.Benchmarks/Scalability/GraphDepthScalingBenchmarks.cs index 99d51b8..3de4af7 100644 --- a/benchmarks/Compono.Benchmarks/Scalability/GraphDepthScalingBenchmarks.cs +++ b/benchmarks/Compono.Benchmarks/Scalability/GraphDepthScalingBenchmarks.cs @@ -6,21 +6,31 @@ namespace Compono.Benchmarks.Scalability; /// /// Shallow vs. deep graph composition cost, per ADR-0034 - generalizes the old suite's one-off /// DeepGraphBenchmarks (which only ever exercised in isolation) -/// into a real comparison against 's shallow, 2-level shape. -/// 's 8-level chain is deep enough (~48 trace entries at its deepest point) -/// to exceed CompositionTraceBuffer's 32-entry initial capacity and trigger a real -/// Array.Resize, unlike . +/// into a real depth-only comparison. /// +/// +/// composes directly - the exact same leaf shape +/// (one member, nothing else) 's +/// resolves at the bottom of its 8-level chain, just at depth 1 instead of depth 8. An earlier +/// version of this benchmark used as the shallow arm instead, which +/// resolves two objects, seven strings, and a collection - categorically more value-generation +/// work than 's single string, so any difference between the two couldn't +/// be attributed to depth alone versus the extra work. Both arms here resolve exactly one leaf +/// string value; depth (1 vs. 8) is the only variable. 's 8-level chain is +/// deep enough (~48 trace entries at its deepest point) to exceed +/// CompositionTraceBuffer's 32-entry initial capacity and trigger a real +/// Array.Resize, unlike the depth-1 case. +/// [MemoryDiagnoser] public class GraphDepthScalingBenchmarks { private readonly Composer _composer = Composer.Create(); - /// Composes the shallow, 2-level graph. + /// Composes directly - depth 1, one string leaf, the same leaf shape resolves at depth 8. [Benchmark(Baseline = true)] - public MediumAggregate Shallow() => _composer.Create(); + public DeepLevel8 Shallow() => _composer.Create(); - /// Composes the 8-level-deep chain. + /// Composes the 8-level-deep chain - depth 8, the same one-string leaf shape as . [Benchmark] public DeepGraph Deep() => _composer.Create(); } diff --git a/benchmarks/Compono.Benchmarks/SourceGeneration/GeneratorDriverBenchmarks.cs b/benchmarks/Compono.Benchmarks/SourceGeneration/GeneratorDriverBenchmarks.cs index 4dcefeb..e2fc1f9 100644 --- a/benchmarks/Compono.Benchmarks/SourceGeneration/GeneratorDriverBenchmarks.cs +++ b/benchmarks/Compono.Benchmarks/SourceGeneration/GeneratorDriverBenchmarks.cs @@ -4,6 +4,7 @@ using Compono.Generators; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.Text; namespace Compono.Benchmarks.SourceGeneration; @@ -35,15 +36,26 @@ public class GeneratorDriverBenchmarks [GlobalSetup] public void Setup() { - _baseCompilation = BuildCompilation(TypeCount, touched: false); - - // Derived from _baseCompilation via ReplaceSyntaxTree, not built as an independent - // CSharpCompilation.Create(...) call - Roslyn's incremental generator cache is keyed off - // compilation/tree identity, so two separately-constructed compilations look entirely - // unrelated to the driver (forcing a full recompute, not a real incremental diff) even if - // their source text is nearly identical. - var touchedTree = BuildSyntaxTree(TypeCount, touched: true); - _touchedCompilation = _baseCompilation.ReplaceSyntaxTree(_baseCompilation.SyntaxTrees.Single(), touchedTree); + _baseCompilation = BuildCompilation(TypeCount); + + // Derived from the base tree via WithChangedText - not a second independently-parsed + // SyntaxTree swapped in via ReplaceSyntaxTree, even though both approaches produce a + // Compilation the driver considers "related" to the base one. WithChangedText performs a + // real incremental re-lex/re-parse that reuses the unaffected internal (green) nodes for + // every untouched region of text, so nodes before the appended text keep the same + // identity they had in the base tree - which is what actually lets the generator's + // incremental pipeline (keyed on node identity/equivalence, not just textual content) skip + // recomputing work for call sites nothing here changed. A tree built from scratch via + // ParseText has no such relationship to the base tree's nodes at all, even if the two + // trees' text is nearly identical - every node in it is new, so incremental work gets + // rerun for the whole tree, silently measuring a wholesale reparse instead of a real + // small-edit scenario. + var baseTree = _baseCompilation.SyntaxTrees.Single(); + var baseText = baseTree.GetText(); + var appended = baseText.WithChanges(new TextChange( + new TextSpan(baseText.Length, 0), "\n// incremental-touch marker - no call site changed\n")); + var touchedTree = baseTree.WithChangedText(appended); + _touchedCompilation = _baseCompilation.ReplaceSyntaxTree(baseTree, touchedTree); var driver = CSharpGeneratorDriver.Create(new ComponoIncrementalGenerator().AsSourceGenerator()); _warmDriver = driver.RunGenerators(_baseCompilation); @@ -61,9 +73,9 @@ public GeneratorDriver CleanGeneration() [Benchmark] public GeneratorDriver IncrementalGeneration() => _warmDriver.RunGenerators(_touchedCompilation); - private static Compilation BuildCompilation(int typeCount, bool touched) + private static Compilation BuildCompilation(int typeCount) { - var syntaxTree = BuildSyntaxTree(typeCount, touched); + var syntaxTree = BuildSyntaxTree(typeCount); List references = [ @@ -82,13 +94,13 @@ private static Compilation BuildCompilation(int typeCount, bool touched) return CSharpCompilation.Create("SourceGenerationBenchmarkAssembly", [syntaxTree], references, compilationOptions); } - private static SyntaxTree BuildSyntaxTree(int typeCount, bool touched) + private static SyntaxTree BuildSyntaxTree(int typeCount) { var parseOptions = CSharpParseOptions.Default.WithLanguageVersion(LanguageVersion.CSharp14); - return CSharpSyntaxTree.ParseText(BuildSource(typeCount, touched), parseOptions, "GeneratedTypes.cs"); + return CSharpSyntaxTree.ParseText(BuildSource(typeCount), parseOptions, "GeneratedTypes.cs"); } - private static string BuildSource(int typeCount, bool touched) + private static string BuildSource(int typeCount) { var source = new StringBuilder(); source.AppendLine("namespace Compono.Benchmarks.SourceGeneration.Generated;"); @@ -107,9 +119,6 @@ private static string BuildSource(int typeCount, bool touched) source.AppendLine(" }"); source.AppendLine("}"); - if (touched) - source.AppendLine("// incremental-touch marker - no call site changed"); - return source.ToString(); } } diff --git a/docs/architecture/current/performance.md b/docs/architecture/current/performance.md index cc0b341..5df8792 100644 --- a/docs/architecture/current/performance.md +++ b/docs/architecture/current/performance.md @@ -67,40 +67,43 @@ Recorded with `BenchmarkDotNet` v0.15.8, Apple M3 Max, macOS Tahoe 26.6, `DefaultJob` means: a pilot stage that determines how many iterations a run needs, a warmup phase, then a set of measured iterations — each launched in its own isolated, managed process, not measured in-process -alongside the benchmark harness. Every table below reports Mean, Error, -and StdDev at minimum, plus Allocated wherever `[MemoryDiagnoser]` -reports it (every benchmark class in the suite); a Ratio column is -included wherever the category has a designated baseline. Full detail — -every category's complete result set, `Gen0`/`Gen1` collection counts, -raw CSV/HTML exports — is in `benchmarks/Compono.Benchmarks`'s -`BenchmarkDotNet.Artifacts/results/` after a real run (see Reproducing, -below); this page reproduces the results most relevant to a consumer, -not every number. +alongside the benchmark harness. Per +[ADR-0034](../../adr/0034-benchmark-suite-strategy-and-redesign.md)'s +Reporting Rules, every table below reports the full mandatory column set: +Mean, Error, StdDev, Allocated, and `Gen0`/`Gen1` wherever BenchmarkDotNet +reports them as nonzero, plus a Ratio column wherever the category has a +designated baseline. A page that reported Mean alone, or Mean and +Allocated alone, would not meet that bar — this page doesn't. Full +detail — every category's complete result set, raw CSV/HTML exports — is +in `benchmarks/Compono.Benchmarks`'s `BenchmarkDotNet.Artifacts/results/` +after a real run (see Reproducing, below). ## Consumer-facing results **Representative models** (`Composer.Create()`, no comparison baseline — the absolute cost a consumer actually pays): -| Model | Mean | Allocated | -|---|---:|---:| -| `SimplePoco` (flat, no dependencies) | 377.6 ns | 1.70 KB | -| `MediumAggregate` (nested dependency + collection) | 991.6 ns | 2.88 KB | -| `DeepGraph` (8-level chain) | 1,046.3 ns | 3.54 KB | -| `LargeCollection` (100-element collection) | 8,780.9 ns | 20.86 KB | +| Model | Mean | Error | StdDev | Gen0 | Gen1 | Allocated | +|---|---:|---:|---:|---:|---:|---:| +| `SimplePoco` (flat, no dependencies) | 368.3 ns | 3.17 ns | 2.65 ns | 0.2084 | 0.0005 | 1.70 KB | +| `MediumAggregate` (nested dependency + collection) | 981.1 ns | 8.21 ns | 6.86 ns | 0.3529 | - | 2.88 KB | +| `DeepGraph` (8-level chain) | 1,016.2 ns | 10.46 ns | 8.73 ns | 0.4330 | 0.0019 | 3.54 KB | +| `LargeCollection` (100-element collection) | 8,615.9 ns | 50.54 ns | 44.80 ns | 2.5482 | 0.0458 | 20.86 KB | **Migrating from AutoFixture.** "Equivalent work" means both frameworks compose the same object graph shape, fill the same number of fields, and (for Compono's side) use its own real default value-generation cost — an 8-character string, a 3-element collection — not a stripped-down or -otherwise favorable graph for either side. Every ratio below is -**AutoFixture relative to Compono** (Compono is always the baseline, -`1.00×`): +otherwise favorable graph for either side. Every Ratio/Alloc Ratio value +below is **AutoFixture relative to Compono** (Compono is always the +baseline, `1.00×`): -| Model | Compono (baseline) | AutoFixture | AutoFixture vs. Compono | -|---|---:|---:|---:| -| `SimplePoco` | 374.8 ns / 1.70 KB | 23,122.4 ns / 29.95 KB | 61.7× slower, 17.6× more allocation | -| `MediumAggregate` | 980.9 ns / 2.88 KB | 74,677.7 ns / 99.21 KB | 76.1× slower, 34.4× more allocation | +| Model | Method | Mean | Error | StdDev | Ratio | Gen0 | Gen1 | Allocated | Alloc Ratio | +|---|---|---:|---:|---:|---:|---:|---:|---:|---:| +| `SimplePoco` | Compono (baseline) | 378.2 ns | 6.30 ns | 8.83 ns | 1.00× | 0.2084 | 0.0005 | 1.70 KB | 1.00× | +| `SimplePoco` | AutoFixture | 24,036.9 ns | 466.53 ns | 572.94 ns | 63.6× | 3.6621 | - | 29.96 KB | 17.6× | +| `MediumAggregate` | Compono (baseline) | 962.0 ns | 15.68 ns | 13.90 ns | 1.00× | 0.3529 | 0.0010 | 2.88 KB | 1.00× | +| `MediumAggregate` | AutoFixture | 77,990.7 ns | 1,543.97 ns | 3,389.05 ns | 81.1× | 11.7188 | - | 99.21 KB | 34.4× | AutoFixture is doing substantially more runtime work here (reflection- based construction plus its own randomized-value-generation pipeline) — @@ -110,10 +113,10 @@ target Compono is trying to "beat." **Provider-enabled profiles** (`Composer.Create()` with a package's provider active): -| Scenario | Mean | Allocated | -|---|---:|---:| -| `UseNSubstitute()` (composing an interface member) | 1.266 μs | 7.05 KB | -| `UseBogus()` (composing two convention-matching `string` members) | 5.481 μs | 7.04 KB | +| Scenario | Mean | Error | StdDev | Gen0 | Gen1 | Allocated | +|---|---:|---:|---:|---:|---:|---:| +| `UseNSubstitute()` (composing an interface member) | 1.235 μs | 0.0178 μs | 0.0166 μs | 0.8621 | 0.0114 | 7.05 KB | +| `UseBogus()` (composing two convention-matching `string` members) | 5.870 μs | 0.1104 μs | 0.1033 μs | 0.8545 | 0.0076 | 7.04 KB | `UseBogus()`'s cost here reflects a fix applied after an earlier run of this suite found it substantially higher — see Feature Overhead below @@ -121,83 +124,124 @@ for the full account. The two numbers aren't measuring the same scope: this row is the full profile (two Bogus-backed members plus the rest of the `MediumAggregate` graph); Feature Overhead's `UseBogus()` row below isolates a single member's marginal cost against its cheapest -alternative. Same underlying mechanism, different scope. +alternative. Same underlying mechanism, different scope — expect the +per-member number below to be smaller than this full-profile one. ## Feature overhead Isolates one mechanism's marginal cost at a time (full detail: [ADR-0034](../../adr/0034-benchmark-suite-strategy-and-redesign.md)): -| Mechanism | Baseline | With feature | Ratio | -|---|---:|---:|---:| -| Member rule (vs. generated-only) | 985.4 ns | 1,305.3 ns | 1.33× | -| Type rule (vs. generated-only) | 985.4 ns | 968.7 ns | 0.98× (noise) | -| Custom `ICompositionValueProvider` (vs. generated-only) | 985.4 ns | 1,324.7 ns | 1.34× | -| `[Shared]`'s row-sharing mechanism (vs. no sharing) | 830.5 ns | 746.7 ns | 0.90× (sharing is cheaper — avoids composing a second independent value) | -| `UseNSubstitute()` (vs. a plain registration) | 325.7 ns | 1,194.1 ns | 3.67× | -| `UseBogus()` (vs. a plain member rule) | 342.0 ns | 2,156.5 ns | 6.31× | +| Mechanism | Method | Mean | Error | StdDev | Ratio | Gen0 | Gen1 | Allocated | Alloc Ratio | +|---|---|---:|---:|---:|---:|---:|---:|---:|---:| +| Configuration rules | GeneratedOnly (baseline) | 962.2 ns | 10.61 ns | 9.40 ns | 1.00× | 0.3529 | - | 2.88 KB | 1.00× | +| Configuration rules | + member rule | 1,208.9 ns | 17.65 ns | 15.65 ns | 1.26× | 0.4082 | 0.0019 | 3.34 KB | 1.16× | +| Configuration rules | + type rule | 956.4 ns | 6.82 ns | 6.38 ns | 0.99× (noise) | 0.3586 | - | 2.93 KB | 1.02× | +| Configuration rules | + custom `ICompositionValueProvider` | 1,312.8 ns | 12.24 ns | 10.85 ns | 1.36× | 0.4368 | 0.0019 | 3.58 KB | 1.24× | +| `[Shared]` row-sharing | Without sharing (baseline) | 805.5 ns | 5.05 ns | 4.48 ns | 1.00× | 0.3042 | 0.0010 | 2.49 KB | 1.00× | +| `[Shared]` row-sharing | With sharing | 742.7 ns | 6.20 ns | 5.80 ns | 0.92× (sharing is cheaper — avoids composing a second independent value) | 0.3109 | 0.0010 | 2.55 KB | 1.02× | +| `UseNSubstitute()` | Registration (baseline) | 332.0 ns | 2.58 ns | 2.42 ns | 1.00× | 0.2027 | 0.0010 | 1.66 KB | 1.00× | +| `UseNSubstitute()` | NSubstitute provider | 1,206.9 ns | 12.61 ns | 11.79 ns | 3.64× | 0.8698 | 0.0114 | 7.12 KB | 4.30× | +| `UseBogus()` | Member rule (baseline) | 333.4 ns | 4.51 ns | 4.22 ns | 1.00× | 0.2027 | 0.0010 | 1.66 KB | 1.00× | +| `UseBogus()` | Bogus convention provider | 2,254.2 ns | 15.50 ns | 13.74 ns | 6.76× | 0.4425 | - | 3.62 KB | 2.18× | **`UseBogus()`: a finding, a root cause, a fix, a confirmation.** An earlier run of this benchmark measured `UseBogus()` at ~865× a plain -member rule (291.5 μs, isolated single-member measurement) — reported in -full per +member rule (291.5 μs, isolated single-member measurement). That result +was published in full, per [ADR-0034](../../adr/0034-benchmark-suite-strategy-and-redesign.md)'s -publication rule, since an unfavorable result gets published exactly -like a favorable one. Investigation traced the cost to -`BogusMemberNameProvider` constructing a new `Bogus.Faker` instance on -every resolution (`src/Compono.Bogus/BogusMemberNameProvider.cs`) — -`Faker` construction, which builds out its full set of category -generators, is genuinely expensive. The implementation was changed to -cache one `Faker` per thread instead of constructing one per request, -reseeding its `Random` immediately before every use — see +publication rule — an unfavorable result is reported exactly like a +favorable one. Investigation traced the cost to `BogusMemberNameProvider` +constructing a new `Bogus.Faker` instance on every resolution +(`src/Compono.Bogus/BogusMemberNameProvider.cs`); `Faker` construction, +which builds out its full set of category generators, is genuinely +expensive. The implementation was changed to cache one `Faker` per +thread for built-in conventions, reseeding its `Random` immediately +before every use, while a custom `AddConvention` delegate still gets its +own single-use `Faker` (it could mutate state a shared instance +shouldn't carry between requests) — see [ADR-0027's Amendment](../../adr/0027-compono-bogus-package-design.md#amendment-2026-08-05-bogusmembernameprovider-reuses-a-per-thread-faker-not-a-fresh-one-per-request) -for why per-thread reuse doesn't reintroduce the concurrent-access hazard -a shared instance would, and what regression coverage backs it. The -benchmark above, rerun after the fix, confirms the result: ~865× -dropped to ~6.31× (this table), and the full-profile Consumer Scenario -cost dropped from 903.4 μs / 2,229.31 KB to 5.481 μs / 7.04 KB (Consumer- -facing results, above). +for the full account and the regression coverage backing it. Rerun after +the fix, the benchmark confirms the result: ~865× dropped to ~6.76× +(this table), and the full-profile Consumer Scenario cost dropped from +903.4 μs / 2,229.31 KB to 5.870 μs / 7.04 KB (Consumer-facing results, +above). ## Scalability **Batch scaling** (`CreateMany(count)` against its own `Create()` -baseline): - -| Count | `CreateMany` Mean | Ratio vs. `Create` | -|---:|---:|---:| -| 1 | 1,045.3 ns | 1.06× | -| 10 | 10,314.6 ns | 10.34× | -| 100 | 102,902.9 ns | 101.96× | -| 1,000 | 1,180,622.5 ns | 1,184.57× | - -Scaling is linear through 100 items; at 1,000 items the ratio (1,184.57× +baseline, per batch size): + +| Count | Method | Mean | Error | StdDev | Ratio | Gen0 | Gen1 | Allocated | Alloc Ratio | +|---:|---|---:|---:|---:|---:|---:|---:|---:|---:| +| 1 | Create (baseline) | 994.0 ns | 7.88 ns | 7.37 ns | 1.00× | 0.3529 | - | 2.88 KB | 1.00× | +| 1 | CreateMany | 1,032.4 ns | 9.32 ns | 8.72 ns | 1.04× | 0.3681 | 0.0019 | 3.02 KB | 1.05× | +| 10 | Create (baseline) | 994.0 ns | 8.53 ns | 7.56 ns | 1.00× | 0.3529 | - | 2.88 KB | 1.00× | +| 10 | CreateMany | 10,329.1 ns | 126.09 ns | 117.94 ns | 10.39× | 3.5858 | 0.0610 | 29.31 KB | 10.17× | +| 100 | Create (baseline) | 988.6 ns | 6.32 ns | 5.91 ns | 1.00× | 0.3529 | - | 2.88 KB | 1.00× | +| 100 | CreateMany | 102,933.0 ns | 829.95 ns | 776.33 ns | 104.12× | 35.7666 | 4.7607 | 292.29 KB | 101.39× | +| 1,000 | Create (baseline) | 994.4 ns | 10.48 ns | 9.81 ns | 1.00× | 0.3529 | - | 2.88 KB | 1.00× | +| 1,000 | CreateMany | 1,180,425.4 ns | 7,238.77 ns | 6,771.15 ns | 1,187.19× | 359.3750 | 179.6875 | 2,943.90 KB | 1,021.19× | + +Scaling is linear through 100 items; at 1,000 items the ratio (1,187.19× against a 1,000× input-size increase) shows a modest, real super-linear component — `Gen1` collections start appearing at this scale (0 at `count=10`, ~180 at `count=1,000`) where they don't at smaller batches, consistent with GC promotion pressure rather than an algorithmic regression in the composition pipeline itself. -**Graph depth** (shallow `MediumAggregate` vs. deep, 8-level `DeepGraph`): -1.008 μs vs. 1.040 μs (1.03×) — a real but modest cost from the deeper -chain's diagnostics-trace-buffer growth (see -[The Provider Pipeline](provider-pipeline.md#diagnostics)), not a -concern at this depth. +**Graph depth** (`DeepLevel8` at depth 1 vs. `DeepGraph`'s chain at depth +8 — both resolve exactly one `string` leaf value, so depth is the only +variable; an earlier version of this benchmark compared against +`MediumAggregate` instead, which resolves seven strings and a collection +on top of its own object graph, conflating depth with total +value-generation work): + +| Method | Mean | Error | StdDev | Ratio | Gen0 | Gen1 | Allocated | Alloc Ratio | +|---|---:|---:|---:|---:|---:|---:|---:|---:| +| Shallow (`DeepLevel8`, depth 1, baseline) | 240.4 ns | 4.79 ns | 4.00 ns | 1.00× | 0.1631 | 0.0005 | 1.34 KB | 1.00× | +| Deep (`DeepGraph`, depth 8) | 1,074.5 ns | 12.74 ns | 11.29 ns | 4.47× | 0.4330 | 0.0019 | 3.54 KB | 2.65× | + +With depth isolated as the only variable, the real cost is clear: 4.47× +the mean, 2.65× the allocation, for a chain 8× as deep — consistent with +each additional level's own dispatch, path-segment, and diagnostics- +trace-buffer bookkeeping (see +[The Provider Pipeline](provider-pipeline.md#diagnostics)), including the +real `Array.Resize` `DeepGraph`'s depth is enough to trigger in the trace +buffer that `DeepLevel8` alone never reaches. **Collection size** (`WithCollectionSize(n)`, 3 to 200 elements): -517.4 ns at 3 elements, 17,143.4 ns at 200 — sub-linear relative to the -66.7× size increase (a ~33.1× time increase), since a fixed per-`Create` -dispatch cost is amortized across more elements at larger sizes. + +| CollectionSize | Mean | Error | StdDev | Gen0 | Gen1 | Allocated | +|---:|---:|---:|---:|---:|---:|---:| +| 3 | 517.6 ns | 4.59 ns | 3.83 ns | 0.2337 | 0.0010 | 1.91 KB | +| 10 | 1,116.5 ns | 9.41 ns | 8.34 ns | 0.4005 | 0.0019 | 3.28 KB | +| 50 | 4,552.5 ns | 24.15 ns | 22.59 ns | 1.3580 | 0.0153 | 11.09 KB | +| 200 | 17,247.1 ns | 261.28 ns | 218.18 ns | 4.9438 | 0.1831 | 40.39 KB | + +Sub-linear relative to the 66.7× size increase (a ~33.3× time increase +from 3 to 200 elements), since a fixed per-`Create` dispatch cost is +amortized across more elements at larger sizes. ## Source generation Clean vs. incremental generator cost, in-process via Roslyn's `GeneratorDriver` (a maintainer-facing, build-time concern, unrelated to -every result above): - -| TypeCount | Clean Mean | Incremental Mean | Ratio | -|---:|---:|---:|---:| -| 1 | 55.92 μs | 25.48 μs | 0.46× | -| 10 | 230.35 μs | 83.44 μs | 0.36× | -| 50 | 1,065.10 μs | 350.55 μs | 0.33× | +every result above). The incremental compilation is derived from the +clean one via `SyntaxTree.WithChangedText` with an append-only edit — not +a second, independently-parsed tree swapped in — so unaffected nodes keep +the identity they had in the base tree, which is what actually lets the +generator's incremental pipeline skip recomputing work for call sites +nothing changed, rather than measuring a wholesale reparse under an +"incremental" label: + +| TypeCount | Method | Mean | Error | StdDev | Ratio | Gen0 | Gen1 | Allocated | Alloc Ratio | +|---:|---|---:|---:|---:|---:|---:|---:|---:|---:| +| 1 | CleanGeneration (baseline) | 55.58 μs | 0.156 μs | 0.122 μs | 1.00× | 12.2070 | 1.4648 | 103.46 KB | 1.00× | +| 1 | IncrementalGeneration | 25.81 μs | 0.448 μs | 0.397 μs | 0.46× | 3.0518 | - | 25.88 KB | 0.25× | +| 10 | CleanGeneration (baseline) | 230.53 μs | 1.614 μs | 1.431 μs | 1.00× | 72.2656 | 15.6250 | 600.10 KB | 1.00× | +| 10 | IncrementalGeneration | 85.56 μs | 1.509 μs | 1.260 μs | 0.37× | 8.0566 | 0.3662 | 66.60 KB | 0.11× | +| 50 | CleanGeneration (baseline) | 1,030.43 μs | 7.461 μs | 7.328 μs | 1.00× | 343.7500 | 109.3750 | 2,809.42 KB | 1.00× | +| 50 | IncrementalGeneration | 364.52 μs | 7.273 μs | 8.084 μs | 0.35× | 29.2969 | 3.9063 | 247.04 KB | 0.09× | Incremental generation is consistently faster and allocates substantially less (0.09×–0.25× of clean generation's allocation) across @@ -216,6 +260,5 @@ dotnet run -c Release --project benchmarks/Compono.Benchmarks -f net10.0 build. Add `-- --filter "*ClassName*"` to run one category at a time (the full suite, across every category's parameter matrix, takes on the order of 15 minutes). Full per-category results (every method, every parameter -value, `Gen0`/`Gen1`/`Gen2`, raw CSV/HTML) are written to -`BenchmarkDotNet.Artifacts/results/` relative to the working directory -`dotnet run` was invoked from. +value, raw CSV/HTML) are written to `BenchmarkDotNet.Artifacts/results/` +relative to the working directory `dotnet run` was invoked from. diff --git a/docs/concepts/collections.md b/docs/concepts/collections.md index 3c282d3..4c6251f 100644 --- a/docs/concepts/collections.md +++ b/docs/concepts/collections.md @@ -51,4 +51,4 @@ conflict (`CompositionConfigurationException`), not last-write-wins. - Apply this to a real task → [Create an Object](../how-to/create-an-object.md). - The configuration surface `WithCollectionSize` is part of → [Registrations and Rules](registrations-and-rules.md). -- Precise API contract → [Public API](../public-api.md). +- Precise API contract → [Reference](../reference/index.md). diff --git a/docs/concepts/composition-model.md b/docs/concepts/composition-model.md index b8e973c..40b4f5c 100644 --- a/docs/concepts/composition-model.md +++ b/docs/concepts/composition-model.md @@ -47,7 +47,8 @@ var customers = composer.CreateMany(3); A `Composer`'s configuration is fixed at `Create` time — there's no method to mutate an already-built `Composer`'s rules afterward. This is a deliberate consequence of Compono's immutable-by-design goal (no mutable -global state, per `docs/public-api.md`'s API goals): the composer you get +global state, per [Design Principles](../architecture/design-principles.md)'s +guiding principles): the composer you get back from `Composer.Create(...)` behaves the same way on every call, for every test that reuses it, with nothing to accidentally leak between tests via shared mutable state. @@ -78,4 +79,4 @@ reflection exception. [Shared Values](shared-values.md). - Group configuration into a reusable unit → [Profiles](profiles.md). - The deeper "how" behind the resolution pipeline → - [Architecture](../architecture.md). + [The Provider Pipeline](../architecture/current/provider-pipeline.md). diff --git a/docs/concepts/determinism-and-seeding.md b/docs/concepts/determinism-and-seeding.md index 228ddeb..7eddadd 100644 --- a/docs/concepts/determinism-and-seeding.md +++ b/docs/concepts/determinism-and-seeding.md @@ -81,7 +81,7 @@ failure is never a "works on my machine, can't repro" report — the seed ## Next - The pipeline stage that actually derives per-value randomness from a - seed → [Architecture](../architecture.md). + seed → [Deterministic Seeding](../architecture/current/deterministic-seeding.md). - Reproduce a specific failing xUnit theory row → [`Compono.XunitV3` Package Guide](../packages/compono-xunitv3.md). - Diagnostic codes and messages → [Diagnostics Reference](../reference/diagnostics.md). diff --git a/docs/concepts/index.md b/docs/concepts/index.md index 61dda2d..3630c25 100644 --- a/docs/concepts/index.md +++ b/docs/concepts/index.md @@ -2,7 +2,7 @@ This section builds the mental model Compono's other sections assume — what each piece is and when to reach for it, not the implementation details -behind it (that's [Architecture](../architecture.md)'s job). +behind it (that's [Architecture](../architecture/index.md)'s job). - [The Composition Model](composition-model.md) — what "composing" a graph means in Compono's terms. diff --git a/docs/concepts/providers.md b/docs/concepts/providers.md index e7a23fc..0fb7f11 100644 --- a/docs/concepts/providers.md +++ b/docs/concepts/providers.md @@ -44,7 +44,8 @@ two logically distinct stages precisely so that ordering between `UseBogus()`/`UseNSubstitute()` calls never matters — a provider only influences the stage it's registered into. The exact stage order relative to registrations, rules, and generated default construction is -[Architecture](../architecture.md)'s concern, not this page's — what +[The Provider Pipeline](../architecture/current/provider-pipeline.md)'s +concern, not this page's — what matters here is that a provider is always a fallback, tried only once nothing more specific (an exact registration, a type/member rule) already claimed the value. @@ -53,6 +54,6 @@ claimed the value. - Providers that ship today → [`Compono.NSubstitute`](../packages/compono-nsubstitute.md), [`Compono.Bogus`](../packages/compono-bogus.md) Package Guides. -- Write your own → [Public API](../public-api.md)'s provider extensibility - contract. -- The full pipeline a provider participates in → [Architecture](../architecture.md). +- Write your own → [The Provider Pipeline](../architecture/current/provider-pipeline.md)'s + provider extensibility contract. +- The full pipeline a provider participates in → [The Provider Pipeline](../architecture/current/provider-pipeline.md). diff --git a/docs/concepts/registrations-and-rules.md b/docs/concepts/registrations-and-rules.md index 85f5e8c..61e9ae0 100644 --- a/docs/concepts/registrations-and-rules.md +++ b/docs/concepts/registrations-and-rules.md @@ -67,7 +67,7 @@ builder.For().Member(x => x.PlacedAt).Use(context => context.Resolve()`) runs ~6.1x faster and -allocates ~43% as much as an equivalent reflection-based baseline, for -the flat-type case Milestone 1 can measure end-to-end today. See -[Performance](performance.md) for methodology, caveats, and how to -reproduce - `benchmarks/Compono.Benchmarks`. +Compono is source-generated by default so that composing a type +dispatches to generated code rather than reflecting over it at runtime. +See [Performance](architecture/current/performance.md) for the full, +current methodology, results, and how to reproduce them - +`benchmarks/Compono.Benchmarks`. ## Status Compono is under active development. APIs are experimental until the first public preview. -See the [Manifesto](manifesto.md), [Architecture](architecture.md), -[Design Principles](design-principles.md), [Public API](public-api.md), -[Performance](performance.md), [MVP](mvp.md), and -[ADRs](adr/0001-source-generation-first.md) for more. +See the [Design Principles](architecture/design-principles.md), +[Architecture](architecture/index.md), [Performance](architecture/current/performance.md), +[MVP](mvp.md), and [ADRs](adr/0001-source-generation-first.md) for more. diff --git a/docs/plans/0002-milestone-2-core-composition-engine.md b/docs/plans/0002-milestone-2-core-composition-engine.md index 66bd3b1..b6c8036 100644 --- a/docs/plans/0002-milestone-2-core-composition-engine.md +++ b/docs/plans/0002-milestone-2-core-composition-engine.md @@ -1367,12 +1367,15 @@ ADR-0014. The task list below reflects the corrected shape. Compono's real random-value generation, so it's faster than `Generated` for doing categorically less work, not because reflection dispatch beats source-generated dispatch - documented explicitly in both the - class' XML doc `` and `docs/performance.md`, the mirror image - of the AutoFixture caveat. Full tables and reproduction steps recorded + class' XML doc `` and (at the time) `docs/performance.md`, the + mirror image of the AutoFixture caveat. Reproduction steps are recorded permanently in - [`architecture/current/performance.md`](../architecture/current/performance.md#milestone-2-phase-4-resolution-pipeline-result) + [`architecture/current/performance.md`](../architecture/current/performance.md) (`architecture/current/provider-pipeline.md`'s Diagnostics section links - there too, rather than duplicating the table). + there too, rather than duplicating the table) - the specific tables this + bullet originally pointed to were superseded by + [ADR-0034](../adr/0034-benchmark-suite-strategy-and-redesign.md)'s + benchmark suite redesign and no longer exist on that page. - A PR #13 review round (Codex) found three real issues, all fixed in the same PR. **Trace buffer's own allocation was asserted, not measured (P1).** The Phase 4 benchmark result above reported total allocation diff --git a/docs/plans/0008-milestone-8-public-preview.md b/docs/plans/0008-milestone-8-public-preview.md index 6ddf2ce..5da130c 100644 --- a/docs/plans/0008-milestone-8-public-preview.md +++ b/docs/plans/0008-milestone-8-public-preview.md @@ -517,7 +517,7 @@ progress. (`SimplePocoConstructionBenchmarks.cs`/ `MediumAggregateConstructionBenchmarks.cs`, handwritten-as-ceiling/ generated/cached-reflection/uncached-reflection), then **removed** - per [ADR-0034's Amendment](../adr/0034-benchmark-suite-strategy-and-redesign.md#amendment-2026-08-05-implementation-strategies-removed--it-compares-different-systems-not-one-variable): + per [ADR-0034's Amendment](../adr/0034-benchmark-suite-strategy-and-redesign.md#amendment-2026-08-05-implementation-strategies-removed-it-compares-different-systems-not-one-variable): the category compared different systems doing different amounts of work (bare construction vs. a full resolution pipeline), not one isolated variable, so a result from it couldn't be attributed to a @@ -595,15 +595,20 @@ progress. - [x] Build and smoke-test the redesigned suite: `dotnet build Compono.slnx -c Release` (0 warnings, 0 errors) and `dotnet run -c Release --project benchmarks/Compono.Benchmarks -f net10.0 -- - --job Dry --filter '*'` (all 49 benchmarks executed without - throwing) — confirms the suite is structurally correct. This is - not the statistically real run the next task produces; `--job Dry` - is one cold iteration per benchmark, fast enough to smoke-test but - not meant to be reported as a real result. + --job Dry --filter '*'` (all 49 benchmarks, across the 15 classes + that existed at that point in this task list — before + Implementation Strategies was implemented-then-removed later in + this same Part, which brought the suite down to 41 benchmarks + across 13 classes — executed without throwing) — confirms the + suite is structurally correct. This is not the statistically real + run the next task produces; `--job Dry` is one cold iteration per + benchmark, fast enough to smoke-test but not meant to be reported + as a real result. - [x] Run the full redesigned suite (`dotnet run -c Release --project benchmarks/Compono.Benchmarks -f net10.0 -- --filter '*'`), `DefaultJob` (not `--job Dry`), and record real results — all 15 - classes produced real Mean/Error/StdDev/Allocated/`Gen0`/`Gen1` + classes (at the time; 13 after Implementation Strategies' later + removal) produced real Mean/Error/StdDev/Allocated/`Gen0`/`Gen1` results (Apple M3 Max, macOS Tahoe 26.6, .NET 10.0.3 arm64 RyuJIT, `BenchmarkDotNet` v0.15.8; total run ~15 minutes). One real, published finding the run surfaced: `UseBogus()` costs ~865x a @@ -627,7 +632,7 @@ progress. pointed at the now-tombstoned `docs/performance.md` instead of this page. - [x] Removed the Implementation Strategies category entirely, per - [ADR-0034's Amendment](../adr/0034-benchmark-suite-strategy-and-redesign.md#amendment-2026-08-05-implementation-strategies-removed--it-compares-different-systems-not-one-variable) — + [ADR-0034's Amendment](../adr/0034-benchmark-suite-strategy-and-redesign.md#amendment-2026-08-05-implementation-strategies-removed-it-compares-different-systems-not-one-variable) — deleted `ImplementationStrategies/` and its dedicated `Baselines/` classes (`HandwrittenComposer`, `CachedReflectionComposer`, `UncachedReflectionComposer`), removed the "Implementation @@ -659,6 +664,63 @@ progress. "equivalent work" definition, a note connecting the Consumer Scenario and Feature Overhead `UseBogus()` numbers' different scope, and consistent `×` formatting throughout. +- [x] Addressed a real adversarial PR review of this phase's work + (PR #53): **`BogusMemberNameProvider`'s thread-local reuse was + real but incomplete** — a custom `AddConvention` delegate could + mutate `Faker` state (`DateTimeReference`, a sub-generator, any of + `Faker`'s ~20 other public settable properties) that would then + leak into a later, unrelated built-in-convention request on the + same thread; reseeding `Random` alone didn't restore isolation. + Fixed by reusing the per-thread `Faker` only for built-in/alias + conventions (`BogusConventions.IsBuiltIn`, a reference-equality + check against the ten built-in delegates) and giving every custom + `AddConvention` delegate its own single-use `Faker`, with a new + regression test mutating `DateTimeReference` in a custom + convention and proving it doesn't perturb a later built-in + request. **The concurrency test didn't force genuine thread + overlap** — `Parallel.ForEachAsync` over a fully-synchronous body + could pass serially by scheduler luck; replaced with real + `Thread` + `Barrier` so all workers release simultaneously. + **`GeneratorDriverBenchmarks`' incremental tree was still built + via a fresh `ParseText` call** (given a different constructor + argument than the base tree, but not derived from it) rather than + `SyntaxTree.WithChangedText`, so it measured a wholesale reparse + under an "incremental" label; fixed to derive the touched tree via + an append-only `WithChangedText` edit so unaffected nodes keep + their base-tree identity. **`GraphDepthScalingBenchmarks` didn't + isolate depth** — its `MediumAggregate` shallow arm resolved seven + strings and a collection against `DeepGraph`'s single string, + conflating depth with total value-generation work; fixed to + compare `DeepLevel8` (depth 1, one string) against `DeepGraph` + (depth 8, same one-string leaf shape) — the real, isolated result + is 4.47× the mean and 2.65× the allocation for 8× the depth, a + much more meaningful number than the original 1.03×/1.23×. + **`architecture/current/performance.md` violated ADR-0034's own + Reporting Rules** by omitting Error/StdDev/Gen0/Gen1 from most + tables despite the page's own Methodology section claiming full + columns; rewritten with the complete mandatory column set on every + table. Also fixed: several broken/stale doc links and anchors + (a `removed--it` double-hyphen anchor MkDocs never generates; + `docs/plans/0002-...`'s link to a heading removed from the + rewritten performance page; `docs/index.md`/ + `docs/getting-started/learning-paths.md`/`docs/concepts/providers.md`/ + `docs/concepts/composition-model.md`/`docs/concepts/index.md`/ + `docs/concepts/determinism-and-seeding.md`/ + `docs/concepts/registrations-and-rules.md`/`docs/concepts/collections.md`/ + `docs/how-to/register-a-type.md` still linking to tombstoned legacy + pages instead of their canonical replacements, including + `docs/index.md`'s stale "~6.1× faster" claim tied to a benchmark + class that no longer exists); a stale "49 benchmarks/15 classes" + verification-record bullet earlier in this list, now annotated with + the post-removal counts; and `docs/roadmap/future-packages.md` + wording that implied `Compono.Generators` was a fifth installable + package rather than an embedded analyzer. One reviewer claim + (returning `GeneratorDriver` from a benchmark method breaks + standard `net10` dry runs via missing `Compono.Generators` restore + assets) was investigated directly — reproduced from a fully clean + `bin`/`obj` state, `--job Dry` and `DefaultJob`, both TypeCount + matrices — and did not reproduce; left as-is, noted in the PR + reply. ## Phase 6: Contributor and repository readiness @@ -1013,7 +1075,7 @@ individually resolved, not left ambiguous): `ExternalComparison/`, `FeatureOverhead/`, `Scalability/`, `SourceGeneration/` (five categories — `ImplementationStrategies/` was implemented, then removed per - [ADR-0034's Amendment](../adr/0034-benchmark-suite-strategy-and-redesign.md#amendment-2026-08-05-implementation-strategies-removed--it-compares-different-systems-not-one-variable)), + [ADR-0034's Amendment](../adr/0034-benchmark-suite-strategy-and-redesign.md#amendment-2026-08-05-implementation-strategies-removed-it-compares-different-systems-not-one-variable)), replacing all 8 existing benchmark files per ADR-0034. `Compono.Benchmarks.csproj` gains `ProjectReference`s to `Compono.NSubstitute`/`Compono.Bogus`, its diff --git a/docs/reference/api/Compono.Bogus/Compono.BogusMemberNameProvider.BogusMemberNameProvider(string).md b/docs/reference/api/Compono.Bogus/Compono.BogusMemberNameProvider.BogusMemberNameProvider(string).md index f009c36..9a7ada2 100644 --- a/docs/reference/api/Compono.Bogus/Compono.BogusMemberNameProvider.BogusMemberNameProvider(string).md +++ b/docs/reference/api/Compono.Bogus/Compono.BogusMemberNameProvider.BogusMemberNameProvider(string).md @@ -14,4 +14,4 @@ public BogusMemberNameProvider(string locale); `locale` [System\.String](https://learn.microsoft.com/en-us/dotnet/api/system.string 'System\.String') -The Bogus locale each handled request's own `Bogus.Faker` uses\. \ No newline at end of file +The Bogus locale each handled request's `Bogus.Faker` uses\. \ No newline at end of file diff --git a/docs/reference/api/Compono.Bogus/Compono.BogusOptions.AddConvention(string,System.Func_Bogus.Faker,string_).md b/docs/reference/api/Compono.Bogus/Compono.BogusOptions.AddConvention(string,System.Func_Bogus.Faker,string_).md index 01e4725..44220b2 100644 --- a/docs/reference/api/Compono.Bogus/Compono.BogusOptions.AddConvention(string,System.Func_Bogus.Faker,string_).md +++ b/docs/reference/api/Compono.Bogus/Compono.BogusOptions.AddConvention(string,System.Func_Bogus.Faker,string_).md @@ -25,12 +25,14 @@ The exact member name to match\. `generate` [System\.Func<](https://learn.microsoft.com/en-us/dotnet/api/system.func-2 'System\.Func\`2')`Bogus.Faker`[,](https://learn.microsoft.com/en-us/dotnet/api/system.func-2 'System\.Func\`2')[System\.String](https://learn.microsoft.com/en-us/dotnet/api/system.string 'System\.String')[>](https://learn.microsoft.com/en-us/dotnet/api/system.func-2 'System\.Func\`2') -Produces this member's value from a seeded `Bogus.Faker`\. The `Bogus.Faker` -instance is reused across later, unrelated requests on the same thread \(per -[BogusMemberNameProvider](Compono.BogusMemberNameProvider.md 'Compono\.BogusMemberNameProvider')'s own performance design\) \- always freshly reseeded -before [generate](Compono.BogusOptions.AddConvention(string,System.Func_Bogus.Faker,string_).md#Compono.BogusOptions.AddConvention(string,System.Func_Bogus.Faker,string_).generate 'Compono\.BogusOptions\.AddConvention\(string, System\.Func\\)\.generate') is called, so read its randomness normally, but never -retain the instance past this call or rely on any state it carries beyond -`Faker.Random`\. +Produces this member's value from a seeded `Bogus.Faker`\. Unlike the built\-in +conventions, this `Bogus.Faker` instance is fresh and single\-use \- never reused +across requests, and never shared with any other convention \- since +[BogusMemberNameProvider](Compono.BogusMemberNameProvider.md 'Compono\.BogusMemberNameProvider') can't guarantee an arbitrary delegate won't mutate +state \(`DateTimeReference`, a sub\-generator, or any other of `Bogus.Faker`'s +public settable properties\) that would otherwise leak into a later, unrelated request\. +Still fine to read its randomness normally \- it's freshly reseeded for this call \- just +don't retain the instance past this call, since nothing else will ever see the same one\. #### Exceptions diff --git a/docs/roadmap/future-packages.md b/docs/roadmap/future-packages.md index 7ebb6cc..a1e8638 100644 --- a/docs/roadmap/future-packages.md +++ b/docs/roadmap/future-packages.md @@ -1,9 +1,15 @@ # Future Packages -Compono's MVP package set — `Compono`, `Compono.Generators`, -`Compono.XunitV3`, `Compono.NSubstitute`, and `Compono.Bogus` — is fully -shipped as of this milestone (see [Package Guides](../packages/index.md)). -No additional package is currently committed via an `Accepted` ADR. +Compono's MVP package set is fully shipped as of this milestone (see +[Package Guides](../packages/index.md)): four independently installable +packages — `Compono`, `Compono.XunitV3`, `Compono.NSubstitute`, and +`Compono.Bogus` — plus `Compono.Generators`, which is not a fifth +installable package at all. It's `IsPackable=false` +([ADR-0003](../adr/0003-generator-package-distribution.md)) and ships +embedded inside `Compono`'s own `.nupkg` as an analyzer +(`analyzers/dotnet/cs`) — a consumer never references it directly, and +it never appears on nuget.org on its own. No additional package is +currently committed via an `Accepted` ADR. ## Natural candidates, not yet designed diff --git a/src/Compono.Bogus/BogusConventions.cs b/src/Compono.Bogus/BogusConventions.cs index 6dceb5a..62ed4bb 100644 --- a/src/Compono.Bogus/BogusConventions.cs +++ b/src/Compono.Bogus/BogusConventions.cs @@ -36,9 +36,24 @@ private static readonly (string Name, BogusConvention Convention, Func> ByConventionCore = Entries.ToFrozenDictionary(entry => entry.Convention, entry => entry.Generate); + // Reference-equality set of the ten built-in delegate instances above - AddAlias resolves to + // one of these exact delegates (BogusConventions.ByConvention[target]), so an alias counts as + // "built-in" here too, correctly, without needing its own separate tracking. + private static readonly FrozenSet> BuiltInDelegates = + Entries.Select(entry => entry.Generate).ToFrozenSet(); + /// Built-in name -> generator, for collision checks and the default lookup. internal static IReadOnlyDictionary> ByName => ByNameCore; /// Built-in convention -> generator, for resolving an alias's target. internal static IReadOnlyDictionary> ByConvention => ByConventionCore; + + /// + /// Whether is one of this package's own built-in convention + /// delegates (including one reached via an alias) - + /// uses this to decide whether a request can safely reuse its per-thread Faker (a + /// built-in delegate never mutates Faker state beyond Random) or needs its own + /// single-use instance (an arbitrary AddConvention delegate might). + /// + internal static bool IsBuiltIn(Func generate) => BuiltInDelegates.Contains(generate); } diff --git a/src/Compono.Bogus/BogusMemberNameProvider.cs b/src/Compono.Bogus/BogusMemberNameProvider.cs index 4c35084..dd26b53 100644 --- a/src/Compono.Bogus/BogusMemberNameProvider.cs +++ b/src/Compono.Bogus/BogusMemberNameProvider.cs @@ -12,6 +12,8 @@ namespace Compono; /// public sealed class BogusMemberNameProvider : ICompositionValueProvider { + private readonly string _locale; + // Immutable, built once - a FrozenDictionary, not a plain Dictionary, since this is a fixed, // read-only lookup for the lifetime of this provider instance. Exact match, case-sensitive, // against CompositionProviderRequest.Name only - no substring/prefix/fuzzy matching, and no @@ -19,16 +21,19 @@ public sealed class BogusMemberNameProvider : ICompositionValueProvider // callout) is deliberately absent from the built-in allowlist this always contains at minimum. private readonly FrozenDictionary> _conventions; - // One Faker per thread, reused across every request that thread handles, reseeded immediately - // before each use - never a request-lifetime instance (constructing Faker's full set of - // category generators is genuinely expensive, per ADR-0034's benchmark suite), and never a - // single instance shared across threads (the exact hazard ADR-0027's Amendment distinguishes - // this from - see that Amendment for why per-thread reuse doesn't reintroduce the - // shared-mutable-state race a package-lifetime-shared Faker would). + // One Faker per thread, reused only for built-in/alias conventions (ADR-0027's Amendment) - + // never for a custom AddConvention delegate, which gets its own single-use Faker instead (see + // TryProvide). Built-in conventions are Compono-authored and never touch Faker state beyond + // Random, so reuse is safe; an arbitrary custom delegate could reassign DateTimeReference, a + // sub-generator (Faker.Name, Faker.Address, ...), or any of Faker's other public settable + // properties, and that mutation would otherwise leak into every later request on the same + // thread if it shared this instance. Never shared across threads either - a thread-local + // instance is never touched by more than one thread, which is what makes reuse safe under + // concurrent composition in the first place. private readonly ThreadLocal _threadFaker; /// Creates a using . - /// The Bogus locale each handled request's own uses. + /// The Bogus locale each handled request's uses. public BogusMemberNameProvider(string locale) : this(locale, BogusConventions.ByName) { @@ -45,6 +50,7 @@ internal BogusMemberNameProvider(string locale, IReadOnlyDictionary> ?? conventions.ToFrozenDictionary(); // trackAllValues: false - nothing here ever enumerates ThreadLocal.Values across @@ -66,12 +72,15 @@ public CompositionProviderResult TryProvide(in CompositionProviderRequest reques if (!_conventions.TryGetValue(name, out var generate)) return CompositionProviderResult.NotHandled; - // This thread's own Faker, reseeded from context.DeriveSeed() before every use - see - // _threadFaker's declaration comment and ADR-0027's Amendment for why reuse is safe here. - // ThreadLocal.Value is annotated T? in the BCL, but _threadFaker was constructed with a - // valueFactory (never the parameterless constructor), so every access - first or - // subsequent, on any thread - is guaranteed non-null by ThreadLocal's own contract. - var faker = _threadFaker.Value!; + // Built-in/alias conventions reuse this thread's cached Faker (fast path); an arbitrary + // custom AddConvention delegate gets a fresh, single-use Faker instead, since we can't + // guarantee it won't mutate state that would otherwise leak into a later, unrelated + // request on the same thread - see _threadFaker's declaration comment and ADR-0027's + // Amendment. ThreadLocal.Value is annotated T? in the BCL, but _threadFaker was + // constructed with a valueFactory (never the parameterless constructor), so every access - + // first or subsequent, on any thread - is guaranteed non-null by ThreadLocal's own + // contract. + var faker = BogusConventions.IsBuiltIn(generate) ? _threadFaker.Value! : new Faker(_locale); faker.Random = new Randomizer(context.DeriveSeed()); return CompositionProviderResult.Handled(generate(faker)); } diff --git a/src/Compono.Bogus/BogusOptions.cs b/src/Compono.Bogus/BogusOptions.cs index 4d3b7c4..2eae228 100644 --- a/src/Compono.Bogus/BogusOptions.cs +++ b/src/Compono.Bogus/BogusOptions.cs @@ -70,12 +70,14 @@ public void AddAlias(string aliasName, BogusConvention target) /// /// The exact member name to match. /// - /// Produces this member's value from a seeded . The - /// instance is reused across later, unrelated requests on the same thread (per - /// 's own performance design) - always freshly reseeded - /// before is called, so read its randomness normally, but never - /// retain the instance past this call or rely on any state it carries beyond - /// Faker.Random. + /// Produces this member's value from a seeded . Unlike the built-in + /// conventions, this instance is fresh and single-use - never reused + /// across requests, and never shared with any other convention - since + /// can't guarantee an arbitrary delegate won't mutate + /// state (DateTimeReference, a sub-generator, or any other of 's + /// public settable properties) that would otherwise leak into a later, unrelated request. + /// Still fine to read its randomness normally - it's freshly reseeded for this call - just + /// don't retain the instance past this call, since nothing else will ever see the same one. /// /// /// or is . diff --git a/test/Compono.Bogus.Tests/DeterminismTests.cs b/test/Compono.Bogus.Tests/DeterminismTests.cs index 94dfca4..d2a1b47 100644 --- a/test/Compono.Bogus.Tests/DeterminismTests.cs +++ b/test/Compono.Bogus.Tests/DeterminismTests.cs @@ -1,4 +1,5 @@ using System.Collections.Concurrent; +using Bogus; namespace Compono.Bogus.Tests; @@ -90,35 +91,49 @@ public void CreateMany_ProducesIndependentlySeededItems_ForAUseBogusOfTRegistere } [Fact] - public async Task TryProvide_ProducesCorrectValues_WhenCalledConcurrently_OnOneSharedProviderInstance() + public void TryProvide_ProducesCorrectValues_WhenCalledConcurrently_OnOneSharedProviderInstance() { // BogusMemberNameProvider caches one Faker per thread (ADR-0027's Amendment) instead of // constructing one per request - this proves that reuse is actually safe under real - // concurrent access, not just assumed safe because no test caught a problem. One provider - // instance, shared across every concurrent composition below (the scenario ADR-0027's - // original "package-lifetime-shared instance" concern is about), driven by - // Parallel.ForEachAsync so calls genuinely overlap across the thread pool rather than - // running sequentially one at a time. + // concurrent access, not just assumed safe because no test caught a problem. + // + // A Parallel.ForEachAsync-driven version of this test was tried first, but its per-item + // body had no genuine await point (the composition call is fully synchronous), so nothing + // guaranteed the runtime actually dispatched work across multiple threads at the same + // instant rather than running items back-to-back - a shared-Faker race could pass this + // test serially, purely by luck of the scheduler. Real System.Threading.Thread instances + // plus a Barrier remove that luck entirely: every worker blocks at the barrier until all + // of them have arrived, then releases simultaneously, so the composition calls below are + // guaranteed to genuinely overlap in wall-clock time on one shared provider instance. + const int workerCount = 16; var provider = new BogusMemberNameProvider("en"); + var barrier = new Barrier(workerCount); var results = new ConcurrentBag<(int Seed, string Value)>(); - await Parallel.ForEachAsync(Enumerable.Range(0, 200), async (seed, _) => - { - var value = Composer.Create(builder => builder.WithSeed(seed).AddSemanticProvider(provider)) - .CreateRow(typeof(DeterminismTests)) - .Resolve(EmailDescriptor); + var workers = Enumerable.Range(0, workerCount) + .Select(seed => new Thread(() => + { + barrier.SignalAndWait(); + var value = Composer.Create(b => b.WithSeed(seed).AddSemanticProvider(provider)) + .CreateRow(typeof(DeterminismTests)) + .Resolve(EmailDescriptor); + + results.Add((seed, value)); + })) + .ToArray(); - results.Add((seed, value)); - await Task.CompletedTask; - }); + foreach (var worker in workers) + worker.Start(); + foreach (var worker in workers) + worker.Join(); // Each seed's concurrently-produced value must match what a fresh, independent, // single-threaded resolution for that same seed produces - proving no cross-call state // leaked between threads sharing the reused Faker, not just that nothing threw. - results.Should().HaveCount(200); + results.Should().HaveCount(workerCount); foreach (var (seed, value) in results) { - var expected = Composer.Create(builder => builder.WithSeed(seed).AddSemanticProvider(new BogusMemberNameProvider("en"))) + var expected = Composer.Create(b => b.WithSeed(seed).AddSemanticProvider(new BogusMemberNameProvider("en"))) .CreateRow(typeof(DeterminismTests)) .Resolve(EmailDescriptor); @@ -126,6 +141,47 @@ await Parallel.ForEachAsync(Enumerable.Range(0, 200), async (seed, _) => } } + [Fact] + public void CustomConventionMutatingFakerState_DoesNotPerturbALaterBuiltInRequestOnTheSameThread() + { + // A custom AddConvention delegate that reassigns Faker.DateTimeReference (a completely + // ordinary thing to do - not a hostile/adversarial delegate) must not leave that mutation + // behind for a later, unrelated built-in-convention request to observe. Built-in + // conventions reuse the thread's cached Faker (BogusConventions.IsBuiltIn); custom + // conventions get their own single-use Faker instead, specifically so this can't happen - + // see ADR-0027's Amendment. + var conventions = new Dictionary> + { + ["Custom"] = faker => + { + faker.DateTimeReference = new DateTime(2000, 1, 1, 0, 0, 0, DateTimeKind.Utc); + return faker.Date.Recent().ToString("O"); + }, + ["Email"] = f => f.Internet.Email(), + }; + var provider = new BogusMemberNameProvider("en", conventions); + + var customDescriptor = new CompositionRequestDescriptor( + CompositionRequestKind.ConstructorParameter, ordinal: 0, "Custom", declaringType: null, Nullability.NotNullable); + + Composer.Create(b => b.WithSeed(4219).AddSemanticProvider(provider)) + .CreateRow(typeof(DeterminismTests)) + .Resolve(customDescriptor); + + // Same provider instance (so the same thread's cached Faker, if a built-in request were + // ever to touch the same instance the custom one just mutated) resolves Email afterward - + // must match an independently-computed reference value exactly, proving the custom + // convention's DateTimeReference mutation never reached the built-in convention's Faker. + var afterCustom = Composer.Create(b => b.WithSeed(4219).AddSemanticProvider(provider)) + .CreateRow(typeof(DeterminismTests)) + .Resolve(EmailDescriptor); + var expected = Composer.Create(b => b.WithSeed(4219).AddSemanticProvider(new BogusMemberNameProvider("en", conventions))) + .CreateRow(typeof(DeterminismTests)) + .Resolve(EmailDescriptor); + + afterCustom.Should().Be(expected); + } + private static CompositionRequestDescriptor EmailDescriptor => new(CompositionRequestKind.ConstructorParameter, ordinal: 0, "Email", declaringType: null, Nullability.NotNullable); From 732dc1fac311d7687162d3919930e66d3a51f758 Mon Sep 17 00:00:00 2001 From: Nick Cipollina Date: Thu, 6 Aug 2026 20:13:12 -0400 Subject: [PATCH 5/5] fix: use true reference equality for built-in Bogus delegate check, soften barrier overlap claim BuiltInDelegates used FrozenSet's default comparer, which for Func compares Method+Target, not object identity - the trust boundary needs literal reference identity, so pass ReferenceEqualityComparer.Instance explicitly. Also fix the concurrency test's comment, which overstated what Barrier.SignalAndWait() guarantees (coordinated start, not literal same-instant execution), and widen the contention window with repeated resolutions per worker instead of one. Co-Authored-By: Claude Sonnet 5 --- src/Compono.Bogus/BogusConventions.cs | 11 ++++++++-- test/Compono.Bogus.Tests/DeterminismTests.cs | 21 ++++++++++++++------ 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/src/Compono.Bogus/BogusConventions.cs b/src/Compono.Bogus/BogusConventions.cs index 62ed4bb..baafdea 100644 --- a/src/Compono.Bogus/BogusConventions.cs +++ b/src/Compono.Bogus/BogusConventions.cs @@ -38,9 +38,16 @@ private static readonly (string Name, BogusConvention Convention, Func would otherwise + // use - Delegate.Equals compares Method+Target, not object identity, so two independently + // written (even textually-identical) lambdas are never equal by it, but relying on that + // distinction rather than requiring literal reference identity would be the wrong contract + // for a trust boundary this load-bearing: the only delegates that should ever count as + // "built-in" are the exact ten instances this class itself constructed, never a + // coincidentally-equivalent one from anywhere else. private static readonly FrozenSet> BuiltInDelegates = - Entries.Select(entry => entry.Generate).ToFrozenSet(); + Entries.Select(static entry => entry.Generate).ToFrozenSet>(ReferenceEqualityComparer.Instance); /// Built-in name -> generator, for collision checks and the default lookup. internal static IReadOnlyDictionary> ByName => ByNameCore; diff --git a/test/Compono.Bogus.Tests/DeterminismTests.cs b/test/Compono.Bogus.Tests/DeterminismTests.cs index d2a1b47..388c76f 100644 --- a/test/Compono.Bogus.Tests/DeterminismTests.cs +++ b/test/Compono.Bogus.Tests/DeterminismTests.cs @@ -102,10 +102,14 @@ public void TryProvide_ProducesCorrectValues_WhenCalledConcurrently_OnOneSharedP // guaranteed the runtime actually dispatched work across multiple threads at the same // instant rather than running items back-to-back - a shared-Faker race could pass this // test serially, purely by luck of the scheduler. Real System.Threading.Thread instances - // plus a Barrier remove that luck entirely: every worker blocks at the barrier until all - // of them have arrived, then releases simultaneously, so the composition calls below are - // guaranteed to genuinely overlap in wall-clock time on one shared provider instance. + // plus a Barrier fix that: every worker blocks until all of them have arrived, then all + // release together, so the workers start from a coordinated point instead of trickling in + // one by one at the scheduler's discretion. That's a stronger starting condition, not a + // guarantee that any two threads execute at the literal same instant thereafter - so each + // worker also does many resolutions back to back (not just one) to keep genuine contention + // on the shared provider's cached Faker for a sustained window, not one narrow instant. const int workerCount = 16; + const int iterationsPerWorker = 100; var provider = new BogusMemberNameProvider("en"); var barrier = new Barrier(workerCount); var results = new ConcurrentBag<(int Seed, string Value)>(); @@ -114,9 +118,14 @@ public void TryProvide_ProducesCorrectValues_WhenCalledConcurrently_OnOneSharedP .Select(seed => new Thread(() => { barrier.SignalAndWait(); - var value = Composer.Create(b => b.WithSeed(seed).AddSemanticProvider(provider)) - .CreateRow(typeof(DeterminismTests)) - .Resolve(EmailDescriptor); + + string value = null!; + for (var i = 0; i < iterationsPerWorker; i++) + { + value = Composer.Create(b => b.WithSeed(seed).AddSemanticProvider(provider)) + .CreateRow(typeof(DeterminismTests)) + .Resolve(EmailDescriptor); + } results.Add((seed, value)); }))