feat(decorators): port the C# decorator layer as zero-cost generic wrappers - #59
Conversation
Adding .gitkeep for PR creation (default mode). This file will be removed when the task is complete. Issue: #58
…appers Adds `doublets::decorators`, a static-composition port of the twelve `Platform.Data.Doublets` decorators: - uniqueness: UniquenessValidator, UniquenessResolver, CascadeUniquenessAndUsagesResolver - usages: UsagesValidator, CascadeUsagesResolver - existence: InnerReferenceExistenceValidator, NonExistentDependenciesCreator - constants: ItselfConstantToSelfReferenceResolver, NullConstantToSelfReferenceResolver - deletion: NonNullContentsLinkDeletionResolver - LoggingDecorator, NoExceptionsDecorator Each decorator is a generic struct that owns the store it wraps and forwards every non-intercepted operation through an `#[inline]` method, so a chosen stack is a single concrete type with no dynamic dispatch. `Validate`, `Resolve` and `CascadeResolve` are zero-sized policy markers selected through the `UniquenessPolicy` / `UsagesPolicy` GAT traits, and `DecoratorsExt` composes a stack by value, returning the concrete type.
`unit::Store::update_links` aliased `old_source`/`old_target` to the incoming change instead of the values read from the store, so every write handler saw `before == after`. `split::Store` already reported the real previous link; this makes the two backends agree.
Adds a behavioural test per decorator plus a regression test for #57: creating the same doublet eight times through a uniqueness-resolving stack must keep a single link and stay deletable.
Adds `integration/src/bins/fusion-probe.rs`, which exports a `bare_*` and a `composed_*` function per operation, and `integration/tests/fusion.rs`, which builds the probe in release mode and compares the disassembled bodies. The composed `create` calls `unit::Store::create_links` directly: all nine decorator layers are gone. `experiments/issue-58/verify-fusion.sh` is the negative control.
Also moves the write-handler tests out of `traits.rs`, which had grown past the 1000-line limit the repository enforces.
`cargo llvm-cov` exports `-C instrument-coverage` through `CARGO_ENCODED_RUSTFLAGS`, which the probe build inherited. Coverage emits a counter per source region, so each inlined decorator layer left a `lock incq` behind even though it produced no call, and the comparison failed under the coverage job while passing everywhere else. Both bodies already called `Store::create_links` directly, so the layers had fused; only the instrumentation differed. The probe now builds with those flags stripped.
Stripping `RUSTFLAGS`/`CARGO_ENCODED_RUSTFLAGS` was not enough: since 0.9, `cargo llvm-cov` injects `-C instrument-coverage` through a `RUSTC_WRAPPER` (see cargo-llvm-cov's `src/wrapper.rs`), so the probe was still instrumented and every inlined decorator layer left a coverage counter behind. The probe build now also drops `RUSTC_WRAPPER`, `RUSTC_WORKSPACE_WRAPPER` and every `*LLVM_COV*` variable, and the comparison skips outright if a coverage counter survives anyway, rather than reporting a fusion failure it did not observe. Verified with cargo-llvm-cov 0.9.0: `cargo llvm-cov --all-features --lcov` now passes, the probe it builds carries 5 incidental `incq` from std instead of the 1908 an instrumented build emits, and the test compares all three operations rather than skipping.
Working session summaryCI is running on Root cause of the CI failure (found and fixed). The Code Coverage job's failure was not a fusion failure. My first fix stripped Verified three ways, not assumed:
Prior run Branch: I'll report the CI result for This summary was automatically extracted from the AI working session output. |
🤖 Solution Draft LogThis log file contains the complete execution trace of the AI solution draft process. 💰 Cost: $19.877000📊 Context and tokens usage:Claude Opus 5: (5 sub-sessions)
Total: (11.8K new + 470.4K cache writes + 18.5M cache reads) input tokens, 234.6K output tokens, $19.877000 cost 🤖 Models used:
📎 Log file uploaded as Gist (6718KB)Now working session is ended, feel free to review and add any feedback on the solution draft. |
✅ Ready to mergeThis pull request is now ready to be merged:
Monitored by hive-mind with --auto-restart-until-mergeable flag |
This reverts commit f8599d3.
Closes #58.
Ports the decorator layer of
Platform.Data.Doubletsto Rust as generic wrappers that compose statically and fuse away at compile time.What is here
A new
doublets::decoratorsmodule with all twelve C# decorators, each intercepting exactly the operations its C# counterpart does:LinksUniquenessValidatorUniquenessValidatorLinksUniquenessResolverUniquenessResolverLinksCascadeUniquenessAndUsagesResolverCascadeUniquenessAndUsagesResolverLinksUsagesValidatorUsagesValidatorLinksCascadeUsagesResolverCascadeUsagesResolverLinksInnerReferenceExistenceValidatorInnerReferenceExistenceValidatorLinksItselfConstantToSelfReferenceResolverItselfConstantToSelfReferenceResolverLinksNullConstantToSelfReferenceResolverNullConstantToSelfReferenceResolverLinksNonExistentDependenciesCreatorNonExistentDependenciesCreatorNonNullContentsLinkDeletionResolverNonNullContentsLinkDeletionResolverLoggingDecoratorLoggingDecoratorNoExceptionsDecoratorNoExceptionsDecoratorDesign, following the five constraints in the issue:
D<T, L: Doublets<T>>owningLby value. NoBox<dyn Links>, no vtable.#[inline]everywhere. Theforward!macro indecorators/macros.rsemits#[inline]on every pass-through method, so a layer that does not intercept an operation leaves no trace.Validate,ResolveandCascadeResolveare zero-sized markers;UniquenessPolicy/UsagesPolicymap each to its decorator through a GAT, sowith_uniqueness(Resolve)picks the layer at compile time.dyn Errorin the hot path. Everything stays in the crate's existingError<T>.DecoratorsExttakes the store by value:with_automatic_uniqueness_and_usages_resolution()builds the C#AutomaticUniquenessAndUsagesResolutionstack in one call.The bug this fixes for callers (#57)
On a bare store,
create_link(a, b)on an existing doublet inserts a duplicate and silently corrupts the index; deleting any of them then panics withattempt to subtract with overflowinsideplatform-trees. Reproduction kept atexperiments/issue-58/duplicate_corruption.rs.With
with_uniqueness(Resolve),create_linkreturns the existing link — exactly theget_or_createbehaviourlink-assistant/routeropen-codes today.doublets/examples/uniqueness.rsshows both sides:duplicate_creation_does_not_corrupt_the_indexindoublets/tests/decorators.rsis the automated version of that reproduction: it fails on a bare store and passes through the resolver.Fusion is checked, not assumed
integration/tests/fusion.rsbuildsintegration/src/bins/fusion-probe.rsin release, disassembles it withllvm-objdump/objdump, and compares the emitted body of an operation on a nine-layer composed stack against the same operation on the bare store:createandeach: the normalized instruction sequences are identical, and the composedcreatecallsunit::Store::create_linksdirectly — no decorator frame survives.count: the linker's identical code folding merged the two probes into one symbol, which is stronger still.The test skips loudly (never fails spuriously) when no disassembler is present, the probe build fails, or the dump is unparsable, and honours
DOUBLETS_SKIP_FUSION_TEST. It handles both GNU and LLVM objdump syntax and macOS_-prefixed symbols.The probe build deliberately does not inherit the harness's compiler environment.
cargo llvm-covturns coverage on two different ways — throughCARGO_ENCODED_RUSTFLAGS, and since 0.9 through aRUSTC_WRAPPERthat injects-C instrument-coveragefor workspace crates (src/wrapper.rs). Coverage emits a counter per source region, so each inlined decorator layer still left alock incqbehind and the comparison failed in the coverage job while passing everywhere else — the bodies both calledStore::create_linksdirectly, so nothing had actually failed to fuse. The probe build now dropsRUSTFLAGS,CARGO_ENCODED_RUSTFLAGS, the rustdoc equivalents,RUSTC_WRAPPER,RUSTC_WORKSPACE_WRAPPER,LLVM_PROFILE_FILEand every*LLVM_COV*variable, and skips outright if a coverage counter survives anyway rather than reporting a failure it did not observe.Verified locally against cargo-llvm-cov 0.9.0, the version CI installs:
cargo llvm-cov --all-features --lcov --output-path lcov.infopasses, the probe it builds carries 5 incidentalincqfrom std instead of the 1908 an instrumented build emits, and the test compares all three operations rather than skipping.experiments/issue-58/verify-fusion.shis the negative control: it flips theforward!macro to#[inline(never)]and asserts the test then fails. It does, with the composed body callingUniquenessValidator::create_linkswhere the bare one callsStore::create_links— so the check is not vacuous.decorators_add_no_stateadditionally asserts viastatic_assertionsthat each decorator and the full automatic stack are the same size as the bare store.Also fixed:
unit::Store::update_linksreported the wrongbeforeWriting the decorator tests surfaced a pre-existing bug.
update_linksaliased the "old" values to the incoming change:so every write handler saw
before == after. It now reads them off the link before the write, matchingsplit::Store, which was already correct. Covered bydoublets/tests/write_handlers.rsfor both backends.Deviations from C#, all documented in
decorators/mod.rs_facadeback-reference so a cascade re-enters at the top of the stack. A statically composed stack has no such back-reference; a decorator only knows the layers below it. For every stack C# itself builds the two coincide. Documented under "# Ordering".UniquenessResolverreports the surviving link to the handler, socreate_linkreturns a valid address rather than the address of the link it just deleted.CascadeUsagesResolvertracks visited links, so a reference cycle terminates instead of overflowing the stack (covered bycascade_usages_resolver_terminates_on_a_reference_cycle).MergeUsagesre-points sources and targets correctly; the C# version writes null targets because of aparamsconstructor mix-up.EnsureCreatedcalls the creator in its loop; the C# loop never terminates.InnerReferenceExistenceValidator::try_each_linksexists becauseLinks::each_linkshas no error channel.NoExceptionsDecoratormapsErr(_)toFlow::Break, the closest analogue of C#'sErrorconstant. It does not catch panics.LoggingDecoratorsurfaces the first writer I/O error instead of discarding it.Tests
doublets/tests/decorators.rs— 20 tests, at least one per decorator, plus the #57 regression, the cycle case, the log-format check and the size assertions.doublets/tests/write_handlers.rs— 2 tests for thebefore/afterfix on both backends.integration/tests/fusion.rs— the disassembly check.doublets/examples/uniqueness.rs— the before/after demonstration.Full local CI parity is green:
cargo fmt --all -- --check,cargo clippy --workspace --all-targets --all-features -- -Dwarnings,cargo test --workspace --all-features,cargo test --doc,cargo package -p doublets --list, and the threerust-scriptchecks.Release is triggered by
changelog.d/20260829_120000_issue_58_decorators.md(bump: minor);Cargo.tomlis untouched, as the version-check job requires.