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
12 changes: 8 additions & 4 deletions THIRD-PARTY-NOTICES.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
# Third-Party Notices

Light.PortableResults incorporates adapted floating-point number-formatting code from:
Light.PortableResults incorporates adapted primitive-formatting code from:

- Project: .NET Runtime
- Repository: https://github.com/dotnet/runtime
- Source line: `release/6.0`
- Immutable source: tag `v6.0.36`, commit `f1dd57165bfd91875761329ac3a8b17f6606ad18`
- Adapted files: `Number.Grisu3.cs`, `Number.DiyFp.cs`, `Number.Dragon4.cs`,
`Number.BigInteger.cs`, `Number.NumberBuffer.cs`, and `Number.Formatting.cs`
- Adapted files under `src/libraries/System.Private.CoreLib/src/System`:
`Number.Grisu3.cs`, `Number.DiyFp.cs`, `Number.Dragon4.cs`, `Number.BigInteger.cs`,
`Number.NumberBuffer.cs`, `Number.Formatting.cs`, `Decimal.DecCalc.cs`,
`Globalization/DateTimeFormat.cs`, and `Guid.cs`
- Adapted XML file: `src/libraries/System.Private.Xml/src/System/Xml/Schema/XsdDuration.cs`
- Local files: the adapted implementation files under
`src/Light.PortableResults/Numbers`, as detailed in that folder's `README.md`
`src/Light.PortableResults/Numbers` and `src/Light.PortableResults/Text`, as detailed in those
folders' `README.md` files

## .NET Foundation MIT License

Expand Down
231 changes: 231 additions & 0 deletions ai-plans/0070-0-try-format-canonical-zero-allocations.md

Large diffs are not rendered by default.

49 changes: 49 additions & 0 deletions ai-plans/0070-1-plan-deviations.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# 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.
85 changes: 85 additions & 0 deletions benchmarks/Benchmarks/CanonicalTextFormatterBenchmarks.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
using System;
using System.Globalization;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Engines;
using Light.PortableResults.Text;

namespace Benchmarks;

[MemoryDiagnoser]
[SimpleJob]
[GroupBenchmarksBy(BenchmarkDotNet.Configs.BenchmarkLogicalGroupRule.ByCategory)]
[CategoriesColumn]
public class CanonicalTextFormatterBenchmarks
{
private readonly Consumer _consumer = new();
private readonly DateTime[] _dateTimes =
{
DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc),
new DateTime(2026, 7, 26, 13, 45, 30, DateTimeKind.Utc).AddTicks(1_234_567),
DateTime.SpecifyKind(DateTime.MaxValue, DateTimeKind.Utc)
};
private readonly Guid[] _guids =
{
Guid.Empty,
new ("a1b2c3d4-e5f6-7890-abcd-ef1234567890"),
new ("ffffffff-ffff-ffff-ffff-ffffffffffff")
};

[Benchmark(Baseline = true)]
[BenchmarkCategory("Guid")]
public void FrameworkGuidTryFormat()
{
Span<char> destination = stackalloc char[CanonicalTextFormatter.MaximumGuidLength];
foreach (var value in _guids)
{
value.TryFormat(destination, out var charsWritten, "D");
_consumer.Consume(charsWritten);
_consumer.Consume(destination[0]);
}
}

[Benchmark]
[BenchmarkCategory("Guid")]
public void CanonicalGuidTryFormat()
{
Span<char> destination = stackalloc char[CanonicalTextFormatter.MaximumGuidLength];
foreach (var value in _guids)
{
CanonicalTextFormatter.TryFormat(value, destination, out var charsWritten);
_consumer.Consume(charsWritten);
_consumer.Consume(destination[0]);
}
}

[Benchmark(Baseline = true)]
[BenchmarkCategory("DateTime")]
public void FrameworkDateTimeTryFormat()
{
Span<char> destination = stackalloc char[CanonicalTextFormatter.MaximumDateTimeLength];
foreach (var value in _dateTimes)
{
value.TryFormat(
destination,
out var charsWritten,
"yyyy-MM-dd'T'HH:mm:ss.FFFFFFFK",
CultureInfo.InvariantCulture
);
_consumer.Consume(charsWritten);
_consumer.Consume(destination[0]);
}
}

[Benchmark]
[BenchmarkCategory("DateTime")]
public void CanonicalDateTimeTryFormat()
{
Span<char> destination = stackalloc char[CanonicalTextFormatter.MaximumDateTimeLength];
foreach (var value in _dateTimes)
{
CanonicalTextFormatter.TryFormat(value, destination, out var charsWritten);
_consumer.Consume(charsWritten);
_consumer.Consume(destination[0]);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@
using System.Globalization;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Engines;
using Light.PortableResults.Numbers;
using Light.PortableResults.Text;

namespace Benchmarks;

[MemoryDiagnoser]
[GroupBenchmarksBy(BenchmarkDotNet.Configs.BenchmarkLogicalGroupRule.ByCategory)]
[CategoriesColumn]
public class CanonicalFloatingPointFormatterBenchmarks
public class CanonicalTextFormatterFloatingPointBenchmarks
{
private const int ValueCount = 1_024;

Expand Down Expand Up @@ -87,7 +87,7 @@ public int CanonicalDoubleTryFormat()
var totalLength = 0;
foreach (var value in _doubleValues)
{
CanonicalFloatingPointFormatter.TryFormat(value, destination, out var charsWritten);
CanonicalTextFormatter.TryFormat(value, destination, out var charsWritten);
totalLength += charsWritten;
}

Expand Down Expand Up @@ -116,7 +116,7 @@ public int CanonicalSingleTryFormat()
var totalLength = 0;
foreach (var value in _singleValues)
{
CanonicalFloatingPointFormatter.TryFormat(value, destination, out var charsWritten);
CanonicalTextFormatter.TryFormat(value, destination, out var charsWritten);
totalLength += charsWritten;
}

Expand All @@ -141,7 +141,7 @@ public void CanonicalDoubleFormat()
{
foreach (var value in _doubleValues)
{
_consumer.Consume(CanonicalFloatingPointFormatter.Format(value));
_consumer.Consume(CanonicalTextFormatter.Format(value));
}
}

Expand All @@ -163,7 +163,7 @@ public void CanonicalSingleFormat()
{
foreach (var value in _singleValues)
{
_consumer.Consume(CanonicalFloatingPointFormatter.Format(value));
_consumer.Consume(CanonicalTextFormatter.Format(value));
}
}

Expand Down
30 changes: 30 additions & 0 deletions benchmarks/Benchmarks/CloudEventsWritingBenchmarks.cs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,34 @@ public void Setup()
"traceid",
MetadataValue.FromString("trace-456", MetadataValueAnnotation.SerializeInCloudEventsExtensionAttributes)
);
metadataBuilder.Add(
"correlationid",
MetadataValue.FromGuid(
new Guid("a1b2c3d4-e5f6-7890-abcd-ef1234567890"),
MetadataValueAnnotation.SerializeInCloudEventsExtensionAttributes
)
);
metadataBuilder.Add(
"recordedat",
MetadataValue.FromDateTime(
new DateTime(2026, 7, 26, 13, 45, 30, DateTimeKind.Utc).AddTicks(1_234_567),
MetadataValueAnnotation.SerializeInCloudEventsExtensionAttributes
)
);
metadataBuilder.Add(
"measurement",
MetadataValue.FromDouble(
36_028_797_018_963_968.0,
MetadataValueAnnotation.SerializeInCloudEventsExtensionAttributes
)
);
metadataBuilder.Add(
"sequence",
MetadataValue.FromInt64(
(long) int.MaxValue + 1,
MetadataValueAnnotation.SerializeInCloudEventsExtensionAttributes
)
);
var metadata = metadataBuilder.Build();

_genericSuccessWithMetadataResult = Result<ContactDto>.Ok(
Expand Down Expand Up @@ -459,4 +487,6 @@ public sealed class ContactDto
}

[JsonSerializable(typeof(CloudEventsWritingBenchmarks.ContactDto))]
[JsonSerializable(typeof(CloudEventsEnvelopeForWriting))]
[JsonSerializable(typeof(CloudEventsEnvelopeForWriting<CloudEventsWritingBenchmarks.ContactDto>))]
internal partial class CloudEventsWritingBenchmarksJsonContext : JsonSerializerContext;
20 changes: 20 additions & 0 deletions benchmarks/Benchmarks/HttpWriteSerializationBenchmarks.cs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,26 @@ public void Setup()
var metadata = MetadataObject.Create(
("correlationId",
MetadataValue.FromString("corr-123", MetadataValueAnnotation.SerializeInHttpResponseBody)),
("requestId",
MetadataValue.FromGuid(
new Guid("a1b2c3d4-e5f6-7890-abcd-ef1234567890"),
MetadataValueAnnotation.SerializeInHttpResponseBody
)),
("recordedAt",
MetadataValue.FromDateTime(
new DateTime(2026, 7, 26, 13, 45, 30, DateTimeKind.Utc).AddTicks(1_234_567),
MetadataValueAnnotation.SerializeInHttpResponseBody
)),
("measurement",
MetadataValue.FromDouble(
36_028_797_018_963_968.0,
MetadataValueAnnotation.SerializeInHttpResponseBody
)),
("sequence",
MetadataValue.FromInt64(
(long) int.MaxValue + 1,
MetadataValueAnnotation.SerializeInHttpResponseBody
)),
("traceId", MetadataValue.FromString("trace-456", MetadataValueAnnotation.SerializeInHttpHeader))
);
_genericSuccessWithMetadata = Result<ContactDto>.Ok(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,6 @@ namespace Light.PortableResults.CloudEvents.Writing.Json;
/// </summary>
public static class JsonCloudEventsExtensions
{
// Guid's canonical D format is the longest bounded primitive encoding. Arbitrarily long String and Uri
// values reuse their existing text when this buffer is insufficient.
private const int CanonicalTextBufferLength = 36;

/// <summary>
/// Serializes the contents of a <see cref="CloudEventsEnvelopeForWriting" /> into the provided
/// <see cref="Utf8JsonWriter" /> using the supplied serializer options.
Expand Down Expand Up @@ -375,7 +371,7 @@ private static void WriteStringAttribute(
MetadataValue value
)
{
Span<char> canonicalText = stackalloc char[CanonicalTextBufferLength];
Span<char> canonicalText = stackalloc char[MetadataValue.MaximumPrimitiveCanonicalLength];
if (value.TryFormatCanonical(canonicalText, out var charsWritten))
{
writer.WritePropertyName(attributeName);
Expand Down
6 changes: 6 additions & 0 deletions src/Light.PortableResults/Light.PortableResults.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@
hosts and corrected on .NET Framework and legacy Mono hosts.
- Adds allocation-free UTF-8 span formatting for canonical Double and Single values, plus public
maximum-length constants shared by the UTF-16 and UTF-8 formatting APIs.
- Adds CanonicalTextFormatter with allocation-free UTF-16 and UTF-8 formatters for all primitive
metadata types, MetadataValue.TryFormatCanonicalUtf8, and public cross-encoding length bounds.
- Removes canonical-string allocations from metadata round-trip validation, JSON metadata writing,
and CloudEvents extension-attribute writing for bounded primitive values.
- Emits primitive metadata arrays as ordered, separate HTTP header values. Custom header converters can reuse
the new public HttpHeaderValueFormatter.

Expand All @@ -28,6 +32,8 @@
- Values of the ten newly typed BCL metadata types no longer flatten to MetadataKind.String.
Their JSON representations now use the canonical encoding of their dedicated kind.
- Whole-number Double and Single metadata values serialize with a trailing .0.
- CanonicalFloatingPointFormatter has been consolidated into the partial CanonicalTextFormatter;
its floating-point constants and formatting overloads now reside on CanonicalTextFormatter.
- Default HTTP header conversion no longer surrounds string metadata values with quotes.
- Float metadata values now use MetadataKind.Single rather than MetadataKind.Double.
- Decimal metadata values now have the dedicated kind MetadataKind.Decimal instead of MetadataKind.String,
Expand Down
Loading
Loading