Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ These are conditional source/runtime compatibility changes from `3.0.0`, even th
- **Null structural elements (#26):** built-in structural collection mapping now preserves null for nullable element targets. For nonnullable element targets under `Throw`, it raises `InvalidOperationException` before constructing the target object or reading source members, rather than dereferencing null in the helper. Higher-precedence nullable converters still receive null. Recheck null handling, side effects, and exception expectations.
- **Enum exception metadata (#27):** nullable-root by-name enum failures now identify the declared input parameter instead of a generated capture name. Update assertions or error handling that inspect `ParamName` or related exception text.

### Performance

- Cycle tracking stores the first active source inline and creates reference-identity spill storage only for a second simultaneous source. Spill storage is retained for later siblings. This reduces measured depth-1 tracked allocations from 232 to 56 bytes per mapping on Windows x64; measured depths 2, 16 and 128 retain their previous allocations. Timing comparisons showed unstable controls and deeper slowdown signals, so this change does not establish an overall throughput improvement or release performance acceptance. Public cycle behavior and statically generated mapping remain unchanged.

### Fixed

- Issue #51: set mappings use the destination default comparer after custom element conversion, including same-type converters, preventing incorrect collapse of distinct mapped values. Identity-mapped sets retain compatible source comparers. [#51](https://github.com/PrimordialCode/Mammoth.LiteMapper/issues/51)
Expand Down
19 changes: 19 additions & 0 deletions PERFORMANCE_OPTIMIZATION_NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,25 @@

These notes are non-authoritative follow-up candidates. `SPECIFICATION.md` remains the product contract.

## Compact cycle tracker allocation evidence (2026-10-13)

The private generated tracker has one object slot. Its own instance denotes empty state, an ordinary first source is stored inline (including null), and a private sealed HashSet subtype denotes spill storage. Reserved sentinel/type-tag sources escape into a fresh set. Promotion populates a local set before publishing it; failed duplicate entry leaves occupancy intact. Spill sets use reference identity and remain available after unwind. Existing Enter-before-try/finally-Exit calls and lazy exception paths are unchanged. No public API, mapping reflection, runtime code generation or specification semantics change.

Source comparison: merged develop `221459f715f2c4a864212ea6838eebacdf067157`, the earlier inline-first prototype, and this compact design. On SPECTRE_GRD (Windows 11 x64, i7-1260P, SDK 10.0.401/runtime 10.0.12), a separate typed allocation probe used 5,000 warmups and five rounds of 1,000 mappings per depth; every round validated mapped shape, cycle failures and tracker reuse. Source construction and assertions were outside measurement.

| Chain depth | Develop bytes/op | Inline-first prototype bytes/op | Compact bytes/op |
|---:|---:|---:|---:|
| 1 | 232 | 72 | 56 |
| 2 | 264 | 280 | 264 |
| 16 | 1,744 | 1,760 | 1,744 |
| 128 | 15,528 | 15,544 | 15,528 |

These are total managed allocations per mapping, not peak memory. The fieldless private spill subtype matches the original HashSet allocation footprint; direct constructor probes measured 24 bytes for the tracker and 64 for either empty set type. Generator tests verify deferred storage and reuse relative to runtime allocation controls.

A separate six-pass BenchmarkDotNet experiment used develop/inline-first/compact/compact/inline-first/develop, 16 cases per pass, two launches, five warmups and ten measured iterations. It retained default CLR policies, affinity mask 4, a 45-second cooldown and a start-CPU check of at most 10%. `DontEnforcePowerPlan` (Guid.Empty) kept Balanced unchanged. Actual iterations were at least 164.905 ms. Timing controls drifted by up to 94.14%; compact depth-1 mappings were faster than develop in all four cross-pass comparisons, while deeper cases produced slowdown signals. Compact versus inline-first shallow ordering reversed between passes. The repository gate blocked all four compact/develop comparisons. This is allocation evidence with inconclusive throughput attribution, not a general speed win or release acceptance. The earlier 3.0.0/develop shallow tracked slowdown remains a separate historical concern; measurements from other PCs are not pooled.

Raw JSON, normalized results, all twelve gate reports, launch statistics, environment, hashes, source patch and test evidence are preserved in `LiteMapper-compact-state-SPECTRE_GRD-20261013.zip` (SHA256 `1D78057FA23247A5A3637132A9B9A5FFBE0A4E445C4C7F4A91588EDA6B995F59`). No diagnostic harness, binaries or benchmark thresholds are part of this shipping change. Final-head CI and stable same-binary controls remain necessary before a release performance decision.

## Issue #32: lazy cycle-path allocation evidence (2026-10-09)

- Baseline: unchanged develop `7b19def74753df90cb3770c7410ff5d7a3951244`. Candidate: issue #32's private inline-leaf/immutable-link diagnostic path representation.
Expand Down
7 changes: 7 additions & 0 deletions STATUS.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Mammoth.LiteMapper Status

## Compact cycle tracker production checkpoint (2026-10-13)

- Scope: promote the reviewed compact tracker from isolated experiments onto develop `221459f715f2c4a864212ea6838eebacdf067157`. One private object slot keeps the first source inline, safely escapes sentinel/type-tag collisions, promotes atomically to identity-based spill storage and retains that storage after unwind. Public API, cycle exception metadata, static mapping, finally cleanup and specification semantics remain unchanged.
- Tests: the prototype's red-first compact state cases yielded 15 failures and four passing controls on the unchanged emitter; allocation regressions also failed before implementation. Production-branch Release build has zero warnings/errors; 1,056 generator, five runtime and one integration tests pass with zero failures/skips. Independent seven-file review found no blocking findings. Local package validation is running with benchmark smoke and Native AOT explicitly deferred; exact-head Windows/Linux, Roslyn, package, trim and required Native AOT CI evidence will be recorded in the draft PR. Prototype results are not substituted for final-head checks.
- Performance: Windows x64 allocation probes show 56 versus 232 bytes per mapping at depth 1; depths 2/16/128 remain 264/1,744/15,528. Six-pass timing controls drifted up to 94.14%, with deeper slowdown signals and all four compact/develop gates blocked. No overall throughput or release acceptance claim. See PERFORMANCE_OPTIMIZATION_NOTES.md for controls, limits and the preserved evidence archive.
- Boundary: user authorized a scoped commit, push and draft PR. Diagnostic harnesses/raw binaries stay outside the shipping diff; benchmark gates remain unchanged. No merge, tag, release, publication or persistent machine-setting changes. The installed AgentStack CLI expects the obsolete flat asset layout and cannot operate on this repository's module layout; direct Git/GitHub CLI is used for the authorized PR, without issue creation or claim/comment mutations.

## Consumer skill review correction (2026-10-12)

- User-directed correction: the consumer skill and mapping reference describe the current library release contract directly. Previous versions appear only in breaking-change migration; normal recipes no longer frame supported behavior as upcoming or unreleased. The package command uses the consumer's configured feed and does not invent an available version. All skill document links and references stay inside its distributable folder; essential guidance is bundled instead of redirecting to repository documentation. This supersedes the initial skill framing in the checkpoint below.
Expand Down
44 changes: 40 additions & 4 deletions src/Mammoth.LiteMapper.Generator/LiteMapperGenerator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4649,19 +4649,55 @@ private static void AppendCycleTracker(StringBuilder builder)
builder.AppendLine();
builder.AppendLine(" private sealed class __LiteMapperCycleTracker");
builder.AppendLine(" {");
builder.AppendLine(" private readonly global::System.Collections.Generic.HashSet<object> _active = new global::System.Collections.Generic.HashSet<object>(__ReferenceIdentityComparer.Instance);");
builder.AppendLine(" private object? _state;");
builder.AppendLine(" public __LiteMapperCycleTracker() { _state = this; }");
builder.AppendLine();
builder.AppendLine(" public void Enter(object source, global::System.Type sourceType, global::System.Type destinationType, string mappingMethod, Path memberPath)");
builder.AppendLine(" {");
builder.AppendLine(" if (!_active.Add(source))");
builder.AppendLine(" if (_state is __ActiveReferences active)");
builder.AppendLine(" {");
builder.AppendLine(" throw new global::Mammoth.LiteMapper.LiteMapperCycleException(sourceType, destinationType, mappingMethod, memberPath.ToString());");
builder.AppendLine(" if (active.Add(source)) return;");
builder.AppendLine(" }");
builder.AppendLine(" else if (object.ReferenceEquals(_state, this))");
builder.AppendLine(" {");
builder.AppendLine(" if (object.ReferenceEquals(source, this) || source is __ActiveReferences)");
builder.AppendLine(" {");
builder.AppendLine(" var escaped = new __ActiveReferences();");
builder.AppendLine(" escaped.Add(source);");
builder.AppendLine(" _state = escaped;");
builder.AppendLine(" }");
builder.AppendLine(" else");
builder.AppendLine(" {");
builder.AppendLine(" _state = source;");
builder.AppendLine(" }");
builder.AppendLine(" return;");
builder.AppendLine(" }");
builder.AppendLine(" else if (!object.ReferenceEquals(_state, source))");
builder.AppendLine(" {");
builder.AppendLine(" var promoted = new __ActiveReferences();");
builder.AppendLine(" promoted.Add(_state!);");
builder.AppendLine(" promoted.Add(source);");
builder.AppendLine(" _state = promoted;");
builder.AppendLine(" return;");
builder.AppendLine(" }");
builder.AppendLine(" throw new global::Mammoth.LiteMapper.LiteMapperCycleException(sourceType, destinationType, mappingMethod, memberPath.ToString());");
builder.AppendLine(" }");
builder.AppendLine();
builder.AppendLine(" public void Exit(object source)");
builder.AppendLine(" {");
builder.AppendLine(" _active.Remove(source);");
builder.AppendLine(" if (_state is __ActiveReferences active)");
builder.AppendLine(" {");
builder.AppendLine(" active.Remove(source);");
builder.AppendLine(" }");
builder.AppendLine(" else if (object.ReferenceEquals(_state, source))");
builder.AppendLine(" {");
builder.AppendLine(" _state = this;");
builder.AppendLine(" }");
builder.AppendLine(" }");
builder.AppendLine();
builder.AppendLine(" private sealed class __ActiveReferences : global::System.Collections.Generic.HashSet<object>");
builder.AppendLine(" {");
builder.AppendLine(" public __ActiveReferences() : base(__ReferenceIdentityComparer.Instance) { }");
builder.AppendLine(" }");
builder.AppendLine();
builder.AppendLine(" public readonly struct Path");
Expand Down
Loading
Loading