Skip to content

feat(decorators): port the C# decorator layer as zero-cost generic wrappers - #59

Merged
konard merged 10 commits into
mainfrom
issue-58-82eb149b35e1
Aug 29, 2026
Merged

feat(decorators): port the C# decorator layer as zero-cost generic wrappers#59
konard merged 10 commits into
mainfrom
issue-58-82eb149b35e1

Conversation

@konard

@konard konard commented Aug 29, 2026

Copy link
Copy Markdown
Member

Closes #58.

Ports the decorator layer of Platform.Data.Doublets to Rust as generic wrappers that compose statically and fuse away at compile time.

What is here

A new doublets::decorators module with all twelve C# decorators, each intercepting exactly the operations its C# counterpart does:

C# decorator This module Create Update Delete Each
LinksUniquenessValidator UniquenessValidator
LinksUniquenessResolver UniquenessResolver
LinksCascadeUniquenessAndUsagesResolver CascadeUniquenessAndUsagesResolver
LinksUsagesValidator UsagesValidator
LinksCascadeUsagesResolver CascadeUsagesResolver
LinksInnerReferenceExistenceValidator InnerReferenceExistenceValidator
LinksItselfConstantToSelfReferenceResolver ItselfConstantToSelfReferenceResolver
LinksNullConstantToSelfReferenceResolver NullConstantToSelfReferenceResolver
LinksNonExistentDependenciesCreator NonExistentDependenciesCreator
NonNullContentsLinkDeletionResolver NonNullContentsLinkDeletionResolver
LoggingDecorator LoggingDecorator
NoExceptionsDecorator NoExceptionsDecorator

Design, following the five constraints in the issue:

  • Static composition. Every decorator is D<T, L: Doublets<T>> owning L by value. No Box<dyn Links>, no vtable.
  • #[inline] everywhere. The forward! macro in decorators/macros.rs emits #[inline] on every pass-through method, so a layer that does not intercept an operation leaves no trace.
  • Policy as a type. Validate, Resolve and CascadeResolve are zero-sized markers; UniquenessPolicy / UsagesPolicy map each to its decorator through a GAT, so with_uniqueness(Resolve) picks the layer at compile time.
  • No dyn Error in the hot path. Everything stays in the crate's existing Error<T>.
  • A builder returning the concrete type. DecoratorsExt takes the store by value:
use doublets::{decorators::{DecoratorsExt, Resolve}, mem, unit, Doublets};

let mut store = unit::Store::<usize, _>::new(mem::Global::new())?
    .with_uniqueness(Resolve)
    .with_usages_validation();

with_automatic_uniqueness_and_usages_resolution() builds the C# AutomaticUniquenessAndUsagesResolution stack 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 with attempt to subtract with overflow inside platform-trees. Reproduction kept at experiments/issue-58/duplicate_corruption.rs.

With with_uniqueness(Resolve), create_link returns the existing link — exactly the get_or_create behaviour link-assistant/router open-codes today. doublets/examples/uniqueness.rs shows both sides:

bare store:
  create_link(1, 2) x8 -> [3, 4, 5, 6, 7, 8, 9, 10]
  count = 10
  deleting any of them now panics in `platform-trees` (issue #57)

with_uniqueness(Resolve):
  create_link(1, 2) x8 -> [3, 3, 3, 3, 3, 3, 3, 3]
  count = 3
  delete(3) -> ok, count = 2
  links = [1: 1 1, 2: 2 2]

duplicate_creation_does_not_corrupt_the_index in doublets/tests/decorators.rs is 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.rs builds integration/src/bins/fusion-probe.rs in release, disassembles it with llvm-objdump/objdump, and compares the emitted body of an operation on a nine-layer composed stack against the same operation on the bare store:

  • create and each: the normalized instruction sequences are identical, and the composed create calls unit::Store::create_links directly — 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-cov turns coverage on two different ways — through CARGO_ENCODED_RUSTFLAGS, and since 0.9 through a RUSTC_WRAPPER that injects -C instrument-coverage for workspace crates (src/wrapper.rs). Coverage emits a counter per source region, so each inlined decorator layer still left a lock incq behind and the comparison failed in the coverage job while passing everywhere else — the bodies both called Store::create_links directly, so nothing had actually failed to fuse. The probe build now drops RUSTFLAGS, CARGO_ENCODED_RUSTFLAGS, the rustdoc equivalents, RUSTC_WRAPPER, RUSTC_WORKSPACE_WRAPPER, LLVM_PROFILE_FILE and 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.info 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.

experiments/issue-58/verify-fusion.sh is the negative control: it flips the forward! macro to #[inline(never)] and asserts the test then fails. It does, with the composed body calling UniquenessValidator::create_links where the bare one calls Store::create_links — so the check is not vacuous.

decorators_add_no_state additionally asserts via static_assertions that each decorator and the full automatic stack are the same size as the bare store.

Also fixed: unit::Store::update_links reported the wrong before

Writing the decorator tests surfaced a pre-existing bug. update_links aliased the "old" values to the incoming change:

let old_source = source;   // the NEW source
let old_target = target;

so every write handler saw before == after. It now reads them off the link before the write, matching split::Store, which was already correct. Covered by doublets/tests/write_handlers.rs for both backends.

Deviations from C#, all documented in decorators/mod.rs

  • C# threads a _facade back-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".
  • UniquenessResolver reports the surviving link to the handler, so create_link returns a valid address rather than the address of the link it just deleted.
  • CascadeUsagesResolver tracks visited links, so a reference cycle terminates instead of overflowing the stack (covered by cascade_usages_resolver_terminates_on_a_reference_cycle).
  • MergeUsages re-points sources and targets correctly; the C# version writes null targets because of a params constructor mix-up.
  • EnsureCreated calls the creator in its loop; the C# loop never terminates.
  • InnerReferenceExistenceValidator::try_each_links exists because Links::each_links has no error channel.
  • NoExceptionsDecorator maps Err(_) to Flow::Break, the closest analogue of C#'s Error constant. It does not catch panics.
  • LoggingDecorator surfaces 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 the before/after fix 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 three rust-script checks.

Release is triggered by changelog.d/20260829_120000_issue_58_decorators.md (bump: minor); Cargo.toml is untouched, as the version-check job requires.

Adding .gitkeep for PR creation (default mode).
This file will be removed when the task is complete.

Issue: #58
@konard konard self-assigned this Aug 29, 2026
konard added 6 commits August 29, 2026 13:47
…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.
@konard konard changed the title [WIP] Port all C# decorators, composed as zero-cost abstractions that fuse into one function per operation feat(decorators): port the C# decorator layer as zero-cost generic wrappers Aug 29, 2026
@konard
konard marked this pull request as ready for review August 29, 2026 14:13
konard added 2 commits August 29, 2026 14:16
`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.
@konard

konard commented Aug 29, 2026

Copy link
Copy Markdown
Member Author

Working session summary

CI is running on ba1fe0f. Here's where things stand.

Root cause of the CI failure (found and fixed). The Code Coverage job's failure was not a fusion failure. cargo llvm-cov 0.9 injects -C instrument-coverage through a RUSTC_WRAPPER (its src/wrapper.rs), which the probe build inherited. Coverage emits a counter per source region, so every inlined decorator layer still left a lock incq behind — both bodies already called Store::create_links directly, so the layers had fused; only the instrumentation differed.

My first fix stripped RUSTFLAGS/CARGO_ENCODED_RUSTFLAGS, which was insufficient because that is not the mechanism. Reading cargo-llvm-cov's source pinned it to the wrapper. The probe build now also drops RUSTC_WRAPPER, RUSTC_WORKSPACE_WRAPPER and every *LLVM_COV* variable, and skips outright if a counter survives anyway rather than reporting a failure it did not observe.

Verified three ways, not assumed:

  • Simulated wrapper (RUSTC_WRAPPER injecting the flag): probe goes from 1908 counters to 5 incidental ones from std; test passes.
  • Real cargo-llvm-cov 0.9.0 — the version CI installs — running CI's exact command cargo llvm-cov --all-features --lcov --output-path lcov.info: exit 0, fusion test ok, no skip note, all three operations compared.
  • Negative control (experiments/issue-58/verify-fusion.sh) still fails when the forwarding macro is switched to #[inline(never)], so the check is not vacuous.

Prior run 7ff23aa: everything green except Code Coverage — Version Check, Changelog Fragment, Lint/Format, Test on ubuntu/macOS/Windows, and Build Package all passed.

Branch: ed9b49aace97af (README decorator section) → 7ff23aaba1fe0f. PR #59 is out of draft with an updated description: #59

I'll report the CI result for ba1fe0f when the run completes.


This summary was automatically extracted from the AI working session output.

@konard

konard commented Aug 29, 2026

Copy link
Copy Markdown
Member Author

🤖 Solution Draft Log

This 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)

  1. 116.6K / 1M (12%) input tokens, 19.8K / 128K (15%) output tokens
  2. 112.6K / 1M (11%) input tokens, 32.1K / 128K (25%) output tokens
  3. 113.9K / 1M (11%) input tokens, 53.1K / 128K (41%) output tokens
  4. 116.5K / 1M (12%) input tokens, 39.6K / 128K (31%) output tokens
  5. 111.1K / 1M (11%) input tokens, 31.4K / 128K (25%) output tokens

Total: (11.8K new + 470.4K cache writes + 18.5M cache reads) input tokens, 234.6K output tokens, $19.877000 cost

🤖 Models used:

  • Tool: Anthropic Claude Code
  • Requested: opus (claude-opus-5)
  • Thinking level: high (~23999 tokens)
  • Model: Claude Opus 5 (claude-opus-5)

📎 Log file uploaded as Gist (6718KB)


Now working session is ended, feel free to review and add any feedback on the solution draft.

@konard

konard commented Aug 29, 2026

Copy link
Copy Markdown
Member Author

✅ Ready to merge

This pull request is now ready to be merged:

  • All CI checks have passed
  • No merge conflicts
  • No pending changes

Monitored by hive-mind with --auto-restart-until-mergeable flag

@konard
konard merged commit 35ee526 into main Aug 29, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Port all C# decorators, composed as zero-cost abstractions that fuse into one function per operation

1 participant