From 5a6fddb31b0e41a21bab3954a164ee8f88f0b164 Mon Sep 17 00:00:00 2001 From: Nick Cipollina Date: Fri, 31 Jul 2026 22:32:49 -0400 Subject: [PATCH 1/2] feat(core): milestone 6 phase 0 - deterministic seed derivation engine wiring Records the Milestone 6 (Bogus integration) design: ADR-0026 (core ICompositionContext.DeriveSeed() capability), ADR-0027 (Compono.Bogus package design), and PLAN-0006 tracking both. Implements ADR-0026's Phase 0 scope - DeriveSeed() gives a public provider or a registration/configuration-rule factory an on-demand, path-derived deterministic seed (reusing ADR-0012's existing FNV-1a fork-key mechanism with a distinct salt) without exposing IRandomSource or path internals, closing the one real gap Milestone 5's NSubstitute integration never needed: nothing exposed deterministic randomness to a provider/factory before this. Co-Authored-By: Claude Sonnet 5 --- ...rministic-seed-derivation-for-providers.md | 262 +++++++++ docs/adr/0027-compono-bogus-package-design.md | 509 ++++++++++++++++++ docs/adr/README.md | 2 + docs/architecture.md | 49 +- docs/mvp.md | 39 +- .../0006-milestone-6-bogus-integration.md | 352 ++++++++++++ docs/plans/README.md | 1 + docs/public-api.md | 100 +++- src/Compono/CompositionContext.cs | 22 + src/Compono/CompositionRow.cs | 3 + src/Compono/ICompositionContext.cs | 19 + src/Compono/IRandomSource.cs | 10 + src/Compono/RandomSource.cs | 8 + test/Compono.Tests/DeriveSeedTests.cs | 124 +++++ .../Compono.Tests/UniqueValueResolverTests.cs | 2 + 15 files changed, 1476 insertions(+), 26 deletions(-) create mode 100644 docs/adr/0026-deterministic-seed-derivation-for-providers.md create mode 100644 docs/adr/0027-compono-bogus-package-design.md create mode 100644 docs/plans/0006-milestone-6-bogus-integration.md create mode 100644 test/Compono.Tests/DeriveSeedTests.cs diff --git a/docs/adr/0026-deterministic-seed-derivation-for-providers.md b/docs/adr/0026-deterministic-seed-derivation-for-providers.md new file mode 100644 index 0000000..1cbf812 --- /dev/null +++ b/docs/adr/0026-deterministic-seed-derivation-for-providers.md @@ -0,0 +1,262 @@ +# [ADR-0026] Deterministic Seed Derivation for Providers and Registration Factories + +**Status:** Accepted + +**Date:** 2026-07-31 + +**Decision Makers:** Nick Cipollina, Claude (design review) + +## Context + +[ADR-0024](0024-public-provider-extensibility-model.md) gave stage 5/6 a public +extension point (`ICompositionValueProvider`), but its first real consumer +(`Compono.NSubstitute`, [ADR-0025](0025-compono-nsubstitute-package-design.md)) +never needed randomness — a substitute has no "value" to generate, just a proxy to +construct. Milestone 6 (`Compono.Bogus`) is the first consumer that genuinely does: +every semantic value it produces (a first name, an email, a whole `Faker`- +generated object) has to come from *somewhere* random, and `docs/mvp.md`'s MVP +success criterion #5 ("a failure produces a readable dependency path and +reproducible seed") only holds if that randomness is deterministic and reproducible +from `Composer`'s own root seed — exactly like every other value the engine +produces. + +[ADR-0012](0012-composition-path-identity-and-deterministic-random-forking.md) +already solved this problem once, for the engine's own internal providers +(`PrimitiveValueProvider` et al.): a structured `CompositionPath` is hashed +(FNV-1a, never a formatted string) into a fork key, so two requests never collide +and renaming a parameter/member (with no reordering) never changes its derived +value. That mechanism is internal — `IRandomSource`, `CompositionPath`, and the +forking logic all live inside `Compono` and are not exposed through +`ICompositionContext`, which today only offers `Resolve(descriptor)` and the +descriptor-less `Resolve()` ([ADR-0019](0019-registrations-and-service-provider-injection.md)). +Neither a public provider nor a registration/rule factory has any way to ask for +its own deterministic randomness — the exact gap Milestone 6's design review +surfaced as the first question to resolve, before any Bogus-specific design could +proceed. + +This ADR is scoped to the mechanism only, mirroring +[ADR-0024](0024-public-provider-extensibility-model.md)'s own split from +[ADR-0025](0025-compono-nsubstitute-package-design.md): a core capability any +future provider or factory can use, not `Compono.Bogus`-specific. +[ADR-0027](0027-compono-bogus-package-design.md) is the first real consumer. + +## Decision Drivers + +- ADR-0012's path-independence guarantee ("renaming an unrelated member never + changes another member's derived value," "adding an unrelated member doesn't + perturb existing values") must extend to provider/factory-generated randomness, + not stay a guarantee only the engine's own built-in providers get. +- Explicit user direction: do **not** expose `IRandomSource`, mutable random + state, or `CompositionPath`/path internals through the public contract. The + public surface is a deterministic **seed** (an `int`), not a random-value- + generation service — the smallest capability that unblocks a consumer able to + seed its own PRNG (Bogus's `Randomizer`, or anything else). +- `design-decisions.md` rule 3: this capability must be generic — usable by any + future provider or factory, not shaped around Bogus's own vocabulary. Nothing + in this ADR mentions `Faker`, `Randomizer`, or any Bogus type. +- Zero new pipeline mechanism, zero new allocation on the hot path for a caller + that never calls it — `PrimitiveValueProvider`'s existing fork-key derivation + already exists; this ADR exposes it, it doesn't reinvent it. +- Statelessness: calling the new method twice for the same active request must + return the same value (idempotent), and must never advance or mutate the + engine's own internal random stream — a caller reading a seed must not be able + to perturb an unrelated built-in value derived from the same path. + +## Considered Options + +1. **A public method on `ICompositionContext`, `int DeriveSeed()`** — callable + only while a request is actively being resolved (mid-`TryProvide` for a + provider, mid-factory for a registration/rule), returning a value derived + purely from the root seed and the current request's path, via the same + FNV-1a hash [ADR-0012](0012-composition-path-identity-and-deterministic-random-forking.md) + already uses for fork-key derivation. No stream, no mutable state, no + `IRandomSource` exposure. +2. **An eager field on `CompositionProviderRequest`** — `ADR-0024`'s public + request struct grows a `Seed` field, computed by `PublicProviderAdapter` for + every stage-5/6 request regardless of whether the provider reads it. +3. **Expose `IRandomSource` (or a public wrapper around it) directly** — give a + provider/factory the engine's own forkable random stream, not just a seed. + +## Decision Outcome + +**Chosen: Option 1** — confirmed directly with the user, who explicitly rejected +exposing `IRandomSource`/path internals (Option 3) and explicitly preferred a +method a caller invokes on demand over an eagerly-computed field every request +carries whether used or not (Option 2 — rejected because most stage-5/6 providers, +including `NSubstituteProvider`, never need randomness at all, and stamping every +request with a derived seed would blur `CompositionProviderRequest`'s existing role +as plain descriptive metadata with real per-request computation cost). + +```csharp +public interface ICompositionContext +{ + T Resolve(in CompositionRequestDescriptor descriptor); + T Resolve(); + + /// + /// Derives a deterministic seed from this context's root seed and the + /// request currently being resolved — usable to seed a caller-owned PRNG (e.g. a + /// Bogus.Randomizer) without exposing the engine's own internal random source or path + /// representation. The same root seed and the same request path always derive the same value; + /// a different path (a different member, a different constructor parameter, a different + /// element of a collection) always derives independently. Calling this method repeatedly for + /// the same active request returns the same value every time — it does not advance any + /// stream, and does not perturb any other value's own derivation. + /// + /// + /// No request is currently being resolved on this context — the same misuse this interface's + /// existing descriptor-less overload already guards against. + /// + int DeriveSeed(); +} +``` + +### Where it's callable from + +`DeriveSeed()` is valid exactly where the descriptor-less `Resolve()` already is +— mid-invocation of whichever context-owned mechanism is actively resolving one +request: + +- **A public provider's `TryProvide`** (stage 5/6, [ADR-0024](0024-public-provider-extensibility-model.md)) — + the primary motivating caller. `CompositionContext.InvokeProvider` + ([ADR-0024 Amendment 1](0024-public-provider-extensibility-model.md#amendment-1-2026-07-31-nested-resolution-and-self-recursion-fixed-before-any-external-consumer-existed)) + already tracks which request a provider invocation is resolving; `DeriveSeed()` + reads that same request's path. +- **A registration factory** (`Register(context => ...)`, stage 3, + [ADR-0019](0019-registrations-and-service-provider-injection.md)) or a + **configuration-rule factory** (`.For().Use(context => ...)`/`.Member(...).Use(...)`, + stage 4, [ADR-0020](0020-composition-configuration-rules.md)) — `InvokeFactory` + already pushes a frame around exactly this call; `DeriveSeed()` reads the + request that triggered the factory, not the factory's own internal + manual-resolve ordinal counter (which is a separate, pre-existing mechanism for + the factory's own *nested* `context.Resolve()` calls — unrelated to this + method). +- **Outside any active request** (a context reference that escaped its call and + is invoked later, or called against a context not currently dispatching + anything) — throws `InvalidOperationException`, identical framing to + `Resolve()`'s existing guard for the same misuse shape. + +### Derivation + +No new hashing mechanism — `DeriveSeed()` reuses +[ADR-0012](0012-composition-path-identity-and-deterministic-random-forking.md)'s +existing FNV-1a path-hash exactly as `PrimitiveValueProvider` already does +internally, with one difference: it hashes with a distinct, fixed salt/namespace +value from whatever key the engine's own internal forking uses for the same path, +so a public caller's derived seed is never accidentally identical to (or +correlated with) a value the engine itself might separately derive from the same +path for its own purposes. This costs one extra hash input, not a new algorithm. +The result is a pure function of `(root seed, current request path, this fixed +salt)` — no `IRandomSource` instance is constructed, read, or advanced, which is +what makes repeated calls idempotent and non-perturbing by construction rather +than by convention. + +### Interaction with existing mechanisms + +- **Path-independence.** A `DeriveSeed()`-backed value inherits ADR-0012's + guarantee directly, for free: it's derived from the exact same structured path + representation, so the same rename-without-reorder / add-an-unrelated-member + stability properties apply to it as to any built-in value. +- **Recursion / manual-resolve frames.** Unrelated. `DeriveSeed()` reads path + state already tracked for diagnostics/forking; it doesn't push, pop, or + interact with the active-construction-frame or manual-resolve-frame stacks at + all. +- **Concurrency.** `Composer`'s configuration (and therefore any `Composer`-served + composition) already supports concurrent `Create()`/`CreateMany()`/`CreateRow` + calls, each with its own `CompositionContext`. `DeriveSeed()` reads only that + one context's own root seed and current path — both already per-context, + immutable-after-construction state — so it introduces no new shared mutable + state and needs no locking beyond what already protects the rest of the + context. +- **Diagnostics.** Not affected — `DeriveSeed()` produces a value for a caller to + consume, not a diagnosable outcome of its own; a failure downstream (e.g. a + provider that calls it and then still can't produce a value) surfaces through + the same existing failure path as any other provider decline. + +### Package Boundaries + +Defined in core `Compono`, on the already-public `ICompositionContext` — no new +type, no new package dependency, and (per the Decision Drivers above) no +Bogus-specific vocabulary anywhere in this ADR's contract. `Compono.NSubstitute` +is unaffected; it never calls this method (its provider needs no randomness, per +[ADR-0025](0025-compono-nsubstitute-package-design.md)'s explicit non-goal). + +### Positive Consequences + +- Closes the exact gap Milestone 6's design review identified: a provider or + factory can now get deterministic, path-independent randomness without the + public contract exposing `IRandomSource` or any engine-internal type. +- Reuses ADR-0012's hashing mechanism entirely — no new algorithm, no new + performance characteristic to benchmark separately from what + `PrimitiveValueProvider` already pays. +- Zero cost for any caller that never invokes it — the method is on-demand, not + an eagerly-computed field every stage-5/6 request now carries. +- Generic: usable by `Compono.Bogus` (ADR-0027) and by any future public provider + or factory that needs deterministic randomness — not a special case wired only + for Bogus. + +### Negative Consequences + +- One more public method on `ICompositionContext`, an interface generated code + already crosses the assembly boundary to call — accepted as the smallest + addition that satisfies the Decision Drivers; the alternative (Option 3) costs + strictly more public surface for a capability nothing in this milestone's scope + needs. +- A caller that captures a `Faker`/PRNG seeded once via `DeriveSeed()` and then + reuses it across *multiple* requests (rather than deriving fresh state per + request) can silently reintroduce path-dependence on its own — this ADR + guarantees the *seed* is independent per path; a consumer of that seed is still + responsible for actually using it per-request, not caching it across requests. + [ADR-0027](0027-compono-bogus-package-design.md) follows this correctly (a + fresh `Faker`/`Randomizer` per handled request), noted here so the constraint + is visible at the mechanism layer too. + +## Pros and Cons of the Options + +### A public `DeriveSeed()` method (chosen) + +- Good, because it costs nothing for a caller that never uses it. +- Good, because it keeps `CompositionProviderRequest` a plain, cheap descriptor + with no per-request computation baked in. +- Good, because "ask the context for a capability" reads more clearly than + "read a field that happens to already be populated." +- Bad, because it's one more method on an already-public interface — accepted, + see Negative Consequences. + +### An eager `CompositionProviderRequest.Seed` field + +- Good, because it needs no new interface method — the value is just already + there. +- Bad, because it computes a fork-hash for every stage-5/6 request regardless of + whether any registered provider actually reads it — real, unconditional cost + for a value most providers (all of Milestone 5's `NSubstituteProvider`) never + touch. +- Bad, because it blurs `CompositionProviderRequest`'s existing role as + descriptive metadata (`RequestedType`/`DeclaringType`/`Name`/`Nullability`) with + a computed capability — a field that looks like data but is secretly doing + work every time a request is constructed. + +### Exposing `IRandomSource` (or a public wrapper) directly + +- Bad, because it's exactly what the user explicitly ruled out: the internal + random-source abstraction should remain free to evolve independently of the + public provider model, per [ADR-0024](0024-public-provider-extensibility-model.md)'s + own precedent of keeping engine-internal types internal. +- Bad, because a stream-based contract (draw the next value) is a stronger, + stateful promise than a caller in this milestone's scope actually needs — a + seed is sufficient to construct an independent PRNG (Bogus's `Randomizer`) on + the caller's own side. + +## Links + +- [ADR-0012](0012-composition-path-identity-and-deterministic-random-forking.md) — + the path-hashing mechanism this ADR exposes a narrow slice of +- [ADR-0019](0019-registrations-and-service-provider-injection.md) — the + manual-resolve frame / descriptor-less `Resolve()` precedent this ADR's + "callable only mid-request" framing follows +- [ADR-0024](0024-public-provider-extensibility-model.md) — the public provider + contract this ADR's primary motivating caller (`TryProvide`) belongs to; + `InvokeProvider`'s Amendment 1 fix, which this ADR's request-tracking builds on + directly +- [ADR-0027](0027-compono-bogus-package-design.md) — `Compono.Bogus`, the first + real consumer of this capability diff --git a/docs/adr/0027-compono-bogus-package-design.md b/docs/adr/0027-compono-bogus-package-design.md new file mode 100644 index 0000000..9d03feb --- /dev/null +++ b/docs/adr/0027-compono-bogus-package-design.md @@ -0,0 +1,509 @@ +# [ADR-0027] Compono.Bogus Package Design + +**Status:** Accepted + +**Date:** 2026-07-31 + +**Decision Makers:** Nick Cipollina, Claude (design review) + +## Context + +[ADR-0024](0024-public-provider-extensibility-model.md) settled the mechanism a +stage-5/6 package plugs into, and validated (by sketch, not commitment) that it +would hold up for Bogus. [ADR-0026](0026-deterministic-seed-derivation-for-providers.md) +closed the one real gap that sketch exposed: nothing gave a provider or factory +deterministic randomness. This ADR turns `docs/mvp.md`'s Milestone 6 scope +("Semantic value-provider contract, Shared deterministic seed, Bogus `Faker` +access, Locale configuration, Conservative member-name conventions, Explicit +member rules, Initial correlated-value experiment") into a real design, and +corrects `docs/public-api.md`'s existing Bogus Integration sketch, which predates +both ADR-0024 and ADR-0026 and shows a `context.Semantic.Email()` accessor that +would require core `ICompositionContext` to carry Bogus-shaped vocabulary — ruled +out during this milestone's design review (`design-decisions.md` rule 3). + +`Compono.Bogus` ends up with **three** distinct, independently useful +customization models, not one: + +1. A built-in, conservative **member-name convention provider** (stage 5) — the + "just call `UseBogus()` and get realistic values" default case. +2. An explicit **member-level rule** — `.For().Member(x => x.Y).UseBogus(faker => ...)` + (stage 4) — for a specific member the conventions don't (or shouldn't) guess. +3. An explicit **whole-object `Faker`** registration — `UseBogus(faker => faker.RuleFor(...))` + (compiles to stage 3) — for a type a consumer wants Bogus to construct + entirely, correlated rules and all, instead of the generated constructor plan. + +All three share one deterministic-randomness story (`DeriveSeed()`, ADR-0026) and +one coexistence story with `Compono.NSubstitute` (disjoint type claims — see +below) — this ADR designs all three together since they're one package's public +surface, but keeps them conceptually separate rather than forcing them into a +single mechanism. + +## Decision Drivers + +- `docs/mvp.md`'s explicit caution: "Ambiguous member names such as `Name` should + not be guessed aggressively" — the convention provider's allowlist must be + small, exact-match, and type-checked, never fuzzy/substring matching. +- `docs/mvp.md`'s MVP success criterion #7 / `design-decisions.md` rule 3: the + core `Compono` package must never reference or know about Bogus. Every design + choice below routes through core capabilities that already exist (stage 5 + providers, stage 4 rules, stage 3 registrations, `DeriveSeed()`) rather than + asking core for anything Bogus-shaped. +- Coexistence with `Compono.NSubstitute`, without either package depending on the + other: `docs/architecture.md`'s Resolution Pipeline already fixes stage 5 + (semantic) before stage 6 (test-double), and a request each package's provider + can plausibly both want to claim must not arise. This ADR achieves that by + construction — see Coexistence with `Compono.NSubstitute`, below — not by + either provider special-casing the other. +- Determinism (ADR-0026): every Bogus-produced value must be reproducible from + the same root seed, independent of unrelated members added elsewhere in the + graph, and safe under concurrent composition (no shared mutable `Faker`/ + `Randomizer` state across requests). +- `docs/mvp.md`'s explicit non-goal framing (mirroring ADR-0025's own "no + recursive auto-configuration"/"no framework API wrappers" restraint): this + package activates and configures Bogus; it does not re-expose or wrap Bogus's + own `Faker`/`Faker` API surface beyond what activation requires. + +## Considered Options + +### Whole-object correlated values: `.DependsOn(...)` vs. `Faker` + +1. **Design a new Compono-native member-dependency mechanism** — + `.For().Member(x => x.Email).DependsOn(x => x.FirstName, x => x.LastName).UseBogus((faker, first, last) => ...)`, + requiring new core semantics: member composition ordering, retaining + already-composed sibling values, dependency-cycle detection, and interaction + with constructor-parameter/required-member composition order. +2. **Use Bogus's own `Faker` for the correlated case**, which already threads + the partially-populated object into a later `RuleFor` callback + (`.RuleFor(x => x.Email, (f, x) => f.Internet.Email(x.FirstName, x.LastName))`) + — no new Compono mechanism at all, since `Faker` already solves exactly + this problem inside Bogus itself. + +### Whole-object `Faker` registration: new pipeline concept vs. sugar over `Register` + +1. **A first-class `UseBogus(...)` API that is purely ergonomic sugar over + the existing stage-3 `Register(Func)` + registration mechanism, with no hidden pipeline stage and no special + runtime behavior of its own** — no new pipeline stage, no new + conflict-detection rule; duplicate registration for the same `T` is the + same build-time `CompositionConfigurationException` `Register` already + produces. +2. **Document the raw `Register(context => faker.UseSeed(context.DeriveSeed()).Generate())` + pattern only, with no package-owned convenience API at all.** + +### Locale coupling between package-wide activation and `UseBogus()` + +1. **Fully independent** — `UseBogus()` takes its own optional locale (or + defaults to Bogus's own default), with no dependency on whether/how + `UseBogus()`/`UseBogus(options => ...)` was called elsewhere in the same + configuration. +2. **`UseBogus()` requires prior `UseBogus()` activation** and reads the + shared `BogusOptions.Locale`, making the two calls order-dependent. + +## Decision Outcome + +**Chosen: Option 2 (`Faker` for correlated values, not `.DependsOn(...)`), +Option 1 (`UseBogus()` as sugar over `Register`), Option 1 (fully +independent locale)** — all three confirmed directly with the user during +design review. + +**When to reach for each model** — the three are complementary, not competing, +and a real profile typically uses more than one at once (see `docs/public-api.md`'s +Bogus Integration section for the worked example): + +- **`UseBogus()`** — project-wide conventions: "most `FirstName`/`Email`/etc. + members across the whole 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 — an + ambiguous name, a domain-specific format, a member the convention provider + would otherwise miss entirely. +- **`UseBogus()`** — a type's values are meaningfully correlated with each + other (an email derived from a name, a full address whose fields agree) and + the whole object is more naturally described as "Bogus owns this type" than + as several independent member rules. + +### Model 1: Conservative member-name conventions (stage 5) + +```csharp +public sealed class BogusOptions +{ + /// + /// The Bogus locale used by the package-wide member-name convention provider + /// () only. UseBogus<T>() is independent of this + /// option and does not read it — it defaults to "en" on its own, or takes an explicit + /// locale parameter. Defaults to Bogus's own default ("en"). + /// + public string Locale { get; set; } = "en"; + + /// Whether the conservative member-name convention provider is active. Defaults to . + public bool EnableMemberNameConventions { get; set; } = true; +} + +public sealed class BogusMemberNameProvider : ICompositionValueProvider +{ + private readonly string _locale; + + public BogusMemberNameProvider(string locale) => _locale = locale; + + public CompositionProviderResult TryProvide(in CompositionProviderRequest request, ICompositionContext context) + { + if (request.RequestedType != typeof(string) || request.Name is not { } name) + return CompositionProviderResult.NotHandled; + + if (!Conventions.TryGetValue(name, out var generate)) + return CompositionProviderResult.NotHandled; + + var faker = new Faker(_locale) { Random = new Randomizer(context.DeriveSeed()) }; + return CompositionProviderResult.Handled(generate(faker)); + } + + // Immutable, built once — a FrozenDictionary, not a plain Dictionary, since this is a + // fixed, read-only lookup table shared across every request this provider ever handles. + private static readonly FrozenDictionary> Conventions = + new Dictionary> + { + ["FirstName"] = f => f.Name.FirstName(), + ["LastName"] = f => f.Name.LastName(), + ["FullName"] = f => f.Name.FullName(), + ["Email"] = f => f.Internet.Email(), + ["PhoneNumber"] = f => f.Phone.PhoneNumber(), + ["StreetAddress"] = f => f.Address.StreetAddress(), + ["City"] = f => f.Address.City(), + ["State"] = f => f.Address.State(), + ["PostalCode"] = f => f.Address.ZipCode(), + ["CompanyName"] = f => f.Company.CompanyName(), + }.ToFrozenDictionary(); +} +``` + +- **Exact match only, case-sensitive, against `CompositionProviderRequest.Name`** + — no substring/prefix/fuzzy matching, no attempt at pluralization or synonym + handling. `Name` (ambiguous per `docs/mvp.md`'s own callout) is deliberately + absent from the allowlist. +- **Type-gated to `string` only** — `RequestedType != typeof(string)` declines + immediately. This is also what makes coexistence with `Compono.NSubstitute` + automatic (below): every convention in `docs/mvp.md`'s list produces a + `string`, so this provider can never claim an interface, delegate, or abstract + class — the exact request shapes `NSubstituteProvider` claims instead. +- **On by default** whenever `UseBogus()`/`UseBogus(options => ...)` is called — + matching `Compono.NSubstitute`'s own "substitutable by default, only the + narrower abstract-class case needs its own toggle" precedent. A consumer who + wants Bogus active only for explicit rules sets + `options.EnableMemberNameConventions = false`. +- **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). +- **Yields to everything earlier in the pipeline** — stage 3 (registrations), + stage 4 (configuration rules, including Model 2's own member-level `UseBogus(...)` + rule) both run before stage 5, so an explicit rule for the same member always + wins over the convention guess, with zero special-casing in this provider — + purely a consequence of `docs/architecture.md`'s already-fixed stage order. + +### Model 2: Explicit member-level rule (stage 4, sugar over `.Use(...)`) + +```csharp +namespace Compono; + +public static class MemberRuleExtensions +{ + extension(MemberRuleBuilder builder) + where TMember : notnull + { + public CompositionBuilder UseBogus(Func configure, string locale = "en") + { + ArgumentNullException.ThrowIfNull(configure); + return builder.Use(context => + { + var faker = new Faker(locale) { Random = new Randomizer(context.DeriveSeed()) }; + return configure(faker); + }); + } + } +} +``` + +(Exact generic member-rule-builder type name — `MemberRuleBuilder` +above — is illustrative, matching whatever +[ADR-0020](0020-composition-configuration-rules.md)'s `.For().Member(x => x.Y)` +chain actually returns; not a new type this ADR introduces.) + +- No `context.Semantic` accessor, no core change beyond + [ADR-0026](0026-deterministic-seed-derivation-for-providers.md)'s already-generic + `DeriveSeed()`. `Compono.Bogus` owns `Faker` construction, locale, and + `Randomizer` seeding entirely on its own side of the `.Use(context => ...)` + boundary [ADR-0020](0020-composition-configuration-rules.md) already + established for every other stage-4 rule. +- Compiles into an ordinary stage-4 rule, so it automatically wins over Model 1's + convention guess for the same member (member rules already take precedence + over type rules and, by pipeline order, over stage 5 entirely) and is itself + overridden by an exact stage-3 registration for the same type, exactly like + any other rule. +- Takes its own `locale` parameter (default `"en"`, matching Bogus's own + default) — deliberately independent of `BogusOptions.Locale`, consistent with + Model 3's own locale independence, below. + +### Model 3: Whole-object `Faker` registration (compiles to stage 3) + +```csharp +namespace Compono; + +public static class CompositionBuilderExtensions +{ + extension(CompositionBuilder builder) + { + public CompositionBuilder UseBogus() => builder.UseBogus(static _ => { }); + + public CompositionBuilder UseBogus(Action configure) + { + ArgumentNullException.ThrowIfNull(configure); + var options = new BogusOptions(); + configure(options); + if (options.EnableMemberNameConventions) + builder.AddSemanticProvider(new BogusMemberNameProvider(options.Locale)); + return builder; + } + + public CompositionBuilder UseBogus(Action> configureFaker) + where T : class => + builder.UseBogus("en", configureFaker); + + public CompositionBuilder UseBogus(string locale, Action> configureFaker) + where T : class + { + ArgumentNullException.ThrowIfNull(configureFaker); + return builder.Register(context => + { + var faker = new Faker(locale); + configureFaker(faker); + return faker.UseSeed(context.DeriveSeed()).Generate(); + }); + } + } +} +``` + +- **Fully independent of `UseBogus()`/`UseBogus(options => ...)`** — no ordering + dependency, no implicit read of `BogusOptions.Locale`. A consumer who wants + both to share a locale centralizes the string themselves (a profile constant, + per the design-review example), rather than this package inferring it through + hidden coupling. This is deliberate, not an oversight worth "fixing" with + shared state later: `UseBogus()` is purely ergonomic sugar over the + existing `Register()` registration mechanism (stage 3) — a plain exact + registration, no hidden pipeline stage, no special runtime behavior of its + own — while `UseBogus()` activates `BogusMemberNameProvider` (stage 5). + They're different pipeline stages solving + different problems, and neither needs the other to function; a consumer can + call `UseBogus()` alone, with `UseBogus()` never called at all, and it + works exactly the same. Coupling them (requiring one before the other, or + silently reading shared options) would blur that separation for a benefit + that only shows up when a consumer wants the same locale in both places — + handled instead by that consumer sharing one string constant, per the + worked example in `docs/public-api.md`'s Bogus Integration section. +- **`locale` is a plain `string`, not an options type, and this is deliberate.** + A `BogusGenerationOptions`-style type was considered during review and + rejected: `Locale` is the only per-registration setting `Faker`'s own + constructor takes today, and `docs/public-api.md`'s own API Design Rules + already caution against speculative surface — a one-property options type + exists only to look extensible, not because a second option is needed now. + If a real per-registration knob emerges (Milestone 7 dogfooding, or later), + an `Action`-based overload can be added then, + alongside the existing `string`-based one, without breaking it — this ADR's + Alpha Compatibility Policy (inherited from + [ADR-0024](0024-public-provider-extensibility-model.md)) already covers + exactly this kind of justified, evidence-driven addition. Call sites should + use the named argument for readability: `UseBogus(locale: "fr", + configureFaker: faker => faker.RuleFor(...))`. +- **`configureFaker` is `Action>`, not `Func, Faker>`.** The + callback *configures* a `Faker` instance; it doesn't transform one into + another. `Faker`'s own `RuleFor`/`CustomInstantiator`/`FinishWith` API is + fluent and returns `this` purely as a chaining convenience — a `Func`-shaped + callback would only ever accidentally work if a caller forgot to return the + same instance (or deliberately returned a *different* one, silently + discarding the original). `Action>` makes returning anything + impossible to get wrong, and matches this package's own `Action`/ + `Action`-shaped configuration callbacks elsewhere in + Compono, rather than introducing the one `Func`-shaped configuration + callback in the whole public surface. +- **Compiles to `Register(Func)`** — no new pipeline + stage, no new conflict rule. Two `UseBogus()` calls (or one `UseBogus()` + plus one direct `Register(...)`) for the same `T` hit the exact same + build-time `CompositionConfigurationException` any duplicate registration + already produces ([ADR-0019](0019-registrations-and-service-provider-injection.md)). +- **A fresh `Faker` per resolved request, never a shared instance.** + `configureFaker` (the `Action>` callback) is captured once at + `Build()` time, same as any other `Register` factory closure — but it is + *invoked* once per `T` resolution, and each invocation constructs its own new + `Faker(locale)` before handing it to `configureFaker`. There is no `Faker` + instance alive before the first request, and no instance is retained or + reused after a request completes: `context.DeriveSeed()` (this specific + request's own derived seed) and `Generate()` both happen against that + request's own fresh instance, inside the same `Register` factory call. + This matches `NSubstituteProvider`'s own "stateless per call" shape exactly — + no mutable state survives from one request to the next, so two concurrent + `Create()` calls for the same `T` never touch the same `Faker` object at + all, let alone race on it. + + **Caching a configured `Faker` across requests was considered and + deliberately rejected.** The obvious "optimization" — construct one + `Faker`, run `configureFaker` on it once at `Build()` time, and reuse that + same instance on every subsequent `Generate()` call, reseeding it per request + via `UseSeed(...)` — looks cheaper (one `Faker` construction instead of + one per request) but is unsafe: `Faker` carries mutable generation state + (its own internal `Randomizer`, rule-evaluation state `UseSeed`/`Generate` + read and write), and Bogus does not document or guarantee that a single + `Faker` instance tolerates concurrent `Generate()` calls. Reusing one + instance across requests would reintroduce exactly the shared-mutable-state + race this section's "never a shared instance" design avoids, the first time + someone reached for it as a performance improvement. This section's design — + a fresh instance per request — is the version to keep; a future contributor + proposing the cached-instance shape as a same-behavior optimization should + treat this paragraph as the record of why it was already rejected, not + rediscover the tradeoff from scratch. +- **Correlated values already work** — `Faker.RuleFor((f, x) => ...)`, + `CustomInstantiator`, `FinishWith`, and nested `Faker` generation are + all ordinary `Faker` API, entirely inside the `configureFaker` callback; this + package adds nothing on top of them. This satisfies `docs/mvp.md`'s "Initial + correlated-value experiment" scope item without a new Compono mechanism — see + the `.DependsOn(...)` deferral below. + +### `.DependsOn(...)` correlated-rule API — deferred + +`docs/mvp.md`'s "Initial correlated-value experiment" is satisfied by Model 3's +`Faker` (above), not by a new Compono-native member-dependency mechanism. +`.For().Member(...).DependsOn(...).UseBogus(...)` is **not** built in this +milestone: it would require new core semantics (member composition ordering, +retaining already-composed sibling values for a later rule to read, dependency- +cycle detection, interaction with constructor-parameter/required-member ordering) +that are a materially larger scope than "integrate Bogus," and would duplicate +capability `Faker` already provides natively. `docs/public-api.md`'s existing +`.DependsOn(...)` sketch is retained as an explicitly deferred future +possibility, not deleted — see Links. + +### Coexistence with `Compono.NSubstitute` + +No shared code, no reference between the two packages in either direction — +cooperation falls out entirely of the existing fixed pipeline order +(`docs/architecture.md`) plus each provider's own narrow type claim: + +- **Stage order is fixed**: semantic providers (stage 5, Bogus) are always tried + before test-double providers (stage 6, NSubstitute), for every request, + regardless of registration order between `UseBogus()`/`UseNSubstitute()` in a + profile or builder chain. +- **Disjoint type claims, by construction, not by coordination.** `BogusMemberNameProvider` + only ever claims `RequestedType == typeof(string)`; `NSubstituteProvider` only + ever claims an interface, delegate, or (optionally) unsealed abstract class — + `string` is none of those. Neither provider needs to know the other package + exists for this to hold; it's a consequence of what each provider's static + type check actually is. +- **Explicit registrations and configuration rules still win over both** — stage + 3/4 run before stage 5/6 unconditionally, so a `Register`/`.For().Use(...)` + for a member both packages might otherwise touch always takes precedence, + with no special-casing needed in either package. +- **`[Shared]` reuse composes across both**: a `[Shared]` interface parameter + substituted by `NSubstituteProvider` and an ordinary string member supplied by + `BogusMemberNameProvider` both write into the same `CompositionScope`, exactly + like any other stage's successful result + ([ADR-0021](0021-row-composition-entry-point-for-test-framework-integrations.md)) — + no interaction to design, since scope reuse has never been provider-specific. +- **`UseBogus()` and `UseNSubstitute()` in the same profile, any call order** — + since neither reads the other's configuration and stage placement is fixed + independent of registration order, `builder.UseNSubstitute().UseBogus()` and + `builder.UseBogus().UseNSubstitute()` compose identically. + +### Package boundary + +`Compono.Bogus` depends on `Compono` and `Bogus` only, matching the existing +package-dependency diagram (`docs/architecture.md`). `BogusMemberNameProvider`/ +`BogusOptions`/`CompositionBuilderExtensions`/`MemberRuleExtensions` are the +package's entire public surface. `Compono.Bogus` never references +`Compono.NSubstitute`, and vice versa. + +### Positive Consequences + +- All three customization models compile to existing pipeline mechanism (stage + 5 provider, stage 4 rule, stage 3 registration) — zero new core mechanism + beyond ADR-0026's `DeriveSeed()`. +- Coexistence with `Compono.NSubstitute` requires no coordination code in either + package — provably disjoint by each provider's own static type check, verified + by test rather than by mutual awareness. +- Correlated values are satisfied by Bogus's own `Faker`, not a new, + independently-maintained Compono mechanism that would duplicate it. +- Locale is never implicitly coupled between activation and whole-object + generation — no hidden call-order dependency to document or get wrong. + +### Negative Consequences + +- `.DependsOn(...)` remains undesigned — a real gap for a consumer who wants + member-level (not whole-object) correlated rules without adopting `Faker` + for that entire type. Accepted as a deliberate scope cut, revisit only if + Milestone 7 dogfooding surfaces a real need `Faker` doesn't already cover. +- Two separate locale parameters (`BogusOptions.Locale` for Model 1, a per-call + `locale` parameter for Models 2/3) means a consumer wanting one consistent + locale across all three models repeats the string — a small, deliberate + ergonomics cost in exchange for no hidden coupling (see Decision Drivers). + +## Pros and Cons of the Options + +### Correlated values via `Faker` (chosen) + +- Good, because it needs zero new Compono mechanism. +- Good, because it's exactly what Bogus's own API is already designed for. +- Bad, because it only covers the whole-object-generation case, not a + correlated *member*-level rule layered onto an otherwise-generated-plan type. + +### Correlated values via a new `.DependsOn(...)` mechanism + +- Good, because it would let a consumer correlate one member against a sibling + without adopting whole-object `Faker` generation for the entire type. +- Bad, because it requires new core ordering/retention/cycle-detection semantics + well beyond "integrate Bogus" — a separate design problem in its own right. +- Bad, because it duplicates capability `Faker` already provides natively. + +### `UseBogus()` as sugar over `Register` (chosen) + +- Good, because duplicate-registration conflict behavior, build-time validation, + and stage-3 precedence all come for free, already tested by + `Compono.Tests`' existing `Register` coverage. +- Good, because it's a first-class, discoverable Bogus-specific entry point + despite adding zero new pipeline concept. +- Good, because constructing a fresh `Faker` inside the factory (rather than + once, outside it, and reused) means the concurrency question `Register`'s + "factory closure, invoked repeatedly" shape might otherwise raise never + arises — no instance is ever shared across requests to begin with. + +### `UseBogus()` fully independent of package-wide locale (chosen) + +- Good, because behavior never depends on builder call order. +- Good, because "own locale, no dependency" is the same posture core's own + scalar-configuration rules already use (set once, no implicit cross-reads). +- Bad, because a consumer wanting one shared locale across every Bogus feature + states it twice rather than once. + +## Links + +- [docs/mvp.md](../mvp.md) — Milestone 6 scope, non-goals, exit criterion +- [docs/public-api.md](../public-api.md) — Bogus Integration section (this ADR + replaces its `context.Semantic.Email()` sketch and its `.DependsOn(...)` + sketch's status, from "design goal" to "explicitly deferred, see this ADR") +- [docs/architecture.md](../architecture.md) — `Compono.Bogus` Package + Boundaries entry, stage 5 (semantic value providers) +- [ADR-0011](0011-composition-scope-shared-values-and-recursion-detection.md) — + the shared-scope mechanism the coexistence section relies on unchanged +- [ADR-0012](0012-composition-path-identity-and-deterministic-random-forking.md) — + the path-independence guarantee this package's values inherit via + `DeriveSeed()` +- [ADR-0019](0019-registrations-and-service-provider-injection.md) — the + `Register` mechanism `UseBogus()` compiles into, including its + duplicate-registration conflict behavior +- [ADR-0020](0020-composition-configuration-rules.md) — the `.For().Member(...).Use(...)` + mechanism the member-level `UseBogus(faker => ...)` sugar wraps +- [ADR-0024](0024-public-provider-extensibility-model.md) — the public provider + contract `BogusMemberNameProvider` implements; the Bogus sketch this ADR + supersedes with a real design +- [ADR-0025](0025-compono-nsubstitute-package-design.md) — `Compono.NSubstitute`, + the package this ADR's Coexistence section verifies against with no code + 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 diff --git a/docs/adr/README.md b/docs/adr/README.md index 46202ec..49336b1 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -87,3 +87,5 @@ the mechanics: numbering, status, and the index. | [0023](0023-rename-compono-xunit-to-compono-xunitv3.md) | Rename Compono.Xunit to Compono.XunitV3 | Accepted | | [0024](0024-public-provider-extensibility-model.md) | Public Provider Extensibility Model | Accepted | | [0025](0025-compono-nsubstitute-package-design.md) | Compono.NSubstitute Package Design | Accepted | +| [0026](0026-deterministic-seed-derivation-for-providers.md) | Deterministic Seed Derivation for Providers and Registration Factories | Accepted | +| [0027](0027-compono-bogus-package-design.md) | Compono.Bogus Package Design | Accepted | diff --git a/docs/architecture.md b/docs/architecture.md index d0aa38c..a54b8c7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -62,6 +62,12 @@ public interface ICompositionContext // 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(); } ``` @@ -191,7 +197,7 @@ not every stage is the same *kind* of thing: | 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 as of Milestone 5 Phase 0 ([ADR-0024](adr/0024-public-provider-extensibility-model.md)) — but no `Compono`-shipped package registers anything into it by default, since `Compono.Bogus` (Milestone 6) doesn't exist yet. Empty in practice until then, populated in mechanism now. | +| 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), design `Accepted`, **not yet implemented** — see [PLAN-0006](plans/0006-milestone-6-bogus-integration.md)) is designed as this stage's first real registrant via `UseBogus()`. Still empty in practice until that plan ships. | | 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) | @@ -208,7 +214,9 @@ 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). Stage 5 alone has no shipped package -to opt into yet — `Compono.Bogus` (Milestone 6) doesn't exist. Provider order +to opt into yet — `Compono.Bogus`'s design is `Accepted` ([ADR-0027](adr/0027-compono-bogus-package-design.md)) +but not yet implemented (Milestone 6, [PLAN-0006](plans/0006-milestone-6-bogus-integration.md)). +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 @@ -856,13 +864,34 @@ 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, `Accepted`, **not yet implemented**) — see +[PLAN-0006](plans/0006-milestone-6-bogus-integration.md) for the phase-by-phase +account. + Owns: -- Bogus-backed semantic providers -- Locale configuration -- Member-name conventions -- Correlated value rules -- Integration with Compono's deterministic seed +- `BogusMemberNameProvider` — the stage-5 semantic value provider (registered + via `AddSemanticProvider`), matching a conservative, exact-match, `string`-typed + member-name allowlist (`FirstName`/`Email`/etc.) +- `BogusOptions` — `Locale`, `EnableMemberNameConventions` +- `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. ## Package Dependency Diagram @@ -954,7 +983,11 @@ Owns: point and its first real consumer are shipped: stage 6 in the Resolution Pipeline table above is populated whenever a consumer calls `UseNSubstitute()`; stage 5 stays empty in practice only because - `Compono.Bogus` (Milestone 6) doesn't exist yet. + `Compono.Bogus` (Milestone 6) isn't implemented yet — its design is + `Accepted` ([ADR-0027](adr/0027-compono-bogus-package-design.md), built on + [ADR-0026](adr/0026-deterministic-seed-derivation-for-providers.md)'s new + `ICompositionContext.DeriveSeed()` capability), 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 diff --git a/docs/mvp.md b/docs/mvp.md index b3048af..81d1069 100644 --- a/docs/mvp.md +++ b/docs/mvp.md @@ -311,20 +311,41 @@ A typical service test can receive a shared substitute, a composed system under ## Milestone 6: Bogus Integration +Owns the reference implementation of Milestone 5's stage-5/6 provider +architecture, and the one core gap that architecture didn't yet need to close: +Milestone 5's `Compono.NSubstitute` never needed randomness, so nothing exposed +deterministic, path-independent random values to a provider or registration +factory. Design: [ADR-0026](adr/0026-deterministic-seed-derivation-for-providers.md) +(core capability: `ICompositionContext.DeriveSeed()`, an on-demand, path-hashed +seed reusing [ADR-0012](adr/0012-composition-path-identity-and-deterministic-random-forking.md)'s +existing mechanism, deliberately not exposing `IRandomSource` or path internals), +[ADR-0027](adr/0027-compono-bogus-package-design.md) (`Compono.Bogus` package: +three customization models — a conservative member-name convention provider +(stage 5), an explicit member-level `UseBogus(faker => ...)` rule (stage 4 +sugar), and a whole-object `UseBogus(...)` registration (stage 3 sugar); +correlated values satisfied via Bogus's own `Faker` rather than a new +Compono-native `.DependsOn(...)` mechanism, which is explicitly deferred; verified +coexistence with `Compono.NSubstitute` via disjoint type claims, with zero +reference between the two packages in either direction). Both ADRs are +`Accepted`; tracked by [PLAN-0006](plans/0006-milestone-6-bogus-integration.md). +**ADR-0026's core capability is implemented (PLAN-0006 Phase 0)** — +`ICompositionContext.DeriveSeed()` is real, tested public API today. **`Compono.Bogus` +itself (ADR-0027) is not yet implemented** — see the plan's phase-by-phase status. + ### Scope - Semantic value-provider contract -- Shared deterministic seed +- Shared deterministic seed (`ICompositionContext.DeriveSeed()`, ADR-0026 — + implemented, PLAN-0006 Phase 0) - Bogus `Faker` access - Locale configuration - Conservative member-name conventions - Explicit member rules -- Initial correlated-value experiment +- Initial correlated-value experiment (satisfied via whole-object `Faker`, + ADR-0027 — not a new Compono-native dependency mechanism) ### Initial Conventions -Potential mappings: - - `FirstName` - `LastName` - `FullName` @@ -336,11 +357,17 @@ Potential mappings: - `PostalCode` - `CompanyName` -Ambiguous member names such as `Name` should not be guessed aggressively. +Ambiguous member names such as `Name` should not be guessed aggressively — +resolved by [ADR-0027](adr/0027-compono-bogus-package-design.md): the allowlist +above, exact match, case-sensitive, gated to `string`-typed members only. ### Exit Criteria -A composed customer can receive realistic, deterministic values without the core package referencing Bogus. +A composed customer can receive realistic, deterministic values without the core +package referencing Bogus — and `UseBogus()`/`UseNSubstitute()` compose in one +profile, any call order, with no special ordering or package-to-package +dependency, per [PLAN-0006](plans/0006-milestone-6-bogus-integration.md)'s Goal +scenario. Not yet met — implementation has not started. ## Milestone 7: Dogfooding diff --git a/docs/plans/0006-milestone-6-bogus-integration.md b/docs/plans/0006-milestone-6-bogus-integration.md new file mode 100644 index 0000000..5061264 --- /dev/null +++ b/docs/plans/0006-milestone-6-bogus-integration.md @@ -0,0 +1,352 @@ +# [PLAN-0006] Milestone 6: Bogus Integration + +**Status:** In Progress + +**Implements:** [ADR-0026](../adr/0026-deterministic-seed-derivation-for-providers.md) +(core capability: `ICompositionContext.DeriveSeed()`), [ADR-0027](../adr/0027-compono-bogus-package-design.md) +(`Compono.Bogus` package: `BogusMemberNameProvider`, `BogusOptions`, member-level +`UseBogus(faker => ...)` sugar, whole-object `UseBogus(...)` sugar, coexistence +with `Compono.NSubstitute`) + +**Note:** both ADRs are `Accepted` as of the design review that produced this plan +(2026-07-31) — implementation may begin. + +## Goal + +```csharp +public sealed class ApplicationTestProfile : ICompositionProfile +{ + public void Configure(CompositionBuilder builder) => + builder + .UseNSubstitute() + .UseBogus(); +} + +[Theory] +[Compose] +public async Task Saves_order( + [Shared] IOrderRepository repository, + CreateOrderHandler handler, + CreateOrder command, + Customer customer) +{ + // customer.FirstName/LastName/Email etc. are realistic, deterministic Bogus values + // repository is a real NSubstitute substitute, reused by handler's own constructor parameter + await handler.Handle(command); + await repository.Received(1).SaveAsync(Arg.Any(), Arg.Any()); +} +``` + +runs end-to-end, packaged (not `ProjectReference`), proving: fixed pipeline +precedence (semantic before test-double) needs no special ordering between +`UseBogus()`/`UseNSubstitute()`; Bogus never claims an interface/delegate/ +abstract-class request and NSubstitute never claims a semantic scalar/member +request; an explicit registration or configuration rule wins over both; the same +seed reproduces the same `customer` values across runs; a `[Shared]` substitute +composed by NSubstitute coexists in the same graph as Bogus-supplied scalar +values with no interaction between the two packages' code. + +## Scope + +Per ADR-0026/ADR-0027's Decision Outcomes: + +- New core `Compono` surface: `ICompositionContext.DeriveSeed()`, backed by + ADR-0012's existing path-hash mechanism with a distinct salt. +- New `Compono.Bogus` package: `BogusOptions`, `BogusMemberNameProvider`, + `CompositionBuilderExtensions.UseBogus()`/`UseBogus(Action)`/ + `UseBogus(Action>)`/`UseBogus(string, Action>)`, + `MemberRuleExtensions.UseBogus(Func, string)` on the existing + member-rule builder. +- New test project: `test/Compono.Bogus.Tests`. +- A `test/Compono.XunitV3.SampleTests` extension proving `Compono.Bogus` and + `Compono.NSubstitute` compose in one real, packaged xUnit v3 consumer (this + plan's own Goal scenario). +- Doc updates: `docs/mvp.md`, `docs/architecture.md`, `docs/public-api.md`. + +Explicitly deferred/non-goals — see ADR-0027's own Decision Outcome/Negative +Consequences: + +- `.DependsOn(...)` — a Compono-native member-dependency mechanism. Correlated + values are satisfied via whole-object `Faker` (`UseBogus()`) instead. +- Any change to `Compono.NSubstitute` — this plan touches `Compono.Bogus` and + core only; coexistence is verified, not implemented, on the NSubstitute side. +- A `Compono.Benchmarks` entry for a Bogus-composed graph — nice-to-have, not + required for this plan's exit criteria (mirrors PLAN-0005's own deferral of + the equivalent NSubstitute benchmark). + +## Phases + +Each phase ships as its own PR, per `design-decisions.md`'s phase rule. + +### Phase 0: Core `DeriveSeed()` capability (ADR-0026) + +**Status:** Done + +- [x] `ICompositionContext.DeriveSeed()`: derives an `int` from the context's + root seed, the request path currently being resolved, and a fixed salt + (`RandomSource.DeriveSeedTag`) distinct from ADR-0012's own internal + per-`PathSegment`-kind fork tags — reusing the existing FNV-1a path-hash + (`IRandomSource.DeriveSeed()`/`RandomSource.DeriveSeed()`, combining the + node's already-forked `_forkState` with the new tag), not a new + algorithm. The 64-bit result is folded into an `int` by XORing its two + halves (`raw ^ (raw >> 32)`), not truncated to the low 32 bits alone. + `CompositionRow` forwards to the wrapped `CompositionContext`, matching + its existing `Resolve()`/`ResolveCollectionSize()` forwarding shape. +- [x] Callable exactly where the descriptor-less `Resolve()` already is: mid- + `TryProvide` (via `InvokeProvider`, ADR-0024 Amendment 1) and mid-factory + (via `InvokeFactory`, stage 3/4 — `.For().Use(...)`/`.Member(...).Use(...)` + rules compile into `TypeRuleProvider`/`MemberRuleProvider`, both of which + invoke their factory through this exact same `InvokeFactory` method, so + `Register` coverage below exercises the identical code path). Throws + `InvalidOperationException` when called outside an active request + (`_manualResolveFrames.Count == 0`), matching `Resolve()`'s existing + guard exactly. +- [x] Idempotent within one active request (repeated calls return the same + value, since it's a pure read of the current node's already-forked state + via `IRandomSource.DeriveSeed()`); never advances or mutates + `NextUInt64()`'s own value-state stream. +- [x] `Compono.Tests` coverage in isolation, before `Compono.Bogus` exists + (`testing.md`'s "verify a new public entry point independently" rule) — + `DeriveSeedTests.cs`, 7 tests × 2 TFMs: same seed + same path → same + derived value; sibling requests inside one factory → independent values; + renaming a constructor parameter without reordering doesn't change its + derived value, reordering does (ADR-0012's guarantee, re-verified for + this new entry point); repeated calls within one active request are + idempotent; calling outside an active request throws; a call from + inside a public provider's `TryProvide` is deterministic for the same + seed; concurrent `Create()` calls against the same shared `Composer` + all land on the same, independently-verified-correct value (no shared + mutable state bleeding between concurrent calls). + +### Phase 1: `Compono.Bogus` package (ADR-0027) + +**Status:** Not Started + +- [ ] New `src/Compono.Bogus/Compono.Bogus.csproj` (matching + `Compono.NSubstitute.csproj`'s TFM/packaging shape — `ProjectReference` to + `Compono` with `PrivateAssets="none"`, `PackageReference` to `Bogus`). +- [ ] `BogusOptions`: `Locale` (`string`, default `"en"`), + `EnableMemberNameConventions` (`bool`, default `true`). +- [ ] `BogusMemberNameProvider : ICompositionValueProvider`: exact-match, + case-sensitive lookup against the documented allowlist (`FirstName`, + `LastName`, `FullName`, `Email`, `PhoneNumber`, `StreetAddress`, `City`, + `State`, `PostalCode`, `CompanyName`), backed by a `FrozenDictionary` + (an immutable, built-once lookup table, not a mutable `Dictionary` — + this is fixed, read-only library data), gated to `RequestedType == + typeof(string)`, `NotHandled` for anything else (including `Name` + itself, deliberately absent from the allowlist). Constructs a fresh + `Faker`/`Randomizer` per handled request, seeded via + `context.DeriveSeed()` — never a shared instance across requests. +- [ ] `CompositionBuilderExtensions.UseBogus()`/`UseBogus(Action)`: + registers `BogusMemberNameProvider` via `AddSemanticProvider` when + `EnableMemberNameConventions` is `true`. +- [ ] `CompositionBuilderExtensions.UseBogus(Action> configureFaker) where T : class`/ + `UseBogus(string locale, Action> configureFaker) where T : class` + (the `class` constraint matches `Faker`'s own; the parameter is named + `configureFaker`, not `configure`, since it's now an `Action`, not a + `Func`). `configureFaker` is `Action>`, not + `Func, Faker>` — it configures the instance in place + (`faker.RuleFor(...)` as a statement, discarding the fluent return), + rather than requiring the caller to return the same instance back. + Compiles to purely ergonomic sugar over the existing `Register` + registration mechanism — no hidden pipeline stage, no special runtime + behavior of its own: + ```csharp + builder.Register(context => + { + var faker = new Faker(locale); + configureFaker(faker); + return faker.UseSeed(context.DeriveSeed()).Generate(); + }); + ``` + A fresh `Faker` is constructed **inside the factory**, once per `T` + resolution — the factory closure itself is captured once at `Build()` + time (same as any other `Register` factory), but no `Faker` + instance is ever retained or reused across requests, so concurrent + `Create()` calls for the same `T` never share one. Caching a + configured `Faker` across requests was considered and rejected — see + ADR-0027's Model 3 section for why (`Faker` carries mutable + generation state with no documented concurrent-`Generate()` safety + guarantee). Fully independent + of `UseBogus()`/`BogusOptions.Locale` — no ordering dependency, defaults + to `"en"` on its own. +- [ ] `MemberRuleExtensions.UseBogus(Func, string locale = "en")` + on the existing `.For().Member(x => x.Y)` builder: compiles to + `.Use(context => configure(new Faker(locale) { Random = new Randomizer(context.DeriveSeed()) }))`. + No `context.Semantic` accessor, no core change beyond Phase 0's + `DeriveSeed()`. + +### Phase 2: Test suites and verification + +**Status:** Not Started + +- [ ] `test/Compono.Bogus.Tests`: `BogusMemberNameProvider` unit coverage (each + allowlisted name against `string`, each allowlisted name against a + non-`string` type declines, `Name` itself declines, an unlisted name + declines); `UseBogus()`/`UseBogus(configure)` wiring a working provider + into a real `Composer`; member-level `UseBogus(faker => ...)` sugar + overriding the convention provider for the same member; whole-object + `UseBogus(...)` producing a fully Bogus-generated instance, including a + correlated `RuleFor((f, x) => ...)` rule; duplicate `UseBogus()` + registration for the same `T` hits the existing + `CompositionConfigurationException`. +- [ ] Determinism regression coverage (ADR-0026's contract, exercised through + real Bogus usage): same seed reproduces the same convention-provider + value and the same `UseBogus()`-generated object; adding an unrelated + Bogus-backed member elsewhere in the graph doesn't perturb an existing + one; `CreateMany(n)` produces independently-seeded items for a + `UseBogus()`-registered type, matching ADR-0012's existing + `CreateMany` seed-derivation contract. +- [ ] Coexistence tests against a real `Composer` with both `UseBogus()` and + `UseNSubstitute()` registered, **any call order**: a string member + resolves via Bogus, an interface/delegate/abstract-class request resolves + via NSubstitute, neither provider is ever attempted for the other's + claimed shape (asserted via diagnostics trace, not just outcome); an + explicit `Register`/`.For().Use(...)` for a type/member either + package could otherwise touch wins over both; a `[Shared]` NSubstitute + substitute and Bogus-supplied scalar values coexist correctly in one + row's scope. +- [ ] `UseBogus()` lifetime/concurrency coverage, proving the corrected + per-request-`Faker` design (ADR-0027) actually holds: the `configure` + callback runs once per resolved object, not once at registration time + (assert an invocation counter increments once per `Create()` call); + two separate `Create()` calls receive two distinct `Faker` + instances (no instance identity/state leaks between requests); a + parallel `Create()`/`CreateMany()` run for a `UseBogus()`- + registered type produces correct, non-corrupted results with no shared + mutable `Faker` state observable across threads (a positive + determinism-under-concurrency test, not a race characterization); the + same seed and request path reproduce the same generated object across + separate runs. +- [ ] An API-surface/approval test locking `Compono.Bogus`'s public shape, + matching `Compono.NSubstitute.Tests`'/`Compono.XunitV3.Tests`' existing + pattern. +- [ ] A real end-to-end run through `test/Compono.XunitV3.SampleTests` (or a new + sibling sample) proving this plan's own Goal scenario — `UseBogus()` and + `UseNSubstitute()` composing one graph under a real xUnit v3 theory, + packaged (not `ProjectReference`) — matching PLAN-0004 Phase 3/PLAN-0005 + Phase 2's real-packaged-consumer strategy, which has twice caught real + packaging/compile-time bugs a `ProjectReference`-only build couldn't + surface. + +### Phase 3: Docs and cleanup + +**Status:** Not Started + +- [ ] `docs/mvp.md` Milestone 6 section: links ADR-0026/ADR-0027/PLAN-0006, + states implementation status per phase, matching Milestone 5's own + phase-by-phase doc-update pattern (update in the PR that actually ships + each phase, not deferred wholesale to this final phase). +- [ ] `docs/architecture.md`: `ICompositionContext`'s conceptual sketch gains + `DeriveSeed()`; stage 5's Resolution Pipeline row and the stages-4/5/6/7 + summary paragraph stop describing stage 5 as unconditionally empty; + `Compono.Bogus` Package Boundaries entry gains a real `Owns` list, Design + line, and implementation status, matching `Compono.NSubstitute`'s entry + shape; the Open Architectural Decisions "public provider extensibility" + entry notes both stage 5 and stage 6 now have real registrants. +- [ ] `docs/public-api.md`: Bogus Integration section replaced with the real + three-model design (convention provider, member-level `UseBogus(faker => ...)`, + whole-object `UseBogus(...)`) — the `context.Semantic.Email()` sketch + and the `.DependsOn(...)` sketch both removed/reframed per ADR-0027; + Naming Vocabulary gains `BogusMemberNameProvider`/`BogusOptions` if + warranted; Diagnostics/Deterministic Reproduction sections cross-reference + `DeriveSeed()`. +- [ ] `docs/adr/README.md`/`docs/plans/README.md` index rows (already added + during the design phase). + +## Critical Files + +- `src/Compono/ICompositionContext.cs`, `src/Compono/CompositionContext.cs` + (`DeriveSeed()` public surface/implementation), `src/Compono/IRandomSource.cs`, + `src/Compono/RandomSource.cs` (`DeriveSeed()` on the fork-state layer, the + new `DeriveSeedTag`), `src/Compono/CompositionRow.cs` (forwarding + implementation) — Phase 0. +- `test/Compono.Tests/DeriveSeedTests.cs` (new), `test/Compono.Tests/UniqueValueResolverTests.cs` + (its hand-written `ICompositionContext` test fake updated for the new + interface member) — Phase 0. +- `src/Compono.Bogus/` (new project) — `BogusOptions.cs`, + `BogusMemberNameProvider.cs`, `CompositionBuilderExtensions.cs`, + `MemberRuleExtensions.cs` — Phase 1. +- `test/Compono.Bogus.Tests/` (new project) — Phase 2. +- `test/Compono.XunitV3.SampleTests/` — new coexistence test(s) — Phase 2. +- `docs/mvp.md`, `docs/architecture.md`, `docs/public-api.md` — Phase 3. + +## Test Plan + +Matches `testing.md`'s existing conventions (xUnit v3 on MTP v2, +Arrange-Act-Assert, fixed-seed determinism assertions, one test project per +`src` project). Per `testing.md`'s "verify a new public entry point in +isolation before the package that will really use it exists" rule, +`DeriveSeed()` gets its own `Compono.Tests` coverage (Phase 0) independent of +`Compono.Bogus`, mirroring PLAN-0005 Phase 0's treatment of +`ICompositionValueProvider`. `Compono.Bogus.Tests` (Phase 2) then covers the +package's own real behavior, its coexistence with `Compono.NSubstitute` in the +same `Composer`, and `UseBogus()`'s per-request `Faker` lifetime under +concurrent composition, plus one real-runner proof (a packaged `test/Compono.XunitV3.SampleTests` run) since that +specific shape has twice caught real bugs a `ProjectReference`-only build +couldn't (PLAN-0004 Phase 3, PLAN-0005 Phase 2). + +## Open Items + +- No `Compono.Benchmarks` entry for a Bogus-composed graph is planned as part of + this plan's own exit criteria — worth adding once `Compono.Bogus` ships, to + characterize `Faker`/`Faker` generation cost against `docs/performance.md`'s + existing baselines, but not required to call this milestone done. +- `.DependsOn(...)` (ADR-0027's deferred member-dependency mechanism) is not + designed here. Revisit only if Milestone 7 dogfooding surfaces a real need + `Faker`'s whole-object correlation doesn't already cover. + +## Notes + +**Phase 0 (Done):** + +- Implemented in the same branch/PR as the design docs (ADR-0026, ADR-0027, + this plan), per explicit user direction — mirrors PLAN-0005 Phase 0's same + choice. Phases 1-3 remain separate PRs, per `design-decisions.md`'s phase + rule. +- Implemented exactly per ADR-0026's Decision Outcome: `IRandomSource`/ + `RandomSource` gained `DeriveSeed()` (a pure read of the node's own + `_forkState` combined with a new, distinct `DeriveSeedTag`, via the same + `Fnv1a.Combine` `RandomSource.Fork` already uses — no new hashing + algorithm); `ICompositionContext`/`CompositionContext` gained the public + `int DeriveSeed()`, guarded by the exact same `_manualResolveFrames.Count == 0` + check the descriptor-less `Resolve()` overload already uses, so both + share one notion of "is a factory/provider invocation currently active." + `CompositionRow` needed a one-line forwarding addition to stay a complete + `ICompositionContext` implementation. +- `test/Compono.Tests/UniqueValueResolverTests.cs`'s hand-written `StubContext + : ICompositionContext` test fake needed a `DeriveSeed()` member (throwing + `NotSupportedException`, matching its existing `Resolve()`/ + `ResolveCollectionSize()` stubs) to keep compiling against the now-larger + interface — the only other `ICompositionContext` implementation in the + codebase besides `CompositionContext`/`CompositionRow` themselves. +- `DeriveSeedTests.cs` (7 tests × 2 TFMs = 14) covers the full contract + entirely through the public `Composer`/`Register`/`AddTestDoubleProvider` + surface — no new internal test seam was needed. The "callable from inside a + factory" and "callable from inside a provider" cases are exercised via + `Register` and a hand-written `ICompositionValueProvider`, respectively; + a separate `.For().Use(...)` test was judged unnecessary since that rule + compiles into `TypeRuleProvider`/`MemberRuleProvider`, both of which invoke + their factory through the exact same `CompositionContext.InvokeFactory` + method `Register` already exercises — testing it a second time would + cover the identical code path, not a new one. +- Full suite green: `Compono.Tests` 426/426 (213 × 2 TFMs — 206 pre-existing + + 7 new), whole-solution `dotnet build`/`dotnet test` 734/734, no warnings. + +Phases 1-3 (the `Compono.Bogus` package itself) haven't started yet. +ADR-0026/ADR-0027 reached `Accepted` on 2026-07-31, after a design review that +resolved (in order): how Bogus's +randomness should relate to ADR-0012's path-independence guarantee (a new, +narrow, on-demand `DeriveSeed()` capability, not an eager field and not exposing +`IRandomSource`); how explicit member rules should access that determinism +(sugar over the existing stage-4 `.Use(context => ...)` mechanism, replacing +`docs/public-api.md`'s stale `context.Semantic` sketch); whether the built-in +convention provider is on by default (yes, matching `Compono.NSubstitute`'s own +precedent); whether correlated values need a new Compono mechanism (no — +`Faker` already solves it, `.DependsOn(...)` is explicitly deferred); whether +whole-object `Faker` generation needs a first-class API (yes, but +implemented as purely ergonomic sugar over the existing `Register` +registration mechanism, not a new pipeline concept or special runtime +behavior); and whether that whole-object API should share package-wide +locale state with `UseBogus()` (no — kept fully independent, no hidden +call-order coupling). diff --git a/docs/plans/README.md b/docs/plans/README.md index f755528..85a2aa0 100644 --- a/docs/plans/README.md +++ b/docs/plans/README.md @@ -48,3 +48,4 @@ one. This file is just the mechanics: numbering, status, and the index. | [0003](0003-milestone-3-profiles-and-configuration.md) | Milestone 3: Profiles and Configuration | Done | | [0004](0004-milestone-4-xunit-integration.md) | Milestone 4: xUnit v3 Integration | Done | | [0005](0005-milestone-5-nsubstitute-integration.md) | Milestone 5: NSubstitute Integration | Done | +| [0006](0006-milestone-6-bogus-integration.md) | Milestone 6: Bogus Integration | Not Started | diff --git a/docs/public-api.md b/docs/public-api.md index 21fbd64..5cda67c 100644 --- a/docs/public-api.md +++ b/docs/public-api.md @@ -332,12 +332,14 @@ builder.For() .Use(_ => new SystemClock()); ``` -Generated semantic data: +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) - .Use(context => context.Semantic.Email()); + .UseBogus(faker => faker.Internet.Email()); ``` Collection size is configured the same way but is **not** a type/member rule @@ -356,22 +358,41 @@ identical key is a build-time conflict, the same as a duplicate registration. ## Bogus Integration -Basic activation: +Design: [ADR-0027](adr/0027-compono-bogus-package-design.md) (`Accepted`, not yet +implemented — see [PLAN-0006](plans/0006-milestone-6-bogus-integration.md)), +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: +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 }); ``` -Explicit Bogus rules: +**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() @@ -379,17 +400,60 @@ builder.For() .UseBogus(faker => faker.Name.FirstName()); ``` -Correlated rules: +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.For() - .Member(x => x.Email) - .DependsOn(x => x.FirstName, x => x.LastName) - .UseBogus((faker, firstName, lastName) => - faker.Internet.Email(firstName, lastName)); +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())); ``` -Correlation syntax is a design goal, not an MVP commitment. +`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 @@ -481,6 +545,15 @@ 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 @@ -550,6 +623,9 @@ Preferred concepts: 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) ## API Design Rules diff --git a/src/Compono/CompositionContext.cs b/src/Compono/CompositionContext.cs index d5375a5..3e5a86a 100644 --- a/src/Compono/CompositionContext.cs +++ b/src/Compono/CompositionContext.cs @@ -261,6 +261,28 @@ public TValue Resolve() return ResolveCore(Nullability.NotNullable, declaringType: null, segment, isShared: false); } + /// + public int DeriveSeed() + { + if (_manualResolveFrames.Count == 0) + { + throw new InvalidOperationException( + $"{nameof(ICompositionContext)}.{nameof(DeriveSeed)}() can only be called from inside a " + + "registration or configuration-rule factory, or a public " + + $"{nameof(ICompositionValueProvider)}.{nameof(ICompositionValueProvider.TryProvide)} invocation."); + } + + // _random is always non-null here: a manual-resolve frame only exists while InvokeFactory/ + // InvokeProvider is running, and both are only ever called from inside ResolveCore's try block, + // which has already set _random for the request currently being resolved. + var raw = _random!.DeriveSeed(); + + // Folds the full 64-bit derived value into an int by XORing its two halves, rather than + // truncating to the low 32 bits alone - keeps entropy from both halves instead of silently + // discarding the high bits. + return unchecked((int)(raw ^ (raw >> 32))); + } + /// public int ResolveCollectionSize() => ResolveCollectionSizeCore(); diff --git a/src/Compono/CompositionRow.cs b/src/Compono/CompositionRow.cs index 82485f8..4e2fe2e 100644 --- a/src/Compono/CompositionRow.cs +++ b/src/Compono/CompositionRow.cs @@ -37,6 +37,9 @@ internal CompositionRow(CompositionContext context, int seed) /// public TValue Resolve() => _context.Resolve(); + /// + public int DeriveSeed() => _context.DeriveSeed(); + /// public int ResolveCollectionSize() => _context.ResolveCollectionSize(); diff --git a/src/Compono/ICompositionContext.cs b/src/Compono/ICompositionContext.cs index c73d13f..9c0d221 100644 --- a/src/Compono/ICompositionContext.cs +++ b/src/Compono/ICompositionContext.cs @@ -43,6 +43,25 @@ public interface ICompositionContext /// TValue Resolve(); + /// + /// Derives a deterministic seed from this context's root seed and the request + /// currently being resolved - usable to seed a caller-owned PRNG (e.g. a Bogus.Randomizer) + /// without exposing the engine's own internal random source or path representation. The same root + /// seed and the same request path always derive the same value; a different path (a different + /// member, a different constructor parameter, a different element of a collection) always derives + /// independently. Calling this method repeatedly for the same active request returns the same + /// value every time - it does not advance any stream, and does not perturb any other value's own + /// derivation. Valid in the same scope as : from inside a + /// registration or configuration-rule factory, or a public + /// invocation. See + /// docs/adr/0026-deterministic-seed-derivation-for-providers.md. + /// + /// + /// No registration/configuration-rule factory or public provider invocation is currently in + /// progress. + /// + int DeriveSeed(); + /// /// Resolves the collection size a generated collection plan should build - the size a /// member-scoped WithCollectionSize override configures for the collection member diff --git a/src/Compono/IRandomSource.cs b/src/Compono/IRandomSource.cs index e91bebd..1cbf89a 100644 --- a/src/Compono/IRandomSource.cs +++ b/src/Compono/IRandomSource.cs @@ -22,4 +22,14 @@ internal interface IRandomSource /// Produces the next pseudo-random 64-bit value for this node's own value stream. ulong NextUInt64(); + + /// + /// Derives a stable 64-bit value from this node's own fork state and a fixed salt distinct from + /// 's own per--kind tags. Never reads or mutates the + /// value-state advances, so repeated calls for the same node return the + /// same result and never perturb that node's own value stream. Backs + /// - see + /// docs/adr/0026-deterministic-seed-derivation-for-providers.md. + /// + ulong DeriveSeed(); } diff --git a/src/Compono/RandomSource.cs b/src/Compono/RandomSource.cs index 629516b..c0a7520 100644 --- a/src/Compono/RandomSource.cs +++ b/src/Compono/RandomSource.cs @@ -23,6 +23,11 @@ internal sealed class RandomSource : IRandomSource private const byte ManualResolveTag = 5; private const byte TestParameterTag = 6; + // Distinct from the seven PathSegment-kind tags above - DeriveSeed's own fixed salt, so a + // caller-derived seed (ADR-0026) is never accidentally identical to a value this same node might + // separately fork for an ordinary PathSegment-keyed child. + private const byte DeriveSeedTag = 7; + private readonly ulong _forkState; private ulong _valueState; @@ -61,4 +66,7 @@ public IRandomSource Fork(PathSegment segment) /// public ulong NextUInt64() => SplitMix64.Next(ref _valueState); + + /// + public ulong DeriveSeed() => Fnv1a.Combine(_forkState, DeriveSeedTag, ReadOnlySpan.Empty); } diff --git a/test/Compono.Tests/DeriveSeedTests.cs b/test/Compono.Tests/DeriveSeedTests.cs new file mode 100644 index 0000000..2b9f612 --- /dev/null +++ b/test/Compono.Tests/DeriveSeedTests.cs @@ -0,0 +1,124 @@ +namespace Compono.Tests; + +/// +/// Exercises Milestone 6 Phase 0's - a path-derived, +/// on-demand deterministic seed a registration/configuration-rule factory or a public +/// can use for its own randomness, without exposing the +/// engine's own internal random source or path representation. See +/// docs/adr/0026-deterministic-seed-derivation-for-providers.md. +/// +public sealed class DeriveSeedTests +{ + [Fact] + public void DeriveSeed_SameSeedAndSameRequestPath_ProducesTheSameValue() + { + var first = ComposeRoot(); + var second = ComposeRoot(); + + first.Should().Be(second); + + static int ComposeRoot() => + Composer.Create(builder => builder + .WithSeed(4219) + .Register(ctx => new Widget(ctx.DeriveSeed()))) + .Create() + .Value; + } + + [Fact] + public void DeriveSeed_SiblingRequestsInsideOneFactory_DeriveIndependentValues() + { + var composer = Composer.Create(builder => builder + .WithSeed(4219) + .Register(ctx => ctx.DeriveSeed()) + .Register(ctx => new Pair(ctx.Resolve(), ctx.Resolve()))); + + var result = composer.Create(); + + result.First.Should().NotBe(result.Second); + } + + [Fact] + public void DeriveSeed_CalledTwiceForTheSameActiveRequest_ReturnsTheSameValueBothTimes() + { + var composer = Composer.Create(builder => builder + .WithSeed(4219) + .Register(ctx => new Pair(ctx.DeriveSeed(), ctx.DeriveSeed()))); + + var result = composer.Create(); + + result.First.Should().Be(result.Second); + } + + [Fact] + public void DeriveSeed_IsUnaffectedByRenamingAConstructorParameter_ButChangedByReorderingIt() + { + var original = ComposeViaDescriptor(ordinal: 0, name: "a"); + var renamed = ComposeViaDescriptor(ordinal: 0, name: "b"); + var reordered = ComposeViaDescriptor(ordinal: 1, name: "a"); + + renamed.Should().Be(original); + reordered.Should().NotBe(original); + + static int ComposeViaDescriptor(int ordinal, string name) + { + var descriptor = new CompositionRequestDescriptor( + CompositionRequestKind.ConstructorParameter, ordinal, name, declaringType: null, Nullability.NotNullable); + + var composer = Composer.Create(builder => builder + .WithSeed(4219) + .Register(ctx => ctx.DeriveSeed()) + .Register(ctx => new Outer(ctx.Resolve(descriptor)))); + + return composer.Create().Value; + } + } + + [Fact] + public void DeriveSeed_WithNoActiveFactoryOrProviderInvocation_Throws() + { + var context = new CompositionContext(); + + var act = () => context.DeriveSeed(); + + act.Should().Throw(); + } + + [Fact] + public void DeriveSeed_IsCallableFromInsideAPublicProviderTryProvide_AndIsDeterministicForTheSameSeed() + { + var first = Composer.Create(builder => builder.WithSeed(4219).AddTestDoubleProvider(new SeedCapturingProvider())).Create(); + var second = Composer.Create(builder => builder.WithSeed(4219).AddTestDoubleProvider(new SeedCapturingProvider())).Create(); + + first.Should().Be(second); + } + + [Fact] + public void DeriveSeed_CalledConcurrently_ProducesTheSameCorrectOutputPerCall_WhenSeedIsExplicit() + { + var composer = Composer.Create(builder => builder + .WithSeed(4219) + .Register(ctx => new Widget(ctx.DeriveSeed()))); + var expected = composer.Create(); + var results = new Widget[50]; + + Parallel.For(0, results.Length, i => results[i] = composer.Create()); + + // Every concurrent Create() call against the same shared Composer landing on the + // exact same, independently-verified-correct value is strong evidence against shared mutable + // state bleeding between concurrent DeriveSeed() calls, not just an absence of exceptions. + results.Should().AllSatisfy(result => result.Should().Be(expected)); + } + + private sealed record Widget(int Value); + + private sealed record Pair(int First, int Second); + + private sealed record Outer(int Value); + + private sealed class SeedCapturingProvider : ICompositionValueProvider + { + public CompositionProviderResult TryProvide(in CompositionProviderRequest request, ICompositionContext context) => + CompositionProviderResult.Handled(new Widget(context.DeriveSeed())); + } +} diff --git a/test/Compono.Tests/UniqueValueResolverTests.cs b/test/Compono.Tests/UniqueValueResolverTests.cs index 7ec4a5e..1289a28 100644 --- a/test/Compono.Tests/UniqueValueResolverTests.cs +++ b/test/Compono.Tests/UniqueValueResolverTests.cs @@ -67,6 +67,8 @@ public TValue Resolve(in CompositionRequestDescriptor descriptor) => public TValue Resolve() => throw new NotSupportedException("Not exercised by these tests."); + public int DeriveSeed() => throw new NotSupportedException("Not exercised by these tests."); + public int ResolveCollectionSize() => throw new NotSupportedException("Not exercised by these tests."); } } From 09c8bdf9069ec4589a04ef76790c6bb548f15df7 Mon Sep 17 00:00:00 2001 From: Nick Cipollina Date: Fri, 31 Jul 2026 22:38:48 -0400 Subject: [PATCH 2/2] docs(plans): mark PLAN-0006 in progress in the index Codex review (PR #32): the plans index still said Not Started even though the plan itself declares In Progress with Phase 0 Done, and this branch ships that phase. Co-Authored-By: Claude Sonnet 5 --- docs/plans/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plans/README.md b/docs/plans/README.md index 85a2aa0..27fa800 100644 --- a/docs/plans/README.md +++ b/docs/plans/README.md @@ -48,4 +48,4 @@ one. This file is just the mechanics: numbering, status, and the index. | [0003](0003-milestone-3-profiles-and-configuration.md) | Milestone 3: Profiles and Configuration | Done | | [0004](0004-milestone-4-xunit-integration.md) | Milestone 4: xUnit v3 Integration | Done | | [0005](0005-milestone-5-nsubstitute-integration.md) | Milestone 5: NSubstitute Integration | Done | -| [0006](0006-milestone-6-bogus-integration.md) | Milestone 6: Bogus Integration | Not Started | +| [0006](0006-milestone-6-bogus-integration.md) | Milestone 6: Bogus Integration | In Progress |