Skip to content

Zero Allocations for TryFormatCanonical - #71

Merged
feO2x merged 7 commits into
mainfrom
70-try-format-canonical-zero-allocations
Aug 2, 2026
Merged

Zero Allocations for TryFormatCanonical#71
feO2x merged 7 commits into
mainfrom
70-try-format-canonical-zero-allocations

Conversation

@feO2x

@feO2x feO2x commented Aug 2, 2026

Copy link
Copy Markdown
Owner

Closes #70

Plan Deviations for Allocation-Free Canonical Formatting

Referenced Plans

  • 0058-fix-runtime-specific-number-metadata.md introduced the public
    CanonicalFloatingPointFormatter for runtime-independent double and float text.
  • 0061-add-utf-8-floating-point-formatting.md added its UTF-8 overloads and established the
    private per-call Dragon4 test seam.
  • 0070-0-try-format-canonical-zero-allocations.md introduced CanonicalTextFormatter for the
    remaining primitive metadata kinds and explicitly retained the existing floating-point formatter.

Deviations

Unified formatter surface

The implemented API no longer exposes a separate CanonicalFloatingPointFormatter. Its public
constants and its Format, TryFormat, and TryFormatUtf8 overloads for double and float are now
members of the partial CanonicalTextFormatter in Light.PortableResults.Text. The floating-point
surface resides in CanonicalTextFormatter.FloatingPoint.cs; the Grisu3, Dragon4, number-buffer, and
compatibility implementations remain internal types in Light.PortableResults.Numbers.

Production call sites, tests, and benchmarks use the unified formatter. The floating-point tests
continue to locate the two private generic TryFormatCore overloads for their forced-Dragon4 corpus,
so the test seam required by plans 0058 and 0061 is unchanged apart from its declaring type.

Rationale

Once plan 0070 added canonical formatting for every other primitive kind, retaining a second public
formatter made the API harder to discover and forced consumers such as MetadataValue to dispatch
between two classes with the same destination, atomicity, and allocation contracts. A partial class
keeps the large floating-point implementation in its own source file without creating a runtime or
performance boundary. The library is not yet stable and permits breaking API changes, so consolidating
the surface now is preferable to preserving the historical split through forwarding APIs.

This deviation changes API ownership and source organization only. Floating-point text, exceptions,
capacity behavior, allocation behavior, UTF-8 output, algorithm selection, and wire formats remain
unchanged.

Public code-unit helper

Plan 0070 originally required the shared code-unit helper to remain unexposed. The implemented
CanonicalCodeUnit is instead a public, top-level type in the focused Light.PortableResults.Text
namespace. This follows the repository's hide-in-plain-sight approach: advanced implementation-oriented
types remain accessible without adding them to the main namespace or nesting them inside a facade.

The type and its FromAscii<TCodeUnit> method are XML-documented. The public method supports
byte and char, matching the formatter's UTF-8 and UTF-16 destinations, and rejects other unmanaged
types with NotSupportedException. The supported generic instantiations retain their direct
allocation-free reinterpretation paths.

feO2x and others added 7 commits August 2, 2026 11:17
MetadataValue.TryFormatCanonical honors its span contract only for Double
and Single; the remaining ten kinds detour through ToCanonicalString and
allocate a throwaway string. The same missing primitives cost an
allocation per candidate on the TryGetXxx read path.

Key decisions recorded in the plan:

- Port the formatters from dotnet/runtime v6.0.36 rather than writing
  them from scratch. That line predates the intrinsics rewrites, so it is
  scalar and compiles on netstandard2.0, and XsdDuration is the normative
  source of the TimeSpan encoding the validators compare against.
- Cover UTF-8 as well as UTF-16 via the existing TCodeUnit renderer,
  closing the serializer integration deferred by #61.
- Ship exactly one implementation for both assets. Whether net10.0 should
  call the framework span formatters is deferred to a follow-up issue;
  this plan only produces the benchmark evidence.
- Do not route String, Char, or Uri through the UTF-8 API in the
  serializers: Utf8JsonWriter does not agree bytewise with pre-transcoded
  replacement bytes under UnsafeRelaxedJsonEscaping.
- Normalize DateTimeKind.Local to UTC in the formatter, matching
  FromDateTime, which keeps the 28-character bound.
- Preserve the allocation-free ToCanonicalString paths for Null, Boolean,
  String, and Uri.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AZTXiVaNguCvmjrTjwWLfQ
The netstandard2.0 layout guard called decimal.GetBits(decimal), which
allocates a four-element array on first use. That contradicted the
unqualified no-allocation criterion, and the warmed-up allocation test
could not have caught it: warm-up triggers type initialization before
measurement starts.

Seed the probe from decimal(int, int, int, bool, byte) instead. The
constructor defines the logical representation just as GetBits does, so
the check is no weaker, and it touches nothing on the heap.

Also isolate the guard in a decimal-specific helper so a layout failure
cannot disable integer, date, TimeSpan, and Guid formatting, and store
its outcome rather than throwing from the initializer, so callers see
PlatformNotSupportedException instead of TypeInitializationException
wrapping it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AZTXiVaNguCvmjrTjwWLfQ
None of these change the design; they stop the plan from asserting more
than is true.

- The read-path rationale claimed every text-accepting TryGetXxx
  reformats to validate. Nine do; TryGetDecimal, TryGetChar, and
  TryGetUri accept text without reformatting.
- "The remaining canonical encodings are ASCII" excluded nothing, but
  Char is included in that set and may be non-ASCII. Scope it to the
  non-text encodings and name the exception.
- bytesWritten is not guaranteed to equal charsWritten for the
  text-bearing kinds, rather than never equal to it: ASCII String, Char,
  and Uri values still have equal counts, so the assertion has to be
  conditioned on content.
- The one-implementation criterion read as banning the decimal
  extraction and transcoding shims the plan itself specifies. Require one
  canonical renderer, and confine target-specific code to those two
  shims, neither of which decides the output text.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AZTXiVaNguCvmjrTjwWLfQ
Tighten the allocation-free canonical formatting plan from 248 to 191
lines. All 17 acceptance criteria and every normative instruction are
preserved; the reduction is redundant prose, upstream line counts, and a
Scope section whose content already appeared elsewhere.

Restore five justifications that the condensation had removed:

- the derivations of MaximumDayNumber and MaximumTimeOfDayTicks, which
  netstandard2.0 cannot check against a BCL type
- why the encodings must be adapted rather than rederived: the TryGetXxx
  validators pin them, so a wrong rule breaks data already on the wire
- the evidence that the decimal layout assumption is real, namely that
  GetBits documents the logical representation and upstream DecCalc
  carries an explicit BIGENDIAN layout
- Numbers/ as the established provenance pattern to follow
- the rule behind the serializer adoption bullets: the writer transcodes
  UTF-16 itself and receives UTF-8 only for ASCII kinds

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AZTXiVaNguCvmjrTjwWLfQ
Add shared UTF-16 and UTF-8 canonical formatters for primitive metadata values, and route validation and JSON serialization through allocation-free span paths. Cover the new contracts with cross-asset tests, allocation assertions, benchmarks, provenance, and plan-deviation documentation.

Unify floating-point formatting under the partial CanonicalTextFormatter and expose the low-level CanonicalCodeUnit helper in the focused Text namespace.

BREAKING CHANGE: CanonicalFloatingPointFormatter has been removed. Use CanonicalTextFormatter for Double and Single canonical formatting.

Closes #70
Handle empty UTF-16 input consistently across package assets and keep UTF-8 capacity checks atomic for every representable span. Assert fixed-buffer invariants, consolidate unreachable failure paths, and extend the shared canonical corpus with empty text and negative scaled decimals.
Signed-off-by: Kenny Pflug <kenny.pflug@live.de>
@feO2x feO2x self-assigned this Aug 2, 2026
@feO2x feO2x added the enhancement New feature or request label Aug 2, 2026
@feO2x feO2x linked an issue Aug 2, 2026 that may be closed by this pull request
17 tasks
@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown

Code Coverage

Package Line Rate Branch Rate Complexity Health
Light.PortableResults 98% 94% 3219
Light.PortableResults.AspNetCore.MinimalApis 89% 75% 25
Light.PortableResults.AspNetCore.Mvc 89% 75% 25
Light.PortableResults.AspNetCore.OpenApi 94% 83% 505
Light.PortableResults.AspNetCore.Shared 100% 100% 28
Light.PortableResults.Validation 97% 89% 2954
Light.PortableResults.Validation.OpenApi 98% 91% 146
Light.PortableResults.Validation.OpenApi.SourceGeneration 92% 86% 1164
Summary 96% (14827 / 15417) 90% (6251 / 6914) 8066

Minimum allowed line rate is 60%

@feO2x
feO2x merged commit da479df into main Aug 2, 2026
2 checks passed
@feO2x
feO2x deleted the 70-try-format-canonical-zero-allocations branch August 2, 2026 12:13
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Allocation-free canonical formatting for MetadataValue

1 participant