Compare commits

...

151 Commits

Author SHA1 Message Date
Loretta 4d75599988 Refactor: use byte[]-output AcBinarySerializer overloads
Refactored binary serialization and cloning to use pool-backed byte[]-output overloads of AcBinarySerializer, reducing allocations and improving performance. Updated CloneTo and CopyTo to use explicit runtime types. SignalParams now emits a Null marker for nulls. Updated SignalRSerializationHelper to use lighter overloads. Added DebugLogArgument for deserialization debug logging. Improved comments and code clarity.
2026-05-27 12:30:07 +02:00
Loretta b1cdf80fad Fix SignalR binary protocol: VarUInt framing & type-safe ser
- Add type-explicit ToBinary/SerializeToBinary overloads to preserve runtime type info for object? serialization, fixing deserialization bugs.
- Refactor VarUInt encoding/decoding to a prefix-tiered scheme (1–5 bytes), replacing LEB128 and preventing buffer overrun/corruption.
- Update all SignalR and serialization call sites to use new overloads.
- Sync SignalR binary protocol VarUInt decoding logic; fix test regressions.
- Add SIGNALR_BINARY_PROTOCOL_VARUINT.md with new wire-format spec and rationale.
- Add debug logging for argument serialization.
- Update .gitignore to not ignore itself.
- Resolves 65 KB value cap and missing in-band abort marker; requires both sender/receiver to use new framing for full compatibility.
2026-05-27 05:47:23 +02:00
Loretta 4a6e101410 Unify AcBinary string marker; prefix-tier VarUInt encoding
Refactored AcBinary to use a single String marker (167) for long-form strings, replacing StringLen8/16/32. Implemented prefix-tier VarUInt encoding for string lengths, introduced FixStrCount constant, and removed legacy LEB128 code paths. Updated all serialization/deserialization logic and documentation to match the new format. Includes related micro-optimizations and code cleanup.
2026-05-26 16:24:33 +02:00
Loretta cf92370bea Refactor AcBinarySerializer to use declared type dispatch
- All serialization APIs now use the declared type (typeof(T) or explicit Type) for dispatch, not value.GetType()
- Added non-generic overloads for Serialize, SerializeChunked, and SerializeChunkedFramed with Type parameter for runtime scenarios
- ScanForDuplicates accepts optional TypeMetadataWrapper to avoid redundant lookups
- Simplified generated writer path and improved wrapper usage
- Benchmarks updated to use new API and cache serialized data
- Minor cleanups: removed unused usings, improved comments, inlined logic
- Ensures consistent, predictable, and more performant type dispatch across all serialization entry points
2026-05-26 07:56:25 +02:00
Loretta d4e4c4480a SGen null-handling parity, micro-opt CV, doc & bench fixes
- Fix SGen collection/dictionary null-handling: always emit PropertySkip for nulls, preventing NREs regardless of nullable annotation.
- Add micro-opt CV threshold (1.5%) to benchmark output for finer-grained result flagging; update reporting and context.
- Benchmark loop: add inter-sample settle delay, trimmed median, and branchless progress for more reliable measurements.
- Add regression tests for SGen null-handling (complex, collection, dictionary; null/non-null; SGen/reflection; FastMode/Default).
- Update docs: clarify SGen null-check contract, add AQN binder security plan, and cross-reference related issues.
- Misc: code cleanups, improved comments, and minor doc clarifications.
2026-05-24 07:39:21 +02:00
Loretta 11d76270dc Fix SGen null complex prop bug, add CHUNK_ABORT to SignalR
- Fix SGen-generated writer bug: always null-check reference-type properties before serialization, emitting PropertySkip if null, to prevent runtime NREs.
- Add regression tests for SGen null complex property handling.
- Introduce CHUNK_ABORT ([203]) marker to SignalR binary protocol for graceful mid-stream serialize failure handling; update protocol logic and docs.
- Improve documentation to cover bug, fix, and new protocol marker.
- Minor: remove explicit net10.0 target from test csproj.
2026-05-23 09:26:48 +02:00
Loretta e2b96b4148 Switch to net9.0; improve AcBinary diagnostics & chunk fallback
- Change target framework to net9.0 in AyCode.Core.targets.
- Add DEBUG-only property access diagnostics to AcBinarySerializer for better error reporting.
- Update AcBinaryHubProtocol to dispose chunk state and retry normal parse on unknown bytes, improving resilience after serialization failures.
- Update comments to clarify new logic and rationale.
2026-05-22 23:37:44 +02:00
Loretta c2a22e5215 Refactor AcBinary string markers; charset/test renames
- Replace single String marker with StringLen8/16/32 (167–169) for explicit string length encoding (1/2/4 bytes)
- Update serialization/deserialization logic and marker checks for new scheme
- Rename charset suffixes: Latin1FixAscii → AsciiFix, add Latin1Fix (5-char Hungarian)
- Update menu, argument parsing, and references for new charset/test names and order
- Swap MaxDepthBehavior enum values: Throw=0, Truncate=1
- Update comments and docs for new marker and charset conventions
2026-05-22 20:04:11 +02:00
Loretta 7f677418af Refactor string wire format to universal FixStr/String
Refactored binary string serialization to use a new universal wire format with FixStr (135–166) and String (167) markers and unsigned excess slots, replacing the previous tiered approach. Updated all read/write logic, marker handling, and type code definitions accordingly. FastWire UTF-16 marker (91) now has dedicated handling with legacy compatibility. Removed legacy StringSmall/Medium/Big/Ascii code paths except for backward-compatibility. Improved buffer management in ArrayBinaryOutput and documented new critical issues regarding buffer ownership and disposal. Added new utility methods for marker encoding/decoding and excess slot sizing. Updated documentation and comments to match the new format.
2026-05-22 10:59:59 +02:00
Loretta 3adad03f15 Refactor string serialization, CLI args, and test data
- Refactored string serialization for performance: ASCII-optimistic encode, single pass, and minimal shifting; extracted string interning logic to TryWriteInternedString for both runtime and SGen paths.
- Updated AcBinarySerializer buffer writes to use BufferAt helper, removing redundant bounds checks.
- Enhanced CLI argument parsing to support multiple args and charset selection; unknown args now emit warnings.
- Switched all test data generation from Hungarian to English.
- Benchmark report now includes .NET runtime version.
- Cached MinStringInternLength in AcBinaryDeserializer for performance.
- Minor BinaryTypeCode flag refactor and doc improvements.
- Added BINARY_ISSUES.md entry for FastWire string interning/ref handling desync bug.
2026-05-21 21:03:03 +02:00
Loretta 7fb74dbbb0 Refactor AcBinary string marker dispatch & add JIT harness
- Refactored AcBinaryDeserializer string marker dispatch: hot path now handles all non-interning markers in a single inlinable method, cold path only for interning markers; improved safety and comments.
- Updated SGen codegen to only emit cold-path for interning-enabled types.
- Added debug guard for corrupted wire in ReadStringBig.
- Rewrote JitDisassemblyBenchmark as a direct JIT-disasm harness (outside BDN); updated Program.cs to use it for --jitasm.
- Benchmarks now pin charset to Latin1Short for cross-process consistency.
- Added new Bash commands for diff, JIT disasm, and diagnostics in settings.local.json.
2026-05-20 12:49:43 +02:00
Loretta 8c20e23ea6 Add SGenOnly build config and centralize build settings
Introduced a new "SGenOnly" build configuration across the solution, updating Directory.Build.props, AyCode.Core.targets, and the .sln file for full support. Centralized TargetFramework and build properties in AyCode.Core.targets, removing redundancy from project files. Updated code to recognize SGEN_ONLY at compile time. Added new Bash commands for file conversion and cleanup. No functional code changes outside build and configuration logic.
2026-05-19 17:41:06 +02:00
Loretta b8d0d85c99 Refactor charset profiles; split StringSmall decode paths
- Benchmark charset profiles are now length-consistent: all *Short = 40 chars, all *Long = 280 chars, across ASCII, Latin1, CJK BMP, Cyrillic, and Mixed.
- `CharsetSuffixes` was rewritten with new profiles and base-string repetition for compile-time constants.
- Menu/configuration updated for new profiles, selection logic, and improved descriptions.
- Docs updated to reflect new profiles, lengths, and serialization tier impacts.
- `StringSmall` deserialization split into `ReadStringSmallCompact` and `ReadStringSmallFastWire`; all call sites now dispatch by mode, clarifying the hot path.
- SGen codegen and runtime dispatch tables updated for the new decode split.
- Binary marker docs clarified: only Intern/Metadata/Polymorph features are wire-symmetric for reader case omission; RefHandling is not.
- Added `BINARY_STRICT_SGEN.md` planning doc for a SGen-only, attribute-required, AOT-friendly NuGet package.
2026-05-19 12:58:22 +02:00
Loretta 3671c70aa1 Fix SGen ref-handling asymmetry; add regression tests
Refactored AcBinarySourceGenerator to use RefAwareEmitPredicate for all ref-handling switch decisions, ensuring child property ref-marker logic is based solely on child compile-time flags. Fixed deserialization drift when parent disables ref-handling but child enables it. Added regression tests and new test models to verify correct round-trip behavior for duplicate child references in collections and dictionaries. Improved XML docs and updated conventions for summary tags. Added SGen string round-trip tests for medium UTF-8/ASCII cases.
2026-05-19 08:32:39 +02:00
Loretta f631fd4b78 AcBinary: Hot/cold marker split for string deserialization
Refactored string property deserialization to separate hot (common, no-feature) and cold (feature-engaged) marker handling, improving JIT inlining and cold-start performance. Introduced `TryReadStringProperty` (hot, inlined) and `TryReadStringColdPath` (cold, optimized) methods. Updated method attributes for better JIT control and clarified WASM string-cache dead code. Added `BINARY_BYTECODE_OPTIMIZATION.md` and updated related docs. Removed AutoMapper, updated logging package versions, and adjusted project files and settings accordingly.
2026-05-18 15:20:56 +02:00
Loretta f68b797a9f Stabilize BDN runs; improve benchmark output ordering
Added WithProcessStabilization to pin CPU affinity and raise process priority for all BDN entry points, matching Console runner stabilization. Benchmark results are now ordered by Engine then RtPerOp for stable, diff-friendly output. Report headers clarify when BDN manages run parameters. Enhanced comments for clarity; no changes to benchmark logic.
2026-05-15 23:05:06 +02:00
Loretta c611d4b535 Refactor: BDN runner, unified reporting, doc overhaul
- Introduce BDN-based runner (AcBinaryVsMemPackBenchmark) mirroring Console's FastestByte scenario; add BdnSummaryAdapter for unified result translation.
- Standardize output: both runners emit .log/.LLM/.output triplets; BDN-native artifacts go under Benchmark/BDN/.
- Simplify CLI: replace granular switches with --serializers; update help and usage.
- Remove legacy benchmark classes; focus on scenario-based approach.
- Rewrite README.md for both AyCode.Benchmark and Console to document dual-runner architecture, output conventions, and dependencies.
- Rotate BINARY_TODO.md; archive closed entries to BINARY_TODO_2026_04.md and BINARY_TODO_2026_05.md.
- Add BINARY_SGEN_OPTIMIZATION.md for SGen per-property emit optimization notes.
- Update comments and docstrings for clarity and maintainability; clarify BenchmarkResult iteration semantics for BDN rows.
2026-05-15 20:54:42 +02:00
Loretta ed03d754ec Refactor Output to BenchmarkReportWriter
- Removed Output.cs and migrated all reporting, formatting, and statistics logic to a new BenchmarkReportWriter static class.
- Extended ReportingContext with run-header fields for richer output metadata.
- Updated BenchmarkLoop to use BenchmarkReportWriter and the new ReportingContext.
- Centralized all output file generation (.log, .LLM, .output) and formatting helpers in BenchmarkReportWriter.
- Improved separation of concerns and unified output artifact naming and metadata.
2026-05-15 20:18:13 +02:00
Loretta 9dcb62ce23 Refactor: move benchmark engines to shared scenarios
Major refactor: all benchmark engine implementations, enums, and helpers moved from Console project to AyCode.Core.Benchmarks.Workloads.Scenarios for unified cross-runner use. BenchmarkResult and reporting logic moved to AyCode.Core.Benchmarks.Reporting. Attribute-flag aggregation centralized in BenchmarkOptions. Updated all usages, project references, and SGen codegen for ref-handling. Prepares codebase for shared reporting and future extensibility.
2026-05-15 19:55:52 +02:00
Loretta d9ab3940eb Refactor AcBinarySourceGenerator into partial classes
Split AcBinarySourceGenerator.cs into multiple partial class files for improved maintainability and clarity. Each major concern (models, type analysis, class info extraction, writer/reader emit, diagnostics, and module init) now resides in its own file. Updated .gitignore and settings.local.json to support the new structure. No functional changes to generator output; this is a pure organizational refactor.
2026-05-15 18:54:22 +02:00
Loretta 638be8c52e Refactor AcBinaryDeserializer for conciseness
Refactored several methods to use ternary operators and single-line returns for early-exit and exception cases. Improved code readability by condensing multi-line if/else and null checks into concise expressions. No changes to functionality.
2026-05-15 10:51:05 +02:00
Loretta 853aa23e37 Refactor string deserialization logic to context methods
Moved StringSmall/Medium/Big/Ascii readers from static helpers in AcBinaryDeserializer to instance methods on BinaryDeserializationContext. Updated all call sites (runtime, SGen, type reader table) to use the new methods. Improved documentation, clarified wire format handling, and added a corrupted-wire guard for StringBig. Removes duplication and centralizes string wire-decode logic.
2026-05-15 10:43:49 +02:00
Loretta 8293a6edd1 Refactor: hoist interned string decode to context methods
- Moved interned string decode logic to BinaryDeserializationContext instance methods, reducing duplication and unifying SGen, runtime, and cross-type paths.
- Updated SGen-emitted code and TypeReaderTable to use new context methods.
- Added performance TODO (ACCORE-BIN-T-K9M3) documenting rationale and acceptance.
- Clarified AcBinarySerializableAttribute XML docs.
- Added repo-scoped nuget.config for deterministic restore.
- Updated settings.local.json with new Bash/dev commands.
- Minor code and comment cleanups for clarity.
2026-05-15 10:14:19 +02:00
Loretta 67b04612a4 [LOADED_DOCS: 3 files, no new loads]
Add per-type EnablePolymorphDetectFeature flag

Replaces the global UsePolymorphType constant with a per-type EnablePolymorphDetectFeature flag on AcBinarySerializableAttribute. The source generator now emits or omits polymorphic type info for System.Object properties based on this flag, defaulting to enabled. Updates diagnostics, documentation, and SerializableClassInfo to support this feature, clarifying the risks of disabling it and improving attribute XML docs for all feature flags.
2026-05-15 09:00:18 +02:00
Loretta 89618c1d10 [LOADED_DOCS: 3 files, no new loads]
Rename ShallowCopy to FlatCopy, add polymorph support

- Renamed all "ShallowCopy" serializer presets and references to "FlatCopy" for clarity and consistency.
- Expanded documentation to clarify flat serialization use cases, especially for delta-update and partial-write scenarios.
- Added EnablePolymorphDetectFeature to AcBinarySerializableAttribute and updated all constructor overloads.
- Set UsePolymorphType = true in AcBinarySourceGenerator to enable polymorphic type support by default.
- Updated all [AcBinarySerializable(...)] usages to include new feature flags, explicitly disabling property filter and polymorph detection for affected types.
- Improved comments and documentation for maintainability.
2026-05-15 08:40:52 +02:00
Loretta a7f2d3605b [LOADED_DOCS: 3 files, no new loads]
Add compile-time error for object-typed props w/o polymorph

Introduces ACBIN002: a compile-time diagnostic that errors if a [AcBinarySerializable] type declares a System.Object property while UsePolymorphType is false. This prevents silent wire corruption by requiring the developer to either enable polymorphic serialization, use a concrete type, or ignore the property. The check is integrated into the source generator initialization.
2026-05-15 08:11:51 +02:00
Loretta f051f32bfa Refactor MaxDepth handling: explicit Throw/Truncate/Disable
- Introduce `MaxDepthBehavior` option (`Throw`, `Truncate`, `Disable`) for explicit depth-limit handling in AcBinarySerializer and SGen.
- Default is now `Throw` (fail-fast); `ShallowCopy` preset uses `Truncate` for shallow-copy semantics.
- Refactor runtime and SGen paths to use unified `TryEnterRecursion`/`ExitRecursion` for correct wire output and inc/dec symmetry.
- Add focused tests to diagnose SGen+Truncate wire-misalignment bug (see `BINARY_ISSUES.md#accore-bin-i-t7k3`).
- Update docs and comments to clarify new behavior and document Toon serializer's current lack of `MaxDepthBehavior` support.
- Adjust tests and usages for new semantics and improved safety.
2026-05-14 14:13:48 +02:00
Loretta 6c61030c8a Add MaxDepthBehavior for serializer recursion control
Introduced MaxDepthBehavior enum and option to control recursion depth handling (Truncate, Throw, Disable) in AcSerializerOptions. Refactored depth-check logic to use a precomputed NeedsDepthCheck flag. Enhanced exception messages for depth-limit violations. Updated tests to assert correct exception behavior for cycles. Improved documentation and added new test/log commands in settings.local.json.
2026-05-14 00:13:06 +02:00
Loretta ac6e66f59f Remove depth param from serializers; use context field
Refactored AcBinary, AcJson, and AcToon serializers to eliminate the explicit depth parameter from all serialization/deserialization methods, generated code, and interfaces. Introduced a global RecursionDepth field on the serialization context, incremented/decremented at recursion entry/exit, and enforced against MaxDepth as a safety net (except when ReferenceHandling=All). Updated all usages, including property, array, and dictionary handling, to use the new context-based depth tracking. Ensured consistency across runtime and generated code.
2026-05-13 23:02:15 +02:00
Loretta b849beb2ee Refactor: outline single-byte buffer grow for hot path
Refactored WriteByte, WriteVarUInt, and WriteVarULong to use a new GrowOne helper for single-byte buffer growth. This moves the Output.Grow call out of the hot path, improving inlining and serialization performance for frequent single-byte writes. Added detailed comments explaining the rationale and AOT benefits.
2026-05-13 20:01:11 +02:00
Loretta 72dab46bde Remove "Test Type" from benchmark outputs
The "Test Type" (TypeName) field was removed from all benchmark output locations, including console summaries, formatted output, and markdown result files. No functional changes were made to the test model definitions; only a BOM was added to SharedTestOrderModels.cs. All other benchmark summary details remain unchanged.
2026-05-13 15:03:39 +02:00
Loretta 027ff6bd49 Refactor benchmark infra: generic, multi-variant test data
Refactored the benchmark and test data infrastructure to use generic, type-safe, and multi-variant models. Introduced generic base classes for the test data hierarchy and factories, with closing-generic aliases for _All_True and _All_False families. Benchmarks now select the correct test data variant per serializer options, and all serializers are generic over the order type. Output and result reporting now include the CLR type name for clarity. Centralized string property handling and improved documentation throughout.
2026-05-13 13:54:53 +02:00
Loretta 32f2de0db3 [LOADED_DOCS: 2 files, no new loads]
Refactor tests to use _All_True model types throughout

Replaced all usages of legacy test model types (e.g., TestOrder, TestOrderItem, SharedTag, etc.) with new, feature-complete _All_True variants across SignalR test infrastructure, data sources, and service handlers. Updated all generic constraints, method signatures, and test data to use the new types. Added SharedTestBaseModels.cs and SharedTestOrderModels.cs to define abstract bases and concrete _All_True models with full serialization attributes. This enables more thorough and realistic serialization/deserialization testing and future extensibility.
2026-05-13 08:40:42 +02:00
Loretta 23f2f57fa7 [LOADED_DOCS: NONE]
Refactor benchmark suite to use enums for config

Replaced string parameters for layer, opMode, and serializerMode with strongly-typed enums (BenchmarkLayer, BenchmarkOpMode, SerializerSelectionMode) across BenchmarkLoop, Menu, and Program. Updated CLI parsing and menu logic to use Enum.TryParse and return enums. Added XML docs for new enums. Improves type safety, code clarity, and maintainability.
2026-05-13 06:19:58 +02:00
Loretta eaafb00739 [LOADED_DOCS: 2 files, no new loads]
Refactor benchmarks to use typed enums for engine/mode

Replaced string-based identifiers for serializer engine, I/O mode, and dispatch mode with strongly-typed enums (BenchmarkEngine, BenchmarkIoMode, BenchmarkDispatchMode). Added BenchmarkEnums.cs with ToDisplay() helpers for consistent output. Updated all benchmark implementations, DTOs, and output logic to use enums. Removed obsolete string constants from Configuration.cs. Merged allocation measurement methods in BenchmarkLoop.cs for clarity. Improves type safety, maintainability, and output consistency.
2026-05-13 05:58:34 +02:00
Loretta ad9e05413c [LOADED_DOCS: 2 files, no new loads]
Refactor: move benchmark logic to BenchmarkLoop.cs

Moved all benchmark execution logic (RunBenchmark, RunBenchmarksForTestData, CreateSerializers) from Program.cs into a new static class BenchmarkLoop in BenchmarkLoop.cs. Program.cs now delegates benchmark runs to BenchmarkLoop, improving separation of concerns. No changes to benchmark functionality.
2026-05-12 14:09:43 +02:00
Loretta c722f775f6 Refactor: move serializer benchmarks to separate files
Moved all ISerializerBenchmark implementations for AcBinary and MemoryPack from Program.cs into dedicated files under Benchmarks/. Improves code organization and maintainability; no logic changes, only file structure refactor.
2026-05-12 13:52:28 +02:00
Loretta bf42815ee5 [LOADED_DOCS: 2 files, no new loads]
Refactor: extract serializer benchmarks to separate files

Moved AcBinary, MemoryPack, MessagePack, and SystemTextJson benchmark classes into dedicated files for clarity. Centralized options formatting and MemoryPack selection logic in a new BenchmarkOptions helper. Updated Program.cs to use these helpers and removed redundant inline implementations, improving code organization without changing benchmark logic.
2026-05-12 13:24:15 +02:00
Loretta 7fe21480e1 [LOADED_DOCS: 2 files, no new loads]
Extract ISerializerBenchmark to its own file

Moved ISerializerBenchmark from Program.cs to a new ISerializerBenchmark.cs file under the AyCode.Core.Serializers.Console.Benchmarks namespace. Updated all benchmark classes in Program.cs to implement the interface from the new namespace and made them internal. Added the necessary using directive to Program.cs. Adjusted a PowerShell script in settings.local.json to ensure the new using is present. Removed the old interface definition from Program.cs.
2026-05-12 13:02:39 +02:00
Loretta 866217a805 [LOADED_DOCS: 2 files, no new loads]
Refactor: move benchmark loop logic to BenchmarkLoop.cs

Refactored all benchmark execution infrastructure from Program.cs into a new internal static class BenchmarkLoop. This includes timing, allocation measurement, progress reporting, GC helpers, MemoryPack setup validation, and test data filtering. Updated Program.cs and all serializer benchmarks to use the new class. Added serAllocPct reporting in Output.cs and a PowerShell script for automated refactoring. No functional changes to benchmark logic.
2026-05-12 11:15:08 +02:00
Loretta 8e8790924c [LOADED_DOCS: 2 files, no new loads]
Refactor: split Program.cs into Menu, Output, DTO

Refactored the benchmark console app for modularity:
- Moved all menu logic to Menu.cs (main/settings menus)
- Moved all output/result formatting to Output.cs
- Extracted BenchmarkResult DTO to BenchmarkResult.cs
- Program.cs now only handles orchestration and the benchmark loop
- Moved GetCurrentCharsetName to Configuration.cs
- Removed obsolete Warmup methods from serializers
No functional changes; improves clarity and maintainability.
2026-05-12 08:33:53 +02:00
Loretta eb3185c78d [LOADED_DOCS: 2 files, no new loads]
Refactor: centralize config/state in Configuration.cs

Moved all benchmark configuration, mutable state, and attribute-flag aggregation from Program.cs to a new Configuration.cs static class. Updated all references in Program.cs and related benchmark classes to use Configuration.<value>. Removed the "profiler" CLI mode and its code. Updated README.md to reflect these changes. This improves maintainability and keeps Program.cs focused on orchestration and UX, with no changes to benchmark logic.
2026-05-11 21:22:48 +02:00
Loretta 46b26b7238 [LOADED_DOCS: 2 files, no new loads]
Refactor output, allocation, and summary logic in Program

- Switched if/else and range checks to C# switch expressions for clarity.
- Improved console progress display with cleaner line updates.
- Added Thread.Sleep after JIT pre-warmup for stable benchmarking.
- Enhanced allocation measurement for serializer/deserializer setup.
- Made options and summary output conditional and more consistent.
- Standardized string outputs and comparison headers.
- Improved comments, XML docs, and code style for maintainability.
- No changes to core algorithms; all changes are quality-of-life and output improvements.
2026-05-11 20:25:39 +02:00
Loretta 969fa550b5 [LOADED_DOCS: 2 files, no new loads]
Phase-isolated Ser/Des warmup & GC in benchmarks

Refactored benchmark loop to perform separate warmup and measurement for serialization and deserialization phases, with forced GC.Collect at each phase boundary for heap and cache isolation. Added ForceGcCollect() and new WarmupSerialize/WarmupDeserialize interface methods (with defaults). Updated output, documentation, and per-phase iteration handling for improved accuracy and clarity. Added detailed comments explaining rationale and effects.
2026-05-11 13:52:38 +02:00
Loretta 73d81ea580 [LOADED_DOCS: 7 files, no new loads]
AcBinary: add framing doc, buffer growth fixes, doc updates

- Added `BINARY_WHYUSE.md` for architectural framing and value proposition
- Updated `BINARY_FEATURES.md` and `README.md` to reference and prioritize the new doc
- Documented AsyncPipeWriterOutput chunk-size limitation and workarounds in `BINARY_ASYNCPIPE_ISSUES.md`
- Refactored buffer growth logic in `AcBinarySerializer.BinarySerializationContext.cs` to validate capacity after grow and throw clear exceptions on under-provisioning; removed dead method
- Fixed chunk size alignment bug in `AsyncPipeWriterOutput.cs` to prevent buffer under-provisioning
- Added `AYCODE_NATIVEAOT` build config support in `Program.cs`
- Improved documentation clarity and error diagnostics for streaming/buffered serialization edge cases
2026-05-11 13:28:43 +02:00
Loretta 96c09a65bb [LOADED_DOCS: 2 files, no new loads]
Enable per-type property filter opt-out in AcBinary

Adds EnablePropertyFilterFeature to AcBinarySerializableAttribute, allowing types to opt out of property filter codegen and runtime checks. Updates source generator, metadata, and runtime logic to honor this flag. Removes UsePropertyFilter constant; emission is now attribute-driven. Also optimizes string serialization for non-ASCII cases and refactors deserializer byte reads for trusted single-segment fast paths. Backward compatible: property filter remains enabled by default.
2026-05-10 19:01:30 +02:00
Loretta eb4b6e7f8f [LOADED_DOCS: 2 files, no new loads]
Optimize AcBinary: add IsTrustedSingleSegment fast-path

Introduce static abstract IsTrustedSingleSegment to IBinaryInputBase and implement in all input types. Update EnsureAvailable to leverage JIT specialization for array-backed inputs, eliminating per-read overhead. Add detailed XML docs on performance trade-offs. No breaking changes; improves deserialization efficiency and clarity.
2026-05-10 16:19:07 +02:00
Loretta 81bc41c118 [LOADED_DOCS: 2 files, no new loads]
FastWire: Add markerless string encoding/decoding

Introduced a markerless FastWire path for string properties and collection elements in AcBinary serialization. Strings are now encoded with a 4-byte int32 sentinel header (-1=null, 0=empty, N>0=content) and UTF-16 bytes, eliminating the type code marker in FastWire mode. Updated code generation, runtime, and documentation to support this, while preserving Compact mode behavior and cross-mode compatibility.
2026-05-10 15:59:31 +02:00
Loretta 3f20948cde [LOADED_DOCS: 3 files, no new loads]
Use ReadOnlySequence<byte> in benchmarks for deserialization

Updated all AcBinary and MemoryPack benchmark deserialization and round-trip verification methods to use ReadOnlySequence<byte> overloads instead of byte[] or ToArray(). This ensures benchmarks exercise the production-realistic deserialization path (e.g., for SignalR/Pipe consumers) and aligns buffer writer semantics across serializers. Added comments to clarify intent. No business logic was changed.
2026-05-10 09:47:41 +02:00
Loretta ef2cafbc38 [LOADED_DOCS: 3 files, no new loads]
Improve AcBinary/MemoryPack bench parity & reporting

- Add geometric/median/arith mean deltas to overall bench output for robust performance comparison.
- Align MemoryPack string encoding with wire mode for fair apples-to-apples results.
- Refactor summary/log/LLM output to use new aggregation methods.
- Add temporary SGen feature gates for A/B testing property filter and polymorph overhead (set false for bench).
- Switch FastWire string encoding to fixed 4-byte LE char length (matches MemPack).
- Update SIMD/transcoder docs: document switch to BCL Utf8 APIs, which outperform custom SIMD.
- Minor code cleanups and improved comments.
- No wire-format changes; all updates are perf/bench/codegen only.
2026-05-10 09:08:31 +02:00
Loretta 1d256ea386 [LOADED_DOCS: 3 files, no new loads]
Switch to BCL UTF-8 APIs for string (de)serialization

Replaced custom Utf8Transcoder logic with System.Text.Encoding.UTF8 and System.Text.Unicode.Utf8 for string encoding/decoding in AcBinarySerializer and AcBinaryDeserializer. PropertyMetadataBase now uses Encoding.UTF8.GetBytes for property name encoding. Retained Utf8Transcoder for any remaining SIMD/custom logic. No public API changes; internal refactoring for performance and maintainability.
2026-05-07 23:54:57 +02:00
Loretta 8eaae4dda3 [LOADED_DOCS: 3 files, no new loads]
Benchmark stabilization & charset-param workload support

Major overhaul of the custom benchmark harness:
- Per-serializer warmup, GC isolation, pilot discard, and CPU pinning for stable, reproducible results
- Adaptive per-cell iteration targeting (~250ms/sample) and statistical reporting (min/max/stddev/CV)
- CLI/menu support for single-cell A/B runs
- Test data refactored to ASCII baselines with configurable charset suffix (6 presets), selectable via menu; charset recorded in all outputs
- Markdown/console output now includes per-op µs, inter-sample range, CV warnings, and iteration counts
- Documentation updated with rationale, methodology, and notes on reverted/experimental optimizations

Enables reliable, cross-charset, release-grade performance measurement for AcBinary.
2026-05-07 19:13:19 +02:00
Loretta 17ef0904d9 Defensive string guards, cleanup, and SGen/RT tests
- Add overflow/corruption guards to string (de)serialization (writer/reader now throw on invalid lengths)
- Remove dead string serialization methods per BINARY_TODO.md audit
- Update BINARY_TODO.md with closure/resolution for H2Q6, O7G2, V4N5, and related entries
- Add MaxStringCharLength constant and update marker reservations in BinaryTypeCode
- Simplify string cache ASCII verification in deserializer
- Add SGen/Runtime round-trip compatibility tests for large/deep data
- Minor code modernization and style improvements
2026-05-07 14:33:39 +02:00
Loretta fa48596dbf [LOADED_DOCS: 8 files, no new loads]
AcBinary: H2Q6 string marker overhaul, 1-pass decode

- Replace FixStr/String with tiered StringSmall/Medium/Big markers for non-ASCII strings (v3 wire format)
- Split StringInternFirst into Small/Medium tiers for interned strings
- Remove all FixStr (non-ASCII) code; FixStrAscii path unchanged
- Writers select smallest tier post-encode; readers use new 1-pass decode helpers
- Update BinaryTypeCode.cs with new marker constants and reservation docs
- Update SGen and all string read/write/skip logic for new markers
- Document marker layout, optimization policy, and endianness caveat in BINARY_FEATURES.md, BINARY_ISSUES.md, BINARY_TODO.md
2026-05-07 09:52:10 +02:00
Loretta abee22b31a [LOADED_DOCS: 3 files, no new loads]
SIMD Utf8Transcoder.GetUtf8ByteCount + test suite

Introduced SIMD-accelerated Utf8Transcoder.GetUtf8ByteCount for efficient UTF-8 byte counting, replacing all writer-side Encoding.UTF8.GetByteCount usages. Added 29 unit tests for correctness across ASCII, Hungarian, CJK, emoji, and boundary cases. Updated benchmarks to ensure FixStr is bypassed and wire mode is selectable. Documented implementation and dead-code review in BINARY_TODO.md. No public API changes.
2026-05-06 13:52:35 +02:00
Loretta 304a4a7bdb [LOADED_DOCS: NONE]
Fix Utf8Transcoder AVX2 bug, add SIMD boundary tests

- Added Hungarian language preference rule to copilot-instructions.md.
- Fixed AVX2 SIMD bug in Utf8Transcoder: corrected upper-half store offset from Vector128<ushort>.Count to Vector256<ushort>.Count, preventing memory overlap on 32+ byte ASCII runs.
- Added Utf8TranscoderTests covering all SIMD/scalar paths, with boundary and round-trip tests for ASCII, Hungarian, CJK, emoji, and mixed content, ensuring correctness and BCL compatibility.
2026-05-05 23:08:11 +02:00
Loretta 8f3bbeacc1 [LOADED_DOCS: 2 files, no new loads]
Refactor: extract UTF-8 transcoder to Utf8Transcoder

Moved all UTF-8/UTF-16 encoding, decoding, and char counting logic from AcBinarySerializer/AcBinaryDeserializer into a new internal Utf8Transcoder class. Updated all call sites to use the new class. Removed redundant private methods from the original classes. Updated BINARY_TODO.md to clarify SIMD decode status and rationale for deferring AVX2 multi-byte SIMD path. No functional changes—pure refactor for maintainability and future SIMD work.
2026-05-05 15:44:56 +02:00
Loretta 651e2a0b9f [LOADED_DOCS: 3 files, no new loads]
SIMD UTF-8 upgrades, i18n test data, MVC disabled

- Switch all test/benchmark data to Hungarian UTF-8 strings for i18n coverage
- Add AVX-512BW, Vector256, and Vector128 SIMD paths for UTF-8/UTF-16 encode/decode (ASCII and multi-byte) in binary serializer/deserializer
- Update WireMode docs for encoding guidance per workload/host
- Block-comment and disable MVC formatters and Microsoft.AspNetCore.App reference due to .NET 10 Hybrid client conflict; update docs to reflect temporary state
- Update appsettings: replace WaitForFlush with FlushPolicy
- Revise BINARY_TODO.md for SIMD transcoder progress and next steps
2026-05-05 15:06:11 +02:00
Loretta 58f7a1c286 [LOADED_DOCS: 3 files, no new loads]
Add docs for AcBinary MVC formatters and pipeline updates

Comprehensive documentation for new ASP.NET Core MVC formatters supporting AcBinary, including registration, media type, request/response flow, error handling, and future plans. Updated project and topic docs to reference MVC formatters and folder structure. Added performance planning entry for StreamPipeWriter congestion fallback. Expanded markerless schema lane rationale and updated architecture docs to reflect MVC formatter integration. Improved navigation and layering documentation.
2026-05-05 06:55:32 +02:00
Loretta 7d9cf10a6e [LOADED_DOCS: 2 files, no new loads]
Remove PipeReader APIs from AcBinaryDeserializer

Refactored to remove all PipeReader-based async deserialization methods from AcBinaryDeserializer. Updated BINARY_TODO.md to clarify that draining PipeReader to AsyncPipeReaderInput is now a consumer responsibility. Refactored AcBinaryInputFormatter to inline the drain-loop and background deserialization, following new layering guidance. Updated comments and docs to reflect these changes.
2026-05-04 14:42:17 +02:00
Loretta e139eca389 [LOADED_DOCS: 2 files, no new loads]
AcBinary: Add ASCII string markers, doc optimizations

Enhanced string encoding with FixStrAscii/StringAscii markers for efficient ASCII handling, updated header flag base to 0xB0, and expanded documentation with marker-dispatch logic, performance results, and markerless schema lane plans.
2026-05-04 14:36:16 +02:00
Loretta 7b94d81485 [LOADED_DOCS: 2 files, no new loads]
AcBinary: ASCII string opt, Type-based API, MVC support

- Add ASCII-optimized string serialization/deserialization with new FixStrAscii/StringAscii markers for fast byte→char widening.
- Introduce non-generic Type-based Serialize/Deserialize overloads for runtime-typed scenarios (plugin, MVC, model binding).
- Add AcBinaryInputFormatter/OutputFormatter and AddAcBinaryFormatters extensions for ASP.NET Core MVC integration.
- Update project references and close ACCORE-BIN-T-N9G6 in docs.
2026-05-04 13:20:33 +02:00
Loretta 265b89da0a [LOADED_DOCS: 2 files, no new loads]
Add issue/TODO for AcBinary default-value omission risk

Documented ACCORE-BIN-I-D9Y2 in BINARY_ISSUES.md, detailing the risk of silent data corruption when omitting default-valued properties if type defaults diverge between writer and reader. Added ACCORE-BIN-T-W7N5 in BINARY_TODO.md to track mitigation options, including a possible opt-out flag and required documentation/tests. Both entries are cross-referenced; decision on mitigation is deferred.
2026-05-04 11:34:57 +02:00
Loretta ed59a0c031 [LOADED_DOCS: 2 files, no new loads]
SIMD-accelerated UTF-8 encode/decode for AcBinary

- Added Vector256-based SIMD path for UTF-8 char counting in deserializer, replacing scalar loop for faster ASCII/multibyte handling.
- Introduced EncodeUtf8SinglePass in serializer: layered SIMD/DWORD/scalar UTF-16→UTF-8 encoding, bypassing Encoding.UTF8.GetBytes.
- Updated serializer to use new encoder for string writes.
- Expanded "fastestbyte" benchmark mode to compare both AcBinary (UTF-8/UTF-16) and MemoryPack strategies.
- Improved comments and docs to clarify new SIMD logic.
2026-05-04 11:15:32 +02:00
Loretta 3a75210c70 [LOADED_DOCS: 2 files, no new loads]
Disable ASCII fast paths; add FastestByte mode, plan tasks

Temporarily disable ASCII string fast paths in AcBinarySerializer and AcBinaryDeserializer to isolate and benchmark the custom UTF-8 encoder/decoder. Add "FastestByte" benchmark mode for focused AcBinary vs MemoryPack Byte[] comparison. Update BINARY_TODO.md with new technical tasks for .NET 11 SIMD decoder, sentinel-length encoding, ASCII marker-dispatch, and a custom UTF-8 encoder. These changes support staged optimization and future performance improvements.
2026-05-04 10:41:59 +02:00
Loretta dc10315fc3 [LOADED_DOCS: NONE]
Optimize string serialization: ASCII & UTF-8 fast paths

- Refactored AcBinarySerializer/Deserializer to use single-pass UTF-8 encoding and a custom allocation-free UTF-8 decoder, improving performance for both ASCII and non-ASCII strings.
- Expanded BinaryTypeCode with new ASCII string markers (FixStrAscii, StringAscii) and updated helpers for robust, branch-friendly string dispatch.
- Updated settings.local.json with new diagnostic and plugin management commands.
2026-05-04 09:11:55 +02:00
Loretta 2c73775389 [LOADED_DOCS: 2 files, no new loads]
Optimize FastWire string (de)serialization and benchmarks

- Increased release benchmark iterations for more robust testing.
- Improved FastWire string deserialization with zero-copy UTF-16.
- Set FastWire and string caching options during context init/reset.
- Optimized FastWire string serialization for direct UTF-16 copy.
- Enhanced non-ASCII string fallback to use Utf8NoBom encoding.
- Refactored WriteFixStr for efficient ASCII and fallback handling.
2026-05-04 07:32:29 +02:00
Loretta 80235c9a3d [LOADED_DOCS: 2 files, no new loads]
Support FixStr for short non-ASCII strings (UTF-8)

Refactored WriteFixStrDirect to encode short non-ASCII strings as FixStr if their UTF-8 byte count is ≤31, not just ASCII. This improves efficiency for short international strings. Also ensured correct buffer sizing and direct UTF-8 encoding. No functional change to project file; property repositioned for clarity.
2026-05-04 07:02:13 +02:00
Loretta 1661ffc4c6 [LOADED_DOCS: 3 files, no new loads]
NativeAOT: full DAMs propagation, trimmer-safe serializers

- Propagate [DynamicallyAccessedMembers] from all public Serialize<T>/Deserialize<T> APIs through all type/property metadata and factories, centralizing requirements in TypeMetadataBase.RequiredMembers.
- Add [UnconditionalSuppressMessage] for known trimmer blind spots (polymorphism, inheritance, nested types) with detailed justifications.
- Update all internal delegate/factory signatures to preserve DAMs context.
- Annotate public APIs for AOT safety; document consumer requirements for SGen or rooted model assemblies.
- Update BINARY_FEATURES.md with NativeAOT/trimmer compatibility, guidance, and limitations.
- Adjust benchmark project for AOT/JIT parity and add i18n test data.
- No breaking API changes; SGen and Runtime paths remain, now fully AOT-compatible.
2026-05-03 22:35:40 +02:00
Loretta 9b8e56557f [LOADED_DOCS: 3 files, no new loads]
NativeAOT: fallback for delegates, exclude MessagePack

Added AYCODE_NATIVEAOT symbol for AOT builds and excluded MessagePack benchmarks from NativeAOT due to lack of AOT support. Updated AcSerializerCommon to use reflection-based delegates when dynamic code is unavailable, ensuring compatibility with both JIT and AOT. Added explanatory comments throughout.
2026-05-03 19:09:25 +02:00
Loretta 97ac3e21a3 [LOADED_DOCS: 3 files, no new loads]
Remove SegmentBufferReader; unify on AsyncPipeReaderInput

Migrates all SignalR chunked streaming receive logic to AsyncPipeReaderInput, fully removing SegmentBufferReader and SegmentBufferReaderInput from the codebase. Updates all references, deserialization paths, and documentation to reflect the new unified primitive. Marks ADR-0003 as accepted (partially executed), closes related TODOs, and clarifies protocol docs. Sets DoubleBuffered as the default FlushPolicy. No wire format or behavioral changes; all tests pass.
2026-05-03 15:21:15 +02:00
Loretta e7b12a1100 [LOADED_DOCS: 3 files, no new loads]
Switch to FlushPolicy enum for streaming flush control

Replaces the legacy bool waitForFlush with a new FlushPolicy enum (PerChunk, DoubleBuffered, Coalesced) across all binary streaming serialization APIs and SignalR protocol options. Updates all code, configuration, and documentation to use the new policy, clarifies memory/throughput trade-offs, and closes related TODOs. Stream-backed writers remain sequential; only parallel-capable Pipe-based writers honor the policy.
2026-05-03 08:13:59 +02:00
Loretta 67589f6b6f [LOADED_DOCS: 3 files, no new loads]
Move DrainFromAsync to tests, add in-memory benchmarks

- Moved AsyncPipeReaderInputExtensions.DrainFromAsync from the main framework to test-only assembly; no longer public API.
- Removed AcBinaryDeserializer.DeserializeFromPipeReaderAsync<T> from public API; tests now inline drain+deserialize logic.
- Added AcBinaryInMemoryPipeBenchmark and AcBinaryInMemoryRawByteArrayBenchmark to complete 2x2 transport × wire-format benchmark matrix.
- Refactored benchmark runner for interactive menu, settings, and CLI parsing.
- Expanded XML docs for AsyncPipeReaderInput and AsyncPipeWriterOutput to clarify push-pattern and real-world usage.
- Updated BINARY_ASYNCPIPE_TODO.md and related docs to reflect these changes.
2026-05-02 15:51:07 +02:00
Loretta 05f90a5639 [LOADED_DOCS: 3 files, no new loads]
Refactor pipe benchmarks to 2-task streaming model

Refactored AcBinaryNamedPipeBenchmark and AcBinaryNamedPipeRawByteArrayBenchmark to use a two-task (producer/consumer) streaming pipeline for deserialization, enabling true Ser↔Des overlap. Reduced BufferWriterChunkSize from 16K to 4K. Updated synchronization, cleanup, and comments to reflect the new architecture and improve performance comparison between chunked and raw byte[] modes.
2026-05-02 11:55:46 +02:00
Loretta a537f18294 [LOADED_DOCS: 3 files, no new loads]
Add raw NamedPipe benchmark & mux-mode AsyncPipe docs

- Add AcBinaryNamedPipeRawByteArrayBenchmark for raw NamedPipe (no chunking) to isolate kernel vs. AsyncPipe overhead
- Refactor progress reporting with in-place updates for all timed/allocation benchmarks
- Document [0xC8] marker as mux-mode direction; add ACCORE-BIN-T-M2X7 and ACCORE-BIN-I-C4N7 for multi-stream and single-consumer constraints
- Expand BINARY_WRITERS.md with parallel-flush regime analysis and allocation context
- Improve result comparison robustness for AsyncPipe-only runs
- Minor doc clarifications and explicit AsyncPipeReaderInput usage patterns
2026-05-02 00:03:22 +02:00
Loretta 329c9c2928 [LOADED_DOCS: 2 files, no new loads]
Refactor AcBinary SGen: switch dispatch + new TODOs

Refactored the AcBinary source generator to use switch-based dispatch for deserialization, replacing sequential if-else chains for improved performance and clarity. Updated comments to document the new logic. Added several new feature and refactor TODOs in BINARY_TODO.md, including Memory/Span overloads, async Stream serialization modes, non-generic Type-based APIs, attribute-driven polymorphism, and a thread-safety fix for serializer options. Each TODO includes rationale and acceptance criteria.
2026-05-01 20:14:09 +02:00
Loretta 3b45de6de3 [LOADED_DOCS: 3 files, no new loads]
Modernize benchmarks, simplify attributes, doc cleanup

- Benchmark output now reports per-op µs and KB/op; added helpers for unit conversion and updated all output formats and headers.
- Split SetupAllocBytes into SetupSerializeAllocBytes and SetupDeserializeAllocBytes for finer allocation reporting.
- Simplified [AcBinarySerializable] usage in test models to single-argument form.
- Edited documentation for clarity, brevity, and consistency; improved navigation, updated technical details, and harmonized terminology across .md files.
2026-05-01 14:01:23 +02:00
Loretta 4375ca5b4a [LOADED_DOCS: 8 files, no new loads]
Add AsyncPipe streaming mode, doc split, and test data tweaks

- Add AsyncPipe-only streaming mode to benchmark suite (CLI/menu)
- Aggregate and display AcBinarySerializableAttribute flags in options output
- Raise IId-ref and repeated-string share in all test data to ~20%
- Use explicit AcBinarySerializable(false, true, ...) on all test models
- Split streaming I/O issues/TODOs into BINARY_ASYNCPIPE_ISSUES.md and BINARY_ASYNCPIPE_TODO.md
- Update README and references for new streaming doc structure
- Minor code and doc cleanups for clarity and accuracy
2026-05-01 09:31:46 +02:00
Loretta 6dbeae9884 [LOADED_DOCS: 3 files, no new loads]
Centralize pipe chunk size and fix buffer reset race

Centralized pipe chunk size config for all AcBinary pipe benchmarks, ensuring app-level and kernel buffer sizes stay in sync. Updated AsyncPipeReaderInput.MessageDone to atomically reset both _readPos and _writePos, preventing stale buffer reads. Improved comments and applied AggressiveOptimization to key methods. Adjusted AcquireChunk to ensure wire chunk fits exactly, avoiding kernel fragmentation. Updated related tests and documentation.
2026-05-01 06:37:08 +02:00
Loretta 5561246e8c [LOADED_DOCS: 3 files, no new loads]
Refactor AcBinary NamedPipe to long-lived multi-message mode

Refactored the AcBinary NamedPipe benchmark to use a single long-lived AsyncPipeReaderInput in multi-message mode, with one background drain task and synchronous deserialization per message. Buffer recycling is now signaled by the consumer via a new MessageDone() method, called in the deserializer's finally block, preventing producer-consumer races. Added IsCompleted property to AsyncPipeReaderInput. Increased release-mode benchmark iteration counts. Updated documentation and comments to reflect the new architecture and rationale.
2026-04-30 21:14:38 +02:00
Loretta 204b361748 [LOADED_DOCS: 3 files, no new loads]
Refactor AcBinary streaming: multi-message protocol

- Renamed framing flags to multiMessage for clarity in AsyncPipeReaderInput/AsyncPipeWriterOutput.
- Multi-message mode ([202]=end-of-message) now auto-resets input for reuse; session end is explicit.
- Updated framing state machine, buffer cycling, and sentinel logic.
- Revised all serializer/deserializer entry points and tests for new protocol.
- Expanded docs and XML comments to detail wire format and protocol constraints.
- Updated benchmarks and tests for new streaming API and multi-message behavior.
- Documented protocol limits and added security issue/TODO for type-name deserialization in SignalR binary protocol.
2026-04-30 19:58:30 +02:00
Loretta 42b40a92c1 [LOADED_DOCS: 3 files, no new loads]
Add NamedPipe round-trip benchmark & streaming infra

- Introduce AcBinaryNamedPipeBenchmark for long-lived NamedPipe round-trip measurement, simulating SignalR streaming.
- Add IoNamedPipe
2026-04-30 14:32:13 +02:00
Loretta 294a3e9609 [LOADED_DOCS: 6 files, no new loads]
Refactor benchmarks; clarify AcBinary doc warnings

Refactored serializer benchmark infra for richer, structured results and added fresh/reused buffer writer scenarios for AcBinary and MemoryPack. Disabled AcBinary SGen for all test models to ensure runtime/reflection-only benchmarks. Updated documentation to clarify and cross-link all silent corruption risks (hash collisions, MaxDepth, PropertyFilter), harmonized warnings, and referenced relevant issue IDs for traceability.
2026-04-30 12:36:37 +02:00
Loretta 4e91d24fdb [LOADED_DOCS: 3 files, no new loads]
Refactor AcBinaryHubProtocol for thread safety

- Removed shared _currentHeaderContext; header context is now passed as a parameter through Parse* and ReadArguments/ReadSingleArgument methods, and stored per-binder for chunked messages.
- Updated AyCodeBinaryHubProtocol to use the new header context flow for type resolution and argument deserialization.
- Added concurrency tests to verify protocol instance safety under multi-threaded use and prevent state corruption or type resolution races.
- Improved documentation and comments to clarify the stateless, concurrency-safe design.
2026-04-30 07:48:01 +02:00
Loretta 6f5c57af6a [LOADED_DOCS: 3 files, no new loads]
Benchmark: multi-sample median timing & EH inlining docs

Added BenchmarkSamples for multi-sample median timing in benchmarks, reducing variance and improving result stability. Updated output to show sample count. Refactored RunTimed to support multiple samples. Expanded documentation on JIT inlining barriers: clarified that EH regions (try/catch/finally/using) in hot-path and generated methods block inlining on .NET 9, and provided guidance for future generator features and stackalloc usage. Added audit requirements for EH and stackalloc in hot paths.
2026-04-30 06:53:59 +02:00
Loretta 96a2f90535 [LOADED_DOCS: 3 files, no new loads]
Refactor AsyncPipeWriterOutput buffer management

Refactored AsyncPipeWriterOutput to lazily allocate and reuse the fallback ArrayPool<byte> buffer across a serialize lifecycle, releasing it only once at the end. Replaced the _ownedBuffer boolean with _hasOwnedBuffer and a nullable _ownedBuffer field. Centralized buffer release logic, updated diagnostics, and improved chunk acquisition to minimize ArrayPool churn and clarify buffer ownership semantics.
2026-04-30 06:04:28 +02:00
Loretta 910b0deab8 [LOADED_DOCS: 2 files, no new loads]
Separate raw and framed streaming in AcBinarySerializer

Refactored AcBinarySerializer and AsyncPipeWriterOutput to support both raw (headerless) and multiplexed/framed ([201][UINT16][data]) streaming wire formats, controlled by a new flag and explicit APIs. Updated AsyncPipeReaderInput and AcBinaryDeserializer to match, with new constructor options and documentation. Expanded tests for both modes and added runtime type detection for flush strategy safety. Minor refactoring and doc improvements throughout.
2026-04-29 16:09:33 +02:00
Loretta 4ca3f51632 [LOADED_DOCS: 2 files, no new loads]
Remove NamedPipe helpers; make binary serializer transport-agnostic

Removed NamedPipe-specific serializer/deserializer helpers from the framework. Refactored tests to manage NamedPipe lifecycle directly using generic PipeWriter/PipeReader APIs. Introduced a transport-agnostic async deserialization method for any PipeReader. Updated XML docs and method signatures to clarify usage and flush strategies. All NamedPipe logic is now test-only; the core binary serializer is fully transport-agnostic. Minor test cleanups included.
2026-04-29 01:13:22 +02:00
Loretta 4a8c961d87 [LOADED_DOCS: 3 files, no new loads]
Refactor AsyncPipeWriterOutput for stream compatibility

- Reduce test chunk size to 256 bytes and update test names/comments
- Add sender-side diagnostic logging and unify with receiver logs
- Detect StreamPipeWriter at runtime and enforce sequential flush/acquire for streams
- Retain parallelism for pipe-based writers (Kestrel/SignalR)
- Add DEBUG-only diagnostics at key chunking points
- Minor code style cleanups and doc clarifications
- Add Bash command to fetch StreamPipeWriter.cs for reference
2026-04-29 00:33:35 +02:00
Loretta ab1af9fcfa [LOADED_DOCS: 3 files, no new loads]
Add streaming pipeline & NamedPipe support to serializer

Introduced AsyncPipeReaderInput for chunked streaming deserialization with true parallel producer/consumer support. Added PipeReader/PipeWriter pipeline integration, struct adapter, and extension methods. Implemented cross-platform NamedPipe roundtrip helpers. Updated default chunk size to 65535 (UINT16 max). Added comprehensive tests for all pipeline steps and updated docs for conventions and project layout.
2026-04-28 20:23:07 +02:00
Loretta 5fa2fa9d73 [LOADED_DOCS: 2 files, no new loads]
Add ADR-0003: AcBinary streaming receive unification

- Add cross-cutting ADR-0003 for AsyncPipeReaderInput and transport-agnostic streaming helpers (NamedPipe, FileStream)
- Update BINARY and SIGNALR_BINARY_PROTOCOL docs to reference ADR-0003
- Add migration TODOs for each ADR-0003 step with acceptance criteria
- Add project ADR-0001 (binary projection serialization) and index
- Clarify buffer sizing and ADR refs in SegmentBufferReader docs
- Migrate JWT key/token log issues from LOGGING to new AUTH topic per ADR
- Update ADR template and improve doc formatting throughout
2026-04-28 14:18:27 +02:00
Loretta 0f9cf6289e [LOADED_DOCS: 3 files, no new loads]
Refactor SIGDS docs, archive DEC log, add pipe tests

- Updated all references to AcSignalRDataSource docs to new SIGNALR_DATASOURCE/README.md location; introduced SIGDS topic and paired issues/TODO files.
- Implemented new Decision Log archival policy: last-15-active entries remain, older entries moved to year-month archive (LLMP-DEC-65, 67); updated docs-archive skill for two-rule rotation.
- Added new SIGDS architectural TODO (ACCORE-SIGDS-T-D9F2) for relocating DataSource code.
- Updated doc tables, glossaries, and conventions for SIGDS.
- Added AcBinarySerializerPipeParallelTests.cs for parallel serialization/deserialization round-trip tests.
2026-04-28 06:36:39 +02:00
Loretta 8e9a0b47c1 [LOADED_DOCS: 3 files, no new loads]
Expand META-TODO scope; add BINARY_TODO entries; doc updates

- Broadened ACCORE-META-T-F8R3 to cover SKILL.md/registry text drift, not just summary staleness
- Added concrete SKILL.md drift examples and clarified fix direction
- Added BINARY_TODOs for Id detection (convention/attribute) and serializer-native ignore attribute
- Updated SIGNALR_BINARY_PROTOCOL_TODO.md and ADR 0001 to clarify deferral of decorator base/handshake TODOs
- Minor topic code length and JSON-in-Binary tech debt clarifications
- Synced references and cross-links with latest protocol decisions
2026-04-27 14:42:10 +02:00
Loretta fc63be3226 [LOADED_DOCS: 3 files, no new loads]
Add META topic, CONSUMERS.md, and refine ADR/Framework rules

- Introduced META topic with META_ISSUES.md and META_TODO.md for protocol meta-tooling tracking.
- Updated TOPIC_CODES.md to register META topic.
- Added CONSUMERS.md to document consumer repos per Framework-First exception.
- Extended Framework-First rule to all docs, documenting the CONSUMERS.md exception.
- Refined adr-author skill: explicit ADR keyword triggers, complexity-based ask-back, and clarified non-trigger cases.
- Improves meta-tooling governance, cross-layer doc boundaries, and ADR process.
2026-04-26 19:29:21 +02:00
Loretta c062ded9a4 [LOADED_DOCS: 3 files, no new loads]
Update ID format to use per-repo prefixes and random suffix

Migrated all issue, TODO, and decision IDs to a new 4-part format: <PREFIX>-<TOPIC>-<TYPE>-<RAND>. Added per-repo prefix declarations in copilot-instructions.md and documented conventions in REPO_PREFIXES.md. Updated all topic registries, logs, cross-references, and documentation to use the new format. Introduced MIGRATION_ID_MAPPING.md for old-to-new ID mapping. Enhanced skills and protocol audit logic to validate and enforce per-repo prefixes and topic codes at runtime. Clarified Framework-First doctrine and ensured all references are unambiguous.
2026-04-26 19:12:50 +02:00
Loretta b59b42d381 [LOADED_DOCS: 3 files, no new loads]
Add Rule #6, remove host folder, update deployment docs

- Introduced Rule #6 to all primary copilot-instructions.md files, enforcing authority checks, conservative rule application, and skill invocation over ad-hoc solutions.
- Removed the obsolete Mango/FruitBank/ deployment-host folder and its copilot-instructions.md; updated REPOS.md to reflect the new repo count.
- Migrated deployment-context documentation to Mango/Source/FruitBank/.github/copilot-instructions.md, preserving the relationship between the server source and plugin after folder removal.
2026-04-26 14:11:03 +02:00
Loretta 0ad9250e4c [LOADED_DOCS: 3 files, no new loads]
Add Rule #6, AUTH topic, ADRs, config & doc updates

- Codified Rule #6 (authority, rule scope, skill invocation) in all primary copilot-instructions.md files
- Clarified skill pre-load/lazy-load rules and LOADED_DOCS prefix
- Forbid skill/template version labels in Decision Log governance
- Scaffolded new AUTH topic with README, ISSUES, and TODO files
- Added repo/project ADR folders and templates; new ADR for AcBinaryHubProtocol decorator stack
- Migrated cross-cutting issues/TODOs to Closed with detailed resolution
- Made FruitBankHybrid.Shared/appsettings.json the canonical config source; suppressed Razor SDK auto-publish to avoid file collisions
- Updated protocol/wire format docs for AcBinaryHubProtocol
- Minor config: updated ports, WaitForFlush, and csproj content rules
2026-04-26 13:44:12 +02:00
Loretta 8f72593741 [LOADED_DOCS: 3 files, no new loads]
ADR skill, status vocab, and security log fixes

- Enhanced `adr-author` skill and ADR template based on first use: clarified opinion sharing, cross-referencing, index updates, and parallel session handling; rewrote ADR template pointer and added first repo-wide ADR.
- Updated skill loading: only `docs-discovery` and `docs-check` pre-loaded; others lazy-loaded; removed `version` from SKILL.md frontmatters.
- Simplified issue/TODO status to `Open`, `InProgress`, `Closed`; updated archiving logic and migrated all entries.
- Added forward-slash glob pattern rule to `docs-discovery/SKILL.md`.
- Closed LOG-I-9 and LOG-I-10: DEBUG-gated sensitive log lines, fixed typo, and cross-referenced new ADR.
- Various documentation and index improvements.
2026-04-25 20:24:32 +02:00
Loretta 9a53aa1d73 [LOADED_DOCS: 5 files (+2 this turn: BINARY_ISSUES.md, LOGGING_ISSUES.md)]
Simplify Status field; add docs-archive skill & archiving

- Reduced Status field values in issues/TODOs to Open, InProgress, Closed; updated all affected entries to new convention
- Introduced docs-archive skill for rotating Closed entries into year-bucketed archive files; process is user-invoked or LLM-suggested, never automatic
- Expanded docs-discovery and protocol documentation to clarify archive file handling and on-demand loading
- Updated session setup: only reactive skills pre-loaded, user-gated skills now lazy-loaded for token efficiency
- Clarified and documented Status update workflow, archive eligibility, and lifecycle
- Updated all relevant issue/TODO files to match new Status conventions and archival process
2026-04-25 17:55:21 +02:00
Loretta 37559b6dc4 Add adr-author skill, ADR template, and log security issues
- Introduced the `adr-author` skill for structured ADR creation; updated session setup and shared skills to require pre-loading it.
- Added `SKILL.md` and `ADR_TEMPLATE.md` for ADR authoring workflow and documentation.
- Updated protocol decision log with entries for the new skill and its integration.
- Documented two critical JWT logging security issues in `LOGGING_ISSUES.md`.
- Minor: added a cleanup Bash command in `settings.local.json`.
2026-04-25 07:24:16 +02:00
Loretta affa85e5c5 [LOADED_DOCS: 4 files, no new loads]
Refactor docs: topic folders, TOON, XCUT, protocol sync

- Migrated all topic documentation into dedicated folders with canonical `README.md`, `ISSUES.md`, and `TODO.md` per topic (e.g., `LOGGING/`, `SIGNALR/`, `BINARY/`, `TOON/`).
- Added comprehensive TOON serializer documentation: design, format, options, attributes, inference, issues, and TODOs.
- Introduced `XCUT` folder for cross-cutting issues and TODOs, with canonical entries and topic cross-references.
- Updated all references and navigation to use new folder-based doc paths; fixed links and clarified doc structure.
- Enhanced AI agent protocol: enforce session skill preloading, `[LOADED_DOCS: ...]` short-name prefix, and mandatory `docs-check` skill for doc/code sync.
- Updated `.csproj` to include all `README.md` files for IDE visibility.
- Improved and clarified SignalR, grid, and project-level documentation.
- Minor code/test tweaks and doc content corrections for consistency.
2026-04-24 21:54:04 +02:00
Loretta 61509f1b95 [LOADED_DOCS: NONE]
Update protocol, docs-discovery skill, and doc layering

- Switched AI AGENT CORE PROTOCOL to new `[LOADED_DOCS: N files (+K this turn: ...)]` prefix format in all primary instruction files; updated examples and enforcement details.
- Added `docs-discovery` skill for proactive .md doc loading before code search; documented usage and integration as a shared agent skill.
- Introduced `## Protocol History` section and `LLM_PROTOCOL_DECISIONS.md` decision log for cross-repo protocol change tracking.
- Expanded protocol-audit skill and `REPOS.md` to support 8 instruction files (primary/inherit split), new invariants, and known issues.
- Added/updated structured TODO and issues docs for serialization, logging, and SignalR binary protocol.
- Improved cross-references, doc layering, and clarified documentation-first coding workflow.
- Various minor doc clarifications and formatting for protocol consistency.
2026-04-24 08:21:49 +02:00
Loretta 06a9efd7f9 Framework-first doctrine, DI logger factory, config refactor
Introduced framework-first design rules and updated documentation to clarify framework vs. consumer boundaries. Added AcLoggerOptions and DI-based logger factory extensions to AyCode.Core, enabling per-category logger instantiation from appsettings.json. Standardized SignalR connection setup with AddAcDefaults, replacing project-specific code. Enhanced protocol configuration for DI scope isolation. Refactored appsettings.json structure and added MSBuild targets for config propagation. Removed obsolete code and updated comments to match new patterns.
2026-04-23 16:11:22 +02:00
Loretta 8b8abb7cbc [LOADED_DOCS: .github\copilot-instructions.md]
Refactor SignalR protocol registration; add DI options

- Added AcSignalRServerProtocolExtensions and AcSignalRProtocolExtensions for idiomatic AddAcBinaryProtocol registration (server/client), using a shared BuildProtocol factory for DI/IOptions/inline config.
- Introduced AcHubConnectionOptions and AcSignalRConnectionExtensions for configuration-driven client setup.
- Refactored AcSignalRClientBase to require a preconfigured IHubConnectionBuilder, moving all connection/protocol config out of the base class.
- Removed legacy protocol constructors; all protocol instantiation is now options-based.
- Enforced WASM + AsyncSegment guard in AcBinaryHubProtocolOptions.Validate.
- Updated SIGNALR_BINARY_PROTOCOL.md and GLOSSARY.md for new DI/config patterns.
- Minor: updated settings.local.json with new DLL/plugin inspection commands.
2026-04-22 22:44:37 +02:00
Loretta c6e1fa8efc Refactor: centralize SignalR protocol config/options
- Added AcBinaryHubProtocolOptions for unified protocol configuration (serializer, mode, buffer size, flush strategy, timeout, name, logger) with validation and DI support.
- Refactored AcBinaryHubProtocol and AyCodeBinaryHubProtocol to use options object; legacy constructors now delegate to options-based API.
- Added per-chunk flush timeout to AsyncPipeWriterOutput and AcBinarySerializer; throws TimeoutException on slow consumers.
- Improved XML docs and comments for pipeline/backpressure/timeout clarity.
- Updated SIGNALR_BINARY_PROTOCOL.md to document new options and AsyncSegment platform rules.
2026-04-20 17:44:37 +02:00
Loretta 939ce9c39b Refactor logging and unify argument deserialization
- Simplified logging with null-conditional operators
- Temporarily disabled WASM AsyncSegment guard for testing
- Unified argument deserialization via GetArgBytes for zero-copy and pooled buffer support
- Removed DeserializeFromSequence in favor of new approach
- Applied improvements to AyCodeBinaryHubProtocol
- Updated comments and performed minor code cleanup
2026-04-20 14:20:34 +02:00
Loretta dc16f493d5 [LOADED_DOCS: .github\copilot-instructions.md]
Add WASM detection and fallback to AcBinaryHubProtocol

Added a static IsBrowser flag to AcBinaryHubProtocol, initialized at type-load using OperatingSystem.IsBrowser(). The constructor now throws PlatformNotSupportedException if AsyncSegment mode is used on WebAssembly. Receive path adapts: skips background Task on WASM and deserializes synchronously on CHUNK_END. Updated logging and documentation to reflect browser-specific behavior.
2026-04-20 09:54:08 +02:00
Loretta 71ccff3ad4 [LOADED_DOCS: NONE]
Enhance logging for SignalR binary protocol and flush logic

- Added detailed LogInformation calls in AcBinaryHubProtocol for serialization/deserialization start/end, including chunked and non-chunked paths.
- Switched some LogInformation to LogDebug for chunk state and parsing to reduce verbosity.
- Improved chunked message logging with total sent size, chunk count, and data bytes.
- Removed .Forget() on _lastFlush in AsyncPipeWriterOutput to prevent double-await, with updated comments.
- Increased default Microsoft log level to Information in AcSignalRClientBase.
2026-04-20 07:10:03 +02:00
Loretta fe2fef55da [LOADED_DOCS: NONE]
Raise SignalR client log level to Warning

Changed the minimum log level for Microsoft.Extensions.Logging in the SignalR client configuration from Debug to Warning. This reduces log verbosity by only outputting messages with a severity of Warning or higher.
2026-04-19 18:42:16 +02:00
Loretta d0ab01d08e [LOADED_DOCS: .github\copilot-instructions.md]
Refactor AyCodeBinaryHubProtocol header logic

Refactored the per-message header to use a DataFlags enum, encoding data argument properties in a single byte for more expressive client handling. Introduced HeaderContext to encapsulate header state. Updated WriteHeader and ReadHeader to use the new format, and refactored ReadSingleArgument to support fast-paths for byte[], ConsumerDeserialize, and header-supplied types. Removed obsolete _currentSignalParams logic and improved documentation throughout.
2026-04-19 13:54:10 +02:00
Loretta 19c470251d [LOADED_DOCS: .github\copilot-instructions.md]
Refactor SignalR protocol type resolution logic

Removed SignalParams.SignalDataType and migrated type resolution to protocol headers using new WriteHeader/ReadHeader extensibility hooks. AyCodeBinaryHubProtocol now writes and reads the concrete data argument type in the message header, enabling correct deserialization of object-typed arguments. Updated AcBinaryHubProtocol to support header context and made relevant helpers protected. Cleaned up legacy SignalDataType logic and improved documentation.
2026-04-19 12:58:31 +02:00
Loretta 4343ab4d53 [LOADED_DOCS: .github\copilot-instructions.md]
Refactor: replace PipeReaderBinaryInput with SegmentBufferReader

- Remove PipeReaderBinaryInput and all related code.
- Add SegmentBufferReader and SegmentBufferReaderInput for efficient, thread-safe chunked streaming deserialization.
- Update AcBinaryDeserializer and AcBinaryHubProtocol to use the new buffer for async segment protocol, improving state management and background deserialization.
- Enhance chunked protocol handling: skip re-presented chunk start frames, track consumed chunk frame bytes, and improve logging.
- Update test infrastructure to support async segment protocol and add AsyncSegmentPipeTransportWriter for realistic testing.
- Update settings.local.json to reflect new build/test commands and remove obsolete files.
- Improves performance, reliability, and testability of chunked SignalR streaming.
2026-04-18 14:31:27 +02:00
Loretta a5d2cd0b0e Add protocol-audit skill and repo config for protocol checks
Added SKILL.md defining the protocol-audit skill to verify AI AGENT CORE PROTOCOL consistency across five AyCode/Mango repos. Introduced REPOS.md listing the repos, their absolute paths, and expected own-dep-repos for audit reference. These additions enable structured, cross-repo protocol compliance checks without direct file modification.
2026-04-17 06:49:20 +02:00
Loretta e73e1b6364 Optimize Pipe sync handling, logging, and chunked protocol
Refactor synchronous PipeReader/PipeWriter usage to avoid Task allocations via fast-path helpers. Add detailed debug/trace logging for chunked message flows and deserialization. Track active reads to prevent protocol errors. Refactor FindStreamedArgSlot and introduce ResolveStreamedArgType for dynamic streamed arg type resolution, with AyCodeBinaryHubProtocol override. Minor code cleanups and improved logging context throughout. Improves performance, correctness, and debuggability.
2026-04-11 18:07:31 +02:00
Loretta 82a407ff82 Chunked framing for AsyncSegment: zero-copy SignalR ser/deser
Implement self-describing chunked protocol ([201][UINT16][data], [202] end) for AsyncSegment mode, enabling true zero-copy, pipeline-parallel serialization/deserialization of large arguments in SignalR.
- AsyncPipeWriterOutput now reserves a 3-byte header per chunk and supports two backpressure modes.
- AcBinaryHubProtocol routes streamable arguments through WriteMessageChunked, with chunk accumulation and background deserialization on the receiver.
- Logging now uses ILogger; documentation and wire format details updated.
- Consumer code updated to use new mode and diagnostics.
- Improves throughput, memory usage, and maintainability for large payloads.
2026-04-11 10:35:03 +02:00
Loretta 83350e43f6 Refactor: clarify and implement protocol serialization modes
Refactored binary protocol to support three explicit serialization/transport strategies via BinaryProtocolMode: Bytes (byte[]), Segment (zerocopy PipeWriter), and AsyncSegment (async PipeWriter with pipeline parallelism). Updated AcBinaryHubProtocol and AyCodeBinaryHubProtocol to select serialization/deserialization paths based on mode. Improved documentation and XML comments to describe each mode's behavior and performance. DI registration now explicitly selects AsyncSegment mode for AyCodeBinaryHubProtocol. Default remains Bytes mode. These changes clarify protocol mechanics and enable better performance tuning.
2026-04-10 16:10:28 +02:00
Loretta 8ff75de55c Add segment streaming to SignalR binary protocol
Implements segment-level streaming for SignalR binary protocol via new AsyncPipeWriterOutput and PipeReaderBinaryInput types, enabling chunked serialization/deserialization directly over PipeWriter/PipeReader. Adds BinaryProtocolMode enum to select between standard and streaming modes. Updates protocol classes and documentation. Lays groundwork for future async streaming support.
2026-04-10 09:27:40 +02:00
Loretta 27cac570be Simplify byte[] wire format in SignalR binary protocol
Refactored AcBinaryHubProtocol and AyCodeBinaryHubProtocol to remove the VarUInt length prefix for raw byte[] arguments. Now, the protocol writes a tag (0x44) followed directly by the raw bytes, with argLength implying the payload size. Updated read logic to match: on detecting the tag, the code skips it and returns the remaining bytes as the payload. Updated documentation to clarify the new fast-path, protocol roles, and AcBinary detection. Set BufferWriterChunkSize to 4096 for SignalR in the base protocol for better alignment with Kestrel. Marked the related issue as resolved.
2026-04-09 08:27:44 +02:00
Loretta f825552ae2 Refactor SignalR binary protocol for extensibility
- Move SignalParams-aware deserialization logic from AcBinaryHubProtocol to new AyCodeBinaryHubProtocol, enabling project-specific customization.
- Make key deserialization and helper methods in AcBinaryHubProtocol protected and virtual for easier extension.
- Improve byte[] handling to distinguish between AcBinary-serialized and raw data.
- Remove diagnostic serialization verification from the base protocol.
- Update DI registration to use AyCodeBinaryHubProtocol with configurable options.
- Adjust client code to support object-based response data and raw byte handling.
- Comment out SignalResponseDataMessage diagnostic logger in Program.cs.
2026-04-09 08:12:50 +02:00
Loretta 3e00876c0f Increase default buffer size; remove diagnostic test/debug
Increased InitialBufferCapacity default to 16 KB in AcBinarySerializerOptions and updated docs. Removed ProtocolRoundTripDiagnosticTest and related diagnostic code from SignalRClientToHubTest.cs. Cleaned up debug output in AcBinaryHubProtocol.cs by removing Debug.WriteLine statements.
2026-04-08 11:09:13 +02:00
Loretta 55e53c248f Improve string serialization and buffer preallocation
- Add VarUIntSize and unsafe VarUInt writers for efficient buffer sizing and writing without redundant checks.
- Update WriteStringUtf8 to preallocate for VarUInt and string body in one step, reducing reallocations and risk of overflow.
- Change ArrayBinaryOutput default initial capacity to 65535.
- Use BufferWriterChunkSize from options in AcBinarySerializer.
- Fix typo in AcBinarySerializerOptions.
- Set SignalR client log level to Warning by default.
2026-04-08 09:50:46 +02:00
Loretta cfc18d9c8e Simulate Kestrel slab transport for SignalR BWO tests
Add a production-faithful test harness for SignalR binary protocol, introducing SlabTransportWriter to simulate Kestrel's slab allocator and always force the BufferWriterOutput owned-buffer path. Add large-payload round-trip tests (including non-ASCII cases) to catch position drift and data corruption bugs. Enhance protocol tests to validate multi-segment output and byte-for-byte correctness. All protocol round-trips now exercise the multi-segment, non-array-backed buffer path.
2026-04-08 08:25:48 +02:00
Loretta 7b1bce711e Refactor test protocol to use slab-like 256B pipe segments
TestMultiSegmentProtocol now uses a custom MemoryPool to simulate Kestrel's slab allocator with 256-byte segments for both writing and reading. This replaces manual multi-segment sequence creation with a real Pipe backed by SlabSimulatingPool, ensuring more realistic segment boundaries and offsets. Old helpers were removed, and comments updated to clarify the improved simulation of production SignalR/Kestrel pipe behavior.
2026-04-08 07:04:00 +02:00
Loretta d060508bd8 Add diagnostics for binary SignalR serialization bugs
Enhances debugging of custom binary serialization/deserialization in SignalR by introducing DiagnosticLogger hooks in both AcBinaryDeserializer and AcBinaryHubProtocol. Adds DEBUG-only verification methods to compare array-based and multi-segment deserialization, as well as IBufferWriter and byte[] serialization outputs, logging mismatches for easier bug isolation. Diagnostic loggers are automatically integrated with the hub and client loggers. Also includes extra debug output and a commented workaround for a known serialization issue. Diagnostics are opt-in and only active in DEBUG builds.
2026-04-07 20:53:20 +02:00
Loretta 26c8cd85ce Refactor BenchmarkTestDataProvider for flexibility & clarity
Moved BenchmarkTestDataProvider and TestDataSet to AyCode.Core.Tests.TestModels with public accessibility. Refactored dataset creation methods to accept a resetId parameter, allowing control over TestDataFactory ID resets. Improved code structure, formatting, and documentation for maintainability. The provider is now more flexible and easier to use in tests.
2026-04-07 14:27:12 +02:00
Loretta accb38cf75 Add SignalR protocol round-trip and multi-segment tests
Introduce diagnostic and test infrastructure for SignalR binary protocol serialization/deserialization, including:
- ProtocolRoundTripDiagnosticTest for isolated protocol byte inspection
- TestMultiSegmentProtocol to exercise multi-segment buffer parsing
- TestInvocationBinder for correct parameter type binding
- Updates to TestableSignalRClient2 and TestableSignalRHub2 to route all messages through protocol round-trip
- Enhanced SendMessageToClient to simulate real SignalR transport
- Clarified SequenceBinaryInput segment handling logic
- Made TryParseMessage virtual in AcBinaryHubProtocol for testability

These changes improve test coverage for cross-boundary and multi-segment scenarios in SignalR message handling.
2026-04-07 12:28:32 +02:00
Loretta 9f909f6380 Refactor SequenceBinaryInput: zero-copy, docs, issues
- Rewrote SequenceBinaryInput for lazy TryGet iteration (no segment array allocation), zero-copy access to segment backing arrays, and efficient cross-boundary reads using a reusable ArrayPool scratch buffer.
- Added Release() to IBinaryInputBase; now always called after deserialization to return scratch buffer.
- BufferWriterChunkSize is now mutable; set to 4096 for SignalR protocol for better pipe alignment.
- Added and updated documentation: detailed input buffer lifecycle, cross-boundary handling, and new BINARY_ISSUES.md and SIGNALR_ISSUES.md for known limitations and planned optimizations.
- No breaking API changes; improves performance, memory usage, and diagnostics for multi-segment binary deserialization.
2026-04-07 10:33:38 +02:00
Loretta 91194fcfa3 Refactor SignalR protocol for zero-copy, typed deserialization
- Change OnReceiveMessage signature to use `object data` (was `SignalData`), enabling type-aware and raw byte[] payloads.
- Implement three-path argument deserialization: byte[] fast-path, IsRawBytesData, and eager typed deserialization via SignalDataType.
- Add SignalDataType and IsRawBytesData fields to SignalParams for protocol guidance.
- Write path now uses AcBinarySerializer zero-copy to pipe; byte[] uses fast-path.
- SequenceBinaryInput now dynamically sizes scratch buffer for large cross-segment reads.
- Deserializer now advances segments before throwing end-of-buffer, improving multi-segment support.
- Set client logging to Debug for better diagnostics.
- Update all docs and markdown to reflect new protocol, dispatch model, and field semantics.
- AyCodeBinaryHubProtocol is now an empty derived class for registration/future hooks; SignalData is no longer the primary payload type.
- SignalResponseDataMessage is now an internal DTO with RawResponseData as object? (typed or byte[]), and GetResponseData<T>() is a direct cast.
2026-04-07 03:10:09 +02:00
Loretta 05808d0d13 SignalR: Add raw byte[] fast-path for DataSource GetAll
Implements a high-performance raw byte[] protocol path for SignalR DataSource GetAll/LoadDataSource, using a new IsRawBytesData flag in SignalParams. When enabled, the server pre-serializes response data and sends it as a byte array, which the protocol passes through without further (de)serialization. The client receives the raw bytes and deserializes as needed, avoiding double serialization/deserialization and improving performance for large payloads.

Adds SerializerType selection to DataSource, propagates SignalParams through hub and protocol layers, and updates client/server/test code to support the new path. Also includes diagnostics flags for binary serialization debugging and fixes for multi-segment buffer handling.
2026-04-07 00:20:52 +02:00
Loretta 2d04b9f8f6 Zero-copy SignalR: direct object response, no SignalData
Major overhaul for SignalR response pipeline:
- All deserialization now uses byte[] (offset/length) for zero-copy, allocation-free operation; all span/memory overloads removed.
- SignalR protocol sends (signalParams, object) directly; SignalData envelope and related logic removed.
- Server sets SignalParams.SignalDataType so protocol deserializes to the correct runtime type on the client.
- SignalResponseDataMessage now only used for client request/response tracking and stream path; RawResponseData holds the actual object.
- All extension methods, helpers, and infrastructure updated to use new byte[]-based APIs.
- AcSignalRDataSource and all test/benchmark code updated for new object flow.
- Removes all diagnostics, logging, and error handling related to binary envelopes.
- Enables true zero-copy, type-safe, allocation-free SignalR response handling.
2026-04-06 22:45:00 +02:00
Loretta d147398698 Switch SignalR payloads to ArrayPool-backed SignalData
Major protocol refactor: all byte[] payloads in SignalR hub/client interfaces, plumbing, and DTOs are now wrapped in SignalData, a disposable, ArrayPool-backed type with Span access. Introduces AyCodeBinaryHubProtocol (derived from AcBinaryHubProtocol) to rent pooled buffers for SignalData on receive. All message signatures, diagnostics, and serialization logic updated. Documentation and tests revised to reflect SignalData usage. Enables zero-copy, low-GC, high-performance binary messaging for large payloads.
2026-04-06 11:17:02 +02:00
Loretta 3b7007002a Refactor SignalR param handling: SignalParams replaces old
Major protocol/API change: replace SignalReceiveParams with SignalParams everywhere. SignalParams now carries packed method parameters as a single byte[] and provides SetParameterValues/GetParameterValues for type-safe packing/unpacking. All hub/client interfaces, method signatures, and dispatch logic updated. Legacy parameter serialization helpers removed; all parameter logic is encapsulated in SignalParams. Documentation and tests updated to reflect new wire format and flow. This unifies parameter handling, clarifies the protocol, and enables robust, extensible type-guided serialization. Breaking change.
2026-04-06 08:49:12 +02:00
Loretta cdd54d3196 Refactor SignalR param serialization to pure binary format
Replaces legacy JSON-in-Binary parameter envelopes with a length-prefixed, per-parameter binary format for all client→server calls. Introduces SerializeParametersToBinary and DeserializeParametersFromBinary for type-safe, zero-copy parameter handling. Marks IdMessage, SignalPostJsonDataMessage, and related wrappers as obsolete. Updates all client CRUD/messaging helpers and server-side dispatch to use object[] binary serialization. Adds DataSerializerType to SignalReceiveParams for response format indication. Updates tests and documentation to reflect new protocol. BREAKING CHANGE: not compatible with previous JSON-in-Binary clients/servers.
2026-04-05 17:21:38 +02:00
Loretta 32018e906a Refactor SignalR: separate metadata and payload transport
Major protocol update: OnReceiveMessage now takes metadata (SignalReceiveParams) and payload (byte[]) as separate hub arguments, not a single envelope. Metadata is AcBinary-serialized; payload uses protocol fast-path. Updated all client/server code, interfaces, and docs. Added ISignalParams and SignalReceiveParams types. Improved AcBinaryHubProtocol diagnostics and made byte[] fast-path more robust. This enables clearer, more debuggable, and future-proof SignalR binary messaging.
2026-04-05 09:30:54 +02:00
Loretta f06bd5004d Remove client-to-server streaming support in SignalR client
Removed OnReceiveStreamMessage from both AcSignalRClientBase and IAcSignalRHubBase, eliminating client-to-server streaming support. Updated StreamAsync calls to use the explicit method name string. This change enforces server-to-client streaming only and cleans up related interface and method references.
2026-04-05 00:30:05 +02:00
Loretta a120cd65ff Optimize serialization lookups; add SignalR binary toggle
- Cache wrapper/metadata in serialization bridge methods to avoid redundant GetType and GetWrapper calls, improving performance.
- Update source generator to combine null/depth checks and cache depthExceeded for collections.
- Add useAcBinaryProtocol option to AcSignalRClientBase, allowing binary protocol to be toggled via constructor and registered via DI.
- Update documentation to reflect new caching rules and performance improvements.
2026-04-04 23:22:47 +02:00
Loretta bbae524e8d SGen root fast path: hybrid dispatch, docs, helpers
Refactored AcBinarySerializer to add a source-generated (SGen) root fast path, bypassing the full runtime dispatch chain for SGen-decorated types and improving performance. Introduced helper methods for context pooling and buffer management. All entry points now use these helpers for consistency. Added comprehensive SGen architecture documentation (BINARY_SGEN.md), updated all related docs to explain the hybrid model, bridge methods, and configuration. Clarified doc loading rules in copilot-instructions.md for strict doc-first enforcement.
2026-04-04 12:55:36 +02:00
Loretta 5ba2684ac4 Improve benchmark reporting and add LLM-friendly results
- Add TypeName to TestDataSet for clearer test scenario reporting
- Display serializer options in console and log outputs
- Extend BenchmarkResult with OptionsDescription
- Serializer benchmarks now provide detailed config summaries
- Log files now include test type and serializer options summary
- Generate .LLM Markdown results for LLM consumption and docs
- Reference .LLM results in BINARY_IMPLEMENTATION.md for visibility
2026-04-04 11:02:08 +02:00
Loretta 9150df6982 Improve binary/SignalR docs, add protocol and writer deep-dives
Major documentation overhaul for binary serialization and SignalR:
- Simplified and clarified AyCode.Core README, with direct links to new deep-dive docs (BINARY_IMPLEMENTATION.md, BINARY_WRITERS.md)
- Added BINARY_WRITERS.md: detailed design and rationale for ArrayBinaryOutput and BufferWriterBinaryOutput, chunk sizing, and buffer management
- Refined BINARY_IMPLEMENTATION.md: clearer buffer management, output strategies, and hot-path rules; references new writers doc
- Added SIGNALR_BINARY_PROTOCOL.md: full wire format, zero-copy pipeline, dual BWO pattern, and read path for custom SignalR protocol
- Updated SignalRs README and SIGNALR.md: clarified protocol, tag system, request/response flow, and technical debt
- Improved cross-linking and discoverability throughout

These changes make the technical documentation clearer, more maintainable, and easier to navigate for advanced contributors.
2026-04-04 09:27:36 +02:00
Loretta 0cb2b6c2d8 SignalR: Add streaming & zero-copy binary protocol
- Introduce OnReceiveStreamMessage for server/client streaming via IAsyncEnumerable<byte[]>
- AcBinaryHubProtocol: switch argument framing to INT32, enable direct zero-copy serialization to SignalR pipe
- Optimize byte[] argument handling (fast-path, no extra alloc)
- BufferWriterBinaryOutput: support configurable chunk size, add FlushAndReset
- AcBinarySerializer: IBufferWriter overload returns bytes written
- Update docs for streaming, protocol, and performance guidance
- Minor refactoring, add InternalsVisibleTo, improve comments
2026-04-04 00:47:48 +02:00
Loretta 896ee257c4 Update defaults, docs, and internals for AcBinary serializer
- Add BINARY_IMPLEMENTATION.md with internal architecture and perf details
- Link new implementation doc from all relevant documentation
- Change default ReferenceHandlingMode to OnlyId
- Change default UseStringInterning to All
- Add InternalsVisibleTo for Mango.Nop.Core
2026-04-02 22:17:46 +02:00
Loretta 75974bf238 Enforce strict AI agent protocol, doc sync, and glossary
- Added "AI AGENT CORE PROTOCOL" to all copilot-instructions.md files: mandates [LOADED_DOCS] prefix, hard-gates tool usage, enforces no-re-read of .md files, and requires user consent for doc/code changes.
- Updated CLAUDE.md to require reading copilot-instructions.md first.
- Added topic-based doc separation and folder navigation rules.
- Changed doc sync: agent now passively detects discrepancies and asks before updating docs.
- Every code-modifying response must end with a [DOCUMENTATION CHECK] section.
- Centralized measurement system and domain traps in new FruitBank.Common/docs/GLOSSARY.md; updated references in FruitBankHybridApp GLOSSARY.md.
- Clarified schema and doc locations in FruitBankHybridApp README.md.
- Added hybrid execution model section to AyCode.Core BINARY_FEATURES.md.
- Removed unnecessary BeginUpdate/EndUpdate calls in MgGridBase.cs for layout persistence.
- Removed full Toon schema from plugin SCHEMA.md to avoid duplication.
2026-04-02 09:02:54 +02:00
Loretta 4a33872b9d Update LLM instruction files for token efficiency and cross-repo navigation
- CLAUDE.md: reduced to single-line pointer to copilot-instructions.md (eliminates redundant auto-loaded content)
- copilot-instructions.md: added @repo name field, relative paths in own-dep-repos, "do not re-read .md files" rule, and explicit permission to navigate external repos

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-31 16:14:59 +02:00
Loretta 7b84ab8111 Clarify redundant code and .md sync instructions
Refined guidance on avoiding redundant code by focusing on the current context rather than broad searches. Updated .md file sync instructions to prioritize code as the source of truth, clarify when and how to update documentation, and outline review practices. Improved wording for clarity and alignment with actual workflows.
2026-03-30 10:15:43 +02:00
Loretta fdff39c44b Add structured metadata blocks to all project/repo docs
Added standardized metadata blocks to the top of each project and repo documentation file. These blocks define project type, and for repos, include layer and dependencies, improving clarity and enabling better tooling support.
2026-03-30 08:53:56 +02:00
Loretta 541cebbed8 Update README links to use code-style paths for docs
Replaced Markdown links with plain code-style paths in all README.md files for consistency. Updated references to logging, SignalR, and dynamic method dispatch documentation. Clarified some documentation paths and improved consistency in context/architecture sections. No code changes—documentation only.
2026-03-30 08:01:05 +02:00
Loretta cb97b33ca0 Update doc links: use inline code, unify relative paths
Replaced all Markdown link syntax in documentation with inline code formatting and consistent relative paths. Updated all cross-references and tables for clarity and uniformity. No code or logic changes; docs only.
2026-03-29 22:32:11 +02:00
Loretta ffd537b5eb Refactor: Split and expand project-level documentation
- Move all major feature docs (logging, binary, SignalR, DataSource) into per-project `docs/` folders with dedicated Markdown files.
- Split monolithic docs into focused files: `BINARY_FORMAT.md`, `BINARY_FEATURES.md`, `BINARY_OPTIONS.md`, `LOGGING.md`, `LOGGING_SERVER.md`, `LOGGING_REMOTE.md`, `SIGNALR.md`, `SIGNALR_SERVER.md`, `SIGNALR_DATASOURCE.md`.
- Update all references in `README.md`, `copilot-instructions.md`, `GLOSSARY.md`, and `ARCHITECTURE.md` to point to the correct per-project doc.
- Add documentation tables to each project’s `README.md` and clarify folder structure.
- Update `.csproj` files to include `docs/**/*.md` for packaging.
- Remove obsolete/moved docs from the solution file.
- Ensure all technical debt warnings and cross-references are preserved and accurate.
- No code changes; documentation only.
2026-03-29 22:16:28 +02:00
Loretta 03d606164c Overhaul SignalR/DataSource docs, update all references
- Added SIGNALR.md (transport) and SIGNALR_DATASOURCE.md (collection) as layered, comprehensive documentation; retired SIGNALR_ARCHITECTURE.md
- Updated all .md files and READMEs to reference new docs and clarify separation between transport and DataSource
- Clarified CRUD tag structure (5 independent tags), single-method tag-based dispatch, and JSON-in-Binary tech debt
- Added slot allocation and wire format clarifications to serialization docs
- Improved documentation layering, conventions, and critical warnings for future maintainers
2026-03-29 18:28:52 +02:00
Loretta 0b27532f17 Document SignalR architecture, grid, and ext deps
Added comprehensive docs for SignalR tag-based dispatch (docs/SIGNALR_ARCHITECTURE.md), including message flow, tag system, dynamic method registry, and tech debt (JSON-in-Binary). Updated all related READMEs, glossaries, and conventions to reference this architecture and clarify grid infrastructure (MgGridBase, FruitBankGridBase) and external dependency locations (AyCode.Core, AyCode.Blazor, Mango.Nop Libraries, FruitBank Plugin). Synchronized solution items and copilot-instructions. Improves discoverability, enforces conventions, and clarifies tech debt for all developers.
2026-03-29 10:43:07 +02:00
Loretta 17daf0fef2 Document AcBinary wire format, sync docs, update conventions
- Add BINARY_FORMAT.md: full AcBinary wire format spec (markers, encoding, options, protocol, interactions)
- Reference BINARY_FORMAT.md from GLOSSARY.md, Binaries/README.md, and Serializers/Binaries/README.md; add new glossary terms
- Clarify and expand config options tables to match new doc
- Add/clarify LLM maintenance rules: always sync .md files with code, auto-fix discrepancies
- Update root README.md: AyCode.Core targets .NET 9, not 10; stress doc/code sync
- Add code reuse and doc sync conventions to copilot-instructions.md and CONVENTIONS.md
- Add docs/ folder and BINARY_FORMAT.md to solution as Solution Items
- Minor clarifications and cross-links in ARCHITECTURE.md and other docs
2026-03-29 09:11:57 +02:00
374 changed files with 36040 additions and 10283 deletions

File diff suppressed because one or more lines are too long

145
.github/LLM_PROTOCOL_DECISIONS.md vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

48
.github/META_ISSUES.md vendored Normal file
View File

@ -0,0 +1,48 @@
# META — Known Issues (workspace meta-tooling)
For planned/actionable work see `META_TODO.md`.
Active issues for the workspace meta-tooling itself: protocol stack, skills, registry conventions, `.github/` files. Distinct from code-domain topics (LOG, BIN, SIG, etc.) which track code/feature concerns.
## In scope
- Skill correctness or coverage gaps (`docs-discovery`, `docs-check`, `protocol-audit`, `adr-author`, `docs-archive`)
- Registry / convention drift (`REPO_PREFIXES.md`, `TOPIC_CODES.md`, `protocol-audit/references/REPOS.md`)
- Protocol-stack inconsistencies (Rule #1-6 wording, `@repo` block format, ID-format edge cases)
- `.github/` infrastructure issues (folder structure, file naming conventions, generation rules)
- ADR / Decision Log governance issues (e.g., `Current protocol state` summary staleness)
## Out of scope
- Code-domain issues (those go to `LOG_ISSUES.md`, `BIN_ISSUES.md`, etc.)
- One-off historical events — those go to `LLM_PROTOCOL_DECISIONS.md` as decisions
- Forward-looking work that has been formally decided — that's a TODO (`META_TODO.md`) not an issue
## Active entries
*(No `ACCORE-META-I-*` entries yet — file just created (LLMP-DEC-62). Add the first entry below as concrete issues are observed.)*
## Issue entry template
```
## ACCORE-META-I-XXXX: Short title
**Severity:** Trivial / Low / Minor / Major / Critical · **Status:** Open / InProgress / Closed (YYYY-MM-DD) · **Area:** <skill / registry / convention name>
### Description
What's broken or inconsistent in the meta-tooling, with quotable evidence (file path + line number, or skill behaviour).
### Root cause
Why it's like that.
### Fix direction
Proposed approach.
### Resolution
(Filled when Status moves to `Closed` — what / where / why per `TOPIC_CODES.md` Status conventions.)
### Related
- Sibling: `META_TODO.md#accore-meta-t-xxxx` (if applicable)
- Cross-topic: `<other-topic>_ISSUES.md#accore-...` (if domain-spillover)
- LLMP-DEC: `LLMP-DEC-N` (if a decision documents the resolution)
```

476
.github/META_TODO.md vendored Normal file

File diff suppressed because one or more lines are too long

236
.github/MIGRATION_ID_MAPPING.md vendored Normal file
View File

@ -0,0 +1,236 @@
# MIGRATION_ID_MAPPING — Phase 4 (deletion-pending)
> ⚠️ **LLM-targeted deletion reminder**: This file was a Phase 4 transient artifact for the ID-format migration. **From 2026-05-10 onward** (2 weeks after Phase 5 completion 2026-04-26), any LLM agent that reads this file MUST surface the following single-line reminder to the user at the start of its next response:
>
> *"`MIGRATION_ID_MAPPING.md` is past its 2-week retention window (since 2026-05-10) — confirm if you want me to delete it now."*
>
> Until 2026-05-10 the file remains untouched as a reverse-lookup aid (git history, old chat transcripts, external references may still mention OLD IDs).
>
> **Phase 4 of the ID-format migration** (see `LLMP-DEC-50`). Maps existing 3-component IDs (`<TOPIC>-<TYPE>-<N>`) to the new 4-component format (`<PREFIX>-<TOPIC>-<TYPE>-<RAND>`).
>
> **Status:** generated 2026-04-26; Phase 5 (per-topic rename) completed 2026-04-26; Phase 6 (cross-ref cleanup) consumes this table. **Original Phase 7 plan (delete this file) is superseded** — this file now persists as a historical reverse-lookup aid, with a 2-week deletion-review window starting 2026-05-10 (per user instruction 2026-04-26).
>
> **Generation rules:**
> - Repo prefix per `REPO_PREFIXES.md` (file → repo → prefix).
> - Random 4-char `[A-Z0-9]` suffix; unique within each `<PREFIX>-<TOPIC>-<TYPE>` triplet.
> - `LLMP-DEC-N` entries are NOT migrated (workspace-meta exception).
> - Template/placeholder IDs (e.g. `## GRID-I-1: ...` example line, `AUTH-I-N` placeholder) are NOT migrated.
> - "Pending" references (mentioned in docs but not yet defined as `_ISSUES.md`/`_TODO.md` entries) are NOT migrated — see "Pending forward-references" section below; they get rewritten/removed in Phase 6.
---
## Summary
| Repo | Prefix | Issue IDs | TODO IDs | Total |
|---|---|---:|---:|---:|
| AyCode.Core | `ACCORE` | 41 | 36 | 77 |
| AyCode.Blazor | `ACBLAZOR` | 0 | 2 | 2 |
| Mango.Nop Libraries | `MGNOPLIB` | 0 | 0 | 0 |
| Mango.Nop.Core (sub-folder) | `MGNOPCORE` | 0 | 0 | 0 |
| Nop.Plugin.Misc.AIPlugin | `MGFBANKPLUG` | 0 | 0 | 0 |
| FruitBank | `FBANKNOP` | 0 | 0 | 0 |
| FruitBankHybridApp | `FBANKAPP` | 0 | 0 | 0 |
| **Total** | | **41** | **38** | **79** |
---
## ACCORE — AyCode.Core
### BINARY (BIN)
Canonical home: `AyCode.Core/docs/BINARY/BINARY_ISSUES.md`, `AyCode.Core/docs/BINARY/BINARY_TODO.md`.
| OLD_ID | FILE (repo-relative) | NEW_ID |
|---|---|---|
| `BIN-I-1` | `AyCode.Core/docs/BINARY/BINARY_ISSUES.md` | `ACCORE-BIN-I-D2J5` |
| `BIN-I-2` | `AyCode.Core/docs/BINARY/BINARY_ISSUES.md` | `ACCORE-BIN-I-G7N3` |
| `BIN-I-3` | `AyCode.Core/docs/BINARY/BINARY_ISSUES.md` | `ACCORE-BIN-I-S1F8` |
| `BIN-I-4` | `AyCode.Core/docs/BINARY/BINARY_ISSUES.md` | `ACCORE-BIN-I-V5L2` |
| `BIN-I-5` | `AyCode.Core/docs/BINARY/BINARY_ISSUES.md` | `ACCORE-BIN-I-K8R4` |
| `BIN-I-6` | `AyCode.Core/docs/BINARY/BINARY_ISSUES.md` | `ACCORE-BIN-I-P3M6` |
| `BIN-I-7` | `AyCode.Core/docs/BINARY/BINARY_ISSUES.md` | `ACCORE-BIN-I-T9X1` |
| `BIN-I-8` | `AyCode.Core/docs/BINARY/BINARY_ISSUES.md` | `ACCORE-BIN-I-B4Y7` |
| `BIN-I-9` | `AyCode.Core/docs/BINARY/BINARY_ISSUES.md` | `ACCORE-BIN-I-H2C5` |
| `BIN-I-10` | `AyCode.Core/docs/BINARY/BINARY_ISSUES.md` | `ACCORE-BIN-I-N6Q3` |
| `BIN-I-11` | `AyCode.Core/docs/BINARY/BINARY_ISSUES.md` | `ACCORE-BIN-I-F1W8` |
| `BIN-I-12` | `AyCode.Core/docs/BINARY/BINARY_ISSUES.md` | `ACCORE-BIN-I-J4D2` |
| `BIN-I-13` | `AyCode.Core/docs/BINARY/BINARY_ISSUES.md` | `ACCORE-BIN-I-R5V9` |
| `BIN-I-14` | `AyCode.Core/docs/BINARY/BINARY_ISSUES.md` | `ACCORE-BIN-I-L7G3` |
| `BIN-I-15` | `AyCode.Core/docs/BINARY/BINARY_ISSUES.md` | `ACCORE-BIN-I-M3K6` |
| `BIN-T-1` | `AyCode.Core/docs/BINARY/BINARY_TODO.md` | `ACCORE-BIN-T-S8P4` |
| `BIN-T-2` | `AyCode.Core/docs/BINARY/BINARY_TODO.md` | `ACCORE-BIN-T-Q2N7` |
| `BIN-T-3` | `AyCode.Core/docs/BINARY/BINARY_TODO.md` | `ACCORE-BIN-T-W9F1` |
| `BIN-T-4` | `AyCode.Core/docs/BINARY/BINARY_TODO.md` | `ACCORE-BIN-T-T5J8` |
### LOGGING (LOG)
Canonical home: `AyCode.Core/docs/LOGGING/LOGGING_ISSUES.md`, `AyCode.Core/docs/LOGGING/LOGGING_TODO.md`.
| OLD_ID | FILE (repo-relative) | NEW_ID |
|---|---|---|
| `LOG-I-1` | `AyCode.Core/docs/LOGGING/LOGGING_ISSUES.md` | `ACCORE-LOG-I-K7M2` |
| `LOG-I-2` | `AyCode.Core/docs/LOGGING/LOGGING_ISSUES.md` | `ACCORE-LOG-I-R9P3` |
| `LOG-I-3` | `AyCode.Core/docs/LOGGING/LOGGING_ISSUES.md` | `ACCORE-LOG-I-L4N8` |
| `LOG-I-4` | `AyCode.Core/docs/LOGGING/LOGGING_ISSUES.md` | `ACCORE-LOG-I-B2H5` |
| `LOG-I-5` | `AyCode.Core/docs/LOGGING/LOGGING_ISSUES.md` | `ACCORE-LOG-I-X7Q1` |
| `LOG-I-6` | `AyCode.Core/docs/LOGGING/LOGGING_ISSUES.md` | `ACCORE-LOG-I-V3J6` |
| `LOG-I-7` | `AyCode.Core/docs/LOGGING/LOGGING_ISSUES.md` | `ACCORE-LOG-I-T8F2` |
| `LOG-I-8` | `AyCode.Core/docs/LOGGING/LOGGING_ISSUES.md` | `ACCORE-LOG-I-M4C9` |
| `LOG-I-9` | `AyCode.Core/docs/LOGGING/LOGGING_ISSUES.md` | `ACCORE-LOG-I-P5W3` |
| `LOG-I-10` | `AyCode.Core/docs/LOGGING/LOGGING_ISSUES.md` | `ACCORE-LOG-I-K1Z7` |
| `LOG-T-1` | `AyCode.Core/docs/LOGGING/LOGGING_TODO.md` | `ACCORE-LOG-T-H6Y4` |
| `LOG-T-2` | `AyCode.Core/docs/LOGGING/LOGGING_TODO.md` | `ACCORE-LOG-T-N2D8` |
| `LOG-T-3` | `AyCode.Core/docs/LOGGING/LOGGING_TODO.md` | `ACCORE-LOG-T-R7L3` |
| `LOG-T-4` | `AyCode.Core/docs/LOGGING/LOGGING_TODO.md` | `ACCORE-LOG-T-F4S6` |
| `LOG-T-5` | `AyCode.Core/docs/LOGGING/LOGGING_TODO.md` | `ACCORE-LOG-T-J9G2` |
| `LOG-T-6` | `AyCode.Core/docs/LOGGING/LOGGING_TODO.md` | `ACCORE-LOG-T-B8K5` |
| `LOG-T-7` | `AyCode.Core/docs/LOGGING/LOGGING_TODO.md` | `ACCORE-LOG-T-X1V4` |
| `LOG-T-8` | `AyCode.Core/docs/LOGGING/LOGGING_TODO.md` | `ACCORE-LOG-T-M7P2` |
| `LOG-T-9` | `AyCode.Core/docs/LOGGING/LOGGING_TODO.md` | `ACCORE-LOG-T-L3T8` |
| `LOG-T-10` | `AyCode.Core/docs/LOGGING/LOGGING_TODO.md` | `ACCORE-LOG-T-Q6Z1` |
| `LOG-T-11` | `AyCode.Core/docs/LOGGING/LOGGING_TODO.md` | `ACCORE-LOG-T-W4H9` |
### SIGNALR_BINARY_PROTOCOL (SBP)
Canonical home: `AyCode.Services/docs/SIGNALR_BINARY_PROTOCOL/SIGNALR_BINARY_PROTOCOL_ISSUES.md`, `..._TODO.md`.
| OLD_ID | FILE (repo-relative) | NEW_ID |
|---|---|---|
| `SBP-I-1` | `AyCode.Services/docs/SIGNALR_BINARY_PROTOCOL/SIGNALR_BINARY_PROTOCOL_ISSUES.md` | `ACCORE-SBP-I-F6T2` |
| `SBP-I-2` | `AyCode.Services/docs/SIGNALR_BINARY_PROTOCOL/SIGNALR_BINARY_PROTOCOL_ISSUES.md` | `ACCORE-SBP-I-G4B5` |
| `SBP-T-1` | `AyCode.Services/docs/SIGNALR_BINARY_PROTOCOL/SIGNALR_BINARY_PROTOCOL_TODO.md` | `ACCORE-SBP-T-P8X9` |
| `SBP-T-2` | `AyCode.Services/docs/SIGNALR_BINARY_PROTOCOL/SIGNALR_BINARY_PROTOCOL_TODO.md` | `ACCORE-SBP-T-K3J7` |
| `SBP-T-3` | `AyCode.Services/docs/SIGNALR_BINARY_PROTOCOL/SIGNALR_BINARY_PROTOCOL_TODO.md` | `ACCORE-SBP-T-L1V4` |
| `SBP-T-4` | `AyCode.Services/docs/SIGNALR_BINARY_PROTOCOL/SIGNALR_BINARY_PROTOCOL_TODO.md` | `ACCORE-SBP-T-R6D2` |
| `SBP-T-5` | `AyCode.Services/docs/SIGNALR_BINARY_PROTOCOL/SIGNALR_BINARY_PROTOCOL_TODO.md` | `ACCORE-SBP-T-H7M5` |
| `SBP-T-6` | `AyCode.Services/docs/SIGNALR_BINARY_PROTOCOL/SIGNALR_BINARY_PROTOCOL_TODO.md` | `ACCORE-SBP-T-N9F3` |
| `SBP-T-7` | `AyCode.Services/docs/SIGNALR_BINARY_PROTOCOL/SIGNALR_BINARY_PROTOCOL_TODO.md` | `ACCORE-SBP-T-J5W8` |
| `SBP-T-8` | `AyCode.Services/docs/SIGNALR_BINARY_PROTOCOL/SIGNALR_BINARY_PROTOCOL_TODO.md` | `ACCORE-SBP-T-B3K6` |
### SIGNALR (SIG)
Canonical home: `AyCode.Services/docs/SIGNALR/SIGNALR_ISSUES.md`, `AyCode.Services/docs/SIGNALR/SIGNALR_TODO.md`.
| OLD_ID | FILE (repo-relative) | NEW_ID |
|---|---|---|
| `SIG-I-1` | `AyCode.Services/docs/SIGNALR/SIGNALR_ISSUES.md` | `ACCORE-SIG-I-R4W7` |
| `SIG-I-2` | `AyCode.Services/docs/SIGNALR/SIGNALR_ISSUES.md` | `ACCORE-SIG-I-L5K3` |
| `SIG-I-3` | `AyCode.Services/docs/SIGNALR/SIGNALR_ISSUES.md` | `ACCORE-SIG-I-H8D6` |
| `SIG-I-4` | `AyCode.Services/docs/SIGNALR/SIGNALR_ISSUES.md` | `ACCORE-SIG-I-P1J4` |
| `SIG-I-5` | `AyCode.Services/docs/SIGNALR/SIGNALR_ISSUES.md` | `ACCORE-SIG-I-N3V8` |
| `SIG-I-6` | `AyCode.Services/docs/SIGNALR/SIGNALR_ISSUES.md` | `ACCORE-SIG-I-T7S2` |
| `SIG-I-7` | `AyCode.Services/docs/SIGNALR/SIGNALR_ISSUES.md` | `ACCORE-SIG-I-B5G9` |
| `SIG-I-8` | `AyCode.Services/docs/SIGNALR/SIGNALR_ISSUES.md` | `ACCORE-SIG-I-K6F1` |
| `SIG-I-9` | `AyCode.Services/docs/SIGNALR/SIGNALR_ISSUES.md` | `ACCORE-SIG-I-X4M7` |
| `SIG-T-1` | `AyCode.Services/docs/SIGNALR/SIGNALR_TODO.md` | `ACCORE-SIG-T-J2P5` |
| `SIG-T-2` | `AyCode.Services/docs/SIGNALR/SIGNALR_TODO.md` | `ACCORE-SIG-T-W8R3` |
| `SIG-T-3` | `AyCode.Services/docs/SIGNALR/SIGNALR_TODO.md` | `ACCORE-SIG-T-D7Q4` |
| `SIG-T-4` | `AyCode.Services/docs/SIGNALR/SIGNALR_TODO.md` | `ACCORE-SIG-T-V9H1` |
| `SIG-T-5` | `AyCode.Services/docs/SIGNALR/SIGNALR_TODO.md` | `ACCORE-SIG-T-M5L6` |
| `SIG-T-6` | `AyCode.Services/docs/SIGNALR/SIGNALR_TODO.md` | `ACCORE-SIG-T-S3N8` |
### TOON (TOON)
Canonical home: `AyCode.Core/docs/TOON/TOON_ISSUES.md`, `AyCode.Core/docs/TOON/TOON_TODO.md`.
| OLD_ID | FILE (repo-relative) | NEW_ID |
|---|---|---|
| `TOON-I-1` | `AyCode.Core/docs/TOON/TOON_ISSUES.md` | `ACCORE-TOON-I-B7L4` |
| `TOON-I-2` | `AyCode.Core/docs/TOON/TOON_ISSUES.md` | `ACCORE-TOON-I-X3H2` |
| `TOON-I-3` | `AyCode.Core/docs/TOON/TOON_ISSUES.md` | `ACCORE-TOON-I-P6V5` |
| `TOON-I-4` | `AyCode.Core/docs/TOON/TOON_ISSUES.md` | `ACCORE-TOON-I-K4Z9` |
| `TOON-T-1` | `AyCode.Core/docs/TOON/TOON_TODO.md` | `ACCORE-TOON-T-D1R7` |
| `TOON-T-2` | `AyCode.Core/docs/TOON/TOON_TODO.md` | `ACCORE-TOON-T-G5M3` |
| `TOON-T-3` | `AyCode.Core/docs/TOON/TOON_TODO.md` | `ACCORE-TOON-T-J8N6` |
| `TOON-T-4` | `AyCode.Core/docs/TOON/TOON_TODO.md` | `ACCORE-TOON-T-V2T4` |
| `TOON-T-5` | `AyCode.Core/docs/TOON/TOON_TODO.md` | `ACCORE-TOON-T-S6B9` |
| `TOON-T-6` | `AyCode.Core/docs/TOON/TOON_TODO.md` | `ACCORE-TOON-T-F3X1` |
| `TOON-T-7` | `AyCode.Core/docs/TOON/TOON_TODO.md` | `ACCORE-TOON-T-M9Q2` |
### XCUT (XCUT)
Canonical home: `AyCode.Core/docs/XCUT/XCUT_ISSUES.md`. Cross-ref pointer entries with the same ID exist in `BINARY_ISSUES.md` (line 148) and `SIGNALR_ISSUES.md` (line 131) — they are renamed to the same NEW_ID in Phase 5 (cross-ref pointers, not duplicate entries).
| OLD_ID | FILE (repo-relative) | NEW_ID |
|---|---|---|
| `XCUT-I-1` | `AyCode.Core/docs/XCUT/XCUT_ISSUES.md` (canonical) + cross-refs in `BINARY_ISSUES.md`, `AyCode.Services/docs/SIGNALR/SIGNALR_ISSUES.md` | `ACCORE-XCUT-I-X8Q1` |
---
## ACBLAZOR — AyCode.Blazor
### MGGRID (GRID)
Canonical home: `AyCode.Blazor.Components/docs/MGGRID/MGGRID_TODO.md`. (No issues yet — `MGGRID_ISSUES.md` only contains a placeholder example line.)
| OLD_ID | FILE (repo-relative) | NEW_ID |
|---|---|---|
| `GRID-T-1` | `AyCode.Blazor.Components/docs/MGGRID/MGGRID_TODO.md` | `ACBLAZOR-GRID-T-V4P7` |
| `GRID-T-2` | `AyCode.Blazor.Components/docs/MGGRID/MGGRID_TODO.md` | `ACBLAZOR-GRID-T-S2L9` |
---
## Pending forward-references (NOT migrated — Phase 6 cleanup)
These IDs are referenced in docs but not yet defined as `_ISSUES.md`/`_TODO.md` entries. They have no OLD_ID body to migrate. Phase 6 must decide per-case: (a) actually create the entry under the new format, or (b) rewrite/remove the reference.
| Referenced ID | Mentioned in | Decision deferred to Phase 6 |
|---|---|---|
| `LOG-T-12` | `docs/AUTH/README.md`, `docs/adr/0001-user-bearer-token-flow.md` | Tentative TODO ("Never log secrets" framework guideline). Either define as `ACCORE-LOG-T-<RAND>` and add to `LOGGING_TODO.md`, or rewrite reference. |
| `SBP-T-9` | `AyCode.Services/docs/adr/0001-acbinary-decorator-feature-stack-design.md` | Reserved for `AcHubProtocolDecoratorBase` impl + handshake; deferred until at least one leaf ADR (0002-0005) reaches `Status: Accepted`. Either define on demand or rewrite reference. |
## Template/placeholder IDs (NOT migrated)
These appear in template/example lines, not as real entries. They stay as-is (or get reformatted to the new placeholder syntax in Phase 5 alongside the topic rename).
| Placeholder | File | Note |
|---|---|---|
| `GRID-I-1` | `AyCode.Blazor.Components/docs/MGGRID/MGGRID_ISSUES.md:7` | Example line: "Add the first `## GRID-I-1: ...` entry below..." — should become "`## ACBLAZOR-GRID-I-<RAND>:`" example. |
| `AUTH-I-N` | `AyCode.Core/docs/AUTH/AUTH_ISSUES.md`, `AyCode.Core/docs/AUTH/README.md` | Generic placeholder — the `N` is literal "N", not a digit. Should become `ACCORE-AUTH-I-<RAND>` example. |
## Workspace-meta IDs (NOT migrated — bare exception)
`LLMP-DEC-N` Decision Log entries do NOT receive a repo prefix per `REPO_PREFIXES.md` "LLMP exception" section. They stay bare. Highest entry as of 2026-04-26: `LLMP-DEC-55` (Phase 3 closure).
---
## Within-triplet duplicate-check (sanity)
| Triplet | Count | Suffixes (sorted) | Unique? |
|---|---:|---|---|
| `ACCORE-BIN-I-` | 15 | B4Y7, D2J5, F1W8, G7N3, H2C5, J4D2, K8R4, L7G3, M3K6, N6Q3, P3M6, R5V9, S1F8, T9X1, V5L2 | ✅ |
| `ACCORE-BIN-T-` | 4 | Q2N7, S8P4, T5J8, W9F1 | ✅ |
| `ACCORE-LOG-I-` | 10 | B2H5, K1Z7, K7M2, L4N8, M4C9, P5W3, R9P3, T8F2, V3J6, X7Q1 | ✅ |
| `ACCORE-LOG-T-` | 11 | B8K5, F4S6, H6Y4, J9G2, L3T8, M7P2, N2D8, Q6Z1, R7L3, W4H9, X1V4 | ✅ |
| `ACCORE-SBP-I-` | 2 | F6T2, G4B5 | ✅ |
| `ACCORE-SBP-T-` | 8 | B3K6, H7M5, J5W8, K3J7, L1V4, N9F3, P8X9, R6D2 | ✅ |
| `ACCORE-SIG-I-` | 9 | B5G9, H8D6, K6F1, L5K3, N3V8, P1J4, R4W7, T7S2, X4M7 | ✅ |
| `ACCORE-SIG-T-` | 6 | D7Q4, J2P5, M5L6, S3N8, V9H1, W8R3 | ✅ |
| `ACCORE-TOON-I-` | 4 | B7L4, K4Z9, P6V5, X3H2 | ✅ |
| `ACCORE-TOON-T-` | 7 | D1R7, F3X1, G5M3, J8N6, M9Q2, S6B9, V2T4 | ✅ |
| `ACCORE-XCUT-I-` | 1 | X8Q1 | ✅ (trivially) |
| `ACBLAZOR-GRID-T-` | 2 | S2L9, V4P7 | ✅ |
All 12 triplets pass uniqueness within-triplet (the only collision domain that matters per `REPO_PREFIXES.md`'s suffix specification — collisions ACROSS triplets are non-issues since the full ID disambiguates).
---
## Phase 5 readiness checklist
For each topic-pair (`_ISSUES.md` + `_TODO.md`) Phase 5 will:
1. Rename headers (`## LOG-I-1: ...` → `## ACCORE-LOG-I-K7M2: ...`).
2. Rewrite intra-file cross-refs (e.g., body text "see LOG-I-3" → "see ACCORE-LOG-I-L4N8").
3. Rewrite TOC/anchor references if present.
4. Verify with grep: zero remaining bare `<TOPIC>-<TYPE>-<N>` matches in the file (post-rename), excluding example/placeholder lines.
5. One commit per topic (12 commits total: BIN, LOG, SBP, SIG, TOON, XCUT, GRID — but BIN/LOG/SIG/SBP/TOON each pair I+T → 5 commits; XCUT and GRID each 1 commit → 7 commits total).
Inter-file cross-refs (e.g., `BINARY_ISSUES.md` mentioning `BIN-T-3`, `BINARY_TODO.md` mentioning `BIN-T-3` from another entry, ADRs mentioning `LOG-I-9` etc.) are handled in Phase 6.
---
## Related
- `REPO_PREFIXES.md` — repo prefix registry (canonical authority for the `<PREFIX>` component).
- `skills/docs-check/references/TOPIC_CODES.md` — topic registry (canonical authority for the `<TOPIC>` component).
- `LLM_PROTOCOL_DECISIONS.md` `LLMP-DEC-50` — migration design decision (7-phase plan).
- `LLM_PROTOCOL_DECISIONS.md` `LLMP-DEC-53`, `LLMP-DEC-54`, `LLMP-DEC-55` — Phases 1-3 closure entries.

106
.github/REPO_PREFIXES.md vendored Normal file
View File

@ -0,0 +1,106 @@
# Repo Prefixes — format spec for repo-level namespace in topic IDs
Specification of the `<PREFIX>` component in the workspace's 4-component ID format. **Each repo declares its own prefix in its own `copilot-instructions.md` `@repo` block** — there is no central prefix listing here, in line with the Framework-First Design Principle (a framework `.md` does not enumerate consumer repos).
This file lives in `AyCode.Core/.github/` because the **format spec** is workspace-meta (used by every repo). It does NOT list non-framework prefixes.
## Full ID format
```
<PREFIX>-<TOPIC>-<TYPE>-<RAND>
```
| Component | Source | Description |
|---|---|---|
| `<PREFIX>` | Each repo's own `copilot-instructions.md` `@repo` block (`prefix = "..."` field) | Repo-level namespace |
| `<TOPIC>` | `docs-check/references/TOPIC_CODES.md` | Topic code (e.g., `LOG`, `BIN`, `SIG`) |
| `<TYPE>` | `docs-check/references/TOPIC_CODES.md` | Entry type (`I` = issue, `T` = TODO, `B` = bug, `C` = critical severity override) |
| `<RAND>` | Generated at creation | 4-character random alphanumeric suffix from `[A-Z0-9]` |
**Format rules**: all uppercase, hyphen-separated, no underscores, no spaces. Hash anchors in markdown cross-refs use lowercase: `accore-log-i-k7m2`.
**Examples** (using this repo's own prefix only — see Framework-First note above):
```
ACCORE-LOG-I-K7M2 # AyCode.Core's logger issue, random suffix K7M2
ACCORE-BIN-T-W9F1 # AyCode.Core's BINARY TODO, random suffix W9F1
ACCORE-XCUT-I-X8Q1 # AyCode.Core's cross-cutting issue
LLMP-DEC-50 # workspace-meta Decision (no prefix — see "LLMP exception" below)
```
## Why per-repo prefixes
Without prefixes, IDs like `LOG-I-5` are not globally unique across repos. Two peers may independently create logger-related issues with colliding IDs. More importantly: **framework docs cannot reference consumer-side issues** per the Framework-First Design Principle (a lower-layer framework cannot depend on a higher-layer consumer). Per-repo prefixes provide:
1. **Globally unique IDs**`ACCORE-LOG-I-K7M2``<other-prefix>-LOG-I-K7M2`, even when topic, type, and random suffix all match.
2. **Layer enforcement is visible** — a framework doc body referencing a higher-layer-prefixed ID becomes an immediate red flag in review (the prefix mismatch reveals the dependency-direction violation).
3. **Cross-repo search via wildcard**`*-LOG-I-*` glob/regex finds all logger issues workspace-wide, with no central registry needed; the LLM filters by prefix after retrieval.
4. **Distributed parallel work** — combined with the random `<RAND>` suffix, multiple developers can create entries in parallel branches without ID-collision at merge time.
## ACCORE — this repo's own prefix
This repo (`AyCode.Core`) uses prefix **`ACCORE`** (declared in this repo's own `copilot-instructions.md` `@repo` block, `prefix = "ACCORE"` field).
## Per-repo prefix declaration convention
Every repo participating in the workspace declares its own prefix in its `.github/copilot-instructions.md` `@repo` block:
```
@repo {
name = "<RepoName>"
prefix = "<PREFIX>" # ← prefix declared here
type = "framework" | "product" | "consumer" | ...
layer = 0..N
own-dep-repos = [...]
}
```
To discover a peer's prefix at agent runtime: read that peer's `copilot-instructions.md` (already loaded if the peer is in `own-dep-repos`). For peers NOT in `own-dep-repos` (i.e., higher-layer consumers from a framework's perspective): cross-repo wildcard search (next section) avoids needing to know the prefix in advance.
## Cross-repo ID search (no central registry needed)
When searching for entries across the workspace (e.g., "all logger issues" — across framework AND any consumer repos), agents use the prefix-wildcard glob:
```
*-LOG-I-* # all logger issues, any prefix
*-BIN-T-* # all BINARY TODOs, any prefix
*-SIG-* # all SIGNALR entries (issues + TODOs + bugs)
```
The wildcard pattern is workspace-discovery-agnostic — no central prefix list required. Result enumeration finds all matches; the LLM filters by prefix per the user's intent. The `docs-discovery` skill includes the cross-repo wildcard convention in its discovery flow.
## Random suffix spec
The `<RAND>` suffix is **4 characters from `[A-Z0-9]`** (36⁴ ≈ 1.7 million combinations per topic-type-prefix triple).
**Generation rules**:
1. Each new entry receives a fresh random suffix at creation time.
2. Before finalizing: the agent globs existing entries (active topic file + all year-bucketed archive files for that topic) and verifies the suffix is not yet used.
3. If collision detected (extremely rare — birthday-paradox 50% probability at ~1300 entries per topic-type-prefix triple): regenerate the suffix.
4. The suffix is **append-only** once assigned — never renumbered, never recycled, never reassigned to a different entry.
**At archive time** (`docs-archive` skill): performs collision-check before moving entries to year-bucketed archive files. If collision detected with an existing archive entry: skill aborts, signals the user, awaits manual resolution — no silent corruption.
## LLMP exception
`LLMP-DEC-N` Decision Log entries **do NOT receive a repo prefix**. They are workspace-meta — there is exactly one `LLM_PROTOCOL_DECISIONS.md` file (in AyCode.Core), and decisions are workspace-wide, not repo-specific. Bare format: `LLMP-DEC-1`, `LLMP-DEC-2`, ...
Sequential numbering for LLMP-DEC entries is **preserved** (legacy from before this registry existed; no random suffix). This is acceptable because the single Decision Log file enforces serialization — no parallel branches create concurrent LLMP-DEC entries that would collide.
If a future scenario emerges where workspace-meta decisions span multiple Decision Logs (unlikely), this exception will be revisited.
## Cross-references
- **Topic codes registry**: `docs-check/references/TOPIC_CODES.md` — the `<TOPIC>` component
- **Decision Log**: `LLM_PROTOCOL_DECISIONS.md` — registry of `LLMP-DEC-N` entries (workspace-meta, no prefix)
- **Per-repo prefix declarations**: each repo's own `.github/copilot-instructions.md` `@repo` block
## Picking a prefix for a new repo
When a new repo joins the workspace, it picks its own prefix (no central approval needed):
1. Choose a prefix (4-12 chars, uppercase, alphanumeric, no hyphens / underscores).
2. Verify it does NOT collide with `Ac*` / `Mg*` C# class-name prefixes (prefix must be ≥ 4 chars to avoid 2-char visual collision in mixed code/markdown content).
3. Verify it is visually distinct from prefixes of repos this new repo will reference or interoperate with (workspace-discovery-time check, not centrally enforced).
4. Declare the prefix in the new repo's `.github/copilot-instructions.md` `@repo` block (`prefix = "<PREFIX>"` field).
5. (Optional) Add an `LLMP-DEC-N` entry recording the new repo's join + prefix choice, if the new repo's existence is workspace-meta-significant.

File diff suppressed because one or more lines are too long

223
.github/skills/adr-author/SKILL.md vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,82 @@
# ADR NNNN: <imperative short title>
<!--
Template for Architecture Decision Records, per the `adr-author` skill.
Copy this file to `<active-repo>/docs/adr/NNNN-<slug>.md` and fill in.
Remove this HTML comment and any placeholder lines you don't use.
NNNN = zero-padded 4 digits, one-past-max in the target docs/adr/ folder.
<slug> = short kebab-case derived from the title.
-->
## Status
<!--
One of: Proposed (YYYY-MM-DD) | Accepted (YYYY-MM-DD) | Superseded by ADR-XXXX (YYYY-MM-DD) | Rejected (YYYY-MM-DD)
Note: ADR Status uses Nygard's classic 4-value vocabulary, distinct from
`_ISSUES.md` / `_TODO.md` 3-value vocabulary (Open / InProgress / Closed).
See `docs-check/references/TOPIC_CODES.md` "Status field conventions" for the latter.
-->
Proposed (YYYY-MM-DD)
## Context
<!--
Objective description of the situation.
What's the problem or opportunity? What's the timing constraint — why decide now?
What's in scope / out of scope?
No opinion or recommendation here — just the facts that frame the decision.
-->
## Decision
<!--
What we decided, in one sentence or one short paragraph.
Imperative tense: "Adopt X", "Use Y", "Migrate to Z".
This is the headline — someone skimming the file should understand the decision
without reading any other section.
-->
## Consequences
**Positive:**
- <what this enables / improves>
- <...>
**Negative:**
- <what this costs / breaks / constrains>
- <...>
**Follow-ups required:**
- <what needs to happen next to fully realize this decision typically a `_TODO.md` entry or a subsequent ADR>
- <...>
## Alternatives considered
<!--
Minimum 2 alternatives. If you only had 1 "option", it wasn't a decision —
it was an implementation path. Consider whether this file is the right artifact.
For "obviously bad" rejections, the one-line reason is enough. Only add
the dimension sub-bullets (Reversibility / Cost / Future flexibility) when
alternatives differ MATERIALLY on those dimensions — don't pad every
rejection with all four dimensions.
-->
- **<alternative name>** (rejected): <one-sentence reason>
- *Reversibility:* <only if cost-of-undo materially differs>
- *Cost:* <only if implementation cost materially differs>
- *Future flexibility:* <only if it closes doors on later options>
- **<alternative name>** (rejected): <one-sentence reason>
## Related
<!-- Remove lines that don't apply. -->
- Supersedes: <ADR-XXXX, if applicable>
- Superseded by: <ADR-YYYY, if this ADR was later overturned>
- Related ADRs: <ADR-ZZZZ, ...>
- Related TODOs/Issues: <e.g., `ACCORE-LOG-T-K7M2`, `ACCORE-BIN-I-3R9P`, ... per `TOPIC_CODES.md` ID format rules and `REPO_PREFIXES.md` prefix scheme>
- External references: <URLs, RFCs, blog posts that informed this decision>

157
.github/skills/docs-archive/SKILL.md vendored Normal file

File diff suppressed because one or more lines are too long

176
.github/skills/docs-check/SKILL.md vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,145 @@
# Topic Codes — registry for AyCode.Core's own topics (`ACCORE`)
Per the Framework-First Design Principle, this Layer 0 registry lists **only the framework's own (`ACCORE`) topics**. Each higher-layer repo hosts its own `TOPIC_CODES.md` for repo-specific topics — see `## Per-repo extension convention` below. A consumer's topic-search at runtime walks `own-dep-repos` to gather both its own and all inherited (lower-layer) topic registries.
Full ID format: `<PREFIX>-<TOPIC>-<TYPE>-<RAND>` — see `AyCode.Core/.github/REPO_PREFIXES.md` for the `<PREFIX>` and `<RAND>` components. The `<TOPIC>` and `<TYPE>` components are defined in this file (for the framework) and in each higher-layer repo's own equivalent (for consumer-side topics).
## Why this registry exists
To make IDs like `ACCORE-LOG-I-K7M2`, `ACCORE-SIG-B-3R9P`, `ACCORE-XCUT-I-A4B7` unambiguous within the framework. The `<TOPIC>` component (registered here for ACCORE) combines with `<PREFIX>` (per `REPO_PREFIXES.md`) and `<RAND>` (4-character random alphanumeric suffix) to form the full ID.
## Framework's own (ACCORE) topic codes
| Code | Topic | Scope | Docs location |
|---------|-----------------------------|-----------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------|
| `LOG` | LOGGING | Logger system: levels, writers, config-reading vs DI factory | `AyCode.Core/AyCode.Core/docs/LOGGING/` (+ variants in `AyCode.Core.Server`, `AyCode.Services`) |
| `AUTH` | AUTH | User authentication: bearer tokens, JWT, login flow, hub authorization | `AyCode.Core/docs/AUTH/` |
| `SIG` | SIGNALR | SignalR transport: tags, client base, dispatch, session | `AyCode.Core/AyCode.Services/docs/SIGNALR/` (+ variant in `AyCode.Services.Server`) |
| `SBP` | SIGNALR_BINARY_PROTOCOL | Binary wire protocol over SignalR: framing, chunking, argument read | `AyCode.Core/AyCode.Services/docs/SIGNALR_BINARY_PROTOCOL/` |
| `SIGDS` | SIGNALR_DATASOURCE | Client-server DataSource on SignalR transport: change tracking, rollback, sync state, load lifecycle, IList<T> wrapper | `AyCode.Core/AyCode.Services.Server/docs/SIGNALR_DATASOURCE/` |
| `BIN` | BINARY | AcBinary serializer: features, format, writers, source generator | `AyCode.Core/AyCode.Core/docs/BINARY/` |
| `TOON` | TOON | Toon serializer: LLM-optimized format with @meta/@types/@data sections | `AyCode.Core/AyCode.Core/docs/TOON/` |
| `XCUT` | cross-cutting | Issues / TODOs spanning ≥2 ACCORE topics — one canonical home, referenced from each affected topic | `AyCode.Core/AyCode.Core/docs/XCUT/` |
| `META` | meta-tooling | Issues/TODOs **about the workspace meta-tooling itself**: skills, registries, `.github/` conventions, ADR/Decision Log governance, protocol-stack edge cases. Distinct from code-domain topics (LOG, BIN, etc.). | `AyCode.Core/.github/META_ISSUES.md` + `AyCode.Core/.github/META_TODO.md` |
| `LLMP` | LLM-protocol meta | LLM protocol decisions (Decision Log entries only — uses `LLMP-DEC-N` form, no prefix) | `AyCode.Core/.github/LLM_PROTOCOL_DECISIONS.md` |
## Type codes (universal across all repos)
| Code | Type | Used in file | Notes |
|-------|----------------|----------------------------------------------|----------------------------------------------------------------------------------------------------|
| `I` | Issue | `{TOPIC}_ISSUES.md` | Concrete concern: spec inconsistency, broken contract, observable edge case |
| `T` | TODO | `{TOPIC}_TODO.md` | Forward-looking planned work: refactor, missing feature, optimization |
| `B` | Bug | `{TOPIC}_ISSUES.md` (alongside `I` entries) | Confirmed broken behaviour, reproducible, needs a code fix |
| `C` | Critical | `{TOPIC}_ISSUES.md` or `{TOPIC}_TODO.md` | **Severity override** — emergency priority, supersedes `I`/`B`/`T` category; body explains type |
| `DEC` | Decision | `LLM_PROTOCOL_DECISIONS.md` | **LLMP-only.** Append-only protocol decision entries. |
### Distinctions
- **I vs B**: Both tracked together in `_ISSUES.md`. Use `B` only when the behaviour is confirmed broken with a reproducer. `I` covers concerns, inconsistencies, doc drift, edge cases without an active bug.
- **C (Critical)**: A severity flag, not a category. `ACCORE-LOG-C-K7M2` means "AyCode.Core's logger critical item with random suffix K7M2" — body must state whether it's an underlying bug / issue / todo. Prefer `C` over `I`/`B`/`T` when severity is emergency. Do NOT double-classify (no `ACCORE-LOG-IB-K7M2` or similar).
- **DEC**: LLMP exception — long form because "LLMP-D-1" is unreadable. Decision Log entries only.
## Per-repo extension convention
Each higher-layer repo MAY host its own `TOPIC_CODES.md` for repo-specific topics. Recommended location:
```
<repo>/.github/TOPIC_CODES.md
```
This per-repo file lists ONLY that repo's own topic codes. Lower-layer (inherited) topics are reachable through the dependency tree — at runtime, the `docs-check` skill walks `own-dep-repos` from the invocation point to gather both this repo's own topics AND all inherited topics from deps.
Topic codes need NOT be globally unique across repos — the `<PREFIX>` component disambiguates. Two repos may legitimately use the same topic code for repo-local concepts (e.g., one framework's `DAL` ≠ another framework's `DAL`).
If a higher-layer repo has no repo-specific topics, the file is omitted (default = the repo uses only inherited topics from its deps).
The framework (this file) does NOT enumerate higher-layer topics — that would violate Framework-First. To find all topics workspace-wide, agents walk the dep tree from a top-layer consumer (which transitively sees everything).
## ID format rules
1. **Format**: `<PREFIX>-<TOPIC>-<TYPE>-<RAND>` — all uppercase, hyphen-separated. The `<PREFIX>` component identifies the owning repo per `AyCode.Core/.github/REPO_PREFIXES.md` (and per each repo's own `@repo.prefix` field). **LLMP exception**: `LLMP-DEC-N` entries (workspace-meta Decision Log) skip the prefix and use sequential `N` instead of `<RAND>` — single-file serialization avoids parallel-branch collision.
2. **Random suffix**: `<RAND>` is a 4-character alphanumeric suffix from `[A-Z0-9]` (~1.7M combinations per `<PREFIX>-<TOPIC>-<TYPE>` triple). Generated at entry creation; the agent globs existing entries (active topic file + all year-bucketed archive files) and verifies uniqueness; regenerate on rare collision.
3. **Append-only**: once assigned, IDs never change. If an entry is reversed or superseded, add a NEW entry that references the prior one — do not renumber, do not re-randomize.
4. **Hash anchor** (markdown cross-file refs): lowercase with hyphens preserved (`LOGGING_ISSUES.md#accore-log-i-k7m2` — GitHub auto-converts). Always use the full prefixed form; bare hash anchors without prefix are ambiguous across repos.
5. **No sub-category in ID**: legacy sub-prefixes like `PROTO-`, `DISPATCH-`, `CONN-`, `DS-` are NOT allowed at ID level. Capture sub-category in the entry body header: `## ACCORE-SIG-I-K7M2 [PROTO]: ...`.
## Registry maintenance — adding a new ACCORE topic
To add a new topic code **for AyCode.Core specifically**:
1. Propose the code (2-6 uppercase chars), short and mnemonic, scoped to ACCORE's domain (framework concerns only).
2. Check it doesn't collide with C# class-name prefixes (`Ac*` / `Mg*`) — the topic code should be visually distinct in mixed code/markdown content.
3. Check it doesn't collide with existing ACCORE topic codes in the table above.
4. Add a row to the "Framework's own (ACCORE) topic codes" table.
5. Create the topic folder: `AyCode.Core/<project>/docs/{TOPIC_FOLDER_NAME}/` with `README.md`, optional `{TOPIC_FOLDER_NAME}_ISSUES.md`, `{TOPIC_FOLDER_NAME}_TODO.md`.
6. Add a Decision Log entry (`LLMP-DEC-N`, in the workspace-level `LLM_PROTOCOL_DECISIONS.md`) recording the new framework topic.
For higher-layer repos: each consumer registers its own topics in its own `TOPIC_CODES.md` per the per-repo extension convention. No framework-level approval is needed — the consumer is sovereign over its own domain.
## Collision avoidance with class-name prefixes
C# code conventions in this workspace:
- `Ac*` — AyCode.Core framework types (e.g., `AcLoggerBase`, `AcBinarySerializer`)
- `Mg*` — Mango company types (e.g., `MgGrid`, `MgDbTableBase`, `MgEntityBase`)
Topic codes intentionally avoid these 2-char prefixes (`Ac`, `Mg`) to prevent visual confusion in mixed content. Topic codes are 2-6 chars and SHOULD NOT start with `Ac` or `Mg`. (Example principle: a hypothetical 2-char `MG` topic code would visually collide with `Mg*` class names; choose a more distinctive ≥3-char code.)
## Examples (ACCORE only)
```
ACCORE-LOG-I-K7M2 # framework's logger issue (random suffix K7M2)
ACCORE-LOG-T-3R9P # framework's logger TODO
ACCORE-LOG-B-A4B7 # framework's logger bug (confirmed broken)
ACCORE-LOG-C-X9Q4 # framework's logger CRITICAL — body: underlying bug / issue / todo
ACCORE-SIG-I-M2K8 # framework's SignalR issue (body may note: [PROTO] sub-category)
ACCORE-SBP-T-7N3F # framework's SignalR Binary Protocol TODO
ACCORE-BIN-B-P5W2 # framework's Binary serializer bug
ACCORE-TOON-I-D8R6 # framework's Toon issue
ACCORE-XCUT-I-F4G1 # framework's cross-cutting issue (affects ≥2 ACCORE topics)
LLMP-DEC-50 # workspace-meta Decision Log entry (no prefix — bare exception)
```
The `<RAND>` suffixes shown above are illustrative. Real entries generate fresh random suffixes at creation time per `REPO_PREFIXES.md`'s "Random suffix spec".
## Cross-references to other files
- **Reference format** (cross-file in markdown): `LOGGING_ISSUES.md#accore-log-i-k7m2` (filename + lowercase hash anchor with full 4-component ID). Always use the full prefixed form — bare hash anchors without prefix are ambiguous across repos.
- **Code comments**: `// See ACCORE-LOG-I-K7M2` — full prefixed form, since the ID is globally unique only with prefix.
- **DB natural key** (future migration): `(prefix, topic, type, suffix)` tuple; or the full string `ACCORE-LOG-I-K7M2` as a single column.
- **Workspace registries**: `AyCode.Core/.github/REPO_PREFIXES.md` (framework prefix spec); this file (ACCORE topics + format spec); each higher-layer repo's own `.github/TOPIC_CODES.md` (consumer-side topics); `AyCode.Core/.github/LLM_PROTOCOL_DECISIONS.md` (LLMP-DEC entries, workspace-meta history).
## Status field conventions
Every entry in `_ISSUES.md`, `_TODO.md`, and `LLM_PROTOCOL_DECISIONS.md` SHOULD carry an explicit `Status` field. **3 allowed values**:
| Status | Meaning | Archive eligible? |
|---|---|---|
| `Open` | Active / unresolved (default for new entries); also used for documented-current-behaviour entries that must remain visible | No |
| `InProgress` | Partial work in flight; some scope addressed but more remains | No |
| `Closed` | Done — bug fixed, decision made (won't fix / superseded by another entry / accepted), TODO completed. The body of the entry explains *what happened* (date, ref, rationale). | Yes |
### Defaults
- New entries default to `Status: Open`.
- For documented current-behaviour entries (accepted limitations / "by design" / "this is how it works"), use `Status: Open` with an optional body callout: `> **Note:** This entry documents accepted current behaviour — not scheduled for change.` These never archive (Open status).
### Update workflow
When status changes, update the `Status` line in-place. **This is the ONE exception to append-only** — the Status field is mutable; entry body / ID / Description remain immutable.
When marking `Closed`:
1. **Format the Status line as** `Status: Closed (YYYY-MM-DD)` — the inline date is what `docs-archive` uses to determine the destination year-bucket.
2. **Add a `### Resolution` sub-section** documenting the closure. **Strongly recommended** — without it, future readers (and the `docs-archive` skill on lookup) have no context for "what changed, why, where". Suggested fields:
- **What:** one-line summary of the change.
- **Where:** code reference (file/class/commit hash) or doc reference (ADR / PR).
- **Why:** the rationale (fix / "won't fix because X" / "superseded by ACCORE-LOG-I-XXXX" / "accepted as-is").
- Optional: scope, date if different from Status line, related entries.
The body carries the **nuance**; the Status field only signals archive-eligibility.
### Lifecycle: archive
`Closed` entries are eligible for rotation into year-bucketed archive files (`<file>_<year>.md`) via the `docs-archive` skill. Year derived from a date in the entry body. Archive operation is user-invoked — closed entries don't disappear automatically. See `AyCode.Core/.github/skills/docs-archive/SKILL.md`.
## Change history
See the Decision Log (`../../../LLM_PROTOCOL_DECISIONS.md`) for the introduction of this registry and future topic-code additions.

200
.github/skills/docs-discovery/SKILL.md vendored Normal file

File diff suppressed because one or more lines are too long

184
.github/skills/protocol-audit/SKILL.md vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,75 @@
# Repos under protocol-audit (framework-only registry)
Per the Framework-First Design Principle, this Layer 0 registry lists **only the framework's own files** participating in the AI AGENT CORE PROTOCOL. Consumer files (Layer 1+) are discovered at audit time via the invocation-point repo's `own-dep-repos` walk — see `## Cross-repo audit discovery (runtime)` below.
## Canonical protocol host
**`AyCode.Core`** — this repo hosts the shared agent skills (`.github/skills/`), the Decision Log (`.github/LLM_PROTOCOL_DECISIONS.md`), and these registry files. All inherit files reference AyCode.Core. Cross-cutting invariants (X1X3) are skipped for the host itself (it does not cross-reference itself).
If the host designation is ever moved to a different repo, update this section AND the inherit-file substring checked by invariant I1 in `SKILL.md`.
## Framework's own primary protocol files
| # | Name | Absolute path | Layer | Host |
|---|-------------|---------------------------------------------|---------------|------|
| 1 | AyCode.Core | `H:\Applications\Aycode\Source\AyCode.Core` | framework (0) | ★ |
The instruction file is at `<abs-path>\.github\copilot-instructions.md`.
## Cross-repo audit discovery (runtime)
When `protocol-audit` is invoked from a higher-layer repo (Layer 1+), the skill discovers participating files by walking the invocation-point repo's `own-dep-repos` recursively:
1. Read the invocation-point repo's `.github/copilot-instructions.md` `@repo` block.
2. For each `own-dep-repos` entry, resolve the path relative to the repo root and read that dep's `@repo` block.
3. Continue transitively until no new deps are found.
4. Audit set = {invocation-point repo} {all walked deps}.
Effective audit scope per invocation:
- From `AyCode.Core` (Layer 0) → audits only `AyCode.Core` (this file's table).
- From `AyCode.Blazor` (Layer 1) → audits `AyCode.Core + AyCode.Blazor`.
- From a Layer 2/3 consumer → audits the full transitive dep tree below it (consumer + all its deps).
The framework cannot directly enumerate consumer files (Framework-First). Higher layers naturally see Layer ≤ N — that's what `own-dep-repos` already encodes.
## File-type classification (by content, not by central registry)
Each discovered file is classified per content inspection:
- **Primary** — contains the `🛑 AI AGENT CORE PROTOCOL (CRITICAL ENFORCEMENT)` header → full invariant set applies (Common + Primary + Cross-cutting).
- **Inherit** — contains the `follows the AI Agent Core Protocol defined in <HOST>` blockquote AND lacks the primary header → reduced invariant set applies (Common + Inherit + Cross-cutting).
- **Unknown** — matches neither pattern → flag as `UNKNOWN` for manual review (do not silently skip).
See `SKILL.md` for the invariant sets.
## Invariants by type
**Primary files** — full invariant set per `SKILL.md`:
- `@repo` block has all 5 required fields (`name`, `prefix`, `type`, `layer`, `own-dep-repos`); paths resolve to existing directories; `prefix` has valid format
- Rule numbering contiguous 1..N; rule count ≥ 5
- Rule #1 uses count+delta format
- Rule #2 contains `CROSS-REPO HARD-GATE` and `PER-QUESTION DOC-FIRST`
- Rule #3 is `STRICT NO-RE-READ POLICY (ANTI-LOOP)` and contains "in context" definition (`lossy compressions`)
- Rule #4 contains auto-detection triggers
- Rule #5 contains broad scope wording (`any file (code, documentation, configuration, memory, or otherwise)`)
- `strictly maintain rule 3` reference exists
- `## Shared Agent Skills` section with all three skills listed (X1)
- `## Protocol History` section referencing `AyCode.Core/.github/LLM_PROTOCOL_DECISIONS.md` (X2)
- Docs-sync rule references the `docs-check` skill (X3)
**Inherit files** — reduced invariant set:
- `@repo` block (if present) has all 5 required fields; paths resolve; `prefix` has valid format
- References AyCode.Core's protocol via substring: `follows the AI Agent Core Protocol defined in AyCode.Core` (I1)
- Does NOT duplicate the numbered Rules #1-5 (I2)
- Has a link to the Decision Log (I3)
- Has `## Shared Agent Skills` section with all three skills listed (X1)
- Has `## Protocol History` section referencing the canonical Decision Log (X2)
- Numbered rules are NOT required (they are inherited from AyCode.Core)
## Known issues
*(No open issues.)*
## Maintenance note
This file lists only the framework's own files. When the framework's own repo set changes (rare — currently a single fixed entry), update the table above. **Consumer participation is auto-discovered** at audit time via `own-dep-repos` walking — no central enumeration of consumer repos is needed here, in line with the Framework-First Design Principle.

View File

@ -0,0 +1,86 @@
using AyCode.Core.Benchmarks.Workloads.Scenarios;
using AyCode.Core.Serializers;
using AyCode.Core.Serializers.Binaries;
using AyCode.Core.Tests.TestModels;
using BenchmarkDotNet.Attributes;
namespace AyCode.Core.Benchmarks;
/// <summary>
/// BDN benchmark mirroring the Console app's "F" menu (<c>SerializerSelectionMode.FastestByte</c>) —
/// the focused 1:1 comparison between <b>AcBinary FastMode Byte[]</b> and <b>MemoryPack Default Byte[]</b>
/// across the 5 production-shaped test data cells (Small / Medium / Large / Repeated / Deep).
///
/// <para>Why this exists: the Console app's adaptive measurement engine gives fast turnaround but is
/// noise-prone; BDN's warmup + iteration + outlier-removal stack tightens the inter-engine delta to
/// the point where ~1-2% micro-optimizations become detectable. Both runners feed the SAME
/// <see cref="ISerializerBenchmark"/>-implementing workload (<see cref="AcBinaryBenchmark{T}"/> /
/// <see cref="MemoryPackBenchmark{T}"/>) — so the BDN numbers are directly comparable to Console's
/// <c>Console.FullBenchmark_Release_*.LLM</c> rows, only with tighter confidence intervals.</para>
///
/// <para>Output: BDN writes its native artifacts to <c>Test_Benchmark_Results/Benchmark/BDN/</c> (set
/// globally in <c>Program.cs</c> via <c>WithArtifactsPath</c>). <see cref="BdnSummaryAdapter"/> then
/// translates the <see cref="BenchmarkDotNet.Reports.Summary"/> into <see cref="Reporting.BenchmarkResult"/>
/// rows and emits the unified <c>Bdn.FullBenchmark_*.{log,LLM,output}</c> triplet next to Console's
/// counterparts in <c>Test_Benchmark_Results/Benchmark/</c>.</para>
/// </summary>
[MemoryDiagnoser]
public class AcBinaryVsMemPackBenchmark
{
/// <summary>
/// The 5 TestData cells matching Console's <c>BenchmarkLayer.Core</c> set —
/// Small (2x2x2x2) / Medium (3x3x3x4) / Large (5x5x5x10) / Repeated (10 items) / Deep (2x4x4x8).
/// Resolved at <see cref="GlobalSetup"/> time via <see cref="BenchmarkTestDataProvider_All_False.CreateTestDataSets"/>
/// (same provider Console uses) so the workload graphs are bit-for-bit identical.
/// </summary>
public static IEnumerable<string> TestDataNames => new[] { "Small", "Medium", "Large", "Repeated", "Deep" };
[ParamsSource(nameof(TestDataNames))]
public string TestData { get; set; } = "";
/// <summary>
/// Engine axis: AcBinary FastMode + Compact wire (UTF-8) vs MemoryPack Default (UTF-8). Compact-on-both-sides
/// keeps the string-encoding dimension constant so the comparison reflects engine differences only.
/// </summary>
[Params("AcBinary", "MemoryPack")]
public string Engine { get; set; } = "";
private ISerializerBenchmark _serializer = null!;
[GlobalSetup]
public void Setup()
{
// BDN runs each benchmark in an isolated child process — the parent's charset selection (a static
// field) does NOT cross the process boundary, so the child would otherwise fall back to the
// compile-time default (Latin1Long). Pin the BDN serializer benchmark to Latin1Short here so its
// cells line up with the Console Latin1Short runs. (Mirrored in BdnSummaryAdapter.WriteResults
// for the parent process — .LLM charset label + Size(B) column.)
BenchmarkTestDataProvider.LongStringSuffix = CharsetSuffixes.Latin1Short;
var allTestData = BenchmarkTestDataProvider_All_False.CreateTestDataSets();
var testDataSet = (TestDataSet<TestOrder_All_False>)allTestData.First(t => t.Name.StartsWith(TestData));
if (Engine == "AcBinary")
{
var options = AcBinarySerializerOptions.FastMode;
options.WireMode = WireMode.Compact;
_serializer = new AcBinaryBenchmark<TestOrder_All_False>(testDataSet.Order, options, "FastMode");
}
else
{
// MemoryPack's wire-mode-aligned ctor — Compact ↔ UTF-8 default for apples-to-apples vs AcBinary Compact.
_serializer = new MemoryPackBenchmark<TestOrder_All_False>(testDataSet.Order, WireMode.Compact, "Default");
}
// Round-trip correctness check before the BDN harness starts measuring — same gate the Console
// runner enforces. Fails the run early if anything's broken (rather than producing meaningless numbers).
if (!_serializer.VerifyRoundTrip())
throw new InvalidOperationException($"Round-trip verification FAILED for {Engine} on {TestData}.");
}
[Benchmark]
public void Serialize() => _serializer.Serialize();
[Benchmark]
public void Deserialize() => _serializer.Deserialize();
}

View File

@ -1,12 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<OutputType>Exe</OutputType>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<OutputType>Exe</OutputType>
</PropertyGroup>
<Import Project="..\AyCode.Core.targets" />
<!-- Exclude Test_Benchmark_Results from build to prevent path length issues -->
<ItemGroup>
<None Remove="Test_Benchmark_Results\**" />
@ -17,6 +16,7 @@
<ItemGroup>
<PackageReference Include="BenchmarkDotNet" Version="0.15.8" />
<PackageReference Include="MemoryPack" Version="1.21.4" />
<PackageReference Include="MessagePack" Version="3.1.4" />
<PackageReference Include="Microsoft.VisualStudio.DiagnosticsHub.BenchmarkDotNetDiagnosers" Version="18.6.37110.2" />
<PackageReference Include="MongoDB.Bson" Version="3.5.2" />

View File

@ -0,0 +1,218 @@
using AyCode.Core.Benchmarks.Reporting;
using AyCode.Core.Benchmarks.Workloads.Scenarios;
using AyCode.Core.Serializers;
using AyCode.Core.Serializers.Binaries;
using AyCode.Core.Tests.TestModels;
using BenchmarkDotNet.Reports;
using System.Text;
namespace AyCode.Core.Benchmarks;
/// <summary>
/// Translates <see cref="BenchmarkDotNet.Reports.Summary"/> (BDN's post-run aggregate) into the unified
/// <see cref="BenchmarkResult"/> rows consumed by <see cref="BenchmarkReportWriter"/>, then emits the
/// <c>Bdn.FullBenchmark_*.{log,LLM,output}</c> triplet alongside Console's counterparts in
/// <see cref="ReportingContext.ResolveResultsDirectory"/>'s <c>Test_Benchmark_Results/Benchmark/</c>.
///
/// <para><b>Why a separate adapter</b>: BDN's Summary is per-method (Serialize / Deserialize as separate
/// <see cref="BenchmarkReport"/>s, parameterised by TestData + Engine). The unified format collapses these
/// into per-cell rows (one row per <c>TestData × Engine</c> with both Ser and Des stats inline). The adapter
/// groups, transposes, and converts ns → ms before handing off to the shared writer.</para>
///
/// <para><b>Mean vs Median</b>: maps BDN's <see cref="BenchmarkDotNet.Mathematics.Statistics.Median"/> into
/// the BenchmarkResult's time columns — same convention as Console (which captures sample-median).
/// Min/Max/StdDev populate the inter-sample range surfaced in <see cref="BenchmarkReportWriter.FormatMicrosWithRange"/>
/// (incl. CV-warning ⚠️ marker when stddev/median exceeds <see cref="ReportingContext.UnstableCVThreshold"/>).</para>
///
/// <para><b>Iteration count = 1</b>: BDN reports <i>per-operation</i> time (ns) — already amortized across N
/// invocations. The unified BenchmarkResult expects total-batch time + iteration count (so <c>µs/op =
/// timeMs / iterations * 1000</c>). Storing Mean-in-ms with iterations = 1 makes the same formula yield
/// Mean-in-µs directly. The actual BDN N count is recorded in the BDN-native artifacts (<c>.../BDN/...</c>)
/// for anyone who wants the raw invocation count.</para>
/// </summary>
public static class BdnSummaryAdapter
{
/// <summary>
/// Post-run entry point — call once after <c>BenchmarkRunner.Run&lt;AcBinaryVsMemPackBenchmark&gt;(...)</c>
/// returns. Produces the BDN-side <c>Bdn.*</c> file triplet AND prints the grouped-results console table
/// (same view Console produces post-run) so the user sees the cell-level deltas immediately, without
/// having to open the .LLM file.
/// </summary>
public static void WriteResults(Summary summary)
{
// Parent-process counterpart of AcBinaryVsMemPackBenchmark.Setup's charset pin: the BDN child
// processes ran Latin1Short, but this adapter runs in the parent process where LongStringSuffix
// would still be the compile-time default (Latin1Long). Set it so GetCharsetName() labels the
// .LLM correctly AND the CreateTestDataSets()/CreateWorkload calls below compute matching Size(B).
BenchmarkTestDataProvider.LongStringSuffix = CharsetSuffixes.Latin1Short;
var allTestData = BenchmarkTestDataProvider_All_False.CreateTestDataSets();
var results = Translate(summary, allTestData);
var ctx = CreateContext();
BenchmarkReportWriter.PrintGroupedResults(results, allTestData);
BenchmarkReportWriter.SaveAll(ctx, results, allTestData);
}
private static ReportingContext CreateContext()
{
#if DEBUG
const string buildConfig = "Debug";
#elif SGEN_ONLY
const string buildConfig = "SGenOnly";
#else
const string buildConfig = "Release";
#endif
return new ReportingContext(
SourceTag: "Bdn",
ResultsDirectory: ReportingContext.ResolveResultsDirectory(),
BuildConfiguration: buildConfig,
Utf8NoBom: new UTF8Encoding(encoderShouldEmitUTF8Identifier: false),
CharsetName: GetCharsetName(),
// Warmup / Samples / TargetSampleMs are BDN-managed (not Console's adaptive engine). Zeros here
// signal "BDN handled internally" in the header; the BDN-native artifacts under .../BDN/ have
// the exact BDN config (warmup count, iteration count, run strategy) for anyone who needs it.
WarmupIterations: 0,
BenchmarkSamples: 0,
TargetSampleMs: 0,
UnstableCVThreshold: 0.03,
MicroOptCVThreshold: 0.015);
}
/// <summary>
/// Looks up the human-readable name for the currently-active <see cref="BenchmarkTestDataProvider.LongStringSuffix"/>
/// charset. Mirrors Console's <c>Configuration.GetCurrentCharsetName</c>. The BDN serializer benchmark
/// pins the charset to <c>Latin1Short</c> — set in <see cref="WriteResults"/> (parent process) and in
/// <c>AcBinaryVsMemPackBenchmark.Setup</c> (child process); see those sites for the process-isolation
/// rationale (BDN's per-benchmark child processes don't inherit a parent static-field mutation).
/// </summary>
private static string GetCharsetName()
{
var s = BenchmarkTestDataProvider.LongStringSuffix;
return s switch
{
CharsetSuffixes.AsciiFix => nameof(CharsetSuffixes.AsciiFix),
CharsetSuffixes.AsciiShort => nameof(CharsetSuffixes.AsciiShort),
CharsetSuffixes.AsciiLong => nameof(CharsetSuffixes.AsciiLong),
CharsetSuffixes.Latin1Fix => nameof(CharsetSuffixes.Latin1Fix),
CharsetSuffixes.Latin1Short => nameof(CharsetSuffixes.Latin1Short),
CharsetSuffixes.Latin1Long => nameof(CharsetSuffixes.Latin1Long),
CharsetSuffixes.CjkBmpShort => nameof(CharsetSuffixes.CjkBmpShort),
CharsetSuffixes.CjkBmpLong => nameof(CharsetSuffixes.CjkBmpLong),
CharsetSuffixes.CyrillicShort => nameof(CharsetSuffixes.CyrillicShort),
CharsetSuffixes.CyrillicLong => nameof(CharsetSuffixes.CyrillicLong),
CharsetSuffixes.MixedShort => nameof(CharsetSuffixes.MixedShort),
CharsetSuffixes.MixedLong => nameof(CharsetSuffixes.MixedLong),
_ => "Custom"
};
}
private static List<BenchmarkResult> Translate(Summary summary, List<TestDataSet> allTestData)
{
var grouped = summary.Reports
.Where(r => r.Success && r.ResultStatistics != null)
.GroupBy(r => (
TestData: GetParam(r, "TestData"),
Engine: GetParam(r, "Engine")
))
.Where(g => !string.IsNullOrEmpty(g.Key.TestData) && !string.IsNullOrEmpty(g.Key.Engine))
.ToList();
var results = new List<BenchmarkResult>(grouped.Count);
foreach (var group in grouped)
{
var testDataSet = (TestDataSet<TestOrder_All_False>)allTestData.First(t => t.Name.StartsWith(group.Key.TestData));
var engineEnum = group.Key.Engine switch
{
"AcBinary" => BenchmarkEngine.AcBinary,
"MemoryPack" => BenchmarkEngine.MemoryPack,
_ => throw new InvalidOperationException($"Unknown engine in BDN params: {group.Key.Engine}")
};
// Construct the same workload instance AcBinaryVsMemPackBenchmark.Setup would build — same options,
// same wire mode. Reading SerializedSize + OptionsDescription from it keeps the BDN-side metadata
// in lockstep with what the workload actually serialised (no drift between hardcoded BDN strings
// and the workload's own OptionsDescription / SerializedSize).
var workload = CreateWorkload(testDataSet, group.Key.Engine);
var result = new BenchmarkResult
{
TestDataName = testDataSet.DisplayName,
Engine = engineEnum,
IoMode = BenchmarkIoMode.ByteArray,
DispatchMode = BenchmarkDispatchMode.SGen,
OptionsPreset = group.Key.Engine == "AcBinary" ? "FastMode" : "Default",
OrderTypeName = nameof(TestOrder_All_False),
SerializedSize = workload.SerializedSize,
OptionsDescription = workload.OptionsDescription,
};
// ns → ms (BenchmarkResult expects ms per op with iter=1, so µs/op = ms * 1000 / 1 = ms*1000).
const double nsToMs = 1.0 / 1_000_000.0;
foreach (var report in group)
{
var methodName = report.BenchmarkCase.Descriptor.WorkloadMethod.Name;
var stats = report.ResultStatistics!;
var allocBytes = report.GcStats.GetBytesAllocatedPerOperation(report.BenchmarkCase) ?? 0;
if (methodName == "Serialize")
{
result.SerializeTimeMs = stats.Median * nsToMs;
result.SerializeTimeMinMs = stats.Min * nsToMs;
result.SerializeTimeMaxMs = stats.Max * nsToMs;
result.SerializeTimeStdDevMs = stats.StandardDeviation * nsToMs;
result.SerializeIterations = 1; // see class-doc "Iteration count = 1" note
result.SerializeAllocBytesPerOp = allocBytes;
}
else if (methodName == "Deserialize")
{
result.DeserializeTimeMs = stats.Median * nsToMs;
result.DeserializeTimeMinMs = stats.Min * nsToMs;
result.DeserializeTimeMaxMs = stats.Max * nsToMs;
result.DeserializeTimeStdDevMs = stats.StandardDeviation * nsToMs;
result.DeserializeIterations = 1;
result.DeserializeAllocBytesPerOp = allocBytes;
}
}
// Compose RT from Ser + Des per-op µs (same logic as Console BenchmarkLoop's in-memory
// composition — since BDN measures Ser and Des independently, RT here is the analytic sum).
var serPerOp = BenchmarkReportWriter.ToPerOpMicros(result.SerializeTimeMs, result.SerializeIterations);
var desPerOp = BenchmarkReportWriter.ToPerOpMicros(result.DeserializeTimeMs, result.DeserializeIterations);
var rtPerOp = serPerOp + desPerOp;
result.RoundTripIterations = Math.Max(result.SerializeIterations, result.DeserializeIterations);
result.RoundTripTimeMs = rtPerOp / 1000.0 * result.RoundTripIterations;
result.RoundTripAllocBytesPerOp = result.SerializeAllocBytesPerOp + result.DeserializeAllocBytesPerOp;
results.Add(result);
}
return results;
}
private static string GetParam(BenchmarkReport report, string name) =>
report.BenchmarkCase.Parameters.Items.FirstOrDefault(p => p.Name == name)?.Value?.ToString() ?? "";
/// <summary>
/// Constructs the same workload instance <see cref="AcBinaryVsMemPackBenchmark.Setup"/> would build —
/// same options, same wire mode. The adapter reads <see cref="ISerializerBenchmark.SerializedSize"/> and
/// <see cref="ISerializerBenchmark.OptionsDescription"/> from this instance so the BDN-side BenchmarkResult
/// rows carry the same workload-side metadata the Console rows have (no risk of drift between hardcoded
/// adapter strings and what the workload actually used).
///
/// Cost: one Serialize call inside the ctor per (TestData × Engine) cell — runs once during summary
/// translation, NOT in BDN's measured hot path. Negligible vs BDN's per-run cost.
/// </summary>
private static ISerializerBenchmark CreateWorkload(TestDataSet<TestOrder_All_False> testDataSet, string engine)
{
if (engine == "AcBinary")
{
var options = AcBinarySerializerOptions.FastMode;
options.WireMode = WireMode.Compact;
return new AcBinaryBenchmark<TestOrder_All_False>(testDataSet.Order, options, "FastMode");
}
return new MemoryPackBenchmark<TestOrder_All_False>(testDataSet.Order, WireMode.Compact, "Default");
}
}

View File

@ -1,91 +1,59 @@
using AyCode.Core.Serializers;
using AyCode.Core.Serializers.Binaries;
using AyCode.Core.Tests.TestModels;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Diagnosers;
using BenchmarkDotNet.Jobs;
namespace AyCode.Core.Benchmarks;
/// <summary>
/// JIT disassembly benchmark for AcBinarySerializer hot path analysis.
/// Shows actual x64 assembly generated by the JIT to verify inlining decisions.
/// Direct JIT-disassembly harness for the AcBinary <b>Large Serialize</b> hot path — the cell where
/// the PGO-driven inline bistability shows up (~120 µs/op fast mode ⇄ ~142 µs/op slow mode, same
/// source, run-to-run).
///
/// Usage: dotnet run -c Release -- --filter *JitDisassemblyBenchmark*
/// Or from Program.cs: --jitasm
/// <para><b>Not a BenchmarkDotNet benchmark.</b> BDN's <c>DisassemblyDiagnoser</c> produced no output
/// ("No benchmarks were disassembled"); this harness leans on the runtime's own JIT disassembler
/// instead. <see cref="Run"/> builds the workload and exercises the Large Ser FastMode path — when the
/// process is launched with <c>DOTNET_JitDisasm=&lt;pattern&gt;</c> the JIT dumps the x64 assembly of
/// every matching method to stdout as it compiles them.</para>
///
/// Output: BenchmarkDotNet artifacts folder contains .asm files with full disassembly.
/// Look for:
/// - WritePropertyOrSkip / WritePropertyMarkerless: are they inlined or called?
/// - WriteInt32 / WriteFloat64Unsafe / etc.: inlined into the caller or separate calls?
/// - context parameter passing: register usage (RCX/RDX/R8/R9)
/// <para><b>Run it</b> (via the <c>--jitasm</c> switch). Set:</para>
/// <list type="bullet">
/// <item><c>DOTNET_TieredCompilation=0</c> — each method compiled once, straight to full-opt Tier-1:
/// deterministic codegen, no tiering/PGO lottery (so the disasm is reproducible).</item>
/// <item><c>DOTNET_JitDisasm=&lt;pattern&gt;</c> — e.g. <c>*GeneratedWriter*</c> for the SGen writer
/// hot loop. The un-inlined <c>call</c>s in that loop are the candidates for the PGO-flipped inline
/// site; pinning the right callee with <c>[MethodImpl(AggressiveInlining)]</c> locks in the fast mode.</item>
/// </list>
///
/// <para>The workload mirrors <see cref="AcBinaryVsMemPackBenchmark"/> exactly — Large (5×5×5×10)
/// <see cref="TestOrder_All_False"/> graph, AsciiShort charset, FastMode + Compact wire.</para>
/// </summary>
[SimpleJob(RuntimeMoniker.Net90)]
[DisassemblyDiagnoser(maxDepth: 4, printSource: true, exportGithubMarkdown: true)]
[MemoryDiagnoser(displayGenColumns: false)]
public class JitDisassemblyBenchmark
public sealed class JitDisassemblyBenchmark
{
private TestOrder _order = null!;
private AcBinarySerializerOptions _fastModeOptions = null!;
private AcBinarySerializerOptions _defaultOptions = null!;
private byte[] _serializedFastMode = null!;
private byte[] _serializedDefault = null!;
[GlobalSetup]
public void Setup()
{
TestDataFactory.ResetIdCounter();
var sharedTag = TestDataFactory.CreateTag("SharedTag");
var sharedUser = TestDataFactory.CreateUser("shareduser");
// Medium data: enough properties to show loop behavior, not too large for disassembly
_order = TestDataFactory.CreateOrder(
itemCount: 3,
palletsPerItem: 3,
measurementsPerPallet: 3,
pointsPerMeasurement: 4,
sharedTag: sharedTag,
sharedUser: sharedUser);
_fastModeOptions = AcBinarySerializerOptions.FastMode;
_defaultOptions = AcBinarySerializerOptions.Default;
_serializedFastMode = AcBinarySerializer.Serialize(_order, _fastModeOptions);
_serializedDefault = AcBinarySerializer.Serialize(_order, _defaultOptions);
}
/// <summary>
/// FastMode serialize — no ref tracking, no string interning.
/// Builds the Large workload and JITs + exercises the Large Ser FastMode hot path. With
/// <c>DOTNET_JitDisasm</c> set, the JIT emits the matching methods' disassembly to stdout on
/// first compile; the loop guarantees every reachable serializer method is JIT-compiled (and,
/// if tiering is left on, promoted to Tier-1).
/// </summary>
[Benchmark(Baseline = true)]
public byte[] Serialize_FastMode()
public void Run()
{
return AcBinarySerializer.Serialize(_order, _fastModeOptions);
}
// Mirror AcBinaryVsMemPackBenchmark exactly: AsciiShort charset (where the Large-Ser bimodality
// was observed), Large (5×5×5×10) TestOrder_All_False graph, FastMode + Compact wire.
BenchmarkTestDataProvider.LongStringSuffix = CharsetSuffixes.AsciiShort;
/// <summary>
/// FastMode deserialize.
/// </summary>
[Benchmark]
public TestOrder Deserialize_FastMode()
{
return AcBinaryDeserializer.Deserialize<TestOrder>(_serializedFastMode, _fastModeOptions);
}
var allTestData = BenchmarkTestDataProvider_All_False.CreateTestDataSets();
var largeSet = (TestDataSet<TestOrder_All_False>)allTestData.First(t => t.Name.StartsWith("Large"));
var order = largeSet.Order;
/// <summary>
/// Default serialize — ref tracking + string interning (scan pass + write pass).
/// Shows IdentityMap lookup overhead in hot path.
/// </summary>
[Benchmark]
public byte[] Serialize_Default()
{
return AcBinarySerializer.Serialize(_order, _defaultOptions);
}
var options = AcBinarySerializerOptions.FastMode;
options.WireMode = WireMode.Compact;
/// <summary>
/// Default deserialize — ref tracking + string interning.
/// </summary>
[Benchmark]
public TestOrder Deserialize_Default()
{
return AcBinaryDeserializer.Deserialize<TestOrder>(_serializedDefault, _defaultOptions);
Console.WriteLine("=== JIT-DISASM HARNESS: Large Ser FastMode (TestOrder_All_False, AsciiShort) — start ===");
byte[] last = null!;
for (var i = 0; i < 50; i++)
last = AcBinarySerializer.Serialize(order, options);
Console.WriteLine($"=== JIT-DISASM HARNESS: done — 50 Large Ser ops, last payload {last.Length} bytes ===");
}
}

View File

@ -70,9 +70,15 @@ namespace AyCode.Benchmark
return;
}
// Configure BenchmarkDotNet to write artifacts into the centralized benchmark directory
// BDN-native artifacts go under <results>/Benchmark/BDN/ (per the unified output convention —
// see ReportingContext docs). The unified Bdn.FullBenchmark_*.{log,LLM,output} triplet (emitted
// by BdnSummaryAdapter after BDN finishes) lands one level up in <results>/Benchmark/, next to
// the Console.*.* counterparts produced by the Console runner.
var bdnArtifactsDir = Path.Combine(benchmarkDir, "BDN");
Directory.CreateDirectory(bdnArtifactsDir);
var config = ManualConfig.Create(DefaultConfig.Instance)
.WithArtifactsPath(benchmarkDir);
.WithArtifactsPath(bdnArtifactsDir);
if (args.Length > 0 && args[0] == "--quick")
{
@ -94,39 +100,27 @@ namespace AyCode.Benchmark
return;
}
if (args.Length > 0 && args[0] == "--minimal")
if (args.Length > 0 && args[0] == "--serializers")
{
RunBenchmark<MinimalBenchmark>(config, benchmarkDir, memDiagDir, "MinimalBenchmark");
return;
}
if (args.Length > 0 && args[0] == "--simple")
{
RunBenchmark<SimpleBinaryBenchmark>(config, benchmarkDir, memDiagDir, "SimpleBinaryBenchmark");
return;
}
if (args.Length > 0 && args[0] == "--complex")
{
RunBenchmark<ComplexBinaryBenchmark>(config, benchmarkDir, memDiagDir, "ComplexBinaryBenchmark");
return;
}
if (args.Length > 0 && args[0] == "--msgpack")
{
RunBenchmark<MessagePackComparisonBenchmark>(config, benchmarkDir, memDiagDir, "MessagePackComparisonBenchmark");
return;
}
if (args.Length > 0 && args[0] == "--sizes")
{
RunSizeComparison();
// Unified serializer benchmark mirroring Console's "F" menu (FastestByte) — AcBinary FastMode
// Byte[] vs MemoryPack Default Byte[] across 5 TestData cells. BdnSummaryAdapter translates
// the BDN Summary into BenchmarkResult rows and emits the Bdn.FullBenchmark_*.{log,LLM,output}
// triplet to <results>/Benchmark/ (BDN-native artifacts go under .../BDN/ via the global config).
WithProcessStabilization(() =>
{
var serializerSummary = BenchmarkRunner.Run<AcBinaryVsMemPackBenchmark>(config);
BdnSummaryAdapter.WriteResults(serializerSummary);
});
return;
}
if (args.Length > 0 && args[0] == "--jitasm")
{
RunBenchmark<JitDisassemblyBenchmark>(config, benchmarkDir, memDiagDir, "JitDisassemblyBenchmark");
// Direct JIT-disasm harness — NOT BenchmarkDotNet. BDN's DisassemblyDiagnoser produced
// nothing here ("No benchmarks were disassembled"); this leans on the runtime's own JIT
// disassembler instead. Launch with DOTNET_TieredCompilation=0 + DOTNET_JitDisasm=<pattern>
// (e.g. *GeneratedWriter*) — the JIT dumps the matching methods' x64 asm to stdout.
new JitDisassemblyBenchmark().Run();
return;
}
@ -134,24 +128,74 @@ namespace AyCode.Benchmark
Console.WriteLine(" --quick Quick benchmark with tabular output (AcBinary vs MessagePack)");
Console.WriteLine(" --test Quick AcBinary test");
Console.WriteLine(" --testmsgpack Quick MessagePack test");
Console.WriteLine(" --minimal Minimal benchmark");
Console.WriteLine(" --simple Simple flat object benchmark");
Console.WriteLine(" --complex Complex hierarchy (AcBinary vs JSON)");
Console.WriteLine(" --msgpack MessagePack comparison");
Console.WriteLine(" --sizes Size comparison only");
Console.WriteLine(" --serializers AcBinary FastMode vs MemoryPack Default across 5 test data cells (mirrors Console F menu)");
Console.WriteLine(" --jitasm JIT disassembly analysis (shows actual x64 assembly for hot path)");
Console.WriteLine(" --save-coverage <file> Save coverage file into Test_Benchmark_Results/CoverageReport");
if (args.Length == 0)
// Default path: hand control to BDN's BenchmarkSwitcher (no args → interactive picker; with
// args → BDN parses them as benchmark filters / job options). Same code path either way — the
// known custom switches above (--serializers, --jitasm, --quick, --test, --testmsgpack,
// --save-coverage) return early before reaching this point.
WithProcessStabilization(() =>
{
BenchmarkSwitcher.FromAssembly(typeof(MinimalBenchmark).Assembly).Run(args, config);
// Collect artifacts after running switcher
BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args, config);
CollectBenchmarkArtifacts(benchmarkDir, memDiagDir, "SwitcherRun");
});
}
/// <summary>
/// Runs the given action with CPU affinity pinned to CPU 0 and process priority raised to High,
/// restoring the original state in <c>finally</c>. Matches the stabilization block in
/// <c>AyCode.Core.Serializers.Console.BenchmarkLoop.RunBenchmark</c> so BDN-side measurements
/// receive the same OS-scheduler insulation the Console runner enjoys.
/// <para><b>Worker process inheritance:</b> BDN spawns a per-job child process to host the
/// workload. CPU affinity propagates from parent → child on both Windows (CreateProcess inherits
/// affinity by default) and Linux (fork+exec inherits via sched_setaffinity). So pinning the
/// orchestrator process here pins the actual measurement loop too — not just the BDN driver.</para>
/// <para>Skipped on macOS where <see cref="Process.ProcessorAffinity"/> throws (priority still
/// raised). Failures inside the try block fall through to a best-effort restore in finally.</para>
/// </summary>
static void WithProcessStabilization(Action action)
{
var process = Process.GetCurrentProcess();
var origAffinity = (IntPtr)0;
var origPriority = ProcessPriorityClass.Normal;
var stabilizationApplied = false;
if (OperatingSystem.IsWindows() || OperatingSystem.IsLinux())
{
try
{
origAffinity = process.ProcessorAffinity;
origPriority = process.PriorityClass;
// Pin to CPU 0 (mask = 1). The choice is arbitrary — what matters is "exactly one
// core, consistently" — not which one. BDN's child worker process inherits the
// affinity, so the measurement loop itself runs pinned. Mirrors Console's pinning.
process.ProcessorAffinity = (IntPtr)1;
process.PriorityClass = ProcessPriorityClass.High;
stabilizationApplied = true;
Console.WriteLine("Stabilization: pinned to CPU 0 (affinity=0x1), priority=High (BDN workers inherit affinity).");
}
catch (Exception ex)
{
// Affinity / priority changes may fail on locked-down hosts (group policies, containers
// without CAP_SYS_NICE on Linux). Surface and continue — BDN still works, just without
// scheduler insulation.
Console.WriteLine($"Stabilization SKIPPED: {ex.GetType().Name}: {ex.Message}");
}
}
else
try
{
BenchmarkSwitcher.FromAssembly(typeof(MinimalBenchmark).Assembly).Run(args, config);
CollectBenchmarkArtifacts(benchmarkDir, memDiagDir, "SwitcherRun");
action();
}
finally
{
if (stabilizationApplied && (OperatingSystem.IsWindows() || OperatingSystem.IsLinux()))
{
try { process.ProcessorAffinity = origAffinity; } catch { /* best-effort */ }
try { process.PriorityClass = origPriority; } catch { /* best-effort */ }
}
}
}
@ -169,7 +213,7 @@ namespace AyCode.Benchmark
// Create test data with shared references
TestDataFactory.ResetIdCounter();
var sharedTag = TestDataFactory.CreateTag("SharedTag");
var sharedTag = TestDataFactory.CreateTag("SharedTag_All_True");
var sharedUser = TestDataFactory.CreateUser("shareduser");
var sharedMeta = TestDataFactory.CreateMetadata("shared", withChild: true);
@ -247,19 +291,19 @@ namespace AyCode.Benchmark
// AcBinary WithRef Deserialize
sw.Restart();
for (int i = 0; i < iterations; i++)
_ = AcBinaryDeserializer.Deserialize<TestOrder>(acBinaryWithRef);
_ = AcBinaryDeserializer.Deserialize<TestOrder_All_True>(acBinaryWithRef);
var acWithRefDeserialize = sw.Elapsed.TotalMilliseconds;
// AcBinary NoRef Deserialize
sw.Restart();
for (int i = 0; i < iterations; i++)
_ = AcBinaryDeserializer.Deserialize<TestOrder>(acBinaryNoRef);
_ = AcBinaryDeserializer.Deserialize<TestOrder_All_True>(acBinaryNoRef);
var acNoRefDeserialize = sw.Elapsed.TotalMilliseconds;
// MessagePack Deserialize
sw.Restart();
for (int i = 0; i < iterations; i++)
_ = MessagePackSerializer.Deserialize<TestOrder>(msgPackData, msgPackOptions);
_ = MessagePackSerializer.Deserialize<TestOrder_All_True>(msgPackData, msgPackOptions);
var msgPackDeserialize = sw.Elapsed.TotalMilliseconds;
results.Add(("Deserialize", "WithRef", acWithRefDeserialize, msgPackDeserialize));
@ -280,7 +324,7 @@ namespace AyCode.Benchmark
for (int i = 0; i < iterations; i++)
{
var target = CreatePopulateTarget(testOrder);
AcBinaryDeserializer.PopulateMerge(acBinaryNoRef.AsSpan(), target);
AcBinaryDeserializer.PopulateMerge(acBinaryNoRef, target);
}
var acMerge = sw.Elapsed.TotalMilliseconds;
results.Add(("Merge", "NoRef", acMerge, 0));
@ -332,12 +376,12 @@ namespace AyCode.Benchmark
Console.WriteLine();
}
static TestOrder CreatePopulateTarget(TestOrder source)
static TestOrder_All_True CreatePopulateTarget(TestOrder_All_True source)
{
var target = new TestOrder { Id = source.Id };
var target = new TestOrder_All_True { Id = source.Id };
foreach (var item in source.Items)
{
target.Items.Add(new TestOrderItem { Id = item.Id });
target.Items.Add(new TestOrderItem_All_True { Id = item.Id });
}
return target;
}

View File

@ -1,27 +1,129 @@
# AyCode.Benchmark
BenchmarkDotNet-based performance benchmarking console app. Compares AcBinary serializer against MessagePack, BSON, and JSON across various scenarios.
BenchmarkDotNet performance suite **plus** the shared workload / reporting infrastructure used by both BDN and the Console runner. Targets .NET 9.
## Key Files
## Role: dual-purpose project
- **`Program.cs`** — CLI entry point with `--quick`, `--test`, `--minimal`, `--simple`, `--complex`, `--msgpack`, `--sizes`, `--jitasm` modes. Collects results to `Test_Benchmark_Results/` at solution root.
- **`SerializationBenchmarks.cs`** — Primary suite: MinimalBenchmark, SimpleBinaryBenchmark, ComplexBinaryBenchmark, MessagePackComparisonBenchmark, AcBinaryVsMessagePackFullBenchmark, SizeComparisonBenchmark, LargeScaleBenchmark (~25K objects), AcJsonVsSystemTextJsonBenchmark.
- **`SourceGeneratorBenchmarks.cs`** — Source-generated vs runtime reflection serializers. Includes PureContractlessBenchmark, SourceGeneratorVsRuntimeBenchmark, RepeatedStringBenchmark (string interning).
- **`SignalRCommunicationBenchmarks.cs`** — Full-stack SignalR message performance: client creation → MessagePack serialization → server deserialization → response → round-trip.
- **`SignalRRoundTripBenchmarks.cs`** — Real SignalR infrastructure benchmarks: primitives, complex objects, collections, mixed parameters.
- **`JitDisassemblyBenchmark.cs`** — JIT analysis: generates .asm files to verify inlining decisions on serialize/deserialize hot paths.
- **`TaskHelperBenchmarks.cs`** — Task/timing utilities: WaitToAsync, ThreadPool (custom vs Task.Run), timing methods (UtcNow.Ticks vs TickCount64).
- **`RefForeachBenchmark.cs`** — Collection iteration patterns: array vs list, foreach vs index, ref readonly vs by-value for large structs.
- **`ValueTypePassingBenchmark.cs`** — Copy-by-value vs `in` parameter for 16-byte types (Decimal, DateTimeOffset, Guid).
This project plays **two roles**:
1. **BDN runner Exe** — standalone benchmark host (`Program.cs` + `[Benchmark]`-decorated classes). Invoke via `dotnet run -c Release --project AyCode.Benchmark -- <switch>`.
2. **Shared workload + reporting library** — exposes `public` types under [`Workloads/Scenarios/`](Workloads/Scenarios/) and [`Reporting/`](Reporting/) that [`AyCode.Core.Serializers.Console`](../AyCode.Core.Serializers.Console/README.md) consumes via `<ProjectReference>`.
Both runners feed the SAME `ISerializerBenchmark` workload (same test data graphs, same wire options, same payload sizes) — so Console's adaptive-engine numbers and BDN's iteration-based numbers are **directly comparable**.
## Output convention
Both runners emit a unified `.log` / `.LLM` / `.output` triplet to `Test_Benchmark_Results/Benchmark/` (resolved at runtime via walk-up to the nearest `AyCode.Core.sln` — worktree-aware):
| File | Source | Content |
|---|---|---|
| `Console.FullBenchmark_<Build>_<ts>.log` | Console runner | Human-readable formatted view |
| `Console.FullBenchmark_<Build>_<ts>.LLM` | Console runner | Markdown table, LLM-paste-friendly |
| `Console.FullBenchmark_<Build>_<ts>.output` | Console runner | Hex dump of Large cell binary |
| `Bdn.FullBenchmark_<Build>_<ts>.log` | BDN runner | Same format as Console |
| `Bdn.FullBenchmark_<Build>_<ts>.LLM` | BDN runner | Same |
| `Bdn.FullBenchmark_<Build>_<ts>.output` | BDN runner | Same |
BDN-native artifacts (BDN's own reports, raw measurements, run logs) go to `Test_Benchmark_Results/Benchmark/BDN/` — kept separate so the unified Console+BDN `.log/.LLM/.output` triplet stays uncluttered.
## Architecture
```
┌────────────────────────────────────────────────────────────────┐
│ AyCode.Benchmark (this project) │
│ │
│ Workloads/Scenarios/ public — shared workload types │
│ ISerializerBenchmark, BenchmarkOptions, BenchmarkEnums, │
│ AcBinaryBenchmark<T>, MemoryPackBenchmark<T>, │
│ AcBinaryBufferWriterBenchmark<T>, ... (12 concretes), │
│ RoundTripValidator │
│ │
│ Reporting/ public — shared reporting types │
│ BenchmarkResult, ReportingContext, BenchmarkReportWriter │
│ │
│ AcBinaryVsMemPackBenchmark.cs BDN [Benchmark] class │
│ (mirrors Console "F" menu) │
│ BdnSummaryAdapter.cs Summary → BenchmarkResult → │
│ BenchmarkReportWriter.SaveAll │
│ Program.cs BDN entry + CLI dispatch │
│ │
│ + KEEP: JitDisassemblyBenchmark, RefForeachBenchmark, │
│ TaskHelperBenchmarks, ValueTypePassingBenchmark, │
│ SourceGeneratorBenchmarks, │
│ SignalRCommunicationBenchmarks, │
│ SignalRRoundTripBenchmarks │
└────────────────────────────────────────────────────────────────┘
│ ProjectReference (one-way)
┌────────────────────────────────────────────────────────────────┐
│ AyCode.Core.Serializers.Console │
│ │
│ BenchmarkLoop.cs custom adaptive measure engine │
│ (CPU 0 pin, High priority, phase-isolated warmup, │
│ 10-sample median + pilot, ~250ms/cell calibration) │
│ Menu.cs / Configuration.cs / Program.cs Console UX │
│ │
│ Uses Benchmark's: │
│ - Workloads/Scenarios/* (interface + concrete benchmarks) │
│ - Reporting/BenchmarkReportWriter (SaveAll, Print...) │
└────────────────────────────────────────────────────────────────┘
```
## Two runners — same workload, different measurement engines
| Aspect | Console (custom engine) | BDN |
|---|---|---|
| Use case | Fast iteration during micro-opt loops | Statistically confident before-commit validation |
| Measurement | Adaptive per-cell iter (target ~250ms), 10 samples + pilot, median | Warmup + N iterations, outlier removal, JIT-stabilized, process-spawn isolation |
| Time per full run | ~1-3 min | ~5-15 min |
| Noise floor | ~3-5% inter-engine delta visible | ~1-2% |
| Output format | Identical (same `BenchmarkReportWriter` writes both) | |
The Console and BDN outputs use the SAME `BenchmarkResult` DTO and the SAME formatter, so cells are directly comparable: pick a cell in `Console.FullBenchmark_*.LLM`, find the same cell in `Bdn.FullBenchmark_*.LLM` — deltas should agree within BDN's tighter CI.
## CLI
```
dotnet run -c Release --project AyCode.Benchmark -- <switch>
```
| Switch | Description |
|---|---|
| `--serializers` | AcBinary FastMode Byte[] vs MemoryPack Default Byte[] across 5 TestData cells (mirrors Console "F" menu / FastestByte). Emits `Bdn.FullBenchmark_*.{log,LLM,output}` + BDN-native artifacts under `BDN/`. |
| `--jitasm` | JIT disassembly analysis (x64 asm of serialize/deserialize hot path). |
| `--quick` | Quick inline benchmark (custom Stopwatch-based, not BDN). |
| `--test` / `--testmsgpack` | Quick smoke tests. |
| `--save-coverage <file>` | Save coverage file into `Test_Benchmark_Results/CoverageReport/`. |
| _(no args)_ | Interactive `BenchmarkSwitcher` — pick from all `[Benchmark]` classes in the assembly. |
## Key files
### Serializer benchmark stack (the refactor scope)
- [`AcBinaryVsMemPackBenchmark.cs`](AcBinaryVsMemPackBenchmark.cs) — BDN `[MemoryDiagnoser]` class. `[ParamsSource]`(TestData = Small/Medium/Large/Repeated/Deep) × `[Params]`(Engine = AcBinary/MemoryPack). `[GlobalSetup]` hidrátálja a Workloads scenario-ját + round-trip-verify.
- [`BdnSummaryAdapter.cs`](BdnSummaryAdapter.cs) — `Summary → List<BenchmarkResult>` translator (groups per `(TestData × Engine)`, ns → ms conversion, GcStats → allocated-bytes-per-op). Calls `BenchmarkReportWriter.PrintGroupedResults` + `SaveAll(ctx with SourceTag="Bdn", ...)`.
- [`Program.cs`](Program.cs) — BDN entry. Sets global `WithArtifactsPath(.../Benchmark/BDN)`; `--serializers` switch wires `BenchmarkRunner.Run<AcBinaryVsMemPackBenchmark>` + adapter.
- [`Workloads/Scenarios/`](Workloads/Scenarios/) — shared workload types (see folder README).
- [`Reporting/`](Reporting/) — shared reporting types (see folder README).
### KEEP benchmarks (independent — not in the serializer-refactor scope)
- [`JitDisassemblyBenchmark.cs`](JitDisassemblyBenchmark.cs) — JIT analysis: emits `.asm` files for serialize/deserialize hot paths.
- [`TaskHelperBenchmarks.cs`](TaskHelperBenchmarks.cs) — Task/timing utilities (WaitToAsync, custom ThreadPool, UtcNow.Ticks vs TickCount64).
- [`ValueTypePassingBenchmark.cs`](ValueTypePassingBenchmark.cs) — Copy-by-value vs `in` parameter for 16-byte types.
- [`RefForeachBenchmark.cs`](RefForeachBenchmark.cs) — Collection iteration patterns (array vs list, foreach vs index, ref readonly).
- [`SourceGeneratorBenchmarks.cs`](SourceGeneratorBenchmarks.cs) — Source-generated vs runtime reflection serializers (PureContractlessBenchmark, RepeatedStringBenchmark).
- [`SignalRCommunicationBenchmarks.cs`](SignalRCommunicationBenchmarks.cs) — Full-stack SignalR perf (client → server → response → round-trip).
- [`SignalRRoundTripBenchmarks.cs`](SignalRRoundTripBenchmarks.cs) — SignalR primitives/complex/collections benchmarks.
## Dependencies
| Dependency | Purpose |
|---|---|
| `BenchmarkDotNet` | Benchmarking framework |
| `MessagePack` | Serialization comparison target |
| `MongoDB.Bson` | BSON comparison target |
---
> **LLM Maintenance:** If you modify code in this folder, update this README to reflect the changes. If you notice the README content does not match the current code, automatically update the README to match the code.
| `BenchmarkDotNet` | BDN harness |
| `MemoryPack` | Comparison target (used by Workloads scenarios + BDN class) |
| `MessagePack` | Comparison target (KEEP benchmarks + Workloads MessagePackBenchmark scenario) |
| `MongoDB.Bson` | KEEP-side comparison target |
| `Microsoft.VisualStudio.DiagnosticsHub.BenchmarkDotNetDiagnosers` | VS Profiler integration |
| `AyCode.Core` (ProjectReference) | AcBinary serializer |
| `AyCode.Core.Tests` (ProjectReference) | Test data factory (`TestDataFactory`, `TestOrder_All_False/True`, `BenchmarkTestDataProvider*`) |
| `AyCode.Core.Serializers.SourceGenerator` (Analyzer-only) | SGen for `[AcBinarySerializable]`-tagged types |

View File

@ -0,0 +1,741 @@
using AyCode.Core.Benchmarks.Workloads.Scenarios;
using AyCode.Core.Serializers.Binaries;
using AyCode.Core.Tests.TestModels;
using System.Globalization;
using System.Runtime.CompilerServices;
using System.Text;
namespace AyCode.Core.Benchmarks.Reporting;
/// <summary>
/// Output formatters for the benchmark suite: per-row console table, .log file, .LLM markdown file, .output
/// binary hex dump. Consumes <see cref="BenchmarkResult"/> rows produced by the benchmark execution loop
/// (Console-side <c>BenchmarkLoop</c> or BDN-side <c>BdnSummaryAdapter</c>) and emits human-readable +
/// LLM-friendly outputs.
///
/// <para>The <see cref="ReportingContext"/> parameter encapsulates per-run state — <see cref="ReportingContext.SourceTag"/>
/// drives the filename prefix ("Console" / "Bdn"), <see cref="ReportingContext.ResultsDirectory"/> is the
/// resolved (walk-up-to-.sln) output folder, and the remaining fields (charset, iter counts, target sample
/// window, CV threshold) carry the run-header info embedded in every emitted artifact.</para>
/// </summary>
public static class BenchmarkReportWriter
{
/// <summary>
/// Per-cell-paired aggregation of an overall comparison. Captures three different aggregation
/// strategies so the reader can judge whether the headline delta is dominated by one large cell
/// (arithmetic mean) or representative of typical workload (geometric mean / median).
/// </summary>
/// <param name="ArithMeanPct">Arithmetic mean of µs/op — magnitude-weighted; biased toward Large cell.</param>
/// <param name="GeoMeanPct">Geometric mean of per-cell ratios — magnitude-neutral; each cell weighted equally.</param>
/// <param name="MedianPct">Median of per-cell ratios — outlier-resistant.</param>
/// <param name="AcAvg">Arithmetic mean AcBinary value (µs/op or bytes).</param>
/// <param name="MpAvg">Arithmetic mean MemPack value.</param>
/// <param name="CellCount">Number of paired cells contributing to the geo/median.</param>
public record OverallStats(double ArithMeanPct, double GeoMeanPct, double MedianPct, double AcAvg, double MpAvg, int CellCount);
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static double ToPerOpMicros(double totalMs, int iterations) => iterations > 0 ? totalMs / iterations * 1000.0 : 0;
// Per-row per-op µs accessors — pull batch-time + iter from BenchmarkResult and convert. Used wherever
// averaging or comparison happens across rows with potentially different iter counts (Winners summary,
// Overall comparison, per-cell summary row). Keeping these as methods rather than properties on
// BenchmarkResult preserves the result-as-data-bag distinction.
public static double SerPerOp(BenchmarkResult r) => ToPerOpMicros(r.SerializeTimeMs, r.SerializeIterations);
public static double DesPerOp(BenchmarkResult r) => ToPerOpMicros(r.DeserializeTimeMs, r.DeserializeIterations);
public static double RtPerOp(BenchmarkResult r) => ToPerOpMicros(r.RoundTripTimeMs, r.RoundTripIterations);
/// <summary>
/// Converts a byte count to KB (1 KB = 1024 B). Display-only helper so allocation columns can
/// render compact F2 KB values (e.g. <c>4.05 KB</c> instead of <c>4,144 B</c>) — header carries
/// the unit so per-row entries stay numbers-only. CSV / raw-data outputs keep the precise byte
/// integers untouched.
/// </summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static double ToKilobytes(long bytes) => bytes / 1024.0;
/// <summary>
/// Computes arithmetic + geometric + median aggregation of an AcBinary-vs-MemPack comparison
/// across paired cells (joined by <c>TestDataName</c>). Per-cell pairing is required for the
/// geo/median variants — a cell where AcBinary or MemPack is missing is dropped from all stats.
/// Returns null when no paired cell has a valid value.
/// </summary>
public static OverallStats? ComputeOverallStats(List<BenchmarkResult> acResults, List<BenchmarkResult> mpResults, Func<BenchmarkResult, double> getValue)
{
if (acResults.Count == 0 || mpResults.Count == 0) return null;
var pairs = (from ac in acResults
join mp in mpResults on ac.TestDataName equals mp.TestDataName
let acV = getValue(ac)
let mpV = getValue(mp)
where acV > 0 && mpV > 0
select (ac: acV, mp: mpV)).ToList();
if (pairs.Count == 0) return null;
var acAvg = pairs.Average(p => p.ac);
var mpAvg = pairs.Average(p => p.mp);
var ratios = pairs.Select(p => p.ac / p.mp).ToList();
// Geometric mean: exp(avg(ln(ratios))) — numerically stable vs Π ratios then ^(1/N).
var geoMean = Math.Exp(ratios.Sum(Math.Log) / ratios.Count);
// Median (paired-ratio): for even N use the midpoint of the two middle values.
var sorted = ratios.OrderBy(r => r).ToList();
var median = sorted.Count % 2 == 1
? sorted[sorted.Count / 2]
: (sorted[sorted.Count / 2 - 1] + sorted[sorted.Count / 2]) / 2.0;
return new OverallStats(
ArithMeanPct: (acAvg / mpAvg - 1) * 100,
GeoMeanPct: (geoMean - 1) * 100,
MedianPct: (median - 1) * 100,
AcAvg: acAvg,
MpAvg: mpAvg,
CellCount: ratios.Count);
}
/// <summary>
/// Formats a per-op micros value with its inter-sample range and CV-threshold marker as
/// <c>"26.86 (24.5..29.1)"</c> or <c>"26.86 (24.5..29.1) ⚠5.2%"</c>. Median first, range in parentheses,
/// CV warning suffix only when CV > <paramref name="unstableCvThreshold"/>. When min == max == median
/// (single-sample / Debug / quick mode), collapses to bare median to avoid visual clutter.
/// All time inputs are total-batch milliseconds; <paramref name="iterations"/> is the per-row iter
/// count (post-adaptive-calibration).
/// </summary>
public static string FormatMicrosWithRange(double medianMs, double minMs, double maxMs, double stdDevMs, int iterations, CultureInfo inv, double unstableCvThreshold, double microOptCvThreshold = 0.0)
{
var med = ToPerOpMicros(medianMs, iterations);
// No range data (single-sample fast path) — surface as bare median, identical to the prior format.
if (minMs <= 0 && maxMs <= 0) return med.ToString("F2", inv);
if (minMs >= medianMs && maxMs <= medianMs) return med.ToString("F2", inv);
var min = ToPerOpMicros(minMs, iterations);
var max = ToPerOpMicros(maxMs, iterations);
var range = $"{med.ToString("F2", inv)} ({min.ToString("F2", inv)}..{max.ToString("F2", inv)})";
// CV (coefficient of variation = stddev / mean) — two-band flagging:
// ⚠️ X.X% : above the unstable threshold (e.g. 3%) — sub-threshold inter-engine
// deltas on this row are essentially noise; entirely dismissable.
// ⚠micro X.X% : above the micro-opt threshold (e.g. 1.5%) but below unstable — not
// noise but sub-2% deltas are at the edge of reliability; cross-check
// with re-run or BDN before declaring a regression / improvement.
// microOptCvThreshold = 0 disables the soft-flag band (backward-compat for callers that
// only want the original unstable-only behaviour).
if (medianMs > 0 && stdDevMs > 0)
{
var cv = stdDevMs / medianMs;
var cvPct = (cv * 100).ToString("F1", inv);
if (cv > unstableCvThreshold) return $"{range} ⚠️{cvPct}%";
if (microOptCvThreshold > 0 && cv > microOptCvThreshold) return $"{range} ⚠micro {cvPct}%";
}
return range;
}
/// <summary>
/// Formats a signed percent delta with explicit sign for positive values (`+1.5%`, `-3.0%`, `0.0%`).
/// Padded to 7 chars (e.g. ` +12.3%`, `-100.0%`) for column alignment in the Overall block.
/// </summary>
public static string FormatPctSigned(double pct) => pct.ToString("+0.0;-0.0;0.0", CultureInfo.InvariantCulture).PadLeft(6) + "%";
/// <summary>
/// Renders one Overall row with arith / geo / median deltas + AcBinary/MemPack absolute means.
/// Color is driven by the geometric-mean delta (magnitude-neutral signal). Skips silently when
/// stats is null (no paired data).
/// </summary>
public static void WriteOverallLine(string label, string unit, OverallStats? stats, string fmt = "F2")
{
if (stats == null) return;
// Color follows geo-mean (the magnitude-neutral signal). The arith-mean column may show a
// different sign when a single big cell dominates — that's exactly the signal we want to surface.
System.Console.ForegroundColor = stats.GeoMeanPct <= 0 ? ConsoleColor.Green : ConsoleColor.Red;
System.Console.WriteLine($" {label,-12} arith {FormatPctSigned(stats.ArithMeanPct)} │ geo {FormatPctSigned(stats.GeoMeanPct)} │ median {FormatPctSigned(stats.MedianPct)} ({stats.AcAvg.ToString(fmt, CultureInfo.InvariantCulture)} {unit} vs {stats.MpAvg.ToString(fmt, CultureInfo.InvariantCulture)} {unit}, {stats.CellCount} cells)");
System.Console.ResetColor();
}
/// <summary>
/// Same as <see cref="WriteOverallLine"/> but appends to a <see cref="StringBuilder"/> (no color).
/// Used by the .log and .LLM file writers.
/// </summary>
public static void AppendOverallLine(StringBuilder sb, string label, string unit, OverallStats? stats, string fmt = "F2")
{
if (stats == null) return;
sb.AppendLine($" {label,-12} arith {FormatPctSigned(stats.ArithMeanPct)} | geo {FormatPctSigned(stats.GeoMeanPct)} | median {FormatPctSigned(stats.MedianPct)} ({stats.AcAvg.ToString(fmt, CultureInfo.InvariantCulture)} {unit} vs {stats.MpAvg.ToString(fmt, CultureInfo.InvariantCulture)} {unit}, {stats.CellCount} cells)");
}
public static void PrintResult(BenchmarkResult result)
{
// Numbers-only per-row entries; the column-headers carry units (µs/op, KB/op).
var ser = result.SerializeTimeMs > 0 ? $"{SerPerOp(result),7:F2}" : " N/A";
var des = result.DeserializeTimeMs > 0 ? $"{DesPerOp(result),7:F2}" : " N/A";
var serAlloc = result.SerializeTimeMs > 0 ? $"{ToKilobytes(result.SerializeAllocBytesPerOp),7:F2}" : " N/A";
var desAlloc = result.DeserializeTimeMs > 0 ? $"{ToKilobytes(result.DeserializeAllocBytesPerOp),7:F2}" : " N/A";
System.Console.WriteLine($" {result.SerializerName,-40} | Size: {result.SerializedSize,8:N0} B | Ser: {ser} µs/op ({serAlloc} KB/op) | Des: {des} µs/op ({desAlloc} KB/op)");
}
public static void PrintGroupedResults(List<BenchmarkResult> results, List<TestDataSet> testDataSets)
{
System.Console.WriteLine("\n");
System.Console.WriteLine("╔══════════════════════════════════════════════════════════════════════════════════════════════════════╗");
System.Console.WriteLine("║ GROUPED RESULTS BY TEST DATA ║");
System.Console.WriteLine("╚══════════════════════════════════════════════════════════════════════════════════════════════════════╝");
// Print serializer options. [OrderType] suffix shows which TestOrder variant each preset serialised.
var optionsMap = results
.Where(r => r.OptionsDescription != null)
.Select(r => (r.SerializerName, r.OrderTypeName, r.OptionsDescription!))
.Distinct()
.ToList();
if (optionsMap.Count > 0)
{
System.Console.WriteLine();
System.Console.WriteLine(" Serializer Options:");
foreach (var (name, orderType, opts) in optionsMap)
System.Console.WriteLine($" {name} [{orderType}]: {opts}");
}
foreach (var testData in testDataSets)
{
// Order by Engine (so the same engine column-position stays stable across cells, especially
// when two engines are within noise floor on a given cell — flip-flopping speed-rank produces
// diff-hostile output across runs). RtPerOp is the secondary tiebreaker for cells where
// multiple variants of the same engine exist (e.g. AcBinary SGen vs Runtime).
var testResults = results.Where(r => r.TestDataName == testData.DisplayName).OrderBy(r => r.Engine).ThenBy(r => RtPerOp(r)).ToList();
// Baseline switched MessagePack → MemoryPack: MemoryPack is the SOTA performance leader.
var memPackResult = testResults.FirstOrDefault(r => (r.Engine == BenchmarkEngine.MemoryPack && r.IoMode == BenchmarkIoMode.ByteArray));
// Pin the comparison to AcBinary's SGen variant — apples-to-apples vs MemoryPack (also source-generated).
// The Runtime variant is shown alongside in the table for context, not used as the headline number.
var acBinaryResult = testResults.FirstOrDefault(r => (r.Engine == BenchmarkEngine.AcBinary && r.IoMode == BenchmarkIoMode.ByteArray && r.DispatchMode == BenchmarkDispatchMode.SGen));
System.Console.WriteLine($"\n┌─ {testData.DisplayName} ─".PadRight(172, '─') + "┐");
// Header-only units; per-row entries are numbers (µs/op for time, KB/op for alloc, KB pair "ser / des" for Setup, B for Size).
System.Console.WriteLine($"│ {"#",-4} │ {"Engine",-11} │ {"Options",-22} │ {"IO",-12} │ {"Mode",-8} │ {"Setup S/D KB",-14} │ {"Size B",-8} │ {"Ser µs/op",-10} │ {"SerAlc KB",-10} │ {"Des µs/op",-10} │ {"DesAlc KB",-10} │ {"RT µs/op",-10} │ {"RTAlc KB",-10} │");
System.Console.WriteLine($"├{"".PadRight(6, '─')}┼{"".PadRight(13, '─')}┼{"".PadRight(24, '─')}┼{"".PadRight(14, '─')}┼{"".PadRight(10, '─')}┼{"".PadRight(16, '─')}┼{"".PadRight(10, '─')}┼{"".PadRight(12, '─')}┼{"".PadRight(12, '─')}┼{"".PadRight(12, '─')}┼{"".PadRight(12, '─')}┼{"".PadRight(12, '─')}┼{"".PadRight(12, '─')}┤");
var rank = 1;
foreach (var result in testResults)
{
var size = $"{result.SerializedSize:N0}";
var setup = $"{ToKilobytes(result.SetupSerializeAllocBytes):F2} / {ToKilobytes(result.SetupDeserializeAllocBytes):F2}";
var ser = result.SerializeTimeMs > 0 ? $"{SerPerOp(result):F2}" : "N/A";
var des = result.DeserializeTimeMs > 0 ? $"{DesPerOp(result):F2}" : "N/A";
var rt = result.RoundTripTimeMs > 0 ? $"{RtPerOp(result):F2}" : "N/A";
var serAlloc = result.SerializeTimeMs > 0 ? $"{ToKilobytes(result.SerializeAllocBytesPerOp):F2}" : "N/A";
var desAlloc = result.DeserializeTimeMs > 0 ? $"{ToKilobytes(result.DeserializeAllocBytesPerOp):F2}" : "N/A";
var rtAlloc = result.RoundTripAllocBytesPerOp > 0 ? $"{ToKilobytes(result.RoundTripAllocBytesPerOp):F2}" : "N/A";
// Highlight MemoryPack baseline (any Byte[]) and AcBinary headline contender (Byte[] + SGen) with win/lose colors.
// The AcBinary Byte[]+Runtime variant is shown unhighlighted — it's contextual (SGen speed-up reference), not the headline.
var isHighlighted = (result.Engine == BenchmarkEngine.MemoryPack && result.IoMode == BenchmarkIoMode.ByteArray)
|| (result.Engine == BenchmarkEngine.AcBinary && result.IoMode == BenchmarkIoMode.ByteArray && result.DispatchMode == BenchmarkDispatchMode.SGen);
var prefix = isHighlighted ? "│►" : "│ ";
var suffix = isHighlighted ? "◄│" : " │";
// Color logic: Green = winner (faster), Red = loser (slower)
if (isHighlighted && memPackResult != null && acBinaryResult != null)
{
var isMemPack = (result.Engine == BenchmarkEngine.MemoryPack && result.IoMode == BenchmarkIoMode.ByteArray);
var memPackFaster = RtPerOp(memPackResult) < RtPerOp(acBinaryResult);
if (isMemPack)
{
System.Console.ForegroundColor = memPackFaster ? ConsoleColor.Green : ConsoleColor.Red;
}
else
{
System.Console.ForegroundColor = memPackFaster ? ConsoleColor.Red : ConsoleColor.Green;
}
}
System.Console.WriteLine($"{prefix}{rank++,4} │ {result.Engine.ToDisplay(),-11} │ {result.OptionsPreset,-22} │ {result.IoMode.ToDisplay(),-12} │ {result.DispatchMode.ToDisplay(),-8} │ {setup,14} │ {size,8} │ {ser,10} │ {serAlloc,10} │ {des,10} │ {desAlloc,10} │ {rt,10} │ {rtAlloc,10}{suffix}");
if (isHighlighted)
{
System.Console.ResetColor();
}
}
// Footer row: AcBinary (Byte[]) vs MemoryPack (Byte[]) comparison per column
if (memPackResult != null && acBinaryResult != null)
{
var sizePct = (acBinaryResult.SerializedSize / (double)memPackResult.SerializedSize - 1) * 100;
// Per-op µs ratio (iter-independent) — Ser/Des may have different iter counts on the two rows.
var serPct = SerPerOp(memPackResult) > 0 ? (SerPerOp(acBinaryResult) / SerPerOp(memPackResult) - 1) * 100 : 0;
var desPct = DesPerOp(memPackResult) > 0 ? (DesPerOp(acBinaryResult) / DesPerOp(memPackResult) - 1) * 100 : 0;
var rtPct = RtPerOp(memPackResult) > 0 ? (RtPerOp(acBinaryResult) / RtPerOp(memPackResult) - 1) * 100 : 0;
var serAllocPct = memPackResult.SerializeAllocBytesPerOp > 0 ? (acBinaryResult.SerializeAllocBytesPerOp / (double)memPackResult.SerializeAllocBytesPerOp - 1) * 100 : 0;
var desAllocPct = memPackResult.DeserializeAllocBytesPerOp > 0 ? (acBinaryResult.DeserializeAllocBytesPerOp / (double)memPackResult.DeserializeAllocBytesPerOp - 1) * 100 : 0;
var rtAllocPct = memPackResult.RoundTripAllocBytesPerOp > 0 ? (acBinaryResult.RoundTripAllocBytesPerOp / (double)memPackResult.RoundTripAllocBytesPerOp - 1) * 100 : 0;
// Footer separator: merge first 5 cols (#, Engine, Options, IO, Mode) → comparison label;
// remaining 8 cols stay aligned (Setup S/D KB, Size, Ser µs/op, SerAlc KB, Des µs/op, DesAlc KB, RT µs/op, RTAlc KB).
System.Console.WriteLine($"├{"".PadRight(6, '─')}┴{"".PadRight(13, '─')}┴{"".PadRight(24, '─')}┴{"".PadRight(14, '─')}┴{"".PadRight(10, '─')}┼{"".PadRight(16, '─')}┼{"".PadRight(10, '─')}┼{"".PadRight(12, '─')}┼{"".PadRight(12, '─')}┼{"".PadRight(12, '─')}┼{"".PadRight(12, '─')}┼{"".PadRight(12, '─')}┼{"".PadRight(12, '─')}┤");
// Merged label cell width = 4 + 11 + 22 + 12 + 8 + 4*3 (dropped separators) = 69
System.Console.Write($"│ {" AcBinary (Byte[]) vs MemoryPack (Byte[])",-69} │ ");
// Setup S/D KB (n/a for Byte[] vs Byte[] — neither pre-allocates)
System.Console.Write($"{"",14}");
System.Console.Write(" │ ");
// Size
System.Console.ForegroundColor = sizePct <= 0 ? ConsoleColor.Green : ConsoleColor.Red;
System.Console.Write($"{sizePct,+7:+0;-0}%");
System.Console.ResetColor();
System.Console.Write(" │ ");
// Serialize
System.Console.ForegroundColor = serPct <= 0 ? ConsoleColor.Green : ConsoleColor.Red;
System.Console.Write($"{serPct,+9:+0;-0}%");
System.Console.ResetColor();
System.Console.Write(" │ ");
// Serialize Alloc
System.Console.ForegroundColor = serAllocPct <= 0 ? ConsoleColor.Green : ConsoleColor.Red;
System.Console.Write($"{serAllocPct,+9:+0;-0}%");
System.Console.ResetColor();
System.Console.Write(" │ ");
// Deserialize
System.Console.ForegroundColor = desPct <= 0 ? ConsoleColor.Green : ConsoleColor.Red;
System.Console.Write($"{desPct,+9:+0;-0}%");
System.Console.ResetColor();
System.Console.Write(" │ ");
// Deserialize Alloc
System.Console.ForegroundColor = desAllocPct <= 0 ? ConsoleColor.Green : ConsoleColor.Red;
System.Console.Write($"{desAllocPct,+9:+0;-0}%");
System.Console.ResetColor();
System.Console.Write(" │ ");
// Round-trip
System.Console.ForegroundColor = rtPct <= 0 ? ConsoleColor.Green : ConsoleColor.Red;
System.Console.Write($"{rtPct,+9:+0;-0}%");
System.Console.ResetColor();
System.Console.Write(" │ ");
// Round-trip Alloc
System.Console.ForegroundColor = rtAllocPct <= 0 ? ConsoleColor.Green : ConsoleColor.Red;
System.Console.Write($"{rtAllocPct,+9:+0;-0}%");
System.Console.ResetColor();
System.Console.WriteLine(" │");
}
// Closing line: merged on left (─ between cols 1-5), ┴ on the right (cols 6-13 boundary, 8 unmerged cells).
System.Console.WriteLine($"└{"".PadRight(6, '─')}─{"".PadRight(13, '─')}─{"".PadRight(24, '─')}─{"".PadRight(14, '─')}─{"".PadRight(10, '─')}┴{"".PadRight(16, '─')}┴{"".PadRight(10, '─')}┴{"".PadRight(12, '─')}┴{"".PadRight(12, '─')}┴{"".PadRight(12, '─')}┴{"".PadRight(12, '─')}┴{"".PadRight(12, '─')}┴{"".PadRight(12, '─')}┘");
}
// Summary: Best serializer for each category
System.Console.WriteLine("\n");
System.Console.WriteLine("╔══════════════════════════════════════════════════════════════════════════════════════════════════════╗");
System.Console.WriteLine("║ SUMMARY: WINNERS ║");
System.Console.WriteLine("╚══════════════════════════════════════════════════════════════════════════════════════════════════════╝");
System.Console.WriteLine($"\n{"Category",-20} │ {"Winner",-40} │ {"Avg Value",-18}");
System.Console.WriteLine($"{"".PadRight(20, '─')}─┼─{"".PadRight(40, '─')}─┼─{"".PadRight(18, '─')}");
// Fastest Serialize — round-trip-only serializers (NamedPipe etc.) excluded:
// their Serialize() captures the full round-trip and isn't comparable to a pure Ser metric.
var fastestSer = results.Where(r => r.SerializeTimeMs > 0 && !r.IsRoundTripOnly)
.GroupBy(r => r.SerializerName)
.Select(g => new { Name = g.Key, AvgPerOp = g.Average(r => SerPerOp(r)) })
.OrderBy(x => x.AvgPerOp)
.FirstOrDefault();
if (fastestSer != null)
System.Console.WriteLine($"{"Fastest Serialize",-20} │ {fastestSer.Name,-40} │ {fastestSer.AvgPerOp,12:F2} µs/op");
// Fastest Deserialize — round-trip-only serializers excluded (their Deserialize() is a no-op).
var fastestDes = results.Where(r => r.DeserializeTimeMs > 0 && !r.IsRoundTripOnly)
.GroupBy(r => r.SerializerName)
.Select(g => new { Name = g.Key, AvgPerOp = g.Average(r => DesPerOp(r)) })
.OrderBy(x => x.AvgPerOp)
.FirstOrDefault();
if (fastestDes != null)
System.Console.WriteLine($"{"Fastest Deserialize",-20} │ {fastestDes.Name,-40} │ {fastestDes.AvgPerOp,12:F2} µs/op");
// Smallest Size
var smallestSize = results
.GroupBy(r => r.SerializerName)
.Select(g => new { Name = g.Key, AvgSize = g.Average(r => r.SerializedSize) })
.OrderBy(x => x.AvgSize)
.FirstOrDefault();
if (smallestSize != null)
System.Console.WriteLine($"{"Smallest Size",-20} │ {smallestSize.Name,-40} │ {smallestSize.AvgSize,15:F0} B");
// Fastest Round-trip — iter-independent per-op average.
var fastestRt = results.Where(r => r.RoundTripTimeMs > 0)
.GroupBy(r => r.SerializerName)
.Select(g => new { Name = g.Key, AvgPerOp = g.Average(r => RtPerOp(r)) })
.OrderBy(x => x.AvgPerOp)
.FirstOrDefault();
if (fastestRt != null)
System.Console.WriteLine($"{"Fastest Round-trip",-20} │ {fastestRt.Name,-40} │ {fastestRt.AvgPerOp,12:F2} µs/op");
// Overall AcBinary (SGen) vs MemoryPack comparison.
var memPackSerResults = results.Where(r => (r.Engine == BenchmarkEngine.MemoryPack && r.IoMode == BenchmarkIoMode.ByteArray) && r.SerializeTimeMs > 0).ToList();
var memPackDesResults = results.Where(r => (r.Engine == BenchmarkEngine.MemoryPack && r.IoMode == BenchmarkIoMode.ByteArray) && r.DeserializeTimeMs > 0).ToList();
var memPackRtResults = results.Where(r => (r.Engine == BenchmarkEngine.MemoryPack && r.IoMode == BenchmarkIoMode.ByteArray) && r.RoundTripTimeMs > 0).ToList();
var acBinarySerResults = results.Where(r => (r.Engine == BenchmarkEngine.AcBinary && r.IoMode == BenchmarkIoMode.ByteArray && r.DispatchMode == BenchmarkDispatchMode.SGen) && r.SerializeTimeMs > 0).ToList();
var acBinaryDesResults = results.Where(r => (r.Engine == BenchmarkEngine.AcBinary && r.IoMode == BenchmarkIoMode.ByteArray && r.DispatchMode == BenchmarkDispatchMode.SGen) && r.DeserializeTimeMs > 0).ToList();
var acBinaryRtResults = results.Where(r => (r.Engine == BenchmarkEngine.AcBinary && r.IoMode == BenchmarkIoMode.ByteArray && r.DispatchMode == BenchmarkDispatchMode.SGen) && r.RoundTripTimeMs > 0).ToList();
// Skip comparison if no data available
if (memPackRtResults.Count == 0 || acBinaryRtResults.Count == 0)
{
System.Console.WriteLine();
System.Console.WriteLine("── AcBinary (Byte[], SGen) vs MemoryPack (Byte[]) (Overall) ──");
System.Console.WriteLine(" (Comparison requires both serialize and deserialize data)");
return;
}
// All averages are over per-op µs (iter-independent). Three aggregations per metric.
var sizeAcResults = results.Where(r => (r.Engine == BenchmarkEngine.AcBinary && r.IoMode == BenchmarkIoMode.ByteArray && r.DispatchMode == BenchmarkDispatchMode.SGen)).ToList();
var sizeMpResults = results.Where(r => (r.Engine == BenchmarkEngine.MemoryPack && r.IoMode == BenchmarkIoMode.ByteArray)).ToList();
var serStats = ComputeOverallStats(acBinarySerResults, memPackSerResults, SerPerOp);
var desStats = ComputeOverallStats(acBinaryDesResults, memPackDesResults, DesPerOp);
var rtStats = ComputeOverallStats(acBinaryRtResults, memPackRtResults, RtPerOp);
var sizeStats = ComputeOverallStats(sizeAcResults, sizeMpResults, r => r.SerializedSize);
var serAllocStats = ComputeOverallStats(acBinarySerResults, memPackSerResults, r => r.SerializeAllocBytesPerOp);
var desAllocStats = ComputeOverallStats(acBinaryDesResults, memPackDesResults, r => r.DeserializeAllocBytesPerOp);
System.Console.WriteLine();
System.Console.WriteLine("── AcBinary (Byte[], SGen) vs MemoryPack (Byte[]) (Overall) ──");
WriteOverallLine("Serialize", "µs/op", serStats);
WriteOverallLine("Deserialize", "µs/op", desStats);
WriteOverallLine("Round-trip", "µs/op", rtStats);
WriteOverallLine("Size", "B", sizeStats, "F0");
WriteOverallLine("Ser Alloc", "B/op", serAllocStats, "F0");
WriteOverallLine("Des Alloc", "B/op", desAllocStats, "F0");
}
/// <summary>
/// Writes the unified file triplet — <c>{SourceTag}.FullBenchmark_{Build}_{timestamp}.{log, LLM, output}</c>
/// — to <see cref="ReportingContext.ResultsDirectory"/>. The <c>.log</c> is the human-readable formatted
/// view, the <c>.LLM</c> is the markdown LLM-paste-friendly view, and the <c>.output</c> is a binary hex
/// dump of the Large test data's AcBinary-Default serialization (for raw inspection / wire-debugging).
/// </summary>
public static void SaveAll(ReportingContext ctx, List<BenchmarkResult> results, List<TestDataSet> testDataSets)
{
Directory.CreateDirectory(ctx.ResultsDirectory);
var timestamp = DateTime.Now.ToString("yyyy-MM-dd_HH-mm-ss");
var baseFileName = $"{ctx.SourceTag}.FullBenchmark_{ctx.BuildConfiguration}_{timestamp}";
var logFilePath = Path.Combine(ctx.ResultsDirectory, $"{baseFileName}.log");
var outputFilePath = Path.Combine(ctx.ResultsDirectory, $"{baseFileName}.output");
var llmFilePath = Path.Combine(ctx.ResultsDirectory, $"{baseFileName}.LLM");
// Save binary output to separate .output file.
// Cast to TestDataSet<TestOrder_All_False> because Phase 1 hardcodes the benchmark variant.
// Phase 2 will replace the cast with an options-driven dispatch (matching CreateSerializers).
var largeTestData = testDataSets.FirstOrDefault(t => t.Name.StartsWith("Large")) as TestDataSet<TestOrder_All_False>;
if (largeTestData != null)
{
var outputSb = new StringBuilder();
outputSb.AppendLine("╔══════════════════════════════════════════════════════════════════════════════════════════════════════╗");
outputSb.AppendLine("║ SERIALIZED BINARY OUTPUT ║");
outputSb.AppendLine($"║ Generated: {DateTime.Now:yyyy-MM-dd HH:mm:ss}".PadRight(100) + "║");
outputSb.AppendLine("╚══════════════════════════════════════════════════════════════════════════════════════════════════════╝");
outputSb.AppendLine();
outputSb.AppendLine("=== SERIALIZED BYTES: Large (5x5x5x10) - AcBinary (Default) ===");
var serializedBytes = AcBinarySerializer.Serialize(largeTestData.Order, AcBinarySerializerOptions.Default);
outputSb.AppendLine($"Size: {serializedBytes.Length:N0} bytes");
outputSb.AppendLine();
outputSb.AppendLine("Hex dump:");
outputSb.AppendLine(FormatHexDump(serializedBytes));
File.WriteAllText(outputFilePath, outputSb.ToString(), ctx.Utf8NoBom);
System.Console.WriteLine($"✓ Binary output saved to: {outputFilePath}");
}
// Save benchmark results to .log file
var sb = new StringBuilder();
sb.AppendLine("╔══════════════════════════════════════════════════════════════════════════════════════════════════════╗");
sb.AppendLine("║ SERIALIZER BENCHMARK RESULTS ║");
sb.AppendLine($"║ Generated: {DateTime.Now:yyyy-MM-dd HH:mm:ss}".PadRight(100) + "║");
sb.AppendLine($"║ Source: {ctx.SourceTag}".PadRight(100) + "║");
sb.AppendLine($"║ Build: {ctx.BuildConfiguration}".PadRight(100) + "║");
sb.AppendLine($"║ Charset: {ctx.CharsetName}".PadRight(100) + "║");
sb.AppendLine($"║ .NET: {System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription} ({Environment.Version})".PadRight(100) + "║");
// For BDN-sourced contexts, warmup / samples / target are managed inside BDN's job config (not by
// our adaptive engine) — surfacing the placeholder zeros as concrete numbers would be misleading.
// Print "BDN-managed" instead; raw BDN config is recoverable from the BDN-native artifacts under .../BDN/.
var isBdn = ctx.SourceTag == "Bdn";
var iterationsHeader = isBdn ? "Iterations: BDN-managed" : $"Iterations: per-cell adaptive (~{ctx.TargetSampleMs} ms target)";
var samplesHeader = isBdn ? "Samples: BDN-managed" : $"Samples: {ctx.BenchmarkSamples} (median) + 1 pilot discarded";
sb.AppendLine($"║ {iterationsHeader}".PadRight(100) + "║");
sb.AppendLine($"║ {samplesHeader}".PadRight(100) + "║");
sb.AppendLine("╚══════════════════════════════════════════════════════════════════════════════════════════════════════╝");
sb.AppendLine();
// Serializer options summary. The bracketed [OrderType] suffix shows which TestOrder variant
// graph each benchmark serialised — AcBinary picks variant per options preset
// (FastMode → _All_False, Default → _All_True; see BenchmarkLoop.UsesAllFalseVariant),
// MemPack / MsgPack always use _All_False. Distinct() de-dupes across cells (each preset
// appears once even though it runs on every test data set).
var optionsMap = results
.Where(r => r.OptionsDescription != null)
.Select(r => (r.SerializerName, r.OrderTypeName, r.OptionsDescription!))
.Distinct()
.ToList();
if (optionsMap.Count > 0)
{
sb.AppendLine("=== SERIALIZER OPTIONS ===");
foreach (var (name, orderType, opts) in optionsMap)
sb.AppendLine($" {name} [{orderType}]: {opts}");
sb.AppendLine();
}
// CSV-like data for easy import — keeps raw byte integers (no KB rounding) so external tools can compute precisely.
// InvariantCulture is mandatory here: the decimal-separator dimension MUST be `.` so the comma-separated
// field delimiters don't collide with locale-specific decimal commas (e.g. Hungarian "7,38" would split
// a single F2 value across two CSV fields).
var inv = CultureInfo.InvariantCulture;
sb.AppendLine("=== RAW DATA (CSV) ===");
sb.AppendLine("TestData,Engine,IO,Mode,Options,Size,SerializeMicrosPerOp,DeserializeMicrosPerOp,RoundTripMicrosPerOp,SerializeAllocBytesPerOp,DeserializeAllocBytesPerOp,RoundTripAllocBytesPerOp,SetupSerializeAllocBytes,SetupDeserializeAllocBytes");
foreach (var testData in testDataSets)
{
var testResults = results.Where(r => r.TestDataName == testData.DisplayName).ToList();
foreach (var result in testResults)
{
sb.AppendLine($"{result.TestDataName},{result.Engine.ToDisplay()},{result.IoMode.ToDisplay()},{result.DispatchMode.ToDisplay()},{result.OptionsPreset},{result.SerializedSize},{SerPerOp(result).ToString("F2", inv)},{DesPerOp(result).ToString("F2", inv)},{RtPerOp(result).ToString("F2", inv)},{result.SerializeAllocBytesPerOp},{result.DeserializeAllocBytesPerOp},{result.RoundTripAllocBytesPerOp},{result.SetupSerializeAllocBytes},{result.SetupDeserializeAllocBytes}");
}
}
sb.AppendLine();
// Formatted results
sb.AppendLine("=== FORMATTED RESULTS BY TEST DATA ===");
sb.AppendLine("(►) = Highlighted: MemoryPack (Byte[]) (baseline) and AcBinary (Byte[])");
sb.AppendLine();
foreach (var testData in testDataSets)
{
// Order by Engine (stable column-position across cells, see PrintGroupedResults for rationale);
// RtPerOp is the secondary tiebreaker between same-engine variants (SGen vs Runtime).
var testResults = results.Where(r => r.TestDataName == testData.DisplayName).OrderBy(r => r.Engine).ThenBy(r => RtPerOp(r)).ToList();
var memPackResult = testResults.FirstOrDefault(r => (r.Engine == BenchmarkEngine.MemoryPack && r.IoMode == BenchmarkIoMode.ByteArray));
var acBinaryResult = testResults.FirstOrDefault(r => (r.Engine == BenchmarkEngine.AcBinary && r.IoMode == BenchmarkIoMode.ByteArray && r.DispatchMode == BenchmarkDispatchMode.SGen));
sb.AppendLine();
sb.AppendLine($"--- {testData.DisplayName} ---");
sb.AppendLine($"{"#",-4} {"Serializer",-42} {"Size B",-12} {"Setup S/D KB",-14} {"Ser µs/op",-12} {"Des µs/op",-12} {"RT µs/op",-12} {"SerAlc KB",-11} {"DesAlc KB",-11}");
sb.AppendLine(new string('-', 140));
var rank = 1;
foreach (var result in testResults)
{
var isHighlighted = ((result.Engine == BenchmarkEngine.MemoryPack || result.Engine == BenchmarkEngine.AcBinary) && result.IoMode == BenchmarkIoMode.ByteArray);
var prefix = isHighlighted ? "► " : " ";
var size = $"{result.SerializedSize:N0}";
var setup = $"{ToKilobytes(result.SetupSerializeAllocBytes):F2} / {ToKilobytes(result.SetupDeserializeAllocBytes):F2}";
var ser = result.SerializeTimeMs > 0 ? $"{SerPerOp(result):F2}" : "N/A";
var des = result.DeserializeTimeMs > 0 ? $"{DesPerOp(result):F2}" : "N/A";
var rt = result.RoundTripTimeMs > 0 ? $"{RtPerOp(result):F2}" : "N/A";
var serAlloc = result.SerializeTimeMs > 0 ? $"{ToKilobytes(result.SerializeAllocBytesPerOp):F2}" : "N/A";
var desAlloc = result.DeserializeTimeMs > 0 ? $"{ToKilobytes(result.DeserializeAllocBytesPerOp):F2}" : "N/A";
sb.AppendLine($"{rank++,2} {prefix}{result.SerializerName,-40} {size,-12} {setup,-14} {ser,-12} {des,-12} {rt,-12} {serAlloc,-11} {desAlloc,-11}");
}
// Summary row for this test data (vs MemoryPack)
if (memPackResult != null && acBinaryResult != null)
{
var sizePct = (acBinaryResult.SerializedSize / (double)memPackResult.SerializedSize - 1) * 100;
var serPct = SerPerOp(memPackResult) > 0 ? (SerPerOp(acBinaryResult) / SerPerOp(memPackResult) - 1) * 100 : 0;
var desPct = DesPerOp(memPackResult) > 0 ? (DesPerOp(acBinaryResult) / DesPerOp(memPackResult) - 1) * 100 : 0;
var rtPct = RtPerOp(memPackResult) > 0 ? (RtPerOp(acBinaryResult) / RtPerOp(memPackResult) - 1) * 100 : 0;
sb.AppendLine($" AcBinary (Byte[]) vs MemoryPack (Byte[]): Size {sizePct:+0;-0}% │ Ser {serPct:+0;-0}% │ Des {desPct:+0;-0}% │ RT {rtPct:+0;-0}%");
}
}
// Summary comparison (vs MemoryPack)
sb.AppendLine();
sb.AppendLine("=== AcBinary (Byte[], SGen) vs MemoryPack (Byte[]) (Overall) ===");
var memPackSerResults2 = results.Where(r => (r.Engine == BenchmarkEngine.MemoryPack && r.IoMode == BenchmarkIoMode.ByteArray) && r.SerializeTimeMs > 0).ToList();
var memPackDesResults2 = results.Where(r => (r.Engine == BenchmarkEngine.MemoryPack && r.IoMode == BenchmarkIoMode.ByteArray) && r.DeserializeTimeMs > 0).ToList();
var memPackRtResults2 = results.Where(r => (r.Engine == BenchmarkEngine.MemoryPack && r.IoMode == BenchmarkIoMode.ByteArray) && r.RoundTripTimeMs > 0).ToList();
var acBinarySerResults2 = results.Where(r => (r.Engine == BenchmarkEngine.AcBinary && r.IoMode == BenchmarkIoMode.ByteArray && r.DispatchMode == BenchmarkDispatchMode.SGen) && r.SerializeTimeMs > 0).ToList();
var acBinaryDesResults2 = results.Where(r => (r.Engine == BenchmarkEngine.AcBinary && r.IoMode == BenchmarkIoMode.ByteArray && r.DispatchMode == BenchmarkDispatchMode.SGen) && r.DeserializeTimeMs > 0).ToList();
var acBinaryRtResults2 = results.Where(r => (r.Engine == BenchmarkEngine.AcBinary && r.IoMode == BenchmarkIoMode.ByteArray && r.DispatchMode == BenchmarkDispatchMode.SGen) && r.RoundTripTimeMs > 0).ToList();
// Skip comparison block if either side has no Byte[] data
if (memPackRtResults2.Count == 0 || acBinaryRtResults2.Count == 0)
{
sb.AppendLine(" (Comparison requires both serialize and deserialize data)");
File.WriteAllText(logFilePath, sb.ToString(), ctx.Utf8NoBom);
System.Console.WriteLine($"✓ Results saved to: {logFilePath}");
SaveLlmResults(ctx, llmFilePath, results, testDataSets);
return;
}
var sizeAcResults2 = results.Where(r => (r.Engine == BenchmarkEngine.AcBinary && r.IoMode == BenchmarkIoMode.ByteArray && r.DispatchMode == BenchmarkDispatchMode.SGen)).ToList();
var sizeMpResults2 = results.Where(r => (r.Engine == BenchmarkEngine.MemoryPack && r.IoMode == BenchmarkIoMode.ByteArray)).ToList();
AppendOverallLine(sb, "Serialize", "µs/op", ComputeOverallStats(acBinarySerResults2, memPackSerResults2, SerPerOp));
AppendOverallLine(sb, "Ser Alloc", "B/op", ComputeOverallStats(acBinarySerResults2, memPackSerResults2, r => r.SerializeAllocBytesPerOp), "F0");
AppendOverallLine(sb, "Deserialize", "µs/op", ComputeOverallStats(acBinaryDesResults2, memPackDesResults2, DesPerOp));
AppendOverallLine(sb, "Des Alloc", "B/op", ComputeOverallStats(acBinaryDesResults2, memPackDesResults2, r => r.DeserializeAllocBytesPerOp), "F0");
AppendOverallLine(sb, "Round-trip", "µs/op", ComputeOverallStats(acBinaryRtResults2, memPackRtResults2, RtPerOp));
AppendOverallLine(sb, "Size", "B", ComputeOverallStats(sizeAcResults2, sizeMpResults2, r => r.SerializedSize), "F0");
File.WriteAllText(logFilePath, sb.ToString(), ctx.Utf8NoBom);
System.Console.WriteLine($"✓ Results saved to: {logFilePath}");
// Save LLM-optimized results
SaveLlmResults(ctx, llmFilePath, results, testDataSets);
}
private static void SaveLlmResults(ReportingContext ctx, string filePath, List<BenchmarkResult> results, List<TestDataSet> testDataSets)
{
var sb = new StringBuilder();
sb.AppendLine($"# AcBinary Benchmark [{ctx.SourceTag}] {ctx.BuildConfiguration} {DateTime.Now:yyyy-MM-dd HH:mm:ss}");
// BDN-sourced: warmup / iter / samples are BDN-job-config-managed (see .../BDN/ artifacts for raw N).
// Console-sourced: our adaptive engine emits real numbers.
var runStatsHeader = ctx.SourceTag == "Bdn"
? "Iterations: BDN-managed | Warmup: BDN-managed | Samples: BDN-managed"
: $"Iterations: per-cell adaptive (target ~{ctx.TargetSampleMs} ms/sample) | Warmup: {ctx.WarmupIterations} per phase (Ser/Des isolated) | Samples: {ctx.BenchmarkSamples} (median) + 1 pilot discarded";
// F1 formatter without an explicit IFormatProvider would pick the current culture (e.g. "3,0%"
// in Hungarian locale) — break parsability of the .LLM. Force InvariantCulture so the header
// matches the row values which already go through CultureInfo.InvariantCulture in FormatMicrosWithRange.
var unstablePct = (ctx.UnstableCVThreshold * 100).ToString("F1", CultureInfo.InvariantCulture);
var microPct = (ctx.MicroOptCVThreshold * 100).ToString("F1", CultureInfo.InvariantCulture);
sb.AppendLine($"Charset: {ctx.CharsetName} | {runStatsHeader} | .NET: {Environment.Version} | UnstableCV: {unstablePct}% | MicroOptCV: {microPct}%");
sb.AppendLine("Baseline: MemoryPack (Byte[]) (SOTA reference) | Verified: round-trip correctness checked once per cell before warmup");
// Options summary. Bracketed [OrderType] surfaces the TestOrder variant each preset serialised —
// see SaveAll for the variant-dispatch rationale.
var optionsMap = results
.Where(r => r.OptionsDescription != null)
.Select(r => (r.SerializerName, r.OrderTypeName, r.OptionsDescription!))
.Distinct()
.ToList();
if (optionsMap.Count > 0)
{
sb.AppendLine();
sb.AppendLine("## Options");
sb.AppendLine();
foreach (var (name, orderType, opts) in optionsMap)
sb.AppendLine($"- **{name} [{orderType}]**: {opts}");
}
sb.AppendLine();
sb.AppendLine("## Results");
sb.AppendLine();
sb.AppendLine("TestData | Engine | IO | Mode | Options | Size(B) | Ser(µs/op) | Deser(µs/op) | RT(µs/op) | SerAlloc(KB/op) | DesAlloc(KB/op) | RTAlloc(KB/op) | Setup S/D(KB) | Iter Ser/Des");
sb.AppendLine("---|---|---|---|---|---|---|---|---|---|---|---|---|---");
var inv = CultureInfo.InvariantCulture;
foreach (var testData in testDataSets)
{
// Order by Engine for stable column-position across cells (see PrintGroupedResults for rationale).
var testResults = results
.Where(r => r.TestDataName == testData.DisplayName)
.OrderBy(r => r.Engine).ThenBy(RtPerOp)
.ToList();
foreach (var r in testResults)
{
var ser = r.SerializeTimeMs > 0 ? FormatMicrosWithRange(r.SerializeTimeMs, r.SerializeTimeMinMs, r.SerializeTimeMaxMs, r.SerializeTimeStdDevMs, r.SerializeIterations, inv, ctx.UnstableCVThreshold, ctx.MicroOptCVThreshold) : "-";
var des = r.DeserializeTimeMs > 0 ? FormatMicrosWithRange(r.DeserializeTimeMs, r.DeserializeTimeMinMs, r.DeserializeTimeMaxMs, r.DeserializeTimeStdDevMs, r.DeserializeIterations, inv, ctx.UnstableCVThreshold, ctx.MicroOptCVThreshold) : "-";
var rt = r.RoundTripTimeMs > 0
? (r.IsRoundTripOnly
? FormatMicrosWithRange(r.RoundTripTimeMs, r.RoundTripTimeMinMs, r.RoundTripTimeMaxMs, r.RoundTripTimeStdDevMs, r.RoundTripIterations, inv, ctx.UnstableCVThreshold, ctx.MicroOptCVThreshold)
: RtPerOp(r).ToString("F2", inv))
: "-";
var serAlloc = r.SerializeTimeMs > 0 ? ToKilobytes(r.SerializeAllocBytesPerOp).ToString("F2", inv) : "-";
var desAlloc = r.DeserializeTimeMs > 0 ? ToKilobytes(r.DeserializeAllocBytesPerOp).ToString("F2", inv) : "-";
var rtAlloc = r.RoundTripAllocBytesPerOp > 0 ? ToKilobytes(r.RoundTripAllocBytesPerOp).ToString("F2", inv) : "-";
var setupAlloc = $"{ToKilobytes(r.SetupSerializeAllocBytes).ToString("F2", inv)} / {ToKilobytes(r.SetupDeserializeAllocBytes).ToString("F2", inv)}";
var iterCol = r.IsRoundTripOnly
? r.RoundTripIterations.ToString(inv)
: $"{(r.SerializeIterations > 0 ? r.SerializeIterations.ToString(inv) : "-")} / {(r.DeserializeIterations > 0 ? r.DeserializeIterations.ToString(inv) : "-")}";
sb.AppendLine($"{r.TestDataName} | {r.Engine.ToDisplay()} | {r.IoMode.ToDisplay()} | {r.DispatchMode.ToDisplay()} | {r.OptionsPreset} | {r.SerializedSize} | {ser} | {des} | {rt} | {serAlloc} | {desAlloc} | {rtAlloc} | {setupAlloc} | {iterCol}");
}
}
// Overall AcBinary (SGen, Byte[]) vs MemoryPack (Byte[]) comparison
var memPackByteArrayResults = results.Where(r => r.Engine == BenchmarkEngine.MemoryPack && r.IoMode == BenchmarkIoMode.ByteArray).ToList();
var acBinarySGenByteArrayResults = results.Where(r => r.Engine == BenchmarkEngine.AcBinary && r.IoMode == BenchmarkIoMode.ByteArray && r.DispatchMode == BenchmarkDispatchMode.SGen).ToList();
var memPackSerResultsLlm = memPackByteArrayResults.Where(r => r.SerializeTimeMs > 0).ToList();
var memPackDesResultsLlm = memPackByteArrayResults.Where(r => r.DeserializeTimeMs > 0).ToList();
var memPackRtResultsLlm = memPackByteArrayResults.Where(r => r.RoundTripTimeMs > 0).ToList();
var acBinarySerResultsLlm = acBinarySGenByteArrayResults.Where(r => r.SerializeTimeMs > 0).ToList();
var acBinaryDesResultsLlm = acBinarySGenByteArrayResults.Where(r => r.DeserializeTimeMs > 0).ToList();
var acBinaryRtResultsLlm = acBinarySGenByteArrayResults.Where(r => r.RoundTripTimeMs > 0).ToList();
if (memPackRtResultsLlm.Count > 0 && acBinaryRtResultsLlm.Count > 0)
{
sb.AppendLine();
sb.AppendLine("## Overall: AcBinary (Byte[], SGen) vs MemoryPack (Byte[])");
sb.AppendLine();
sb.AppendLine("Three aggregations of per-cell results: **arith** = arithmetic mean of µs/op (magnitude-weighted, Large cell dominates); **geo** = geometric mean of per-cell ratios (each cell weighted equally); **median** = median of per-cell ratios (outlier-resistant). Negative % = AcBinary faster/smaller; positive % = MemPack faster/smaller. The geo/median variants surface when a single big cell skews the arithmetic mean.");
sb.AppendLine();
sb.AppendLine("```");
AppendOverallLine(sb, "Serialize", "µs/op", ComputeOverallStats(acBinarySerResultsLlm, memPackSerResultsLlm, SerPerOp));
AppendOverallLine(sb, "Ser Alloc", "B/op", ComputeOverallStats(acBinarySerResultsLlm, memPackSerResultsLlm, r => r.SerializeAllocBytesPerOp), "F0");
AppendOverallLine(sb, "Deserialize", "µs/op", ComputeOverallStats(acBinaryDesResultsLlm, memPackDesResultsLlm, DesPerOp));
AppendOverallLine(sb, "Des Alloc", "B/op", ComputeOverallStats(acBinaryDesResultsLlm, memPackDesResultsLlm, r => r.DeserializeAllocBytesPerOp), "F0");
AppendOverallLine(sb, "Round-trip", "µs/op", ComputeOverallStats(acBinaryRtResultsLlm, memPackRtResultsLlm, RtPerOp));
AppendOverallLine(sb, "Size", "B", ComputeOverallStats(acBinarySGenByteArrayResults, memPackByteArrayResults, r => r.SerializedSize), "F0");
sb.AppendLine("```");
}
File.WriteAllText(filePath, sb.ToString(), ctx.Utf8NoBom);
System.Console.WriteLine($"✓ LLM results saved to: {filePath}");
}
/// <summary>
/// Formats byte array as hex dump with offset, hex values, and ASCII representation.
/// </summary>
public static string FormatHexDump(byte[] bytes, int bytesPerLine = 16)
{
var sb = new StringBuilder();
for (var i = 0; i < bytes.Length; i += bytesPerLine)
{
// Offset
sb.Append($"{i:X8} ");
// Hex bytes
for (var j = 0; j < bytesPerLine; j++)
{
if (i + j < bytes.Length)
sb.Append($"{bytes[i + j]:X2} ");
else
sb.Append(" ");
if (j == 7) sb.Append(' '); // Extra space in middle
}
sb.Append(" |");
// ASCII representation
for (var j = 0; j < bytesPerLine && i + j < bytes.Length; j++)
{
var b = bytes[i + j];
sb.Append(b is >= 32 and < 127 ? (char)b : '.');
}
sb.AppendLine("|");
}
return sb.ToString();
}
}

View File

@ -0,0 +1,93 @@
using AyCode.Core.Benchmarks.Workloads.Scenarios;
namespace AyCode.Core.Benchmarks.Reporting;
/// <summary>
/// Per-cell benchmark result row. Populated by the benchmark execution loop (Console-side
/// <c>BenchmarkLoop.RunBenchmarksForTestData</c> / BDN-side <c>BdnSummaryAdapter</c>); consumed by the
/// output formatters in <c>BenchmarkReportWriter</c> (console table + .log + .LLM file writers).
/// Pure DTO — no behaviour.
/// </summary>
public sealed class BenchmarkResult
{
public string TestDataName { get; set; } = "";
public BenchmarkEngine Engine { get; set; }
public BenchmarkIoMode IoMode { get; set; }
public BenchmarkDispatchMode DispatchMode { get; set; }
public string OptionsPreset { get; set; } = "";
/// <summary>
/// CLR type name of the order graph serialised in this row (e.g. <c>"TestOrder_All_False"</c>,
/// <c>"TestOrder_All_True"</c>). Captured from <see cref="ISerializerBenchmark.OrderTypeName"/> in
/// the runner loop; surfaced in the SERIALIZER OPTIONS section of every output
/// (.log, .LLM, console) so the reader can correlate each preset with its TestOrder variant
/// without inflating the per-row tables with an extra column.
/// </summary>
public string OrderTypeName { get; set; } = "";
/// <summary>True if Serialize() captures a full round-trip and Deserialize() is a no-op
/// (single-use streaming transports like NamedPipe). Excluded from "Fastest Serialize" / "Fastest Deserialize"
/// winners rankings; still ranked in "Fastest Round-trip". Display-side: Ser µs/op / SerAlloc / Des µs/op / DesAlloc
/// all show "N/A" since they were never measured separately; RT µs/op / RT Alloc carry the full round-trip values.</summary>
public bool IsRoundTripOnly { get; set; }
/// <summary>Synthesized display name for backwards compatibility / single-string-row scenarios. Includes DispatchMode so SGen and Runtime variants of the same preset don't collide in grouping (e.g. SUMMARY: WINNERS).</summary>
public string SerializerName => $"{Engine.ToDisplay()} ({IoMode.ToDisplay()}, {OptionsPreset}, {DispatchMode.ToDisplay()})";
public string? OptionsDescription { get; set; }
public int SerializedSize { get; set; }
public double SerializeTimeMs { get; set; }
public double DeserializeTimeMs { get; set; }
// Per-sample min/max alongside the median (median is the *Time*Ms field above). Surfaces
// inter-sample range — the visible noise floor for the row. 0 when the operation was skipped
// (mode != "all"/"ser"/"des") or when a single-sample fast path was used (min == max == median).
public double SerializeTimeMinMs { get; set; }
public double SerializeTimeMaxMs { get; set; }
public double DeserializeTimeMinMs { get; set; }
public double DeserializeTimeMaxMs { get; set; }
// Sample-population stddev (ms). Used by FormatMicrosWithRange to compute CV (stddev/mean)
// and emit the ⚠️ marker on rows above Configuration.UnstableCVThreshold. 0 in single-sample mode.
public double SerializeTimeStdDevMs { get; set; }
public double DeserializeTimeStdDevMs { get; set; }
// Per-row adaptive iteration count (post-CalibrateIterations). Each Ser and Des function calibrates
// independently to land its sample window at ~Configuration.TargetSampleMs; per-op µs is then iter-independent
// (`SerializeTimeMs / SerializeIterations * 1000`). For round-trip-only rows (NamedPipe etc.),
// RoundTripIterations carries the calibrated iter count; SerializeIterations and DeserializeIterations
// stay 0 (Ser and Des are not separately measurable on those rows).
//
// BDN-sourced rows (populated by <c>BdnSummaryAdapter</c>) follow a different convention: per-op time
// is stored directly in <c>*TimeMs</c> with <c>Iterations = 1</c>, so the same <c>TimeMs / Iterations * 1000</c>
// formula yields per-op µs. The actual BDN N count is NOT preserved on these rows — consumers that
// read <c>SerializeIterations</c> as a nominal loop count (e.g. "bytes allocated over N iterations")
// will misinterpret BDN rows. For the raw N, read the BDN-native artifacts under
// <c>Test_Benchmark_Results/Benchmark/BDN/</c>.
public int SerializeIterations { get; set; }
public int DeserializeIterations { get; set; }
public int RoundTripIterations { get; set; }
public long SerializeAllocBytesPerOp { get; set; }
public long DeserializeAllocBytesPerOp { get; set; }
public long SetupSerializeAllocBytes { get; set; }
public long SetupDeserializeAllocBytes { get; set; }
/// <summary>Total round-trip time. For in-memory benchmarks: synthesized so that
/// <c>RoundTripTimeMs / RoundTripIterations</c> yields the correct <c>SerPerOp + DesPerOp</c> µs/op
/// (necessary because Ser and Des may have different iter counts post-calibration).
/// For round-trip-only benchmarks (NamedPipe etc.): the directly-measured pipe round-trip time.</summary>
public double RoundTripTimeMs { get; set; }
// Round-trip min/max + stddev — only populated for round-trip-only benchmarks (NamedPipe etc.) where
// RT is directly measured. For in-memory rows RT = Ser + Des, which has no single-sample
// distribution; surface Ser/Des range separately instead.
public double RoundTripTimeMinMs { get; set; }
public double RoundTripTimeMaxMs { get; set; }
public double RoundTripTimeStdDevMs { get; set; }
/// <summary>Total round-trip allocation per op. For in-memory benchmarks: <c>SerializeAlloc + DeserializeAlloc</c>.
/// For round-trip-only benchmarks: process-wide allocation measured via <see cref="GC.GetTotalAllocatedBytes"/>
/// (covers ALL threads — client, server-drain, channel internals — not just the caller).</summary>
public long RoundTripAllocBytesPerOp { get; set; }
}

View File

@ -0,0 +1,30 @@
# Reporting
Shared reporting types — the `BenchmarkResult` DTO that captures one cell of a benchmark run + the `BenchmarkReportWriter` that turns a list of these into the unified `.log` / `.LLM` / `.output` triplet + the `ReportingContext` bundle that parameterises both runners.
## Layout
- [`BenchmarkResult.cs`](BenchmarkResult.cs) — per-cell result row. `(TestData × Engine × IoMode × OptionsPreset × DispatchMode)` tuple + Ser / Des / RT timings (median, min, max, stddev — all ms-batch units), iter counts (post-calibration), allocated bytes per op, setup-side one-time alloc, `IsRoundTripOnly` flag, derived `SerializerName`. Pure DTO — no behaviour. Populated by either:
- Console `BenchmarkLoop.RunBenchmarksForTestData` (after adaptive measurement)
- BDN `BdnSummaryAdapter.Translate` (after BDN Summary is in hand)
- [`ReportingContext.cs`](ReportingContext.cs) — record bundle for the writer:
- `SourceTag``"Console"` / `"Bdn"`; drives the filename prefix
- `ResultsDirectory` — resolved at startup via `ResolveResultsDirectory()` walking up from `AppContext.BaseDirectory` to the nearest `AyCode.Core.sln`, then `Test_Benchmark_Results/Benchmark/`. Worktree-aware.
- `BuildConfiguration``"Debug"` / `"Release"` / `"NativeAOT"`; rendered into both the filename AND the report header
- `Utf8NoBom` — shared `UTF8Encoding(false)` for all `File.WriteAllText` calls
- `CharsetName`, `WarmupIterations`, `BenchmarkSamples`, `TargetSampleMs`, `UnstableCVThreshold` — run-header info embedded in every emitted artifact
- [`BenchmarkReportWriter.cs`](BenchmarkReportWriter.cs) — the writer itself:
- `SaveAll(ctx, results, testDataSets)` — orchestrator. Writes the `.log` (formatted text + CSV + per-cell tables + Overall aggregation), `.LLM` (markdown table + Overall aggregation), and `.output` (hex dump of the Large cell's AcBinary serialization). All three land in `ctx.ResultsDirectory` with the `{ctx.SourceTag}.FullBenchmark_{Build}_{ts}.<ext>` filename pattern.
- `PrintGroupedResults(results, testDataSets)` — colored per-cell tables to `System.Console`. Highlights MemoryPack (baseline) and AcBinary (SGen-Byte[]) rows with green/red win/lose colors, footer row shows pct deltas per metric.
- `PrintResult(result)` — single-line summary printed during the per-cell loop (real-time progress signal).
- `ComputeOverallStats(acResults, mpResults, valueSelector)` — paired-cell aggregation across `TestDataName` (arithmetic mean / geometric mean / median of per-cell ratios). Null-safe.
- `FormatMicrosWithRange(...)``26.86 (24.50..29.10)` style with ⚠CV-warning suffix when stddev/median exceeds the `UnstableCVThreshold`. All formatting goes through `CultureInfo.InvariantCulture` so the CSV section in `.log` stays parseable regardless of the host locale.
- `ToPerOpMicros` / `SerPerOp` / `DesPerOp` / `RtPerOp` / `ToKilobytes` / `FormatPctSigned` / `FormatHexDump` / `AppendOverallLine` — helper utilities used inline by the report-rendering methods.
## Conventions
- **Time units in `BenchmarkResult`**: all `*TimeMs` fields are total-batch milliseconds. Per-op µs = `TimeMs / Iterations * 1000`. For BDN-sourced rows the adapter stores `Mean_ns / 1e6` with `Iterations = 1`, so the same formula yields per-op µs directly (`ms * 1000 = µs`).
- **InvariantCulture** is enforced everywhere a numeric value is rendered to file (`.log` CSV section, `.LLM` markdown cells). Console-output (the colored tables) uses default culture for human-friendliness.
- **`SourceTag` discriminator**: appears in three places — the filename prefix (`Console.` / `Bdn.`), the `.log` header (`║ Source: Console`), the `.LLM` H1 (`# AcBinary Benchmark [Console] Release ...`). Anyone diffing or grepping outputs can pin the source unambiguously.

View File

@ -0,0 +1,45 @@
using System.Text;
namespace AyCode.Core.Benchmarks.Reporting;
/// <summary>
/// Context bundle for the unified benchmark report writer. Same record on both sides (Console / BDN),
/// the <see cref="SourceTag"/> differs ("Console" / "Bdn") and drives the filename prefix
/// (e.g. <c>Console.FullBenchmark_Release_{timestamp}.LLM</c> vs <c>Bdn.FullBenchmark_Release_{timestamp}.LLM</c>).
/// The <see cref="ResultsDirectory"/> resolution walks up from <see cref="AppContext.BaseDirectory"/> to the
/// nearest <c>AyCode.Core.sln</c> and combines with <c>Test_Benchmark_Results\Benchmark</c> — works across
/// build modes (Debug / Release / AOT publish) and worktrees (each worktree has its own .sln, so its bench
/// results land alongside its code). The remaining fields capture run-header information (charset, iter
/// counts, target sample window, CV threshold) so the writer can render a self-documenting header in both
/// the <c>.log</c> and <c>.LLM</c> outputs.
/// </summary>
public sealed record ReportingContext(
string SourceTag,
string ResultsDirectory,
string BuildConfiguration,
UTF8Encoding Utf8NoBom,
string CharsetName,
int WarmupIterations,
int BenchmarkSamples,
int TargetSampleMs,
double UnstableCVThreshold,
double MicroOptCVThreshold)
{
/// <summary>
/// Walks up from the assembly's BaseDirectory to find the repo root (marker: <c>AyCode.Core.sln</c>).
/// Returns <c>{repoRoot}\Test_Benchmark_Results\Benchmark</c>. Worktree-aware: if running from a
/// worktree, the walk finds the worktree's own .sln (each worktree has its own checkout), so
/// results land in the worktree's results folder — the natural place when the worktree's code
/// changes are what produced the numbers.
/// </summary>
public static string ResolveResultsDirectory()
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir != null && !File.Exists(Path.Combine(dir.FullName, "AyCode.Core.sln")))
dir = dir.Parent;
if (dir == null)
throw new InvalidOperationException(
"Cannot locate repo root (AyCode.Core.sln) from AppContext.BaseDirectory: " + AppContext.BaseDirectory);
return Path.Combine(dir.FullName, "Test_Benchmark_Results", "Benchmark");
}
}

View File

@ -1,713 +0,0 @@
using AyCode.Core.Extensions;
using AyCode.Core.Tests.TestModels;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Jobs;
using MessagePack;
using MessagePack.Resolvers;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using JsonSerializer = System.Text.Json.JsonSerializer;
using MongoDB.Bson;
using MongoDB.Bson.IO;
using MongoDB.Bson.Serialization;
using System.IO;
using AyCode.Core.Serializers.Binaries;
using AyCode.Core.Serializers.Jsons;
using AyCode.Core.Serializers;
namespace AyCode.Core.Benchmarks;
/// <summary>
/// Minimal benchmark to test if BenchmarkDotNet works without stack overflow.
/// </summary>
[ShortRunJob]
[MemoryDiagnoser]
public class MinimalBenchmark
{
private byte[] _data = null!;
private string _json = null!;
[GlobalSetup]
public void Setup()
{
// Use very simple data - no circular references
var simpleData = new { Id = 1, Name = "Test", Value = 42.5 };
_json = System.Text.Json.JsonSerializer.Serialize(simpleData);
_data = Encoding.UTF8.GetBytes(_json);
Console.WriteLine($"Setup complete. Data size: {_data.Length} bytes");
}
[Benchmark]
public int GetLength() => _data.Length;
[Benchmark]
public string GetJson() => _json;
}
/// <summary>
/// Binary vs JSON benchmark with simple flat objects (no circular references).
/// </summary>
[ShortRunJob]
[MemoryDiagnoser]
public class SimpleBinaryBenchmark
{
private PrimitiveTestClass _testData = null!;
private byte[] _binaryData = null!;
private string _jsonData = null!;
[GlobalSetup]
public void Setup()
{
_testData = TestDataFactory.CreatePrimitiveTestData();
_binaryData = AcBinarySerializer.Serialize(_testData, AcBinarySerializerOptions.WithoutReferenceHandling);
_jsonData = AcJsonSerializer.Serialize(_testData, AcJsonSerializerOptions.WithoutReferenceHandling);
Console.WriteLine($"Binary: {_binaryData.Length} bytes, JSON: {_jsonData.Length} chars");
}
[Benchmark(Description = "Binary Serialize")]
public byte[] SerializeBinary() => AcBinarySerializer.Serialize(_testData, AcBinarySerializerOptions.WithoutReferenceHandling);
[Benchmark(Description = "JSON Serialize", Baseline = true)]
public string SerializeJson() => AcJsonSerializer.Serialize(_testData, AcJsonSerializerOptions.WithoutReferenceHandling);
[Benchmark(Description = "Binary Deserialize")]
public PrimitiveTestClass? DeserializeBinary() => AcBinaryDeserializer.Deserialize<PrimitiveTestClass>(_binaryData);
[Benchmark(Description = "JSON Deserialize")]
public PrimitiveTestClass? DeserializeJson() => AcJsonDeserializer.Deserialize<PrimitiveTestClass>(_jsonData, AcJsonSerializerOptions.WithoutReferenceHandling);
}
/// <summary>
/// Complex hierarchy benchmark - AcBinary vs JSON only (no MessagePack to isolate the issue).
/// Uses AcBinary without reference handling.
/// </summary>
[ShortRunJob]
[MemoryDiagnoser]
[RankColumn]
public class ComplexBinaryBenchmark
{
private TestOrder _testOrder = null!;
private byte[] _acBinaryData = null!;
private string _jsonData = null!;
private AcBinarySerializerOptions _binaryOptions = null!;
private AcJsonSerializerOptions _jsonOptions = null!;
[GlobalSetup]
public void Setup()
{
Console.WriteLine("Creating test data...");
_testOrder = TestDataFactory.CreateBenchmarkOrder(
itemCount: 2,
palletsPerItem: 2,
measurementsPerPallet: 2,
pointsPerMeasurement: 3);
Console.WriteLine($"Created order with {_testOrder.Items.Count} items");
_binaryOptions = AcBinarySerializerOptions.WithoutReferenceHandling;
_jsonOptions = AcJsonSerializerOptions.WithoutReferenceHandling;
Console.WriteLine("Serializing AcBinary...");
_acBinaryData = AcBinarySerializer.Serialize(_testOrder, _binaryOptions);
Console.WriteLine($"AcBinary size: {_acBinaryData.Length} bytes");
Console.WriteLine("Serializing JSON...");
_jsonData = AcJsonSerializer.Serialize(_testOrder, _jsonOptions);
Console.WriteLine($"JSON size: {_jsonData.Length} chars");
var jsonBytes = Encoding.UTF8.GetByteCount(_jsonData);
Console.WriteLine($"\n=== SIZE COMPARISON ===");
Console.WriteLine($"AcBinary: {_acBinaryData.Length,8:N0} bytes ({100.0 * _acBinaryData.Length / jsonBytes:F1}%)");
Console.WriteLine($"JSON: {jsonBytes,8:N0} bytes (100.0%)");
}
[Benchmark(Description = "AcBinary Serialize")]
public byte[] Serialize_AcBinary() => AcBinarySerializer.Serialize(_testOrder, _binaryOptions);
[Benchmark(Description = "JSON Serialize", Baseline = true)]
public string Serialize_Json() => AcJsonSerializer.Serialize(_testOrder, _jsonOptions);
[Benchmark(Description = "AcBinary Deserialize")]
public TestOrder? Deserialize_AcBinary() => AcBinaryDeserializer.Deserialize<TestOrder>(_acBinaryData);
[Benchmark(Description = "JSON Deserialize")]
public TestOrder? Deserialize_Json() => AcJsonDeserializer.Deserialize<TestOrder>(_jsonData, _jsonOptions);
}
/// <summary>
/// Full comparison with MessagePack and BSON - AcBinary uses NO reference handling everywhere.
/// </summary>
[ShortRunJob]
[MemoryDiagnoser]
[RankColumn]
public class MessagePackComparisonBenchmark
{
private TestOrder _testOrder = null!;
private byte[] _acBinaryData = null!;
private byte[] _msgPackData = null!;
private byte[] _bsonData = null!;
private string _jsonData = null!;
private AcBinarySerializerOptions _binaryOptions = null!;
private MessagePackSerializerOptions _msgPackOptions = null!;
private AcJsonSerializerOptions _jsonOptions = null!;
[GlobalSetup]
public void Setup()
{
Console.WriteLine("Creating test data...");
_testOrder = TestDataFactory.CreateBenchmarkOrder(
itemCount: 2,
palletsPerItem: 2,
measurementsPerPallet: 2,
pointsPerMeasurement: 3);
_binaryOptions = AcBinarySerializerOptions.WithoutReferenceHandling;
_msgPackOptions = ContractlessStandardResolver.Options.WithCompression(MessagePackCompression.None);
_jsonOptions = AcJsonSerializerOptions.WithoutReferenceHandling;
_acBinaryData = AcBinarySerializer.Serialize(_testOrder, _binaryOptions);
_jsonData = AcJsonSerializer.Serialize(_testOrder, _jsonOptions);
// MessagePack serialization in try-catch to see if it fails
try
{
Console.WriteLine("Serializing MessagePack...");
_msgPackData = MessagePackSerializer.Serialize(_testOrder, _msgPackOptions);
Console.WriteLine($"MessagePack size: {_msgPackData.Length} bytes");
}
catch (Exception ex)
{
Console.WriteLine($"MessagePack serialization failed: {ex.Message}");
_msgPackData = Array.Empty<byte>();
}
// BSON serialization
try
{
Console.WriteLine("Serializing BSON...");
var bsonDoc = _testOrder.ToBsonDocument();
_bsonData = bsonDoc.ToBson();
Console.WriteLine($"BSON size: {_bsonData.Length} bytes");
}
catch (Exception ex)
{
Console.WriteLine($"BSON serialization failed: {ex.Message}");
_bsonData = Array.Empty<byte>();
}
var jsonBytes = Encoding.UTF8.GetByteCount(_jsonData);
Console.WriteLine($"\n=== SIZE COMPARISON ===");
Console.WriteLine($"AcBinary: {_acBinaryData.Length,8:N0} bytes ({100.0 * _acBinaryData.Length / jsonBytes:F1}%)");
Console.WriteLine($"MessagePack: {_msgPackData.Length,8:N0} bytes ({100.0 * _msgPackData.Length / jsonBytes:F1}%)");
Console.WriteLine($"BSON: {_bsonData.Length,8:N0} bytes ({100.0 * _bsonData.Length / jsonBytes:F1}%)");
Console.WriteLine($"JSON: {jsonBytes,8:N0} bytes (100.0%)");
}
[Benchmark(Description = "AcBinary Serialize")]
public byte[] Serialize_AcBinary() => AcBinarySerializer.Serialize(_testOrder, _binaryOptions);
[Benchmark(Description = "MessagePack Serialize", Baseline = true)]
public byte[] Serialize_MsgPack() => MessagePackSerializer.Serialize(_testOrder, _msgPackOptions);
[Benchmark(Description = "BSON Serialize")]
public byte[] Serialize_Bson() => _testOrder.ToBsonDocument().ToBson();
[Benchmark(Description = "AcBinary Deserialize")]
public TestOrder? Deserialize_AcBinary() => AcBinaryDeserializer.Deserialize<TestOrder>(_acBinaryData);
[Benchmark(Description = "MessagePack Deserialize")]
public TestOrder? Deserialize_MsgPack() => MessagePackSerializer.Deserialize<TestOrder>(_msgPackData, _msgPackOptions);
[Benchmark(Description = "BSON Deserialize")]
public TestOrder? Deserialize_Bson()
{
if (_bsonData == null || _bsonData.Length == 0) return null;
using var ms = new MemoryStream(_bsonData);
using var reader = new BsonBinaryReader(ms);
return BsonSerializer.Deserialize<TestOrder>(reader);
}
}
/// <summary>
/// Comprehensive AcBinary vs MessagePack comparison benchmark.
/// Tests: NoRef (everywhere), Populate, Serialize, Deserialize, Size
/// </summary>
[ShortRunJob]
[MemoryDiagnoser]
[RankColumn]
public class AcBinaryVsMessagePackFullBenchmark
{
// Test data
private TestOrder _testOrder = null!;
private TestOrder _populateTarget = null!;
// Serialized data - AcBinary
private byte[] _acBinaryWithRef = null!;
private byte[] _acBinaryNoRef = null!;
// Serialized data - MessagePack
private byte[] _msgPackData = null!;
private byte[] _bsonData = null!;
// Options
private AcBinarySerializerOptions _withRefOptions = null!;
private AcBinarySerializerOptions _noRefOptions = null!;
private MessagePackSerializerOptions _msgPackOptions = null!;
[GlobalSetup]
public void Setup()
{
// Create test data with shared references
TestDataFactory.ResetIdCounter();
var sharedTag = TestDataFactory.CreateTag("SharedTag");
var sharedUser = TestDataFactory.CreateUser("shareduser");
var sharedMeta = TestDataFactory.CreateMetadata("shared", withChild: true);
_testOrder = TestDataFactory.CreateOrder(
itemCount: 3,
palletsPerItem: 3,
measurementsPerPallet: 3,
pointsPerMeasurement: 4,
sharedTag: sharedTag,
sharedUser: sharedUser,
sharedMetadata: sharedMeta);
// Setup options - WithRef uses Default (which has reference handling), NoRef explicitly disables it
_withRefOptions = AcBinarySerializerOptions.Default;
_noRefOptions = AcBinarySerializerOptions.WithoutReferenceHandling;
_msgPackOptions = ContractlessStandardResolver.Options.WithCompression(MessagePackCompression.None);
// Serialize with different options
_acBinaryWithRef = AcBinarySerializer.Serialize(_testOrder, _withRefOptions);
_acBinaryNoRef = AcBinarySerializer.Serialize(_testOrder, _noRefOptions);
_msgPackData = MessagePackSerializer.Serialize(_testOrder, _msgPackOptions);
// BSON
try
{
_bsonData = _testOrder.ToBsonDocument().ToBson();
}
catch
{
_bsonData = Array.Empty<byte>();
}
// Create populate target
_populateTarget = new TestOrder { Id = _testOrder.Id };
foreach (var item in _testOrder.Items)
{
_populateTarget.Items.Add(new TestOrderItem { Id = item.Id });
}
// Print size comparison
PrintSizeComparison();
}
private void PrintSizeComparison()
{
Console.WriteLine("\n" + new string('=', 60));
Console.WriteLine("?? SIZE COMPARISON (AcBinary vs MessagePack vs BSON)");
Console.WriteLine(new string('=', 60));
Console.WriteLine($" AcBinary WithRef: {_acBinaryWithRef.Length,8:N0} bytes");
Console.WriteLine($" AcBinary NoRef: {_acBinaryNoRef.Length,8:N0} bytes");
Console.WriteLine($" MessagePack: {_msgPackData.Length,8:N0} bytes");
Console.WriteLine($" BSON: {_bsonData.Length,8:N0} bytes");
Console.WriteLine(new string('-', 60));
Console.WriteLine($" AcBinary/MsgPack: {100.0 * _acBinaryWithRef.Length / Math.Max(1, _msgPackData.Length):F1}% (WithRef)");
Console.WriteLine($" AcBinary/MsgPack: {100.0 * _acBinaryNoRef.Length / Math.Max(1, _msgPackData.Length):F1}% (NoRef)");
Console.WriteLine(new string('=', 60) + "\n");
}
#region Serialize Benchmarks
[Benchmark(Description = "AcBinary Serialize WithRef")]
public byte[] Serialize_AcBinary_WithRef() => AcBinarySerializer.Serialize(_testOrder, _withRefOptions);
[Benchmark(Description = "AcBinary Serialize NoRef")]
public byte[] Serialize_AcBinary_NoRef() => AcBinarySerializer.Serialize(_testOrder, _noRefOptions);
[Benchmark(Description = "MessagePack Serialize", Baseline = true)]
public byte[] Serialize_MsgPack() => MessagePackSerializer.Serialize(_testOrder, _msgPackOptions);
[Benchmark(Description = "BSON Serialize")]
public byte[] Serialize_Bson() => _testOrder.ToBsonDocument().ToBson();
#endregion
#region Deserialize Benchmarks
[Benchmark(Description = "AcBinary Deserialize WithRef")]
public TestOrder? Deserialize_AcBinary_WithRef() => AcBinaryDeserializer.Deserialize<TestOrder>(_acBinaryWithRef);
[Benchmark(Description = "AcBinary Deserialize NoRef")]
public TestOrder? Deserialize_AcBinary_NoRef() => AcBinaryDeserializer.Deserialize<TestOrder>(_acBinaryNoRef);
[Benchmark(Description = "MessagePack Deserialize")]
public TestOrder? Deserialize_MsgPack() => MessagePackSerializer.Deserialize<TestOrder>(_msgPackData, _msgPackOptions);
[Benchmark(Description = "BSON Deserialize")]
public TestOrder? Deserialize_Bson()
{
if (_bsonData == null || _bsonData.Length == 0) return null;
using var ms = new MemoryStream(_bsonData);
using var reader = new BsonBinaryReader(ms);
return BsonSerializer.Deserialize<TestOrder>(reader);
}
#endregion
#region Populate Benchmarks
[Benchmark(Description = "AcBinary Populate WithRef")]
public void Populate_AcBinary_WithRef()
{
var target = CreatePopulateTarget();
AcBinaryDeserializer.Populate(_acBinaryWithRef, target);
}
[Benchmark(Description = "AcBinary Populate NoRef")]
public void Populate_AcBinary_NoRef()
{
// Create fresh target each time to avoid state accumulation
var target = CreatePopulateTarget();
AcBinaryDeserializer.Populate(_acBinaryNoRef, target);
}
[Benchmark(Description = "AcBinary PopulateMerge WithRef")]
public void PopulateMerge_AcBinary_WithRef()
{
var target = CreatePopulateTarget();
AcBinaryDeserializer.PopulateMerge(_acBinaryWithRef.AsSpan(), target);
}
[Benchmark(Description = "AcBinary PopulateMerge NoRef")]
public void PopulateMerge_AcBinary_NoRef()
{
// Create fresh target each time to avoid state accumulation
var target = CreatePopulateTarget();
AcBinaryDeserializer.PopulateMerge(_acBinaryNoRef.AsSpan(), target);
}
private TestOrder CreatePopulateTarget()
{
var target = new TestOrder { Id = _testOrder.Id };
foreach (var item in _testOrder.Items)
{
target.Items.Add(new TestOrderItem { Id = item.Id });
}
return target;
}
#endregion
}
/// <summary>
/// Detailed size comparison - not a performance benchmark, just size output.
/// Now includes BSON size output and uses AcBinary without reference handling.
/// </summary>
[ShortRunJob]
[MemoryDiagnoser]
public class SizeComparisonBenchmark
{
private TestOrder _smallOrder = null!;
private TestOrder _mediumOrder = null!;
private TestOrder _largeOrder = null!;
private MessagePackSerializerOptions _msgPackOptions = null!;
private AcBinarySerializerOptions _withRefOptions = null!;
private AcBinarySerializerOptions _noRefOptions = null!;
[GlobalSetup]
public void Setup()
{
_msgPackOptions = ContractlessStandardResolver.Options.WithCompression(MessagePackCompression.None);
_withRefOptions = AcBinarySerializerOptions.WithoutReferenceHandling;
_noRefOptions = AcBinarySerializerOptions.WithoutReferenceHandling;
// Small order
TestDataFactory.ResetIdCounter();
_smallOrder = TestDataFactory.CreateOrder(itemCount: 1, palletsPerItem: 1, measurementsPerPallet: 1, pointsPerMeasurement: 2);
// Medium order
TestDataFactory.ResetIdCounter();
var sharedTag = TestDataFactory.CreateTag("Shared");
var sharedUser = TestDataFactory.CreateUser("shared");
_mediumOrder = TestDataFactory.CreateOrder(
itemCount: 3, palletsPerItem: 2, measurementsPerPallet: 2, pointsPerMeasurement: 3,
sharedTag: sharedTag, sharedUser: sharedUser);
// Large order
TestDataFactory.ResetIdCounter();
sharedTag = TestDataFactory.CreateTag("SharedLarge");
sharedUser = TestDataFactory.CreateUser("sharedlarge");
var sharedMeta = TestDataFactory.CreateMetadata("meta", withChild: true);
_largeOrder = TestDataFactory.CreateOrder(
itemCount: 5, palletsPerItem: 4, measurementsPerPallet: 3, pointsPerMeasurement: 5,
sharedTag: sharedTag, sharedUser: sharedUser, sharedMetadata: sharedMeta);
PrintDetailedSizeComparison();
}
private void PrintDetailedSizeComparison()
{
Console.WriteLine("\n" + new string('=', 80));
Console.WriteLine("?? DETAILED SIZE COMPARISON: AcBinary vs MessagePack vs BSON");
Console.WriteLine(new string('=', 80));
PrintOrderSize("Small Order (1x1x1x2)", _smallOrder);
PrintOrderSize("Medium Order (3x2x2x3) + SharedRefs", _mediumOrder);
PrintOrderSize("Large Order (5x4x3x5) + SharedRefs", _largeOrder);
Console.WriteLine(new string('=', 80) + "\n");
}
private void PrintOrderSize(string name, TestOrder order)
{
var acWithRef = AcBinarySerializer.Serialize(order, _withRefOptions);
var acNoRef = AcBinarySerializer.Serialize(order, _noRefOptions);
var msgPack = MessagePackSerializer.Serialize(order, _msgPackOptions);
byte[] bson;
try { bson = order.ToBsonDocument().ToBson(); } catch { bson = Array.Empty<byte>(); }
Console.WriteLine($"\n {name}:");
Console.WriteLine($" AcBinary WithRef: {acWithRef.Length,8:N0} bytes ({100.0 * acWithRef.Length / Math.Max(1, msgPack.Length),5:F1}% of MsgPack)");
Console.WriteLine($" AcBinary NoRef: {acNoRef.Length,8:N0} bytes ({100.0 * acNoRef.Length / Math.Max(1, msgPack.Length),5:F1}% of MsgPack)");
Console.WriteLine($" MessagePack: {msgPack.Length,8:N0} bytes (100.0%)");
Console.WriteLine($" BSON: {bson.Length,8:N0} bytes (compared to MsgPack)");
var withRefSaving = msgPack.Length - acWithRef.Length;
var noRefSaving = msgPack.Length - acNoRef.Length;
if (withRefSaving > 0)
Console.WriteLine($" ?? AcBinary WithRef saves: {withRefSaving:N0} bytes ({100.0 * withRefSaving / msgPack.Length:F1}%)");
else
Console.WriteLine($" ?? AcBinary WithRef larger by: {-withRefSaving:N0} bytes");
}
[Benchmark(Description = "Placeholder")]
public int Placeholder() => 1; // Just to make BenchmarkDotNet happy
}
public enum BinaryBenchmarkMode
{
Default,
NoReferenceHandling,
FastMode
}
public abstract class AcBinaryOptionsBenchmarkBase
{
protected TestOrder TestOrder = null!;
protected AcBinarySerializerOptions BinaryOptions = null!;
protected MessagePackSerializerOptions MsgPackOptions = null!;
protected byte[] AcBinaryData = null!;
protected byte[] MsgPackData = null!;
[Params(BinaryBenchmarkMode.Default, BinaryBenchmarkMode.NoReferenceHandling, BinaryBenchmarkMode.FastMode)]
public BinaryBenchmarkMode Mode { get; set; }
[GlobalSetup]
public void GlobalSetup()
{
TestDataFactory.ResetIdCounter();
TestOrder = TestDataFactory.CreateBenchmarkOrder(
itemCount: 4,
palletsPerItem: 3,
measurementsPerPallet: 3,
pointsPerMeasurement: 6);
BinaryOptions = CreateBinaryOptions(Mode);
MsgPackOptions = ContractlessStandardResolver.Options.WithCompression(MessagePackCompression.None);
AcBinaryData = AcBinarySerializer.Serialize(TestOrder, BinaryOptions);
MsgPackData = MessagePackSerializer.Serialize(TestOrder, MsgPackOptions);
var ratio = MsgPackData.Length == 0 ? 0 : 100.0 * AcBinaryData.Length / MsgPackData.Length;
Console.WriteLine($"[BenchmarkSetup] Mode={Mode} | AcBinary={AcBinaryData.Length} bytes | MessagePack={MsgPackData.Length} bytes | Ratio={ratio:F1}%");
}
private static AcBinarySerializerOptions CreateBinaryOptions(BinaryBenchmarkMode mode) => mode switch
{
BinaryBenchmarkMode.Default => new AcBinarySerializerOptions(),
BinaryBenchmarkMode.NoReferenceHandling => AcBinarySerializerOptions.WithoutReferenceHandling,
BinaryBenchmarkMode.FastMode => new AcBinarySerializerOptions
{
UseMetadata = false,
UseStringInterning = StringInterningMode.None,
ReferenceHandling = ReferenceHandlingMode.None,
},
_ => new AcBinarySerializerOptions()
};
}
[ShortRunJob]
[MemoryDiagnoser]
[RankColumn]
public class AcBinaryOptionsSerializeBenchmark : AcBinaryOptionsBenchmarkBase
{
[Benchmark(Description = "MessagePack Serialize", Baseline = true)]
public byte[] Serialize_MessagePack() => MessagePackSerializer.Serialize(TestOrder, MsgPackOptions);
[Benchmark(Description = "AcBinary Serialize")]
public byte[] Serialize_AcBinary() => AcBinarySerializer.Serialize(TestOrder, BinaryOptions);
}
[ShortRunJob]
[MemoryDiagnoser]
[RankColumn]
public class AcBinaryOptionsDeserializeBenchmark : AcBinaryOptionsBenchmarkBase
{
[Benchmark(Description = "MessagePack Deserialize", Baseline = true)]
public TestOrder? Deserialize_MessagePack() => MessagePackSerializer.Deserialize<TestOrder>(MsgPackData, MsgPackOptions);
[Benchmark(Description = "AcBinary Deserialize")]
public TestOrder? Deserialize_AcBinary() => AcBinaryDeserializer.Deserialize<TestOrder>(AcBinaryData);
}
/// <summary>
/// Large-scale benchmark simulating production workloads.
/// Tests with ~50,000+ IId objects with deep hierarchy and shared references.
/// This is closer to real-world scenarios with 2200 root items and 4-5MB binary data.
/// </summary>
[ShortRunJob]
[MemoryDiagnoser]
[RankColumn]
public class LargeScaleBinaryBenchmark
{
// Test data - smaller scale for benchmark (500 items ? 25K objects)
// Production would be 2200 items ? 100K+ objects
private TestOrder _testOrder = null!;
private TestOrder _populateTarget = null!;
// Serialized data
private byte[] _acBinaryData = null!;
private byte[] _msgPackData = null!;
// Options
private AcBinarySerializerOptions _binaryOptions = null!;
private MessagePackSerializerOptions _msgPackOptions = null!;
private int _objectCount;
[GlobalSetup]
public void Setup()
{
Console.WriteLine("Creating large-scale test data...");
// Use 500 root items for benchmark (?25K objects)
// Production would use 2200 (?100K+ objects)
const int rootItems = 500;
const int pallets = 3;
const int measurements = 3;
const int points = 4;
_objectCount = TestDataFactory.CalculateObjectCount(rootItems, pallets, measurements, points);
Console.WriteLine($"Creating ~{_objectCount:N0} IId objects...");
_testOrder = TestDataFactory.CreateLargeScaleBenchmarkOrder(rootItems, pallets, measurements, points);
Console.WriteLine($"Created order with {_testOrder.Items.Count} root items");
_binaryOptions = AcBinarySerializerOptions.WithoutReferenceHandling;
_msgPackOptions = ContractlessStandardResolver.Options.WithCompression(MessagePackCompression.None);
Console.WriteLine("Serializing AcBinary...");
_acBinaryData = AcBinarySerializer.Serialize(_testOrder, _binaryOptions);
Console.WriteLine("Serializing MessagePack...");
_msgPackData = MessagePackSerializer.Serialize(_testOrder, _msgPackOptions);
// Create populate target
_populateTarget = new TestOrder { Id = _testOrder.Id };
foreach (var item in _testOrder.Items.Take(10)) // Only first 10 for populate target
{
_populateTarget.Items.Add(new TestOrderItem { Id = item.Id });
}
PrintStats();
}
private void PrintStats()
{
Console.WriteLine("\n" + new string('=', 70));
Console.WriteLine("?? LARGE-SCALE BENCHMARK STATS");
Console.WriteLine(new string('=', 70));
Console.WriteLine($" Root Items: {_testOrder.Items.Count:N0}");
Console.WriteLine($" Total Objects: ~{_objectCount:N0} IId objects");
Console.WriteLine($" AcBinary Size: {_acBinaryData.Length:N0} bytes ({_acBinaryData.Length / 1024.0 / 1024.0:F2} MB)");
Console.WriteLine($" MsgPack Size: {_msgPackData.Length:N0} bytes ({_msgPackData.Length / 1024.0 / 1024.0:F2} MB)");
Console.WriteLine($" Size Ratio: {100.0 * _acBinaryData.Length / _msgPackData.Length:F1}% of MsgPack");
Console.WriteLine(new string('=', 70) + "\n");
}
[Benchmark(Description = "LargeScale AcBinary Deserialize")]
public TestOrder? Deserialize_AcBinary() => AcBinaryDeserializer.Deserialize<TestOrder>(_acBinaryData);
[Benchmark(Description = "LargeScale MsgPack Deserialize", Baseline = true)]
public TestOrder? Deserialize_MsgPack() => MessagePackSerializer.Deserialize<TestOrder>(_msgPackData, _msgPackOptions);
[Benchmark(Description = "LargeScale AcBinary Serialize")]
public byte[] Serialize_AcBinary() => AcBinarySerializer.Serialize(_testOrder, _binaryOptions);
[Benchmark(Description = "LargeScale MsgPack Serialize")]
public byte[] Serialize_MsgPack() => MessagePackSerializer.Serialize(_testOrder, _msgPackOptions);
}
/// <summary>
/// AcJson vs System.Text.Json comparison - measures Newtonsoft.Json based AcJson against modern STJ.
/// Uses simple flat object (PrimitiveTestClass) to avoid circular reference issues.
/// </summary>
[ShortRunJob]
[MemoryDiagnoser]
[RankColumn]
public class AcJsonVsSystemTextJsonBenchmark
{
private PrimitiveTestClass _testData = null!;
private string _acJsonData = null!;
private string _stjData = null!;
private AcJsonSerializerOptions _acJsonOptions = null!;
private JsonSerializerOptions _stjOptions = null!;
[GlobalSetup]
public void Setup()
{
Console.WriteLine("Creating test data for AcJson vs System.Text.Json...");
// Use simple flat object to avoid circular reference issues
_testData = TestDataFactory.CreatePrimitiveTestData();
// Setup options
_acJsonOptions = AcJsonSerializerOptions.WithoutReferenceHandling;
_stjOptions = new JsonSerializerOptions
{
WriteIndented = false,
DefaultIgnoreCondition = JsonIgnoreCondition.Never,
ReferenceHandler = null // No reference handling
};
// Pre-serialize
_acJsonData = AcJsonSerializer.Serialize(_testData, _acJsonOptions);
_stjData = JsonSerializer.Serialize(_testData, _stjOptions);
Console.WriteLine($"AcJson size: {_acJsonData.Length:N0} chars");
Console.WriteLine($"STJ size: {_stjData.Length:N0} chars");
Console.WriteLine($"Size ratio: {100.0 * _acJsonData.Length / _stjData.Length:F1}%");
}
[Benchmark(Description = "AcJson Serialize", Baseline = true)]
public string Serialize_AcJson() =>
AcJsonSerializer.Serialize(_testData, _acJsonOptions);
[Benchmark(Description = "System.Text.Json Serialize")]
public string Serialize_STJ() =>
JsonSerializer.Serialize(_testData, _stjOptions);
[Benchmark(Description = "AcJson Deserialize")]
public PrimitiveTestClass? Deserialize_AcJson() =>
AcJsonDeserializer.Deserialize<PrimitiveTestClass>(_acJsonData, _acJsonOptions);
[Benchmark(Description = "System.Text.Json Deserialize")]
public PrimitiveTestClass? Deserialize_STJ() =>
JsonSerializer.Deserialize<PrimitiveTestClass>(_stjData, _stjOptions);
}

View File

@ -107,18 +107,18 @@ public class SignalRCommunicationBenchmarks
[Benchmark(Description = "Server: Deserialize complex OrderItem")]
[BenchmarkCategory("Server", "Deserialize")]
public TestOrderItem Server_DeserializeComplexOrderItem()
public TestOrderItem_All_True Server_DeserializeComplexOrderItem()
{
var postMessage = MessagePackSerializer.Deserialize<SignalRPostMessageDto>(_complexOrderItemMessage, SignalRMessageFactory.ContractlessOptions);
return postMessage.PostDataJson!.JsonTo<TestOrderItem>()!;
return postMessage.PostDataJson!.JsonTo<TestOrderItem_All_True>()!;
}
[Benchmark(Description = "Server: Deserialize complex Order")]
[BenchmarkCategory("Server", "Deserialize")]
public TestOrder Server_DeserializeComplexOrder()
public TestOrder_All_True Server_DeserializeComplexOrder()
{
var postMessage = MessagePackSerializer.Deserialize<SignalRPostMessageDto>(_complexOrderMessage, SignalRMessageFactory.ContractlessOptions);
return postMessage.PostDataJson!.JsonTo<TestOrder>()!;
return postMessage.PostDataJson!.JsonTo<TestOrder_All_True>()!;
}
#endregion
@ -144,10 +144,10 @@ public class SignalRCommunicationBenchmarks
[Benchmark(Description = "Client: Deserialize complex Order response")]
[BenchmarkCategory("Client", "Response")]
public TestOrder? Client_DeserializeOrderResponse()
public TestOrder_All_True? Client_DeserializeOrderResponse()
{
var response = SignalRMessageFactory.DeserializeResponse(_complexResponseMessage);
return response?.ResponseData?.JsonTo<TestOrder>();
return response?.ResponseData?.JsonTo<TestOrder_All_True>();
}
#endregion
@ -171,19 +171,19 @@ public class SignalRCommunicationBenchmarks
[Benchmark(Description = "Full: Complex Order round-trip")]
[BenchmarkCategory("Full")]
public TestOrder? Full_ComplexOrderRoundTrip()
public TestOrder_All_True? Full_ComplexOrderRoundTrip()
{
// Client creates message
var requestBytes = SignalRMessageFactory.CreateComplexObjectMessage(_data.TestOrder);
// Server deserializes
var postMessage = MessagePackSerializer.Deserialize<SignalRPostMessageDto>(requestBytes, SignalRMessageFactory.ContractlessOptions);
var order = postMessage.PostDataJson!.JsonTo<TestOrder>()!;
var order = postMessage.PostDataJson!.JsonTo<TestOrder_All_True>()!;
// Server modifies and creates response
order.OrderNumber = "PROCESSED-" + order.OrderNumber;
var responseBytes = SignalRMessageFactory.CreateSuccessResponse(CommonSignalRTags.TestOrderParam, order);
// Client deserializes response
var response = SignalRMessageFactory.DeserializeResponse(responseBytes);
return response?.ResponseData?.JsonTo<TestOrder>();
return response?.ResponseData?.JsonTo<TestOrder_All_True>();
}
#endregion
}

View File

@ -24,9 +24,9 @@ public class SignalRRoundTripBenchmarks
private BenchmarkSignalRService _service = null!;
// Pre-created test data
private TestOrderItem _testOrderItem = null!;
private TestOrder _testOrder = null!;
private SharedTag _sharedTag = null!;
private TestOrderItem_All_True _testOrderItem = null!;
private TestOrder_All_True _testOrder = null!;
private SharedTag_All_True _sharedTag = null!;
private int[] _intArray = null!;
private List<string> _stringList = null!;
private Guid _testGuid;
@ -41,9 +41,9 @@ public class SignalRRoundTripBenchmarks
_hub.RegisterService(_service, _client);
// Pre-create test data
_testOrderItem = new TestOrderItem { Id = 1, ProductName = "Widget", Quantity = 5, UnitPrice = 10.50m };
_testOrderItem = new TestOrderItem_All_True { Id = 1, ProductName = "Widget", Quantity = 5, UnitPrice = 10.50m };
_testOrder = TestDataFactory.CreateOrder(itemCount: 3);
_sharedTag = new SharedTag { Id = 1, Name = "Important", Color = "#FF0000" };
_sharedTag = new SharedTag_All_True { Id = 1, Name = "Important", Color = "#FF0000" };
_intArray = [1, 2, 3, 4, 5];
_stringList = ["apple", "banana", "cherry"];
_testGuid = Guid.NewGuid();
@ -104,25 +104,25 @@ public class SignalRRoundTripBenchmarks
#region Complex Object Benchmarks
[Benchmark(Description = "RoundTrip: TestOrderItem")]
[Benchmark(Description = "RoundTrip: TestOrderItem_All_True")]
[BenchmarkCategory("Complex")]
public TestOrderItem? RoundTrip_TestOrderItem()
public TestOrderItem_All_True? RoundTrip_TestOrderItem()
{
return _client.PostDataSync<TestOrderItem, TestOrderItem>(BenchmarkSignalRTags.TestOrderItemParam, _testOrderItem);
return _client.PostDataSync<TestOrderItem_All_True, TestOrderItem_All_True>(BenchmarkSignalRTags.TestOrderItemParam, _testOrderItem);
}
[Benchmark(Description = "RoundTrip: TestOrder (3 items)")]
[Benchmark(Description = "RoundTrip: TestOrder_All_True (3 items)")]
[BenchmarkCategory("Complex")]
public TestOrder? RoundTrip_TestOrder()
public TestOrder_All_True? RoundTrip_TestOrder()
{
return _client.PostDataSync<TestOrder, TestOrder>(BenchmarkSignalRTags.TestOrderParam, _testOrder);
return _client.PostDataSync<TestOrder_All_True, TestOrder_All_True>(BenchmarkSignalRTags.TestOrderParam, _testOrder);
}
[Benchmark(Description = "RoundTrip: SharedTag")]
[Benchmark(Description = "RoundTrip: SharedTag_All_True")]
[BenchmarkCategory("Complex")]
public SharedTag? RoundTrip_SharedTag()
public SharedTag_All_True? RoundTrip_SharedTag()
{
return _client.PostDataSync<SharedTag, SharedTag>(BenchmarkSignalRTags.SharedTagParam, _sharedTag);
return _client.PostDataSync<SharedTag_All_True, SharedTag_All_True>(BenchmarkSignalRTags.SharedTagParam, _sharedTag);
}
#endregion
@ -212,16 +212,16 @@ public class BenchmarkSignalRClient : AcSignalRClientBase, IAcSignalRHubItemServ
public TResponse? GetAllSync<TResponse>(int tag)
=> GetAllAsync<TResponse>(tag).GetAwaiter().GetResult();
protected override Task MessageReceived(int messageTag, byte[] messageBytes) => Task.CompletedTask;
protected override Task MessageReceived(int messageTag, SignalParams signalParams, object data) => Task.CompletedTask;
protected override HubConnectionState GetConnectionState() => HubConnectionState.Connected;
protected override bool IsConnected() => true;
protected override Task StartConnectionInternal() => Task.CompletedTask;
protected override Task StopConnectionInternal() => Task.CompletedTask;
protected override ValueTask DisposeConnectionInternal() => ValueTask.CompletedTask;
protected override async Task SendToHubAsync(int messageTag, byte[]? messageBytes, int? requestId)
protected override async Task SendToHubAsync(int messageTag, int? requestId, SignalParams signalParams, object? data)
{
await _hub.OnReceiveMessage(messageTag, messageBytes, requestId);
await _hub.OnReceiveMessage(messageTag, requestId, signalParams, data ?? Array.Empty<byte>());
}
}
@ -247,8 +247,8 @@ public class BenchmarkSignalRHub : AcWebSignalRHubBase<BenchmarkSignalRTags, Tes
protected override string? GetUserIdentifier() => "benchmark-user";
protected override ClaimsPrincipal? GetUser() => null;
protected override Task ResponseToCaller(int messageTag, ISignalRMessage message, int? requestId)
=> SendMessageToClient(_callerClient, messageTag, message, requestId);
protected override Task ResponseToCaller(int messageTag, SignalResponseStatus status, object? responseData, int? requestId, SignalParams? clientSignalParams = null)
=> SendMessageToClient(_callerClient, messageTag, status, responseData, requestId, clientSignalParams);
}
/// <summary>
@ -278,7 +278,7 @@ public class BenchmarkSignalRService
public string HandleMultipleTypes(bool flag, string text, int number) => $"{flag}-{text}-{number}";
[SignalR(BenchmarkSignalRTags.TestOrderItemParam)]
public TestOrderItem HandleTestOrderItem(TestOrderItem item) => new()
public TestOrderItem_All_True HandleTestOrderItem(TestOrderItem_All_True item) => new()
{
Id = item.Id,
ProductName = $"Processed: {item.ProductName}",
@ -287,10 +287,10 @@ public class BenchmarkSignalRService
};
[SignalR(BenchmarkSignalRTags.TestOrderParam)]
public TestOrder HandleTestOrder(TestOrder order) => order;
public TestOrder_All_True HandleTestOrder(TestOrder_All_True order) => order;
[SignalR(BenchmarkSignalRTags.SharedTagParam)]
public SharedTag HandleSharedTag(SharedTag tag) => tag;
public SharedTag_All_True HandleSharedTag(SharedTag_All_True tag) => tag;
[SignalR(BenchmarkSignalRTags.IntArrayParam)]
public int[] HandleIntArray(int[] values) => values.Select(x => x * 2).ToArray();
@ -299,7 +299,7 @@ public class BenchmarkSignalRService
public List<string> HandleStringList(List<string> items) => items.Select(x => x.ToUpper()).ToList();
[SignalR(BenchmarkSignalRTags.IntAndDtoParam)]
public string HandleIntAndDto(int id, TestOrderItem item) => $"{id}-{item?.ProductName}";
public string HandleIntAndDto(int id, TestOrderItem_All_True item) => $"{id}-{item?.ProductName}";
[SignalR(BenchmarkSignalRTags.FiveParams)]
public string HandleFiveParams(int a, string b, bool c, Guid d, decimal e) => $"{a}-{b}-{c}-{d}-{e}";

View File

@ -0,0 +1,49 @@
using AyCode.Core.Serializers.Binaries;
using AyCode.Core.Tests.TestModels;
using System.Runtime.CompilerServices;
namespace AyCode.Core.Benchmarks.Workloads.Scenarios;
/// <summary>
/// AcBinary benchmark, Byte[] I/O mode. The headline AcBinary row in every cell — compared
/// against <see cref="MemoryPackBenchmark{T}"/> as the SOTA baseline.
/// </summary>
public sealed class AcBinaryBenchmark<T> : ISerializerBenchmark where T : class
{
private readonly T _order;
private readonly AcBinarySerializerOptions _options;
private readonly byte[] _serialized;
public BenchmarkEngine Engine => BenchmarkEngine.AcBinary;
public BenchmarkIoMode IoMode => BenchmarkIoMode.ByteArray;
public BenchmarkDispatchMode DispatchMode => _options.UseGeneratedCode ? BenchmarkDispatchMode.SGen : BenchmarkDispatchMode.Runtime;
public Type OrderType => typeof(T);
public string OptionsPreset { get; }
public int SerializedSize => _serialized.Length;
public long SetupSerializeAllocBytes => 0;
public long SetupDeserializeAllocBytes => 0;
public string OptionsDescription => BenchmarkOptions.BuildAcBinary(_options);
public AcBinaryBenchmark(T order, AcBinarySerializerOptions options, string optionsPreset)
{
_order = order;
_options = options;
OptionsPreset = optionsPreset;
_serialized = AcBinarySerializer.Serialize(order, options);
}
[MethodImpl(MethodImplOptions.NoInlining)]
public void Serialize() => AcBinarySerializer.Serialize(_order, _options);
[MethodImpl(MethodImplOptions.NoInlining)]
public void Deserialize() => AcBinaryDeserializer.Deserialize<T>(_serialized, _options);
public bool VerifyRoundTrip()
{
var bytes = AcBinarySerializer.Serialize(_order, _options);
var roundTripped = AcBinaryDeserializer.Deserialize<T>(bytes, _options);
return RoundTripValidator.DeepEqualsViaJson(_order, roundTripped);
}
}

View File

@ -0,0 +1,70 @@
using AyCode.Core.Serializers.Binaries;
using AyCode.Core.Tests.TestModels;
using System.Buffers;
using System.Runtime.CompilerServices;
namespace AyCode.Core.Benchmarks.Workloads.Scenarios;
/// <summary>
/// Benchmarks AcBinary via the IBufferWriter overload with a pre-allocated, reused ArrayBufferWriter.
/// Realistic IBufferWriter usage pattern: caller owns + reuses the writer (zero alloc per call after warmup).
/// </summary>
public sealed class AcBinaryBufferWriterBenchmark<T> : ISerializerBenchmark where T : class
{
private readonly T _order;
private readonly AcBinarySerializerOptions _options;
private readonly byte[] _serialized;
private readonly ArrayBufferWriter<byte> _bufferWriter;
public BenchmarkEngine Engine => BenchmarkEngine.AcBinary;
public BenchmarkIoMode IoMode => BenchmarkIoMode.BufWrReuse;
public BenchmarkDispatchMode DispatchMode => _options.UseGeneratedCode ? BenchmarkDispatchMode.SGen : BenchmarkDispatchMode.Runtime;
public Type OrderType => typeof(T);
public string OptionsPreset { get; }
public int SerializedSize => _serialized.Length;
public long SetupSerializeAllocBytes { get; }
public long SetupDeserializeAllocBytes => 0;
public string OptionsDescription => BenchmarkOptions.BuildAcBinary(_options);
public AcBinaryBufferWriterBenchmark(T order, AcBinarySerializerOptions options, string optionsPreset)
{
_order = order;
_options = options;
OptionsPreset = optionsPreset;
_serialized = AcBinarySerializer.Serialize(order, options);
// Measure ONLY the BufferWriter infrastructure setup on the serialize side (excluding the
// helper Serialize above). Deserialize side reads directly from `_serialized` byte[] — no
// dedicated setup allocation, hence SetupDeserializeAllocBytes = 0.
GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect();
var beforeSetup = GC.GetAllocatedBytesForCurrentThread();
_bufferWriter = new ArrayBufferWriter<byte>(_serialized.Length * 2);
var afterSetup = GC.GetAllocatedBytesForCurrentThread();
SetupSerializeAllocBytes = afterSetup - beforeSetup;
}
[MethodImpl(MethodImplOptions.NoInlining)]
public void Serialize()
{
_bufferWriter.ResetWrittenCount(); // reuse — no alloc, no zeroing
AcBinarySerializer.Serialize(_order, _bufferWriter, _options);
}
// BufWr semantic: read from a ReadOnlySequence<byte> (the ROS overload), NOT from byte[] —
// single-segment array-backed sequence triggers the fast-path in AcBinaryDeserializer.cs:298 which
// redirects to the byte[] overload. This means the bench actually exercises the ROS-input path
// (the production-realistic surface for SignalR / Pipe consumers) rather than secretly testing
// byte[] Deser under the BufWr label.
[MethodImpl(MethodImplOptions.NoInlining)]
public void Deserialize() => AcBinaryDeserializer.Deserialize<T>(new ReadOnlySequence<byte>(_serialized), _options);
public bool VerifyRoundTrip()
{
_bufferWriter.ResetWrittenCount();
AcBinarySerializer.Serialize(_order, _bufferWriter, _options);
var roundTripped = AcBinaryDeserializer.Deserialize<T>(new ReadOnlySequence<byte>(_bufferWriter.WrittenMemory), _options);
return RoundTripValidator.DeepEqualsViaJson(_order, roundTripped);
}
}

View File

@ -0,0 +1,64 @@
using AyCode.Core.Serializers.Binaries;
using AyCode.Core.Tests.TestModels;
using System.Buffers;
using System.Runtime.CompilerServices;
namespace AyCode.Core.Benchmarks.Workloads.Scenarios;
/// <summary>
/// Benchmarks AcBinary via the IBufferWriter overload, allocating a FRESH ArrayBufferWriter on EVERY call.
/// One-shot scenario — represents code that doesn't reuse a writer across calls.
/// Uses BufferWriterChunkSize=4096 (production-realistic, SignalR-aligned) instead of the 65535 default —
/// otherwise AcBinary would request 64KB upfront via GetSpan(), forcing the fresh ABW to allocate 64KB
/// regardless of payload size (heavy over-allocation for small payloads).
/// </summary>
public sealed class AcBinaryFreshBufferWriterBenchmark<T> : ISerializerBenchmark where T : class
{
private readonly T _order;
private readonly AcBinarySerializerOptions _options;
private readonly byte[] _serialized;
public BenchmarkEngine Engine => BenchmarkEngine.AcBinary;
public BenchmarkIoMode IoMode => BenchmarkIoMode.BufWrNew;
public BenchmarkDispatchMode DispatchMode => _options.UseGeneratedCode ? BenchmarkDispatchMode.SGen : BenchmarkDispatchMode.Runtime;
public Type OrderType => typeof(T);
public string OptionsPreset { get; }
public int SerializedSize => _serialized.Length;
public long SetupSerializeAllocBytes => 0;
public long SetupDeserializeAllocBytes => 0;
public string OptionsDescription => BenchmarkOptions.BuildAcBinary(_options, $", BufferSize={_options.BufferWriterChunkSize}B");
public AcBinaryFreshBufferWriterBenchmark(T order, AcBinarySerializerOptions options, string optionsPreset)
{
_order = order;
// BufferWriterChunkSize comes from the caller (central source of truth in CreateSerializers
// — the binaryFastMode4KbChunk options instance). Do NOT mutate _options here; tune the chunk
// size in CreateSerializers only.
_options = options;
OptionsPreset = optionsPreset;
_serialized = AcBinarySerializer.Serialize(order, _options);
}
[MethodImpl(MethodImplOptions.NoInlining)]
public void Serialize()
{
var abw = new ArrayBufferWriter<byte>(); // FRESH every call — alloc + grow as needed
AcBinarySerializer.Serialize(_order, abw, _options);
}
// BufWr semantic: read from a ReadOnlySequence<byte> (the ROS overload), NOT from byte[] —
// single-segment array-backed sequence triggers the fast-path in AcBinaryDeserializer.cs:298 which
// redirects to the byte[] overload. This means the bench actually exercises the ROS-input path
// (the production-realistic surface for SignalR / Pipe consumers) rather than secretly testing
// byte[] Deser under the BufWr label.
[MethodImpl(MethodImplOptions.NoInlining)]
public void Deserialize() => AcBinaryDeserializer.Deserialize<T>(new ReadOnlySequence<byte>(_serialized), _options);
public bool VerifyRoundTrip()
{
var abw = new ArrayBufferWriter<byte>();
AcBinarySerializer.Serialize(_order, abw, _options);
var roundTripped = AcBinaryDeserializer.Deserialize<T>(new ReadOnlySequence<byte>(abw.WrittenMemory), _options);
return RoundTripValidator.DeepEqualsViaJson(_order, roundTripped);
}
}

View File

@ -0,0 +1,191 @@
using AyCode.Core.Serializers.Binaries;
using AyCode.Core.Tests.Serialization; // DrainFromAsync extension (test-only, used by benchmark)
using AyCode.Core.Tests.TestModels;
using System.IO.Pipelines;
using System.Runtime.CompilerServices;
namespace AyCode.Core.Benchmarks.Workloads.Scenarios;
/// <summary>
/// Same chunked-framed AsyncPipe code path as <see cref="AcBinaryNamedPipeBenchmark{T}"/>, but the transport
/// is an in-memory <see cref="System.IO.Pipelines.Pipe"/> instead of a kernel <c>NamedPipe</c>. The Pipe's
/// <c>Writer</c>/<c>Reader</c> pair is a managed-only zero-copy slab handoff — no syscalls, no kernel
/// buffer copy, no IRP queueing.
///
/// <para><b>Why this benchmark matters</b>: by holding ALL other variables constant (same SerializeChunkedFramed,
/// same AsyncPipeReaderInput, same drain task, same consumer task, same multi-message wire format), this
/// row isolates the <b>kernel-NamedPipe transport overhead</b> from the chunked-streaming framework's pure
/// CPU cost. The expected delta vs <see cref="AcBinaryNamedPipeBenchmark{T}"/>: per-chunk overhead drops from
/// ~25-30 µs (kernel-syscall pair + IRP) to ~1-2 µs (managed slab handoff). Multi-chunk Large-message rows
/// should converge dramatically toward <see cref="AcBinaryNamedPipeRawByteArrayBenchmark{T}"/>.</para>
///
/// <para><b>Real-world relevance</b>: in-memory Pipe is the typical primitive used for cross-thread serializer
/// pipelines inside a single process (e.g. SignalR's Kestrel transport adapter, gRPC framework internals,
/// custom message brokers). The numbers from this row reflect that scenario, NOT the kernel-pipe loopback
/// of the NamedPipe benchmark.</para>
/// </summary>
public sealed class AcBinaryInMemoryPipeBenchmark<T> : ISerializerBenchmark, IDisposable where T : class
{
private readonly T _order;
private readonly AcBinarySerializerOptions _options;
private readonly byte[] _serialized; // for SerializedSize reporting only
// Long-lived in-memory pipe lifecycle (set up once in ctor — NOT timed).
private readonly Pipe _pipe;
private readonly PipeWriter _pipeWriter;
private readonly PipeReader _pipeReader;
// Long-lived multi-message receive infrastructure (set up once in ctor) — same pattern as the NamedPipe
// variant: drain pumps reader into AsyncPipeReaderInput, consumer task drives Deserialize<T>(input).
private readonly AsyncPipeReaderInput _input;
private readonly CancellationTokenSource _cts;
private readonly Task _drainTask;
private readonly Task _consumerTask;
private readonly ManualResetEventSlim _consumeRequest = new(false);
private readonly ManualResetEventSlim _consumeDone = new(false);
private object? _lastResult;
private bool _captureResult;
private bool _disposed;
public BenchmarkEngine Engine => BenchmarkEngine.AcBinary;
public BenchmarkIoMode IoMode => BenchmarkIoMode.InMemoryPipe;
public BenchmarkDispatchMode DispatchMode => _options.UseGeneratedCode ? BenchmarkDispatchMode.SGen : BenchmarkDispatchMode.Runtime;
public Type OrderType => typeof(T);
public string OptionsPreset { get; }
public int SerializedSize => _serialized.Length;
public long SetupSerializeAllocBytes { get; }
public long SetupDeserializeAllocBytes { get; }
public bool IsRoundTripOnly => true;
public string OptionsDescription => BenchmarkOptions.BuildAcBinary(_options, $", BufferSize={_options.BufferWriterChunkSize}B, Transport=Pipe(in-memory,multiMessage,2-task)");
public AcBinaryInMemoryPipeBenchmark(T order, AcBinarySerializerOptions options, string optionsPreset)
{
_order = order;
_options = options;
OptionsPreset = optionsPreset;
_serialized = AcBinarySerializer.Serialize(order, _options);
// === SERIALIZE-side setup measurement ===
// In-memory Pipe construction. NO kernel-pipe pair, NO Connect handshake — just a managed Pipe object
// and a reference to its Writer side. PipeWriterImpl (parallel-flush capable, NOT StreamPipeWriter).
GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect();
var beforeSer = GC.GetAllocatedBytesForCurrentThread();
_pipe = new Pipe();
_pipeWriter = _pipe.Writer;
var afterSer = GC.GetAllocatedBytesForCurrentThread();
SetupSerializeAllocBytes = afterSer - beforeSer;
// === DESERIALIZE-side setup measurement ===
// PipeReader reference + AsyncPipeReaderInput (ArrayPool rent + ManualResetEventSlim) + drain task +
// consumer task scaffolding. Identical to the NamedPipe variant on the receive side.
GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect();
var beforeDes = GC.GetAllocatedBytesForCurrentThread();
_pipeReader = _pipe.Reader;
_input = new AsyncPipeReaderInput(_options.BufferWriterChunkSize * 2, multiMessage: true);
_cts = new CancellationTokenSource();
_drainTask = Task.Run(() => _input.DrainFromAsync(_pipeReader, _cts.Token));
_consumerTask = Task.Run(ConsumeLoop);
var afterDes = GC.GetAllocatedBytesForCurrentThread();
SetupDeserializeAllocBytes = afterDes - beforeDes;
}
// BG consumer: parks on _consumeRequest, runs Deserialize<T>(_input) when signaled, signals _consumeDone.
// Mirror of AcBinaryNamedPipeBenchmark.ConsumeLoop — same pattern, same MRES protocol.
private void ConsumeLoop()
{
var ct = _cts.Token;
try
{
while (true)
{
_consumeRequest.Wait(ct);
if (ct.IsCancellationRequested) return;
_consumeRequest.Reset();
try
{
var result = AcBinaryDeserializer.Deserialize<T>(_input, _options);
if (_captureResult) _lastResult = result;
}
catch
{
// Swallow — see ConsumeLoop in NamedPipe variant for rationale.
}
finally
{
_consumeDone.Set();
}
}
}
catch (OperationCanceledException)
{
// Cooperative cancel — Dispose path. Swallow.
}
}
[MethodImpl(MethodImplOptions.NoInlining)]
public void Serialize()
{
// Same 2-task streaming pipeline as NamedPipe variant — only the transport differs (in-memory Pipe
// instead of kernel NamedPipe). Per-chunk SerializeChunkedFramed → PipeWriter slab → drain task
// reads from PipeReader → input.Feed → consumer Deserialize<T> consumes byte-by-byte.
//
// Uses the Pipe-overload (instead of the PipeWriter-overload) so the FlushPolicy parameter is
// exposed for tuning. Toggle between FlushPolicy.PerChunk (bounded peak memory, per-chunk await
// FlushAsync) and FlushPolicy.Coalesced (fire-and-forget per chunk, pipe-coalesced flushes up to
// PauseWriterThreshold ~64 KB) to A/B-test the streaming-pipeline overhead. FlushPolicy.PerChunk
// is functionally equivalent to the PipeWriter-overload (both internally route to
// SerializeToPipeWriterCore with FlushPolicy.PerChunk).
_consumeDone.Reset();
_consumeRequest.Set();
AcBinarySerializer.SerializeChunkedFramed(_order, _pipe, _options, FlushPolicy.Coalesced);
_consumeDone.Wait();
}
[MethodImpl(MethodImplOptions.NoInlining)]
public void Deserialize()
{
// No-op: per-iter round-trip is captured in Serialize(). See IsRoundTripOnly contract.
}
public bool VerifyRoundTrip()
{
_captureResult = true;
try
{
Serialize();
var result = _lastResult as T;
return result != null && RoundTripValidator.DeepEqualsViaJson(_order, result);
}
finally
{
_captureResult = false;
_lastResult = null;
}
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
// Cancel drain + consumer tasks → both exit. Pulse _consumeRequest in case consumer is parked.
try { _cts.Cancel(); } catch { /* swallow on teardown */ }
try { _consumeRequest.Set(); } catch { /* nudge in case consumer Wait is parked */ }
try { _drainTask.Wait(TimeSpan.FromSeconds(2)); } catch { /* swallow on teardown */ }
try { _consumerTask.Wait(TimeSpan.FromSeconds(2)); } catch { /* swallow on teardown */ }
// Complete writer + reader (in-memory Pipe — no underlying stream to dispose).
try { _pipeWriter.CompleteAsync().AsTask().Wait(TimeSpan.FromSeconds(2)); } catch { /* swallow on teardown */ }
try { _pipeReader.Complete(); } catch { /* swallow on teardown */ }
try { _input.Dispose(); } catch { /* swallow on teardown */ }
try { _consumeRequest.Dispose(); } catch { /* swallow on teardown */ }
try { _consumeDone.Dispose(); } catch { /* swallow on teardown */ }
try { _cts.Dispose(); } catch { /* swallow on teardown */ }
}
}

View File

@ -0,0 +1,169 @@
using AyCode.Core.Serializers.Binaries;
using AyCode.Core.Tests.TestModels;
using System.Runtime.CompilerServices;
namespace AyCode.Core.Benchmarks.Workloads.Scenarios;
/// <summary>
/// Raw <c>byte[]</c> over an in-memory cross-thread handoff — NO transport (no NamedPipe, no Pipe, no
/// Channel<see langword="&lt;T&gt;"/>). Calling thread serialises into a fresh <c>byte[]</c>, hands it to a
/// background consumer task via a single byte[] slot + MRES pair; the consumer deserialises and signals done.
///
/// <para><b>Why this benchmark matters</b>: completes the 2x2 transport × wire-format matrix:</para>
/// <list type="bullet">
/// <item><description><b>NamedPipe + Chunked</b> = <see cref="AcBinaryNamedPipeBenchmark{T}"/></description></item>
/// <item><description><b>NamedPipe + Raw</b> = <see cref="AcBinaryNamedPipeRawByteArrayBenchmark{T}"/></description></item>
/// <item><description><b>In-memory Pipe + Chunked</b> = <see cref="AcBinaryInMemoryPipeBenchmark{T}"/></description></item>
/// <item><description><b>In-memory + Raw</b> = THIS row — apples-to-apples baseline for the in-memory chunked row</description></item>
/// </list>
/// <para>Side-by-side with <see cref="AcBinaryInMemoryPipeBenchmark{T}"/> this isolates the chunked-streaming
/// framework's pure CPU cost, with the same in-memory transport (zero kernel involvement) on both sides.
/// Side-by-side with <see cref="AcBinaryNamedPipeRawByteArrayBenchmark{T}"/> this isolates the kernel-NamedPipe
/// overhead on the raw-byte[] side.</para>
/// </summary>
public sealed class AcBinaryInMemoryRawByteArrayBenchmark<T> : ISerializerBenchmark, IDisposable where T : class
{
private readonly T _order;
private readonly AcBinarySerializerOptions _options;
private readonly byte[] _serialized; // for SerializedSize reporting only
// Long-lived consumer-task infrastructure (Deserialize on BG thread, signaled per iter).
// No transport — just a byte[] slot for handoff between calling thread and consumer task.
private readonly CancellationTokenSource _cts;
private readonly Task _consumerTask;
private readonly ManualResetEventSlim _consumeRequest = new(false);
private readonly ManualResetEventSlim _consumeDone = new(false);
private byte[]? _pendingBytes; // calling thread → consumer task handoff slot
private object? _lastResult; // captured during VerifyRoundTrip; null in benchmark iters
private bool _captureResult;
private bool _disposed;
public BenchmarkEngine Engine => BenchmarkEngine.AcBinary;
public BenchmarkIoMode IoMode => BenchmarkIoMode.InMemoryRaw;
public BenchmarkDispatchMode DispatchMode => _options.UseGeneratedCode ? BenchmarkDispatchMode.SGen : BenchmarkDispatchMode.Runtime;
public Type OrderType => typeof(T);
public string OptionsPreset { get; }
public int SerializedSize => _serialized.Length;
public long SetupSerializeAllocBytes { get; }
public long SetupDeserializeAllocBytes { get; }
public bool IsRoundTripOnly => true;
public string OptionsDescription => BenchmarkOptions.BuildAcBinary(_options, $", BufferSize={_options.BufferWriterChunkSize}B, Transport=in-memory(raw,2-task)");
public AcBinaryInMemoryRawByteArrayBenchmark(T order, AcBinarySerializerOptions options, string optionsPreset)
{
_order = order;
_options = options;
OptionsPreset = optionsPreset;
_serialized = AcBinarySerializer.Serialize(order, _options);
// === SERIALIZE-side setup measurement ===
// Nothing to set up — calling thread allocates byte[] per iter via AcBinarySerializer.Serialize.
SetupSerializeAllocBytes = 0;
// === DESERIALIZE-side setup measurement ===
// 1× background consumer-task + 2× MRES (request / done) + cancellation source.
GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect();
var beforeDes = GC.GetAllocatedBytesForCurrentThread();
_cts = new CancellationTokenSource();
_consumerTask = Task.Run(ConsumerLoop);
var afterDes = GC.GetAllocatedBytesForCurrentThread();
SetupDeserializeAllocBytes = afterDes - beforeDes;
}
// BG consumer: parks on _consumeRequest, picks up the byte[] from _pendingBytes, runs Deserialize<T>(bytes),
// signals _consumeDone. Direct in-process handoff — no transport syscall, no buffer copy beyond the byte[]
// reference itself (zero-copy by reference).
private void ConsumerLoop()
{
var ct = _cts.Token;
try
{
while (true)
{
_consumeRequest.Wait(ct);
if (ct.IsCancellationRequested) return;
_consumeRequest.Reset();
try
{
var bytes = _pendingBytes;
if (bytes != null)
{
var result = AcBinaryDeserializer.Deserialize<T>(bytes, _options);
if (_captureResult) _lastResult = result;
}
}
catch
{
// Swallow — see ConsumerLoop in NamedPipe variant for rationale.
}
finally
{
_consumeDone.Set();
}
}
}
catch (OperationCanceledException)
{
// Cooperative cancel — Dispose path. Swallow.
}
}
[MethodImpl(MethodImplOptions.NoInlining)]
public void Serialize()
{
// 2-task in-memory pipeline:
// 1. Calling thread serialises → fresh byte[] (per-iter alloc, matches AcBinaryBenchmark contract).
// 2. Calling thread parks the byte[] into _pendingBytes and signals consumer task. Consumer task
// picks up the reference (zero-copy) and runs Deserialize<T>(bytes).
// 3. Calling thread waits for _consumeDone (consumer task finished Des).
//
// Same architectural limitation as the NamedPipe-raw variant: Des cannot start until full bytes
// are available. Only the per-iter Ser↔Des thread-handoff overlaps slightly (calling thread starts
// signalling and waiting while consumer thread takes the byte[]).
var bytes = AcBinarySerializer.Serialize(_order, _options);
_pendingBytes = bytes;
_consumeDone.Reset();
_consumeRequest.Set();
_consumeDone.Wait();
}
[MethodImpl(MethodImplOptions.NoInlining)]
public void Deserialize()
{
// No-op: per-iter round-trip is captured in Serialize(). See IsRoundTripOnly contract.
}
public bool VerifyRoundTrip()
{
_captureResult = true;
try
{
Serialize();
var result = _lastResult as T;
return result != null && RoundTripValidator.DeepEqualsViaJson(_order, result);
}
finally
{
_captureResult = false;
_lastResult = null;
}
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
try { _cts.Cancel(); } catch { /* swallow on teardown */ }
try { _consumeRequest.Set(); } catch { /* nudge in case consumer Wait is parked */ }
try { _consumerTask.Wait(TimeSpan.FromSeconds(2)); } catch { /* swallow on teardown */ }
try { _consumeRequest.Dispose(); } catch { /* swallow on teardown */ }
try { _consumeDone.Dispose(); } catch { /* swallow on teardown */ }
try { _cts.Dispose(); } catch { /* swallow on teardown */ }
}
}

View File

@ -0,0 +1,238 @@
using AyCode.Core.Serializers.Binaries;
using AyCode.Core.Tests.Serialization; // DrainFromAsync extension (test-only, used by benchmark)
using AyCode.Core.Tests.TestModels;
using System.IO.Pipelines;
using System.IO.Pipes;
using System.Runtime.CompilerServices;
namespace AyCode.Core.Benchmarks.Workloads.Scenarios;
/// <summary>
/// Benchmarks AcBinary over a long-lived NamedPipe IPC connection using the AcBinary native streaming API
/// (<see cref="AcBinarySerializer.SerializeChunked{T}(T, System.IO.Pipelines.PipeWriter, AcBinarySerializerOptions)"/>
/// + <see cref="AsyncPipeReaderInput"/> + <see cref="AsyncPipeReaderInputExtensions.DrainFromAsync"/>).
/// Mirrors what a real consumer (e.g. <c>DeserializeFromPipeReaderAsync</c>) does per message:
/// long-lived <see cref="AsyncPipeReaderInput"/> with multi-message wire framing on top of a long-lived NamedPipe.
///
/// <para><b>Architecture</b>:</para>
/// <list type="bullet">
/// <item>Constructor (NOT timed): sets up <see cref="NamedPipeServerStream"/> + <see cref="NamedPipeClientStream"/>,
/// waits for connection, creates one long-lived <see cref="System.IO.Pipelines.PipeWriter"/> /
/// <see cref="System.IO.Pipelines.PipeReader"/> pair, ONE long-lived <see cref="AsyncPipeReaderInput"/>
/// in <c>multiMessage = true</c> mode, ONE drain Task that pumps <see cref="AsyncPipeReaderInputExtensions.DrainFromAsync"/>
/// forever, and ONE deserialize Task that loops <c>AcBinaryDeserializer.Deserialize&lt;T&gt;(input, opts)</c>
/// producing into a <see cref="System.Threading.Channels.Channel{T}"/>.</item>
/// <item>Per-iteration <see cref="Serialize"/> (timed): sender writes via
/// <see cref="AcBinarySerializer.SerializeChunkedFramed{T}(T, System.IO.Pipelines.PipeWriter, AcBinarySerializerOptions)"/>
/// — multi-message wire (<c>[201][UINT16][data]...[202]</c>); the <c>[202]</c> end marker arms the input's
/// <c>_readPos = -1</c> sentinel, so the next message's first <c>AppendToBuffer</c> recycles the buffer to 0.
/// Then receiver awaits the channel for the deserialized result.</item>
/// <item><see cref="Deserialize"/> is a no-op (full round-trip captured in <see cref="Serialize"/>);
/// <see cref="IsRoundTripOnly"/>=true → Ser ms / SerAlloc oszlopok N/A, RT ms = full round-trip.</item>
/// </list>
///
/// <para><b>Per-iter overhead</b>: 0 new <c>Task.Run</c>, 0 new <c>AsyncPipeReaderInput</c>, 0 new <c>CancellationTokenSource</c>.
/// Pure cost = <c>SerializeChunkedFramed</c> (CPU + chunk-onkénti flush) + kernel write/read syscalls + 1 sync barrier
/// (channel) + deserialized graph alloc. The "multi-message reuse" pattern enabled by Q4T8 fix (R5K2 minimum: <c>_readPos = -1</c>
/// sentinel + <c>AppendToBuffer</c> sliding-window cycling).</para>
///
/// <para><b>Approximation note</b>: single-process loopback NamedPipe. Real cross-process / cross-machine SignalR
/// adds further transport latency (TCP, WebSocket framing) on top. The benchmark gives a lower bound.</para>
/// </summary>
public sealed class AcBinaryNamedPipeBenchmark<T> : ISerializerBenchmark, IDisposable where T : class
{
private readonly T _order;
private readonly AcBinarySerializerOptions _options;
private readonly byte[] _serialized; // for SerializedSize reporting only
// Long-lived pipe lifecycle (set up once in ctor — NOT timed).
private readonly NamedPipeServerStream _pipeServer;
private readonly NamedPipeClientStream _pipeClient;
private readonly PipeWriter _pipeWriter;
private readonly PipeReader _pipeReader;
// Long-lived multi-message receive infrastructure (set up once in ctor).
private readonly AsyncPipeReaderInput _input;
private readonly CancellationTokenSource _cts;
private readonly Task _drainTask; // BG: PipeReader → input.Feed (continuous pump)
private readonly Task _consumerTask; // BG: per-iter Deserialize<T>(input) loop, signaled by calling thread
private readonly ManualResetEventSlim _consumeRequest = new(false);
private readonly ManualResetEventSlim _consumeDone = new(false);
private object? _lastResult; // captured during VerifyRoundTrip; null in benchmark iters
private bool _captureResult; // toggle: when true, ConsumeLoop stores result; otherwise discards
private bool _disposed;
public BenchmarkEngine Engine => BenchmarkEngine.AcBinary;
public BenchmarkIoMode IoMode => BenchmarkIoMode.NamedPipe;
public BenchmarkDispatchMode DispatchMode => _options.UseGeneratedCode ? BenchmarkDispatchMode.SGen : BenchmarkDispatchMode.Runtime;
public Type OrderType => typeof(T);
public string OptionsPreset { get; }
public int SerializedSize => _serialized.Length;
public long SetupSerializeAllocBytes { get; }
public long SetupDeserializeAllocBytes { get; }
public bool IsRoundTripOnly => true;
public string OptionsDescription => BenchmarkOptions.BuildAcBinary(_options, $", BufferSize={_options.BufferWriterChunkSize}B, Transport=NamedPipe(long-lived,multiMessage,2-task)");
public AcBinaryNamedPipeBenchmark(T order, AcBinarySerializerOptions options, string optionsPreset)
{
_order = order;
// BufferWriterChunkSize comes from the caller (central source of truth in CreateSerializers
// — the binaryFastMode4KbChunk options instance). Do NOT mutate _options here; tune the chunk
// size in CreateSerializers only.
_options = options;
OptionsPreset = optionsPreset;
_serialized = AcBinarySerializer.Serialize(order, _options);
// 1× pipe setup. Kernel-side pipe buffer (inBufferSize / outBufferSize on the server ctor — the
// client inherits the server-defined buffer size at connect time) matches BufferWriterChunkSize
// exactly: AsyncPipeWriterOutput now treats chunkSize as the chunk-on-wire total size (header +
// data), so one WriteFile(chunkSize) syscall lands in exactly one kernel-page slot — page-aligned,
// no fragmentation, no IRP reordering. _options.BufferWriterChunkSize is the single tunable source.
var pipeName = $"AcBinaryBench-{Guid.NewGuid():N}";
// === SERIALIZE-side setup measurement ===
// pipe-pair (server + client) + connect handshake + writer-side PipeWriter wrapper.
GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect();
var beforeSer = GC.GetAllocatedBytesForCurrentThread();
_pipeServer = new NamedPipeServerStream(pipeName, PipeDirection.In, 1, PipeTransmissionMode.Byte,
System.IO.Pipes.PipeOptions.Asynchronous,
inBufferSize: _options.BufferWriterChunkSize,
outBufferSize: _options.BufferWriterChunkSize);
_pipeClient = new NamedPipeClientStream(".", pipeName, PipeDirection.Out, System.IO.Pipes.PipeOptions.Asynchronous);
var serverWait = _pipeServer.WaitForConnectionAsync();
_pipeClient.Connect();
serverWait.GetAwaiter().GetResult();
_pipeWriter = PipeWriter.Create(_pipeClient);
var afterSer = GC.GetAllocatedBytesForCurrentThread();
SetupSerializeAllocBytes = afterSer - beforeSer;
// === DESERIALIZE-side setup measurement ===
// PipeReader wrapper + AsyncPipeReaderInput (ArrayPool rent + ManualResetEventSlim) + drain
// task + consumer task scaffolding. Two long-lived BG tasks total: drain pumps bytes from the
// kernel pipe into input; consumer drives Deserialize<T>(input) per iter on signal.
GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect();
var beforeDes = GC.GetAllocatedBytesForCurrentThread();
_pipeReader = PipeReader.Create(_pipeServer);
_input = new AsyncPipeReaderInput(_options.BufferWriterChunkSize * 2, multiMessage: true);
_cts = new CancellationTokenSource();
// Drain task: pumps PipeReader → input.Feed forever (or until cancel). Single Task.Run for
// the full benchmark lifetime — its overhead is amortised across all messages.
_drainTask = Task.Run(() => _input.DrainFromAsync(_pipeReader, _cts.Token));
// Consumer task: per-iter Deserialize<T>(input) loop. Started here once; signaled per-iter via
// _consumeRequest. Enables Ser↔Des streaming overlap — calling thread runs SerializeChunkedFramed
// while THIS task simultaneously runs Deserialize<T>, both consuming/producing through the
// sliding-window buffer pipelined by the drain task.
_consumerTask = Task.Run(ConsumeLoop);
var afterDes = GC.GetAllocatedBytesForCurrentThread();
SetupDeserializeAllocBytes = afterDes - beforeDes;
}
// BG consumer: parks on _consumeRequest, runs Deserialize<T>(_input) when signaled, signals _consumeDone.
// The Deserialize call internally blocks on the input's MRES whenever the drain hasn't yet fed enough
// bytes for the next read — that's where the streaming-pipeline overlap with the calling thread (Ser)
// happens.
private void ConsumeLoop()
{
var ct = _cts.Token;
try
{
while (true)
{
_consumeRequest.Wait(ct);
if (ct.IsCancellationRequested) return;
_consumeRequest.Reset();
try
{
var result = AcBinaryDeserializer.Deserialize<T>(_input, _options);
if (_captureResult) _lastResult = result;
}
catch
{
// Swallow — calling thread sees the failure via missing/incorrect _lastResult during VerifyRoundTrip,
// or the benchmark loop just continues (timing impacted). Production teardown handled in Dispose.
}
finally
{
_consumeDone.Set();
}
}
}
catch (OperationCanceledException)
{
// Cooperative cancel — Dispose path. Swallow.
}
}
[MethodImpl(MethodImplOptions.NoInlining)]
public void Serialize()
{
// 2-task streaming pipeline:
// 1. Calling thread signals consumer task to begin Deserialize<T>(input). Consumer immediately
// starts; first read blocks on input's MRES because no bytes flowed yet.
// 2. Calling thread starts SerializeChunkedFramed → chunks flow through PipeWriter → kernel pipe →
// drain task (BG) feeds input.Feed → MRES pulses → consumer's Deserialize<T> consumes bytes
// chunk by chunk. Ser↔Des truly overlap here.
// 3. Calling thread waits for _consumeDone (signaling Deserialize<T> returned).
_consumeDone.Reset();
_consumeRequest.Set();
AcBinarySerializer.SerializeChunkedFramed(_order, _pipeWriter, _options);
_consumeDone.Wait();
}
[MethodImpl(MethodImplOptions.NoInlining)]
public void Deserialize()
{
// No-op: per-iter round-trip is captured in Serialize(). See IsRoundTripOnly contract.
}
public bool VerifyRoundTrip()
{
// Use the same 2-task streaming path as the benchmark, but capture the result for graph-equality.
_captureResult = true;
try
{
Serialize();
var result = _lastResult as T;
return result != null && RoundTripValidator.DeepEqualsViaJson(_order, result);
}
finally
{
_captureResult = false;
_lastResult = null;
}
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
// Cancel drain + consumer tasks → both exit. Pulse _consumeRequest in case consumer is parked.
try { _cts.Cancel(); } catch { /* swallow on teardown */ }
try { _consumeRequest.Set(); } catch { /* nudge in case consumer Wait is parked */ }
try { _drainTask.Wait(TimeSpan.FromSeconds(2)); } catch { /* swallow on teardown */ }
try { _consumerTask.Wait(TimeSpan.FromSeconds(2)); } catch { /* swallow on teardown */ }
// Complete writer + dispose pipe lifecycle.
try { _pipeWriter.CompleteAsync().AsTask().Wait(TimeSpan.FromSeconds(2)); } catch { /* swallow on teardown */ }
try { _pipeReader.Complete(); } catch { /* swallow on teardown */ }
try { _pipeClient.Dispose(); } catch { /* swallow on teardown */ }
try { _pipeServer.Dispose(); } catch { /* swallow on teardown */ }
try { _input.Dispose(); } catch { /* swallow on teardown */ }
try { _consumeRequest.Dispose(); } catch { /* swallow on teardown */ }
try { _consumeDone.Dispose(); } catch { /* swallow on teardown */ }
try { _cts.Dispose(); } catch { /* swallow on teardown */ }
}
}

View File

@ -0,0 +1,214 @@
using AyCode.Core.Serializers.Binaries;
using AyCode.Core.Tests.TestModels;
using System.IO.Pipes;
using System.Runtime.CompilerServices;
namespace AyCode.Core.Benchmarks.Workloads.Scenarios;
/// <summary>
/// Raw <c>byte[]</c> over a long-lived NamedPipe — NO chunk-framing, NO <c>AsyncPipeReaderInput</c>,
/// NO sliding-window buffer. Calling thread serialises + writes; a long-lived background consumer task
/// reads and deserialises. Two-task pattern enables Ser↔Read overlap (kernel-pipe-pipelined) AND
/// avoids the kernel-buffer-full deadlock when <c>bytes.Length &gt; inBufferSize</c>.
///
/// Side-by-side with <see cref="AcBinaryNamedPipeBenchmark{T}"/> (chunked-framed AsyncPipe stack) this
/// isolates two cost components on the SAME kernel-pipe transport with the SAME <c>inBufferSize</c>:
/// <list type="bullet">
/// <item><description><b>This row vs <see cref="AcBinaryBenchmark{T}"/> (Byte[])</b> — pure kernel-NamedPipe
/// overhead (WriteFile / ReadFile syscalls + IRP queueing + buffer-copy + thread-handoff).</description></item>
/// <item><description><b>This row vs <see cref="AcBinaryNamedPipeBenchmark{T}"/> (chunked-framed)</b> — pure
/// AsyncPipe-framework overhead (chunk header writes + sliding-window <c>Feed</c> + MRES wait inside
/// <c>AsyncPipeReaderInput</c>) AND the streaming-pipeline benefit of intra-message Ser↔Des overlap (which
/// raw lacks — raw can only Ser↔Read overlap, with Des sequential after Read completes).</description></item>
/// </list>
/// Per-iter <c>byte[]</c> allocation from <c>AcBinarySerializer.Serialize</c> is part of the cost (matches
/// <see cref="AcBinaryBenchmark{T}"/>'s API contract); the receive-side scratch buffer is also allocated per-iter
/// on the consumer-task (counted via <c>GC.GetTotalAllocatedBytes</c> in <c>BenchmarkLoop.MeasureAllocationTotal</c>).
/// </summary>
public sealed class AcBinaryNamedPipeRawByteArrayBenchmark<T> : ISerializerBenchmark, IDisposable where T : class
{
private readonly T _order;
private readonly AcBinarySerializerOptions _options;
private readonly byte[] _serialized; // for SerializedSize reporting + receive-side size known upfront
// Long-lived pipe lifecycle (set up once in ctor — NOT timed).
private readonly NamedPipeServerStream _pipeServer;
private readonly NamedPipeClientStream _pipeClient;
// Long-lived consumer-task infrastructure (Read + Deserialize on BG thread, signaled per iter).
// Mirrors AcBinaryNamedPipeBenchmark's drain+consumer pair, but raw byte[] doesn't have an
// intermediate sliding-window buffer, so Read+Des happen sequentially in one BG task: Read N bytes
// → Deserialize<T>(bytes) → signal done. Calling thread's Ser↔Write overlaps with this BG Read+Des
// through kernel-pipe pipelining.
private readonly CancellationTokenSource _cts;
private readonly Task _consumerTask;
private readonly ManualResetEventSlim _consumeRequest = new(false);
private readonly ManualResetEventSlim _consumeDone = new(false);
private int _pendingReadSize;
private object? _lastResult; // captured during VerifyRoundTrip; null in benchmark iters
private bool _captureResult; // toggle: when true, ConsumerLoop stores result; otherwise discards
private bool _disposed;
public BenchmarkEngine Engine => BenchmarkEngine.AcBinary;
public BenchmarkIoMode IoMode => BenchmarkIoMode.NamedPipeRaw;
public BenchmarkDispatchMode DispatchMode => _options.UseGeneratedCode ? BenchmarkDispatchMode.SGen : BenchmarkDispatchMode.Runtime;
public Type OrderType => typeof(T);
public string OptionsPreset { get; }
public int SerializedSize => _serialized.Length;
public long SetupSerializeAllocBytes { get; }
public long SetupDeserializeAllocBytes { get; }
public bool IsRoundTripOnly => true;
public string OptionsDescription => BenchmarkOptions.BuildAcBinary(_options, $", BufferSize={_options.BufferWriterChunkSize}B, Transport=NamedPipe(raw,2-task)");
public AcBinaryNamedPipeRawByteArrayBenchmark(T order, AcBinarySerializerOptions options, string optionsPreset)
{
_order = order;
// BufferWriterChunkSize comes from the caller — same source-of-truth contract as
// AcBinaryNamedPipeBenchmark. The kernel pipe-buffer (inBufferSize) is wired to it so the
// raw-vs-chunked comparison runs on identical transport conditions.
_options = options;
OptionsPreset = optionsPreset;
_serialized = AcBinarySerializer.Serialize(order, _options);
var pipeName = $"AcBinaryBenchRaw-{Guid.NewGuid():N}";
// === SERIALIZE-side setup measurement ===
// pipe-pair (server + client) + connect handshake. NO PipeWriter wrapper — we use the raw
// Stream.Write API directly, matching the no-framing semantics of this benchmark.
GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect();
var beforeSer = GC.GetAllocatedBytesForCurrentThread();
_pipeServer = new NamedPipeServerStream(pipeName, PipeDirection.In, 1, PipeTransmissionMode.Byte,
System.IO.Pipes.PipeOptions.Asynchronous,
inBufferSize: _options.BufferWriterChunkSize,
outBufferSize: _options.BufferWriterChunkSize);
_pipeClient = new NamedPipeClientStream(".", pipeName, PipeDirection.Out, System.IO.Pipes.PipeOptions.Asynchronous);
var serverWait = _pipeServer.WaitForConnectionAsync();
_pipeClient.Connect();
serverWait.GetAwaiter().GetResult();
var afterSer = GC.GetAllocatedBytesForCurrentThread();
SetupSerializeAllocBytes = afterSer - beforeSer;
// === DESERIALIZE-side setup measurement ===
// 1× background consumer-task + 2× MRES (request / done) + cancellation source. Matches the
// chunked benchmark's deserialize-side setup cost shape.
GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect();
var beforeDes = GC.GetAllocatedBytesForCurrentThread();
_cts = new CancellationTokenSource();
_consumerTask = Task.Run(ConsumerLoop);
var afterDes = GC.GetAllocatedBytesForCurrentThread();
SetupDeserializeAllocBytes = afterDes - beforeDes;
}
// BG consumer: parks on _consumeRequest, reads N bytes from pipe, runs Deserialize<T>(bytes), signals
// _consumeDone. The Read overlaps with the calling thread's Write through the kernel-pipe; Des happens
// sequentially after Read completes (raw byte[] needs the full message to deserialize).
private void ConsumerLoop()
{
var ct = _cts.Token;
try
{
while (true)
{
_consumeRequest.Wait(ct);
if (ct.IsCancellationRequested) return;
_consumeRequest.Reset();
try
{
var size = _pendingReadSize;
var bytes = new byte[size]; // per-iter alloc — counted by BenchmarkLoop.MeasureAllocationTotal
var totalRead = 0;
while (totalRead < size)
{
var n = _pipeServer.Read(bytes, totalRead, size - totalRead);
if (n == 0) break; // pipe closed / EOF — partial read swallowed
totalRead += n;
}
var result = AcBinaryDeserializer.Deserialize<T>(bytes, _options);
if (_captureResult) _lastResult = result;
}
catch
{
// Swallow — calling thread sees the failure via missing/incorrect _lastResult during VerifyRoundTrip,
// or the benchmark loop just continues (timing impacted). Production teardown handled in Dispose.
}
finally
{
_consumeDone.Set();
}
}
}
catch (OperationCanceledException)
{
// Cooperative cancel — Dispose path. Swallow.
}
}
[MethodImpl(MethodImplOptions.NoInlining)]
public void Serialize()
{
// 2-task streaming pipeline:
// 1. Calling thread serialises → fresh byte[] (per-iter alloc, matches AcBinaryBenchmark contract).
// 2. Calling thread hands off expected size + signals consumer task. Consumer task starts Read loop
// on the pipe (BG thread). Calling thread proceeds to Write the bytes — Read and Write overlap
// through the kernel-pipe (kernel buffer fills, drains as consumer reads, sender resumes).
// 3. Calling thread waits for _consumeDone (consumer task finished Read+Des).
//
// Note: unlike chunked, raw byte[] cannot do Ser↔Des overlap (Des needs the full bytes before
// starting). Only Write↔Read overlaps here. The Des sequence on BG thread is: Read full bytes →
// Des the full graph → signal done. This is the architectural difference between raw and chunked.
var bytes = AcBinarySerializer.Serialize(_order, _options);
_pendingReadSize = bytes.Length;
_consumeDone.Reset();
_consumeRequest.Set();
_pipeClient.Write(bytes, 0, bytes.Length);
_pipeClient.Flush();
_consumeDone.Wait();
}
[MethodImpl(MethodImplOptions.NoInlining)]
public void Deserialize()
{
// No-op: per-iter round-trip is captured in Serialize(). See IsRoundTripOnly contract.
}
public bool VerifyRoundTrip()
{
// Use the same 2-task streaming path as the benchmark, but capture the result for graph-equality.
_captureResult = true;
try
{
Serialize();
var result = _lastResult as T;
return result != null && RoundTripValidator.DeepEqualsViaJson(_order, result);
}
finally
{
_captureResult = false;
_lastResult = null;
}
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
// Cancel the consumer task → ConsumerLoop exits its Wait via OperationCanceledException.
try { _cts.Cancel(); } catch { /* swallow on teardown */ }
try { _consumeRequest.Set(); } catch { /* nudge in case consumer Wait is parked */ }
try { _consumerTask.Wait(TimeSpan.FromSeconds(2)); } catch { /* swallow on teardown */ }
// Symmetric teardown — close client first (writer side), then server.
try { _pipeClient.Dispose(); } catch { /* swallow on teardown */ }
try { _pipeServer.Dispose(); } catch { /* swallow on teardown */ }
try { _consumeRequest.Dispose(); } catch { /* swallow on teardown */ }
try { _consumeDone.Dispose(); } catch { /* swallow on teardown */ }
try { _cts.Dispose(); } catch { /* swallow on teardown */ }
}
}

View File

@ -0,0 +1,143 @@
namespace AyCode.Core.Benchmarks.Workloads.Scenarios;
/// <summary>
/// Serializer engine identifier — replaces the prior <c>Configuration.EngineXxx</c> string constants
/// with a type-safe enum. The benchmark-result <c>Engine</c> column uses <see cref="ToDisplay"/> for
/// the human-readable form.
/// </summary>
public enum BenchmarkEngine
{
AcBinary,
MemoryPack,
#if !AYCODE_NATIVEAOT
MessagePack,
#endif
SystemTextJson,
}
/// <summary>
/// I/O mode identifier — replaces the prior <c>Configuration.IoXxx</c> string constants. Note that
/// <see cref="NamedPipe"/> and <see cref="NamedPipeRaw"/> share the display string <c>"NamedPipe"</c>
/// (they distinguish chunked-framed vs raw-byte[] semantics, but render identically in the IO column);
/// the same applies to <see cref="InMemoryPipe"/> + <see cref="InMemoryRaw"/> (<c>"Pipe(in-mem)"</c>).
/// </summary>
public enum BenchmarkIoMode
{
ByteArray,
BufWrReuse,
BufWrNew,
String,
NamedPipe,
NamedPipeRaw,
InMemoryPipe,
InMemoryRaw,
}
/// <summary>
/// Dispatch mode identifier — replaces the prior <c>Configuration.ModeXxx</c> string constants.
/// Describes how property access / type dispatch happens for a given benchmark row:
/// <list type="bullet">
/// <item><see cref="SGen"/> — compile-time source generator path (Unsafe.As&lt;T&gt; direct fields, slot-array wrapper lookup).</item>
/// <item><see cref="Runtime"/> — reflection / compiled-delegate path.</item>
/// <item><see cref="Hybrid"/> — SGen root with non-SGen child types reached via bridge methods (see docs/BINARY/BINARY_SGEN.md).</item>
/// </list>
/// </summary>
public enum BenchmarkDispatchMode
{
SGen,
Runtime,
Hybrid,
}
/// <summary>
/// Test-data layer filter — selects which test data cells participate in the run.
/// Replaces the prior string-typed <c>layer</c> parameter; CLI/menu callers parse user input via
/// <see cref="Enum.TryParse{T}(string, bool, out T)"/> with <c>ignoreCase: true</c>.
/// <list type="bullet">
/// <item><see cref="All"/> — no filter; every test data set runs.</item>
/// <item><see cref="Core"/>/<see cref="Comprehensive"/>/<see cref="Edge"/> — preset bundles (Comprehensive ⊇ Core, Edge ⊇ Comprehensive).</item>
/// <item><see cref="Small"/>/<see cref="Medium"/>/<see cref="Large"/>/<see cref="Repeated"/>/<see cref="Deep"/> — single-cell mini-suites for tight A/B iteration loops.</item>
/// </list>
/// </summary>
public enum BenchmarkLayer
{
All,
Core,
Comprehensive,
Edge,
Small,
Medium,
Large,
Repeated,
Deep,
}
/// <summary>
/// Per-phase operation filter — selects which sides of the benchmark (Ser, Des, both) run for each
/// serializer. Round-trip-only benchmarks (NamedPipe etc.) treat <see cref="Deserialize"/> alone as
/// a no-op and only run on <see cref="Serialize"/> or <see cref="All"/>. Replaces the prior string-typed
/// <c>mode</c>/<c>opMode</c> parameter.
/// </summary>
public enum BenchmarkOpMode
{
All,
Serialize,
Deserialize,
}
/// <summary>
/// Serializer-set selection — drives the runner's serializer-factory to return one of three
/// preset bundles instead of a magic string. Replaces the prior string-typed <c>serializerMode</c>
/// parameter.
/// <list type="bullet">
/// <item><see cref="Standard"/> — full suite minus AsyncPipe (the streaming benchmark is opt-in).</item>
/// <item><see cref="FastestByte"/> — focused AcBinary FastMode Byte[] vs MemoryPack Byte[] 1:1 comparison.</item>
/// <item><see cref="AsyncPipe"/> — streaming I/O isolation (NamedPipe + in-memory Pipe variants only).</item>
/// </list>
/// </summary>
public enum SerializerSelectionMode
{
Standard,
FastestByte,
AsyncPipe,
}
/// <summary>
/// Display-string converters for the benchmark enums. Renders enum values into the column-friendly
/// human-readable form used by the per-row console table, the <c>.log</c> file CSV/formatted output,
/// and the <c>.LLM</c> markdown table. Centralised here so every output formatter renders identically.
/// </summary>
public static class BenchmarkEnumExtensions
{
public static string ToDisplay(this BenchmarkEngine engine) => engine switch
{
BenchmarkEngine.AcBinary => "AcBinary",
BenchmarkEngine.MemoryPack => "MemoryPack",
#if !AYCODE_NATIVEAOT
BenchmarkEngine.MessagePack => "MessagePack",
#endif
BenchmarkEngine.SystemTextJson => "System.Text.Json",
_ => throw new ArgumentOutOfRangeException(nameof(engine), engine, null),
};
public static string ToDisplay(this BenchmarkIoMode mode) => mode switch
{
BenchmarkIoMode.ByteArray => "Byte[]",
BenchmarkIoMode.BufWrReuse => "BufWr reuse",
BenchmarkIoMode.BufWrNew => "BufWr new",
BenchmarkIoMode.String => "String",
BenchmarkIoMode.NamedPipe => "NamedPipe",
BenchmarkIoMode.NamedPipeRaw => "NamedPipe",
BenchmarkIoMode.InMemoryPipe => "Pipe(in-mem)",
BenchmarkIoMode.InMemoryRaw => "Pipe(in-mem)",
_ => throw new ArgumentOutOfRangeException(nameof(mode), mode, null),
};
public static string ToDisplay(this BenchmarkDispatchMode mode) => mode switch
{
BenchmarkDispatchMode.SGen => "SGen",
BenchmarkDispatchMode.Runtime => "Runtime",
BenchmarkDispatchMode.Hybrid => "Hybrid",
_ => throw new ArgumentOutOfRangeException(nameof(mode), mode, null),
};
}

View File

@ -0,0 +1,89 @@
using AyCode.Core.Serializers;
using AyCode.Core.Serializers.Attributes;
using AyCode.Core.Serializers.Binaries;
using MemoryPack;
using System.Reflection;
namespace AyCode.Core.Benchmarks.Workloads.Scenarios;
/// <summary>
/// Per-engine options-formatting + selection helpers shared by all benchmark rows. Centralizes
/// the Options-column display string (so the .log / .LLM / console headers stay consistent), the
/// MemoryPack <c>WireMode</c>-aligned options selection (so AcBinary FastWire ↔ MemoryPack UTF-16
/// comparisons stay apples-to-apples), and the cached <see cref="AttrFlags"/> attribute-flag aggregation.
/// </summary>
public static class BenchmarkOptions
{
/// <summary>
/// Aggregated <see cref="AcBinarySerializableAttribute"/> feature flags across every type tagged with
/// the attribute in the loaded assemblies. Cached on first access (single reflection scan at startup).
/// Used by <see cref="BuildAcBinary"/> so the per-row Options column shows BOTH the configured
/// options-level value AND the effective attribute-level enable flag — a feature flagged off at the
/// type level overrides the options regardless of preset, and that asymmetry must surface in the log
/// to avoid misreading a "RefHandling=OnlyId" / "Interning=All" line as actually active.
/// Aggregation rule: if ALL tagged types have the feature enabled → <c>true</c>; if any tagged type
/// disables it → <c>false</c> (a single disabling type suppresses the feature on the type-graph).
/// </summary>
public static readonly (bool refHandling, bool internString, bool metadata, bool idTracking, bool propertyFilter) AttrFlags
= ScanAttributeFlags();
private static (bool refHandling, bool internString, bool metadata, bool idTracking, bool propertyFilter) ScanAttributeFlags()
{
var attrs = AppDomain.CurrentDomain.GetAssemblies()
.SelectMany(a => { try { return a.GetTypes(); } catch { return Array.Empty<Type>(); } })
.Select(t => t.GetCustomAttribute<AcBinarySerializableAttribute>())
.Where(a => a != null)
.ToList();
if (attrs.Count == 0) return (false, false, false, false, false);
return (
refHandling: attrs.All(a => a!.EnableRefHandlingFeature),
internString: attrs.All(a => a!.EnableInternStringFeature),
metadata: attrs.All(a => a!.EnableMetadataFeature),
idTracking: attrs.All(a => a!.EnableIdTrackingFeature),
propertyFilter: attrs.All(a => a!.EnablePropertyFilterFeature));
}
/// <summary>
/// Common Options-column formatter for every AcBinary serializer benchmark row. Renders the
/// configured options-level value AND the effective attribute-level enable flag side-by-side
/// (e.g. <c>Interning=All(opt) | False (attr)</c>) so attribute-suppressed features cannot
/// silently mislead. Pass any benchmark-specific extras (e.g. <c>", BufferSize=4096B"</c>)
/// in <paramref name="extra"/> — they are appended after the common fields.
/// </summary>
public static string BuildAcBinary(AcBinarySerializerOptions options, string extra = "")
{
// PropertyFilter: opt-side is "Set"/"None" depending on whether a callback is registered (the callback
// itself isn't a meaningful display value); attr-side is the cross-type-aggregated bool (true = every
// tagged type has the feature enabled, false = at least one type opted out via
// [AcBinarySerializable(enablePropertyFilterFeature: false)] → SGen-emit + Runtime hot-loop both gate).
var propFilterOpt = options.PropertyFilter == null ? "None" : "Set";
return $"WireMode={options.WireMode}, " +
$"RefHandling={options.ReferenceHandling}(opt) | {AttrFlags.refHandling} (attr), " +
$"Interning={options.UseStringInterning}(opt) | {AttrFlags.internString} (attr), " +
$"Metadata={options.UseMetadata}(opt) | {AttrFlags.metadata} (attr), " +
$"PropertyFilter={propFilterOpt}(opt) | {AttrFlags.propertyFilter} (attr), " +
$"SGen={options.UseGeneratedCode}, " +
$"Compression={options.UseCompression}{extra}";
}
/// <summary>
/// Returns MemoryPack serializer options aligned with the given <paramref name="wireMode"/> for a fair
/// apples-to-apples wire-format comparison:
/// <list type="bullet">
/// <item><see cref="WireMode.Compact"/> → <see cref="MemoryPackSerializerOptions.Default"/> (UTF-8) — both
/// engines encode UTF-8, comparison is purely about header / tier / dispatch overhead.</item>
/// <item><see cref="WireMode.Fast"/> → <see cref="MemoryPackSerializerOptions.Utf16"/> (UTF-16 raw memcpy) —
/// both engines write UTF-16 raw bytes, so wire-size and CPU comparison reflect the same string-encoding family.</item>
/// </list>
/// Without this alignment the FastWire vs MemPack-default comparison conflates two unrelated dimensions
/// (UTF-16 raw vs UTF-8 encoded) and produces a misleading +40% wire-size delta that is structurally
/// the encoding-family difference, NOT an AcBinary-specific overhead.
/// </summary>
public static MemoryPackSerializerOptions GetMemPack(WireMode wireMode) =>
wireMode == WireMode.Fast
? MemoryPackSerializerOptions.Utf16
: MemoryPackSerializerOptions.Default;
}

View File

@ -0,0 +1,72 @@
namespace AyCode.Core.Benchmarks.Workloads.Scenarios;
/// <summary>
/// Common contract for every per-engine, per-I/O-mode benchmark row. Each implementation captures
/// one (Engine × IoMode × OptionsPreset) combination — e.g. <c>AcBinary Byte[] FastMode SGen</c> —
/// and exposes a uniform <c>Serialize</c> / <c>Deserialize</c> hot-path that the benchmark loop
/// drives through warmup + adaptive-iter calibration + measurement.
///
/// <para>The default <see cref="WarmupSerialize"/> + <see cref="WarmupDeserialize"/> methods iterate
/// the hot path N times — overrides are only needed when an implementor wants Ser/Des-specific
/// warmup state (rare). Round-trip-only benchmarks (NamedPipe etc.) set <see cref="IsRoundTripOnly"/>
/// to true so the bench loop skips the Des-phase and routes timing into the RT columns.</para>
/// </summary>
public interface ISerializerBenchmark
{
/// <summary>Serializer engine — typed enum, see <see cref="BenchmarkEnumExtensions.ToDisplay(BenchmarkEngine)"/> for the human-readable form.</summary>
BenchmarkEngine Engine { get; }
/// <summary>I/O mode — typed enum, see <see cref="BenchmarkEnumExtensions.ToDisplay(BenchmarkIoMode)"/> for the human-readable form.</summary>
BenchmarkIoMode IoMode { get; }
/// <summary>Dispatch mode — typed enum (<see cref="BenchmarkDispatchMode.SGen"/> / <see cref="BenchmarkDispatchMode.Runtime"/> / <see cref="BenchmarkDispatchMode.Hybrid"/>). For AcBinary derived from <c>UseGeneratedCode</c> + child-type SGen coverage; non-AcBinary engines report their own native dispatch model.</summary>
BenchmarkDispatchMode DispatchMode { get; }
/// <summary>Options preset name — e.g. "FastMode", "Default", "NoIntern", "WithCompression". Stays string because preset names are open-ended (per-instance constructor argument).</summary>
string OptionsPreset { get; }
/// <summary>
/// CLR type of the order graph this benchmark serializes (e.g. <c>typeof(TestOrder_All_False)</c>,
/// <c>typeof(TestOrder_All_True)</c>). Per-instance: AcBinary picks variant by options preset
/// (caller-side dispatch rule), MemPack / MsgPack always use <c>_All_False</c>.
/// Concrete benchmarks return <c>typeof(T)</c> for their generic parameter.
/// </summary>
Type OrderType { get; }
/// <summary>
/// Derived display name for the <see cref="OrderType"/>. Default-interface impl reads
/// <c>OrderType.Name</c>; concrete classes don't need to override. Surfaced in the SERIALIZER
/// OPTIONS section of every output (.log, .LLM, console) — not in the per-row tables — so the
/// reader correlates each preset with its TestOrder variant without inflating the result columns.
/// </summary>
string OrderTypeName => OrderType.Name;
/// <summary>Synthesized display name from Engine + IoMode + OptionsPreset.</summary>
string Name => $"{Engine.ToDisplay()} ({IoMode.ToDisplay()}, {OptionsPreset})";
int SerializedSize { get; }
string? OptionsDescription => null;
/// <summary>One-time SERIALIZER-side setup allocation cost (e.g., pre-allocated ArrayBufferWriter with internal buffer). Captured in constructor; 0 for byte[] API and Fresh-BufWriter variants.</summary>
long SetupSerializeAllocBytes { get; }
/// <summary>One-time DESERIALIZER-side setup allocation cost (e.g., long-lived AsyncPipeReaderInput's ArrayPool rent + ManualResetEventSlim, drain-task scaffolding). Captured in constructor; 0 for byte[] API and any setup-free deserialize path.</summary>
long SetupDeserializeAllocBytes { get; }
/// <summary>True when Serialize() does a full round-trip (e.g. NamedPipe) and Deserialize() is a no-op.
/// Used by the SUMMARY: WINNERS section to skip such cells from "Fastest Serialize" and "Fastest Deserialize"
/// rankings (because both metrics are misleading there) — they still participate in "Fastest Round-trip".
/// Default false for in-memory IO modes which measure Ser and Des separately.</summary>
bool IsRoundTripOnly => false;
/// <summary>Warm only the Serialize path. Default body iterates <see cref="Serialize"/> N times.
/// Overrides are only needed when the implementor wants Ser-specific warmup-state (e.g. pre-allocate buffers).
/// On <see cref="IsRoundTripOnly"/> benchmarks (NamedPipe-style) <see cref="Serialize"/> performs the full RT,
/// so this warms the entire round-trip path.</summary>
void WarmupSerialize(int iterations)
{
for (var i = 0; i < iterations; i++) Serialize();
}
/// <summary>Warm only the Deserialize path. Default body iterates <see cref="Deserialize"/> N times.
/// On <see cref="IsRoundTripOnly"/> benchmarks <see cref="Deserialize"/> is a no-op, so the bench loop
/// skips the Des-phase entirely for those cells.</summary>
void WarmupDeserialize(int iterations)
{
for (var i = 0; i < iterations; i++) Deserialize();
}
void Serialize();
void Deserialize();
/// <summary>Round-trip correctness check — called once per cell before warmup. Returns true if Serialize+Deserialize preserves data.</summary>
bool VerifyRoundTrip();
}

View File

@ -0,0 +1,49 @@
using AyCode.Core.Serializers;
using AyCode.Core.Tests.TestModels;
using MemoryPack;
using System.Runtime.CompilerServices;
namespace AyCode.Core.Benchmarks.Workloads.Scenarios;
/// <summary>
/// MemoryPack benchmark, Byte[] I/O mode. The SOTA baseline AcBinary is compared against in every
/// cell. WireMode-aligned options via <see cref="BenchmarkOptions.GetMemPack"/> so Compact ↔ UTF-8
/// and FastWire ↔ UTF-16 are apples-to-apples on the string-encoding axis.
/// </summary>
public sealed class MemoryPackBenchmark<T> : ISerializerBenchmark where T : class
{
private readonly T _order;
private readonly MemoryPackSerializerOptions _options;
private readonly byte[] _serialized;
public BenchmarkEngine Engine => BenchmarkEngine.MemoryPack;
public BenchmarkIoMode IoMode => BenchmarkIoMode.ByteArray;
public BenchmarkDispatchMode DispatchMode => BenchmarkDispatchMode.SGen; // MemoryPack always uses [MemoryPackable] source-generated formatters
public Type OrderType => typeof(T);
public string OptionsPreset { get; }
public int SerializedSize => _serialized.Length;
public long SetupSerializeAllocBytes => 0;
public long SetupDeserializeAllocBytes => 0;
public string? OptionsDescription => $"StringEncoding={_options.StringEncoding}";
public MemoryPackBenchmark(T order, WireMode wireMode, string optionsPreset)
{
_order = order;
OptionsPreset = optionsPreset;
_options = BenchmarkOptions.GetMemPack(wireMode);
_serialized = MemoryPackSerializer.Serialize(order, _options);
}
[MethodImpl(MethodImplOptions.NoInlining)]
public void Serialize() => MemoryPackSerializer.Serialize(_order, _options);
[MethodImpl(MethodImplOptions.NoInlining)]
public void Deserialize() => MemoryPackSerializer.Deserialize<T>(_serialized, _options);
public bool VerifyRoundTrip()
{
var bytes = MemoryPackSerializer.Serialize(_order, _options);
var roundTripped = MemoryPackSerializer.Deserialize<T>(bytes, _options);
return RoundTripValidator.DeepEqualsViaJson(_order, roundTripped);
}
}

View File

@ -0,0 +1,65 @@
using AyCode.Core.Serializers;
using AyCode.Core.Tests.TestModels;
using MemoryPack;
using System.Buffers;
using System.Runtime.CompilerServices;
namespace AyCode.Core.Benchmarks.Workloads.Scenarios;
/// <summary>
/// Benchmarks MemoryPack via the IBufferWriter overload with a pre-allocated, reused ArrayBufferWriter.
/// Apples-to-apples counterpart to <see cref="AcBinaryBufferWriterBenchmark{T}"/> — MemoryPack's IBufferWriter
/// is the path it's designed for.
/// </summary>
public sealed class MemoryPackBufferWriterBenchmark<T> : ISerializerBenchmark where T : class
{
private readonly T _order;
private readonly MemoryPackSerializerOptions _options;
private readonly byte[] _serialized;
private readonly ArrayBufferWriter<byte> _bufferWriter;
public BenchmarkEngine Engine => BenchmarkEngine.MemoryPack;
public BenchmarkIoMode IoMode => BenchmarkIoMode.BufWrReuse;
public BenchmarkDispatchMode DispatchMode => BenchmarkDispatchMode.SGen; // MemoryPack always uses [MemoryPackable] source-generated formatters
public Type OrderType => typeof(T);
public string OptionsPreset { get; }
public int SerializedSize => _serialized.Length;
public long SetupSerializeAllocBytes { get; }
public long SetupDeserializeAllocBytes => 0;
public string? OptionsDescription => $"StringEncoding={_options.StringEncoding}";
public MemoryPackBufferWriterBenchmark(T order, WireMode wireMode, string optionsPreset)
{
_order = order;
OptionsPreset = optionsPreset;
_options = BenchmarkOptions.GetMemPack(wireMode);
_serialized = MemoryPackSerializer.Serialize(order, _options);
// Serialize-side setup only — see AcBinaryBufferWriterBenchmark for the full rationale.
GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect();
var beforeSetup = GC.GetAllocatedBytesForCurrentThread();
_bufferWriter = new ArrayBufferWriter<byte>(_serialized.Length * 2);
var afterSetup = GC.GetAllocatedBytesForCurrentThread();
SetupSerializeAllocBytes = afterSetup - beforeSetup;
}
[MethodImpl(MethodImplOptions.NoInlining)]
public void Serialize()
{
_bufferWriter.ResetWrittenCount();
MemoryPackSerializer.Serialize(_bufferWriter, _order, _options);
}
// BufWr semantic: read from a ReadOnlySequence<byte> overload (apples-to-apples with AcBinary's
// BufWr Deser path). MemoryPack's ROS overload also single-segment-fast-paths internally.
[MethodImpl(MethodImplOptions.NoInlining)]
public void Deserialize() => MemoryPackSerializer.Deserialize<T>(new ReadOnlySequence<byte>(_serialized), _options);
public bool VerifyRoundTrip()
{
_bufferWriter.ResetWrittenCount();
MemoryPackSerializer.Serialize(_bufferWriter, _order, _options);
var roundTripped = MemoryPackSerializer.Deserialize<T>(new ReadOnlySequence<byte>(_bufferWriter.WrittenMemory), _options);
return RoundTripValidator.DeepEqualsViaJson(_order, roundTripped);
}
}

View File

@ -0,0 +1,56 @@
using AyCode.Core.Serializers;
using AyCode.Core.Tests.TestModels;
using MemoryPack;
using System.Buffers;
using System.Runtime.CompilerServices;
namespace AyCode.Core.Benchmarks.Workloads.Scenarios;
/// <summary>
/// Benchmarks MemoryPack via the IBufferWriter overload, allocating a FRESH ArrayBufferWriter on EVERY call.
/// Apples-to-apples counterpart to <see cref="AcBinaryFreshBufferWriterBenchmark{T}"/>.
/// </summary>
public sealed class MemoryPackFreshBufferWriterBenchmark<T> : ISerializerBenchmark where T : class
{
private readonly T _order;
private readonly MemoryPackSerializerOptions _options;
private readonly byte[] _serialized;
public BenchmarkEngine Engine => BenchmarkEngine.MemoryPack;
public BenchmarkIoMode IoMode => BenchmarkIoMode.BufWrNew;
public BenchmarkDispatchMode DispatchMode => BenchmarkDispatchMode.SGen; // MemoryPack always uses [MemoryPackable] source-generated formatters
public Type OrderType => typeof(T);
public string OptionsPreset { get; }
public int SerializedSize => _serialized.Length;
public long SetupSerializeAllocBytes => 0;
public long SetupDeserializeAllocBytes => 0;
public string? OptionsDescription => $"StringEncoding={_options.StringEncoding}";
public MemoryPackFreshBufferWriterBenchmark(T order, WireMode wireMode, string optionsPreset)
{
_order = order;
OptionsPreset = optionsPreset;
_options = BenchmarkOptions.GetMemPack(wireMode);
_serialized = MemoryPackSerializer.Serialize(order, _options);
}
[MethodImpl(MethodImplOptions.NoInlining)]
public void Serialize()
{
var abw = new ArrayBufferWriter<byte>();
MemoryPackSerializer.Serialize(abw, _order, _options);
}
// BufWr semantic: read from a ReadOnlySequence<byte> overload (apples-to-apples with AcBinary's
// BufWr Deser path). MemoryPack's ROS overload also single-segment-fast-paths internally.
[MethodImpl(MethodImplOptions.NoInlining)]
public void Deserialize() => MemoryPackSerializer.Deserialize<T>(new ReadOnlySequence<byte>(_serialized), _options);
public bool VerifyRoundTrip()
{
var abw = new ArrayBufferWriter<byte>();
MemoryPackSerializer.Serialize(abw, _order, _options);
var roundTripped = MemoryPackSerializer.Deserialize<T>(new ReadOnlySequence<byte>(abw.WrittenMemory), _options);
return RoundTripValidator.DeepEqualsViaJson(_order, roundTripped);
}
}

View File

@ -0,0 +1,59 @@
#if !AYCODE_NATIVEAOT
using AyCode.Core.Tests.TestModels;
using MessagePack;
using MessagePack.Resolvers;
using System.Runtime.CompilerServices;
namespace AyCode.Core.Benchmarks.Workloads.Scenarios;
/// <summary>
/// MessagePack benchmark, Byte[] I/O mode. Excluded from NativeAOT build because v3's StandardResolver
/// falls back to DynamicGenericResolver for closed-generic types (List&lt;TestOrderItem_All_True&gt; et al.),
/// which uses Activator.CreateInstance on formatter types the AOT trimmer drops →
/// MissingMethodException at runtime. Available for regular JIT runs (<c>dotnet run</c>) only.
/// </summary>
public sealed class MessagePackBenchmark<T> : ISerializerBenchmark where T : class
{
private readonly T _order;
private readonly MessagePackSerializerOptions _options;
private readonly byte[] _serialized;
public BenchmarkEngine Engine => BenchmarkEngine.MessagePack;
public BenchmarkIoMode IoMode => BenchmarkIoMode.ByteArray;
public BenchmarkDispatchMode DispatchMode => BenchmarkDispatchMode.SGen; // MessagePack uses [MessagePackObject] source-generated formatters (StandardResolver)
public Type OrderType => typeof(T);
public string OptionsPreset { get; }
public int SerializedSize => _serialized.Length;
public long SetupSerializeAllocBytes => 0;
public long SetupDeserializeAllocBytes => 0;
public string OptionsDescription { get; }
public MessagePackBenchmark(T order, string optionsPreset)
{
_order = order;
OptionsPreset = optionsPreset;
//_options = ContractlessStandardResolver.Options.WithCompression(MessagePackCompression.None);
//_options = ContractlessStandardResolver.Options.WithCompression(MessagePackCompression.Lz4Block);
_options = MessagePackSerializerOptions.Standard.WithCompression(MessagePackCompression.None);
var isContractless = _options.Resolver is ContractlessStandardResolver;
OptionsDescription = $"Mode={( isContractless ? "Contractless" : "ContractBased")}, Compression={_options.Compression}";
_serialized = MessagePackSerializer.Serialize(order, _options);
}
[MethodImpl(MethodImplOptions.NoInlining)]
public void Serialize() => MessagePackSerializer.Serialize(_order, _options);
[MethodImpl(MethodImplOptions.NoInlining)]
public void Deserialize() => MessagePackSerializer.Deserialize<T>(_serialized, _options);
public bool VerifyRoundTrip()
{
var bytes = MessagePackSerializer.Serialize(_order, _options);
var roundTripped = MessagePackSerializer.Deserialize<T>(bytes, _options);
return RoundTripValidator.DeepEqualsViaJson(_order, roundTripped);
}
}
#endif

View File

@ -0,0 +1,43 @@
# Workloads / Scenarios
Shared workload + scenario types used by **both** the Console runner (custom adaptive measure engine) and the BDN runner (`AcBinaryVsMemPackBenchmark` in the parent folder). Same wire payloads, same options, same round-trip-verify gate → Console and BDN cells are directly comparable.
## Layout
### Contract types
- [`ISerializerBenchmark.cs`](ISerializerBenchmark.cs) — common contract for every (Engine × IoMode × OptionsPreset) row. `Serialize()` / `Deserialize()` hot-path + warmup hooks + `VerifyRoundTrip()` for the pre-warmup correctness gate. Round-trip-only benchmarks (NamedPipe / in-memory Pipe) set `IsRoundTripOnly = true` and let the bench loop skip the Des-phase.
- [`BenchmarkEnums.cs`](BenchmarkEnums.cs) — `BenchmarkEngine` / `BenchmarkIoMode` / `BenchmarkDispatchMode` / `BenchmarkLayer` / `BenchmarkOpMode` / `SerializerSelectionMode` + `ToDisplay()` extensions for the column-friendly rendering used by every output formatter.
- [`BenchmarkOptions.cs`](BenchmarkOptions.cs) — per-engine options-formatting helpers + the cached `AttrFlags` aggregation (assembly-scan of `[AcBinarySerializable]` feature flags) + `GetMemPack(WireMode)` for the wire-mode-aligned MemoryPack-options selection.
- [`RoundTripValidator.cs`](RoundTripValidator.cs) — universal deep-equality oracle via canonical System.Text.Json. Called by every benchmark's `VerifyRoundTrip()` before warmup. AOT-skipped (STJ reflection path incompatible).
### Concrete benchmarks (12 implementations)
**AcBinary** (7 variants — different I/O modes):
- [`AcBinaryBenchmark.cs`](AcBinaryBenchmark.cs) — `Byte[]` API. Headline AcBinary row.
- [`AcBinaryBufferWriterBenchmark.cs`](AcBinaryBufferWriterBenchmark.cs) — pre-allocated, reused `ArrayBufferWriter<byte>`.
- [`AcBinaryFreshBufferWriterBenchmark.cs`](AcBinaryFreshBufferWriterBenchmark.cs) — fresh `ArrayBufferWriter` per call (one-shot scenario, 4 KB chunk).
- [`AcBinaryNamedPipeBenchmark.cs`](AcBinaryNamedPipeBenchmark.cs) — chunked-framed `AsyncPipe` over kernel NamedPipe (long-lived, multi-message, 2-task pipeline).
- [`AcBinaryNamedPipeRawByteArrayBenchmark.cs`](AcBinaryNamedPipeRawByteArrayBenchmark.cs) — raw `byte[]` over kernel NamedPipe (no chunk-framing, Read+Des sequential after Read completes).
- [`AcBinaryInMemoryPipeBenchmark.cs`](AcBinaryInMemoryPipeBenchmark.cs) — chunked-framed `AsyncPipe` over in-memory `System.IO.Pipelines.Pipe` (zero kernel involvement, isolates streaming-framework CPU cost from kernel-pipe transport overhead).
- [`AcBinaryInMemoryRawByteArrayBenchmark.cs`](AcBinaryInMemoryRawByteArrayBenchmark.cs) — raw `byte[]` over in-memory cross-thread handoff (no transport at all, completes the 2×2 [chunked|raw] × [kernel|memory] matrix).
**MemoryPack** (3 variants — apples-to-apples with the AcBinary I/O modes):
- [`MemoryPackBenchmark.cs`](MemoryPackBenchmark.cs) — `Byte[]` API. SOTA baseline.
- [`MemoryPackBufferWriterBenchmark.cs`](MemoryPackBufferWriterBenchmark.cs) — reused `ArrayBufferWriter`.
- [`MemoryPackFreshBufferWriterBenchmark.cs`](MemoryPackFreshBufferWriterBenchmark.cs) — fresh `ArrayBufferWriter` per call.
**Other** (reference comparison, typically disabled in active suite):
- [`MessagePackBenchmark.cs`](MessagePackBenchmark.cs) — JIT-only (AOT-incompatible — v3 StandardResolver falls back to `Activator.CreateInstance` on trimmed closed-generic types).
- [`SystemTextJsonBenchmark.cs`](SystemTextJsonBenchmark.cs) — String I/O mode, reflection-based metadata. Far behind binary serializers on µs/op; useful as a JSON baseline when activated.
## Convention
Every concrete benchmark:
1. Stores the test data graph + serializer options in its ctor and pre-computes a `_serialized` byte array for `SerializedSize` reporting.
2. Implements `Serialize()` / `Deserialize()` as `[MethodImpl(NoInlining)]` hot-paths — the bench loop drives these directly through warmup + adaptive-iter calibration + measurement.
3. Implements `VerifyRoundTrip()` by calling `RoundTripValidator.DeepEqualsViaJson(original, roundTripped)` on the result of a single Ser+Des pass.
4. Round-trip-only variants (NamedPipe / in-memory Pipe) override `IsRoundTripOnly => true`, route the full Ser+wire+Des roundtrip through `Serialize()`, and leave `Deserialize()` as a no-op.
The runner (Console `BenchmarkLoop` or BDN `AcBinaryVsMemPackBenchmark`) creates the appropriate concrete via factory helpers and drives the contract — no scenario-specific knowledge in the runner.

View File

@ -0,0 +1,49 @@
using System.Text.Json;
namespace AyCode.Core.Benchmarks.Workloads.Scenarios;
/// <summary>
/// Round-trip correctness validator — serializes both sides to canonical System.Text.Json form
/// and compares the resulting strings. Works for any object graph without a custom comparer (slower
/// than property-by-property but universal). Used by every benchmark's <c>VerifyRoundTrip()</c>
/// implementation as the deep-equality oracle before warmup begins.
/// </summary>
public static class RoundTripValidator
{
#if !AYCODE_NATIVEAOT
private static readonly JsonSerializerOptions VerifyJsonOpts = new()
{
WriteIndented = false,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
ReferenceHandler = System.Text.Json.Serialization.ReferenceHandler.IgnoreCycles
};
#endif
/// <summary>
/// Round-trip equality check via canonical System.Text.Json. Returns true if both sides serialize
/// to identical JSON strings.
/// </summary>
/// <remarks>
/// AOT publish skip: <c>System.Text.Json</c>'s reflection path uses runtime closed-generic instantiation
/// (<c>JsonPropertyInfo&lt;TestStatus&gt;</c> et al.) that the trimmer drops, causing
/// <c>NotSupportedException: missing native code or metadata</c>. The validation is JIT-only — the actual
/// benchmark Serialize/Deserialize loops don't touch this path. Under AOT we return <c>true</c> so all
/// <c>VerifyRoundTrip()</c> calls pass without running the cross-format validation.
/// </remarks>
public static bool DeepEqualsViaJson(object? a, object? b)
{
#if AYCODE_NATIVEAOT
// Skip cross-format validation under AOT — STJ reflection path is incompatible. The roundtrip
// itself still runs (caller-side Serialize+Deserialize), just the JSON-canonical compare is bypassed.
return true;
#else
if (a == null && b == null) return true;
if (a == null || b == null) return false;
var jsonA = JsonSerializer.Serialize(a, VerifyJsonOpts);
var jsonB = JsonSerializer.Serialize(b, VerifyJsonOpts);
return jsonA == jsonB;
#endif
}
}

View File

@ -0,0 +1,59 @@
using AyCode.Core.Tests.TestModels;
using System.Runtime.CompilerServices;
using System.Text;
using System.Text.Json;
namespace AyCode.Core.Benchmarks.Workloads.Scenarios;
/// <summary>
/// System.Text.Json benchmark, String I/O mode. Reference comparison — uses reflection-based metadata
/// (no source-generator opt-in here). Typically NOT in the active suite (commented out in the
/// caller-side <c>CreateSerializers</c>); ranks far behind binary serializers on µs/op but provides
/// a familiar JSON baseline when needed.
/// </summary>
public sealed class SystemTextJsonBenchmark<T> : ISerializerBenchmark where T : class
{
private readonly T _order;
private readonly JsonSerializerOptions _options;
private readonly string _serialized;
private readonly byte[] _serializedUtf8;
public BenchmarkEngine Engine => BenchmarkEngine.SystemTextJson;
public BenchmarkIoMode IoMode => BenchmarkIoMode.String;
public BenchmarkDispatchMode DispatchMode => BenchmarkDispatchMode.Runtime; // System.Text.Json default uses reflection-based metadata (no source generator opt-in here)
public Type OrderType => typeof(T);
public string OptionsPreset { get; }
public int SerializedSize => _serializedUtf8.Length;
public long SetupSerializeAllocBytes => 0;
public long SetupDeserializeAllocBytes => 0;
public SystemTextJsonBenchmark(T order, string optionsPreset)
{
_order = order;
OptionsPreset = optionsPreset;
_options = new JsonSerializerOptions
{
WriteIndented = false,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
ReferenceHandler = System.Text.Json.Serialization.ReferenceHandler.IgnoreCycles
};
_serialized = JsonSerializer.Serialize(order, _options);
// Encoding.UTF8.GetBytes(string) does NOT prepend the BOM (the preamble is only emitted by
// GetPreamble() / stream-level writes), so this produces identical bytes to the prior
// `new UTF8Encoding(false).GetBytes(_serialized)` call. Size-reporting only.
_serializedUtf8 = Encoding.UTF8.GetBytes(_serialized);
}
[MethodImpl(MethodImplOptions.NoInlining)]
public void Serialize() => JsonSerializer.Serialize(_order, _options);
[MethodImpl(MethodImplOptions.NoInlining)]
public void Deserialize() => JsonSerializer.Deserialize<T>(_serialized, _options);
public bool VerifyRoundTrip()
{
var json = JsonSerializer.Serialize(_order, _options);
var roundTripped = JsonSerializer.Deserialize<T>(json, _options);
return RoundTripValidator.DeepEqualsViaJson(_order, roundTripped);
}
}

View File

@ -1,21 +1,53 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\AyCode.Core\AyCode.Core.csproj" />
<ProjectReference Include="..\AyCode.Core.Tests\AyCode.Core.Tests.csproj" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\AyCode.Core\AyCode.Core.csproj" />
<ProjectReference Include="..\AyCode.Core.Tests\AyCode.Core.Tests.csproj" />
<ProjectReference Include="..\AyCode.Benchmark\AyCode.Benchmark.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="MemoryPack" Version="1.21.4" />
<PackageReference Include="MessagePack" Version="3.1.4" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="MemoryPack" Version="1.21.4" />
<PackageReference Include="MessagePack" Version="3.1.4" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
</ItemGroup>
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<PropertyGroup>
<OutputType>Exe</OutputType>
<StartupObject>AyCode.Core.Serializers.Console.Program</StartupObject>
</PropertyGroup>
<Import Project="..\AyCode.Core.targets" />
<!-- AOT-mode is publish-time only.
Why conditional on $(_IsPublishing): with .NET 8+, an unconditional <PublishAot>true</PublishAot>
forces the SDK to auto-set <IsDynamicCodeSupported>false</IsDynamicCodeSupported> as a runtime
host config option — meaning even regular `dotnet build` / `dotnet run` outputs report
RuntimeFeature.IsDynamicCodeSupported == false at runtime. That makes AcSerializerCommon's
Runtime path take the reflection fallback (ctor.Invoke / PropertyInfo.GetValue) instead of
Expression.Compile during JIT testing — Release benchmark numbers measure reflection, not
compiled expressions. Restricting PublishAot to actual publish keeps JIT semantics for
`dotnet build` / `dotnet run` while preserving full AOT analysis on `dotnet publish`.
AYCODE_NATIVEAOT define moved here too — it's the publish-time #if symbol that gates out
MessagePack benchmark + STJ-based DeepEqualsViaJson validation in Program.cs (both
incompatible with AOT trim/runtime constraints). Same conditioning ensures the symbol is
defined exactly when PublishAot is in effect. -->
<PropertyGroup Condition="'$(_IsPublishing)' == 'true'">
<PublishAot>true</PublishAot>
<InvariantGlobalization>true</InvariantGlobalization>
<DefineConstants>$(DefineConstants);AYCODE_NATIVEAOT</DefineConstants>
<!-- DAMs propagation chain landed across the public Deserialize<T>/Serialize<T> entry points down to
AcSerializerCommon factory methods. Remaining trim warnings concentrate on:
(a) serialize-side polymorphism via obj.GetType() — fundamental trimmer blind spot
(b) internal Type-flow through serialize helpers (ScanValueGenerated, WritePropertyOrSkip)
(c) external dependencies (MemoryPack/MessagePack/AutoMapper/MongoDB/STJ) — out of scope
Suppress for now so builds succeed; revisit if AOT runtime issues surface beyond ctor metadata. -->
<SuppressTrimAnalysisWarnings>true</SuppressTrimAnalysisWarnings>
<TrimmerSingleWarn>false</TrimmerSingleWarn>
<JsonSerializerIsReflectionEnabledByDefault>true</JsonSerializerIsReflectionEnabledByDefault>
</PropertyGroup>
</Project>

View File

@ -0,0 +1,888 @@
using AyCode.Core.Benchmarks.Reporting;
using AyCode.Core.Benchmarks.Workloads.Scenarios;
using AyCode.Core.Serializers.Binaries;
using AyCode.Core.Tests.TestModels;
using MemoryPack;
using System.Diagnostics;
using System.Runtime.CompilerServices;
namespace AyCode.Core.Serializers.Console;
/// <summary>
/// Benchmark execution: end-to-end orchestration (<see cref="RunBenchmark"/>), per-cell loop
/// (<see cref="RunBenchmarksForTestData"/>), serializer factory (<see cref="CreateSerializers"/>),
/// and the timing / calibration / allocation helpers. Pure benchmark-execution infrastructure —
/// no display formatting (that lives in <c>Output</c>) and no UX-flow (that lives in <c>Program</c>
/// + <c>Menu</c>).
/// </summary>
internal static class BenchmarkLoop
{
/// <summary>
/// Runs the benchmark suite end-to-end for the given configuration: pre-warmup → per-cell warmup
/// + measurement → grouped results print → save to disk. Used by both the CLI and interactive
/// menu paths; the interactive loop calls this repeatedly without restarting the process.
/// </summary>
internal static void RunBenchmark(BenchmarkLayer layer, BenchmarkOpMode opMode, SerializerSelectionMode serializerMode)
{
System.Console.WriteLine("╔══════════════════════════════════════════════════════════════════════╗");
System.Console.WriteLine("║ COMPREHENSIVE SERIALIZER BENCHMARK SUITE ║");
System.Console.WriteLine("╚══════════════════════════════════════════════════════════════════════╝");
// Stabilization: pin the entire benchmark process to a single logical CPU and bump priority
// class. Single-core affinity stops Windows from migrating the bench thread between cores
// mid-sample (a migration evicts L1/L2 caches and corrupts a measurement); High priority
// reduces preemption by background tasks (Defender scans, indexer, etc.) that otherwise
// randomly inflate samples by 5-15%.
// Try/finally guarantees the original state is restored even if a benchmark throws — leaving
// a developer machine pinned to one core after a crashed run is a real foot-gun.
// Skipped on Debug single-sample mode (Configuration.BenchmarkSamples <= 1) where stabilization is moot.
var process = Process.GetCurrentProcess();
var origAffinity = (IntPtr)0;
var origPriority = ProcessPriorityClass.Normal;
var stabilizationApplied = false;
// ProcessorAffinity is only supported on Windows + Linux (CA1416). macOS would throw at
// runtime; skip the affinity step there but still raise priority class (which IS supported
// on macOS, just less effective for stabilization than affinity pinning).
if (Configuration.BenchmarkSamples > 1 && (OperatingSystem.IsWindows() || OperatingSystem.IsLinux()))
{
try
{
origAffinity = process.ProcessorAffinity;
origPriority = process.PriorityClass;
// Pin to CPU 0 (mask = 1). Choosing CPU 0 is arbitrary; what matters is "exactly one
// core, consistently" — not which one. If CPU 0 is heavily contended on the host
// (e.g. dedicated to system-wide IRQs on some Windows configs), the user can tweak
// the mask here. The benchmark is single-threaded for the in-memory rows so single
// core is sufficient; round-trip-only NamedPipe rows have a server-drain thread
// that will share the core (acceptable — the bench measures end-to-end RT anyway).
process.ProcessorAffinity = (IntPtr)1;
process.PriorityClass = ProcessPriorityClass.High;
stabilizationApplied = true;
System.Console.WriteLine($"Stabilization: pinned to CPU 0 (affinity=0x1), priority=High.");
}
catch (Exception ex)
{
// Affinity/priority changes may fail on locked-down hosts (group policies, containers
// without CAP_SYS_NICE on Linux, etc.). Surface and continue — the benchmark still
// works, just with the platform default scheduling.
System.Console.WriteLine($"Stabilization SKIPPED: {ex.GetType().Name}: {ex.Message}");
}
}
try
{
var allResults = new List<BenchmarkResult>();
var allTestDataSets = BuildMultiVariantTestDataSets();
var testDataSets = FilterByLayer(allTestDataSets, layer);
System.Console.WriteLine($"Layer: {layer} | OpMode: {opMode} | SerializerMode: {serializerMode} | Charset: {Configuration.GetCurrentCharsetName()} | Iterations: per-cell adaptive (~{Configuration.TargetSampleMs} ms target) | Warmup: {Configuration.WarmupIterations} per phase (Ser/Des isolated) | Samples: {Configuration.BenchmarkSamples} (median) + pilot discard");
System.Console.WriteLine($"Build: {Configuration.BuildConfiguration} | .NET: {Environment.Version} | Test Cells: {testDataSets.Count}/{allTestDataSets.Count}");
System.Console.WriteLine();
// Global JIT pre-warmup — touches every (testdata × serializer) code path BEFORE any timing happens.
// Without this, the FIRST test data measured carries JIT-tier-promotion latency: the per-cell warmup
// alone doesn't ensure that every Serialize<T>/IBufferWriter overload is fully Tier 1 by the time we
// start measuring. Symptom: first cell's BufferWriter variants run ~2x slower than the SAME variants
// on later cells (e.g. Small BufWr reuse 9ms vs Medium BufWr reuse 4ms — even though Medium is bigger).
// Pre-warmup runs every overload at least once with each data shape so .NET 9's tiered JIT promotes
// them all in the background; the per-cell warmup that follows then locks in cache + branch state.
if (Configuration.BenchmarkSamples > 1) // skip in DEBUG (single-sample fast iteration)
{
System.Console.WriteLine($"Global JIT pre-warmup ({testDataSets.Count} cells × all serializers, light pass)...");
foreach (var testData in testDataSets)
{
var preSerializers = CreateSerializers(testData, serializerMode);
try
{
foreach (var s in preSerializers)
{
// Light warmup just to trigger Tier 0 → Tier 1 promotion. Phase-isolated:
// Ser path first, then Des path — same pattern as the per-cell warmup in
// RunBenchmarksForTestData (which still runs afterwards for cache/BTB warming).
s.WarmupSerialize(2000);
s.WarmupDeserialize(2000);
}
}
finally
{
// Dispose any IDisposable serializers (NamedPipe / FileStream variants own OS resources).
foreach (var s in preSerializers) (s as IDisposable)?.Dispose();
}
}
// Let background tiered-JIT compilation drain before we begin measuring.
if (Configuration.JitSleep > 0) Thread.Sleep(Configuration.JitSleep);
System.Console.WriteLine("✓ Global pre-warmup complete.\n");
}
foreach (var testData in testDataSets)
{
System.Console.WriteLine($"\n{'═'.ToString().PadRight(70, '═')}");
System.Console.WriteLine($"TEST DATA: {testData.DisplayName}");
System.Console.WriteLine($"{'═'.ToString().PadRight(70, '═')}");
var results = RunBenchmarksForTestData(testData, opMode, serializerMode);
allResults.AddRange(results);
}
// Build the reporting context (resolves path via walk-up to .sln, snapshots run-config).
var ctx = new ReportingContext(
SourceTag: "Console",
ResultsDirectory: ReportingContext.ResolveResultsDirectory(),
BuildConfiguration: Configuration.BuildConfiguration,
Utf8NoBom: Configuration.Utf8NoBom,
CharsetName: Configuration.GetCurrentCharsetName(),
WarmupIterations: Configuration.WarmupIterations,
BenchmarkSamples: Configuration.BenchmarkSamples,
TargetSampleMs: Configuration.TargetSampleMs,
UnstableCVThreshold: Configuration.UnstableCVThreshold,
MicroOptCVThreshold: Configuration.MicroOptCVThreshold);
// Print grouped results
BenchmarkReportWriter.PrintGroupedResults(allResults, testDataSets);
// Save results to file (.log + .LLM + .output)
BenchmarkReportWriter.SaveAll(ctx, allResults, testDataSets);
System.Console.WriteLine("\n✓ Benchmark complete!");
}
finally
{
// Restore process state — affinity/priority changes are process-wide and persist across
// interactive-mode iterations of the menu. Without restore, the second menu run would
// already be on CPU-0 + High priority before its own try-block applied them, masking
// any stabilization-disabled comparison.
if (stabilizationApplied && (OperatingSystem.IsWindows() || OperatingSystem.IsLinux()))
{
try { process.ProcessorAffinity = origAffinity; } catch { /* best-effort */ }
try { process.PriorityClass = origPriority; } catch { /* best-effort */ }
}
}
}
private static List<BenchmarkResult> RunBenchmarksForTestData(TestDataSet testData, BenchmarkOpMode mode, SerializerSelectionMode serializerMode)
{
var results = new List<BenchmarkResult>();
var serializers = CreateSerializers(testData, serializerMode);
// Round-trip correctness check — once per (cell × serializer), BEFORE warmup. Aborts the entire benchmark on failure.
System.Console.WriteLine("Verifying round-trip correctness...");
foreach (var serializer in serializers)
{
if (!serializer.VerifyRoundTrip())
{
System.Console.Error.WriteLine($"❌ FATAL: Round-trip verification FAILED for {serializer.Name} on {testData.DisplayName}");
System.Console.Error.WriteLine("Benchmark numbers from a serializer with broken round-trip would be meaningless. Aborting.");
Environment.Exit(1);
}
}
System.Console.WriteLine("✓ All serializers passed round-trip verification.");
// Per-serializer, PER-PHASE (warmup → calibrate → measurement) cycle: each serializer's Ser-path and
// Des-path get COMPLETELY ISOLATED warmup→measure rounds, with a GC.Collect at every phase boundary.
//
// Why phase-isolation: a combined warmup (Ser+Des interleaved) leaves the CPU I-cache + branch-predictor
// in a "compromise state" — neither Ser nor Des code-set dominates. The first phase to measure pays a
// cache-miss penalty as its code-set displaces the leftover-warmup-state. Isolated warmup→measure pairs
// keep the I-cache HOT for ONLY the measured path, both in the warmup (priming) and the measurement
// (steady-state). Branch-predictor history also stays clean per path.
//
// GC.Collect at every boundary: removes residual allocation pressure from the previous phase (write-buffer
// pool churn from Ser, deserialized object graph from Des) so the next phase starts with a quiescent
// heap — GC tier-promotion timing during measurement is then driven only by THAT phase's allocations.
//
// Configuration.JitSleep per-phase: tiered JIT background promotion drain after each warmup (mode-aware: 0 ms in AOT).
// Each phase's freshly-promoted methods settle before its timing starts.
System.Console.WriteLine($"Running benchmarks (target ~{Configuration.TargetSampleMs} ms/sample × {Configuration.BenchmarkSamples} samples median, phase-isolated warmup/measure per Ser/Des)...\n");
foreach (var serializer in serializers)
{
var result = new BenchmarkResult
{
TestDataName = testData.DisplayName, // Use DisplayName for IId% info
Engine = serializer.Engine,
IoMode = serializer.IoMode,
DispatchMode = serializer.DispatchMode,
OptionsPreset = serializer.OptionsPreset,
OrderTypeName = serializer.OrderTypeName,
OptionsDescription = serializer.OptionsDescription,
SerializedSize = serializer.SerializedSize,
SetupSerializeAllocBytes = serializer.SetupSerializeAllocBytes,
SetupDeserializeAllocBytes = serializer.SetupDeserializeAllocBytes,
IsRoundTripOnly = serializer.IsRoundTripOnly
};
// Group label for in-place \r progress. Identifies (cell × serializer) so a stuck benchmark
// is visibly stuck on a specific row at a specific %% rather than silently hanging.
var groupLabel = $"{result.SerializerName}";
if (serializer.IsRoundTripOnly)
{
// Round-trip-only benchmarks (NamedPipe etc.): single phase — Serialize() performs the full RT,
// Deserialize() is a no-op. We use the Ser-phase entry-points (WarmupSerialize) to warm the
// entire round-trip path, then record into the RT result columns.
if (mode is BenchmarkOpMode.All or BenchmarkOpMode.Serialize)
{
ForceGcCollect();
serializer.WarmupSerialize(Configuration.WarmupIterations);
if (Configuration.JitSleep > 0) Thread.Sleep(Configuration.JitSleep);
var rtIter = CalibrateIterations(() => serializer.Serialize(), Configuration.TargetSampleMs);
var (rtMed, rtMin, rtMax, rtStd) = RunTimed(() => serializer.Serialize(), rtIter, $"{groupLabel} [RT timing]");
result.RoundTripTimeMs = rtMed;
result.RoundTripTimeMinMs = rtMin;
result.RoundTripTimeMaxMs = rtMax;
result.RoundTripTimeStdDevMs = rtStd;
result.RoundTripIterations = rtIter;
// Process-wide allocation measurement: server-drain-thread allocations (server-side new byte[len])
// also show up — otherwise current-thread alloc would only count the client side and look ~halved.
result.RoundTripAllocBytesPerOp = MeasureAllocation(() => serializer.Serialize(), rtIter, $"{groupLabel} [RT alloc]", processWide: true);
}
// mode == BenchmarkOpMode.Deserialize alone is meaningless for a round-trip-only benchmark; skip silently.
}
else
{
// ── Ser phase ── isolated warmup → Configuration.JitSleep → calibrate → time → alloc; preceded by GC.Collect.
if (mode is BenchmarkOpMode.All or BenchmarkOpMode.Serialize)
{
ForceGcCollect();
serializer.WarmupSerialize(Configuration.WarmupIterations);
if (Configuration.JitSleep > 0) Thread.Sleep(Configuration.JitSleep);
var serIter = CalibrateIterations(() => serializer.Serialize(), Configuration.TargetSampleMs);
var (serMed, serMin, serMax, serStd) = RunTimed(() => serializer.Serialize(), serIter, $"{groupLabel} [Ser timing]");
result.SerializeTimeMs = serMed;
result.SerializeTimeMinMs = serMin;
result.SerializeTimeMaxMs = serMax;
result.SerializeTimeStdDevMs = serStd;
result.SerializeIterations = serIter;
// Dedicated alloc-only sample (separate from timing samples; keeps timing pure)
result.SerializeAllocBytesPerOp = MeasureAllocation(() => serializer.Serialize(), serIter, $"{groupLabel} [Ser alloc]");
}
// ── Des phase ── isolated warmup → Configuration.JitSleep → calibrate → time → alloc; preceded by GC.Collect.
// The GC.Collect here is critical: it discards the Ser-phase's write-buffer pool churn so the
// Des-phase's allocation measurement reflects ONLY Des-side allocations (deserialized object graph).
if (mode is BenchmarkOpMode.All or BenchmarkOpMode.Deserialize)
{
ForceGcCollect();
serializer.WarmupDeserialize(Configuration.WarmupIterations);
if (Configuration.JitSleep > 0) Thread.Sleep(Configuration.JitSleep);
var desIter = CalibrateIterations(() => serializer.Deserialize(), Configuration.TargetSampleMs);
var (desMed, desMin, desMax, desStd) = RunTimed(() => serializer.Deserialize(), desIter, $"{groupLabel} [Des timing]");
result.DeserializeTimeMs = desMed;
result.DeserializeTimeMinMs = desMin;
result.DeserializeTimeMaxMs = desMax;
result.DeserializeTimeStdDevMs = desStd;
result.DeserializeIterations = desIter;
result.DeserializeAllocBytesPerOp = MeasureAllocation(() => serializer.Deserialize(), desIter, $"{groupLabel} [Des alloc]");
}
// Compose RT from Ser+Des. Because Ser and Des may have DIFFERENT iter counts post-calibration,
// batch-time addition would be misleading. Instead: compute per-op µs (iter-independent),
// then synthesize RoundTripTimeMs against RoundTripIterations = max(serIter, desIter) so that
// RoundTripTimeMs / RoundTripIterations * 1000 == Output.SerPerOp + Output.DesPerOp.
var serPerOp = BenchmarkReportWriter.ToPerOpMicros(result.SerializeTimeMs, result.SerializeIterations);
var desPerOp = BenchmarkReportWriter.ToPerOpMicros(result.DeserializeTimeMs, result.DeserializeIterations);
var rtPerOp = serPerOp + desPerOp;
result.RoundTripIterations = Math.Max(result.SerializeIterations, result.DeserializeIterations);
result.RoundTripTimeMs = rtPerOp / 1000.0 * result.RoundTripIterations;
result.RoundTripAllocBytesPerOp = result.SerializeAllocBytesPerOp + result.DeserializeAllocBytesPerOp;
}
results.Add(result);
BenchmarkReportWriter.PrintResult(result);
}
// Dispose any IDisposable serializers (NamedPipe / FileStream variants own OS resources that must be released
// before the next test data builds new ones — otherwise pipes / handles leak across test cells).
foreach (var s in serializers) (s as IDisposable)?.Dispose();
return results;
}
/// <summary>
/// Phase 2 multi-variant test-data builder. Constructs each cell in both the _All_False and
/// _All_True families, then cross-registers _All_True on the _All_False primaries so the
/// CreateSerializers downstream can pick the matching variant per AcBinary options preset.
/// </summary>
/// <remarks>
/// Memory cost: ~600 KB across 5 cells (Large dominates at ~340 KB for both variants). The two
/// families are built independently — same data values + same numeric sequence (per-family
/// _idCounter reset). MemPack/MsgPack benchmarks consume the _All_True variant canonically;
/// AcBinary's variant is preset-dependent (see CreateSerializers).
/// </remarks>
private static List<TestDataSet> BuildMultiVariantTestDataSets()
{
var allFalse = BenchmarkTestDataProvider_All_False.CreateTestDataSets();
var allTrue = BenchmarkTestDataProvider.CreateTestDataSets();
// Zip by ordinal — both providers emit the same 5 cells in the same order
// (Small / Medium / Large / Repeated / Deep), confirmed by their identical
// CreateTestDataSets call sequence on the generic base.
for (var i = 0; i < allFalse.Count; i++)
{
var falseDs = (TestDataSet<TestOrder_All_False>)allFalse[i];
var trueDs = (TestDataSet<TestOrder_All_True>)allTrue[i];
falseDs.RegisterVariant(trueDs.Order);
}
return allFalse;
}
/// <summary>
/// Phase 2 variant dispatch rule for AcBinary: a preset uses <c>TestOrder_All_False</c> iff every
/// AcBinary "feature flag" is off (no string interning, no reference handling, no metadata, no
/// property filter). Any "true"-flagged feature promotes the benchmark to <c>TestOrder_All_True</c>
/// — the richer graph + opt-out attribute model exercises the feature's deduplication / dispatch
/// path on real shared-reference content. WireMode, SGen mode, and Compression are encoding-axis
/// options and intentionally NOT part of this decision (they don't change which graph shape is
/// meaningful to feed).
/// </summary>
private static bool UsesAllFalseVariant(AcBinarySerializerOptions options) =>
options.UseStringInterning == StringInterningMode.None &&
options.ReferenceHandling == ReferenceHandlingMode.None &&
!options.UseMetadata &&
options.PropertyFilter == null;
// Per-class factory helpers — each returns ISerializerBenchmark closed over the variant T
// selected by UsesAllFalseVariant(options). Compile-time T at the new T() call site preserves
// SGen apples-to-apples (no runtime reflection, no type erasure across the JIT boundary).
private static ISerializerBenchmark MakeAcBinary(TestDataSet td, AcBinarySerializerOptions opt, string preset) =>
UsesAllFalseVariant(opt)
? new AcBinaryBenchmark<TestOrder_All_False>(td.GetOrder<TestOrder_All_False>(), opt, preset)
: new AcBinaryBenchmark<TestOrder_All_True>(td.GetOrder<TestOrder_All_True>(), opt, preset);
private static ISerializerBenchmark MakeAcBinaryBufferWriter(TestDataSet td, AcBinarySerializerOptions opt, string preset) =>
UsesAllFalseVariant(opt)
? new AcBinaryBufferWriterBenchmark<TestOrder_All_False>(td.GetOrder<TestOrder_All_False>(), opt, preset)
: new AcBinaryBufferWriterBenchmark<TestOrder_All_True>(td.GetOrder<TestOrder_All_True>(), opt, preset);
private static ISerializerBenchmark MakeAcBinaryFreshBufferWriter(TestDataSet td, AcBinarySerializerOptions opt, string preset) =>
UsesAllFalseVariant(opt)
? new AcBinaryFreshBufferWriterBenchmark<TestOrder_All_False>(td.GetOrder<TestOrder_All_False>(), opt, preset)
: new AcBinaryFreshBufferWriterBenchmark<TestOrder_All_True>(td.GetOrder<TestOrder_All_True>(), opt, preset);
private static ISerializerBenchmark MakeAcBinaryNamedPipe(TestDataSet td, AcBinarySerializerOptions opt, string preset) =>
UsesAllFalseVariant(opt)
? new AcBinaryNamedPipeBenchmark<TestOrder_All_False>(td.GetOrder<TestOrder_All_False>(), opt, preset)
: new AcBinaryNamedPipeBenchmark<TestOrder_All_True>(td.GetOrder<TestOrder_All_True>(), opt, preset);
private static ISerializerBenchmark MakeAcBinaryNamedPipeRaw(TestDataSet td, AcBinarySerializerOptions opt, string preset) =>
UsesAllFalseVariant(opt)
? new AcBinaryNamedPipeRawByteArrayBenchmark<TestOrder_All_False>(td.GetOrder<TestOrder_All_False>(), opt, preset)
: new AcBinaryNamedPipeRawByteArrayBenchmark<TestOrder_All_True>(td.GetOrder<TestOrder_All_True>(), opt, preset);
private static ISerializerBenchmark MakeAcBinaryInMemoryPipe(TestDataSet td, AcBinarySerializerOptions opt, string preset) =>
UsesAllFalseVariant(opt)
? new AcBinaryInMemoryPipeBenchmark<TestOrder_All_False>(td.GetOrder<TestOrder_All_False>(), opt, preset)
: new AcBinaryInMemoryPipeBenchmark<TestOrder_All_True>(td.GetOrder<TestOrder_All_True>(), opt, preset);
private static ISerializerBenchmark MakeAcBinaryInMemoryRaw(TestDataSet td, AcBinarySerializerOptions opt, string preset) =>
UsesAllFalseVariant(opt)
? new AcBinaryInMemoryRawByteArrayBenchmark<TestOrder_All_False>(td.GetOrder<TestOrder_All_False>(), opt, preset)
: new AcBinaryInMemoryRawByteArrayBenchmark<TestOrder_All_True>(td.GetOrder<TestOrder_All_True>(), opt, preset);
private static List<ISerializerBenchmark> CreateSerializers(TestDataSet testData, SerializerSelectionMode serializerMode)
{
// Phase 2 variant dispatch (refined): AcBinary picks variant per UsesAllFalseVariant(options).
// MemPack / MsgPack canonically use _All_False (no AcBinary opt-in/opt-out axis — both
// produce identical MemPack/MsgPack wire on either variant since their contract is family-
// agnostic). `orderFalse` is the cell primary; `orderTrue` is fetched on-demand by the AcBinary
// factory helpers when an options preset has a "true" flag.
var orderFalse = testData.GetOrder<TestOrder_All_False>();
// FastestByte mode — focused 1:1 comparison on the "fastest Byte[]" path.
// TWO benchmarks: AcBinary FastMode Byte[] (Compact UTF-8) + MemoryPack Byte[].
// - Compact: smallest wire, UTF-8 encode/decode CPU cost vs MemPack head-to-head.
// Tight optimization-iteration loop: ~30-45 sec vs full 2-3 min.
//
// FastWire row (UTF-16 raw memcpy) commented out for the current optimization sprint —
// we are tuning Compact mode against MemPack directly; FastWire was used as a noise-floor
// reference earlier. Re-enable when revisiting Fast wire-mode performance.
if (serializerMode == SerializerSelectionMode.FastestByte)
{
var fastestByteOptions = AcBinarySerializerOptions.FastMode;
fastestByteOptions.WireMode = Configuration.SelectedWireMode;
return new List<ISerializerBenchmark>
{
MakeAcBinary(testData, fastestByteOptions, "FastMode"),
//MakeAcBinary(testData, fastWireOptions, "FastMode (FastWire)"),
// MemPack uses _All_False (the AcBinary opt-in/opt-out axis doesn't apply — MemoryPackable
// serialises identical bytes either way; _All_False matches the orderFalse variant the test
// data factory already built, no extra graph allocation needed).
new MemoryPackBenchmark<TestOrder_All_False>(orderFalse, Configuration.SelectedWireMode, "Default"),
};
}
// AsyncPipe-only mode — return ONLY the AsyncPipe streaming benchmark (no other serializer).
// Streaming I/O has long-lived pipe setup + kernel-buffer overhead that, when interleaved with
// the standard byte-array / IBufferWriter measurements, masks the steady-state numbers. Run it
// in isolation so the timing numbers reflect ONLY the streaming path.
if (serializerMode == SerializerSelectionMode.AsyncPipe)
{
// NamedPipe — pipe-aligned chunk size for the long-lived IPC scenario. The chunkSize here
// drives the AsyncPipeWriterOutput's chunk-on-wire size (header + data, page-aligned thanks to
// the AcquireChunk fix) AND the kernel pipe buffer size (inBufferSize/outBufferSize on the
// NamedPipeServerStream ctor). Same value across both layers = one WriteFile(chunkSize) syscall
// fits blocking-free in one kernel pipe-buffer slot. Single source of truth for both app-level
// wire chunk AND kernel transfer unit; change ONLY this line when tuning.
var binaryFastModePipeChunkOnly = AcBinarySerializerOptions.FastMode;
binaryFastModePipeChunkOnly.BufferWriterChunkSize = Configuration.PipeChunkSize;
binaryFastModePipeChunkOnly.WireMode = Configuration.SelectedWireMode;
return new List<ISerializerBenchmark>
{
// Chunked-framed AsyncPipe: SerializeChunkedFramed + AsyncPipeReaderInput.DrainFromAsync.
// Measures the FULL streaming-I/O stack — wire framing + drain task + sliding-window buffer +
// MRES wait-on-byte-shortage — over a kernel NamedPipe.
MakeAcBinaryNamedPipe(testData, binaryFastModePipeChunkOnly, "FastMode (PipeChunk)"),
// Raw byte[] over NamedPipe (sync receive, no chunk-framing). Same kernel-pipe transport,
// same inBufferSize, but: serialize → byte[] → Stream.Write → Stream.Read → Deserialize<T>(byte[]).
// No drain task, no AsyncPipeReaderInput, no [201][UINT16][data]…[202] framing. Side-by-side with
// the chunked-row above this isolates AsyncPipe-framework-overhead (Δ vs raw) from
// kernel-transport-overhead (raw vs in-process Byte[]).
MakeAcBinaryNamedPipeRaw(testData, binaryFastModePipeChunkOnly, "FastMode (PipeRaw)"),
// Chunked-framed AsyncPipe over an IN-MEMORY System.IO.Pipelines.Pipe (NO NamedPipe, NO kernel).
// Same chunked-streaming code path (SerializeChunkedFramed → AsyncPipeReaderInput) but with the
// kernel-pipe replaced by a managed-only Pipe. Eliminates per-chunk syscall overhead (~30 µs/chunk
// on NamedPipe → ~1-2 µs/chunk on in-memory Pipe). Side-by-side with the NamedPipe row above this
// isolates pure CPU cost of the chunked-streaming framework (vs kernel-pipe transport cost) — the
// in-memory Pipe row should be much closer to the raw-byte[] row, validating that NamedPipe loopback
// is the worst-case benchmark scenario for chunked-streaming and not representative of real network
// / file / cross-thread Pipe scenarios.
MakeAcBinaryInMemoryPipe(testData, binaryFastModePipeChunkOnly, "FastMode (PipeChunk)"),
// Raw byte[] over IN-MEMORY direct cross-thread handoff (no transport at all). Apples-to-apples
// baseline for the in-memory chunked row above: same in-memory transport (zero kernel), but raw
// byte[] vs chunked-streaming wire format. Completes the 2x2 matrix [chunked,raw] × [kernel,memory].
MakeAcBinaryInMemoryRaw(testData, binaryFastModePipeChunkOnly, "FastMode (PipeRaw)"),
};
}
// Standard mode — all serializers EXCEPT AsyncPipe (the streaming benchmark is opt-in via the
// AsyncPipe menu / CLI mode, never bundled with the steady-state suite).
var binaryNoInternOption = AcBinarySerializerOptions.Default;
binaryNoInternOption.UseStringInterning = StringInterningMode.None;
binaryNoInternOption.WireMode = Configuration.SelectedWireMode;
var binaryDefaultNoSgenOption = AcBinarySerializerOptions.Default;
binaryDefaultNoSgenOption.UseGeneratedCode = false;
binaryDefaultNoSgenOption.WireMode = Configuration.SelectedWireMode;
var binaryFastModeNoSgenOption = AcBinarySerializerOptions.FastMode;
binaryFastModeNoSgenOption.UseGeneratedCode = false;
binaryFastModeNoSgenOption.WireMode = Configuration.SelectedWireMode;
var binaryFastModeOption = AcBinarySerializerOptions.FastMode;
binaryFastModeOption.WireMode = Configuration.SelectedWireMode;
// BufWr new — 4 KB chunk size for the FRESH ArrayBufferWriter scenario. The chunkSize here drives
// the serializer's GetSpan(N) request → the ArrayBufferWriter's internal allocation per call.
// Small chunk = small per-call allocation, optimum for one-shot serialization where each iteration
// allocates a fresh ABW. Independent of the AsyncPipe profile (different mechanism: alloc overhead
// vs syscall count).
var binaryFastModeBufWrChunk = AcBinarySerializerOptions.FastMode;
binaryFastModeBufWrChunk.BufferWriterChunkSize = Configuration.PipeChunkSize;
binaryFastModeBufWrChunk.WireMode = Configuration.SelectedWireMode;
// In-memory Pipe variant — same 4 KB chunkSize as the AsyncPipe mode, no kernel-pipe alignment
// concern (managed slabs are not page-aligned anyway). Drives SerializeChunkedFramed via the in-memory
// System.IO.Pipelines.Pipe (zero-copy slab handoff between producer and drain task).
var binaryFastModePipeChunkInMem = AcBinarySerializerOptions.FastMode;
binaryFastModePipeChunkInMem.BufferWriterChunkSize = Configuration.PipeChunkSize;
binaryFastModePipeChunkInMem.WireMode = Configuration.SelectedWireMode;
var defaultOptions = AcBinarySerializerOptions.Default;
defaultOptions.UseStringInterning = StringInterningMode.None;
defaultOptions.ReferenceHandling = ReferenceHandlingMode.OnlyId;
defaultOptions.WireMode = Configuration.SelectedWireMode;
return new List<ISerializerBenchmark>
{
// ============================================================
// AcBinary — Byte[] API (uncomment to compare option presets side-by-side)
// ============================================================
// Fastest Byte[] — SGen path (UseGeneratedCode=true, default).
MakeAcBinary(testData, binaryFastModeOption, "FastMode"),
// Fastest Byte[] — Runtime path (UseGeneratedCode=false). Same wire/options, no source-generated dispatch.
// Always paired with the SGen variant so every layer can compare the SGen speed-up apples-to-apples.
// NativeAOT-safe: AcSerializerCommon.Create*Getter/Setter falls back to reflection-based delegates
// when RuntimeFeature.IsDynamicCodeSupported is false (slower but works under AOT publish).
MakeAcBinary(testData, binaryFastModeNoSgenOption, "FastMode"),
// Default preset Byte[] — RefHandling=OnlyId (deduplicates IId-shared references on the wire) +
// UseStringInterning=All (deduplicates repeated strings). Showcases the Default preset's wire-size
// and CPU trade-off vs FastMode on the ~20% IId-ref / repeated-string test data.
// Default preset (ReferenceHandling=OnlyId + StringInterning) → _All_True graph.
// Phase 2 variant-dispatch rule: any options preset with a "true"-flagged feature uses
// the _All_True family (rich graph, opt-out AcBinarySerializable attribute matches).
MakeAcBinary(testData, defaultOptions, "Default"),
//MakeAcBinary(testData, binaryDefaultNoSgenOption, "Default"),
//MakeAcBinary(testData, AcBinarySerializerOptions.WithoutReferenceHandling, "NoRef"),
//MakeAcBinary(testData, binaryNoInternOption, "NoIntern"),
// AcBinary via IBufferWriter (reused ArrayBufferWriter — long-running service / batch scenario)
MakeAcBinaryBufferWriter(testData, binaryFastModeOption, "FastMode"),
// AcBinary via IBufferWriter (FRESH ArrayBufferWriter per call — one-shot scenario).
// 4 KB chunk size from binaryFastModeBufWrChunk — minimises the per-call ArrayBufferWriter
// allocation. Optimum for this scenario.
MakeAcBinaryFreshBufferWriter(testData, binaryFastModeBufWrChunk, "FastMode (4KB)"),
// AcBinary chunked-streaming over an IN-MEMORY Pipe (no kernel transport). Side-by-side with the
// Byte[] / IBufferWriter rows above this shows the chunked-streaming framework's pure CPU cost
// (no NamedPipe loopback noise) vs the simpler in-process serialize-then-deserialize patterns.
// The IO column shows "Pipe(in-mem)" — distinct from the NamedPipe AsyncPipe rows in [P] mode.
MakeAcBinaryInMemoryPipe(testData, binaryFastModePipeChunkInMem, "FastMode (PipeChunk)"),
// Raw byte[] over IN-MEMORY direct cross-thread handoff (no transport, no kernel, no Pipe). Apples-to-
// apples baseline for the in-memory chunked row above: same in-memory pattern, but raw byte[] vs
// chunked-streaming wire format. The IO column shows "Bytes(in-mem)".
MakeAcBinaryInMemoryRaw(testData, binaryFastModePipeChunkInMem, "FastMode (PipeRaw)"),
// AsyncPipe streaming over kernel NamedPipe (AcBinaryNamedPipeBenchmark) is intentionally OMITTED
// here — run it via the dedicated AsyncPipe menu [P] / CLI mode for isolated kernel-transport
// measurements.
// ============================================================
// MemoryPack — three I/O modes for apples-to-apples comparison
// ============================================================
// MemPack uses _All_False (see FastestByte-mode comment above for rationale).
new MemoryPackBenchmark<TestOrder_All_False>(orderFalse, Configuration.SelectedWireMode, "Default"),
new MemoryPackBufferWriterBenchmark<TestOrder_All_False>(orderFalse, Configuration.SelectedWireMode, "Default"),
new MemoryPackFreshBufferWriterBenchmark<TestOrder_All_False>(orderFalse, Configuration.SelectedWireMode, "Default"),
// ============================================================
// MessagePack — for legacy comparison
// ============================================================
#if !AYCODE_NATIVEAOT
// MessagePack v3's DynamicGenericResolver uses Activator.CreateInstance on trimmed
// ListFormatter<T> et al. — fails under NativeAOT publish with "No parameterless constructor".
// Excluded from the AOT build; available for regular JIT runs only.
new MessagePackBenchmark<TestOrder_All_False>(orderFalse, "ContractBased"),
#endif
// System.Text.Json (commented — JSON serializer for reference; not in active suite)
//new SystemTextJsonBenchmark<TestOrder_All_False>(orderFalse, "Default")
};
}
/// <summary>
/// Forces a full GC cycle at a phase boundary in the benchmark loop. Two-pass collect with finalizer drain
/// in between: the first pass moves managed garbage to the finalization queue, <c>WaitForPendingFinalizers</c>
/// runs the finalizers, the second pass reclaims any objects the finalizers released. After this returns the
/// heap is in a known-quiescent state — the next warmup/measurement phase starts on a clean slate, isolated
/// from the previous phase's residual allocations (write-buffer pools, intern cache, write-plan arrays, etc.).
/// Called between every Ser-phase / Des-phase boundary in <see cref="RunBenchmarksForTestData"/>.
/// </summary>
[MethodImpl(MethodImplOptions.NoInlining)]
internal static void ForceGcCollect()
{
GC.Collect(2, GCCollectionMode.Forced, blocking: true);
GC.WaitForPendingFinalizers();
GC.Collect(2, GCCollectionMode.Forced, blocking: true);
}
/// <summary>
/// Runs the action <paramref name="iterations"/> times for <see cref="Configuration.BenchmarkSamples"/> independent samples,
/// returning the median, min, and max elapsed time. Multi-sample design reduces single-run variance
/// from ~±15% to ~±5% by smoothing transient effects (background activity, thermal/turbo state).
/// When <see cref="Configuration.BenchmarkSamples"/> &lt;= 1, falls back to single-sample timing (Debug / quick mode).
/// When <paramref name="progressLabel"/> is non-null, emits in-place <c>\r</c> progress updates so a
/// stuck benchmark (e.g. deadlocked NamedPipe row) is visibly stuck at a specific %% rather than
/// silently hanging.
///
/// Stabilization (added 2026-05-07):
/// 1) Pilot sample is run BEFORE the recorded loop and discarded. The first measurement after
/// warmup tends to absorb residual JIT bookkeeping and GC bookkeeping; dropping it tightens
/// the min/max range without throwing away signal (the median is the SAME data as before).
/// 2) GC.Collect / WaitForPendingFinalizers / GC.Collect runs BEFORE every recorded sample.
/// Without this, GC pressure from sample N occasionally triggered a Gen-2 pause inside
/// sample N+1, painting it as an outlier; collecting up-front gives every sample the
/// same starting heap shape.
/// 3) Returns (median, min, max) so the caller can surface the inter-sample range — visible
/// noise floor for the row, replacing the previous "median only" view.
/// </summary>
internal static (double medianMs, double minMs, double maxMs, double stdDevMs) RunTimed(Action action, int iterations, string? progressLabel = null)
{
var samples = Configuration.BenchmarkSamples;
if (samples <= 1)
{
// Single-sample fast path (Debug or trivial run) — no allocation, no sort, no stddev.
var sw = Stopwatch.StartNew();
RunWithProgress(action, iterations, progressLabel, samples: 1, sampleIndex: 0);
sw.Stop();
var ms = sw.Elapsed.TotalMilliseconds;
EndProgress(progressLabel, ms);
return (ms, ms, ms, 0);
}
// Pilot sample (discarded). Counts as sample index 0 of (samples + 1) for progress display
// so the user sees an extra "warmup-ish" tick before the recorded samples start.
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
var pilotSw = Stopwatch.StartNew();
RunWithProgress(action, iterations, progressLabel, samples + 1, sampleIndex: 0);
pilotSw.Stop();
// intentionally not stored
var times = new double[samples];
for (var s = 0; s < samples; s++)
{
// Per-sample GC settle. Forces every sample to start from the same heap state, so
// a Gen-2 pause caused by the previous sample doesn't bleed into the next sample's
// timing. Cost is paid OUTSIDE the Stopwatch window — no impact on the measurement.
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
// Inter-sample thermal-settle: CPU boost-clock can drop mid-batch under sustained load
// (e.g. 10×250ms = 2.5 sec burst). InterSampleSettleMs lets the boost-clock state
// settle so later samples don't read systematically slower than early ones. Skip before
// the first sample (no prior heat to settle from). Set to 0 in Configuration to disable.
if (s > 0 && Configuration.InterSampleSettleMs > 0)
Thread.Sleep(Configuration.InterSampleSettleMs);
var sw = Stopwatch.StartNew();
RunWithProgress(action, iterations, progressLabel, samples + 1, sampleIndex: s + 1);
sw.Stop();
times[s] = sw.Elapsed.TotalMilliseconds;
}
// Capture min/max/sum/sumSq BEFORE sort to avoid order ambiguity (Array.Sort is in-place).
var minMs = double.MaxValue;
var maxMs = double.MinValue;
var sum = 0.0;
var sumSq = 0.0;
for (var i = 0; i < times.Length; i++)
{
var t = times[i];
sum += t;
sumSq += t * t;
if (t < minMs) minMs = t;
if (t > maxMs) maxMs = t;
}
// Population stddev (not sample-stddev — we treat the captured samples as the population for
// CV computation). variance = E[X²] - E[X]² with Math.Max(0, ...) guard against tiny negative
// values from FP rounding when samples are nearly identical.
var mean = sum / times.Length;
var variance = (sumSq / times.Length) - (mean * mean);
var stdDevMs = Math.Sqrt(Math.Max(0.0, variance));
Array.Sort(times);
// Trimmed median: when samples >= 4, drop the single min and single max (sorted-array
// first and last) and compute median on the remaining (samples - 2) entries. Removes the
// worst per-sample contamination (a thermal spike, OS preempt, or a GC pause that escaped
// the per-sample GC.Collect settle) without throwing away too much signal. The min/max /
// stdDev outputs still reflect the FULL sample population — the trim affects only the
// headline median figure, so the visible range still shows the actual measurement extremes.
var trimStart = samples >= 4 ? 1 : 0;
var trimCount = samples >= 4 ? samples - 2 : samples;
var medianMs = trimCount % 2 == 1
? times[trimStart + trimCount / 2]
: (times[trimStart + trimCount / 2 - 1] + times[trimStart + trimCount / 2]) / 2.0;
EndProgress(progressLabel, medianMs);
return (medianMs, minMs, maxMs, stdDevMs);
}
/// <summary>
/// Per-cell adaptive iteration calibration. Runs a 100-iter measurement after warmup and computes
/// how many iterations are needed to reach <see cref="Configuration.TargetSampleMs"/> wall-clock per sample.
/// Returns iter rounded UP to the nearest 1000, floored at 1000 (the prior fixed minimum) and
/// ceiling-capped at 200_000 (sanity bound for pathologically fast ops). In Debug single-sample mode
/// (<c>Configuration.BenchmarkSamples &lt;= 1</c>) returns the global <see cref="Configuration.TestIterations"/> unchanged —
/// calibration overhead is unjustified there. Calibration runs OUTSIDE the timed sample loop and
/// does NOT count toward warmup; its sole purpose is to measure per-op cost.
/// </summary>
internal static int CalibrateIterations(Action action, int targetMs)
{
if (Configuration.BenchmarkSamples <= 1) return Configuration.TestIterations; // Debug fast path
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
const int calibIter = 100;
var sw = Stopwatch.StartNew();
for (var i = 0; i < calibIter; i++) action();
sw.Stop();
var ms = sw.Elapsed.TotalMilliseconds;
// Pathologically-fast op below Stopwatch resolution — cap at ceiling (further calibration won't help).
if (ms <= 0.0001) return 200_000;
var iterPerMs = calibIter / ms;
var raw = (int)Math.Ceiling(targetMs * iterPerMs);
// Round UP to nearest 1000 — keeps numbers human-readable in the markdown output.
var rounded = ((raw + 999) / 1000) * 1000;
return rounded switch
{
< 1000 => 1000,
> 200_000 => 200_000,
_ => rounded
};
}
/// <summary>
/// Measures per-call allocation in bytes after a clean GC. Single dedicated sample (no median) — keeps
/// timing samples pure. When <paramref name="processWide"/> is <c>true</c>, uses
/// <see cref="GC.GetTotalAllocatedBytes"/> instead of <see cref="GC.GetAllocatedBytesForCurrentThread"/>
/// — needed for round-trip-only benchmarks (NamedPipe etc.) where the work happens across multiple
/// threads (server-side <c>new byte[len]</c> buffers, drain-pump-thread allocations). Per-thread mode
/// is slightly cleaner for in-memory benchmarks; process-wide mode is slightly noisier (background
/// threads / GC bookkeeping leak in) but over 1000 iterations the signal dominates.
/// </summary>
internal static long MeasureAllocation(Action action, int iterations, string? progressLabel = null, bool processWide = false)
{
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
var sw = Stopwatch.StartNew();
var before = processWide ? GC.GetTotalAllocatedBytes(precise: true) : GC.GetAllocatedBytesForCurrentThread();
RunWithProgress(action, iterations, progressLabel, samples: 1, sampleIndex: 0);
var after = processWide ? GC.GetTotalAllocatedBytes(precise: true) : GC.GetAllocatedBytesForCurrentThread();
sw.Stop();
EndProgress(progressLabel, sw.Elapsed.TotalMilliseconds);
return (after - before) / iterations;
}
// ============================================================================================
// Progress reporting — \r-driven in-place updates so a stuck benchmark surfaces the exact phase
// and % where it stopped, instead of appearing as a silent hang. Used by RunTimed and the
// MeasureAllocation* helpers when the caller passes a non-null progressLabel.
// ============================================================================================
// Tracks the longest line written by the current progress session, so EndProgress can clear
// any leftover characters from a prior longer line (avoids "ghost" trailing chars after \r).
private static int _progressLastLineLen;
/// <summary>
/// Runs <paramref name="action"/> <paramref name="iterations"/> times, emitting \r-overwriting
/// progress every ~10% (approx. 10 progress prints per sample). When <paramref name="label"/>
/// is null, runs without any progress output (zero overhead beyond a null check per iter).
/// </summary>
private static void RunWithProgress(Action action, int iterations, string? label, int samples, int sampleIndex)
{
if (label is null)
{
for (var i = 0; i < iterations; i++) action();
return;
}
// Batch-based progress emit — ~10 progress prints per sample. The inner loop is branchless
// (no per-iter modulo / progress check), so the per-iter overhead is bare `action()` cost.
// The outer loop drives the batches; progress emit happens once per batch on the boundary.
// This keeps sub-µs ops cleanly measurable — the prior `if ((i + 1) % step == 0)` check
// added a 1-2 cycle per-iter branch that distorted hot loops near the Stopwatch resolution.
var step = Math.Max(1, iterations / 10);
var done = 0;
while (done < iterations)
{
var batch = Math.Min(step, iterations - done);
// Inner tight loop: no progress check, no modulo. Just the measured action() calls.
for (var i = 0; i < batch; i++) action();
done += batch;
var pct = (int)(done * 100L / iterations);
var line = samples > 1
? $" > {label} sample {sampleIndex + 1}/{samples} {pct,3}% ({done}/{iterations})"
: $" > {label} {pct,3}% ({done}/{iterations})";
System.Console.Write('\r');
System.Console.Write(line);
if (line.Length < _progressLastLineLen)
System.Console.Write(new string(' ', _progressLastLineLen - line.Length));
_progressLastLineLen = line.Length;
}
}
/// <summary>
/// Closes a progress line cleanly: clears any leftover chars and writes a final "done" line on
/// the same row, terminated by \n so subsequent <c>WriteLine</c> calls render below.
/// </summary>
private static void EndProgress(string? label, double elapsedMs)
{
if (label is null) return;
var done = $" > {label} done in {elapsedMs,7:F1} ms";
System.Console.Write('\r');
System.Console.Write(done);
if (done.Length < _progressLastLineLen)
System.Console.Write(new string(' ', _progressLastLineLen - done.Length));
System.Console.WriteLine();
_progressLastLineLen = 0;
}
/// <summary>
/// Validates MemoryPack setup at startup. Aborts the benchmark if TestOrder_All_True is not [MemoryPackable].
/// Without this attribute, MemoryPack falls back to runtime resolver (slower) — comparison would be INVALID.
/// </summary>
internal static void ValidateMemoryPackSetup()
{
var typesToCheck = new[] { typeof(TestOrder_All_True) };
foreach (var type in typesToCheck)
{
var hasAttr = type.GetCustomAttributes(typeof(MemoryPackableAttribute), inherit: true).Any();
if (!hasAttr)
{
System.Console.Error.WriteLine($"❌ FATAL: {type.FullName} is not [MemoryPackable] — MemoryPack would fall back to runtime resolver, comparison is INVALID for SGen-vs-SGen claim.");
System.Console.Error.WriteLine("Add [MemoryPackable] to the type and any nested types referenced from it.");
Environment.Exit(1);
}
}
}
/// <summary>
/// Filters test data sets by layer keyword. Layered approach lets you run only what's needed for the iteration cadence.
/// P1: only "Core" data exists (Small/Medium/Large/Repeated/Deep). Comprehensive and Edge layers will be expanded in P2.
/// </summary>
internal static List<TestDataSet> FilterByLayer(List<TestDataSet> all, BenchmarkLayer layer)
{
if (layer == BenchmarkLayer.All) return all.ToList();
var coreNames = new[] { "Small", "Medium", "Large", "Repeated", "Deep" };
// P2 will add: "Flat", "Polymorphic", "Collection", "Numeric", "NonAscii", etc.
var comprehensiveExtras = new string[] { /* P2 */ };
// P3 will add: "ColdStart", "VeryLarge", "PathologicalString", etc.
var edgeExtras = new string[] { /* P3 */ };
return layer switch
{
BenchmarkLayer.Core => all.Where(t => StartsWithAny(t.Name, coreNames)).ToList(),
BenchmarkLayer.Comprehensive => all.Where(t => StartsWithAny(t.Name, coreNames) || StartsWithAny(t.Name, comprehensiveExtras)).ToList(),
BenchmarkLayer.Edge => all.Where(t => StartsWithAny(t.Name, coreNames) || StartsWithAny(t.Name, comprehensiveExtras) || StartsWithAny(t.Name, edgeExtras)).ToList(),
// Single-cell A/B mini-suite filters — match by case-insensitive prefix on Name.
// Use case: tight optimization-iteration loop on one specific cell (e.g. `dotnet run -- repeated`
// or interactive menu shortcut), avoiding the full ~110 sec suite when only one cell is in scope.
BenchmarkLayer.Small => all.Where(t => t.Name.StartsWith("Small", StringComparison.OrdinalIgnoreCase)).ToList(),
BenchmarkLayer.Medium => all.Where(t => t.Name.StartsWith("Medium", StringComparison.OrdinalIgnoreCase)).ToList(),
BenchmarkLayer.Large => all.Where(t => t.Name.StartsWith("Large", StringComparison.OrdinalIgnoreCase)).ToList(),
BenchmarkLayer.Repeated => all.Where(t => t.Name.StartsWith("Repeated", StringComparison.OrdinalIgnoreCase)).ToList(),
BenchmarkLayer.Deep => all.Where(t => t.Name.StartsWith("Deep", StringComparison.OrdinalIgnoreCase)).ToList(),
_ => all.ToList()
};
static bool StartsWithAny(string name, string[] prefixes) => prefixes.Any(name.StartsWith);
}
}

View File

@ -1,234 +0,0 @@
using AyCode.Core.Serializers.Binaries;
using AyCode.Core.Tests.TestModels;
namespace AyCode.Core.Serializers.Console;
internal static class BenchmarkTestDataProvider
{
internal static List<TestDataSet> CreateTestDataSets()
{
return new List<TestDataSet>
{
CreateSmallTestData(),
CreateMediumTestData(),
CreateLargeTestData(),
CreateRepeatedStringsTestData(),
CreateDeepNestedTestData()
};
}
internal static TestOrder CreateProfilerOrder()
{
TestDataFactory.ResetIdCounter();
var sharedTag = TestDataFactory.CreateTag("SharedTag");
var sharedUser = TestDataFactory.CreateUser("shareduser");
return TestDataFactory.CreateOrder(
itemCount: 3,
palletsPerItem: 3,
measurementsPerPallet: 3,
pointsPerMeasurement: 4,
sharedTag: sharedTag,
sharedUser: sharedUser);
}
private static TestDataSet CreateSmallTestData()
{
TestDataFactory.ResetIdCounter();
var sharedTag = TestDataFactory.CreateTag("SharedTag");
var sharedUser = TestDataFactory.CreateUser("shareduser");
var order = TestDataFactory.CreateOrder(
itemCount: 2,
palletsPerItem: 2,
measurementsPerPallet: 2,
pointsPerMeasurement: 2,
sharedTag: sharedTag,
sharedUser: sharedUser);
ClearDeepLevelRefs(order);
return new TestDataSet("Small (2x2x2x2)", order, iidRefPercent: 10);
}
private static TestDataSet CreateMediumTestData()
{
TestDataFactory.ResetIdCounter();
var sharedTag = TestDataFactory.CreateTag("SharedTag");
var sharedUser = TestDataFactory.CreateUser("shareduser");
var sharedMeta = TestDataFactory.CreateMetadata("shared", withChild: true);
var sharedPreferences = new UserPreferences
{
Theme = "dark",
Language = "en-US",
NotificationsEnabled = true,
EmailDigestFrequency = "weekly"
};
sharedUser.Preferences = sharedPreferences;
var order = TestDataFactory.CreateOrder(
itemCount: 3,
palletsPerItem: 3,
measurementsPerPallet: 3,
pointsPerMeasurement: 4,
sharedTag: sharedTag,
sharedUser: sharedUser,
sharedMetadata: sharedMeta,
sharedPreferences: sharedPreferences);
ClearDeepLevelRefs(order);
return new TestDataSet("Medium (3x3x3x4)", order, iidRefPercent: 10);
}
private static TestDataSet CreateLargeTestData()
{
TestDataFactory.ResetIdCounter();
var sharedTag = TestDataFactory.CreateTag("SharedTag");
var sharedUser = TestDataFactory.CreateUser("shareduser");
var sharedPreferences = new UserPreferences
{
Theme = "light",
Language = "de-DE",
NotificationsEnabled = false,
EmailDigestFrequency = "daily"
};
sharedUser.Preferences = sharedPreferences;
var order = TestDataFactory.CreateOrder(
itemCount: 5,
palletsPerItem: 5,
measurementsPerPallet: 5,
pointsPerMeasurement: 10,
sharedTag: sharedTag,
sharedUser: sharedUser,
sharedPreferences: sharedPreferences);
ClearDeepLevelRefs(order);
return new TestDataSet("Large (5x5x5x10)", order, iidRefPercent: 10);
}
private static TestDataSet CreateRepeatedStringsTestData()
{
TestDataFactory.ResetIdCounter();
var sharedTag = TestDataFactory.CreateTag("RepeatedTag");
var sharedUser = TestDataFactory.CreateUser("repeateduser");
var sharedPreferences = new UserPreferences
{
Theme = "dark",
Language = "en-US",
NotificationsEnabled = true,
EmailDigestFrequency = "weekly"
};
sharedUser.Preferences = sharedPreferences;
var order = TestDataFactory.CreateOrder(
itemCount: 10,
palletsPerItem: 2,
measurementsPerPallet: 2,
pointsPerMeasurement: 2,
sharedTag: sharedTag,
sharedUser: sharedUser,
sharedPreferences: sharedPreferences);
foreach (var item in order.Items)
{
item.Status = TestStatus.Processing;
item.ProductName = "CommonProductName_RepeatedForTesting";
}
ClearDeepLevelRefs(order);
return new TestDataSet("Repeated Strings (10 items)", order, iidRefPercent: 10);
}
private static TestDataSet CreateDeepNestedTestData()
{
TestDataFactory.ResetIdCounter();
var sharedTag = TestDataFactory.CreateTag("DeepTag");
var sharedUser = TestDataFactory.CreateUser("deepuser");
var sharedCategory = TestDataFactory.CreateCategory("DeepCategory");
var sharedPreferences = new UserPreferences
{
Theme = "light",
Language = "fr-FR",
NotificationsEnabled = false,
EmailDigestFrequency = "monthly"
};
sharedUser.Preferences = sharedPreferences;
var order = TestDataFactory.CreateOrder(
itemCount: 2,
palletsPerItem: 4,
measurementsPerPallet: 4,
pointsPerMeasurement: 8,
sharedTag: sharedTag,
sharedUser: sharedUser,
sharedPreferences: sharedPreferences,
sharedCategory: sharedCategory);
ClearDeepLevelRefs(order);
return new TestDataSet("Deep Nested (2x4x4x8)", order, iidRefPercent: 10);
}
private static void ClearDeepLevelRefs(TestOrder order)
{
foreach (var item in order.Items)
{
foreach (var pallet in item.Pallets)
{
pallet.Tag = null;
pallet.Inspector = null;
pallet.Category = null;
foreach (var measurement in pallet.Measurements)
{
measurement.Tag = null;
measurement.Operator = null;
foreach (var point in measurement.Points)
{
point.Tag = null;
point.Verifier = null;
}
}
}
}
}
}
internal sealed class TestDataSet
{
public string Name { get; }
public TestOrder Order { get; }
/// <summary>
/// Percentage of IId shared references in the data (0-100).
/// Higher values mean more deduplication benefit for Default mode.
/// </summary>
public int IIdRefPercent { get; }
public TestDataSet(string name, TestOrder order, int iidRefPercent = 0)
{
Name = name;
Order = order;
IIdRefPercent = iidRefPercent;
}
/// <summary>
/// Gets display name including IId ref percentage if set.
/// </summary>
public string DisplayName => IIdRefPercent > 0
? $"{Name} [{IIdRefPercent}% IId refs]"
: Name;
}

View File

@ -0,0 +1,120 @@
using AyCode.Core.Serializers.Binaries;
using AyCode.Core.Tests.TestModels;
using System.Runtime.CompilerServices;
using System.Text;
namespace AyCode.Core.Serializers.Console;
/// <summary>
/// Configuration state for the benchmark application. Holds compile-time and runtime constants,
/// per-run mutable settings (WireMode, charset, iteration counts), and the attribute-flag
/// aggregation that drives the per-row Options column. Split out from <c>Program.cs</c> so the
/// entry-point file can focus on UX-flow and benchmark orchestration; everything in this class
/// is config / state — no benchmark logic. Single instance (static class) — the application is
/// a one-shot process, no multi-tenant state isolation needed.
/// </summary>
internal static class Configuration
{
#if AYCODE_NATIVEAOT
internal const string BuildConfiguration = "NativeAOT";
#elif DEBUG
internal const string BuildConfiguration = "Debug";
#elif SGEN_ONLY
internal const string BuildConfiguration = "SGenOnly";
#else
internal const string BuildConfiguration = "Release";
#endif
#if DEBUG
internal static int WarmupIterations = 0;
internal static int TestIterations = 1;
internal static int BenchmarkSamples = 1; // Debug: single sample, fast iteration
#else
internal static int WarmupIterations = 5000; //10000 — per-phase (Ser AND Des get their own warmup separately)
internal static int TestIterations = 1000; //1000
internal static int BenchmarkSamples = 10;
#endif
// Interactive settings: selected AcBinary wire mode for benchmark runs.
// 1 = Compact, 2 = Fast
internal static WireMode SelectedWireMode = WireMode.Compact;
// Engine / IO mode / Dispatch mode identifiers → Benchmarks/BenchmarkEnums.cs (typed enums with ToDisplay)
// Single source of truth for the chunk size used by ALL pipe-related benchmarks (NamedPipe PipeChunk,
// NamedPipe PipeRaw, in-memory Pipe, in-memory RawMem) AND the NamedPipe server's inBufferSize/outBufferSize.
// Same value across both layers ensures apples-to-apples comparison: chunked-streaming chunk-on-wire size
// matches the kernel pipe-buffer slot exactly. Tweak HERE when experimenting; do NOT scatter chunkSize
// overrides across individual benchmark rows.
internal const int PipeChunkSize = 4096;
// Per-cell adaptive iteration target wall-clock duration. Each Ser/Des function calibrates its
// own iteration count post-warmup so the sample batch lands in this range — equalizes the
// per-sample window across cells of vastly different per-op cost (Small ~6 ns/op vs Large
// ~140 µs/op). Below ~100 ms Stopwatch precision and OS preempt spikes start to dominate.
internal const int TargetSampleMs = 250;
// CV (coefficient of variation = stddev / mean) threshold above which a row's range is flagged
// as "unstable" in the markdown output (⚠️ marker). 3% is a reasonable noise-floor expectation
// for stabilized in-memory benchmarks; rows above it should be discounted when reading
// sub-3% inter-engine deltas.
internal const double UnstableCVThreshold = 0.03;
// Lower-bound CV threshold for micro-optimization measurement reliability. Rows with CV in
// the (MicroOptCVThreshold, UnstableCVThreshold] range get a softer "⚠micro" flag — they are
// not unstable enough to be entirely dismissable, but sub-2% inter-engine deltas observed on
// such a row are at the edge of the noise floor and should be cross-checked (re-run, BDN).
// Use case: micro-opt sprints where a ~1-2% signal lives below the unstable threshold but the
// row's own CV is still high enough to make that signal suspect.
internal const double MicroOptCVThreshold = 0.015;
// Inter-sample cool-down delay (ms) inserted between recorded samples in the timed loop.
// Mitigates CPU thermal-throttling drift across a sustained burst (e.g. 10×250ms = 2.5 sec):
// without it, boost-clock can drop mid-batch on thermally-constrained hosts (laptops esp.),
// and the later samples in the batch read systematically slower than the early ones. 50ms is
// enough for boost-clock state to settle but cheap in total (~500ms / cell) — quick-bench
// workflow is not meaningfully slower.
internal const int InterSampleSettleMs = 50;
// JIT-tier-promotion drain delay between warmup and measurement.
// - JIT mode (RuntimeFeature.IsDynamicCodeCompiled == true): tiered JIT promotes hot methods
// in a background thread; we wait briefly for the queue to drain so the first measurement
// sample doesn't catch a Tier-0 → Tier-1 transition mid-flight.
// - AOT mode (NativeAOT publish): no dynamic compilation happens; the sleep is pure noise.
// 250ms (vs the historical 3000ms) is sufficient for a few-method working set under .NET 9's
// tiered JIT — empirically the queue drains in <100ms for the bench's hot path.
internal static int JitSleep => RuntimeFeature.IsDynamicCodeCompiled ? 250 : 0;
// OptionsPreset values are passed per-instance (constructor argument), not constants —
// each CreateSerializers call line specifies its own preset name (e.g. "FastMode", "NoIntern").
internal static readonly UTF8Encoding Utf8NoBom = new(encoderShouldEmitUTF8Identifier: false);
/// <summary>
/// Returns a human-readable name for the currently-active <c>BenchmarkTestDataProvider.LongStringSuffix</c>
/// charset. Returns "Custom" when the suffix doesn't match any of the predefined
/// <see cref="CharsetSuffixes"/> constants. Used in menu state display, console run header, and
/// the .LLM / .log output headers so per-charset bench files are self-documenting.
/// </summary>
internal static string GetCurrentCharsetName()
{
var s = BenchmarkTestDataProvider.LongStringSuffix;
return s switch
{
CharsetSuffixes.AsciiFix => nameof(CharsetSuffixes.AsciiFix),
CharsetSuffixes.AsciiShort => nameof(CharsetSuffixes.AsciiShort),
CharsetSuffixes.AsciiLong => nameof(CharsetSuffixes.AsciiLong),
CharsetSuffixes.Latin1Fix => nameof(CharsetSuffixes.Latin1Fix),
CharsetSuffixes.Latin1Short => nameof(CharsetSuffixes.Latin1Short),
CharsetSuffixes.Latin1Long => nameof(CharsetSuffixes.Latin1Long),
CharsetSuffixes.CjkBmpShort => nameof(CharsetSuffixes.CjkBmpShort),
CharsetSuffixes.CjkBmpLong => nameof(CharsetSuffixes.CjkBmpLong),
CharsetSuffixes.CyrillicShort => nameof(CharsetSuffixes.CyrillicShort),
CharsetSuffixes.CyrillicLong => nameof(CharsetSuffixes.CyrillicLong),
CharsetSuffixes.MixedShort => nameof(CharsetSuffixes.MixedShort),
CharsetSuffixes.MixedLong => nameof(CharsetSuffixes.MixedLong),
_ => "Custom"
};
}
}

View File

@ -0,0 +1,255 @@
using AyCode.Core.Benchmarks.Workloads.Scenarios;
using AyCode.Core.Serializers.Binaries;
using AyCode.Core.Tests.TestModels;
namespace AyCode.Core.Serializers.Console;
/// <summary>
/// Interactive console menu for the benchmark application. Shown when the user runs without CLI args:
/// top-level layer/mode selection + nested Settings sub-menus (iteration counts, wire mode, charset).
/// All settings mutate <see cref="Configuration"/> in place; the menu loop returns control to the
/// caller (<c>Program.Main</c>) once the user picks a benchmark layer or quits.
/// </summary>
internal static class Menu
{
/// <summary>
/// Interactive menu shown when no CLI args. Returns the (layer, serializerMode) tuple, or null on Quit.
/// Loops on settings-changes ([S]) — user is returned to this menu after modifying iteration counts.
/// </summary>
internal static (BenchmarkLayer layer, SerializerSelectionMode serializerMode)? ShowInteractiveMenu()
{
while (true)
{
System.Console.WriteLine();
System.Console.WriteLine("╔══════════════════════════════════════════════════════════╗");
System.Console.WriteLine("║ AcBinary Benchmark Suite ║");
System.Console.WriteLine("╚══════════════════════════════════════════════════════════╝");
System.Console.WriteLine();
System.Console.WriteLine("Select benchmark layer:");
System.Console.WriteLine();
System.Console.WriteLine(" [1] Core — daily iteration");
System.Console.WriteLine(" [2] Comprehensive — release validation");
System.Console.WriteLine(" [3] Edge cases — refactor verification");
System.Console.WriteLine(" [A] All layers");
System.Console.WriteLine(" [F] FastestByte — AcBinary FastMode Byte[] vs MemoryPack Byte[] only (tight optimization loop)");
System.Console.WriteLine(" [P] AsyncPipe — streaming I/O isolation (only AsyncPipe, all test data)");
System.Console.WriteLine($" [S] Settings — Iteration / WireMode (current: {Configuration.SelectedWireMode})");
System.Console.WriteLine(" [Q] Quit");
System.Console.Write("\nSelection: ");
var key = System.Console.ReadKey(intercept: false).KeyChar;
System.Console.WriteLine();
switch (char.ToLower(key))
{
case '1': return (BenchmarkLayer.Core, SerializerSelectionMode.Standard);
case '2': return (BenchmarkLayer.Comprehensive, SerializerSelectionMode.Standard);
case '3': return (BenchmarkLayer.Edge, SerializerSelectionMode.Standard);
case 'a': return (BenchmarkLayer.All, SerializerSelectionMode.Standard);
case 'f': return (BenchmarkLayer.All, SerializerSelectionMode.FastestByte);
case 'p': return (BenchmarkLayer.All, SerializerSelectionMode.AsyncPipe);
case 's':
ShowSettingsMenu();
continue; // re-display the main menu after settings update
case 'q': return null;
default: return (BenchmarkLayer.All, SerializerSelectionMode.Standard);
}
}
}
/// <summary>
/// Settings sub-menu — dispatches to per-area sub-menus (iteration counts, wire mode, charset).
/// Returns to the caller (which re-displays the main menu) when [B]ack is pressed.
/// </summary>
private static void ShowSettingsMenu()
{
while (true)
{
System.Console.WriteLine();
System.Console.WriteLine("─────────────────────────────────────────────");
System.Console.WriteLine("Settings");
System.Console.WriteLine("─────────────────────────────────────────────");
System.Console.WriteLine(" [1] Iteration — Warmup / Iterations / Samples");
System.Console.WriteLine($" [2] WireMode — current: {Configuration.SelectedWireMode}");
System.Console.WriteLine($" [3] Charset — current: {Configuration.GetCurrentCharsetName()}");
System.Console.WriteLine(" [B] Back");
System.Console.Write("\nSelection: ");
var key = System.Console.ReadKey(intercept: false).KeyChar;
System.Console.WriteLine();
switch (char.ToLower(key))
{
case '1':
ShowIterationSettingsMenu();
break;
case '2':
ShowWireModeSettingsMenu();
break;
case '3':
ShowCharsetSettingsMenu();
break;
case 'b':
return;
default:
continue;
}
}
}
private static void ShowCharsetSettingsMenu()
{
while (true)
{
System.Console.WriteLine();
System.Console.WriteLine("─────────────────────────────────────────────");
System.Console.WriteLine("Charset settings — long-string suffix profile");
System.Console.WriteLine("─────────────────────────────────────────────");
System.Console.WriteLine($"Current: {Configuration.GetCurrentCharsetName()}");
System.Console.WriteLine();
System.Console.WriteLine(" All *Short = 40 char, all *Long = 280 char (= Short × 7) — length-consistent across charsets.");
System.Console.WriteLine();
System.Console.WriteLine(" [1] AsciiFix — empty suffix; baseline-only short values → FixStrAscii tier");
System.Console.WriteLine(" [2] AsciiShort — 40 char pure ASCII (quic × 8) → StringAscii tier");
System.Console.WriteLine(" [3] AsciiLong — 280 char pure ASCII → StringAscii tier");
System.Console.WriteLine(" [4] Latin1Fix — 5 char Hungarian (árví) → FixStr-lean tier");
System.Console.WriteLine(" [5] Latin1Short — 40 char Hungarian (árví × 8) → StringSmall tier");
System.Console.WriteLine(" [6] Latin1Long — 280 char Hungarian (default) → StringMedium tier");
System.Console.WriteLine(" [7] CjkBmpShort — 40 char CJK BMP (3-byte runs) → StringSmall tier");
System.Console.WriteLine(" [8] CjkBmpLong — 280 char CJK BMP → StringMedium tier");
System.Console.WriteLine(" [9] CyrillicShort — 40 char Cyrillic (2-byte runs) → StringSmall tier");
System.Console.WriteLine(" [0] CyrillicLong — 280 char Cyrillic → StringMedium tier");
System.Console.WriteLine(" [A] MixedShort — 40 char multi-codepage → StringSmall tier");
System.Console.WriteLine(" [C] MixedLong — 280 char multi-codepage → StringMedium tier");
System.Console.WriteLine(" [B] Back");
System.Console.Write("\nSelection: ");
var key = System.Console.ReadKey(intercept: false).KeyChar;
System.Console.WriteLine();
switch (char.ToLower(key))
{
case '1':
BenchmarkTestDataProvider.LongStringSuffix = CharsetSuffixes.AsciiFix;
System.Console.WriteLine("✓ Charset set to AsciiFix");
return;
case '2':
BenchmarkTestDataProvider.LongStringSuffix = CharsetSuffixes.AsciiShort;
System.Console.WriteLine("✓ Charset set to AsciiShort");
return;
case '3':
BenchmarkTestDataProvider.LongStringSuffix = CharsetSuffixes.AsciiLong;
System.Console.WriteLine("✓ Charset set to AsciiLong");
return;
case '4':
BenchmarkTestDataProvider.LongStringSuffix = CharsetSuffixes.Latin1Fix;
System.Console.WriteLine("✓ Charset set to Latin1Fix");
return;
case '5':
BenchmarkTestDataProvider.LongStringSuffix = CharsetSuffixes.Latin1Short;
System.Console.WriteLine("✓ Charset set to Latin1Short");
return;
case '6':
BenchmarkTestDataProvider.LongStringSuffix = CharsetSuffixes.Latin1Long;
System.Console.WriteLine("✓ Charset set to Latin1Long");
return;
case '7':
BenchmarkTestDataProvider.LongStringSuffix = CharsetSuffixes.CjkBmpShort;
System.Console.WriteLine("✓ Charset set to CjkBmpShort");
return;
case '8':
BenchmarkTestDataProvider.LongStringSuffix = CharsetSuffixes.CjkBmpLong;
System.Console.WriteLine("✓ Charset set to CjkBmpLong");
return;
case '9':
BenchmarkTestDataProvider.LongStringSuffix = CharsetSuffixes.CyrillicShort;
System.Console.WriteLine("✓ Charset set to CyrillicShort");
return;
case '0':
BenchmarkTestDataProvider.LongStringSuffix = CharsetSuffixes.CyrillicLong;
System.Console.WriteLine("✓ Charset set to CyrillicLong");
return;
case 'a':
BenchmarkTestDataProvider.LongStringSuffix = CharsetSuffixes.MixedShort;
System.Console.WriteLine("✓ Charset set to MixedShort");
return;
case 'c':
BenchmarkTestDataProvider.LongStringSuffix = CharsetSuffixes.MixedLong;
System.Console.WriteLine("✓ Charset set to MixedLong");
return;
case 'b':
return;
default:
continue;
}
}
}
private static void ShowIterationSettingsMenu()
{
System.Console.WriteLine();
System.Console.WriteLine("─────────────────────────────────────────────");
System.Console.WriteLine("Iteration settings — press Enter to keep current value");
System.Console.WriteLine("─────────────────────────────────────────────");
System.Console.WriteLine();
Configuration.WarmupIterations = PromptInt("WarmupIterations", Configuration.WarmupIterations, min: 0);
Configuration.TestIterations = PromptInt("TestIterations ", Configuration.TestIterations, min: 1);
Configuration.BenchmarkSamples = PromptInt("BenchmarkSamples", Configuration.BenchmarkSamples, min: 1);
System.Console.WriteLine();
System.Console.WriteLine($"✓ Iteration settings updated: Warmup={Configuration.WarmupIterations} | Iterations={Configuration.TestIterations} | Samples={Configuration.BenchmarkSamples}");
}
private static void ShowWireModeSettingsMenu()
{
while (true)
{
System.Console.WriteLine();
System.Console.WriteLine("─────────────────────────────────────────────");
System.Console.WriteLine("WireMode settings");
System.Console.WriteLine("─────────────────────────────────────────────");
System.Console.WriteLine($"Current: {Configuration.SelectedWireMode}");
System.Console.WriteLine(" [1] Compact");
System.Console.WriteLine(" [2] Fast");
System.Console.WriteLine(" [B] Back");
System.Console.Write("\nSelection: ");
var key = System.Console.ReadKey(intercept: false).KeyChar;
System.Console.WriteLine();
switch (char.ToLower(key))
{
case '1':
Configuration.SelectedWireMode = WireMode.Compact;
System.Console.WriteLine("✓ WireMode set to Compact");
return;
case '2':
Configuration.SelectedWireMode = WireMode.Fast;
System.Console.WriteLine("✓ WireMode set to Fast");
return;
case 'b':
return;
default:
continue;
}
}
}
/// <summary>
/// Prompts the user for an integer with a default (current value). Returns the current value if
/// the user presses Enter on empty input or if parsing fails / value is below the minimum.
/// </summary>
private static int PromptInt(string name, int currentValue, int min)
{
System.Console.Write($" {name} [{currentValue}]: ");
var input = System.Console.ReadLine()?.Trim() ?? "";
if (input.Length == 0) return currentValue;
if (int.TryParse(input, out var newValue) && newValue >= min) return newValue;
System.Console.WriteLine($" ! Invalid value (need int ≥ {min}) — keeping {currentValue}");
return currentValue;
}
}

View File

@ -1,16 +1,25 @@
using AyCode.Core.Compression;
using AyCode.Core.Compression;
using AyCode.Core.Serializers.Attributes;
using AyCode.Core.Serializers.Binaries;
using AyCode.Core.Tests.Serialization; // DrainFromAsync extension (test-only, used by benchmark)
using AyCode.Core.Tests.TestModels;
using MemoryPack;
#if !AYCODE_NATIVEAOT
using MessagePack;
using MessagePack.Resolvers;
#endif
using Microsoft.Extensions.Options;
using System.Buffers;
using System.Diagnostics;
using System.IO.Pipelines;
using System.IO.Pipes;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Text;
using System.Text.Json;
using AyCode.Core.Benchmarks.Workloads.Scenarios;
namespace AyCode.Core.Serializers.Console;
/// <summary>
@ -25,843 +34,146 @@ namespace AyCode.Core.Serializers.Console;
/// </summary>
public static class Program
{
private const string ResultsDirectory = @"H:\Applications\Aycode\Source\AyCode.Core\Test_Benchmark_Results\Benchmark";
#if DEBUG
private const string BuildConfiguration = "Debug";
#else
private const string BuildConfiguration = "Release";
#endif
// Serializer name constants
private const string SerializerMessagePack = "MessagePack";
private const string SerializerAcBinaryDefault = "AcBinary (Default)";
private const string SerializerAcBinaryDefaultNoSgen = "AcBinary (Def, NoSgen)";
private const string SerializerAcBinaryNoRef = "AcBinary (NoRef)";
private const string SerializerAcBinaryFastMode = "AcBinary (FastMode)";
private const string SerializerAcBinaryFastNoSgen = "AcBinary (Fast, NoSgen)";
private const string SerializerAcBinaryNoIntern = "AcBinary (NoIntern)";
private const string SerializerMemoryPack = "MemoryPack";
//private const string SerializerAcBinaryBufferWriter = "AcBinary (BufferWriter)";
//private const string SerializerSystemTextJson = "System.Text.Json";
private static readonly UTF8Encoding Utf8NoBom = new(encoderShouldEmitUTF8Identifier: false);
#if DEBUG
private static int WarmupIterations = 0;
private static int TestIterations = 1;
#else
private static int WarmupIterations = 5000;
private static int TestIterations = 1000;
//private static int WarmupIterations = 5000;
//private static int TestIterations = 2000;
#endif
// Configuration (constants, mutable state, attribute-flag aggregation) → Configuration.cs
// BuildAcBinary + GetMemPack helpers → Benchmarks/BenchmarkOptions.cs
public static void Main(string[] args)
{
// Set console encoding to UTF-8 for proper Unicode character display
System.Console.OutputEncoding = Encoding.UTF8;
var mode = args.Length > 0 ? args[0].ToLower() : "all";
// Setup validation — abort BEFORE any benchmark logic if MemoryPack baseline is invalid.
// Done early so user is told immediately, not after warmup.
BenchmarkLoop.ValidateMemoryPackSetup();
if (mode == "quick")
// CLI mode (args provided): run once, parse args, exit. Backward-compatible behaviour.
if (args.Length > 0)
{
WarmupIterations = 5;
TestIterations = 100;
mode = "all";
}
if (!TryParseCliArgs(args, out var layer, out var opMode, out var serializerMode))
return; // invalid args
// Profiler mode: warmup only, then exit (for memory profiler analysis)
if (mode == "profiler")
{
RunProfilerMode();
BenchmarkLoop.RunBenchmark(layer, opMode, serializerMode);
return;
}
System.Console.WriteLine("╔══════════════════════════════════════════════════════════════════════╗");
System.Console.WriteLine("║ COMPREHENSIVE SERIALIZER BENCHMARK SUITE ║");
System.Console.WriteLine("╚══════════════════════════════════════════════════════════════════════╝");
System.Console.WriteLine($"Mode: {mode} | Iterations: {TestIterations} | Warmup: {WarmupIterations}");
System.Console.WriteLine($"Build: {BuildConfiguration} | .NET: {Environment.Version}");
System.Console.WriteLine();
var allResults = new List<BenchmarkResult>();
var testDataSets = BenchmarkTestDataProvider.CreateTestDataSets();
foreach (var testData in testDataSets)
// Interactive mode (no args): loop the menu so the user doesn't have to restart between runs.
// Q exits the menu (and the application).
while (true)
{
System.Console.WriteLine($"\n{'═'.ToString().PadRight(70, '═')}");
System.Console.WriteLine($"TEST DATA: {testData.DisplayName}");
System.Console.WriteLine($"{'═'.ToString().PadRight(70, '═')}");
var selection = Menu.ShowInteractiveMenu();
if (selection == null) return; // user pressed Q
var results = RunBenchmarksForTestData(testData, mode);
allResults.AddRange(results);
}
BenchmarkLoop.RunBenchmark(selection.Value.layer, BenchmarkOpMode.All, selection.Value.serializerMode);
// Print grouped results
PrintGroupedResults(allResults, testDataSets);
// Save results to file
SaveResults(allResults, testDataSets);
System.Console.WriteLine("\n✓ Benchmark complete!");
}
/// <summary>
/// Profiler mode: warmup only, then EXIT immediately.
/// Usage: dotnet run -- profiler
/// </summary>
private static void RunProfilerMode()
{
System.Console.WriteLine("╔══════════════════════════════════════════════════════════════════════╗");
System.Console.WriteLine("║ PROFILER MODE (AcBinary only) ║");
System.Console.WriteLine("╚══════════════════════════════════════════════════════════════════════╝");
System.Console.WriteLine($"Build: {BuildConfiguration} | .NET: {Environment.Version}");
System.Console.WriteLine();
var order = BenchmarkTestDataProvider.CreateProfilerOrder();
var options = AcBinarySerializerOptions.WithoutReferenceHandling;
options.UseStringInterning = StringInterningMode.None;
byte[] bytes = AcBinarySerializer.Serialize(order, options);
// Warmup (fills caches)
System.Console.WriteLine("Warming up (1000 iterations)...");
for (var i = 0; i < 1000; i++)
{
_ = AcBinarySerializer.Serialize(order, options);
_ = AcBinaryDeserializer.Deserialize<TestOrder>(bytes);
}
Thread.Sleep(2000);
System.Console.WriteLine("Warmup complete. Caches are now populated.");
System.Console.WriteLine();
// HOT PATH - this is what the profiler should capture!
System.Console.WriteLine("Running hot path serialization (1000 iterations for profiling)...");
for (var i = 0; i < 1000; i++)
{
_ = AcBinarySerializer.Serialize(order, options);
//_ = AcBinaryDeserializer.Deserialize<TestOrder>(bytes);
}
System.Console.WriteLine("Running hot path deserialization (1000 iterations for profiling)...");
for (var i = 0; i < 1000; i++)
{
_ = AcBinaryDeserializer.Deserialize<TestOrder>(bytes);
}
System.Console.WriteLine("Hot path complete.");
System.Console.WriteLine();
System.Console.WriteLine(">>> ATTACH MEMORY PROFILER NOW <<<");
System.Console.WriteLine("Press any key to exit...");
System.Console.ReadKey(intercept: true);
System.Console.WriteLine();
System.Console.WriteLine("✓ Profiler mode complete. Exiting now.");
}
#region Benchmark Execution
private static List<BenchmarkResult> RunBenchmarksForTestData(TestDataSet testData, string mode)
{
var results = new List<BenchmarkResult>();
var serializers = CreateSerializers(testData);
// Warmup all serializers
System.Console.WriteLine($"Warming up ({WarmupIterations} iterations)...");
foreach (var serializer in serializers)
{
serializer.Warmup(WarmupIterations);
}
// Wait for tiered JIT background compilation to complete
Thread.Sleep(3000);
// Run benchmarks
System.Console.WriteLine($"Running benchmarks ({TestIterations} iterations)...\n");
foreach (var serializer in serializers)
{
var result = new BenchmarkResult
{
TestDataName = testData.DisplayName, // Use DisplayName for IId% info
SerializerName = serializer.Name,
SerializedSize = serializer.SerializedSize
};
if (mode is "all" or "serialize" or "ser")
{
result.SerializeTimeMs = RunTimed(() => serializer.Serialize(), TestIterations);
}
if (mode is "all" or "deserialize" or "des")
{
result.DeserializeTimeMs = RunTimed(() => serializer.Deserialize(), TestIterations);
}
results.Add(result);
PrintResult(result);
}
return results;
}
private static List<ISerializerBenchmark> CreateSerializers(TestDataSet testData)
{
var binaryNoInternOption = AcBinarySerializerOptions.Default;
binaryNoInternOption.UseStringInterning = StringInterningMode.None;
var binaryDefaultNoSgenOption = AcBinarySerializerOptions.Default;
binaryDefaultNoSgenOption.UseGeneratedCode = false;
var binaryFastModeNoSgenOption = AcBinarySerializerOptions.FastMode;
binaryFastModeNoSgenOption.UseGeneratedCode = false;
return new List<ISerializerBenchmark>
{
// AcBinary variants
//new AcBinaryBenchmark(testData.Order, AcBinarySerializerOptions.FastMode, SerializerAcBinaryFastMode),
////new AcBinaryBenchmark(testData.Order, binaryFastModeNoSgenOption, SerializerAcBinaryFastNoSgen),
//new AcBinaryBenchmark(testData.Order, AcBinarySerializerOptions.Default, SerializerAcBinaryDefault),
////new AcBinaryBenchmark(testData.Order, binaryDefaultNoSgenOption, SerializerAcBinaryDefaultNoSgen),
//new AcBinaryBenchmark(testData.Order, AcBinarySerializerOptions.WithoutReferenceHandling, SerializerAcBinaryNoRef),
//new AcBinaryBenchmark(testData.Order, binaryNoInternOption, SerializerAcBinaryNoIntern),
//new AcBinaryBenchmark(testData.Order, AcBinarySerializerOptions.FastMode, SerializerAcBinaryFastMode),
//new AcBinaryBenchmark(testData.Order, AcBinarySerializerOptions.FastMode, SerializerAcBinaryFastNoSgen),
new AcBinaryBenchmark(testData.Order, AcBinarySerializerOptions.FastMode, SerializerAcBinaryDefault),
//new AcBinaryBenchmark(testData.Order, AcBinarySerializerOptions.FastMode, SerializerAcBinaryDefaultNoSgen),
//new AcBinaryBenchmark(testData.Order, AcBinarySerializerOptions.FastMode, SerializerAcBinaryNoRef),
//new AcBinaryBenchmark(testData.Order, AcBinarySerializerOptions.FastMode, SerializerAcBinaryNoIntern),
// MemoryPack
new MemoryPackBenchmark(testData.Order, SerializerMemoryPack),
// MessagePack
new MessagePackBenchmark(testData.Order, SerializerMessagePack),
// AcBinary BufferWriter
//new AcBinaryBufferWriterBenchmark(testData.Order, AcBinarySerializerOptions.FastMode, SerializerAcBinaryBufferWriter),
// System.Text.Json
//new SystemTextJsonBenchmark(testData.Order, SerializerSystemTextJson)
};
}
private static double RunTimed(Action action, int iterations)
{
var sw = Stopwatch.StartNew();
for (var i = 0; i < iterations; i++)
{
action();
}
sw.Stop();
return sw.Elapsed.TotalMilliseconds;
}
#endregion
#region Serializer Implementations
private interface ISerializerBenchmark
{
string Name { get; }
int SerializedSize { get; }
void Warmup(int iterations);
void Serialize();
void Deserialize();
}
private sealed class AcBinaryBenchmark : ISerializerBenchmark
{
private readonly TestOrder _order;
private readonly AcBinarySerializerOptions _options;
private readonly byte[] _serialized;
public string Name { get; }
public int SerializedSize => _serialized.Length;
public AcBinaryBenchmark(TestOrder order, AcBinarySerializerOptions options, string name)
{
_order = order;
_options = options;
Name = name;
_serialized = AcBinarySerializer.Serialize(order, options);
//_options.UseCompression = Lz4CompressionMode.Block;
}
public void Warmup(int iterations)
{
for (var i = 0; i < iterations; i++)
{
Serialize();
Deserialize();
}
}
[MethodImpl(MethodImplOptions.NoInlining)]
public void Serialize()
{
AcBinarySerializer.Serialize(_order, _options);
//if (_options.ReferenceHandling != ReferenceHandlingMode.None || _options.UseStringInterning != StringInterningMode.None)
//{
// AcBinarySerializer.ScanOnly(_order, _options);
//}
//else AcBinarySerializer.Serialize(_order, _options);
}
[MethodImpl(MethodImplOptions.NoInlining)]
public void Deserialize() => AcBinaryDeserializer.Deserialize<TestOrder>(_serialized, _options);
}
private sealed class MemoryPackBenchmark : ISerializerBenchmark
{
private readonly TestOrder _order;
private readonly byte[] _serialized;
public string Name { get; }
public int SerializedSize => _serialized.Length;
public MemoryPackBenchmark(TestOrder order, string name)
{
_order = order;
Name = name;
_serialized = MemoryPackSerializer.Serialize(order);
}
public void Warmup(int iterations)
{
for (var i = 0; i < iterations; i++)
{
Serialize();
Deserialize();
}
}
[MethodImpl(MethodImplOptions.NoInlining)]
public void Serialize() => MemoryPackSerializer.Serialize(_order);
[MethodImpl(MethodImplOptions.NoInlining)]
public void Deserialize() => MemoryPackSerializer.Deserialize<TestOrder>(_serialized);
}
private sealed class MessagePackBenchmark : ISerializerBenchmark
{
private readonly TestOrder _order;
private readonly MessagePackSerializerOptions _options;
private readonly byte[] _serialized;
public string Name { get; }
public int SerializedSize => _serialized.Length;
public MessagePackBenchmark(TestOrder order, string name)
{
_order = order;
Name = name;
//_options = ContractlessStandardResolver.Options.WithCompression(MessagePackCompression.None);
//_options = ContractlessStandardResolver.Options.WithCompression(MessagePackCompression.Lz4Block);
_options = MessagePackSerializerOptions.Standard.WithCompression(MessagePackCompression.None);
_serialized = MessagePackSerializer.Serialize(order, _options);
}
public void Warmup(int iterations)
{
for (var i = 0; i < iterations; i++)
{
Serialize();
Deserialize();
}
}
[MethodImpl(MethodImplOptions.NoInlining)]
public void Serialize() => MessagePackSerializer.Serialize(_order, _options);
[MethodImpl(MethodImplOptions.NoInlining)]
public void Deserialize() => MessagePackSerializer.Deserialize<TestOrder>(_serialized, _options);
}
private sealed class AcBinaryBufferWriterBenchmark : ISerializerBenchmark
{
private readonly TestOrder _order;
private readonly AcBinarySerializerOptions _options;
private readonly byte[] _serialized;
private ArrayBufferWriter<byte> _bufferWriter;
public string Name { get; }
public int SerializedSize => _serialized.Length;
public AcBinaryBufferWriterBenchmark(TestOrder order, AcBinarySerializerOptions options, string name)
{
_order = order;
_options = options;
Name = name;
_serialized = AcBinarySerializer.Serialize(order, options);
//_bufferWriter = new ArrayBufferWriter<byte>();
}
public void Warmup(int iterations)
{
for (var i = 0; i < iterations; i++)
{
Serialize();
Deserialize();
}
}
[MethodImpl(MethodImplOptions.NoInlining)]
public void Serialize()
{
//_bufferWriter.ResetWrittenCount();
_bufferWriter = new ArrayBufferWriter<byte>();
AcBinarySerializer.Serialize(_order, _bufferWriter, _options);
}
[MethodImpl(MethodImplOptions.NoInlining)]
public void Deserialize() => AcBinaryDeserializer.Deserialize<TestOrder>(_serialized, _options);
}
private sealed class SystemTextJsonBenchmark : ISerializerBenchmark
{
private readonly TestOrder _order;
private readonly JsonSerializerOptions _options;
private readonly string _serialized;
private readonly byte[] _serializedUtf8;
public string Name { get; }
public int SerializedSize => _serializedUtf8.Length;
public SystemTextJsonBenchmark(TestOrder order, string name)
{
_order = order;
Name = name;
_options = new JsonSerializerOptions
{
WriteIndented = false,
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
ReferenceHandler = System.Text.Json.Serialization.ReferenceHandler.IgnoreCycles
};
_serialized = System.Text.Json.JsonSerializer.Serialize(order, _options);
_serializedUtf8 = Utf8NoBom.GetBytes(_serialized);
}
public void Warmup(int iterations)
{
for (var i = 0; i < iterations; i++)
{
Serialize();
Deserialize();
}
}
[MethodImpl(MethodImplOptions.NoInlining)]
public void Serialize() => System.Text.Json.JsonSerializer.Serialize(_order, _options);
[MethodImpl(MethodImplOptions.NoInlining)]
public void Deserialize() => System.Text.Json.JsonSerializer.Deserialize<TestOrder>(_serialized, _options);
}
#endregion
#region Results
private sealed class BenchmarkResult
{
public string TestDataName { get; set; } = "";
public string SerializerName { get; set; } = "";
public int SerializedSize { get; set; }
public double SerializeTimeMs { get; set; }
public double DeserializeTimeMs { get; set; }
public double RoundTripTimeMs => SerializeTimeMs + DeserializeTimeMs;
}
private static void PrintResult(BenchmarkResult result)
{
var ser = result.SerializeTimeMs > 0 ? $"{result.SerializeTimeMs,8:F2} ms" : " N/A";
var des = result.DeserializeTimeMs > 0 ? $"{result.DeserializeTimeMs,8:F2} ms" : " N/A";
System.Console.WriteLine($" {result.SerializerName,-25} | Size: {result.SerializedSize,8:N0} | Ser: {ser} | Des: {des}");
}
private static void PrintGroupedResults(List<BenchmarkResult> results, List<TestDataSet> testDataSets)
{
System.Console.WriteLine("\n");
System.Console.WriteLine("╔══════════════════════════════════════════════════════════════════════════════════════════════════════╗");
System.Console.WriteLine("║ GROUPED RESULTS BY TEST DATA ║");
System.Console.WriteLine("╚══════════════════════════════════════════════════════════════════════════════════════════════════════╝");
foreach (var testData in testDataSets)
{
var testResults = results.Where(r => r.TestDataName == testData.DisplayName).OrderBy(r => r.RoundTripTimeMs).ToList();
var msgPackResult = testResults.FirstOrDefault(r => r.SerializerName == SerializerMessagePack);
var acBinaryResult = testResults.FirstOrDefault(r => r.SerializerName == SerializerAcBinaryDefault);
System.Console.WriteLine($"\n┌─ {testData.DisplayName} ─".PadRight(98, '─') + "┐");
System.Console.WriteLine($"│ {"#",-4} │ {"Serializer",-25} │ {"Size",-10} │ {"Serialize",-12} │ {"Deserialize",-12} │ {"Round-trip",-12} │");
System.Console.WriteLine($"├{"".PadRight(6, '─')}┼{"".PadRight(27, '─')}┼{"".PadRight(12, '─')}┼{"".PadRight(14, '─')}┼{"".PadRight(14, '─')}┼{"".PadRight(14, '─')}┤");
var rank = 1;
foreach (var result in testResults)
{
var size = $"{result.SerializedSize:N0}";
var ser = result.SerializeTimeMs > 0 ? $"{result.SerializeTimeMs:F2} ms" : "N/A";
var des = result.DeserializeTimeMs > 0 ? $"{result.DeserializeTimeMs:F2} ms" : "N/A";
var rt = result.RoundTripTimeMs > 0 ? $"{result.RoundTripTimeMs:F2} ms" : "N/A";
// Highlight MessagePack and AcBinary (Default) with win/lose colors
var isHighlighted = result.SerializerName is SerializerMessagePack or SerializerAcBinaryDefault;
var prefix = isHighlighted ? "│►" : "│ ";
var suffix = isHighlighted ? "◄│" : " │";
// Color logic: Green = winner (faster), Red = loser (slower)
if (isHighlighted && msgPackResult != null && acBinaryResult != null)
{
var isMsgPack = result.SerializerName == SerializerMessagePack;
var msgPackFaster = msgPackResult.RoundTripTimeMs < acBinaryResult.RoundTripTimeMs;
if (isMsgPack)
{
System.Console.ForegroundColor = msgPackFaster ? ConsoleColor.Green : ConsoleColor.Red;
}
else
{
System.Console.ForegroundColor = msgPackFaster ? ConsoleColor.Red : ConsoleColor.Green;
}
}
System.Console.WriteLine($"{prefix}{rank++,4} │ {result.SerializerName,-25} │ {size,10} │ {ser,12} │ {des,12} │ {rt,12}{suffix}");
if (isHighlighted)
{
System.Console.ResetColor();
}
}
// Footer row: AcBinary (Default) vs MessagePack comparison per column
if (msgPackResult != null && acBinaryResult != null)
{
var sizePct = (acBinaryResult.SerializedSize / (double)msgPackResult.SerializedSize - 1) * 100;
var serPct = msgPackResult.SerializeTimeMs > 0 ? (acBinaryResult.SerializeTimeMs / msgPackResult.SerializeTimeMs - 1) * 100 : 0;
var desPct = msgPackResult.DeserializeTimeMs > 0 ? (acBinaryResult.DeserializeTimeMs / msgPackResult.DeserializeTimeMs - 1) * 100 : 0;
var rtPct = msgPackResult.RoundTripTimeMs > 0 ? (acBinaryResult.RoundTripTimeMs / msgPackResult.RoundTripTimeMs - 1) * 100 : 0;
System.Console.WriteLine($"├{"".PadRight(6, '─')}┴{"".PadRight(27, '─')}┼{"".PadRight(12, '─')}┼{"".PadRight(14, '─')}┼{"".PadRight(14, '─')}┼{"".PadRight(13, '─')}┤");
System.Console.Write($"│ ► Default vs {SerializerMessagePack,-19} │ ");
// Size
System.Console.ForegroundColor = sizePct <= 0 ? ConsoleColor.Green : ConsoleColor.Red;
System.Console.Write($"{sizePct,+9:+0;-0}%");
System.Console.ResetColor();
System.Console.Write(" │ ");
// Serialize
System.Console.ForegroundColor = serPct <= 0 ? ConsoleColor.Green : ConsoleColor.Red;
System.Console.Write($"{serPct,+11:+0;-0}%");
System.Console.ResetColor();
System.Console.Write(" │ ");
// Deserialize
System.Console.ForegroundColor = desPct <= 0 ? ConsoleColor.Green : ConsoleColor.Red;
System.Console.Write($"{desPct,+11:+0;-0}%");
System.Console.ResetColor();
System.Console.Write(" │ ");
// Round-trip
System.Console.ForegroundColor = rtPct <= 0 ? ConsoleColor.Green : ConsoleColor.Red;
System.Console.Write($"{rtPct,+10:+0;-0}%");
System.Console.ResetColor();
System.Console.WriteLine(" │");
}
System.Console.WriteLine($"└{"".PadRight(6, '─')}─{"".PadRight(27, '─')}┴{"".PadRight(12, '─')}┴{"".PadRight(14, '─')}┴{"".PadRight(14, '─')}┴{"".PadRight(13, '─')}┘");
//System.Console.WriteLine($"GrowBufferCount: {AcBinarySerializer.GrowBufferCount}");
//System.Console.WriteLine($"GrowBufferTotalBytes: {AcBinarySerializer.GrowBufferTotalBytes:N0} bytes");
}
// Summary: Best serializer for each category
System.Console.WriteLine("\n");
System.Console.WriteLine("╔══════════════════════════════════════════════════════════════════════════════════════════════════════╗");
System.Console.WriteLine("║ SUMMARY: WINNERS ║");
System.Console.WriteLine("╚══════════════════════════════════════════════════════════════════════════════════════════════════════╝");
System.Console.WriteLine($"\n{"Category",-20} │ {"Winner",-25} │ {"Avg Value",-18}");
System.Console.WriteLine($"{"".PadRight(20, '─')}─┼─{"".PadRight(25, '─')}─┼─{"".PadRight(18, '─')}");
// Fastest Serialize
var fastestSer = results.Where(r => r.SerializeTimeMs > 0)
.GroupBy(r => r.SerializerName)
.Select(g => new { Name = g.Key, AvgTime = g.Average(r => r.SerializeTimeMs) })
.OrderBy(x => x.AvgTime)
.FirstOrDefault();
if (fastestSer != null)
System.Console.WriteLine($"{"Fastest Serialize",-20} │ {fastestSer.Name,-25} │ {fastestSer.AvgTime,15:F2} ms");
// Fastest Deserialize
var fastestDes = results.Where(r => r.DeserializeTimeMs > 0)
.GroupBy(r => r.SerializerName)
.Select(g => new { Name = g.Key, AvgTime = g.Average(r => r.DeserializeTimeMs) })
.OrderBy(x => x.AvgTime)
.FirstOrDefault();
if (fastestDes != null)
System.Console.WriteLine($"{"Fastest Deserialize",-20} │ {fastestDes.Name,-25} │ {fastestDes.AvgTime,15:F2} ms");
// Smallest Size
var smallestSize = results
.GroupBy(r => r.SerializerName)
.Select(g => new { Name = g.Key, AvgSize = g.Average(r => r.SerializedSize) })
.OrderBy(x => x.AvgSize)
.FirstOrDefault();
if (smallestSize != null)
System.Console.WriteLine($"{"Smallest Size",-20} │ {smallestSize.Name,-25} │ {smallestSize.AvgSize,15:F0} B");
// Fastest Round-trip
var fastestRt = results.Where(r => r.RoundTripTimeMs > 0)
.GroupBy(r => r.SerializerName)
.Select(g => new { Name = g.Key, AvgTime = g.Average(r => r.RoundTripTimeMs) })
.OrderBy(x => x.AvgTime)
.FirstOrDefault();
if (fastestRt != null)
System.Console.WriteLine($"{"Fastest Round-trip",-20} │ {fastestRt.Name,-25} │ {fastestRt.AvgTime,15:F2} ms");
// Overall AcBinary Default vs MessagePack comparison
var msgPackSerResults = results.Where(r => r.SerializerName == SerializerMessagePack && r.SerializeTimeMs > 0).ToList();
var msgPackDesResults = results.Where(r => r.SerializerName == SerializerMessagePack && r.DeserializeTimeMs > 0).ToList();
var msgPackRtResults = results.Where(r => r.SerializerName == SerializerMessagePack && r.RoundTripTimeMs > 0).ToList();
var acBinarySerResults = results.Where(r => r.SerializerName == SerializerAcBinaryDefault && r.SerializeTimeMs > 0).ToList();
var acBinaryDesResults = results.Where(r => r.SerializerName == SerializerAcBinaryDefault && r.DeserializeTimeMs > 0).ToList();
var acBinaryRtResults = results.Where(r => r.SerializerName == SerializerAcBinaryDefault && r.RoundTripTimeMs > 0).ToList();
// Skip comparison if no data available
if (msgPackRtResults.Count == 0 || acBinaryRtResults.Count == 0)
{
System.Console.WriteLine();
System.Console.WriteLine($"── {SerializerAcBinaryDefault} vs {SerializerMessagePack} (Overall) ──");
System.Console.WriteLine(" (Comparison requires both serialize and deserialize data)");
return;
System.Console.WriteLine("─────────────────────────────────────────────────────────────────────");
System.Console.WriteLine("Returning to menu — press any key to continue, or Q to quit...");
var key = System.Console.ReadKey(intercept: true);
if (key.Key == ConsoleKey.Q) return;
System.Console.WriteLine();
}
var msgPackAvgSer = msgPackSerResults.Count > 0 ? msgPackSerResults.Average(r => r.SerializeTimeMs) : 0;
var msgPackAvgDes = msgPackDesResults.Average(r => r.DeserializeTimeMs);
var msgPackAvgRt = msgPackRtResults.Average(r => r.RoundTripTimeMs);
var msgPackAvgSize = results.Where(r => r.SerializerName == SerializerMessagePack).Average(r => r.SerializedSize);
var acBinaryAvgSer = acBinarySerResults.Count > 0 ? acBinarySerResults.Average(r => r.SerializeTimeMs) : 0;
var acBinaryAvgDes = acBinaryDesResults.Average(r => r.DeserializeTimeMs);
var acBinaryAvgRt = acBinaryRtResults.Average(r => r.RoundTripTimeMs);
var acBinaryAvgSize = results.Where(r => r.SerializerName == SerializerAcBinaryDefault).Average(r => r.SerializedSize);
System.Console.WriteLine();
System.Console.WriteLine($"── {SerializerAcBinaryDefault} vs {SerializerMessagePack} (Overall) ──");
// Only show serialize comparison if data available
if (msgPackAvgSer > 0 && acBinaryAvgSer > 0)
{
var serPctAll = (acBinaryAvgSer / msgPackAvgSer - 1) * 100;
System.Console.ForegroundColor = serPctAll <= 0 ? ConsoleColor.Green : ConsoleColor.Red;
System.Console.WriteLine($" Serialize: {serPctAll:+0;-0}% ({acBinaryAvgSer:F2} ms vs {msgPackAvgSer:F2} ms)");
System.Console.ResetColor();
}
var desPctAll = (acBinaryAvgDes / msgPackAvgDes - 1) * 100;
var rtPctAll = (acBinaryAvgRt / msgPackAvgRt - 1) * 100;
var sizePctAll = (acBinaryAvgSize / msgPackAvgSize - 1) * 100;
System.Console.ForegroundColor = desPctAll <= 0 ? ConsoleColor.Green : ConsoleColor.Red;
System.Console.WriteLine($" Deserialize: {desPctAll:+0;-0}% ({acBinaryAvgDes:F2} ms vs {msgPackAvgDes:F2} ms)");
System.Console.ResetColor();
System.Console.ForegroundColor = rtPctAll <= 0 ? ConsoleColor.Green : ConsoleColor.Red;
System.Console.WriteLine($" Round-trip: {rtPctAll:+0;-0}% ({acBinaryAvgRt:F2} ms vs {msgPackAvgRt:F2} ms)");
System.Console.ResetColor();
System.Console.ForegroundColor = sizePctAll <= 0 ? ConsoleColor.Green : ConsoleColor.Red;
System.Console.WriteLine($" Size: {sizePctAll:+0;-0}% ({acBinaryAvgSize:F0} B vs {msgPackAvgSize:F0} B)");
System.Console.ResetColor();
}
private static void SaveResults(List<BenchmarkResult> results, List<TestDataSet> testDataSets)
{
Directory.CreateDirectory(ResultsDirectory);
var timestamp = DateTime.Now.ToString("yyyy-MM-dd_HH-mm-ss");
var baseFileName = $"Console.FullBenchmark_{BuildConfiguration}_{timestamp}";
var logFilePath = Path.Combine(ResultsDirectory, $"{baseFileName}.log");
var outputFilePath = Path.Combine(ResultsDirectory, $"{baseFileName}.output");
// Save binary output to separate .output file
var largeTestData = testDataSets.FirstOrDefault(t => t.Name.StartsWith("Large"));
if (largeTestData != null)
{
var outputSb = new StringBuilder();
outputSb.AppendLine("╔══════════════════════════════════════════════════════════════════════════════════════════════════════╗");
outputSb.AppendLine("║ SERIALIZED BINARY OUTPUT ║");
outputSb.AppendLine($"║ Generated: {DateTime.Now:yyyy-MM-dd HH:mm:ss}".PadRight(100) + "║");
outputSb.AppendLine("╚══════════════════════════════════════════════════════════════════════════════════════════════════════╝");
outputSb.AppendLine();
outputSb.AppendLine("=== SERIALIZED BYTES: Large (5x5x5x10) - AcBinary (Default) ===");
var serializedBytes = AcBinarySerializer.Serialize(largeTestData.Order, AcBinarySerializerOptions.Default);
outputSb.AppendLine($"Size: {serializedBytes.Length:N0} bytes");
outputSb.AppendLine();
outputSb.AppendLine("Hex dump:");
outputSb.AppendLine(FormatHexDump(serializedBytes));
File.WriteAllText(outputFilePath, outputSb.ToString(), Utf8NoBom);
System.Console.WriteLine($"✓ Binary output saved to: {outputFilePath}");
}
// Save benchmark results to .log file
var sb = new StringBuilder();
sb.AppendLine("╔══════════════════════════════════════════════════════════════════════════════════════════════════════╗");
sb.AppendLine("║ SERIALIZER BENCHMARK RESULTS ║");
sb.AppendLine($"║ Generated: {DateTime.Now:yyyy-MM-dd HH:mm:ss}".PadRight(100) + "║");
sb.AppendLine($"║ Build: {BuildConfiguration}".PadRight(100) + "║");
sb.AppendLine($"║ Iterations: {TestIterations}".PadRight(100) + "║");
sb.AppendLine("╚══════════════════════════════════════════════════════════════════════════════════════════════════════╝");
sb.AppendLine();
// CSV-like data for easy import
sb.AppendLine("=== RAW DATA (CSV) ===");
sb.AppendLine("TestData,Serializer,Size,SerializeMs,DeserializeMs,RoundTripMs");
foreach (var testData in testDataSets)
{
var testResults = results.Where(r => r.TestDataName == testData.DisplayName).ToList();
foreach (var result in testResults)
{
sb.AppendLine($"{result.TestDataName},{result.SerializerName},{result.SerializedSize},{result.SerializeTimeMs:F2},{result.DeserializeTimeMs:F2},{result.RoundTripTimeMs:F2}");
}
}
sb.AppendLine();
// Formatted results
sb.AppendLine("=== FORMATTED RESULTS BY TEST DATA ===");
sb.AppendLine($"(►) = Highlighted: {SerializerMessagePack} (baseline) and {SerializerAcBinaryDefault}");
sb.AppendLine();
foreach (var testData in testDataSets)
{
var testResults = results.Where(r => r.TestDataName == testData.DisplayName).OrderBy(r => r.RoundTripTimeMs).ToList();
var msgPackResult = testResults.FirstOrDefault(r => r.SerializerName == SerializerMessagePack);
var acBinaryResult = testResults.FirstOrDefault(r => r.SerializerName == SerializerAcBinaryDefault);
sb.AppendLine();
sb.AppendLine($"--- {testData.DisplayName} ---");
sb.AppendLine($"{"#",-4} {"Serializer",-26} {"Size",-12} {"Serialize",-14} {"Deserialize",-14} {"Round-trip",-14}");
sb.AppendLine(new string('-', 86));
var rank = 1;
foreach (var result in testResults)
{
var isHighlighted = result.SerializerName is SerializerMessagePack or SerializerAcBinaryDefault;
var prefix = isHighlighted ? "► " : " ";
var size = $"{result.SerializedSize:N0}";
var ser = result.SerializeTimeMs > 0 ? $"{result.SerializeTimeMs:F2} ms" : "N/A";
var des = result.DeserializeTimeMs > 0 ? $"{result.DeserializeTimeMs:F2} ms" : "N/A";
var rt = result.RoundTripTimeMs > 0 ? $"{result.RoundTripTimeMs:F2} ms" : "N/A";
sb.AppendLine($"{rank++,2} {prefix}{result.SerializerName,-24} {size,-12} {ser,-14} {des,-14} {rt,-14}");
}
// Summary row for this test data
if (msgPackResult != null && acBinaryResult != null)
{
var sizePct = (acBinaryResult.SerializedSize / (double)msgPackResult.SerializedSize - 1) * 100;
var serPct = msgPackResult.SerializeTimeMs > 0 ? (acBinaryResult.SerializeTimeMs / msgPackResult.SerializeTimeMs - 1) * 100 : 0;
var desPct = msgPackResult.DeserializeTimeMs > 0 ? (acBinaryResult.DeserializeTimeMs / msgPackResult.DeserializeTimeMs - 1) * 100 : 0;
var rtPct = msgPackResult.RoundTripTimeMs > 0 ? (acBinaryResult.RoundTripTimeMs / msgPackResult.RoundTripTimeMs - 1) * 100 : 0;
sb.AppendLine($" {SerializerAcBinaryDefault} vs {SerializerMessagePack}: Size {sizePct:+0;-0}% │ Ser {serPct:+0;-0}% │ Des {desPct:+0;-0}% │ RT {rtPct:+0;-0}%");
}
//sb.AppendLine($"GrowBufferCount: {AcBinarySerializer.GrowBufferCount}");
//sb.AppendLine($"GrowBufferTotalBytes: {AcBinarySerializer.GrowBufferTotalBytes:N0} bytes");
}
// Summary comparison
sb.AppendLine();
sb.AppendLine($"=== {SerializerAcBinaryDefault} vs {SerializerMessagePack} (Overall) ===");
var msgPackSerResults2 = results.Where(r => r.SerializerName == SerializerMessagePack && r.SerializeTimeMs > 0).ToList();
var msgPackDesResults2 = results.Where(r => r.SerializerName == SerializerMessagePack && r.DeserializeTimeMs > 0).ToList();
var msgPackRtResults2 = results.Where(r => r.SerializerName == SerializerMessagePack && r.RoundTripTimeMs > 0).ToList();
var acBinarySerResults2 = results.Where(r => r.SerializerName == SerializerAcBinaryDefault && r.SerializeTimeMs > 0).ToList();
var acBinaryDesResults2 = results.Where(r => r.SerializerName == SerializerAcBinaryDefault && r.DeserializeTimeMs > 0).ToList();
var acBinaryRtResults2 = results.Where(r => r.SerializerName == SerializerAcBinaryDefault && r.RoundTripTimeMs > 0).ToList();
if (msgPackSerResults2.Count > 0 && acBinarySerResults2.Count > 0)
{
var msgPackAvgSer2 = msgPackSerResults2.Average(r => r.SerializeTimeMs);
var acBinaryAvgSer2 = acBinarySerResults2.Average(r => r.SerializeTimeMs);
sb.AppendLine($" Serialize: {((acBinaryAvgSer2 / msgPackAvgSer2 - 1) * 100):+0;-0}% ({acBinaryAvgSer2:F2} ms vs {msgPackAvgSer2:F2} ms)");
}
if (msgPackDesResults2.Count > 0 && acBinaryDesResults2.Count > 0)
{
var msgPackAvgDes2 = msgPackDesResults2.Average(r => r.DeserializeTimeMs);
var acBinaryAvgDes2 = acBinaryDesResults2.Average(r => r.DeserializeTimeMs);
sb.AppendLine($" Deserialize: {((acBinaryAvgDes2 / msgPackAvgDes2 - 1) * 100):+0;-0}% ({acBinaryAvgDes2:F2} ms vs {msgPackAvgDes2:F2} ms)");
}
if (msgPackRtResults2.Count > 0 && acBinaryRtResults2.Count > 0)
{
var msgPackAvgRt2 = msgPackRtResults2.Average(r => r.RoundTripTimeMs);
var acBinaryAvgRt2 = acBinaryRtResults2.Average(r => r.RoundTripTimeMs);
sb.AppendLine($" Round-trip: {((acBinaryAvgRt2 / msgPackAvgRt2 - 1) * 100):+0;-0}% ({acBinaryAvgRt2:F2} ms vs {msgPackAvgRt2:F2} ms)");
}
var msgPackAvgSize2 = results.Where(r => r.SerializerName == SerializerMessagePack).Average(r => r.SerializedSize);
var acBinaryAvgSize2 = results.Where(r => r.SerializerName == SerializerAcBinaryDefault).Average(r => r.SerializedSize);
sb.AppendLine($" Size: {((acBinaryAvgSize2 / msgPackAvgSize2 - 1) * 100):+0;-0}% ({acBinaryAvgSize2:F0} B vs {msgPackAvgSize2:F0} B)");
File.WriteAllText(logFilePath, sb.ToString(), Utf8NoBom);
System.Console.WriteLine($"✓ Results saved to: {logFilePath}");
}
/// <summary>
/// Formats byte array as hex dump with offset, hex values, and ASCII representation.
/// Parses CLI arguments into (layer, opMode, serializerMode) and, as a side effect, the active
/// charset (<see cref="BenchmarkTestDataProvider.LongStringSuffix"/>). Each arg is classified
/// independently and case-insensitively, so multiple args combine in any order — e.g.
/// <c>FastestByte AsciiShort</c> or <c>Serialize Large Latin1Short</c>. Per arg, in order:
/// <c>"quick"</c> (mutates <see cref="Configuration"/> warmup/iter counts), <see cref="SerializerSelectionMode"/>,
/// <see cref="BenchmarkOpMode"/>, <see cref="BenchmarkLayer"/>, then a charset name
/// (see <see cref="TryApplyCharsetArg"/>). Unrecognized args are warned and ignored; dimensions left
/// unset keep their defaults (All, All, Standard, and the <see cref="BenchmarkTestDataProvider.LongStringSuffix"/>
/// field default for charset). Always returns <c>true</c> (kept for caller-side abort symmetry).
/// </summary>
private static string FormatHexDump(byte[] bytes, int bytesPerLine = 16)
private static bool TryParseCliArgs(string[] args, out BenchmarkLayer layer, out BenchmarkOpMode opMode, out SerializerSelectionMode serializerMode)
{
var sb = new StringBuilder();
for (var i = 0; i < bytes.Length; i += bytesPerLine)
layer = BenchmarkLayer.All;
opMode = BenchmarkOpMode.All;
serializerMode = SerializerSelectionMode.Standard;
// Each arg is classified independently → multiple args combine in any order. Without the
// charset branch the CLI path never sets the charset, so it silently used the Latin1Long
// field default — diverging from interactive runs (where the menu pins it).
foreach (var arg in args)
{
// Offset
sb.Append($"{i:X8} ");
// Hex bytes
for (var j = 0; j < bytesPerLine; j++)
// Quick mode: short warmup, few iterations, small sample count. Not an enum value — it's a
// Configuration meta-flag, so handle it before the enum-parse cascade.
if (string.Equals(arg, "quick", StringComparison.OrdinalIgnoreCase))
{
if (i + j < bytes.Length)
sb.Append($"{bytes[i + j]:X2} ");
else
sb.Append(" ");
if (j == 7) sb.Append(' '); // Extra space in middle
Configuration.WarmupIterations = 5;
Configuration.TestIterations = 100;
Configuration.BenchmarkSamples = 3;
continue;
}
sb.Append(" |");
// ASCII representation
for (var j = 0; j < bytesPerLine && i + j < bytes.Length; j++)
// Serializer-selection (AsyncPipe/FastestByte/Standard).
if (Enum.TryParse<SerializerSelectionMode>(arg, ignoreCase: true, out var sm))
{
var b = bytes[i + j];
sb.Append(b is >= 32 and < 127 ? (char)b : '.');
serializerMode = sm;
continue;
}
sb.AppendLine("|");
// Op-mode (Serialize/Deserialize/All).
if (Enum.TryParse<BenchmarkOpMode>(arg, ignoreCase: true, out var om))
{
opMode = om;
continue;
}
// Layer (Core/Comprehensive/Edge/Small/Medium/Large/Repeated/Deep/All).
if (Enum.TryParse<BenchmarkLayer>(arg, ignoreCase: true, out var ly))
{
layer = ly;
continue;
}
// Charset (long-string suffix profile) — mirrors the interactive ShowCharsetSettingsMenu.
if (TryApplyCharsetArg(arg))
continue;
// Unknown arg — ignored, defaults stand. Matches prior unrecognized-arg leniency.
System.Console.Error.WriteLine($"Warning: unrecognized argument '{arg}'. Ignored (defaults: Layer=All, OpMode=All, SerializerMode=Standard, charset unchanged).");
}
return sb.ToString();
return true;
}
#endregion
/// <summary>
/// Maps a case-insensitive charset name to its <see cref="CharsetSuffixes"/> value and assigns
/// <see cref="BenchmarkTestDataProvider.LongStringSuffix"/>. Names mirror the interactive
/// <c>ShowCharsetSettingsMenu</c> options. <see cref="CharsetSuffixes"/> members are <c>const string</c>,
/// so this is a name→value match rather than an <see cref="Enum.TryParse{T}(string, bool, out T)"/>.
/// Returns <c>false</c> when the name is not a known charset (the caller then treats the arg as unknown).
/// </summary>
private static bool TryApplyCharsetArg(string arg)
{
string? suffix = arg.ToLowerInvariant() switch
{
"asciifix" => CharsetSuffixes.AsciiFix,
"asciishort" => CharsetSuffixes.AsciiShort,
"asciilong" => CharsetSuffixes.AsciiLong,
"latin1fix" => CharsetSuffixes.Latin1Fix,
"latin1short" => CharsetSuffixes.Latin1Short,
"latin1long" => CharsetSuffixes.Latin1Long,
"cjkbmpshort" => CharsetSuffixes.CjkBmpShort,
"cjkbmplong" => CharsetSuffixes.CjkBmpLong,
"cyrillicshort" => CharsetSuffixes.CyrillicShort,
"cyrilliclong" => CharsetSuffixes.CyrillicLong,
"mixedshort" => CharsetSuffixes.MixedShort,
"mixedlong" => CharsetSuffixes.MixedLong,
_ => null
};
if (suffix is null)
return false;
BenchmarkTestDataProvider.LongStringSuffix = suffix;
return true;
}
// RunBenchmark + RunBenchmarksForTestData + CreateSerializers → BenchmarkLoop.cs
// Serializer implementations (ISerializerBenchmark + 12 concrete benchmark classes) → Benchmarks/
// Results / output formatters → Output.cs
// BenchmarkResult DTO → BenchmarkResult.cs
}

View File

@ -1,34 +1,89 @@
# AyCode.Core.Serializers.Console
Standalone benchmark console application for comparing serializer performance. Targets .NET 9. Measures serialize/deserialize speed, output size, and compression across multiple serializers and data shapes.
Interactive console runner for the serializer benchmark suite. Targets .NET 9.
## Compared Serializers
> **Companion**: shares its workload + reporting infrastructure with the BDN runner in [`AyCode.Benchmark/`](../AyCode.Benchmark/README.md) via `<ProjectReference>`. See that project's README for the full dual-runner architecture.
- **AcBinary** — Multiple configurations: Default, NoRef, FastMode, NoIntern, with/without source generation
- **MessagePack**
- **MemoryPack**
## Role
(System.Text.Json and Newtonsoft.Json comparisons exist but are currently commented out.)
This is the **fast-iteration** half of the benchmark stack — a custom adaptive measure engine optimized for short turnaround (~1-3 min full run) during micro-optimization loops. The BDN half lives in `AyCode.Benchmark` and produces statistically tighter numbers (~5-15 min full run) for before-commit validation. Both runners emit the **same** `.log` / `.LLM` / `.output` triplet to `Test_Benchmark_Results/Benchmark/` — Console prefixes with `Console.`, BDN with `Bdn.`.
## Key Files
## Compared serializers
- **`Program.cs`** — Benchmark runner. Modes: `all` (default), `quick` (fewer iterations), `serialize`, `deserialize`, `profiler` (memory profiler warmup). Outputs results to `Test_Benchmark_Results/Benchmark/`. Iterations: 5000 warmup + 1000 test (Release), 0+1 (Debug).
- **`BenchmarkTestDataProvider.cs`** — Test data factory producing 5 data shapes:
- Small (2x2x2x2), Medium (3x3x3x4), Large (5x5x5x10)
- Repeated Strings (10 items, string deduplication testing)
- Deep Nested (2x4x4x8, depth stress test)
- Uses `TestOrder` model from `AyCode.Core.Tests` with configurable IId reference percentages.
- **AcBinary** — multiple options presets: `FastMode` (Compact wire, no ref handling, no interning), `Default` (with ref handling + interning), plus SGen / Runtime dispatch variants and Compact / Fast wire modes.
- **MemoryPack** — SOTA baseline, wire-mode-aligned with AcBinary for apples-to-apples encoding comparison (UTF-8 ↔ Compact, UTF-16 ↔ Fast).
- **MessagePack** — JIT-only (AOT incompatible due to dynamic resolver).
- **System.Text.Json** — reference comparison (commented out in `CreateSerializers` by default).
## Key files
- [`Program.cs`](Program.cs) — entry point. Parses CLI args (`Core` / `Comprehensive` / `Edge` / per-cell / op-mode / serializer-set) or falls into interactive `Menu`.
- [`Menu.cs`](Menu.cs) — interactive layer/serializer-set selection + nested settings (iteration counts, wire mode, charset).
- [`BenchmarkLoop.cs`](BenchmarkLoop.cs) — custom adaptive measure engine. CPU 0 affinity pin + High priority for stabilization, JIT pre-warmup, phase-isolated Ser/Des warmup→measure with `GC.Collect` at every boundary, 10-sample median + pilot discard, adaptive iter calibration to ~250ms/cell wall-clock, dedicated allocation-only sample.
- [`Configuration.cs`](Configuration.cs) — Console-side state (`SelectedWireMode`, `WarmupIterations`, `BenchmarkSamples`, `TargetSampleMs`, charset selection, `BuildConfiguration` const from `#if DEBUG/RELEASE/AYCODE_NATIVEAOT`).
Workload + reporting types — `ISerializerBenchmark`, `BenchmarkResult`, `BenchmarkOptions`, `BenchmarkEnums`, `BenchmarkReportWriter`, `ReportingContext`, the 12 concrete `*Benchmark<T>` classes (`AcBinaryBenchmark`, `MemoryPackBenchmark`, `AcBinaryBufferWriterBenchmark`, ...), `RoundTripValidator` — live in [`AyCode.Benchmark/Workloads/Scenarios/`](../AyCode.Benchmark/Workloads/Scenarios/) and [`AyCode.Benchmark/Reporting/`](../AyCode.Benchmark/Reporting/).
## Test data
5 cells, provided by `AyCode.Core.Tests.TestModels.BenchmarkTestDataProvider*`:
- **Small** (2×2×2×2)
- **Medium** (3×3×3×4)
- **Large** (5×5×5×10)
- **Repeated Strings** (10 items, string-deduplication stress)
- **Deep Nested** (2×4×4×8, depth stress)
20% IId reference rate by default. Two graph variants (`TestOrder_All_False` / `_All_True`) are built per cell — AcBinary's option preset picks which variant gets fed to it (`UsesAllFalseVariant` rule in `BenchmarkLoop`).
## Charset profiles (Menu → Settings → Charset)
Controls the `BenchmarkTestDataProvider.LongStringSuffix` — the string-tail appended to property values. Influences string-marker selection on the wire (FixStrAscii vs StringSmall / Medium / Big / StringAscii), interning hit rates, and UTF-8 encode cost.
**Consistent length across all charsets** (UTF-16 char count): every `*Short` = 40 char, every `*Long` = 280 char (= Short × 7). Isolates the workload variable to UTF-8 byte content per charset (1-byte ASCII vs 2-byte Latin1 / Cyrillic vs 3-byte CJK vs mixed) — wire-size and encode/decode cost differences are pure charset effects, not length effects.
| Profile | UTF-16 char | UTF-8 byte (approx) | Tier |
|---|---|---|---|
| `Latin1FixAscii` | 0 | 0 | FixStrAscii / FixStr-equivalent (baseline-only) |
| `AsciiShort` | 40 | 40 | StringAscii (167) |
| `AsciiLong` | 280 | 280 | StringAscii (167) |
| `Latin1Short` | 40 | ~72 | StringSmall (91) |
| `Latin1Long` (**default**) | 280 | ~504 | StringMedium (94) |
| `CjkBmpShort` | 40 | ~104 | StringSmall |
| `CjkBmpLong` | 280 | ~728 | StringMedium |
| `CyrillicShort` | 40 | ~72 | StringSmall |
| `CyrillicLong` | 280 | ~504 | StringMedium |
| `MixedShort` | 40 | ~88 | StringSmall |
| `MixedLong` | 280 | ~616 | StringMedium |
## CLI
```
dotnet run -c Release --project AyCode.Core.Serializers.Console -- [arg]
```
| Arg | Result |
|---|---|
| _(no args)_ | Interactive menu — pick layer (Core / Comprehensive / Edge / Small / Medium / Large / Repeated / Deep / All) × serializer-set (Standard / FastestByte ["F"] / AsyncPipe ["P"]). |
| `Core` / `Comprehensive` / `Edge` / `Small` / `Medium` / `Large` / `Repeated` / `Deep` / `All` | Run that layer at `Standard` serializer-set, `All` op-mode. |
| `FastestByte` / `AsyncPipe` / `Standard` | Run that serializer-set, `All` layer, `All` op-mode. |
| `Serialize` / `Deserialize` / `All` | Run that op-mode, `All` layer, `Standard` serializer-set. |
| `quick` | Single-sample fast mode (Debug-equivalent — very loose numbers, smoke-test only). |
Output: `Test_Benchmark_Results/Benchmark/Console.FullBenchmark_<Build>_<timestamp>.{log,LLM,output}`.
## Dependencies
| Dependency | Purpose |
|---|---|
| `AyCode.Core` | Core library with AcBinary serializer |
| `AyCode.Core.Tests` | Test models (`TestOrder`, `TestDataFactory`, etc.) |
| `MemoryPack` | Competitor benchmark |
| `MessagePack` | Competitor benchmark |
| `Newtonsoft.Json` | Competitor benchmark |
| `AyCode.Core` (ProjectReference) | AcBinary serializer |
| `AyCode.Core.Tests` (ProjectReference) | Test data factory + test models |
| `AyCode.Benchmark` (ProjectReference) | Shared workload + reporting (`ISerializerBenchmark`, `BenchmarkResult`, `BenchmarkReportWriter`, `ReportingContext`, the 12 concrete benchmark classes) |
| `MemoryPack` | Comparison target (also via Workloads) |
| `MessagePack` | Comparison target |
| `Newtonsoft.Json` | Comparison target (currently disabled) |
---
## Build & publish notes
> **LLM Maintenance:** If you modify code in this folder, update this README to reflect the changes. If you notice the README content does not match the current code, automatically update the README to match the code.
- `<StartupObject>AyCode.Core.Serializers.Console.Program</StartupObject>` in the csproj explicitly disambiguates the entry point — necessary because this Exe references another Exe (`AyCode.Benchmark`), and the build would otherwise complain about multiple `Main` methods.
- AOT publish (`dotnet publish -c Release`) is configured via `'$(_IsPublishing)' == 'true'` PropertyGroup. The Benchmark project's BDN-stack (BenchmarkDotNet, Iced disassembler, MongoDB.Bson) is pulled in transitively — accepted tradeoff for the unified workload sharing.

View File

@ -0,0 +1,182 @@
using System.Collections.Generic;
using Microsoft.CodeAnalysis;
namespace AyCode.Core.Serializers.SourceGenerator;
/// <summary>
/// Build-time diagnostics for the AcBinary source generator.
///
/// <para><b>Registered diagnostics</b>:</para>
/// <list type="bullet">
/// <item><c>ACBIN001</c> — <see cref="CircularReferenceWarning"/>: detects circular type references
/// among <c>[AcBinarySerializable]</c> types and warns the developer to consider ref-handling mode.</item>
/// <item><c>ACBIN002</c> — <see cref="PolymorphicPropertyWithFeatureDisabledError"/>: ACCORE-BIN-I-T7K3
/// compile-time guard. Fires when a type opts out of <c>EnablePolymorphDetectFeature</c> AND still
/// declares an <c>object</c> property — the SGen-emitted writer would silently corrupt the wire.</item>
/// </list>
/// </summary>
public partial class AcBinarySourceGenerator
{
private static readonly DiagnosticDescriptor CircularReferenceWarning = new(
id: "ACBIN001",
title: "Circular reference detected",
messageFormat: "Type '{0}' participates in a circular reference chain: {1}. Consider using ReferenceHandling.OnlyId or .All to avoid exponential serialization size.",
category: "AcBinarySerializer",
defaultSeverity: DiagnosticSeverity.Warning,
isEnabledByDefault: true);
/// <summary>
/// ACCORE-BIN-I-T7K3 compile-time guard: a property declared as <c>System.Object</c> requires
/// polymorphic-prefix emit (<c>ObjectWithTypeName</c>) so the deserializer can resolve the
/// concrete runtime type. When the type opts out of the feature via
/// <c>[AcBinarySerializable(enablePolymorphDetectFeature: false)]</c>, the prefix is suppressed
/// and the wire silently corrupts on round-trip (FixObj slot byte against <c>typeof(object)</c>
/// at read-time → 0-byte object wrapper → reader position drifts → downstream
/// <c>DECIMAL_DRIFT</c> / <c>IndexOutOfRangeException</c>).
///
/// Surface the misconfiguration at build time so the silent corruption is structurally
/// impossible. Three escape hatches for the developer:
/// 1. Enable the polymorph-detect feature on the type
/// (<c>[AcBinarySerializable(...enablePolymorphDetectFeature: true)]</c> — default true).
/// 2. Change the property type to a concrete type (no polymorphism needed).
/// 3. Mark the property with <c>[AcBinaryIgnore]</c> — ignored properties are filtered out
/// at property enumeration, so this diagnostic does not fire for them.
/// </summary>
private static readonly DiagnosticDescriptor PolymorphicPropertyWithFeatureDisabledError = new(
id: "ACBIN002",
title: "Polymorphic property requires EnablePolymorphDetectFeature",
messageFormat: "Type '{0}' contains property '{1}' declared as System.Object, but EnablePolymorphDetectFeature is disabled on the type. " +
"The generated writer would silently corrupt the wire on round-trip. " +
"To fix: (1) enable EnablePolymorphDetectFeature on [AcBinarySerializable], (2) change '{1}' to a concrete type, or (3) exclude it with [AcBinaryIgnore].",
category: "AcBinarySerializer",
defaultSeverity: DiagnosticSeverity.Error,
isEnabledByDefault: true);
/// <summary>
/// ACCORE-BIN-I-T7K3 guard: emits <see cref="PolymorphicPropertyWithFeatureDisabledError"/>
/// (ACBIN002) for every <c>System.Object</c>-declared property on any
/// <c>[AcBinarySerializable]</c> type whose <c>EnablePolymorphDetectFeature</c> is <c>false</c>.
/// Per-class gating: types with the feature enabled (default) skip the check entirely; only
/// opt-out types are scanned for misuse.
/// </summary>
private static void DetectAndReportPolymorphicMisuse(List<SerializableClassInfo> classes, SourceProductionContext spc)
{
foreach (var ci in classes)
{
if (ci.EnablePolymorphDetect) continue; // Feature enabled → polymorphic prefix is emitted, no misuse possible.
foreach (var p in ci.Properties)
{
if (p.IsObjectDeclaredType)
{
spc.ReportDiagnostic(Diagnostic.Create(
PolymorphicPropertyWithFeatureDisabledError, Location.None,
ci.ClassName, p.Name));
}
}
}
}
/// <summary>
/// Detects circular reference chains among [AcBinarySerializable] types at compile time
/// and reports ACBIN001 warnings. Uses DFS with 3-color marking to find back-edges.
/// </summary>
private static void DetectAndReportCycles(List<SerializableClassInfo> classes, SourceProductionContext spc)
{
// Build lookup: WriterClassName → FullTypeName
var writerToFull = new Dictionary<string, string>(classes.Count);
foreach (var ci in classes)
{
var writerName = string.IsNullOrEmpty(ci.Namespace)
? $"{ci.ClassName}_GeneratedWriter"
: $"{ci.Namespace}.{ci.ClassName}_GeneratedWriter";
writerToFull[writerName] = ci.FullTypeName;
}
// Build adjacency list: FullTypeName → set of referenced FullTypeNames
var adjacency = new Dictionary<string, HashSet<string>>(classes.Count);
foreach (var ci in classes)
{
var edges = new HashSet<string>();
foreach (var p in ci.Properties)
{
if (p.TypeKind == PropertyTypeKind.Complex && p.HasGeneratedWriter && p.WriterClassName != null)
{
if (writerToFull.TryGetValue(p.WriterClassName, out var target))
edges.Add(target);
}
if (p.ElementKind == PropertyTypeKind.Complex && p.ElementHasGeneratedWriter && p.ElementWriterClassName != null)
{
if (writerToFull.TryGetValue(p.ElementWriterClassName, out var target))
edges.Add(target);
}
if (p.DictValueKind == PropertyTypeKind.Complex && p.DictValueHasGeneratedWriter && p.DictValueWriterClassName != null)
{
if (writerToFull.TryGetValue(p.DictValueWriterClassName, out var target))
edges.Add(target);
}
}
adjacency[ci.FullTypeName] = edges;
}
// DFS with 3-color marking: White=0, Gray=1, Black=2
var color = new Dictionary<string, int>(classes.Count);
foreach (var ci in classes)
color[ci.FullTypeName] = 0;
var stack = new List<string>();
var reported = new HashSet<string>();
void Dfs(string node)
{
color[node] = 1; // Gray
stack.Add(node);
if (adjacency.TryGetValue(node, out var neighbors))
{
foreach (var next in neighbors)
{
if (!color.TryGetValue(next, out var c)) continue;
if (c == 1) // Gray → back-edge = cycle
{
var cycleStart = stack.IndexOf(next);
var parts = new List<string>();
for (var i = cycleStart; i < stack.Count; i++)
parts.Add(ShortTypeName(stack[i]));
parts.Add(ShortTypeName(next)); // close the cycle
var cycleDesc = string.Join(" → ", parts);
for (var i = cycleStart; i < stack.Count; i++)
{
if (reported.Add(stack[i]))
{
spc.ReportDiagnostic(Diagnostic.Create(
CircularReferenceWarning, Location.None,
ShortTypeName(stack[i]), cycleDesc));
}
}
}
else if (c == 0) // White → unvisited
{
Dfs(next);
}
}
}
stack.RemoveAt(stack.Count - 1);
color[node] = 2; // Black
}
foreach (var ci in classes)
{
if (color[ci.FullTypeName] == 0)
Dfs(ci.FullTypeName);
}
}
private static string ShortTypeName(string fullTypeName)
{
var dot = fullTypeName.LastIndexOf('.');
return dot >= 0 ? fullTypeName.Substring(dot + 1) : fullTypeName;
}
}

View File

@ -0,0 +1,43 @@
using System.Collections.Generic;
using System.Text;
namespace AyCode.Core.Serializers.SourceGenerator;
/// <summary>
/// Module-init emit pass: generates the static class with a <c>[ModuleInitializer]</c> method that
/// auto-registers every generated writer / reader instance into the runtime registries
/// (<c>AcBinarySerializer.RegisterGeneratedWriter</c> / <c>AcBinaryDeserializer.RegisterGeneratedReader</c>).
/// Emitted once per compilation as <c>AcBinaryGeneratedWriters_Init.g.cs</c>.
/// </summary>
public partial class AcBinarySourceGenerator
{
private static string GenInit(List<SerializableClassInfo> classes)
{
var sb = new StringBuilder(512);
sb.AppendLine("// <auto-generated/>");
sb.AppendLine("using System.Runtime.CompilerServices;");
sb.AppendLine("using AyCode.Core.Serializers.Binaries;");
sb.AppendLine();
sb.AppendLine("namespace AyCode.Core.Serializers.Generated;");
sb.AppendLine();
sb.AppendLine("internal static class AcBinaryGeneratedWritersInit");
sb.AppendLine("{");
sb.AppendLine(" [ModuleInitializer]");
sb.AppendLine(" internal static void Register()");
sb.AppendLine(" {");
foreach (var ci in classes)
{
var writerRef = string.IsNullOrEmpty(ci.Namespace)
? $"{ci.ClassName}_GeneratedWriter"
: $"{ci.Namespace}.{ci.ClassName}_GeneratedWriter";
var readerRef = string.IsNullOrEmpty(ci.Namespace)
? $"{ci.ClassName}_GeneratedReader"
: $"{ci.Namespace}.{ci.ClassName}_GeneratedReader";
sb.AppendLine($" AcBinarySerializer.RegisterGeneratedWriter(typeof({ci.FullTypeName}), {writerRef}.Instance);");
sb.AppendLine($" AcBinaryDeserializer.RegisterGeneratedReader(typeof({ci.FullTypeName}), {readerRef}.Instance);");
}
sb.AppendLine(" }");
sb.AppendLine("}");
return sb.ToString();
}
}

View File

@ -0,0 +1,909 @@
using System.Collections.Generic;
using System.Text;
namespace AyCode.Core.Serializers.SourceGenerator;
/// <summary>
/// Reader-side emit pass: generates the <c>IGeneratedBinaryReader</c> implementation for each
/// <c>[AcBinarySerializable]</c> type. Emits <c>ReadProperties</c> (inline property reads with marker
/// dispatch) and <c>ReadObject</c> (entry point with cache-index registration).
///
/// <para>Sub-passes:</para>
/// <list type="bullet">
/// <item><c>EmitReadProp</c> — per-property read emit (markerless + markered variants).</item>
/// <item><c>EmitReadString</c> — H2Q6 string-tier marker dispatch (FixStrAscii + tier-tables +
/// intern cases gated by <c>EnableInternStringFeature</c>).</item>
/// <item><c>EmitReadComplex</c> — Object / ObjectRef* / FixObj-slot dispatch for IId-typed children.</item>
/// <item><c>EmitReadCollection</c> / <c>EmitReadCollectionInline</c> / <c>EmitReadCollectionElement</c> /
/// <c>EmitReadNonComplexCollectionElement</c> — collection-shape inline reading.</item>
/// <item><c>EmitReadDictionary</c> / <c>EmitReadDictElement</c> — dict-shape inline reading.</item>
/// <item><c>EmitReadMarkeredValue</c> / <c>EmitReadMarkeredValueForKind</c> — primitive value-with-marker reads.</item>
/// <item><c>EmitReadMarkerless</c> — markerless primitive reads (FastMode + per-property markerless types).</item>
/// </list>
/// </summary>
public partial class AcBinarySourceGenerator
{
#region Reader Code Generation
/// <summary>
/// Generates the IGeneratedBinaryReader implementation for a type.
/// Phase 1: handles markerless path (no UseMetadata). UseMetadata/ChainMode → runtime fallback.
/// Eliminates: GetWrapper dictionary lookup, CreateInstance delegate, property setter delegates,
/// AccessorType switch dispatch, ReadValue dispatch table.
/// </summary>
private static string GenReader(SerializableClassInfo ci)
{
var sb = new StringBuilder(4096);
sb.AppendLine("// <auto-generated/>");
sb.AppendLine("#nullable enable");
sb.AppendLine("using System.Runtime.CompilerServices;");
sb.AppendLine("using AyCode.Core.Serializers.Binaries;");
sb.AppendLine();
if (!string.IsNullOrEmpty(ci.Namespace))
sb.AppendLine($"namespace {ci.Namespace};");
sb.AppendLine();
sb.AppendLine($"internal sealed class {ci.ClassName}_GeneratedReader : IGeneratedBinaryReader");
sb.AppendLine("{");
sb.AppendLine($" internal static readonly {ci.ClassName}_GeneratedReader Instance = new();");
sb.AppendLine();
// ReadProperties — reads all properties into an existing instance (mirrors WriteProperties)
// No depth safety net on deserialize: wire format is linear + finite, the serializer-side counter
// already prevents pathological depth in well-formed payloads.
sb.AppendLine(" public void ReadProperties<TInput>(object value, AcBinaryDeserializer.BinaryDeserializationContext<TInput> context)");
sb.AppendLine(" where TInput : struct, IBinaryInputBase");
sb.AppendLine(" {");
sb.AppendLine($" var obj = Unsafe.As<{ci.FullTypeName}>(value);");
// Emit property reads — markerless for primitive types, markered for the rest
foreach (var p in ci.Properties)
{
sb.AppendLine();
EmitReadProp(sb, p, " ", ci.EnableMetadata, ci.EnableInternString);
}
sb.AppendLine(" }");
sb.AppendLine();
// ReadObject — IGeneratedBinaryReader implementation (delegates to ReadProperties)
sb.AppendLine(" public object? ReadObject<TInput>(AcBinaryDeserializer.BinaryDeserializationContext<TInput> context, int cacheIndex)");
sb.AppendLine(" where TInput : struct, IBinaryInputBase");
sb.AppendLine(" {");
sb.AppendLine($" var obj = new {ci.FullTypeName}();");
sb.AppendLine(" if (cacheIndex >= 0)");
sb.AppendLine(" context.RegisterInternedValueAt(cacheIndex, obj);");
sb.AppendLine(" ReadProperties<TInput>(obj, context);");
sb.AppendLine(" return obj;");
sb.AppendLine(" }");
sb.AppendLine("}");
return sb.ToString();
}
/// <summary>
/// Emits inline read code for a single property.
/// Markerless types: read raw value directly (no type code in stream).
/// Markered types: read type code byte, then dispatch.
/// Mirrors the serializer's EmitProp symmetry.
/// </summary>
private static void EmitReadProp(StringBuilder sb, PropInfo p, string i, bool enableMetadata, bool enableInternString)
{
var a = $"obj.{p.Name}";
// Markerless types: read raw value directly — mirrors EmitMarkerless in writer
if (IsMarkerless(p.TypeKind))
{
if (p.TypeKind == PropertyTypeKind.Enum)
sb.AppendLine($"{i}{{ var ev = context.ReadVarInt(); {a} = Unsafe.As<int, {p.TypeNameForTypeof}>(ref ev); }}");
else
EmitReadMarkerless(sb, p.TypeKind, a, i);
return;
}
// ACCORE-BIN-T-K9M3 — caller-driven string marker dispatch. SGen-emit reads the marker byte
// locally + handles FastWire on a separate branch; BinaryDeserializationContext.TryReadStringProperty
// decodes every non-interning marker (FixStrAscii / StringAscii / StringSmall/Medium/Big / Null /
// StringEmpty) in one inlinable body. The 3 interning markers go through TryReadStringColdPath
// (AggressiveOptimization, Tier-1 direct). enableInternString gates the `|| TryReadStringColdPath`
// emit: interning-enabled types get the short-circuit; non-interning types omit the cold call
// entirely — the writer never produces interning markers for them, so TryReadStringProperty alone
// is total. PropertySkip / unknown → TryReadStringProperty returns false → property left at
// default (don't-touch contract preserved).
if (p.TypeKind == PropertyTypeKind.String)
{
sb.AppendLine($"{i}if (context.FastWire)");
sb.AppendLine($"{i}{{");
sb.AppendLine($"{i} {a} = context.ReadStringUtf16Markerless()!;");
sb.AppendLine($"{i}}}");
sb.AppendLine($"{i}else");
sb.AppendLine($"{i}{{");
sb.AppendLine($"{i} var tc_{p.Name} = context.ReadByte();");
sb.AppendLine($"{i} string? v_{p.Name};");
if (enableInternString)
sb.AppendLine($"{i} if (context.TryReadStringProperty(tc_{p.Name}, out v_{p.Name}) || context.TryReadStringColdPath(tc_{p.Name}, out v_{p.Name}))");
else
sb.AppendLine($"{i} if (context.TryReadStringProperty(tc_{p.Name}, out v_{p.Name}))");
sb.AppendLine($"{i} {{");
sb.AppendLine($"{i} {a} = v_{p.Name}!;");
sb.AppendLine($"{i} }}");
sb.AppendLine($"{i}}}");
return;
}
// Markered types: read type code, then dispatch
var tc = $"tc_{p.Name}";
sb.AppendLine($"{i}var {tc} = context.ReadByte();");
// PropertySkip → leave default
sb.AppendLine($"{i}if ({tc} != BinaryTypeCode.PropertySkip)");
sb.AppendLine($"{i}{{");
// Nullable value types
if (IsNullableVTKind(p.TypeKind))
{
sb.AppendLine($"{i} if ({tc} == BinaryTypeCode.Null) {{ /* null */ }}");
sb.AppendLine($"{i} else");
sb.AppendLine($"{i} {{");
EmitReadMarkeredValue(sb, Underlying(p.TypeKind), a, tc, i + " ", p, nullable: true);
sb.AppendLine($"{i} }}");
}
else
{
switch (p.TypeKind)
{
case PropertyTypeKind.String:
EmitReadString(sb, a, tc, i + " ", enableInternString);
break;
case PropertyTypeKind.Complex:
EmitReadComplex(sb, p, a, tc, i + " ");
break;
case PropertyTypeKind.Collection:
EmitReadCollection(sb, p, a, tc, i + " ", enableInternString);
break;
case PropertyTypeKind.Dictionary:
EmitReadDictionary(sb, p, a, tc, i + " ", enableInternString);
break;
default:
// Unknown markered type (char, sbyte, etc.) — rewind + runtime fallback
sb.AppendLine($"{i} context._position--;");
if (p.IsNullable)
sb.AppendLine($"{i} {a} = ({p.TypeNameForTypeof}?)AcBinaryDeserializer.ReadValueGenerated(context, typeof({p.TypeNameForTypeof}));");
else
sb.AppendLine($"{i} {a} = ({p.TypeNameForTypeof})AcBinaryDeserializer.ReadValueGenerated(context, typeof({p.TypeNameForTypeof}))!;");
break;
}
}
sb.AppendLine($"{i}}}");
}
/// <summary>
/// Emits raw value read — no type code in stream. Mirrors EmitMarkerless exactly.
/// </summary>
private static void EmitReadMarkerless(StringBuilder sb, PropertyTypeKind k, string a, string i)
{
switch (k)
{
case PropertyTypeKind.Int32: sb.AppendLine($"{i}{a} = context.ReadVarInt();"); break;
case PropertyTypeKind.Int64: sb.AppendLine($"{i}{a} = context.ReadVarLong();"); break;
case PropertyTypeKind.Double: sb.AppendLine($"{i}{a} = context.ReadDoubleUnsafe();"); break;
case PropertyTypeKind.Single: sb.AppendLine($"{i}{a} = context.ReadSingleUnsafe();"); break;
case PropertyTypeKind.Decimal: sb.AppendLine($"{i}{a} = context.ReadDecimalUnsafe();"); break;
case PropertyTypeKind.DateTime: sb.AppendLine($"{i}{a} = context.ReadDateTimeUnsafe();"); break;
case PropertyTypeKind.Guid: sb.AppendLine($"{i}{a} = context.ReadGuidUnsafe();"); break;
case PropertyTypeKind.Byte: sb.AppendLine($"{i}{a} = context.ReadByte();"); break;
case PropertyTypeKind.Int16: sb.AppendLine($"{i}{a} = context.ReadInt16Unsafe();"); break;
case PropertyTypeKind.UInt16: sb.AppendLine($"{i}{a} = context.ReadUInt16Unsafe();"); break;
case PropertyTypeKind.UInt32: sb.AppendLine($"{i}{a} = context.ReadVarUInt();"); break;
case PropertyTypeKind.UInt64: sb.AppendLine($"{i}{a} = context.ReadVarULong();"); break;
case PropertyTypeKind.TimeSpan: sb.AppendLine($"{i}{a} = new System.TimeSpan(context.ReadRaw<long>());"); break;
case PropertyTypeKind.DateTimeOffset: sb.AppendLine($"{i}{a} = context.ReadDateTimeOffsetUnsafe();"); break;
case PropertyTypeKind.Boolean: sb.AppendLine($"{i}{a} = context.ReadByte() != 0;"); break;
}
}
/// <summary>
/// Emits inline string read from type code. Handles all H2Q6 (v3 wire format) string markers:
/// FixStr (short-form universal, 135-166), String (long-form universal, 167),
/// StringUtf16 (FastWire marker, 91),
/// StringInternFirstSmall/Medium (interning tiers, 104/105),
/// StringInterned (cache ref, 92), StringEmpty (93), Null.
///
/// FixStr is checked first as the hot path for short strings; non-ASCII
/// tier markers carry both <c>charLen</c> and <c>utf8Len</c> in fixed-width headers (1-pass decode).
/// </summary>
private static void EmitReadString(StringBuilder sb, string a, string tc, string i, bool enableInternString)
{
// FixStr is the hot path — short-form universal marker with charLength in the marker.
sb.AppendLine($"{i}if (BinaryTypeCode.IsFixStr({tc}))");
sb.AppendLine($"{i}{{");
sb.AppendLine($"{i} {a} = context.ReadUniversalFixStr({tc});");
sb.AppendLine($"{i}}}");
// Switch gives O(1) dispatch via JIT jump table for the remaining markers.
sb.AppendLine($"{i}else switch ({tc})");
sb.AppendLine($"{i}{{");
// Interning case (2nd+ occurrence ref) — only emit when EnableInternStringFeature is enabled
// on this type. When disabled, the writer never emits StringInterned markers for this type's
// properties, so the reader doesn't need to handle them. ACCORE-BIN-T-K9M3 Phase C.
if (enableInternString)
{
sb.AppendLine($"{i} case BinaryTypeCode.StringInterned:");
sb.AppendLine($"{i} {a} = context.GetInternedString((int)context.ReadVarUInt());");
sb.AppendLine($"{i} break;");
}
// StringUtf16 marker + String. Wire-decode body is shared with the runtime path
// (TypeReaderTable + cross-type populate) — see context.ReadStringUtf16Marker()
// and ReadUniversalLongString.
// These markers are feature-independent: writer emits them on any string property regardless of
// intern setting (intern is opt-in per-property via [AcStringIntern] + InternBit).
sb.AppendLine($"{i} case BinaryTypeCode.StringUtf16:");
sb.AppendLine($"{i} {a} = context.ReadStringUtf16Marker();");
sb.AppendLine($"{i} break;");
sb.AppendLine($"{i} case BinaryTypeCode.String:");
sb.AppendLine($"{i} {a} = context.ReadUniversalLongString();");
sb.AppendLine($"{i} break;");
// Interning first-occurrence cases — see comment above.
if (enableInternString)
{
sb.AppendLine($"{i} case BinaryTypeCode.StringInternFirstSmall:");
sb.AppendLine($"{i} {a} = context.ReadAndRegisterInternedStringSmall();");
sb.AppendLine($"{i} break;");
sb.AppendLine($"{i} case BinaryTypeCode.StringInternFirstMedium:");
sb.AppendLine($"{i} {a} = context.ReadAndRegisterInternedStringMedium();");
sb.AppendLine($"{i} break;");
}
sb.AppendLine($"{i} case BinaryTypeCode.Null:");
sb.AppendLine($"{i} {a} = null;");
sb.AppendLine($"{i} break;");
sb.AppendLine($"{i} case BinaryTypeCode.StringEmpty:");
sb.AppendLine($"{i} {a} = string.Empty;");
sb.AppendLine($"{i} break;");
sb.AppendLine($"{i}}}");
}
/// <summary>
/// Emits inline read for a Complex property.
/// SGen reader only runs in non-metadata mode → ObjectWithMetadata never appears.
/// Compile-time ChildNeedsRefScan eliminates ObjectRefFirst/ObjectRef when provably unused.
/// Non-nullable + no ref → ZERO branches (tc consumed but ignored).
/// No SGen → runtime fallback via ReadValueGenerated.
/// </summary>
private static void EmitReadComplex(StringBuilder sb, PropInfo p, string a, string tc, string i)
{
if (!p.HasGeneratedWriter)
{
// No SGen reader — runtime fallback (rewind + ReadValueGenerated)
if (p.IsNullable)
{
sb.AppendLine($"{i}if ({tc} == BinaryTypeCode.Null) {a} = null;");
sb.AppendLine($"{i}else");
sb.AppendLine($"{i}{{");
sb.AppendLine($"{i} context._position--;");
sb.AppendLine($"{i} {a} = ({p.TypeNameForTypeof}?)AcBinaryDeserializer.ReadValueGenerated(context, typeof({p.TypeNameForTypeof}));");
sb.AppendLine($"{i}}}");
}
else
{
sb.AppendLine($"{i}context._position--;");
sb.AppendLine($"{i}{a} = ({p.TypeNameForTypeof})AcBinaryDeserializer.ReadValueGenerated(context, typeof({p.TypeNameForTypeof}))!;");
}
return;
}
var reader = p.WriterClassName!.Replace("_GeneratedWriter", "_GeneratedReader");
var cast = $"({p.TypeNameForTypeof})";
// Ref-aware switch decision routed through RefAwareEmitPredicate — single source of truth shared
// with the writer-side EmitDirectCollectionWrite + the sibling EmitReadCollectionElement. The
// decision depends EXCLUSIVELY on the child compile-time fact `ChildNeedsRefScan` — the parent
// EnableRefHandlingFeature flag is NOT a factor here (it governs only the parent's SELF-tracking
// emit in the scan pass, not the marker dispatch for child property reads). Asymmetry-bug fix:
// see AcBinarySerializerIIdReferenceTests.Serialize_RefMarkerCollectionElement_ParentRefHandlingFeatureOff_DriftReproduction.
if (!RefAwareEmitPredicate.ChildEmitsRefMarker(p))
{
// Compile-time proven: child never tracked → only Object (+ Null for nullable) in stream
// Inline: parent creates instance, calls ReadProperties directly (mirrors EmitDirectObjectWrite)
// FixObj slot bytes (0..SlotCount-1) are also valid markers here — populate slot cache
// to keep _nextRuntimeSlot in sync with the serializer's _nextTypeSlot counter.
if (p.IsNullable)
{
sb.AppendLine($"{i}if ({tc} == BinaryTypeCode.Null) {{ /* null */ }}");
sb.AppendLine($"{i}else");
sb.AppendLine($"{i}{{");
sb.AppendLine($"{i} if ({tc} < BinaryTypeCode.Object) {{ context.GetWrapper(typeof({p.TypeNameForTypeof}), {tc}); if ({tc} >= context._nextRuntimeSlot) context._nextRuntimeSlot = {tc} + 1; }}");
sb.AppendLine($"{i} var rc_{p.Name} = new {p.TypeNameForTypeof}();");
sb.AppendLine($"{i} {reader}.Instance.ReadProperties(rc_{p.Name}, context);");
sb.AppendLine($"{i} {a} = rc_{p.Name};");
sb.AppendLine($"{i}}}");
}
else
{
// ZERO branches — tc is always Object or FixObj
sb.AppendLine($"{i}{{");
sb.AppendLine($"{i} if ({tc} < BinaryTypeCode.Object) {{ context.GetWrapper(typeof({p.TypeNameForTypeof}), {tc}); if ({tc} >= context._nextRuntimeSlot) context._nextRuntimeSlot = {tc} + 1; }}");
sb.AppendLine($"{i} var rc_{p.Name} = new {p.TypeNameForTypeof}();");
sb.AppendLine($"{i} {reader}.Instance.ReadProperties(rc_{p.Name}, context);");
sb.AppendLine($"{i} {a} = rc_{p.Name};");
sb.AppendLine($"{i}}}");
}
}
else
{
// Ref tracking possible — switch on tc (Object / ObjectRefFirst / [Null] / ObjectRef / <Object).
// The 4 known TypeCode constants are emitted as switch cases — the JIT compiles them as a
// jump-table for O(1) dispatch (vs the previous if-else chain's sequential ==-compares).
// The polymorphic FixObj range-check (tc < Object) goes into the default branch — runtime
// bridge path is rare on a typical SGen graph, so default fall-through is acceptable.
// Inline: parent creates instance + handles cache registration.
sb.AppendLine($"{i}switch ({tc})");
sb.AppendLine($"{i}{{");
sb.AppendLine($"{i} case BinaryTypeCode.Object:");
sb.AppendLine($"{i} {{");
sb.AppendLine($"{i} var rc_{p.Name} = new {p.TypeNameForTypeof}();");
sb.AppendLine($"{i} {reader}.Instance.ReadProperties(rc_{p.Name}, context);");
sb.AppendLine($"{i} {a} = rc_{p.Name};");
sb.AppendLine($"{i} break;");
sb.AppendLine($"{i} }}");
sb.AppendLine($"{i} case BinaryTypeCode.ObjectRefFirst:");
sb.AppendLine($"{i} {{");
sb.AppendLine($"{i} var ci_{p.Name} = (int)context.ReadVarUInt();");
sb.AppendLine($"{i} var rc_{p.Name} = new {p.TypeNameForTypeof}();");
sb.AppendLine($"{i} context.RegisterInternedValueAt(ci_{p.Name}, rc_{p.Name});");
sb.AppendLine($"{i} {reader}.Instance.ReadProperties(rc_{p.Name}, context);");
sb.AppendLine($"{i} {a} = rc_{p.Name};");
sb.AppendLine($"{i} break;");
sb.AppendLine($"{i} }}");
if (p.IsNullable)
sb.AppendLine($"{i} case BinaryTypeCode.Null: break;");
sb.AppendLine($"{i} case BinaryTypeCode.ObjectRef:");
sb.AppendLine($"{i} {a} = {cast}context.GetInternedObject((int)context.ReadVarUInt())!;");
sb.AppendLine($"{i} break;");
// FixObj slot (0..SlotCount-1): same type via FixObj marker (non-meta, non-ref mode).
// Populate slot cache to keep _nextRuntimeSlot in sync with the serializer.
sb.AppendLine($"{i} default:");
sb.AppendLine($"{i} if ({tc} < BinaryTypeCode.Object)");
sb.AppendLine($"{i} {{");
sb.AppendLine($"{i} context.GetWrapper(typeof({p.TypeNameForTypeof}), {tc});");
sb.AppendLine($"{i} if ({tc} >= context._nextRuntimeSlot) context._nextRuntimeSlot = {tc} + 1;");
sb.AppendLine($"{i} var rc_{p.Name} = new {p.TypeNameForTypeof}();");
sb.AppendLine($"{i} {reader}.Instance.ReadProperties(rc_{p.Name}, context);");
sb.AppendLine($"{i} {a} = rc_{p.Name};");
sb.AppendLine($"{i} }}");
sb.AppendLine($"{i} break;");
sb.AppendLine($"{i}}}");
}
}
/// <summary>
/// Returns true when collection element reading can be inlined (no runtime ReadValue dispatch needed).
/// </summary>
private static bool CanInlineCollectionRead(PropInfo p)
{
if (p.ElementKind == PropertyTypeKind.Complex && p.ElementHasGeneratedWriter) return true;
if (p.ElementKind == PropertyTypeKind.String) return true;
if (p.ElementKind == PropertyTypeKind.Enum) return true;
if (IsMarkerless(p.ElementKind)) return true; // all primitives
return false;
}
/// <summary>
/// Emits inline read for a Collection property.
/// Known collection kind + inlineable element → inline Array loop with direct element reads.
/// Else → runtime fallback via ReadValueGenerated.
/// </summary>
private static void EmitReadCollection(StringBuilder sb, PropInfo p, string a, string tc, string i, bool enableInternString)
{
// Check if we can inline: known collection shape + inlineable element type
if (p.CollectionKind != null && CanInlineCollectionRead(p))
{
EmitReadCollectionInline(sb, p, a, tc, i, enableInternString);
return;
}
// Runtime fallback
if (p.IsNullable)
{
sb.AppendLine($"{i}if ({tc} == BinaryTypeCode.Null) {a} = null;");
sb.AppendLine($"{i}else");
sb.AppendLine($"{i}{{");
sb.AppendLine($"{i} context._position--;");
sb.AppendLine($"{i} {a} = ({p.TypeNameForTypeof}?)AcBinaryDeserializer.ReadValueGenerated(context, typeof({p.TypeNameForTypeof}));");
sb.AppendLine($"{i}}}");
}
else
{
sb.AppendLine($"{i}context._position--;");
sb.AppendLine($"{i}{a} = ({p.TypeNameForTypeof})AcBinaryDeserializer.ReadValueGenerated(context, typeof({p.TypeNameForTypeof}))!;");
}
}
/// <summary>
/// Emits inline read for a Dictionary property.
/// Wire format: [Dictionary][VarUInt count][key₁ value₁ key₂ value₂ ...].
/// Keys and values are read inline when their types are known (primitive/string/Complex+SGen).
/// </summary>
private static void EmitReadDictionary(StringBuilder sb, PropInfo p, string a, string tc, string i, bool enableInternString)
{
var s = p.Name;
var keyType = p.DictKeyTypeName ?? "object";
var valType = p.DictValueTypeName ?? "object";
// Can we inline key/value reads?
var canInlineKey = p.DictKeyKind == PropertyTypeKind.String || IsMarkerless(p.DictKeyKind) || p.DictKeyKind == PropertyTypeKind.Enum;
var canInlineValue = p.DictValueKind == PropertyTypeKind.String || IsMarkerless(p.DictValueKind) || p.DictValueKind == PropertyTypeKind.Enum
|| (p.DictValueKind == PropertyTypeKind.Complex && p.DictValueHasGeneratedWriter);
var canInline = canInlineKey || canInlineValue; // partial inline is still beneficial
if (p.IsNullable)
{
sb.AppendLine($"{i}if ({tc} == BinaryTypeCode.Null) {a} = null;");
sb.AppendLine($"{i}else if ({tc} == BinaryTypeCode.Dictionary)");
}
else
{
sb.AppendLine($"{i}if ({tc} == BinaryTypeCode.Dictionary)");
}
sb.AppendLine($"{i}{{");
sb.AppendLine($"{i} var cnt_{s} = (int)context.ReadVarUInt();");
sb.AppendLine($"{i} var dict_{s} = new System.Collections.Generic.Dictionary<{keyType}, {valType}>(cnt_{s});");
sb.AppendLine($"{i} for (var di_{s} = 0; di_{s} < cnt_{s}; di_{s}++)");
sb.AppendLine($"{i} {{");
// Read key
if (canInlineKey)
EmitReadDictElement(sb, p.DictKeyKind, keyType, $"dk_{s}", s, i + " ", null, false, enableInternString);
else
sb.AppendLine($"{i} var dk_{s} = ({keyType})AcBinaryDeserializer.ReadValueGenerated(context, typeof({keyType}))!;");
// Read value
if (p.DictValueKind == PropertyTypeKind.Complex && p.DictValueHasGeneratedWriter)
{
var valReader = p.DictValueWriterClassName!.Replace("_GeneratedWriter", "_GeneratedReader");
var vtc = $"vtc_{s}";
sb.AppendLine($"{i} var {vtc} = context.ReadByte();");
sb.AppendLine($"{i} {valType}? dv_{s} = null;");
sb.AppendLine($"{i} if ({vtc} == BinaryTypeCode.Object)");
sb.AppendLine($"{i} {{");
sb.AppendLine($"{i} var rv_{s} = new {valType}();");
sb.AppendLine($"{i} {valReader}.Instance.ReadProperties(rv_{s}, context);");
sb.AppendLine($"{i} dv_{s} = rv_{s};");
sb.AppendLine($"{i} }}");
// ObjectRefFirst / ObjectRef cases — routed through RefAwareEmitPredicate. Single source of
// truth shared with EmitReadComplex / EmitReadCollectionElement / EmitDirectCollectionWrite.
// The decision depends EXCLUSIVELY on the dict-value compile-time fact `DictValueNeedsRefScan`
// — the parent EnableRefHandlingFeature flag is NOT a factor here (it governs only the parent's
// SELF-tracking emit in the scan pass, GenWriter.cs:140). Symmetric with the writer-side
// dict-value emit. Asymmetry-bug fix: see AcBinarySerializerIIdReferenceTests
// .Serialize_RefMarkerCollectionElement_ParentRefHandlingFeatureOff_DriftReproduction.
if (RefAwareEmitPredicate.DictValueEmitsRefMarker(p))
{
sb.AppendLine($"{i} else if ({vtc} == BinaryTypeCode.ObjectRefFirst)");
sb.AppendLine($"{i} {{");
sb.AppendLine($"{i} var rci_{s} = (int)context.ReadVarUInt();");
sb.AppendLine($"{i} var rv_{s} = new {valType}();");
sb.AppendLine($"{i} context.RegisterInternedValueAt(rci_{s}, rv_{s});");
sb.AppendLine($"{i} {valReader}.Instance.ReadProperties(rv_{s}, context);");
sb.AppendLine($"{i} dv_{s} = rv_{s};");
sb.AppendLine($"{i} }}");
sb.AppendLine($"{i} else if ({vtc} == BinaryTypeCode.ObjectRef)");
sb.AppendLine($"{i} dv_{s} = ({valType})context.GetInternedObject((int)context.ReadVarUInt())!;");
}
sb.AppendLine($"{i} else if ({vtc} != BinaryTypeCode.Null)");
sb.AppendLine($"{i} {{");
sb.AppendLine($"{i} context._position--;");
sb.AppendLine($"{i} dv_{s} = ({valType}?)AcBinaryDeserializer.ReadValueGenerated(context, typeof({valType}));");
sb.AppendLine($"{i} }}");
}
else if (canInlineValue)
EmitReadDictElement(sb, p.DictValueKind, valType, $"dv_{s}", s, i + " ", null, true, enableInternString);
else
sb.AppendLine($"{i} var dv_{s} = ({valType}?)AcBinaryDeserializer.ReadValueGenerated(context, typeof({valType}));");
// Add to dictionary
sb.AppendLine($"{i} if (dk_{s} != null) dict_{s}[dk_{s}] = dv_{s}!;");
sb.AppendLine($"{i} }}");
sb.AppendLine($"{i} {a} = dict_{s};");
sb.AppendLine($"{i}}}");
}
/// <summary>
/// Emits inline read for a single dictionary key or value element.
/// Reads type code byte, then dispatches based on element kind.
/// </summary>
private static void EmitReadDictElement(StringBuilder sb, PropertyTypeKind kind, string typeName, string varName, string propSuffix, string i, PropInfo? p, bool isRefType, bool enableInternString)
{
var etc = $"{varName}_tc";
sb.AppendLine($"{i}var {etc} = context.ReadByte();");
if (kind == PropertyTypeKind.String)
{
sb.AppendLine($"{i}{typeName}? {varName} = null;");
EmitReadString(sb, varName, etc, i, enableInternString);
}
else if (kind == PropertyTypeKind.Enum)
{
sb.AppendLine($"{i}{typeName} {varName} = default;");
sb.AppendLine($"{i}if ({etc} == BinaryTypeCode.Enum)");
sb.AppendLine($"{i}{{");
sb.AppendLine($"{i} var eb = context.ReadByte();");
sb.AppendLine($"{i} int eiv;");
sb.AppendLine($"{i} if (BinaryTypeCode.IsTinyInt(eb)) eiv = BinaryTypeCode.DecodeTinyInt(eb);");
sb.AppendLine($"{i} else eiv = context.ReadVarInt();");
sb.AppendLine($"{i} {varName} = ({typeName})(object)eiv;");
sb.AppendLine($"{i}}}");
sb.AppendLine($"{i}else if (BinaryTypeCode.IsTinyInt({etc})) {varName} = ({typeName})(object)BinaryTypeCode.DecodeTinyInt({etc});");
}
else
{
// Primitive value type — never nullable
sb.AppendLine($"{i}{typeName} {varName} = default;");
EmitReadMarkeredValueForKind(sb, kind, varName, etc, i);
}
}
/// <summary>
/// Emits markered value read by kind only (no PropInfo needed). For dict key/value inline reads.
/// </summary>
private static void EmitReadMarkeredValueForKind(StringBuilder sb, PropertyTypeKind k, string a, string tc, string i)
{
switch (k)
{
case PropertyTypeKind.Int32:
sb.AppendLine($"{i}if (BinaryTypeCode.IsTinyInt({tc})) {a} = BinaryTypeCode.DecodeTinyInt({tc});");
sb.AppendLine($"{i}else if ({tc} == BinaryTypeCode.Int32) {a} = context.ReadVarInt();");
break;
case PropertyTypeKind.Int64:
sb.AppendLine($"{i}if (BinaryTypeCode.IsTinyInt({tc})) {a} = BinaryTypeCode.DecodeTinyInt({tc});");
sb.AppendLine($"{i}else if ({tc} == BinaryTypeCode.Int32) {a} = context.ReadVarInt();");
sb.AppendLine($"{i}else if ({tc} == BinaryTypeCode.Int64) {a} = context.ReadVarLong();");
break;
case PropertyTypeKind.Boolean:
sb.AppendLine($"{i}if ({tc} == BinaryTypeCode.True) {a} = true;");
sb.AppendLine($"{i}else if ({tc} == BinaryTypeCode.False) {a} = false;");
break;
case PropertyTypeKind.Double:
sb.AppendLine($"{i}if ({tc} == BinaryTypeCode.Float64) {a} = context.ReadDoubleUnsafe();");
break;
case PropertyTypeKind.Single:
sb.AppendLine($"{i}if ({tc} == BinaryTypeCode.Float32) {a} = context.ReadSingleUnsafe();");
break;
case PropertyTypeKind.Decimal:
sb.AppendLine($"{i}if ({tc} == BinaryTypeCode.Decimal) {a} = context.ReadDecimalUnsafe();");
break;
case PropertyTypeKind.DateTime:
sb.AppendLine($"{i}if ({tc} == BinaryTypeCode.DateTime) {a} = context.ReadDateTimeUnsafe();");
break;
case PropertyTypeKind.Guid:
sb.AppendLine($"{i}if ({tc} == BinaryTypeCode.Guid) {a} = context.ReadGuidUnsafe();");
break;
case PropertyTypeKind.Byte:
sb.AppendLine($"{i}if (BinaryTypeCode.IsTinyInt({tc})) {a} = (byte)BinaryTypeCode.DecodeTinyInt({tc});");
sb.AppendLine($"{i}else if ({tc} == BinaryTypeCode.UInt8) {a} = context.ReadByte();");
break;
case PropertyTypeKind.Int16:
sb.AppendLine($"{i}if (BinaryTypeCode.IsTinyInt({tc})) {a} = (short)BinaryTypeCode.DecodeTinyInt({tc});");
sb.AppendLine($"{i}else if ({tc} == BinaryTypeCode.Int16) {a} = context.ReadInt16Unsafe();");
break;
case PropertyTypeKind.UInt16:
sb.AppendLine($"{i}if (BinaryTypeCode.IsTinyInt({tc})) {a} = (ushort)BinaryTypeCode.DecodeTinyInt({tc});");
sb.AppendLine($"{i}else if ({tc} == BinaryTypeCode.UInt16) {a} = context.ReadUInt16Unsafe();");
break;
case PropertyTypeKind.UInt32:
sb.AppendLine($"{i}if (BinaryTypeCode.IsTinyInt({tc})) {a} = (uint)BinaryTypeCode.DecodeTinyInt({tc});");
sb.AppendLine($"{i}else if ({tc} == BinaryTypeCode.UInt32) {a} = context.ReadVarUInt();");
break;
case PropertyTypeKind.UInt64:
sb.AppendLine($"{i}if (BinaryTypeCode.IsTinyInt({tc})) {a} = (ulong)BinaryTypeCode.DecodeTinyInt({tc});");
sb.AppendLine($"{i}else if ({tc} == BinaryTypeCode.UInt64) {a} = context.ReadVarULong();");
break;
case PropertyTypeKind.TimeSpan:
sb.AppendLine($"{i}if ({tc} == BinaryTypeCode.TimeSpan) {a} = context.ReadTimeSpanUnsafe();");
break;
case PropertyTypeKind.DateTimeOffset:
sb.AppendLine($"{i}if ({tc} == BinaryTypeCode.DateTimeOffset) {a} = context.ReadDateTimeOffsetUnsafe();");
break;
}
}
/// <summary>
/// Emits inline collection read: Array marker already consumed as tc.
/// Reads count + loops with direct element reads (Complex with SGen, or primitive/string/enum).
/// Eliminates per-element: ReadValue dispatch, ReadObjectCore dict lookup, Activator.CreateInstance.
/// </summary>
private static void EmitReadCollectionInline(StringBuilder sb, PropInfo p, string a, string tc, string i, bool enableInternString)
{
var isComplexElement = p.ElementKind == PropertyTypeKind.Complex && p.ElementHasGeneratedWriter;
var elemType = p.ElementFullTypeName!;
var s = p.Name;
// Null check
if (p.IsNullable)
{
sb.AppendLine($"{i}if ({tc} == BinaryTypeCode.Null) {a} = null;");
sb.AppendLine($"{i}else if ({tc} == BinaryTypeCode.Array)");
}
else
{
sb.AppendLine($"{i}if ({tc} == BinaryTypeCode.Array)");
}
sb.AppendLine($"{i}{{");
sb.AppendLine($"{i} var cnt_{s} = (int)context.ReadVarUInt();");
// Create collection + loop based on kind
if (p.CollectionKind == "Array")
{
sb.AppendLine($"{i} var col_{s} = new {elemType}[cnt_{s}];");
sb.AppendLine($"{i} for (var ri_{s} = 0; ri_{s} < cnt_{s}; ri_{s}++)");
sb.AppendLine($"{i} {{");
if (isComplexElement)
EmitReadCollectionElement(sb, p.ElementWriterClassName!.Replace("_GeneratedWriter", "_GeneratedReader"), elemType, $"({elemType})", $"ri_{s}", s, i + " ", isArray: true, p.ElementNeedsRefScan, enableInternString);
else
EmitReadNonComplexCollectionElement(sb, p, $"ri_{s}", s, i + " ", isArray: true, null, enableInternString);
sb.AppendLine($"{i} }}");
}
else if (p.CollectionKind == "Counted" && p.CollectionAddMethod != null)
{
// Concrete custom collection — use actual type + correct add method
if (p.CollectionHasCapacityCtor)
sb.AppendLine($"{i} var col_{s} = new {p.TypeNameForTypeof}(cnt_{s});");
else
sb.AppendLine($"{i} var col_{s} = new {p.TypeNameForTypeof}();");
sb.AppendLine($"{i} for (var ri_{s} = 0; ri_{s} < cnt_{s}; ri_{s}++)");
sb.AppendLine($"{i} {{");
if (isComplexElement)
EmitReadCollectionElement(sb, p.ElementWriterClassName!.Replace("_GeneratedWriter", "_GeneratedReader"), elemType, $"({elemType})", $"ri_{s}", s, i + " ", isArray: false, p.ElementNeedsRefScan, enableInternString, p.CollectionAddMethod);
else
EmitReadNonComplexCollectionElement(sb, p, $"ri_{s}", s, i + " ", isArray: false, p.CollectionAddMethod, enableInternString);
sb.AppendLine($"{i} }}");
}
else // List, IndexedCollection, Counted-interface → List<T> with Add
{
sb.AppendLine($"{i} var col_{s} = new System.Collections.Generic.List<{elemType}>(cnt_{s});");
sb.AppendLine($"{i} for (var ri_{s} = 0; ri_{s} < cnt_{s}; ri_{s}++)");
sb.AppendLine($"{i} {{");
if (isComplexElement)
EmitReadCollectionElement(sb, p.ElementWriterClassName!.Replace("_GeneratedWriter", "_GeneratedReader"), elemType, $"({elemType})", $"ri_{s}", s, i + " ", isArray: false, p.ElementNeedsRefScan, enableInternString);
else
EmitReadNonComplexCollectionElement(sb, p, $"ri_{s}", s, i + " ", isArray: false, null, enableInternString);
sb.AppendLine($"{i} }}");
}
sb.AppendLine($"{i} {a} = col_{s};");
sb.AppendLine($"{i}}}");
}
/// <summary>
/// Emits per-element read inside collection loop.
/// SGen reader = non-metadata mode → no ObjectWithMetadata fallback.
/// !needsRefScan → only Object/Null possible → 1 branch per element.
/// </summary>
private static void EmitReadCollectionElement(StringBuilder sb, string reader, string elemTypeName, string elemCast, string indexVar, string propSuffix, string i, bool isArray, bool needsRefScan, bool enableInternString, string? addMethod = null)
{
var etc = $"etc_{propSuffix}";
sb.AppendLine($"{i}var {etc} = context.ReadByte();");
var addCall = addMethod ?? "Add";
var assignNull = isArray ? $"col_{propSuffix}[{indexVar}] = null!;" : $"col_{propSuffix}.{addCall}(null!);";
var assignExpr = isArray ? $"col_{propSuffix}[{indexVar}] = re_{propSuffix};" : $"col_{propSuffix}.{addCall}(re_{propSuffix});";
// Ref-aware switch decision routed through RefAwareEmitPredicate — single source of truth shared
// with the writer-side EmitDirectCollectionWrite + EmitReadComplex. The decision depends
// EXCLUSIVELY on the element compile-time fact `needsRefScan` — the parent EnableRefHandlingFeature
// flag is NOT a factor here. Asymmetry-bug fix:
// see AcBinarySerializerIIdReferenceTests.Serialize_RefMarkerCollectionElement_ParentRefHandlingFeatureOff_DriftReproduction.
if (!RefAwareEmitPredicate.ElementEmitsRefMarker(needsRefScan))
{
// No ref tracking → only Object, FixObj or Null in stream — inline ReadProperties
// FixObj slot: populate slot cache to keep _nextRuntimeSlot in sync.
sb.AppendLine($"{i}if ({etc} == BinaryTypeCode.Null) {{ {assignNull} }}");
sb.AppendLine($"{i}else");
sb.AppendLine($"{i}{{");
sb.AppendLine($"{i} if ({etc} < BinaryTypeCode.Object) {{ context.GetWrapper(typeof({elemTypeName}), {etc}); if ({etc} >= context._nextRuntimeSlot) context._nextRuntimeSlot = {etc} + 1; }}");
sb.AppendLine($"{i} var re_{propSuffix} = new {elemTypeName}();");
sb.AppendLine($"{i} {reader}.Instance.ReadProperties(re_{propSuffix}, context);");
sb.AppendLine($"{i} {assignExpr}");
sb.AppendLine($"{i}}}");
}
else
{
// Switch on etc (Object / ObjectRefFirst / Null / ObjectRef / <Object). The JIT emits the
// 4 known TypeCode constants as a jump-table (O(1) dispatch); the polymorphic FixObj
// range-check (etc < Object) goes into the default branch. Object hot-path stays first.
sb.AppendLine($"{i}switch ({etc})");
sb.AppendLine($"{i}{{");
sb.AppendLine($"{i} case BinaryTypeCode.Object:");
sb.AppendLine($"{i} {{");
sb.AppendLine($"{i} var re_{propSuffix} = new {elemTypeName}();");
sb.AppendLine($"{i} {reader}.Instance.ReadProperties(re_{propSuffix}, context);");
sb.AppendLine($"{i} {assignExpr}");
sb.AppendLine($"{i} break;");
sb.AppendLine($"{i} }}");
sb.AppendLine($"{i} case BinaryTypeCode.ObjectRefFirst:");
sb.AppendLine($"{i} {{");
sb.AppendLine($"{i} var ci_{propSuffix} = (int)context.ReadVarUInt();");
sb.AppendLine($"{i} var re_{propSuffix} = new {elemTypeName}();");
sb.AppendLine($"{i} context.RegisterInternedValueAt(ci_{propSuffix}, re_{propSuffix});");
sb.AppendLine($"{i} {reader}.Instance.ReadProperties(re_{propSuffix}, context);");
sb.AppendLine($"{i} {assignExpr}");
sb.AppendLine($"{i} break;");
sb.AppendLine($"{i} }}");
sb.AppendLine($"{i} case BinaryTypeCode.Null:");
sb.AppendLine($"{i} {assignNull}");
sb.AppendLine($"{i} break;");
sb.AppendLine($"{i} case BinaryTypeCode.ObjectRef:");
if (isArray)
sb.AppendLine($"{i} col_{propSuffix}[{indexVar}] = {elemCast}context.GetInternedObject((int)context.ReadVarUInt())!;");
else
sb.AppendLine($"{i} col_{propSuffix}.{addCall}({elemCast}context.GetInternedObject((int)context.ReadVarUInt())!);");
sb.AppendLine($"{i} break;");
// FixObj slot (0..SlotCount-1): same type via FixObj marker.
// Populate slot cache to keep _nextRuntimeSlot in sync with the serializer.
sb.AppendLine($"{i} default:");
sb.AppendLine($"{i} if ({etc} < BinaryTypeCode.Object)");
sb.AppendLine($"{i} {{");
sb.AppendLine($"{i} context.GetWrapper(typeof({elemTypeName}), {etc});");
sb.AppendLine($"{i} if ({etc} >= context._nextRuntimeSlot) context._nextRuntimeSlot = {etc} + 1;");
sb.AppendLine($"{i} var re_{propSuffix} = new {elemTypeName}();");
sb.AppendLine($"{i} {reader}.Instance.ReadProperties(re_{propSuffix}, context);");
sb.AppendLine($"{i} {assignExpr}");
sb.AppendLine($"{i} }}");
sb.AppendLine($"{i} break;");
sb.AppendLine($"{i}}}");
}
}
/// <summary>
/// Emits per-element read for non-Complex collection elements (String, primitive, Enum).
/// Reads type code byte, then dispatches based on ElementKind.
/// </summary>
private static void EmitReadNonComplexCollectionElement(StringBuilder sb, PropInfo p, string indexVar, string propSuffix, string i, bool isArray, string? addMethod, bool enableInternString)
{
var addCall = addMethod ?? "Add";
var elemType = p.ElementFullTypeName!;
var colRef = $"col_{propSuffix}";
// String element FastWire markerless fast-path — same wire as property-level (int32 sentinel header).
// All FastWire string writes funnel through `WriteStringWithDispatch.FastWire = WriteStringUtf16Markerless`,
// so collection elements use the same markerless format. Skips the etc-read entirely in FastWire mode.
if (p.ElementKind == PropertyTypeKind.String)
{
var tempVar = $"sv_{propSuffix}";
sb.AppendLine($"{i}string? {tempVar};");
sb.AppendLine($"{i}if (context.FastWire)");
sb.AppendLine($"{i}{{");
sb.AppendLine($"{i} {tempVar} = context.ReadStringUtf16Markerless();");
sb.AppendLine($"{i}}}");
sb.AppendLine($"{i}else");
sb.AppendLine($"{i}{{");
sb.AppendLine($"{i} var etc_{propSuffix} = context.ReadByte();");
sb.AppendLine($"{i} {tempVar} = null;");
EmitReadString(sb, tempVar, $"etc_{propSuffix}", i + " ", enableInternString);
sb.AppendLine($"{i}}}");
if (isArray)
sb.AppendLine($"{i}{colRef}[{indexVar}] = {tempVar}!;");
else
sb.AppendLine($"{i}{colRef}.{addCall}({tempVar}!);");
return;
}
var etc = $"etc_{propSuffix}";
sb.AppendLine($"{i}var {etc} = context.ReadByte();");
if (p.ElementKind == PropertyTypeKind.Enum)
{
// Enum element: Enum marker or TinyInt
var tempVar = $"ev_{propSuffix}";
sb.AppendLine($"{i}{elemType} {tempVar} = default;");
sb.AppendLine($"{i}if ({etc} == BinaryTypeCode.Enum)");
sb.AppendLine($"{i}{{");
sb.AppendLine($"{i} var eb = context.ReadByte();");
sb.AppendLine($"{i} int eiv;");
sb.AppendLine($"{i} if (BinaryTypeCode.IsTinyInt(eb)) eiv = BinaryTypeCode.DecodeTinyInt(eb);");
sb.AppendLine($"{i} else eiv = context.ReadVarInt();");
sb.AppendLine($"{i} {tempVar} = ({elemType})(object)eiv;");
sb.AppendLine($"{i}}}");
sb.AppendLine($"{i}else if (BinaryTypeCode.IsTinyInt({etc})) {tempVar} = ({elemType})(object)BinaryTypeCode.DecodeTinyInt({etc});");
if (isArray)
sb.AppendLine($"{i}{colRef}[{indexVar}] = {tempVar};");
else
sb.AppendLine($"{i}{colRef}.{addCall}({tempVar});");
}
else
{
// Primitive element: read markered value
var tempVar = $"pv_{propSuffix}";
sb.AppendLine($"{i}{elemType} {tempVar} = default;");
// Create a minimal PropInfo-like context for EmitReadMarkeredValue
EmitReadMarkeredValue(sb, p.ElementKind, tempVar, etc, i, p, nullable: false);
if (isArray)
sb.AppendLine($"{i}{colRef}[{indexVar}] = {tempVar};");
else
sb.AppendLine($"{i}{colRef}.{addCall}({tempVar});");
}
}
/// <summary>
/// Emits markered value read for primitive types (with type code already read).
/// Handles TinyInt encoding for integer types.
/// </summary>
private static void EmitReadMarkeredValue(StringBuilder sb, PropertyTypeKind k, string a, string tc, string i, PropInfo p, bool nullable)
{
var assign = nullable ? $"{a} = " : $"{a} = ";
switch (k)
{
case PropertyTypeKind.Int32:
sb.AppendLine($"{i}if (BinaryTypeCode.IsTinyInt({tc})) {assign}BinaryTypeCode.DecodeTinyInt({tc});");
sb.AppendLine($"{i}else if ({tc} == BinaryTypeCode.Int32) {assign}context.ReadVarInt();");
break;
case PropertyTypeKind.Int64:
sb.AppendLine($"{i}if (BinaryTypeCode.IsTinyInt({tc})) {assign}BinaryTypeCode.DecodeTinyInt({tc});");
sb.AppendLine($"{i}else if ({tc} == BinaryTypeCode.Int32) {assign}context.ReadVarInt();");
sb.AppendLine($"{i}else if ({tc} == BinaryTypeCode.Int64) {assign}context.ReadVarLong();");
break;
case PropertyTypeKind.Boolean:
sb.AppendLine($"{i}if ({tc} == BinaryTypeCode.True) {assign}true;");
sb.AppendLine($"{i}else if ({tc} == BinaryTypeCode.False) {assign}false;");
break;
case PropertyTypeKind.Double:
sb.AppendLine($"{i}if ({tc} == BinaryTypeCode.Float64) {assign}context.ReadDoubleUnsafe();");
break;
case PropertyTypeKind.Single:
sb.AppendLine($"{i}if ({tc} == BinaryTypeCode.Float32) {assign}context.ReadSingleUnsafe();");
break;
case PropertyTypeKind.Decimal:
sb.AppendLine($"{i}if ({tc} == BinaryTypeCode.Decimal) {assign}context.ReadDecimalUnsafe();");
break;
case PropertyTypeKind.DateTime:
sb.AppendLine($"{i}if ({tc} == BinaryTypeCode.DateTime) {assign}context.ReadDateTimeUnsafe();");
break;
case PropertyTypeKind.Guid:
sb.AppendLine($"{i}if ({tc} == BinaryTypeCode.Guid) {assign}context.ReadGuidUnsafe();");
break;
case PropertyTypeKind.Byte:
sb.AppendLine($"{i}if (BinaryTypeCode.IsTinyInt({tc})) {assign}(byte)BinaryTypeCode.DecodeTinyInt({tc});");
sb.AppendLine($"{i}else if ({tc} == BinaryTypeCode.UInt8) {assign}context.ReadByte();");
break;
case PropertyTypeKind.Int16:
sb.AppendLine($"{i}if (BinaryTypeCode.IsTinyInt({tc})) {assign}(short)BinaryTypeCode.DecodeTinyInt({tc});");
sb.AppendLine($"{i}else if ({tc} == BinaryTypeCode.Int16) {assign}context.ReadInt16Unsafe();");
break;
case PropertyTypeKind.UInt16:
sb.AppendLine($"{i}if (BinaryTypeCode.IsTinyInt({tc})) {assign}(ushort)BinaryTypeCode.DecodeTinyInt({tc});");
sb.AppendLine($"{i}else if ({tc} == BinaryTypeCode.UInt16) {assign}context.ReadUInt16Unsafe();");
break;
case PropertyTypeKind.UInt32:
sb.AppendLine($"{i}if (BinaryTypeCode.IsTinyInt({tc})) {assign}(uint)BinaryTypeCode.DecodeTinyInt({tc});");
sb.AppendLine($"{i}else if ({tc} == BinaryTypeCode.UInt32) {assign}context.ReadVarUInt();");
break;
case PropertyTypeKind.UInt64:
sb.AppendLine($"{i}if (BinaryTypeCode.IsTinyInt({tc})) {assign}(ulong)BinaryTypeCode.DecodeTinyInt({tc});");
sb.AppendLine($"{i}else if ({tc} == BinaryTypeCode.UInt64) {assign}context.ReadVarULong();");
break;
case PropertyTypeKind.Enum:
sb.AppendLine($"{i}if ({tc} == BinaryTypeCode.Enum)");
sb.AppendLine($"{i}{{");
sb.AppendLine($"{i} var eb = context.ReadByte();");
sb.AppendLine($"{i} int ev;");
sb.AppendLine($"{i} if (BinaryTypeCode.IsTinyInt(eb)) ev = BinaryTypeCode.DecodeTinyInt(eb);");
sb.AppendLine($"{i} else ev = context.ReadVarInt();");
sb.AppendLine($"{i} {assign}({p.TypeNameForTypeof})(object)ev;");
sb.AppendLine($"{i}}}");
sb.AppendLine($"{i}else if (BinaryTypeCode.IsTinyInt({tc})) {assign}({p.TypeNameForTypeof})(object)BinaryTypeCode.DecodeTinyInt({tc});");
break;
case PropertyTypeKind.TimeSpan:
sb.AppendLine($"{i}if ({tc} == BinaryTypeCode.TimeSpan) {assign}context.ReadTimeSpanUnsafe();");
break;
case PropertyTypeKind.DateTimeOffset:
sb.AppendLine($"{i}if ({tc} == BinaryTypeCode.DateTimeOffset) {assign}context.ReadDateTimeOffsetUnsafe();");
break;
}
}
#endregion
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,363 @@
using System.Collections.Generic;
using System.Linq;
using Microsoft.CodeAnalysis;
namespace AyCode.Core.Serializers.SourceGenerator;
/// <summary>
/// Class-info extraction pass — transforms a Roslyn <see cref="GeneratorAttributeSyntaxContext"/>
/// (a class/struct annotated with <c>[AcBinarySerializable]</c>) into the <see cref="SerializableClassInfo"/>
/// model consumed by the emit passes (writer / reader / scan / init).
///
/// <para>Reads the attribute's feature flags (1-, 4-, 5-, 6-bool ctor variants), walks the inheritance
/// hierarchy via <c>GetAllSerializablePropertySymbols</c>, and computes per-property metadata: kind,
/// nullability, intern eligibility, complex / collection / dictionary element types, generated-writer
/// pointers, FNV hashes for inline-metadata, and recursive scan-need flags.</para>
/// </summary>
public partial class AcBinarySourceGenerator
{
private static SerializableClassInfo? GetClassInfo(GeneratorAttributeSyntaxContext context)
{
if (!(context.TargetSymbol is INamedTypeSymbol typeSymbol))
return null;
var namespaceName = typeSymbol.ContainingNamespace.IsGlobalNamespace
? string.Empty
: typeSymbol.ContainingNamespace.ToDisplayString();
var properties = new List<PropInfo>();
// Read feature flags from [AcBinarySerializable] — disabled features eliminate
// corresponding code blocks from generated ScanObject/WriteProperties.
var enableIdTracking = true;
var enableRefHandling = true;
var enableInternString = true;
var enableMetadata = true;
var enablePropertyFilter = true;
var enablePolymorphDetect = true;
var binarySerializableAttr = typeSymbol.GetAttributes().FirstOrDefault(a =>
a.AttributeClass?.ToDisplayString() == AttributeName);
if (binarySerializableAttr != null)
{
if (binarySerializableAttr.ConstructorArguments.Length == 1)
{
// Single bool ctor: AcBinarySerializable(enableAllFeatures)
var all = (bool)binarySerializableAttr.ConstructorArguments[0].Value!;
enableIdTracking = all;
enableRefHandling = all;
enableInternString = all;
enableMetadata = all;
enablePropertyFilter = all;
enablePolymorphDetect = all;
}
else if (binarySerializableAttr.ConstructorArguments.Length == 4)
{
// Four bool ctor: (metadata, idTracking, refHandling, internString) — filter + polymorph default to true
enableMetadata = (bool)binarySerializableAttr.ConstructorArguments[0].Value!;
enableIdTracking = (bool)binarySerializableAttr.ConstructorArguments[1].Value!;
enableRefHandling = (bool)binarySerializableAttr.ConstructorArguments[2].Value!;
enableInternString = (bool)binarySerializableAttr.ConstructorArguments[3].Value!;
}
else if (binarySerializableAttr.ConstructorArguments.Length == 5)
{
// Five bool ctor: (metadata, idTracking, refHandling, internString, propertyFilter) — polymorph defaults to true
enableMetadata = (bool)binarySerializableAttr.ConstructorArguments[0].Value!;
enableIdTracking = (bool)binarySerializableAttr.ConstructorArguments[1].Value!;
enableRefHandling = (bool)binarySerializableAttr.ConstructorArguments[2].Value!;
enableInternString = (bool)binarySerializableAttr.ConstructorArguments[3].Value!;
enablePropertyFilter = (bool)binarySerializableAttr.ConstructorArguments[4].Value!;
}
else if (binarySerializableAttr.ConstructorArguments.Length == 6)
{
// Six bool ctor: (metadata, idTracking, refHandling, internString, propertyFilter, polymorphDetect)
enableMetadata = (bool)binarySerializableAttr.ConstructorArguments[0].Value!;
enableIdTracking = (bool)binarySerializableAttr.ConstructorArguments[1].Value!;
enableRefHandling = (bool)binarySerializableAttr.ConstructorArguments[2].Value!;
enableInternString = (bool)binarySerializableAttr.ConstructorArguments[3].Value!;
enablePropertyFilter = (bool)binarySerializableAttr.ConstructorArguments[4].Value!;
enablePolymorphDetect = (bool)binarySerializableAttr.ConstructorArguments[5].Value!;
}
}
foreach (var p in GetAllSerializablePropertySymbols(typeSymbol))
{
// String interning attribútum detektálás (null = no attr, true/false = explicit)
bool? stringInternAttr = null;
if (!enableInternString)
{
stringInternAttr = false;
}
else if (GetKind(p.Type) == PropertyTypeKind.String)
{
var attr = p.GetAttributes().FirstOrDefault(a => a.AttributeClass?.ToDisplayString() == "AyCode.Core.Serializers.Binaries.AcStringInternAttribute");
if (attr != null && attr.ConstructorArguments.Length == 1 && attr.ConstructorArguments[0].Kind == TypedConstantKind.Primitive)
{
stringInternAttr = (bool)attr.ConstructorArguments[0].Value!;
}
}
// For typeof(): strip trailing '?' from nullable reference types (typeof(T?) is invalid for ref types)
// Nullable value types (int?, Guid?) keep '?' because typeof(int?) == typeof(Nullable<int>) is valid
var typeDisplayName = p.Type.ToDisplayString();
var typeNameForTypeof = (p.Type.NullableAnnotation == NullableAnnotation.Annotated && !p.Type.IsValueType)
? typeDisplayName.TrimEnd('?')
: typeDisplayName;
// Direct object write detection for Complex property types:
// Check if the property type has [AcBinarySerializable] (→ has generated writer)
// and if it implements IId<T> (→ needs ref tracking in generated code)
var kind = GetKind(p.Type);
bool hasGenWriter = false;
bool propTypeIsIId = false;
bool propEnableMetadata = true;
bool childNeedsIdScan = true;
bool childNeedsAllRefScan = true;
bool childNeedsInternScan = true;
string? writerClassName = null;
string? propIdTypeName = null;
int childTypeNameHash = 0;
int[]? childPropertyHashes = null;
if (kind == PropertyTypeKind.Complex)
{
// Resolve to the actual type symbol (strip nullable annotation for ref types)
// For SharedTag? → SharedTag. OriginalDefinition handles generic types.
var resolvedType = p.Type is INamedTypeSymbol namedPropType
? namedPropType.OriginalDefinition
: p.Type;
hasGenWriter = resolvedType.Locations.Any(l => l.IsInSource)
&& resolvedType.GetAttributes().Any(a =>
a.AttributeClass?.ToDisplayString() == AttributeName);
if (hasGenWriter)
{
// Read child type's EnableMetadataFeature
propEnableMetadata = ReadEnableMetadata(resolvedType);
var childScanFlags = ComputeNeedsScan(resolvedType);
childNeedsIdScan = childScanFlags.needsIdScan;
childNeedsAllRefScan = childScanFlags.needsAllRefScan;
childNeedsInternScan = childScanFlags.needsInternScan;
var iidIface = resolvedType.AllInterfaces.FirstOrDefault(i =>
i.IsGenericType &&
i.OriginalDefinition.ToDisplayString() == "AyCode.Core.Interfaces.IId<T>");
propTypeIsIId = iidIface != null;
if (iidIface != null)
propIdTypeName = iidIface.TypeArguments[0].ToDisplayString();
// Writer class: {Namespace}.{FlatName}_GeneratedWriter
var flatName = BuildFlatName((INamedTypeSymbol)resolvedType);
var ns = resolvedType.ContainingNamespace.IsGlobalNamespace
? string.Empty
: resolvedType.ContainingNamespace.ToDisplayString();
writerClassName = string.IsNullOrEmpty(ns)
? $"{flatName}_GeneratedWriter"
: $"{ns}.{flatName}_GeneratedWriter";
// UseMetadata: compute child type hash-es for inline metadata
childTypeNameHash = ComputeFnvHash(resolvedType.Name);
childPropertyHashes = ComputeChildPropertyHashes(resolvedType);
}
}
// Collection element type analysis for inline collection write
PropertyTypeKind elemKind = PropertyTypeKind.Unknown;
bool elemHasGenWriter = false;
bool elemIsIId = false;
bool elemEnableMetadata = true;
bool elemNeedsIdScan = true;
bool elemNeedsAllRefScan = true;
bool elemNeedsInternScan = true;
string? elemWriterClassName = null;
string? elemIdTypeName = null;
string? collKind = null;
string? collAddMethod = null;
bool collHasCapacityCtor = false;
string? elemFullTypeName = null;
int elementTypeNameHash = 0;
int[]? elementPropertyHashes = null;
if (kind == PropertyTypeKind.Collection)
{
var elemType = GetCollectionElementType(p.Type);
if (elemType != null)
{
elemKind = GetKind(elemType);
elemFullTypeName = elemType.ToDisplayString();
// Detect collection shape for inline write
if (p.Type is IArrayTypeSymbol)
collKind = "Array";
else if (p.Type is INamedTypeSymbol collNamedType)
{
var origDef = collNamedType.OriginalDefinition.ToDisplayString();
collKind = origDef switch
{
"System.Collections.Generic.List<T>" => "List",
"System.Collections.Generic.IList<T>" => "IndexedCollection",
"System.Collections.Generic.IReadOnlyList<T>" => "IndexedCollection",
"System.Collections.Generic.HashSet<T>" => "Counted", // has Count, no indexer
"System.Collections.Generic.Queue<T>" => "Counted",
"System.Collections.Generic.ICollection<T>" => "Counted",
"System.Collections.Generic.IReadOnlyCollection<T>" => "Counted",
"System.Collections.Generic.SortedSet<T>" => "Counted",
"System.Collections.Generic.LinkedList<T>" => "Counted",
_ => null
};
// Determine add method + capacity ctor for Counted concrete types
if (collKind == "Counted")
{
collAddMethod = origDef switch
{
"System.Collections.Generic.HashSet<T>" => "Add",
"System.Collections.Generic.SortedSet<T>" => "Add",
"System.Collections.Generic.Queue<T>" => "Enqueue",
"System.Collections.Generic.LinkedList<T>" => "AddLast",
_ => null // ICollection<T>, IReadOnlyCollection<T> → backed by List<T>
};
collHasCapacityCtor = origDef is
"System.Collections.Generic.HashSet<T>" or
"System.Collections.Generic.Queue<T>";
}
}
// For Complex element types, check for generated writer
if (elemKind == PropertyTypeKind.Complex)
{
var resolvedElem = elemType is INamedTypeSymbol namedElem
? namedElem.OriginalDefinition : elemType;
elemHasGenWriter = resolvedElem.Locations.Any(l => l.IsInSource)
&& resolvedElem.GetAttributes().Any(a =>
a.AttributeClass?.ToDisplayString() == AttributeName);
if (elemHasGenWriter)
{
// Read element type's EnableMetadataFeature
elemEnableMetadata = ReadEnableMetadata(resolvedElem);
var elemScanFlags = ComputeNeedsScan(resolvedElem);
elemNeedsIdScan = elemScanFlags.needsIdScan;
elemNeedsAllRefScan = elemScanFlags.needsAllRefScan;
elemNeedsInternScan = elemScanFlags.needsInternScan;
var elemIidIface = resolvedElem.AllInterfaces.FirstOrDefault(ifc =>
ifc.IsGenericType &&
ifc.OriginalDefinition.ToDisplayString() == "AyCode.Core.Interfaces.IId<T>");
elemIsIId = elemIidIface != null;
if (elemIidIface != null)
elemIdTypeName = elemIidIface.TypeArguments[0].ToDisplayString();
var elemFlatName = BuildFlatName((INamedTypeSymbol)resolvedElem);
var ens = resolvedElem.ContainingNamespace.IsGlobalNamespace
? string.Empty : resolvedElem.ContainingNamespace.ToDisplayString();
elemWriterClassName = string.IsNullOrEmpty(ens)
? $"{elemFlatName}_GeneratedWriter"
: $"{ens}.{elemFlatName}_GeneratedWriter";
// UseMetadata: compute element type hash-es for inline metadata
elementTypeNameHash = ComputeFnvHash(resolvedElem.Name);
elementPropertyHashes = ComputeChildPropertyHashes(resolvedElem);
}
}
}
}
// Dictionary key/value type analysis for inline dictionary read
PropertyTypeKind dictKeyKind = PropertyTypeKind.Unknown;
PropertyTypeKind dictValueKind = PropertyTypeKind.Unknown;
string? dictKeyTypeName = null;
string? dictValueTypeName = null;
bool dictValueHasGenWriter = false;
string? dictValueWriterClassName = null;
bool dictValueIsIId = false;
bool dictValueEnableMetadata = true;
bool dictValueNeedsIdScan = true;
bool dictValueNeedsAllRefScan = true;
bool dictValueNeedsInternScan = true;
int dictValueTypeNameHash = 0;
int[]? dictValuePropertyHashes = null;
if (kind == PropertyTypeKind.Dictionary)
{
var (keyType, valueType) = GetDictionaryKeyValueTypes(p.Type);
if (keyType != null)
{
dictKeyKind = GetKind(keyType);
dictKeyTypeName = keyType.ToDisplayString();
}
if (valueType != null)
{
dictValueKind = GetKind(valueType);
dictValueTypeName = valueType.ToDisplayString();
if (dictValueKind == PropertyTypeKind.Complex)
{
var resolvedValue = valueType is INamedTypeSymbol nvt ? nvt.OriginalDefinition : valueType;
dictValueHasGenWriter = resolvedValue.Locations.Any(l => l.IsInSource)
&& resolvedValue.GetAttributes().Any(a =>
a.AttributeClass?.ToDisplayString() == AttributeName);
if (dictValueHasGenWriter)
{
var vfn = BuildFlatName((INamedTypeSymbol)resolvedValue);
var vns = resolvedValue.ContainingNamespace.IsGlobalNamespace
? string.Empty : resolvedValue.ContainingNamespace.ToDisplayString();
dictValueWriterClassName = string.IsNullOrEmpty(vns)
? $"{vfn}_GeneratedWriter"
: $"{vns}.{vfn}_GeneratedWriter";
dictValueEnableMetadata = ReadEnableMetadata(resolvedValue);
var dvScanFlags = ComputeNeedsScan(resolvedValue);
dictValueNeedsIdScan = dvScanFlags.needsIdScan;
dictValueNeedsAllRefScan = dvScanFlags.needsAllRefScan;
dictValueNeedsInternScan = dvScanFlags.needsInternScan;
var dvIidIface = resolvedValue.AllInterfaces.FirstOrDefault(ifc =>
ifc.IsGenericType &&
ifc.OriginalDefinition.ToDisplayString() == "AyCode.Core.Interfaces.IId<T>");
dictValueIsIId = dvIidIface != null;
dictValueTypeNameHash = ComputeFnvHash(resolvedValue.Name);
dictValuePropertyHashes = ComputeChildPropertyHashes(resolvedValue);
}
}
}
}
properties.Add(new PropInfo(
p.Name,
typeDisplayName,
typeNameForTypeof,
kind,
p.Type.NullableAnnotation == NullableAnnotation.Annotated || IsNullableVT(p.Type),
p.Type.SpecialType == SpecialType.System_Object,
stringInternAttr, hasGenWriter, propTypeIsIId, writerClassName, propIdTypeName,
elemKind, elemHasGenWriter, elemIsIId, elemWriterClassName, elemIdTypeName, collKind, elemFullTypeName,
collAddMethod, collHasCapacityCtor,
dictKeyKind, dictValueKind, dictKeyTypeName, dictValueTypeName, dictValueHasGenWriter, dictValueWriterClassName,
dictValueIsIId, dictValueEnableMetadata, dictValueTypeNameHash, dictValuePropertyHashes,
dictValueNeedsIdScan, dictValueNeedsAllRefScan, dictValueNeedsInternScan,
childTypeNameHash, childPropertyHashes,
elementTypeNameHash, elementPropertyHashes,
propEnableMetadata, elemEnableMetadata,
childNeedsIdScan, childNeedsAllRefScan, childNeedsInternScan,
elemNeedsIdScan, elemNeedsAllRefScan, elemNeedsInternScan));
}
// IId<T>: Id first (index 0), then alphabetical — matches runtime TypeMetadataBase ordering
// If EnableIdTrackingFeature == false, skip IId detection entirely → isIId = false
var isIId = false;
string? idTypeName = null;
if (enableIdTracking)
{
var iidInterface = typeSymbol.AllInterfaces.FirstOrDefault(i =>
i.IsGenericType &&
i.OriginalDefinition.ToDisplayString() == "AyCode.Core.Interfaces.IId<T>");
if (iidInterface != null)
{
isIId = true;
idTypeName = iidInterface.TypeArguments[0].ToDisplayString();
}
}
// Properties are already in runtime-matching order from GetAllSerializablePropertySymbols:
// derived → base, each level sorted alphabetically (matches TypeMetadataBase.GetUnfilteredProperties).
var className = BuildFlatName(typeSymbol);
var typeNameHash = ComputeFnvHash(typeSymbol.Name);
var propertyNameHashes = properties.Select(prop => ComputeFnvHash(prop.Name)).ToArray();
var selfScanFlags = ComputeNeedsScan(typeSymbol);
return new SerializableClassInfo(namespaceName, className, typeSymbol.ToDisplayString(), properties, isIId, idTypeName, enableRefHandling, typeNameHash, propertyNameHashes, enableMetadata, enablePropertyFilter, enablePolymorphDetect, enableInternString, selfScanFlags.needsIdScan, selfScanFlags.needsAllRefScan, selfScanFlags.needsInternScan);
}
}

View File

@ -0,0 +1,309 @@
using System.Collections.Generic;
namespace AyCode.Core.Serializers.SourceGenerator;
// Source-generator model types — pure POCO data carriers describing a `[AcBinarySerializable]` type
// and its serializable properties. Consumed by all emit / diagnostics / analysis passes in the partial
// `AcBinarySourceGenerator` class (see siblings `*.GenWriter.cs`, `*.GenReader.cs`, etc.).
internal sealed class SerializableClassInfo
{
public string Namespace { get; }
public string ClassName { get; }
public string FullTypeName { get; }
public List<PropInfo> Properties { get; }
/// <summary>True if this type implements IId&lt;T&gt;</summary>
public bool IsIId { get; }
/// <summary>The Id type name ("int", "long", "System.Guid") if IsIId, null otherwise</summary>
public string? IdTypeName { get; }
/// <summary>True if EnableRefHandlingFeature is enabled — controls non-IId All mode tracking code emission.</summary>
public bool EnableRefHandling { get; }
/// <summary>FNV-1a hash of ClassName (matches runtime SourceType.Name hash)</summary>
public int TypeNameHash { get; }
/// <summary>FNV-1a hash of each property name, in property order</summary>
public int[] PropertyNameHashes { get; }
/// <summary>When false, skip inline metadata and use markerless property write for this type.</summary>
public bool EnableMetadata { get; }
/// <summary>True if EnablePropertyFilterFeature is enabled — controls per-property HasPropertyFilter
/// guard emission in WriteProperties / ScanObject. When false, the filter check is omitted entirely
/// → leaner generated code on the hot path (typical for high-throughput types that never use a filter).</summary>
public bool EnablePropertyFilter { get; }
/// <summary>True if EnablePolymorphDetectFeature is enabled — controls <c>ObjectWithTypeName</c> + AQN
/// prefix emit on <c>System.Object</c>-declared properties. When false, the prefix is suppressed
/// AND ACBIN002 fires at build time if such a property exists on this type (guarding against silent
/// wire corruption). Opt-out is intentional: dev guarantees no polymorphic <c>object</c> property
/// will be serialized on this type, or all such properties are excluded via <c>[AcBinaryIgnore]</c>.</summary>
public bool EnablePolymorphDetect { get; }
/// <summary>True if EnableInternStringFeature is enabled — controls whether the SGen-emitted reader
/// contains <c>StringInterned</c>, <c>StringInternFirstSmall</c>, <c>StringInternFirstMedium</c> case-ágakat.
/// When false, those cases are omitted (the writer doesn't emit those markers when intern is off,
/// so the reader doesn't need to handle them). Leaner switch dispatch (~30% fewer string cases) +
/// smaller IL → faster cold-start JIT + smaller AOT publish.</summary>
public bool EnableInternString { get; }
/// <summary>When true, type subtree has IId types needing scan (active in OnlyId + All).</summary>
public bool NeedsIdScan { get; }
/// <summary>When true, type subtree has non-IId ref tracking (active only in All mode).</summary>
public bool NeedsAllRefScan { get; }
/// <summary>When true, type subtree needs string interning scan.</summary>
public bool NeedsInternScan { get; }
/// <summary>Derived: NeedsIdScan || NeedsAllRefScan.</summary>
public bool NeedsRefScan => NeedsIdScan || NeedsAllRefScan;
/// <summary>Derived: any scan axis active.</summary>
public bool NeedsScan => NeedsIdScan || NeedsAllRefScan || NeedsInternScan;
public SerializableClassInfo(string ns, string cn, string ftn, List<PropInfo> p, bool isIId, string? idTypeName, bool enableRefHandling, int typeNameHash, int[] propertyNameHashes, bool enableMetadata, bool enablePropertyFilter, bool enablePolymorphDetect, bool enableInternString, bool needsIdScan, bool needsAllRefScan, bool needsInternScan)
{ Namespace = ns; ClassName = cn; FullTypeName = ftn; Properties = p; IsIId = isIId; IdTypeName = idTypeName; EnableRefHandling = enableRefHandling; TypeNameHash = typeNameHash; PropertyNameHashes = propertyNameHashes; EnableMetadata = enableMetadata; EnablePropertyFilter = enablePropertyFilter; EnablePolymorphDetect = enablePolymorphDetect; EnableInternString = enableInternString; NeedsIdScan = needsIdScan; NeedsAllRefScan = needsAllRefScan; NeedsInternScan = needsInternScan; }
}
internal sealed class PropInfo
{
public string Name { get; }
public string TypeName { get; }
/// <summary>
/// Type name safe for typeof() — nullable ref type annotation stripped (typeof(T?) invalid for ref types).
/// </summary>
public string TypeNameForTypeof { get; }
public PropertyTypeKind TypeKind { get; }
public bool IsNullable { get; }
/// <summary>
/// Pre-computed interning flags matching runtime BinaryPropertyAccessorBase._interningFlags.
/// Bit layout: bit N = eligible when StringInterningMode == N.
/// None=0 → bit 0 never set. Attribute=1 → bit 1. All=2 → bit 2.
/// No attr: 0b100 (4), [AcStringIntern(true)]: 0b110 (6), [AcStringIntern(false)]: 0b000 (0).
/// </summary>
public int InterningFlags { get; }
/// <summary>True when declared property type is System.Object. Runtime type dispatch needed.</summary>
public bool IsObjectDeclaredType { get; }
/// <summary>True if the Complex property type has [AcBinarySerializable] → has a generated writer.</summary>
public bool HasGeneratedWriter { get; }
/// <summary>True if the Complex property type implements IId&lt;T&gt; → needs ref tracking in write pass.</summary>
public bool IsIId { get; }
/// <summary>Generated writer class name, e.g. "SharedTag_GeneratedWriter". Only set when HasGeneratedWriter.</summary>
public string? WriterClassName { get; }
/// <summary>Id type name ("int", "long", "System.Guid") for IId child types. Null if not IId.</summary>
public string? IdTypeName { get; }
// Collection element metadata — set when TypeKind == Collection and element type is Complex with generated writer
/// <summary>Element type kind for collection properties. Only meaningful when TypeKind == Collection.</summary>
public PropertyTypeKind ElementKind { get; }
/// <summary>True if collection element type has [AcBinarySerializable].</summary>
public bool ElementHasGeneratedWriter { get; }
/// <summary>True if collection element type implements IId&lt;T&gt;.</summary>
public bool ElementIsIId { get; }
/// <summary>Generated writer class name for collection element type.</summary>
public string? ElementWriterClassName { get; }
/// <summary>Id type name for collection element IId types. Null if not IId.</summary>
public string? ElementIdTypeName { get; }
/// <summary>Collection type: "List", "Array", "IndexedCollection", "Counted", or null (unknown — fallback to runtime).</summary>
public string? CollectionKind { get; }
/// <summary>Full element type name for generated code (e.g. "SharedTag").</summary>
public string? ElementFullTypeName { get; }
/// <summary>Add method for Counted concrete collections. null → List&lt;T&gt;.Add(), "Add" → HashSet/SortedSet, "Enqueue" → Queue, "AddLast" → LinkedList.</summary>
public string? CollectionAddMethod { get; }
/// <summary>True if the concrete Counted collection has a capacity constructor (HashSet, Queue).</summary>
public bool CollectionHasCapacityCtor { get; }
// Dictionary metadata — set when TypeKind == Dictionary
/// <summary>Key type kind for dictionary properties.</summary>
public PropertyTypeKind DictKeyKind { get; }
/// <summary>Value type kind for dictionary properties.</summary>
public PropertyTypeKind DictValueKind { get; }
/// <summary>Key type name for generated code.</summary>
public string? DictKeyTypeName { get; }
/// <summary>Value type name for generated code.</summary>
public string? DictValueTypeName { get; }
/// <summary>True if dictionary value type has [AcBinarySerializable].</summary>
public bool DictValueHasGeneratedWriter { get; }
/// <summary>Generated writer class name for dictionary value type.</summary>
public string? DictValueWriterClassName { get; }
/// <summary>True if dictionary value type implements IId&lt;T&gt;.</summary>
public bool DictValueIsIId { get; }
/// <summary>When false, dict value type skips inline metadata.</summary>
public bool DictValueEnableMetadata { get; }
/// <summary>FNV-1a hash of dict value type name.</summary>
public int DictValueTypeNameHash { get; }
/// <summary>FNV-1a hashes of dict value type's properties.</summary>
public int[]? DictValuePropertyHashes { get; }
/// <summary>When true, dict value subtree has IId types needing scan.</summary>
public bool DictValueNeedsIdScan { get; }
/// <summary>When true, dict value subtree has non-IId ref tracking.</summary>
public bool DictValueNeedsAllRefScan { get; }
/// <summary>When true, dict value subtree needs string interning scan.</summary>
public bool DictValueNeedsInternScan { get; }
/// <summary>Derived: DictValueNeedsIdScan || DictValueNeedsAllRefScan.</summary>
public bool DictValueNeedsRefScan => DictValueNeedsIdScan || DictValueNeedsAllRefScan;
/// <summary>Derived: any dict value scan axis active.</summary>
public bool DictValueNeedsScan => DictValueNeedsIdScan || DictValueNeedsAllRefScan || DictValueNeedsInternScan;
// UseMetadata inline hash-ek (Complex/Collection child típushoz)
/// <summary>FNV-1a hash of child type name (Complex property). Only set when HasGeneratedWriter.</summary>
public int ChildTypeNameHash { get; }
/// <summary>FNV-1a hashes of child type's properties. Only set when HasGeneratedWriter.</summary>
public int[]? ChildPropertyHashes { get; }
/// <summary>FNV-1a hash of collection element type name. Only set when ElementHasGeneratedWriter.</summary>
public int ElementTypeNameHash { get; }
/// <summary>FNV-1a hashes of collection element type's properties. Only set when ElementHasGeneratedWriter.</summary>
public int[]? ElementPropertyHashes { get; }
/// <summary>When false, child Complex type skips inline metadata in generated code.</summary>
public bool ChildEnableMetadata { get; }
/// <summary>When false, collection element type skips inline metadata in generated code.</summary>
public bool ElementEnableMetadata { get; }
/// <summary>When true, child subtree has IId types needing scan (active in OnlyId + All).</summary>
public bool ChildNeedsIdScan { get; }
/// <summary>When true, child subtree has non-IId ref tracking (active only in All mode).</summary>
public bool ChildNeedsAllRefScan { get; }
/// <summary>When true, child subtree needs string interning scan.</summary>
public bool ChildNeedsInternScan { get; }
/// <summary>Derived: ChildNeedsIdScan || ChildNeedsAllRefScan.</summary>
public bool ChildNeedsRefScan => ChildNeedsIdScan || ChildNeedsAllRefScan;
/// <summary>Derived: any child scan axis active.</summary>
public bool ChildNeedsScan => ChildNeedsIdScan || ChildNeedsAllRefScan || ChildNeedsInternScan;
/// <summary>When true, element subtree has IId types needing scan (active in OnlyId + All).</summary>
public bool ElementNeedsIdScan { get; }
/// <summary>When true, element subtree has non-IId ref tracking (active only in All mode).</summary>
public bool ElementNeedsAllRefScan { get; }
/// <summary>When true, element subtree needs string interning scan.</summary>
public bool ElementNeedsInternScan { get; }
/// <summary>Derived: ElementNeedsIdScan || ElementNeedsAllRefScan.</summary>
public bool ElementNeedsRefScan => ElementNeedsIdScan || ElementNeedsAllRefScan;
/// <summary>Derived: any element scan axis active.</summary>
public bool ElementNeedsScan => ElementNeedsIdScan || ElementNeedsAllRefScan || ElementNeedsInternScan;
public PropInfo(string n, string tn, string tnForTypeof, PropertyTypeKind tk, bool nullable,
bool isObjectDeclaredType = false,
bool? stringInternAttr = null, bool hasGeneratedWriter = false, bool isIId = false, string? writerClassName = null, string? idTypeName = null,
PropertyTypeKind elementKind = PropertyTypeKind.Unknown, bool elementHasGenWriter = false, bool elementIsIId = false,
string? elementWriterClassName = null, string? elementIdTypeName = null, string? collectionKind = null, string? elementFullTypeName = null,
string? collectionAddMethod = null, bool collectionHasCapacityCtor = false,
PropertyTypeKind dictKeyKind = PropertyTypeKind.Unknown, PropertyTypeKind dictValueKind = PropertyTypeKind.Unknown,
string? dictKeyTypeName = null, string? dictValueTypeName = null,
bool dictValueHasGeneratedWriter = false, string? dictValueWriterClassName = null,
bool dictValueIsIId = false, bool dictValueEnableMetadata = true,
int dictValueTypeNameHash = 0, int[]? dictValuePropertyHashes = null,
bool dictValueNeedsIdScan = true, bool dictValueNeedsAllRefScan = true, bool dictValueNeedsInternScan = true,
int childTypeNameHash = 0, int[]? childPropertyHashes = null,
int elementTypeNameHash = 0, int[]? elementPropertyHashes = null,
bool childEnableMetadata = true, bool elementEnableMetadata = true,
bool childNeedsIdScan = true, bool childNeedsAllRefScan = true, bool childNeedsInternScan = true,
bool elementNeedsIdScan = true, bool elementNeedsAllRefScan = true, bool elementNeedsInternScan = true)
{
Name = n;
TypeName = tn;
TypeNameForTypeof = tnForTypeof;
TypeKind = tk;
IsNullable = nullable;
IsObjectDeclaredType = isObjectDeclaredType;
HasGeneratedWriter = hasGeneratedWriter;
IsIId = isIId;
WriterClassName = writerClassName;
IdTypeName = idTypeName;
ElementKind = elementKind;
ElementHasGeneratedWriter = elementHasGenWriter;
ElementIsIId = elementIsIId;
ElementWriterClassName = elementWriterClassName;
ElementIdTypeName = elementIdTypeName;
CollectionKind = collectionKind;
ElementFullTypeName = elementFullTypeName;
CollectionAddMethod = collectionAddMethod;
CollectionHasCapacityCtor = collectionHasCapacityCtor;
DictKeyKind = dictKeyKind;
DictValueKind = dictValueKind;
DictKeyTypeName = dictKeyTypeName;
DictValueTypeName = dictValueTypeName;
DictValueHasGeneratedWriter = dictValueHasGeneratedWriter;
DictValueWriterClassName = dictValueWriterClassName;
DictValueIsIId = dictValueIsIId;
DictValueEnableMetadata = dictValueEnableMetadata;
DictValueTypeNameHash = dictValueTypeNameHash;
DictValuePropertyHashes = dictValuePropertyHashes;
DictValueNeedsIdScan = dictValueNeedsIdScan;
DictValueNeedsAllRefScan = dictValueNeedsAllRefScan;
DictValueNeedsInternScan = dictValueNeedsInternScan;
ChildTypeNameHash = childTypeNameHash;
ChildPropertyHashes = childPropertyHashes;
ElementTypeNameHash = elementTypeNameHash;
ElementPropertyHashes = elementPropertyHashes;
ChildEnableMetadata = childEnableMetadata;
ElementEnableMetadata = elementEnableMetadata;
ChildNeedsIdScan = childNeedsIdScan;
ChildNeedsAllRefScan = childNeedsAllRefScan;
ChildNeedsInternScan = childNeedsInternScan;
ElementNeedsIdScan = elementNeedsIdScan;
ElementNeedsAllRefScan = elementNeedsAllRefScan;
ElementNeedsInternScan = elementNeedsInternScan;
// Mirror runtime _interningFlags computation from BinaryPropertyAccessorBase
int flags = 0;
if (stringInternAttr == true) flags |= (1 << 1); // Attribute bit
if (stringInternAttr != false) flags |= (1 << 2); // All bit
InterningFlags = flags;
}
}
internal enum PropertyTypeKind
{
Unknown, String, Int32, Int64, Int16, Byte, UInt16, UInt32, UInt64,
Boolean, Single, Double, Decimal, DateTime, DateTimeOffset, TimeSpan, Guid, Enum,
Collection, Complex, Dictionary,
NullableInt32, NullableInt64, NullableInt16, NullableByte, NullableUInt16, NullableUInt32, NullableUInt64,
NullableBoolean, NullableSingle, NullableDouble, NullableDecimal, NullableDateTime,
NullableDateTimeOffset, NullableTimeSpan, NullableGuid, NullableEnum
}
/// <summary>
/// Single source of truth for the compile-time decision: does the SGen-emit need a full ref-aware
/// switch (<c>Object</c> / <c>ObjectRefFirst</c> / <c>Null</c> / <c>ObjectRef</c> / FixObj) for a
/// given Complex property or collection element, OR can it use the zero-branch path
/// (<c>Object</c> / <c>Null</c> / FixObj only)?
///
/// <para><b>Predicate semantics</b>: the decision depends EXCLUSIVELY on whether the child
/// element subtree may emit ref markers — captured by <c>PropInfo.ChildNeedsRefScan</c> /
/// <c>PropInfo.ElementNeedsRefScan</c>. The parent-level <c>EnableRefHandlingFeature</c> flag is
/// <b>NOT</b> a factor here — that flag governs only the parent's SELF-tracking emit in the scan
/// pass (<c>GenWriter.cs</c> line 140), it does NOT suppress marker dispatch for child element
/// properties of THIS type.</para>
///
/// <para><b>Writer / reader symmetry</b> — invoked from BOTH sides so the compile-time decision is
/// identical at every call site:</para>
/// <list type="bullet">
/// <item><c>GenReader.EmitReadComplex</c> — guards zero-branch vs full ref-aware switch.</item>
/// <item><c>GenReader.EmitReadCollectionElement</c> — same guard for collection-element dispatch.</item>
/// <item><c>GenReader.EmitReadDictionary</c> — same guard for dictionary-value dispatch.</item>
/// <item><c>GenWriter.EmitDirectCollectionWrite</c> — guards <c>Object</c>-only vs
/// <c>WriteObjectRefMarker*</c> (runtime decide) emit on the writer side.</item>
/// </list>
///
/// <para><b>Why a generator-only helper, not a runtime helper</b> — the result is inlined into
/// the generated code as either the zero-branch ag or the full-switch ag. The predicate runs
/// once per emit-site at generation time; the runtime code has zero overhead from this abstraction
/// (no method call, no branch on the runtime hot path).</para>
///
/// <para>Regression target: <c>AcBinarySerializerIIdReferenceTests.Serialize_RefMarkerCollectionElement_ParentRefHandlingFeatureOff_DriftReproduction</c>.</para>
/// </summary>
internal static class RefAwareEmitPredicate
{
/// <summary>
/// Reader-side decision for a Complex property (<c>EmitReadComplex</c>) — does the
/// emit need a full ref-aware switch on <c>p.ChildNeedsRefScan</c>?
/// </summary>
internal static bool ChildEmitsRefMarker(PropInfo p) => p.ChildNeedsRefScan;
/// <summary>
/// Reader-side decision for a collection element (<c>EmitReadCollectionElement</c>) and
/// writer-side decision for the same element (<c>EmitDirectCollectionWrite</c>) — keyed on
/// <c>p.ElementNeedsRefScan</c>.
/// </summary>
internal static bool ElementEmitsRefMarker(PropInfo p) => p.ElementNeedsRefScan;
/// <summary>
/// Reader-side overload for <c>EmitReadCollectionElement</c> when only the bool flag is in
/// scope (e.g. when <c>PropInfo</c> is unrolled at the call site). Same semantics — kept as
/// a thin overload so EVERY call site routes through this predicate, not the raw field.
/// </summary>
internal static bool ElementEmitsRefMarker(bool elementNeedsRefScan) => elementNeedsRefScan;
/// <summary>
/// Reader-side decision for a dictionary value (<c>EmitReadDictionary</c>) — keyed on
/// <c>p.DictValueNeedsRefScan</c>. Symmetric with the Complex / Collection-element overloads.
/// </summary>
internal static bool DictValueEmitsRefMarker(PropInfo p) => p.DictValueNeedsRefScan;
}

View File

@ -0,0 +1,368 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.CodeAnalysis;
namespace AyCode.Core.Serializers.SourceGenerator;
/// <summary>
/// Type-analysis utilities for the AcBinary source generator: kind detection, FNV-1a hashing,
/// symbol enumeration, name flattening, and recursive scan-need computation. All methods are
/// pure functions over Roslyn symbols (no mutable state, safe to call from any emit pass).
/// </summary>
public partial class AcBinarySourceGenerator
{
/// <summary>
/// Returns true for property types that use markerless serialization in FastMode.
/// These types have ExpectedTypeCode at runtime — no type marker byte, no PropertySkip for defaults.
/// </summary>
private static bool IsMarkerless(PropertyTypeKind k) => k switch
{
PropertyTypeKind.Int32 or PropertyTypeKind.Int64 or PropertyTypeKind.Int16 or
PropertyTypeKind.Byte or PropertyTypeKind.UInt16 or PropertyTypeKind.UInt32 or PropertyTypeKind.UInt64 or
PropertyTypeKind.Double or PropertyTypeKind.Single or PropertyTypeKind.Decimal or
PropertyTypeKind.DateTime or PropertyTypeKind.Guid or
PropertyTypeKind.TimeSpan or PropertyTypeKind.DateTimeOffset or
PropertyTypeKind.Boolean or PropertyTypeKind.Enum => true,
_ => false
};
/// <summary>
/// Builds a flat class name for nested types: Outer_Inner_Leaf.
/// For top-level types returns the simple name unchanged.
/// </summary>
private static string BuildFlatName(INamedTypeSymbol typeSymbol)
{
if (typeSymbol.ContainingType == null)
return typeSymbol.Name;
var parts = new List<string>();
var current = typeSymbol;
while (current != null)
{
parts.Add(current.Name);
current = current.ContainingType;
}
parts.Reverse();
return string.Join("_", parts);
}
/// <summary>
/// Reads EnableMetadataFeature from a type's [AcBinarySerializable] attribute.
/// Returns true (default) if no attribute or enableAllFeatures=true.
/// </summary>
private static bool ReadEnableMetadata(ITypeSymbol type)
{
var attr = type.GetAttributes().FirstOrDefault(a =>
a.AttributeClass?.ToDisplayString() == AttributeName);
if (attr == null) return true;
if (attr.ConstructorArguments.Length == 1)
return (bool)attr.ConstructorArguments[0].Value!;
if (attr.ConstructorArguments.Length == 4)
return (bool)attr.ConstructorArguments[0].Value!;
return true;
}
/// <summary>
/// Computes whether a type needs scan pass work, split into ref tracking and string interning.
/// Uses a per-call HashSet to guard against circular references (no static cache —
/// static state is unsafe in incremental generators as it persists across builds).
/// Returns (needsRefScan, needsInternScan) — these are independent axes.
/// </summary>
private static (bool needsIdScan, bool needsAllRefScan, bool needsInternScan) ComputeNeedsScan(ITypeSymbol type)
{
return ComputeNeedsScanCore(type, new HashSet<string>());
}
private static (bool needsIdScan, bool needsAllRefScan, bool needsInternScan) ComputeNeedsScanCore(ITypeSymbol type, HashSet<string> visiting)
{
// Circular reference guard: if already visiting this type, assume true (safe fallback)
var key = type.ToDisplayString();
if (!visiting.Add(key))
return (true, true, true);
// Read [AcBinarySerializable] flags
var attr = type.GetAttributes().FirstOrDefault(a =>
a.AttributeClass?.ToDisplayString() == AttributeName);
bool enableIdTracking = true, enableRefHandling = true, enableInternString = true;
if (attr != null)
{
if (attr.ConstructorArguments.Length == 1)
{
var all = (bool)attr.ConstructorArguments[0].Value!;
enableIdTracking = enableRefHandling = enableInternString = all;
}
else if (attr.ConstructorArguments.Length == 4)
{
enableIdTracking = (bool)attr.ConstructorArguments[1].Value!;
enableRefHandling = (bool)attr.ConstructorArguments[2].Value!;
enableInternString = (bool)attr.ConstructorArguments[3].Value!;
}
}
// IId tracking: active in OnlyId + All modes
var isIId = enableIdTracking && type.AllInterfaces.Any(i =>
i.IsGenericType && i.OriginalDefinition.ToDisplayString() == "AyCode.Core.Interfaces.IId<T>");
var needsIdScan = isIId;
// Non-IId ref tracking: active only in All mode
var needsAllRefScan = !isIId && enableRefHandling;
var needsInternScan = false;
// Check properties for string interning or complex children
foreach (var p in GetAllSerializablePropertySymbols(type))
{
// Early exit: if all flags are already true, no need to check more properties
if (needsIdScan && needsAllRefScan && needsInternScan) break;
var kind = GetKind(p.Type);
// String with interning?
if (enableInternString && kind == PropertyTypeKind.String)
{
var internAttr = p.GetAttributes().FirstOrDefault(a =>
a.AttributeClass?.ToDisplayString() == "AyCode.Core.Serializers.Binaries.AcStringInternAttribute");
if (internAttr == null || (internAttr.ConstructorArguments.Length == 1 && (bool)internAttr.ConstructorArguments[0].Value!))
needsInternScan = true;
}
// Complex child → recurse
if (kind == PropertyTypeKind.Complex)
{
var resolved = p.Type is INamedTypeSymbol nt ? nt.OriginalDefinition : p.Type;
var childFlags = ComputeNeedsScanCore(resolved, visiting);
needsIdScan |= childFlags.needsIdScan;
needsAllRefScan |= childFlags.needsAllRefScan;
needsInternScan |= childFlags.needsInternScan;
}
// Collection → check element type
if (kind == PropertyTypeKind.Collection)
{
var elemType = GetCollectionElementType(p.Type);
if (elemType != null)
{
var elemKind = GetKind(elemType);
if (enableInternString && elemKind == PropertyTypeKind.String)
needsInternScan = true;
if (elemKind == PropertyTypeKind.Complex)
{
var resolvedElem = elemType is INamedTypeSymbol ne ? ne.OriginalDefinition : elemType;
var elemFlags = ComputeNeedsScanCore(resolvedElem, visiting);
needsIdScan |= elemFlags.needsIdScan;
needsAllRefScan |= elemFlags.needsAllRefScan;
needsInternScan |= elemFlags.needsInternScan;
}
}
}
// Dictionary → check key and value types
if (kind == PropertyTypeKind.Dictionary)
{
var (keyType, valueType) = GetDictionaryKeyValueTypes(p.Type);
if (keyType != null && enableInternString && GetKind(keyType) == PropertyTypeKind.String)
needsInternScan = true;
if (valueType != null)
{
var valKind = GetKind(valueType);
if (enableInternString && valKind == PropertyTypeKind.String)
needsInternScan = true;
if (valKind == PropertyTypeKind.Complex)
{
var resolvedVal = valueType is INamedTypeSymbol nv ? nv.OriginalDefinition : valueType;
var valFlags = ComputeNeedsScanCore(resolvedVal, visiting);
needsIdScan |= valFlags.needsIdScan;
needsAllRefScan |= valFlags.needsAllRefScan;
needsInternScan |= valFlags.needsInternScan;
}
}
}
}
return (needsIdScan, needsAllRefScan, needsInternScan);
}
#region FNV-1a Hash (compile-time)
private static int ComputeFnvHash(string value)
{
uint hash = 2166136261;
for (int i = 0; i < value.Length; i++)
{
hash ^= value[i];
hash *= 16777619;
}
return (int)hash;
}
/// <summary>
/// Computes FNV-1a hashes for all serializable properties of a child type.
/// Property filtering and ordering matches runtime TypeMetadataBase exactly:
/// derived → base, each level sorted alphabetically, with ignore attribute filtering.
/// </summary>
private static int[] ComputeChildPropertyHashes(ITypeSymbol resolvedType)
{
// Use hierarchy-walking helper — order matches runtime TypeMetadataBase
var props = GetAllSerializablePropertySymbols(resolvedType);
return props.Select(p => ComputeFnvHash(p.Name)).ToArray();
}
#endregion
/// <summary>
/// Collects all serializable property symbols from the full inheritance hierarchy.
/// Order matches runtime TypeMetadataBase.GetUnfilteredProperties exactly:
/// derived → base, each level sorted alphabetically by name.
/// Filters: public, get+set, non-indexer, non-static, no ignore attributes.
/// Deduplicates by name (most-derived override wins).
/// </summary>
private static List<IPropertySymbol> GetAllSerializablePropertySymbols(ITypeSymbol typeSymbol)
{
var result = new List<IPropertySymbol>();
var seen = new HashSet<string>();
for (var currentType = typeSymbol as INamedTypeSymbol;
currentType != null && currentType.SpecialType != SpecialType.System_Object;
currentType = currentType.BaseType)
{
var levelProps = new List<IPropertySymbol>();
foreach (var member in currentType.GetMembers())
{
if (member is IPropertySymbol p &&
p.DeclaredAccessibility == Accessibility.Public &&
p.GetMethod != null && p.SetMethod != null &&
!p.IsIndexer && !p.IsStatic &&
seen.Add(p.Name)) // dedup: most-derived wins
{
var hasIgnore = p.GetAttributes().Any(a =>
{
var name = a.AttributeClass?.Name ?? "";
return name == "JsonIgnoreAttribute" || name == "IgnoreMemberAttribute" || name == "BsonIgnoreAttribute";
});
if (hasIgnore) continue;
levelProps.Add(p);
}
}
// Sort each level alphabetically — matches runtime OrderBy(p => p.Name, Ordinal)
levelProps.Sort((a, b) => string.Compare(a.Name, b.Name, StringComparison.Ordinal));
result.AddRange(levelProps);
}
return result;
}
#region Type analysis
private static bool IsNullableVT(ITypeSymbol t) =>
t is INamedTypeSymbol n && n.IsGenericType && n.ConstructedFrom.SpecialType == SpecialType.System_Nullable_T;
private static PropertyTypeKind GetKind(ITypeSymbol type)
{
if (type is INamedTypeSymbol n && n.IsGenericType && n.ConstructedFrom.SpecialType == SpecialType.System_Nullable_T)
return GetKindCore(n.TypeArguments[0], true);
return GetKindCore(type, false);
}
private static PropertyTypeKind GetKindCore(ITypeSymbol type, bool nullable)
{
switch (type.SpecialType)
{
case SpecialType.System_String: return PropertyTypeKind.String;
case SpecialType.System_Int32: return nullable ? PropertyTypeKind.NullableInt32 : PropertyTypeKind.Int32;
case SpecialType.System_Int64: return nullable ? PropertyTypeKind.NullableInt64 : PropertyTypeKind.Int64;
case SpecialType.System_Int16: return nullable ? PropertyTypeKind.NullableInt16 : PropertyTypeKind.Int16;
case SpecialType.System_Byte: return nullable ? PropertyTypeKind.NullableByte : PropertyTypeKind.Byte;
case SpecialType.System_UInt16: return nullable ? PropertyTypeKind.NullableUInt16 : PropertyTypeKind.UInt16;
case SpecialType.System_UInt32: return nullable ? PropertyTypeKind.NullableUInt32 : PropertyTypeKind.UInt32;
case SpecialType.System_UInt64: return nullable ? PropertyTypeKind.NullableUInt64 : PropertyTypeKind.UInt64;
case SpecialType.System_Boolean: return nullable ? PropertyTypeKind.NullableBoolean : PropertyTypeKind.Boolean;
case SpecialType.System_Single: return nullable ? PropertyTypeKind.NullableSingle : PropertyTypeKind.Single;
case SpecialType.System_Double: return nullable ? PropertyTypeKind.NullableDouble : PropertyTypeKind.Double;
case SpecialType.System_Decimal: return nullable ? PropertyTypeKind.NullableDecimal : PropertyTypeKind.Decimal;
case SpecialType.System_DateTime: return nullable ? PropertyTypeKind.NullableDateTime : PropertyTypeKind.DateTime;
default: break;
}
var fn = type.ToDisplayString();
if (fn == "System.Guid") return nullable ? PropertyTypeKind.NullableGuid : PropertyTypeKind.Guid;
if (fn == "System.TimeSpan") return nullable ? PropertyTypeKind.NullableTimeSpan : PropertyTypeKind.TimeSpan;
if (fn == "System.DateTimeOffset") return nullable ? PropertyTypeKind.NullableDateTimeOffset : PropertyTypeKind.DateTimeOffset;
if (type.TypeKind == TypeKind.Enum) return nullable ? PropertyTypeKind.NullableEnum : PropertyTypeKind.Enum;
if (type is IArrayTypeSymbol) return PropertyTypeKind.Collection;
// Dictionary detection: must come before IEnumerable<T> (Dictionary implements both)
if (type is INamedTypeSymbol dictNt && dictNt.IsGenericType)
{
var orig = dictNt.OriginalDefinition.ToDisplayString();
if (orig == "System.Collections.Generic.IDictionary<TKey, TValue>" ||
orig == "System.Collections.Generic.Dictionary<TKey, TValue>" ||
dictNt.AllInterfaces.Any(ifc => ifc.OriginalDefinition.ToDisplayString() == "System.Collections.Generic.IDictionary<TKey, TValue>"))
return PropertyTypeKind.Dictionary;
}
if (type is INamedTypeSymbol nt && nt.AllInterfaces.Any(iface => iface.OriginalDefinition.SpecialType == SpecialType.System_Collections_Generic_IEnumerable_T))
return PropertyTypeKind.Collection;
if (type.TypeKind == TypeKind.Class || type.TypeKind == TypeKind.Struct) return PropertyTypeKind.Complex;
return PropertyTypeKind.Unknown;
}
/// <summary>
/// Extracts the element type T from List&lt;T&gt;, T[], IList&lt;T&gt;, IEnumerable&lt;T&gt;.
/// Returns null if the element type cannot be determined.
/// </summary>
private static ITypeSymbol? GetCollectionElementType(ITypeSymbol type)
{
// T[] → element type
if (type is IArrayTypeSymbol arrayType)
return arrayType.ElementType;
// Generic collections: List<T>, IList<T>, ICollection<T>, IEnumerable<T>
if (type is INamedTypeSymbol namedType && namedType.IsGenericType)
{
// Direct: List<T>, HashSet<T>, etc. — first type argument
var iface = namedType.AllInterfaces
.FirstOrDefault(i => i.OriginalDefinition.SpecialType == SpecialType.System_Collections_Generic_IEnumerable_T);
if (iface != null)
return iface.TypeArguments[0];
}
return null;
}
/// <summary>
/// Extracts key and value types from Dictionary&lt;K,V&gt; or IDictionary&lt;K,V&gt;.
/// </summary>
private static (ITypeSymbol? keyType, ITypeSymbol? valueType) GetDictionaryKeyValueTypes(ITypeSymbol type)
{
if (type is INamedTypeSymbol nt && nt.IsGenericType)
{
var orig = nt.OriginalDefinition.ToDisplayString();
if (orig == "System.Collections.Generic.Dictionary<TKey, TValue>" ||
orig == "System.Collections.Generic.IDictionary<TKey, TValue>")
return (nt.TypeArguments[0], nt.TypeArguments[1]);
var iface = nt.AllInterfaces.FirstOrDefault(i =>
i.OriginalDefinition.ToDisplayString() == "System.Collections.Generic.IDictionary<TKey, TValue>");
if (iface != null)
return (iface.TypeArguments[0], iface.TypeArguments[1]);
}
return (null, null);
}
private static bool IsNullableVTKind(PropertyTypeKind k) => k >= PropertyTypeKind.NullableInt32;
private static PropertyTypeKind Underlying(PropertyTypeKind k) => k switch
{
PropertyTypeKind.NullableInt32 => PropertyTypeKind.Int32, PropertyTypeKind.NullableInt64 => PropertyTypeKind.Int64,
PropertyTypeKind.NullableInt16 => PropertyTypeKind.Int16, PropertyTypeKind.NullableByte => PropertyTypeKind.Byte,
PropertyTypeKind.NullableUInt16 => PropertyTypeKind.UInt16, PropertyTypeKind.NullableUInt32 => PropertyTypeKind.UInt32,
PropertyTypeKind.NullableUInt64 => PropertyTypeKind.UInt64, PropertyTypeKind.NullableBoolean => PropertyTypeKind.Boolean,
PropertyTypeKind.NullableSingle => PropertyTypeKind.Single, PropertyTypeKind.NullableDouble => PropertyTypeKind.Double,
PropertyTypeKind.NullableDecimal => PropertyTypeKind.Decimal, PropertyTypeKind.NullableDateTime => PropertyTypeKind.DateTime,
PropertyTypeKind.NullableDateTimeOffset => PropertyTypeKind.DateTimeOffset, PropertyTypeKind.NullableTimeSpan => PropertyTypeKind.TimeSpan,
PropertyTypeKind.NullableGuid => PropertyTypeKind.Guid, PropertyTypeKind.NullableEnum => PropertyTypeKind.Enum,
_ => PropertyTypeKind.Unknown
};
#endregion
}

View File

@ -12,6 +12,15 @@ Targets **netstandard2.0** (required for Roslyn analyzers/generators).
- `ModuleInitializer` — Auto-registers all generated writers/readers at startup.
- Circular reference detection with `ACBIN001` diagnostic warning.
## Slot Allocation
Each generated writer reserves a unique type slot via `AcBinarySerializer.AllocateWrapperSlot()` (static field initializer, `Interlocked.Increment`).
- **Slots 063** — reserved for runtime polymorphic types (assigned dynamically on first encounter)
- **Slots 64+** — source-generated types (allocated at `[ModuleInitializer]` registration time)
**Slot indices are NOT stable across compilations.** The order depends on Roslyn's `ForAttributeWithMetadataName()` enumeration order, which may vary between builds. This is fine because slots are only meaningful within a single serialization/deserialization session — they are never persisted to disk or sent over the wire as slot indices (the wire format uses type names or metadata hashes for cross-session/cross-type compatibility).
## Feature Flags
The `[AcBinarySerializable]` attribute supports per-type feature control:
@ -28,7 +37,3 @@ Disabled features eliminate corresponding code blocks from generated output (zer
|---|---|
| `Microsoft.CodeAnalysis.CSharp` | Roslyn syntax/semantic APIs |
| `Microsoft.CodeAnalysis.Analyzers` | Analyzer best practices |
---
> **LLM Maintenance:** If you modify code in this folder, update this README to reflect the changes. If you notice the README content does not match the current code, automatically update the README to match the code.

View File

@ -7,7 +7,7 @@
<ItemGroup>
<PackageReference Include="MessagePack" Version="3.1.4" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.5" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.11" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
</ItemGroup>
@ -15,4 +15,9 @@
<ProjectReference Include="..\AyCode.Core\AyCode.Core.csproj" />
</ItemGroup>
<ItemGroup>
<None Include="docs\**\*.md" />
<None Include="**\README.md" Exclude="$(DefaultItemExcludes);docs\**" />
</ItemGroup>
</Project>

View File

@ -1,11 +1,9 @@
# Loggers
Provides a singleton `GlobalLogger` for application-wide logging with multiple severity levels (Detail, Debug, Info, Warning, Suggest, Error) and support for pluggable log writers.
Server-side singleton logger for static access across the application.
> For full logging architecture see `docs/LOGGING/README.md`. For core logger and writer abstractions see `AyCode.Core/Loggers/README.md`.
## Key Files
- **`GlobalLogger.cs`** — Singleton static logger that delegates to `AcLoggerBase`. Supports category names, caller member tracking, and configurable `LogLevel` and `AppType`.
---
> **LLM Maintenance:** If you modify code in this folder, update this README to reflect the changes. If you notice the README content does not match the current code, automatically update the README to match the code.
- **`GlobalLogger.cs`** — Singleton static wrapper around an internal `AcGlobalLoggerBase` (sealed `AcLoggerBase` subclass). Provides static methods for all log levels (`Detail`, `Debug`, `Info`, `Warning`, `Suggest`, `Error`, `Write`). Default category: `"GLOBAL_LOGGER"`. Reads config from `appsettings.json` like any `AcLoggerBase`. Exposes `GetWriters` and `Writer<T>()` for accessing specific writer instances.

View File

@ -1,7 +1,17 @@
# AyCode.Core.Server
@project {
type = "framework"
}
Server-side extension of AyCode.Core. Provides server-specific implementations that build on the shared core library.
## Documentation
| Document | Topic |
|---|---|
| `LOGGING/README.md` | GlobalLogger singleton, server-side logging |
## Folder Structure
| Folder | Purpose |
@ -21,7 +31,3 @@ Server-side extension of AyCode.Core. Provides server-specific implementations t
| `MessagePack` | MessagePack serialization |
| `Newtonsoft.Json` | JSON serialization |
| `Microsoft.Extensions.Logging.Abstractions` | Logging abstractions |
---
> **LLM Maintenance:** If you modify code in this folder, update this README to reflect the changes. If you notice the README content does not match the current code, automatically update the README to match the code.

View File

@ -0,0 +1,23 @@
# Server Logging
Server-side logging extensions. Core framework (base classes, config, LogLevel, ILogger bridge): `AyCode.Core/AyCode.Core/docs/LOGGING/README.md` | Remote writers (HTTP, browser, SignalR): `AyCode.Services/docs/LOGGING/README.md`.
## GlobalLogger
Server-side singleton for static access. Wraps an internal `AcGlobalLoggerBase` instance (sealed `AcLoggerBase` subclass):
```csharp
GlobalLogger.Info("Server started");
GlobalLogger.Error("Failed to process", ex, "MyCategory");
GlobalLogger.Writer<IAcConsoleLogWriter>()?.Suggest("hint");
```
Default category: `"GLOBAL_LOGGER"`. Reads config from `appsettings.json` like any other `AcLoggerBase` instance.
All static methods mirror the `IAcLogWriterBase` contract: `Detail`, `Debug`, `Info`, `Warning`, `Suggest`, `Error`, `Write`.
## Key Source Files
| Component | Path |
|-----------|------|
| GlobalLogger | `Loggers/GlobalLogger.cs` |

View File

@ -0,0 +1,16 @@
# AyCode.Core.Server documentation
Topic docs for the `AyCode.Core.Server` project (Layer 0, server-side).
## Topics
- [`LOGGING/`](LOGGING/README.md) — Server-side logger (variant of AyCode.Core's base logger)
## Navigation
Per the folder-navigation rule, start here when browsing `docs/`. Each topic folder has its own `README.md` (main content) + optional `TOPIC_ISSUES.md` and `TOPIC_TODO.md`.
## See also
- **Base logger** (framework): `../../AyCode.Core/AyCode.Core/docs/LOGGING/README.md`
- **Remote logger** (AyCode.Services variant): `../../AyCode.Services/docs/LOGGING/README.md`

View File

@ -16,7 +16,3 @@ Concrete entity implementations inheriting from AyCode.Entities abstract generic
## Relationships
User ↔ Company (many-to-many via UserToCompany), User → Profile → Address (one-to-one chain), EmailMessage → EmailRecipient (one-to-many).
---
> **LLM Maintenance:** If you modify code in this folder, update this README to reflect the changes. If you notice the README content does not match the current code, automatically update the README to match the code.

View File

@ -15,7 +15,3 @@ Concrete entity implementations for database integration testing. Exposes types
| `MSTest` | Test framework |
| `AyCode.Core.Tests` | Shared test utilities |
| `AyCode.Entities` / `AyCode.Entities.Server` | Abstract entity base classes |
---
> **LLM Maintenance:** If you modify code in this folder, update this README to reflect the changes. If you notice the README content does not match the current code, automatically update the README to match the code.

View File

@ -5,7 +5,3 @@ GZip compression utility tests.
## Key Files
- **`GzipHelperTests.cs`** — Tests GzipHelper.Compress(), DecompressToString(), DecompressToRentedBuffer() (ArrayPool), IsGzipCompressed() (magic byte detection).
---
> **LLM Maintenance:** If you modify code in this folder, update this README to reflect the changes. If you notice the README content does not match the current code, automatically update the README to match the code.

View File

@ -5,7 +5,3 @@ Hand-written examples of the code pattern that the AcBinarySerializable source g
## Key Files
- **`TestOrderWriter.cs`** — Example IGeneratedBinaryWriter: direct property access (no reflection), alphabetical order, value types inline, complex types delegate to runtime. Demonstrates ICache-friendly pattern (~500B vs 27KB runtime).
---
> **LLM Maintenance:** If you modify code in this folder, update this README to reflect the changes. If you notice the README content does not match the current code, automatically update the README to match the code.

View File

@ -5,7 +5,7 @@ using AyCode.Core.Tests.TestModels;
namespace AyCode.Core.Tests.GeneratedWriters;
/// <summary>
/// Hand-written generated binary writer for TestOrder.
/// Hand-written generated binary writer for TestOrder_All_True.
/// Demonstrates the pattern that the source generator will produce.
///
/// Bypasses the runtime switch/delegate property loop:
@ -21,22 +21,19 @@ internal sealed class TestOrderWriter : IGeneratedBinaryWriter
{
internal static readonly TestOrderWriter Instance = new();
public void WriteProperties<TOutput>(
object value,
AcBinarySerializer.BinarySerializationContext<TOutput> context,
int depth)
public void WriteProperties<TOutput>(object value,
AcBinarySerializer.BinarySerializationContext<TOutput> context)
where TOutput : struct, IBinaryOutputBase
{
var obj = Unsafe.As<TestOrder>(value);
var nextDepth = depth;
var obj = Unsafe.As<TestOrder_All_True>(value);
// Properties in alphabetical order (matching runtime serializer):
// AuditMetadata: MetadataInfo? (complex, nullable)
WriteComplexOrNull(obj.AuditMetadata, context, nextDepth);
// AuditMetadata: MetadataInfo_All_True? (complex, nullable)
WriteComplexOrNull(obj.AuditMetadata, context);
// Category: SharedCategory? (complex, nullable)
WriteComplexOrNull(obj.Category, context, nextDepth);
// Category: SharedCategory_All_True? (complex, nullable)
WriteComplexOrNull(obj.Category, context);
// CreatedAt: DateTime (markerless)
context.WriteDateTimeBits(obj.CreatedAt);
@ -44,23 +41,23 @@ internal sealed class TestOrderWriter : IGeneratedBinaryWriter
// Id: int (markerless)
context.WriteVarInt(obj.Id);
// Items: List<TestOrderItem> (collection)
WriteComplexOrNull(obj.Items, context, nextDepth);
// Items: List<TestOrderItem_All_True> (collection)
WriteComplexOrNull(obj.Items, context);
// MetadataList: List<MetadataInfo> (collection)
WriteComplexOrNull(obj.MetadataList, context, nextDepth);
// MetadataList: List<MetadataInfo_All_True> (collection)
WriteComplexOrNull(obj.MetadataList, context);
// NoMergeItems: List<TestOrderItem> (collection)
WriteComplexOrNull(obj.NoMergeItems, context, nextDepth);
// NoMergeItems: List<TestOrderItem_All_True> (collection)
WriteComplexOrNull(obj.NoMergeItems, context);
// OrderMetadata: MetadataInfo? (complex, nullable)
WriteComplexOrNull(obj.OrderMetadata, context, nextDepth);
// OrderMetadata: MetadataInfo_All_True? (complex, nullable)
WriteComplexOrNull(obj.OrderMetadata, context);
// OrderNumber: string
AcBinarySerializer.WriteStringGenerated(obj.OrderNumber, context);
// Owner: SharedUser? (complex, nullable)
WriteComplexOrNull(obj.Owner, context, nextDepth);
WriteComplexOrNull(obj.Owner, context);
// PaidDateUtc: DateTime? (nullable)
var paidDate = obj.PaidDateUtc;
@ -74,23 +71,23 @@ internal sealed class TestOrderWriter : IGeneratedBinaryWriter
context.WriteByte(BinaryTypeCode.Null);
}
// PrimaryTag: SharedTag? (complex, nullable)
WriteComplexOrNull(obj.PrimaryTag, context, nextDepth);
// PrimaryTag: SharedTag_All_True? (complex, nullable)
WriteComplexOrNull(obj.PrimaryTag, context);
// SecondaryTag: SharedTag? (complex, nullable)
WriteComplexOrNull(obj.SecondaryTag, context, nextDepth);
// SecondaryTag: SharedTag_All_True? (complex, nullable)
WriteComplexOrNull(obj.SecondaryTag, context);
// Status: TestStatus (enum, markerless)
context.WriteVarInt((int)obj.Status);
// Tags: List<SharedTag> (collection)
WriteComplexOrNull(obj.Tags, context, nextDepth);
// Tags: List<SharedTag_All_True> (collection)
WriteComplexOrNull(obj.Tags, context);
// TotalAmount: decimal (markerless)
context.WriteDecimalBits(obj.TotalAmount);
}
public void ScanObject<TOutput>(object value, AcBinarySerializer.BinarySerializationContext<TOutput> context, int depth) where TOutput : struct, IBinaryOutputBase
public void ScanObject<TOutput>(object value, AcBinarySerializer.BinarySerializationContext<TOutput> context) where TOutput : struct, IBinaryOutputBase
{
throw new NotImplementedException();
}
@ -98,12 +95,12 @@ internal sealed class TestOrderWriter : IGeneratedBinaryWriter
public void ScanForDuplicates<TOutput>(object value, AcBinarySerializer.BinarySerializationContext<TOutput> context) where TOutput : struct, IBinaryOutputBase
{
if (!context.HasCaching) return;
ScanObject(value, context, 0);
ScanObject(value, context);
context.SortWritePlan();
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static void WriteComplexOrNull<TOutput>(object? value, AcBinarySerializer.BinarySerializationContext<TOutput> context, int depth)
private static void WriteComplexOrNull<TOutput>(object? value, AcBinarySerializer.BinarySerializationContext<TOutput> context)
where TOutput : struct, IBinaryOutputBase
{
if (value == null)
@ -112,6 +109,6 @@ internal sealed class TestOrderWriter : IGeneratedBinaryWriter
return;
}
AcBinarySerializer.WriteValueGenerated(value, value.GetType(), context, depth);
AcBinarySerializer.WriteValueGenerated(value, value.GetType(), context);
}
}

View File

@ -116,7 +116,7 @@ public sealed class JsonExtensionTests
public void SemanticReference_SharedTag_SerializesWithSemanticId()
{
// Arrange
var sharedTag = TestDataFactory.CreateTag("SharedTag");
var sharedTag = TestDataFactory.CreateTag("SharedTag_All_True");
var order = TestDataFactory.CreateOrder(itemCount: 2, sharedTag: sharedTag);
// Act
@ -133,7 +133,7 @@ public sealed class JsonExtensionTests
{
// Arrange
var sharedTag = TestDataFactory.CreateTag("OriginalKey");
var order = new TestOrder
var order = new TestOrder_All_True
{
Id = 1,
OrderNumber = "ORD-001",
@ -183,19 +183,19 @@ public sealed class JsonExtensionTests
public void NewtonsoftReference_DeepNestedNonId_HandlesCorrectly()
{
// Arrange
var rootMeta = new MetadataInfo
var rootMeta = new MetadataInfo_All_True
{
Key = "Root",
Value = "RootValue",
ChildMetadata = new MetadataInfo
ChildMetadata = new MetadataInfo_All_True
{
Key = "Child",
Value = "ChildValue",
ChildMetadata = new MetadataInfo { Key = "GrandChild", Value = "GrandChildValue" }
ChildMetadata = new MetadataInfo_All_True { Key = "GrandChild", Value = "GrandChildValue" }
}
};
var order = new TestOrder
var order = new TestOrder_All_True
{
Id = 1,
OrderNumber = "ORD-001",
@ -225,7 +225,7 @@ public sealed class JsonExtensionTests
var sharedMeta = TestDataFactory.CreateMetadata();
sharedTag.Description = sharedMeta.Key; // Link them
var order = new TestOrder
var order = new TestOrder_All_True
{
Id = 1,
OrderNumber = "ORD-001",
@ -234,7 +234,7 @@ public sealed class JsonExtensionTests
OrderMetadata = sharedMeta,
AuditMetadata = sharedMeta,
Tags = [sharedTag],
Items = [new TestOrderItem { Id = 10, ProductName = "A", Tag = sharedTag, ItemMetadata = sharedMeta }]
Items = [new TestOrderItem_All_True { Id = 10, ProductName = "A", Tag = sharedTag, ItemMetadata = sharedMeta }]
};
var json = order.ToJson();
@ -254,8 +254,8 @@ public sealed class JsonExtensionTests
// Arrange
var order = TestDataFactory.CreateOrder(itemCount: 1);
order.NoMergeItems = [
new TestOrderItem { Id = 100, ProductName = "NoMerge-A" },
new TestOrderItem { Id = 101, ProductName = "NoMerge-B" }
new TestOrderItem_All_True { Id = 100, ProductName = "NoMerge-A" },
new TestOrderItem_All_True { Id = 101, ProductName = "NoMerge-B" }
];
var originalRef = order.NoMergeItems;
@ -284,13 +284,13 @@ public sealed class JsonExtensionTests
public void NonIdCollection_ReplacesContent()
{
// Arrange
var order = new TestOrder
var order = new TestOrder_All_True
{
Id = 1,
OrderNumber = "ORD-001",
MetadataList = [
new MetadataInfo { Key = "Old-A" },
new MetadataInfo { Key = "Old-B" }
new MetadataInfo_All_True { Key = "Old-A" },
new MetadataInfo_All_True { Key = "Old-B" }
]
};
@ -367,7 +367,7 @@ public sealed class JsonExtensionTests
// Act
var json = order.ToJson();
var deserialized = json.JsonTo<TestOrder>();
var deserialized = json.JsonTo<TestOrder_All_True>();
// Assert
Assert.IsNotNull(deserialized);
@ -442,7 +442,7 @@ public sealed class JsonExtensionTests
[TestMethod]
public void WasmCompat_AcJsonSerializer_SimpleObject()
{
var item = new TestOrderItem { Id = 1, ProductName = "Test", Quantity = 10, UnitPrice = 99.99m, Status = TestStatus.Processing };
var item = new TestOrderItem_All_True { Id = 1, ProductName = "Test", Quantity = 10, UnitPrice = 99.99m, Status = TestStatus.Processing };
var json = AcJsonSerializer.Serialize(item);
@ -453,10 +453,10 @@ public sealed class JsonExtensionTests
[TestMethod]
public void WasmCompat_AcJsonDeserializer_RoundTrip()
{
var original = new TestOrderItem { Id = 42, ProductName = "WASM Test", Quantity = 5, UnitPrice = 25.50m, Status = TestStatus.Shipped };
var original = new TestOrderItem_All_True { Id = 42, ProductName = "WASM Test", Quantity = 5, UnitPrice = 25.50m, Status = TestStatus.Shipped };
var json = AcJsonSerializer.Serialize(original);
var deserialized = AcJsonDeserializer.Deserialize<TestOrderItem>(json);
var deserialized = AcJsonDeserializer.Deserialize<TestOrderItem_All_True>(json);
Assert.IsNotNull(deserialized);
Assert.AreEqual(42, deserialized.Id);
@ -484,14 +484,14 @@ public sealed class JsonExtensionTests
[TestMethod]
public void WasmCompat_EmptyCollections_HandleCorrectly()
{
var order = new TestOrder { Id = 1, OrderNumber = "EMPTY-TEST", Items = [], Tags = [] };
var order = new TestOrder_All_True { Id = 1, OrderNumber = "EMPTY-TEST", Items = [], Tags = [] };
var json = AcJsonSerializer.Serialize(order);
Assert.IsTrue(json.Contains("\"Items\":[]"), "Empty Items should serialize as []");
Assert.IsTrue(json.Contains("\"Tags\":[]"), "Empty Tags should serialize as []");
var deserialized = AcJsonDeserializer.Deserialize<TestOrder>(json);
var deserialized = AcJsonDeserializer.Deserialize<TestOrder_All_True>(json);
Assert.IsNotNull(deserialized?.Items);
Assert.AreEqual(0, deserialized.Items.Count);
@ -513,8 +513,8 @@ public sealed class JsonExtensionTests
[TestMethod]
public void WasmCompat_SharedReferences_IdRefResolution()
{
var sharedTag = new SharedTag { Id = 999, Name = "SharedKey" };
var order = new TestOrder { Id = 1, OrderNumber = "REF-TEST", PrimaryTag = sharedTag, SecondaryTag = sharedTag, Tags = [sharedTag] };
var sharedTag = new SharedTag_All_True { Id = 999, Name = "SharedKey" };
var order = new TestOrder_All_True { Id = 1, OrderNumber = "REF-TEST", PrimaryTag = sharedTag, SecondaryTag = sharedTag, Tags = [sharedTag] };
var json = AcJsonSerializer.Serialize(order);
@ -528,7 +528,7 @@ public sealed class JsonExtensionTests
NullValueHandling = NullValueHandling.Ignore
};
var deserialized = JsonConvert.DeserializeObject<TestOrder>(json, nativeSettings);
var deserialized = JsonConvert.DeserializeObject<TestOrder_All_True>(json, nativeSettings);
Assert.IsNotNull(deserialized);
Assert.AreSame(deserialized.PrimaryTag, deserialized.SecondaryTag);
@ -543,10 +543,10 @@ public sealed class JsonExtensionTests
public void CrossSerializer_MixedReferences_CompatibleWithNewtonsoft()
{
// Arrange
var sharedTag = new SharedTag { Id = 100, Name = "SharedKey", CreatedAt = DateTime.UtcNow };
var sharedMeta = new MetadataInfo { Key = "SharedMeta", Value = "MetaValue", ChildMetadata = new MetadataInfo { Key = "Child" } };
var sharedTag = new SharedTag_All_True { Id = 100, Name = "SharedKey", CreatedAt = DateTime.UtcNow };
var sharedMeta = new MetadataInfo_All_True { Key = "SharedMeta", Value = "MetaValue", ChildMetadata = new MetadataInfo_All_True { Key = "Child" } };
var order = new TestOrder
var order = new TestOrder_All_True
{
Id = 1,
OrderNumber = "ORD-001",
@ -556,7 +556,7 @@ public sealed class JsonExtensionTests
OrderMetadata = sharedMeta,
AuditMetadata = sharedMeta,
Tags = [sharedTag],
Items = [new TestOrderItem { Id = 10, ProductName = "Product-A", Tag = sharedTag, ItemMetadata = sharedMeta }]
Items = [new TestOrderItem_All_True { Id = 10, ProductName = "Product-A", Tag = sharedTag, ItemMetadata = sharedMeta }]
};
// Act - Serialize with AyCode
@ -570,7 +570,7 @@ public sealed class JsonExtensionTests
NullValueHandling = NullValueHandling.Ignore
};
var deserialized = JsonConvert.DeserializeObject<TestOrder>(json, nativeSettings);
var deserialized = JsonConvert.DeserializeObject<TestOrder_All_True>(json, nativeSettings);
// Assert
Assert.IsNotNull(deserialized);
@ -589,11 +589,11 @@ public sealed class JsonExtensionTests
var json = @"{
""Id"": 1,
""OrderNumber"": ""ORD-001"",
""PrimaryTag"": { ""$id"": ""1"", ""Id"": 100, ""Name"": ""SharedTag"" },
""PrimaryTag"": { ""$id"": ""1"", ""Id"": 100, ""Name"": ""SharedTag_All_True"" },
""SecondaryTag"": { ""$ref"": ""1"" }
}";
var order = new TestOrder { Id = 1, OrderNumber = "OLD" };
var order = new TestOrder_All_True { Id = 1, OrderNumber = "OLD" };
// Act
json.JsonTo(order);
@ -602,7 +602,7 @@ public sealed class JsonExtensionTests
Assert.IsNotNull(order.PrimaryTag, "PrimaryTag should be set");
Assert.IsNotNull(order.SecondaryTag, "SecondaryTag should be set from $ref");
Assert.AreEqual(100, order.PrimaryTag.Id);
Assert.AreEqual("SharedTag", order.PrimaryTag.Name);
Assert.AreEqual("SharedTag_All_True", order.PrimaryTag.Name);
Assert.AreSame(order.PrimaryTag, order.SecondaryTag,
"SecondaryTag should reference the same object as PrimaryTag via $ref");
}
@ -613,14 +613,14 @@ public sealed class JsonExtensionTests
var json = @"{
""Id"": 1,
""OrderNumber"": ""ORD-001"",
""PrimaryTag"": { ""$id"": ""1"", ""Id"": 100, ""Name"": ""SharedTag"" },
""PrimaryTag"": { ""$id"": ""1"", ""Id"": 100, ""Name"": ""SharedTag_All_True"" },
""Tags"": [
{ ""$ref"": ""1"" },
{ ""$id"": ""2"", ""Id"": 200, ""Name"": ""OtherTag"" }
]
}";
var order = new TestOrder { Id = 1, OrderNumber = "OLD", Tags = new List<SharedTag>() };
var order = new TestOrder_All_True { Id = 1, OrderNumber = "OLD", Tags = new List<SharedTag_All_True>() };
// Act
json.JsonTo(order);
@ -648,11 +648,11 @@ public sealed class JsonExtensionTests
""PrimaryTag"": { ""$ref"": ""1"" }
}";
var order = new TestOrder
var order = new TestOrder_All_True
{
Id = 1,
OrderNumber = "OLD",
Items = new List<TestOrderItem> { new TestOrderItem { Id = 10, ProductName = "OLD" } }
Items = new List<TestOrderItem_All_True> { new TestOrderItem_All_True { Id = 10, ProductName = "OLD" } }
};
// Act
@ -672,10 +672,10 @@ public sealed class JsonExtensionTests
""Id"": 1,
""OrderNumber"": ""ORD-001"",
""SecondaryTag"": { ""$ref"": ""1"" },
""PrimaryTag"": { ""$id"": ""1"", ""Id"": 100, ""Name"": ""SharedTag"" }
""PrimaryTag"": { ""$id"": ""1"", ""Id"": 100, ""Name"": ""SharedTag_All_True"" }
}";
var order = new TestOrder { Id = 1, OrderNumber = "OLD" };
var order = new TestOrder_All_True { Id = 1, OrderNumber = "OLD" };
// Act
json.JsonTo(order);
@ -693,7 +693,7 @@ public sealed class JsonExtensionTests
var json = @"{
""Id"": 1,
""OrderNumber"": ""ORD-001"",
""PrimaryTag"": { ""$id"": ""1"", ""Id"": 100, ""Name"": ""SharedTag"" },
""PrimaryTag"": { ""$id"": ""1"", ""Id"": 100, ""Name"": ""SharedTag_All_True"" },
""SecondaryTag"": { ""$ref"": ""1"" },
""Tags"": [
{ ""$ref"": ""1"" },
@ -702,7 +702,7 @@ public sealed class JsonExtensionTests
]
}";
var order = new TestOrder { Id = 1, OrderNumber = "OLD", Tags = new List<SharedTag>() };
var order = new TestOrder_All_True { Id = 1, OrderNumber = "OLD", Tags = new List<SharedTag_All_True>() };
// Act
json.JsonTo(order);
@ -731,10 +731,10 @@ public sealed class JsonExtensionTests
""PrimaryTag"": { ""$ref"": ""deep1"" }
}";
var order = new TestOrder
var order = new TestOrder_All_True
{
Id = 1,
Items = new List<TestOrderItem> { new TestOrderItem { Id = 10 } }
Items = new List<TestOrderItem_All_True> { new TestOrderItem_All_True { Id = 10 } }
};
// Act
@ -762,10 +762,10 @@ public sealed class JsonExtensionTests
}]
}";
var order = new TestOrder
var order = new TestOrder_All_True
{
Id = 1,
Items = new List<TestOrderItem> { new TestOrderItem { Id = 10 } }
Items = new List<TestOrderItem_All_True> { new TestOrderItem_All_True { Id = 10 } }
};
// Act
@ -789,7 +789,7 @@ public sealed class JsonExtensionTests
}";
// Act
var order = AcJsonDeserializer.Deserialize<TestOrder>(json);
var order = AcJsonDeserializer.Deserialize<TestOrder_All_True>(json);
// Assert
Assert.IsNotNull(order);
@ -820,7 +820,7 @@ public sealed class JsonExtensionTests
}";
// Act
var order = AcJsonDeserializer.Deserialize<TestOrder>(json);
var order = AcJsonDeserializer.Deserialize<TestOrder_All_True>(json);
// Assert
Assert.IsNotNull(order);
@ -841,8 +841,8 @@ public sealed class JsonExtensionTests
""SecondaryTag"": { ""$ref"": ""1"" }
}";
var existingTag = new SharedTag { Id = 999, Name = "ExistingTag" };
var order = new TestOrder
var existingTag = new SharedTag_All_True { Id = 999, Name = "ExistingTag" };
var order = new TestOrder_All_True
{
Id = 1,
SecondaryTag = existingTag
@ -1047,9 +1047,9 @@ public sealed class JsonExtensionTests
{
Id = 1,
Name = "Test",
Tag = new SharedTag { Id = 1, Name = "Tag" }
Tag = new SharedTag_All_True { Id = 1, Name = "Tag" }
};
// Using existing Tag property with Guid in SharedTag's CreatedAt
// Using existing Tag property with Guid in SharedTag_All_True's CreatedAt
var json = AcJsonSerializer.Serialize(obj);
@ -1240,8 +1240,8 @@ public sealed class JsonExtensionTests
public void Populate_ObjectToObject_PopulatesProperties()
{
var json = "{\"Name\": \"Updated\", \"Id\": 99}";
var obj = new SharedTag { Id = 1, Name = "Original" };
AcJsonDeserializer.Populate(json, obj, typeof(SharedTag));
var obj = new SharedTag_All_True { Id = 1, Name = "Original" };
AcJsonDeserializer.Populate(json, obj, typeof(SharedTag_All_True));
Assert.AreEqual(99, obj.Id);
Assert.AreEqual("Updated", obj.Name);
}
@ -1249,14 +1249,14 @@ public sealed class JsonExtensionTests
[TestMethod]
public void Deserialize_NullJson_ReturnsDefault()
{
var result = AcJsonDeserializer.Deserialize<TestOrderItem>("null");
var result = AcJsonDeserializer.Deserialize<TestOrderItem_All_True>("null");
Assert.IsNull(result);
}
[TestMethod]
public void Deserialize_EmptyJson_ReturnsDefault()
{
var result = AcJsonDeserializer.Deserialize<TestOrderItem>("");
var result = AcJsonDeserializer.Deserialize<TestOrderItem_All_True>("");
Assert.IsNull(result);
}
@ -1312,7 +1312,7 @@ public sealed class JsonExtensionTests
try
{
AcJsonDeserializer.Deserialize<TestOrderItem>(invalidJson);
AcJsonDeserializer.Deserialize<TestOrderItem_All_True>(invalidJson);
Assert.Fail("Expected AcJsonDeserializationException");
}
catch (AcJsonDeserializationException)
@ -1329,7 +1329,7 @@ public sealed class JsonExtensionTests
try
{
AcJsonDeserializer.Deserialize<TestOrderItem>(doubleQuotedJson);
AcJsonDeserializer.Deserialize<TestOrderItem_All_True>(doubleQuotedJson);
Assert.Fail("Expected AcJsonDeserializationException for double-serialized JSON");
}
catch (AcJsonDeserializationException ex)
@ -1346,7 +1346,7 @@ public sealed class JsonExtensionTests
try
{
AcJsonDeserializer.Deserialize<TestOrderItem>(arrayJson);
AcJsonDeserializer.Deserialize<TestOrderItem_All_True>(arrayJson);
Assert.Fail("Expected AcJsonDeserializationException");
}
catch (AcJsonDeserializationException ex)
@ -1363,7 +1363,7 @@ public sealed class JsonExtensionTests
try
{
AcJsonDeserializer.Deserialize<List<TestOrderItem>>(objectJson);
AcJsonDeserializer.Deserialize<List<TestOrderItem_All_True>>(objectJson);
Assert.Fail("Expected AcJsonDeserializationException");
}
catch (AcJsonDeserializationException ex)
@ -1376,7 +1376,7 @@ public sealed class JsonExtensionTests
public void Populate_NullTarget_ThrowsArgumentNullException()
{
var json = "{\"Id\":1}";
TestOrderItem? target = null;
TestOrderItem_All_True? target = null;
try
{
@ -1392,7 +1392,7 @@ public sealed class JsonExtensionTests
[TestMethod]
public void Populate_InvalidJson_ThrowsException()
{
var target = new TestOrderItem();
var target = new TestOrderItem_All_True();
var invalidJson = "{ not valid }";
try
@ -1409,7 +1409,7 @@ public sealed class JsonExtensionTests
[TestMethod]
public void Populate_ArrayToNonList_ThrowsException()
{
var target = new TestOrderItem();
var target = new TestOrderItem_All_True();
var arrayJson = "[1,2,3]";
try
@ -1432,7 +1432,7 @@ public sealed class JsonExtensionTests
{
var json = "{\"Id\":1,\"ProductName\":\"Test \\\"quoted\\\" and \\\\backslash\"}";
var result = AcJsonDeserializer.Deserialize<TestOrderItem>(json);
var result = AcJsonDeserializer.Deserialize<TestOrderItem_All_True>(json);
Assert.IsNotNull(result);
Assert.AreEqual("Test \"quoted\" and \\backslash", result.ProductName);
@ -1443,7 +1443,7 @@ public sealed class JsonExtensionTests
{
var json = "{\"Id\":1,\"ProductName\":\"中文日本語한국어🎉\"}";
var result = AcJsonDeserializer.Deserialize<TestOrderItem>(json);
var result = AcJsonDeserializer.Deserialize<TestOrderItem_All_True>(json);
Assert.IsNotNull(result);
Assert.AreEqual("中文日本語한국어🎉", result.ProductName);
@ -1454,7 +1454,7 @@ public sealed class JsonExtensionTests
{
var json = "{\"Id\":999999999,\"ProductName\":\"Big\",\"Quantity\":2147483647}";
var result = AcJsonDeserializer.Deserialize<TestOrderItem>(json);
var result = AcJsonDeserializer.Deserialize<TestOrderItem_All_True>(json);
Assert.IsNotNull(result);
Assert.AreEqual(999999999, result.Id);
@ -1464,7 +1464,7 @@ public sealed class JsonExtensionTests
[TestMethod]
public void Serialize_ThenDeserialize_RoundTripPreservesData()
{
var original = new TestOrderItem
var original = new TestOrderItem_All_True
{
Id = 42,
ProductName = "Test with \"quotes\" and \\backslash",
@ -1474,7 +1474,7 @@ public sealed class JsonExtensionTests
};
var json = original.ToJson();
var restored = AcJsonDeserializer.Deserialize<TestOrderItem>(json);
var restored = AcJsonDeserializer.Deserialize<TestOrderItem_All_True>(json);
Assert.IsNotNull(restored);
Assert.AreEqual(original.Id, restored.Id);
@ -1491,12 +1491,12 @@ public sealed class JsonExtensionTests
[TestMethod]
public void Deserialize_TaskWrappedJson_DirectDeserialization_OnlyGetsRootProperties()
{
// This JSON represents a serialized Task<TestOrderItem> - the actual data is in "Result"
// This JSON represents a serialized Task<TestOrderItem_All_True> - the actual data is in "Result"
// This happens when someone forgets to await an async method before serializing
var taskWrappedJson = "{\"Result\":{\"Id\":1,\"ProductName\":\"Processed: TestProduct\",\"Quantity\":10,\"UnitPrice\":20,\"TotalPrice\":200},\"Id\":1,\"Status\":5,\"IsCompleted\":true,\"IsCompletedSuccessfully\":true}";
// Direct deserialization to TestOrderItem only gets root-level properties
var result = AcJsonDeserializer.Deserialize<TestOrderItem>(taskWrappedJson);
// Direct deserialization to TestOrderItem_All_True only gets root-level properties
var result = AcJsonDeserializer.Deserialize<TestOrderItem_All_True>(taskWrappedJson);
Assert.IsNotNull(result);
// Id=1 is at root level and matches
@ -1509,11 +1509,11 @@ public sealed class JsonExtensionTests
[TestMethod]
public void Deserialize_TaskWrappedJson_UseWrapperClass_ExtractsCorrectly()
{
// This JSON represents a serialized Task<TestOrderItem> - the actual data is in "Result"
// This JSON represents a serialized Task<TestOrderItem_All_True> - the actual data is in "Result"
var taskWrappedJson = "{\"Result\":{\"Id\":1,\"ProductName\":\"Processed: TestProduct\",\"Quantity\":10,\"UnitPrice\":20,\"TotalPrice\":200},\"Id\":1,\"Status\":5,\"IsCompleted\":true,\"IsCompletedSuccessfully\":true}";
// Proper approach: deserialize to a wrapper type and extract Result
var wrapper = AcJsonDeserializer.Deserialize<TaskResultWrapper<TestOrderItem>>(taskWrappedJson);
var wrapper = AcJsonDeserializer.Deserialize<TaskResultWrapper<TestOrderItem_All_True>>(taskWrappedJson);
Assert.IsNotNull(wrapper);
Assert.IsNotNull(wrapper.Result);

View File

@ -25,7 +25,3 @@ MSTest unit tests for AyCode.Core serialization, compression, and utilities. Cov
| `MessagePack` | Serialization comparison |
| `MemoryPack` | Serialization comparison |
| `MongoDB.Bson` | BSON comparison |
---
> **LLM Maintenance:** If you modify code in this folder, update this README to reflect the changes. If you notice the README content does not match the current code, automatically update the README to match the code.

View File

@ -14,7 +14,7 @@ public class AcBinarySerializerBenchmarkTests
var binary = AcBinarySerializer.Serialize(order);
Assert.IsTrue(binary.Length > 0, "Binary data should not be empty");
var result = AcBinaryDeserializer.Deserialize<TestOrder>(binary);
var result = AcBinaryDeserializer.Deserialize<TestOrder_All_True>(binary);
Assert.IsNotNull(result);
Assert.AreEqual(order.Id, result.Id);
Assert.AreEqual(order.OrderNumber, result.OrderNumber);
@ -29,7 +29,7 @@ public class AcBinarySerializerBenchmarkTests
var binary = AcBinarySerializer.Serialize(order);
Assert.IsTrue(binary.Length > 0);
var result = AcBinaryDeserializer.Deserialize<TestOrder>(binary);
var result = AcBinaryDeserializer.Deserialize<TestOrder_All_True>(binary);
Assert.IsNotNull(result);
Assert.AreEqual(order.Id, result.Id);
}
@ -42,7 +42,7 @@ public class AcBinarySerializerBenchmarkTests
var binary = AcBinarySerializer.Serialize(order);
Assert.IsTrue(binary.Length > 0, "Binary data should not be empty");
var result = AcBinaryDeserializer.Deserialize<TestOrder>(binary);
var result = AcBinaryDeserializer.Deserialize<TestOrder_All_True>(binary);
Assert.IsNotNull(result);
Assert.AreEqual(order.Id, result.Id);
Assert.AreEqual(order.OrderNumber, result.OrderNumber);
@ -69,8 +69,8 @@ public class AcBinarySerializerBenchmarkTests
Console.WriteLine($"With interning: {binaryWithInterning.Length}, Without: {binaryWithoutInterning.Length}");
// Both should deserialize correctly regardless of size
var result1 = AcBinaryDeserializer.Deserialize<TestOrder>(binaryWithInterning);
var result2 = AcBinaryDeserializer.Deserialize<TestOrder>(binaryWithoutInterning);
var result1 = AcBinaryDeserializer.Deserialize<TestOrder_All_True>(binaryWithInterning);
var result2 = AcBinaryDeserializer.Deserialize<TestOrder_All_True>(binaryWithoutInterning);
Assert.IsNotNull(result1);
Assert.IsNotNull(result2);

View File

@ -20,7 +20,7 @@ public class AcBinarySerializerChainReferenceTests
public void ChainPopulate_IIdObjects_PreservesReferences()
{
// Setup: Create internal cache with 5 categories
var internalCache = new List<SharedCategory>
var internalCache = new List<SharedCategory_All_True>
{
new() { Id = 1, Name = "Category1", SortOrder = 1 },
new() { Id = 2, Name = "Category2", SortOrder = 2 },
@ -30,7 +30,7 @@ public class AcBinarySerializerChainReferenceTests
};
// Server returns subset of categories (like grid pagination - page 2: items 3-5)
var serverData = new List<SharedCategory>
var serverData = new List<SharedCategory_All_True>
{
new() { Id = 3, Name = "Category3_Updated", SortOrder = 33 },
new() { Id = 4, Name = "Category4_Updated", SortOrder = 44 },
@ -41,10 +41,10 @@ public class AcBinarySerializerChainReferenceTests
var binary = serverData.ToBinary();
// Grid's visible list (empty initially)
var gridVisibleList = new List<SharedCategory>();
var gridVisibleList = new List<SharedCategory_All_True>();
// CRITICAL: Use Chain API to parse once, populate both cache and grid
using var chain = binary.BinaryToChain<List<SharedCategory>>();
using var chain = binary.BinaryToChain<List<SharedCategory_All_True>>();
// First: Update internal cache (will become 3 items: 3-5 updated)
chain.ThenPopulate(internalCache);
@ -77,7 +77,7 @@ public class AcBinarySerializerChainReferenceTests
public void JsonChainPopulate_IIdObjects_PreservesReferences()
{
// Setup: Create internal cache
var internalCache = new List<SharedCategory>
var internalCache = new List<SharedCategory_All_True>
{
new() { Id = 1, Name = "Category1", SortOrder = 1 },
new() { Id = 2, Name = "Category2", SortOrder = 2 },
@ -85,7 +85,7 @@ public class AcBinarySerializerChainReferenceTests
};
// Server returns subset
var serverData = new List<SharedCategory>
var serverData = new List<SharedCategory_All_True>
{
new() { Id = 2, Name = "Category2_Updated", SortOrder = 22 },
new() { Id = 3, Name = "Category3_Updated", SortOrder = 33 }
@ -95,10 +95,10 @@ public class AcBinarySerializerChainReferenceTests
var json = serverData.ToJson();
// Grid's visible list
var gridVisibleList = new List<SharedCategory>();
var gridVisibleList = new List<SharedCategory_All_True>();
// Use JSON Chain API
using var chain = json.JsonToChain<List<SharedCategory>>();
using var chain = json.JsonToChain<List<SharedCategory_All_True>>();
// Update internal cache (will replace with 2 items)
chain.ThenPopulate(internalCache);
@ -163,22 +163,22 @@ public class AcBinarySerializerChainReferenceTests
{
// Large internal cache
var internalCache = Enumerable.Range(1, 10)
.Select(i => new SharedCategory { Id = i, Name = $"Category{i}", SortOrder = i * 10 })
.Select(i => new SharedCategory_All_True { Id = i, Name = $"Category{i}", SortOrder = i * 10 })
.ToList();
// Server returns items 3-7
var serverData = Enumerable.Range(3, 5)
.Select(i => new SharedCategory { Id = i, Name = $"Category{i}_Updated", SortOrder = i * 11 })
.Select(i => new SharedCategory_All_True { Id = i, Name = $"Category{i}_Updated", SortOrder = i * 11 })
.ToList();
var binary = serverData.ToBinary();
// Three different grid pages/views
var gridPage1 = new List<SharedCategory>();
var gridPage2 = new List<SharedCategory>();
var gridPage3 = new List<SharedCategory>();
var gridPage1 = new List<SharedCategory_All_True>();
var gridPage2 = new List<SharedCategory_All_True>();
var gridPage3 = new List<SharedCategory_All_True>();
using var chain = binary.BinaryToChain<List<SharedCategory>>();
using var chain = binary.BinaryToChain<List<SharedCategory_All_True>>();
// Update cache first
chain.ThenPopulate(internalCache);
@ -208,17 +208,17 @@ public class AcBinarySerializerChainReferenceTests
[TestMethod]
public void ChainPopulate_SimpleCase_Works()
{
var list1 = new List<SharedCategory>();
var list2 = new List<SharedCategory>();
var list1 = new List<SharedCategory_All_True>();
var list2 = new List<SharedCategory_All_True>();
var serverData = new List<SharedCategory>
var serverData = new List<SharedCategory_All_True>
{
new() { Id = 1, Name = "Cat1", SortOrder = 10 }
};
var binary = serverData.ToBinary();
using var chain = binary.BinaryToChain<List<SharedCategory>>();
using var chain = binary.BinaryToChain<List<SharedCategory_All_True>>();
// First populate
chain.ThenPopulate(list1);

View File

@ -311,15 +311,14 @@ public class AcBinarySerializerChainTests
}
[TestMethod]
public void DeserializeChain_ReadOnlyMemory_WorksCorrectly()
public void DeserializeChain_ByteArray_WorksCorrectly()
{
// Arrange
var original = new TestSimpleClass { Id = 42, Name = "Memory Test" };
var binary = original.ToBinary();
ReadOnlyMemory<byte> memory = binary;
// Act
using var chain = memory.BinaryToChain<TestSimpleClass>();
using var chain = binary.BinaryToChain<TestSimpleClass>();
var result = chain.Value;
// Assert
@ -329,16 +328,15 @@ public class AcBinarySerializerChainTests
}
[TestMethod]
public void PopulateChain_ReadOnlyMemory_WorksCorrectly()
public void PopulateChain_ByteArray_WorksCorrectly()
{
// Arrange
var original = new TestSimpleClass { Id = 99, Name = "Memory Update" };
var binary = original.ToBinary();
ReadOnlyMemory<byte> memory = binary;
var target = new TestSimpleClass { Id = 1, Name = "Old" };
// Act
using var chain = memory.BinaryToChain(target);
using var chain = binary.BinaryToChain(target);
// Assert
Assert.AreEqual(99, target.Id);

View File

@ -96,9 +96,9 @@ public class AcBinarySerializerIIdReferenceTests
foreach (var mode in modes)
{
// Arrange: SAME instance used multiple times
var userPreferences = new UserPreferences();
var sharedTag = new SharedTag { Id = 1, Name = "ImportantTag", Color = "#FF0000" };
var sharedUser = new SharedUser { Id = 1, Preferences = userPreferences };
var userPreferences = new UserPreferences_All_True();
var sharedTag = new SharedTag_All_True { Id = 1, Name = "ImportantTag", Color = "#FF0000" };
var sharedUser = new SharedUser_All_True { Id = 1, Preferences = userPreferences };
var order = new TestOrder_Circ_Ref
{
@ -109,8 +109,8 @@ public class AcBinarySerializerIIdReferenceTests
Items =
[
new TestOrderItem_Circ_Ref { Id = 1, ProductName = "Product-A", Tag = sharedTag, Assignee = sharedUser },
new TestOrderItem_Circ_Ref { Id = 2, ProductName = "Product-B", Tag = sharedTag, Assignee = new SharedUser { Id = 2, Preferences = userPreferences }},
new TestOrderItem_Circ_Ref { Id = 3, ProductName = "Product-C", Tag = sharedTag, Assignee = new SharedUser { Id = 3, Preferences = userPreferences } }
new TestOrderItem_Circ_Ref { Id = 2, ProductName = "Product-B", Tag = sharedTag, Assignee = new SharedUser_All_True { Id = 2, Preferences = userPreferences }},
new TestOrderItem_Circ_Ref { Id = 3, ProductName = "Product-C", Tag = sharedTag, Assignee = new SharedUser_All_True { Id = 3, Preferences = userPreferences } }
]
};
@ -126,14 +126,19 @@ public class AcBinarySerializerIIdReferenceTests
ReferenceHandling = mode,
UseGeneratedCode = useSgen,
UseMetadata = useMeta,
MaxDepth = 10
MaxDepth = 10,
// None mode has no ref tracking → the cycle (Items[1].ParentOrder = order) is unprotected.
// Use Truncate so the recursion silently bottoms out with Null at the depth limit instead of throwing.
MaxDepthBehavior = mode == ReferenceHandlingMode.None
? MaxDepthBehavior.Truncate
: MaxDepthBehavior.Throw
};
Console.WriteLine($"\n========== ReferenceHandling: {options.ReferenceHandling}, UseSgen: {options.UseGeneratedCode}, UseMeta: {options.UseMetadata} ==========");
// Act
var binary = AcBinarySerializer.Serialize(order, options);
//WriteBinaryToConsole(binary);
if (mode == ReferenceHandlingMode.None) WriteBinaryToConsole(binary);
var result = binary.BinaryTo<TestOrder_Circ_Ref>(); // Options from header
var objectRefCount = CountObjectRefs(binary, false);
@ -148,12 +153,11 @@ public class AcBinarySerializerIIdReferenceTests
switch (mode)
{
case ReferenceHandlingMode.None:
//none esetén miért nincs infinite loop??? - J.
// Note: CountObjectRefs raw byte scan is unreliable in None mode —
// byte 65 (ObjectRef) == ASCII 'A', so "Product-A" and circular-ref
// depth expansion produce many false positives. Skip count assertion;
// data integrity checks below verify correct deserialization.
//WriteBinaryToConsole(binary);
// Truncate semantic: cycle bottoms out with Null at MaxDepth=10 → serialize succeeds, deserialize
// produces a partial graph where deep cyclic references read as null. Data integrity at root +
// first few levels still holds (verified below after the switch). CountObjectRefs raw byte scan
// is unreliable in None mode — byte 65 (ObjectRef) == ASCII 'A', so "Product-A" produces false
// positives. Skip count assertion; rely on data integrity checks instead.
break;
case ReferenceHandlingMode.OnlyId:
@ -167,7 +171,7 @@ public class AcBinarySerializerIIdReferenceTests
break;
case ReferenceHandlingMode.All:
// IId types + Non-IId (UserPreferences) should have ObjectRefs
// IId types + Non-IId (UserPreferences_All_True) should have ObjectRefs
Assert.IsTrue(objectRefCount >= 4, $"[{mode}] Expected at least 4 ObjectRefs, found {objectRefCount}");
Assert.AreSame(result.PrimaryTag, result.Items[0].Tag, $"[{mode}] Tag reference identity failed");
Assert.AreSame(result.Owner, result.Items[0].Assignee, $"[{mode}] User reference identity failed");
@ -176,7 +180,7 @@ public class AcBinarySerializerIIdReferenceTests
Assert.AreSame(result.Parent, result.Items[1]);
// Non-IId should also have reference identity in All mode
Assert.AreSame(result.Owner.Preferences, result.Items[0].Assignee.Preferences, $"[{mode}] UserPreferences reference identity failed - Non-IId should work in All mode!");
Assert.AreSame(result.Owner.Preferences, result.Items[0].Assignee.Preferences, $"[{mode}] UserPreferences_All_True reference identity failed - Non-IId should work in All mode!");
break;
}
@ -207,44 +211,44 @@ public class AcBinarySerializerIIdReferenceTests
{
// Arrange: DIFFERENT instances but SAME IId.Id
// CRITICAL: Multiple DIFFERENT TYPES all have Id=1 - must not be confused!
var sharedTag = new SharedTag { Id = 55, Name = "ImportantTag_55", Color = "#FF0000" };
var order = new TestOrder
var sharedTag = new SharedTag_All_True { Id = 55, Name = "ImportantTag_55", Color = "#FF0000" };
var order = new TestOrder_All_True
{
Id = 1,
OrderNumber = "ORD-001",
// All three types have Id=1 - tests (Type, Id) keying, not just Id
PrimaryTag = new SharedTag { Id = 1, Name = "Tag_Id1", Color = "#FF0000" },
Owner = new SharedUser { Id = 1, Username = "User_Id1", Email = "user1@test.com" },
Category = new SharedCategory { Id = 1, Name = "Category_Id1", SortOrder = 10 },
PrimaryTag = new SharedTag_All_True { Id = 1, Name = "Tag_Id1", Color = "#FF0000" },
Owner = new SharedUser_All_True { Id = 1, Username = "User_Id1", Email = "user1@test.com" },
Category = new SharedCategory_All_True { Id = 1, Name = "Category_Id1", SortOrder = 10 },
Items =
[
new TestOrderItem
new TestOrderItem_All_True
{
Id = 1,
ProductName = "Product-A",
Tag = new SharedTag { Id = 1, Name = "Tag_Id1", Color = "#FF0000" },
Assignee = new SharedUser { Id = 1, Username = "User_Id1", Email = "user1@test.com" }
Tag = new SharedTag_All_True { Id = 1, Name = "Tag_Id1", Color = "#FF0000" },
Assignee = new SharedUser_All_True { Id = 1, Username = "User_Id1", Email = "user1@test.com" }
},
new TestOrderItem
new TestOrderItem_All_True
{
Id = 2,
ProductName = "Product-B",
Tag = new SharedTag { Id = 1, Name = "Tag_Id1", Color = "#FF0000" },
Assignee = new SharedUser { Id = 1, Username = "User_Id1", Email = "user1@test.com" }
Tag = new SharedTag_All_True { Id = 1, Name = "Tag_Id1", Color = "#FF0000" },
Assignee = new SharedUser_All_True { Id = 1, Username = "User_Id1", Email = "user1@test.com" }
},
new TestOrderItem
new TestOrderItem_All_True
{
Id = 3,
ProductName = "Product-C",
Tag = new SharedTag { Id = 1, Name = "Tag_Id1", Color = "#FF0000" },
Assignee = new SharedUser { Id = 1, Username = "User_Id1", Email = "user1@test.com" }
Tag = new SharedTag_All_True { Id = 1, Name = "Tag_Id1", Color = "#FF0000" },
Assignee = new SharedUser_All_True { Id = 1, Username = "User_Id1", Email = "user1@test.com" }
}
]
};
// Act
var binary = order.ToBinary();
var result = binary.BinaryTo<TestOrder>();
var result = binary.BinaryTo<TestOrder_All_True>();
// Assert 1: Check if ObjectRef is used (IId-based deduplication active)
var objectRefCount = CountObjectRefs(binary);
@ -254,11 +258,11 @@ public class AcBinarySerializerIIdReferenceTests
// Assert 3: Reference identity - same TYPE with same Id should be same reference
// Tags with Id=1 should all be same reference
Assert.AreSame(result.PrimaryTag, result.Items[0].Tag,
"CRITICAL: Item[0].Tag should be same reference as PrimaryTag (same SharedTag.Id=1)");
"CRITICAL: Item[0].Tag should be same reference as PrimaryTag (same SharedTag_All_True.Id=1)");
Assert.AreSame(result.PrimaryTag, result.Items[1].Tag,
"CRITICAL: Item[1].Tag should be same reference as PrimaryTag (same SharedTag.Id=1)");
"CRITICAL: Item[1].Tag should be same reference as PrimaryTag (same SharedTag_All_True.Id=1)");
Assert.AreSame(result.PrimaryTag, result.Items[2].Tag,
"CRITICAL: Item[2].Tag should be same reference as PrimaryTag (same SharedTag.Id=1)");
"CRITICAL: Item[2].Tag should be same reference as PrimaryTag (same SharedTag_All_True.Id=1)");
// Users with Id=1 should all be same reference
Assert.AreSame(result.Owner, result.Items[0].Assignee,
@ -325,38 +329,38 @@ public class AcBinarySerializerIIdReferenceTests
public void DifferentInstances_SameIId_SmallerBinaryWithDataIntegrity()
{
// Arrange: 10 different instances with SAME IId
var orderWithSameIId = new TestOrder
var orderWithSameIId = new TestOrder_All_True
{
Id = 1,
OrderNumber = "SAME-IID",
Items = Enumerable.Range(1, 10).Select(i => new TestOrderItem
Items = Enumerable.Range(1, 10).Select(i => new TestOrderItem_All_True
{
Id = i,
ProductName = $"Product-{i}",
// All have SAME IId.Id = 1, but DIFFERENT instances
Assignee = new SharedUser { Id = 1, Username = "shared_user_name", Email = "shared@test.com" }
Assignee = new SharedUser_All_True { Id = 1, Username = "shared_user_name", Email = "shared@test.com" }
}).ToList()
};
// Arrange: 10 different instances with DIFFERENT IIds
var orderWithDifferentIIds = new TestOrder
var orderWithDifferentIIds = new TestOrder_All_True
{
Id = 1,
OrderNumber = "DIFF-IID",
Items = Enumerable.Range(1, 10).Select(i => new TestOrderItem
Items = Enumerable.Range(1, 10).Select(i => new TestOrderItem_All_True
{
Id = i,
ProductName = $"Product-{i}",
// All have DIFFERENT IId.Id
Assignee = new SharedUser { Id = i * 100, Username = "unique_user_name", Email = "unique@test.com" }
Assignee = new SharedUser_All_True { Id = i * 100, Username = "unique_user_name", Email = "unique@test.com" }
}).ToList()
};
// Act
var sameIIdBinary = orderWithSameIId.ToBinary();
var diffIIdBinary = orderWithDifferentIIds.ToBinary();
var sameIIdResult = sameIIdBinary.BinaryTo<TestOrder>();
var diffIIdResult = diffIIdBinary.BinaryTo<TestOrder>();
var sameIIdResult = sameIIdBinary.BinaryTo<TestOrder_All_True>();
var diffIIdResult = diffIIdBinary.BinaryTo<TestOrder_All_True>();
// Assert 1: Size comparison
Console.WriteLine($"Same IId binary size: {sameIIdBinary.Length} bytes");
@ -506,15 +510,15 @@ public class AcBinarySerializerIIdReferenceTests
public void IIdDetection_Diagnostic()
{
// Test GetIdInfo directly
var sharedTagType = typeof(SharedTag);
var sharedTagType = typeof(SharedTag_All_True);
var idInfo = AyCode.Core.Helpers.JsonUtilities.GetIdInfo(sharedTagType);
Console.WriteLine($"SharedTag GetIdInfo: IsId={idInfo.IsId}, IdType={idInfo.IdType?.Name}");
Assert.IsTrue(idInfo.IsId, "SharedTag should be detected as IId<int>");
Assert.AreEqual(typeof(int), idInfo.IdType, "SharedTag Id type should be int");
Console.WriteLine($"SharedTag_All_True GetIdInfo: IsId={idInfo.IsId}, IdType={idInfo.IdType?.Name}");
Assert.IsTrue(idInfo.IsId, "SharedTag_All_True should be detected as IId<int>");
Assert.AreEqual(typeof(int), idInfo.IdType, "SharedTag_All_True Id type should be int");
// Test SharedUser
var sharedUserType = typeof(SharedUser);
var sharedUserType = typeof(SharedUser_All_True);
var userIdInfo = AyCode.Core.Helpers.JsonUtilities.GetIdInfo(sharedUserType);
Console.WriteLine($"SharedUser GetIdInfo: IsId={userIdInfo.IsId}, IdType={userIdInfo.IdType?.Name}");
Assert.IsTrue(userIdInfo.IsId, "SharedUser should be detected as IId<int>");
@ -532,7 +536,7 @@ public class AcBinarySerializerIIdReferenceTests
[TestMethod]
public void SharedCategory_DataIntegrity()
{
var categories = new List<SharedCategory>
var categories = new List<SharedCategory_All_True>
{
new() { Id = 1, Name = "Category1", SortOrder = 1, IsDefault = true },
new() { Id = 2, Name = "Category2", SortOrder = 2, ParentCategoryId = 1 },
@ -540,7 +544,7 @@ public class AcBinarySerializerIIdReferenceTests
};
var binary = categories.ToBinary();
var result = binary.BinaryTo<List<SharedCategory>>();
var result = binary.BinaryTo<List<SharedCategory_All_True>>();
Assert.IsNotNull(result);
Assert.AreEqual(3, result.Count);
@ -557,4 +561,93 @@ public class AcBinarySerializerIIdReferenceTests
}
#endregion
#region SGen-emit writer/reader ref-handling asymmetry regression target
/// <summary>
/// Target test for the SGen-emit writer/reader asymmetry hypothesis — covers BOTH
/// collection-element AND dictionary-value ref-marker paths in a single graph.
/// <para>
/// Setup:
/// <list type="bullet">
/// <item><c>TestRefAsymParent</c> [AcBinarySerializable(false)] — parent EnableRefHandlingFeature=false.</item>
/// <item><c>TestRefAsymChild</c> [AcBinarySerializable(true)] — child IId&lt;int&gt;, all features ON.</item>
/// <item>Same child instance referenced twice in the parent's <c>Children</c> list
/// AND twice as VALUES in the parent's <c>ChildrenMap</c> dictionary.</item>
/// <item>Runtime <c>ReferenceHandling=All</c> + <c>Interning=All</c> (via Default options).</item>
/// <item><c>MarkerDecimal</c> property AFTER the list — drift detection slot (decimal = 16 fixed bytes).</item>
/// <item><c>MarkerDecimal2</c> property AFTER the dictionary — second drift detection slot,
/// catches the symmetric dict-value emit asymmetry (EmitReadDictionary:482).</item>
/// </list>
/// </para>
/// <para>
/// Expected if the asymmetry-hypothesis holds: the writer (runtime via
/// WriteObjectGenerated bridge) emits ObjectRefFirst+ObjectRef for the duplicates; the SGen
/// reader-emit's zero-branch path (parent flag false guarding out the ref-aware switch)
/// misreads the VarUInt cacheIdx as a property-marker byte → DECIMAL_DRIFT exception or
/// value-mismatch on MarkerDecimal / MarkerDecimal2.
/// </para>
/// <para>
/// Expected if the hypothesis is WRONG: the test passes — different fix direction needed.
/// </para>
/// </summary>
[TestMethod]
public void Serialize_RefMarkerCollectionElement_ParentRefHandlingFeatureOff_DriftReproduction()
{
var sharedChild = new TestRefAsymChild { Id = 1, Name = "Shared" };
var parent = new TestRefAsymParent
{
Id = 100,
Children = new List<TestRefAsymChild> { sharedChild, sharedChild },
MarkerDecimal = 999.99m,
ChildrenMap = new Dictionary<int, TestRefAsymChild>
{
{ 10, sharedChild },
{ 20, sharedChild },
},
MarkerDecimal2 = 888.88m,
};
var options = AcBinarySerializerOptions.Default; // RefHandling=All, Interning=All
options.UseGeneratedCode = true;
var bytes = AcBinarySerializer.Serialize(parent, options);
// Sanity check: did the writer actually emit an ObjectRef marker for the duplicates?
var objectRefCount = CountObjectRefs(bytes, writeBinaryToConsole: false);
Console.WriteLine($"Wire size: {bytes.Length}, ObjectRef occurrences: {objectRefCount}");
var result = AcBinaryDeserializer.Deserialize<TestRefAsymParent>(bytes, options);
Assert.IsNotNull(result, "Deserialize returned null — wire corruption");
Assert.AreEqual(parent.Id, result.Id, "Parent.Id mismatch — possible drift before the list");
// --- Collection-element path (EmitReadCollectionElement) ---
Assert.IsNotNull(result.Children, "Children list was null after round-trip");
Assert.AreEqual(2, result.Children.Count, "Children count mismatch");
Assert.IsNotNull(result.Children[0]);
Assert.IsNotNull(result.Children[1]);
Assert.AreEqual(sharedChild.Id, result.Children[0].Id, "Children[0].Id mismatch");
Assert.AreEqual(sharedChild.Name, result.Children[0].Name, "Children[0].Name mismatch");
Assert.AreEqual(sharedChild.Id, result.Children[1].Id, "Children[1].Id mismatch — drift on the duplicate");
Assert.AreEqual(sharedChild.Name, result.Children[1].Name, "Children[1].Name mismatch — drift on the duplicate");
Assert.AreEqual(parent.MarkerDecimal, result.MarkerDecimal,
"MarkerDecimal drift — wire-position desync after the Children list (smoking gun for collection-element SGen-emit asymmetry)");
// --- Dictionary-value path (EmitReadDictionary) ---
Assert.IsNotNull(result.ChildrenMap, "ChildrenMap was null after round-trip");
Assert.AreEqual(2, result.ChildrenMap.Count, "ChildrenMap count mismatch");
Assert.IsTrue(result.ChildrenMap.ContainsKey(10), "ChildrenMap missing key=10");
Assert.IsTrue(result.ChildrenMap.ContainsKey(20), "ChildrenMap missing key=20");
Assert.IsNotNull(result.ChildrenMap[10]);
Assert.IsNotNull(result.ChildrenMap[20]);
Assert.AreEqual(sharedChild.Id, result.ChildrenMap[10].Id, "ChildrenMap[10].Id mismatch");
Assert.AreEqual(sharedChild.Name, result.ChildrenMap[10].Name, "ChildrenMap[10].Name mismatch");
Assert.AreEqual(sharedChild.Id, result.ChildrenMap[20].Id, "ChildrenMap[20].Id mismatch — drift on the dict-value duplicate");
Assert.AreEqual(sharedChild.Name, result.ChildrenMap[20].Name, "ChildrenMap[20].Name mismatch — drift on the dict-value duplicate");
Assert.AreEqual(parent.MarkerDecimal2, result.MarkerDecimal2,
"MarkerDecimal2 drift — wire-position desync after the ChildrenMap dictionary (smoking gun for dict-value SGen-emit asymmetry)");
}
#endregion
}

View File

@ -0,0 +1,314 @@
using System;
using AyCode.Core.Extensions;
using AyCode.Core.Serializers;
using AyCode.Core.Serializers.Binaries;
using AyCode.Core.Tests.TestModels;
using Microsoft.VisualStudio.TestTools.UnitTesting;
namespace AyCode.Core.Tests.Serialization;
/// <summary>
/// Focused repro tests for <c>MaxDepthBehavior.Truncate</c> on cyclic graphs.
///
/// Tracks <c>BINARY_ISSUES.md#accore-bin-i-t7k3</c>: SGen path produces wire-misalignment
/// (<c>DECIMAL_DRIFT</c> on round-trip) when Truncate fires inside a cycle. Runtime path
/// works correctly with the same data — that's the control test for diagnosis.
///
/// Designed for interactive debugger sessions: minimal graph, small <c>MaxDepth=5</c>,
/// single cycle property. Step through `WriteObjectFullMarkerIId` to compare runtime
/// vs SGen call sequences at the truncation boundary.
/// </summary>
[TestClass]
public class AcBinarySerializerMaxDepthTruncateTests
{
private const int MaxDepthForTest = 5;
/// <summary>
/// Builds the minimal cyclic graph used by most tests below.
/// Cycle: <c>order → Items[0] → ParentOrder → order → …</c>.
/// Only primitive properties on the leaf entities so the body is short and the wire is easy to diff.
/// </summary>
private static TestOrder_Circ_Ref BuildMinimalCycle()
{
var order = new TestOrder_Circ_Ref
{
Id = 1,
OrderNumber = "TEST-001",
Items =
[
new TestOrderItem_Circ_Ref
{
Id = 10,
ProductName = "Product-A",
Quantity = 5
}
]
};
order.Items[0].ParentOrder = order; // ← closes the cycle
return order;
}
/// <summary>
/// Builds the cyclic graph PLUS sets the polymorphic <c>Parent</c> property (declared <c>object?</c>)
/// to a non-IId concrete instance — mirroring the failing SameInstance test's setup for None mode.
/// This routes through <see cref="WriteValueNonPrimitiveWithWrapperPoly"/> →
/// <see cref="WriteObjectPolymorphic"/> on every cycle level (Parent is written at every TestOrder body).
/// </summary>
private static TestOrder_Circ_Ref BuildCycleWithPolymorphicParent()
{
var order = BuildMinimalCycle();
// Parent is `object?` on TestOrder_Circ_Ref — polymorphic write path.
// UserPreferences_All_True is non-IId, leaf-like (Language, LightTheme strings + scalars), no further refs.
order.Parent = new UserPreferences_All_True { Language = "en-US", Theme = "light" };
return order;
}
/// <summary>
/// Diagnostic helper: dump the wire bytes as hex for visual comparison.
/// Useful in the debugger to spot the runtime-vs-SGen wire diff.
/// </summary>
private static void DumpWire(string label, byte[] wire)
{
Console.WriteLine($"=== {label} | {wire.Length} bytes ===");
Console.WriteLine(BitConverter.ToString(wire));
Console.WriteLine();
}
/// <summary>
/// CONTROL TEST — runtime path with Truncate. Should pass.
/// If this fails, the bug is broader than the SGen path; fix here first.
/// </summary>
[TestMethod]
public void Runtime_None_Truncate_CyclicGraph_RoundTrips()
{
var order = BuildMinimalCycle();
var options = new AcBinarySerializerOptions
{
ReferenceHandling = ReferenceHandlingMode.None,
UseGeneratedCode = false, // ← runtime path
UseMetadata = false,
MaxDepth = MaxDepthForTest,
MaxDepthBehavior = MaxDepthBehavior.Truncate
};
// Act
var binary = AcBinarySerializer.Serialize(order, options);
DumpWire("Runtime + Truncate", binary);
var result = binary.BinaryTo<TestOrder_Circ_Ref>();
// Assert: serialize+deserialize succeeds; root + first-level data intact;
// ParentOrder is null at the truncation boundary (instead of a full cycle round-trip).
Assert.IsNotNull(result, "Deserialize result should not be null");
Assert.AreEqual(1, result.Id, "root Id");
Assert.AreEqual("TEST-001", result.OrderNumber, "root OrderNumber");
Assert.IsNotNull(result.Items, "Items list should be materialized");
Assert.AreEqual(1, result.Items.Count, "Items count");
Assert.AreEqual(10, result.Items[0].Id, "Item Id");
Assert.AreEqual("Product-A", result.Items[0].ProductName, "Item ProductName");
Assert.AreEqual(5, result.Items[0].Quantity, "Item Quantity");
// ParentOrder may or may not be set depending on where truncation fires —
// the contract is "the deserialize must not throw and root-level data must be intact".
}
/// <summary>
/// BUG REPRO — SGen path with Truncate. Currently fails with <c>DECIMAL_DRIFT</c>
/// on round-trip. Same input as the runtime control above; only <c>UseGeneratedCode</c> differs.
/// </summary>
/// <remarks>
/// Step through with the VS debugger:
/// 1. Break in <c>WriteObjectFullMarkerIId</c> for both runs (runtime test above + this).
/// 2. Compare <c>_position</c>, <c>_recursionDepth</c>, and the wire-bytes-just-written at each
/// call-site between the two paths.
/// 3. Identify the byte position where the SGen wire diverges from the runtime wire.
/// 4. Likely culprits to inspect:
/// - <c>TryEnterRecursion</c> inc/dec balance on the SGen-emit code path
/// - <c>WriteObjectFullMarkerIId</c> ref-handling branches (2nd-occurrence ExitRecursion undo)
/// - SGen-emitted property-loop ordering vs runtime <c>WritePropertiesMarkerless</c>
/// </remarks>
[TestMethod]
public void Sgen_None_Truncate_CyclicGraph_RoundTrips()
{
var order = BuildMinimalCycle();
var options = new AcBinarySerializerOptions
{
ReferenceHandling = ReferenceHandlingMode.None,
UseGeneratedCode = true, // ← SGen path (triggers the bug)
UseMetadata = false,
MaxDepth = MaxDepthForTest,
MaxDepthBehavior = MaxDepthBehavior.Truncate
};
// Act
var binary = AcBinarySerializer.Serialize(order, options);
DumpWire("SGen + Truncate", binary);
var result = binary.BinaryTo<TestOrder_Circ_Ref>();
// Assert: same as runtime control. Currently throws DECIMAL_DRIFT.
Assert.IsNotNull(result, "Deserialize result should not be null");
Assert.AreEqual(1, result.Id, "root Id");
Assert.AreEqual("TEST-001", result.OrderNumber, "root OrderNumber");
Assert.IsNotNull(result.Items, "Items list should be materialized");
Assert.AreEqual(1, result.Items.Count, "Items count");
Assert.AreEqual(10, result.Items[0].Id, "Item Id");
Assert.AreEqual("Product-A", result.Items[0].ProductName, "Item ProductName");
Assert.AreEqual(5, result.Items[0].Quantity, "Item Quantity");
}
/// <summary>
/// SGen + Truncate + useMetadata=true variant. Also currently fails (multi-byte marker variant
/// of the same underlying issue). Useful for the debug session to confirm whether the fix
/// also covers the metadata code path or just the simple Object-marker path.
/// </summary>
[TestMethod]
public void Sgen_None_Truncate_UseMetadata_CyclicGraph_RoundTrips()
{
var order = BuildMinimalCycle();
var options = new AcBinarySerializerOptions
{
ReferenceHandling = ReferenceHandlingMode.None,
UseGeneratedCode = true,
UseMetadata = true, // ← multi-byte marker (ObjectWithMetadata + inline meta)
MaxDepth = MaxDepthForTest,
MaxDepthBehavior = MaxDepthBehavior.Truncate
};
// Act
var binary = AcBinarySerializer.Serialize(order, options);
DumpWire("SGen + Truncate + useMetadata", binary);
var result = binary.BinaryTo<TestOrder_Circ_Ref>();
// Assert: same root-level integrity expectation.
Assert.IsNotNull(result);
Assert.AreEqual(1, result.Id);
Assert.AreEqual("TEST-001", result.OrderNumber);
Assert.IsNotNull(result.Items);
Assert.AreEqual(1, result.Items.Count);
Assert.AreEqual("Product-A", result.Items[0].ProductName);
}
/// <summary>
/// CONTROL — runtime + polymorphic Parent. Should pass.
/// Compared to <see cref="Sgen_None_Truncate_PolymorphicCycle_RoundTrips"/> below: same data, different code path.
/// </summary>
[TestMethod]
public void Runtime_None_Truncate_PolymorphicCycle_RoundTrips()
{
var order = BuildCycleWithPolymorphicParent();
var options = new AcBinarySerializerOptions
{
ReferenceHandling = ReferenceHandlingMode.None,
UseGeneratedCode = false,
UseMetadata = false,
MaxDepth = MaxDepthForTest,
MaxDepthBehavior = MaxDepthBehavior.Truncate
};
var binary = AcBinarySerializer.Serialize(order, options);
DumpWire("Runtime + Truncate + PolymorphicParent", binary);
var result = binary.BinaryTo<TestOrder_Circ_Ref>();
Assert.IsNotNull(result);
Assert.AreEqual(1, result.Id);
Assert.AreEqual("TEST-001", result.OrderNumber);
Assert.IsNotNull(result.Items);
Assert.AreEqual(1, result.Items.Count);
// Root-level Parent should round-trip (depth 1 — well below MaxDepth).
Assert.IsNotNull(result.Parent, "Root order.Parent should round-trip — depth 1 < MaxDepth");
Assert.IsInstanceOfType(result.Parent, typeof(UserPreferences_All_True));
}
/// <summary>
/// REPRO — SGen + polymorphic Parent inside a cycle. This is the failing case in the
/// original SameInstance test for (useSgen=true, useMeta=false) None mode.
/// </summary>
/// <remarks>
/// The cycle (Items[0].ParentOrder = order) makes <c>TestOrder.WriteProperties</c> recurse.
/// At each cycle level, the body writes its <c>Parent</c> property polymorphically via
/// <c>WriteValueNonPrimitiveWithWrapperPoly</c> → <c>WriteObjectPolymorphic</c>. When the
/// cycle reaches <c>MaxDepth</c>, the SGen path produces wire bytes that the SGen reader
/// later mis-interprets (<c>DECIMAL_DRIFT</c> on TotalAmount at the deepest unwind frame).
/// Runtime control above with the same data works correctly — diff the two wires to find
/// where SGen diverges.
///
/// Focus debug-watch targets at the truncation boundary:
/// - <see cref="AcBinarySerializer.WriteObjectPolymorphic"/> Truncate path (Null written)
/// - <see cref="AcBinarySerializer.BinarySerializationContext{TOutput}.TryEnterRecursion"/> inc/dec balance
/// - The polymorphic-prefix wire bytes (FixObj-slot vs ObjectWithTypeName) immediately before/after the truncate
/// </remarks>
[TestMethod]
public void Sgen_None_Truncate_PolymorphicCycle_RoundTrips()
{
var order = BuildCycleWithPolymorphicParent();
var options = new AcBinarySerializerOptions
{
ReferenceHandling = ReferenceHandlingMode.None,
UseGeneratedCode = true, // ← SGen path (triggers the bug)
UseMetadata = false,
MaxDepth = MaxDepthForTest,
MaxDepthBehavior = MaxDepthBehavior.Truncate
};
var binary = AcBinarySerializer.Serialize(order, options);
DumpWire("SGen + Truncate + PolymorphicParent", binary);
var result = binary.BinaryTo<TestOrder_Circ_Ref>();
Assert.IsNotNull(result);
Assert.AreEqual(1, result.Id);
Assert.AreEqual("TEST-001", result.OrderNumber);
Assert.IsNotNull(result.Items);
Assert.AreEqual(1, result.Items.Count);
Assert.IsNotNull(result.Parent, "Root order.Parent should round-trip — depth 1 < MaxDepth");
Assert.IsInstanceOfType(result.Parent, typeof(UserPreferences_All_True));
}
/// <summary>
/// Non-cyclic shallow case — the primary delta-update use case.
/// Serialize an entity with intentionally truncated nested collections (MaxDepth=1),
/// verify root + first-level scalar properties round-trip while nested complex ones become null.
/// Both runtime and SGen paths should pass this. If SGen fails here too, the bug isn't
/// cycle-specific — it's pure Truncate-emission corruption.
/// </summary>
[TestMethod]
[DataRow(false, DisplayName = "Runtime")]
[DataRow(true, DisplayName = "SGen")]
public void Sgen_Or_Runtime_None_Truncate_NoCycle_ShallowRoundTrip(bool useSgen)
{
var order = new TestOrder_Circ_Ref
{
Id = 42,
OrderNumber = "DELTA-UPDATE-001",
Items =
[
new TestOrderItem_Circ_Ref { Id = 1, ProductName = "P1" },
new TestOrderItem_Circ_Ref { Id = 2, ProductName = "P2" }
]
// No cycle. Items array elements truncate at MaxDepth=1.
};
var options = new AcBinarySerializerOptions
{
ReferenceHandling = ReferenceHandlingMode.None,
UseGeneratedCode = useSgen,
UseMetadata = false,
MaxDepth = 1, // root + 1 level
MaxDepthBehavior = MaxDepthBehavior.Truncate
};
var binary = AcBinarySerializer.Serialize(order, options);
DumpWire($"NoCycle Truncate ({(useSgen ? "SGen" : "Runtime")})", binary);
var result = binary.BinaryTo<TestOrder_Circ_Ref>();
Assert.IsNotNull(result);
Assert.AreEqual(42, result.Id);
Assert.AreEqual("DELTA-UPDATE-001", result.OrderNumber);
// Items elements are at depth 2 — get truncated to null at MaxDepth=1.
// Result.Items list itself should exist (it's at depth 1), but elements should be null.
// The exact element-or-null result is depth-implementation-dependent — the strict invariant
// is "deserialize doesn't throw and root scalars are intact".
}
}

View File

@ -0,0 +1,226 @@
using System.IO.Pipelines;
using System.IO.Pipes;
using AyCode.Core.Serializers.Binaries;
using AyCode.Core.Tests.TestModels;
using static AyCode.Core.Tests.TestModels.AcSerializerModels;
namespace AyCode.Core.Tests.Serialization;
/// <summary>
/// Cross-platform NamedPipe IPC roundtrip tests proving AcBinarySerializer's streaming framework
/// works on arbitrary <c>PipeWriter</c>/<c>PipeReader</c> sources without per-transport adapters.
///
/// <para>The serializer/deserializer surface intentionally has NO NamedPipe-specific helpers —
/// the tests own the <see cref="NamedPipeServerStream"/> / <see cref="NamedPipeClientStream"/>
/// lifecycle directly and call the generic
/// <see cref="AcBinarySerializer.SerializeChunked{T}(T, PipeWriter, AcBinarySerializerOptions)"/> +
/// <see cref="AcBinaryDeserializer.Deserialize{T}(AsyncPipeReaderInput, AcBinarySerializerOptions)"/>
/// primitives, with the receive-side drain implemented via the test-only
/// <see cref="AsyncPipeReaderInputExtensions.DrainFromAsync"/> extension. The same generic
/// primitives apply to FileStream / NetworkStream / custom transports — consumers own the
/// transport lifecycle, framework stays transport-agnostic.</para>
///
/// <para>With <c>BufferWriterChunkSize = 256</c>, even small test payloads cross multiple chunk
/// boundaries on the wire — exercises the real chunking + sliding-window cycling behavior.</para>
/// </summary>
[TestClass]
public class AcBinarySerializerNamedPipeTests
{
[TestMethod]
public async Task RoundTrip_SmallChunkSize_PayloadEquals()
{
var pipeName = $"AcBinaryTest-{Guid.NewGuid():N}";
// 256-byte chunk size = Kestrel slab default; small enough to force multi-chunk framing
// for our 50-item payload, exercises the AsyncSegment chunked wire format end-to-end.
var opts = new AcBinarySerializerOptions { BufferWriterChunkSize = 256 };
var original = CreatePayload(50);
var result = await RunNamedPipeRoundTripAsync(pipeName, original, opts);
Assert.IsNotNull(result);
AssertPayloadEquals(original, result);
}
[TestMethod]
public async Task RoundTrip_LargeScalePayload_ChunkSize256_StructuralEquality()
{
// Production-scale payload via TestDataFactory: 100 root items × 3 pallets × 3 measurements × 4 points
// = ~3700 deeply-nested objects with shared references (50 tags, 20 users, metadata, 10 categories).
// Serialized size ~few hundred KB → many chunks at chunkSize=256 → real backpressure-driven streaming
// (sequential per-chunk flush on StreamPipeWriter, bytes flow incrementally as consumer drains).
#if DEBUG
// Capture BOTH receiver and sender state to diagnose StreamPipeWriter interaction if needed.
var diagLogs = new List<string>();
AsyncPipeReaderInput.DiagnosticLog = msg => diagLogs.Add($"[R] {msg}");
AsyncPipeWriterOutput.DiagnosticLog = msg => diagLogs.Add($"[S] {msg}");
#endif
try
{
var pipeName = $"AcBinaryTest-{Guid.NewGuid():N}";
var opts = new AcBinarySerializerOptions { BufferWriterChunkSize = 256 };
var original = TestDataFactory.CreateLargeScaleBenchmarkOrder(rootItemCount: 100);
var result = await RunNamedPipeRoundTripAsync(pipeName, original, opts);
Assert.IsNotNull(result);
Assert.AreEqual(original.Id, result.Id);
Assert.AreEqual(original.OrderNumber, result.OrderNumber);
Assert.AreEqual(original.Status, result.Status);
Assert.AreEqual(original.TotalAmount, result.TotalAmount);
// Deep structure: count items + pallets + measurements + points must match exactly
var origCounts = CountTestOrderHierarchy(original);
var resultCounts = CountTestOrderHierarchy(result);
Assert.AreEqual(origCounts.items, resultCounts.items, "Items count mismatch");
Assert.AreEqual(origCounts.pallets, resultCounts.pallets, "Pallets count mismatch");
Assert.AreEqual(origCounts.measurements, resultCounts.measurements, "Measurements count mismatch");
Assert.AreEqual(origCounts.points, resultCounts.points, "Points count mismatch");
}
finally
{
#if DEBUG
AsyncPipeReaderInput.DiagnosticLog = null;
AsyncPipeWriterOutput.DiagnosticLog = null;
if (diagLogs.Count > 0)
{
Console.WriteLine($"=== Sender [S] + Receiver [R] DiagnosticLog trail ({diagLogs.Count} entries) ===");
// Print last 60 entries (most relevant to failure point)
var startIdx = Math.Max(0, diagLogs.Count - 60);
if (startIdx > 0) Console.WriteLine($" ... ({startIdx} earlier entries elided)");
for (var i = startIdx; i < diagLogs.Count; i++) Console.WriteLine($" [{i}] {diagLogs[i]}");
Console.WriteLine($"=== End DiagnosticLog ===");
}
#endif
}
}
/// <summary>
/// Owns the full NamedPipe lifecycle: binds server, accepts connect, drives the generic
/// <see cref="AcBinarySerializer.SerializeChunked{T}(T, PipeWriter, AcBinarySerializerOptions)"/> on
/// the client side, and on the server side runs the canonical drain+deserialize pair
/// (test-only <see cref="AsyncPipeReaderInputExtensions.DrainFromAsync"/> on the calling thread,
/// <see cref="AcBinaryDeserializer.Deserialize{T}(AsyncPipeReaderInput, AcBinarySerializerOptions)"/>
/// on a Task.Run BG thread). The framework helpers know nothing about NamedPipe — only PipeWriter /
/// PipeReader.
/// </summary>
private static async Task<T?> RunNamedPipeRoundTripAsync<T>(string pipeName, T original, AcBinarySerializerOptions opts)
{
// Server-side bind is synchronous (NamedPipeServerStream ctor registers the pipe with
// the OS), so the client can immediately attempt connect once we hand off to async.
await using var pipeServer = new NamedPipeServerStream(pipeName, PipeDirection.In, 1, PipeTransmissionMode.Message, System.IO.Pipes.PipeOptions.Asynchronous);
var receiveTask = Task.Run(async () =>
{
await pipeServer.WaitForConnectionAsync().ConfigureAwait(false);
var pipeReader = PipeReader.Create(pipeServer);
// Inlined version of what the removed DeserializeFromPipeReaderAsync used to do:
// single-message mode + drain on calling thread + deserialize on Task.Run BG.
using var input = new AsyncPipeReaderInput(initialCapacity: opts.BufferWriterChunkSize * 2, multiMessage: false);
var deserTask = Task.Run(() => AcBinaryDeserializer.Deserialize<T>(input, opts));
await input.DrainFromAsync(pipeReader).ConfigureAwait(false);
return await deserTask.ConfigureAwait(false);
});
await using var pipeClient = new NamedPipeClientStream(".", pipeName, PipeDirection.Out, System.IO.Pipes.PipeOptions.Asynchronous);
await pipeClient.ConnectAsync().ConfigureAwait(false);
var pipeWriter = PipeWriter.Create(pipeClient);
try
{
// Public PipeWriter overload (raw chunked stream — no per-chunk frame headers,
// bit-compatible with Serialize(v, opts) byte[] output). Auto-selects sequential
// flush strategy because PipeWriter.Create(stream) returns StreamPipeWriter
// (race-incompatible with parallel send).
AcBinarySerializer.SerializeChunked(original, pipeWriter, opts);
}
finally
{
await pipeWriter.CompleteAsync().ConfigureAwait(false);
}
return await receiveTask.ConfigureAwait(false);
}
private static (int items, int pallets, int measurements, int points) CountTestOrderHierarchy(TestOrder_All_True order)
{
var items = order.Items.Count;
int pallets = 0, measurements = 0, points = 0;
foreach (var item in order.Items)
{
pallets += item.Pallets.Count;
foreach (var p in item.Pallets)
{
measurements += p.Measurements.Count;
points += p.Measurements.Sum(m => m.Points.Count);
}
}
return (items, pallets, measurements, points);
}
// Note: a "default chunk size" test was deliberately omitted. The default
// AcBinarySerializerOptions.BufferWriterChunkSize used to be 65536, which exceeded the
// UINT16 max (256). Fixed in this work to 256. Tests above explicitly set chunk size
// for reproducibility regardless of default.
private static TestParentWithDateTimeItemCollection CreatePayload(int itemCount)
{
var now = DateTime.UtcNow;
var items = new List<TestEntityWithDateTimeAndInt>(itemCount);
for (var i = 0; i < itemCount; i++)
{
items.Add(new TestEntityWithDateTimeAndInt
{
Id = i + 1,
IntValue = i * 3,
Created = now.AddMinutes(-i),
Modified = now.AddMinutes(i),
StatusCode = i % 4,
Name = $"item-{i}"
});
}
return new TestParentWithDateTimeItemCollection
{
Id = 11,
Name = "named-pipe-roundtrip",
Created = now,
Items = items
};
}
private static void AssertPayloadEquals(TestParentWithDateTimeItemCollection expected, TestParentWithDateTimeItemCollection actual)
{
Assert.AreEqual(expected.Id, actual.Id);
Assert.AreEqual(expected.Name, actual.Name);
Assert.AreEqual(expected.Created, actual.Created);
Assert.IsNotNull(expected.Items);
Assert.IsNotNull(actual.Items);
Assert.AreEqual(expected.Items.Count, actual.Items.Count);
for (var i = 0; i < expected.Items.Count; i++)
{
var e = expected.Items[i];
var a = actual.Items[i];
Assert.AreEqual(e.Id, a.Id);
Assert.AreEqual(e.IntValue, a.IntValue);
Assert.AreEqual(e.Created, a.Created);
Assert.AreEqual(e.Modified, a.Modified);
Assert.AreEqual(e.StatusCode, a.StatusCode);
Assert.AreEqual(e.Name, a.Name);
}
}
}

View File

@ -0,0 +1,729 @@
using AyCode.Core.Serializers.Binaries;
using AyCode.Core.Tests.TestModels;
using System.IO;
using System.IO.Pipelines;
using static AyCode.Core.Tests.TestModels.AcSerializerModels;
namespace AyCode.Core.Tests.Serialization;
/// <summary>
/// Unit tests for <see cref="AsyncPipeReaderInput"/> (Step 1, ACCORE-BIN-T-D6H4) and the
/// <see cref="AsyncPipeReaderInputExtensions.DrainFromAsync"/> extension (Step 2, ACCORE-BIN-T-M2K1),
/// plus the real parallel pipeline test (Step 3, ACCORE-BIN-T-V7C9), plus runtime type-detect
/// sanity pinning (Step 4).
///
/// <para>Tests run with <see cref="AsyncPipeReaderInput"/>'s default <c>multiMessage = true</c> —
/// <see cref="AsyncPipeReaderInput.Feed"/> expects the AsyncSegment chunked wire format
/// <c>[201][UINT16 LE size][data]</c> per chunk, tolerates <c>[200]</c> CHUNK_START prefix, and
/// signals end-of-stream on <c>[202]</c> CHUNK_END. The <see cref="WrapInChunkFrame"/> helper
/// wraps test data into single chunk frames; multi-chunk tests concatenate multiple frames.</para>
///
/// <para>Wire format identical to <see cref="AsyncPipeWriterOutput"/> framed output and to
/// SignalR's <c>AcBinaryHubProtocol.TryParseChunkData</c> input — unified across all transports
/// per ADR-0003 §9.</para>
/// </summary>
[TestClass]
public class AcBinarySerializerPipeParallelTests
{
// ====================================================================
// Step 1 — AsyncPipeReaderInput contract (ACCORE-BIN-T-D6H4)
// ====================================================================
[TestMethod]
public void Feed_EmptyData_NoOp()
{
using var input = new AsyncPipeReaderInput(64);
input.Feed(ReadOnlySpan<byte>.Empty);
input.Complete();
// No data → TryAdvanceSegment returns false immediately
input.Initialize(out var buffer, out var position, out var bufferLength);
Assert.AreEqual(0, bufferLength);
var hasMore = input.TryAdvanceSegment(ref buffer, ref position, ref bufferLength, 1);
Assert.IsFalse(hasMore);
}
[TestMethod]
public void Feed_AppendsBytes_AccessibleViaTryAdvanceSegment()
{
using var input = new AsyncPipeReaderInput(64);
var data = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 };
input.Feed(WrapInChunkFrame(data)); // [201][UINT16=8][1..8]
input.Complete();
var consumed = ConsumeAll(input);
CollectionAssert.AreEqual(data, consumed);
}
[TestMethod]
public void Initialize_BeforeFeed_ReturnsEmptyBuffer()
{
using var input = new AsyncPipeReaderInput(64);
input.Initialize(out var buffer, out var position, out var bufferLength);
Assert.IsNotNull(buffer);
Assert.AreEqual(0, position);
Assert.AreEqual(0, bufferLength);
}
[TestMethod]
public void Initialize_AfterFeed_ReturnsAvailableData()
{
using var input = new AsyncPipeReaderInput(64);
var data = new byte[] { 10, 20, 30 };
input.Feed(WrapInChunkFrame(data));
input.Initialize(out var buffer, out var position, out var bufferLength);
Assert.AreEqual(0, position);
Assert.AreEqual(3, bufferLength);
Assert.AreEqual((byte)10, buffer[0]);
Assert.AreEqual((byte)20, buffer[1]);
Assert.AreEqual((byte)30, buffer[2]);
}
[TestMethod]
public void Complete_AllConsumed_TryAdvanceSegmentReturnsFalse()
{
using var input = new AsyncPipeReaderInput(64);
input.Feed(WrapInChunkFrame([1, 2, 3]));
input.Complete();
// Simulate consumer that has read all 3 bytes
input.Initialize(out var buffer, out var position, out var bufferLength);
position = bufferLength;
var hasMore = input.TryAdvanceSegment(ref buffer, ref position, ref bufferLength, 1);
Assert.IsFalse(hasMore);
}
[TestMethod]
public void Complete_WithLeftoverData_TryAdvanceSegmentReturnsTrueWithRemainder()
{
using var input = new AsyncPipeReaderInput(64);
input.Feed(WrapInChunkFrame([1, 2, 3]));
input.Feed(WrapInChunkFrame([4, 5, 6]));
input.Complete();
// Simulate consumer that has read 3 of 6 bytes — advance should expose the rest
input.Initialize(out var buffer, out var position, out var bufferLength);
Assert.AreEqual(6, bufferLength);
position = 3;
var hasMore = input.TryAdvanceSegment(ref buffer, ref position, ref bufferLength, 1);
Assert.IsTrue(hasMore);
Assert.AreEqual(3, position);
Assert.AreEqual(6, bufferLength);
Assert.AreEqual((byte)4, buffer[3]);
Assert.AreEqual((byte)5, buffer[4]);
Assert.AreEqual((byte)6, buffer[5]);
}
[TestMethod]
public void Grow_PastInitialCapacity_BytesPreservedAcrossGrows()
{
// Initial capacity = 16, feed > 16 bytes consecutively (no consume between) → forces grow
using var input = new AsyncPipeReaderInput(16);
var data = new byte[64];
for (var i = 0; i < data.Length; i++) data[i] = (byte)i;
// Feed in chunks that overflow the initial buffer (each wrapped in a chunk frame)
input.Feed(WrapInChunkFrame(data, 0, 16));
input.Feed(WrapInChunkFrame(data, 16, 16)); // grow #1
input.Feed(WrapInChunkFrame(data, 32, 32)); // grow #2
input.Complete();
var consumed = ConsumeAll(input);
CollectionAssert.AreEqual(data, consumed);
}
[TestMethod]
public async Task ProducerConsumer_Concurrency_AllBytesDeliveredInOrder()
{
const int totalBytes = 8192;
const int chunkSize = 17; // intentional: not a power of 2, exercises partial fills
using var input = new AsyncPipeReaderInput(64);
var expected = new byte[totalBytes];
for (var i = 0; i < totalBytes; i++) expected[i] = (byte)(i & 0xFF);
var consumeTask = Task.Run(() => ConsumeAll(input));
var produceTask = Task.Run(() =>
{
try
{
var offset = 0;
while (offset < expected.Length)
{
var take = Math.Min(chunkSize, expected.Length - offset);
input.Feed(WrapInChunkFrame(expected, offset, take));
offset += take;
}
}
finally
{
input.Complete();
}
});
await Task.WhenAll(consumeTask, produceTask);
var actual = consumeTask.Result;
CollectionAssert.AreEqual(expected, actual);
}
[TestMethod]
public async Task ProducerConsumer_SlidingWindowCycle_ManyResetsHandledCorrectly()
{
// Small initial buffer + slow producer drives many reset-to-0 cycles.
const int totalBytes = 32 * 1024;
const int chunkSize = 7;
using var input = new AsyncPipeReaderInput(32);
var expected = new byte[totalBytes];
for (var i = 0; i < totalBytes; i++) expected[i] = (byte)(i & 0xFF);
var consumeTask = Task.Run(() => ConsumeAll(input));
var produceTask = Task.Run(async () =>
{
try
{
var offset = 0;
while (offset < expected.Length)
{
var take = Math.Min(chunkSize, expected.Length - offset);
input.Feed(WrapInChunkFrame(expected, offset, take));
offset += take;
if ((offset & 0x7F) == 0) await Task.Yield();
}
}
finally
{
input.Complete();
}
});
await Task.WhenAll(consumeTask, produceTask);
var actual = consumeTask.Result;
Assert.AreEqual(expected.Length, actual.Length);
CollectionAssert.AreEqual(expected, actual);
}
[TestMethod]
public void Dispose_DoesNotThrow()
{
var input = new AsyncPipeReaderInput(64);
input.Feed(WrapInChunkFrame([1, 2, 3]));
input.Complete();
input.Dispose();
}
[TestMethod]
public void Constructor_InvalidCapacity_ThrowsArgumentOutOfRange()
{
_ = Assert.ThrowsExactly<ArgumentOutOfRangeException>(() => new AsyncPipeReaderInput(0));
_ = Assert.ThrowsExactly<ArgumentOutOfRangeException>(() => new AsyncPipeReaderInput(-1));
}
[TestMethod]
public void Feed_PartialFrameAcrossCalls_ParsedCorrectly()
{
// Verifies the framing state machine survives partial frame headers / sizes / data
// split across multiple Feed calls.
using var input = new AsyncPipeReaderInput(64);
var data = new byte[] { 10, 20, 30, 40, 50 };
var frame = WrapInChunkFrame(data); // 8 bytes total: [201][05][00][10][20][30][40][50]
// Feed byte-by-byte to stress the state machine
for (var i = 0; i < frame.Length; i++) input.Feed(frame.AsSpan(i, 1));
input.Complete();
var consumed = ConsumeAll(input);
CollectionAssert.AreEqual(data, consumed);
}
[TestMethod]
public void Feed_ChunkEndMarker_AutoResetsForNextMessage()
{
// [202] CHUNK_END is end-of-MESSAGE on the WIRE — NOT end-of-session and NOT, by itself,
// a buffer-cursor recycle. On [202], the framing-state machine resets to AwaitingHeader so
// the next [201] header is parsed correctly; buffer-cursor recycling is armed separately by
// the consumer via MessageDone() (typically from the AcBinaryDeserializer.Deserialize<T>(
// AsyncPipeReaderInput, opts) finally block, AFTER the deserialiser has finished reading
// the structurally-complete graph). See BINARY_ISSUES.md#accore-bin-i-q4t8 / R5K2 fix.
// Session end is signalled separately by an external Complete() / stream-EOF.
using var input = new AsyncPipeReaderInput(64);
// Message 1
input.Feed(WrapInChunkFrame([1, 2, 3]));
input.Feed([202]); // CHUNK_END — framing reset only (no buffer-cursor recycle, no completion)
// First message is consumable
input.Initialize(out var buf1, out var pos1, out var bufLen1);
Assert.AreEqual(3, bufLen1);
Assert.AreEqual(1, buf1[0]);
Assert.AreEqual(2, buf1[1]);
Assert.AreEqual(3, buf1[2]);
// Simulate the AcBinaryDeserializer.Deserialize<T>(input, opts) finally block: the consumer
// calls MessageDone() AFTER it has finished reading the graph. This arms the
// _readPos = -1 sentinel; the next AppendToBuffer for message 2 sees rp < 0 and recycles
// the buffer to 0 (sliding-window cycling).
input.MessageDone();
// Message 2 — same long-lived input, just keeps going
input.Feed(WrapInChunkFrame([10, 20, 30, 40]));
input.Feed([202]);
// Re-initialize for the next deserializer call — the buffer was recycled to 0 by the
// sliding-window cycling triggered when AppendToBuffer saw _readPos == -1 (sentinel
// armed by the MessageDone() call above).
input.Initialize(out var buf2, out var pos2, out var bufLen2);
Assert.AreEqual(4, bufLen2);
Assert.AreEqual(10, buf2[0]);
Assert.AreEqual(20, buf2[1]);
Assert.AreEqual(30, buf2[2]);
Assert.AreEqual(40, buf2[3]);
// Now signal end-of-session explicitly
input.Complete();
// After Complete, TryAdvanceSegment returns false on empty — session truly ended
var pos3 = bufLen2;
var bufLen3 = bufLen2;
var buf3 = buf2;
var hasMore = input.TryAdvanceSegment(ref buf3, ref pos3, ref bufLen3, 1);
Assert.IsFalse(hasMore);
}
[TestMethod]
public void Feed_ExternalComplete_SignalsEndOfSession()
{
// Explicit Complete() (or stream-EOF in the DrainFromAsync path) is the session-end signal —
// distinct from per-message [202] markers which only auto-reset for the next message.
using var input = new AsyncPipeReaderInput(64);
input.Feed(WrapInChunkFrame([1, 2, 3]));
input.Complete(); // external session-end
input.Initialize(out var buffer, out var position, out var bufferLength);
Assert.AreEqual(3, bufferLength);
position = bufferLength;
var hasMore = input.TryAdvanceSegment(ref buffer, ref position, ref bufferLength, 1);
Assert.IsFalse(hasMore);
}
[TestMethod]
public void Feed_UnexpectedMarker_ThrowsInvalidDataException()
{
using var input = new AsyncPipeReaderInput(64);
// Byte 0x42 is not 200/201/202 — should throw
_ = Assert.ThrowsExactly<InvalidDataException>(() => input.Feed([0x42]));
}
// ====================================================================
// Step 2 — DrainFromAsync extension (ACCORE-BIN-T-M2K1)
// ====================================================================
[TestMethod]
public async Task DrainFromAsync_NullInput_ThrowsArgumentNullException()
{
var pipe = new Pipe();
await pipe.Writer.CompleteAsync();
await Assert.ThrowsExactlyAsync<ArgumentNullException>(async () => await AsyncPipeReaderInputExtensions.DrainFromAsync(null!, pipe.Reader));
}
[TestMethod]
public async Task DrainFromAsync_NullReader_ThrowsArgumentNullException()
{
using var input = new AsyncPipeReaderInput(64);
await Assert.ThrowsExactlyAsync<ArgumentNullException>(async () => await input.DrainFromAsync(null!));
}
[TestMethod]
public async Task DrainFromAsync_PipeWithData_FeedsAllBytes()
{
using var input = new AsyncPipeReaderInput(64);
var pipe = new Pipe();
var data = new byte[] { 1, 2, 3, 4, 5, 6, 7, 8 };
await pipe.Writer.WriteAsync(WrapInChunkFrame(data));
await pipe.Writer.CompleteAsync();
await input.DrainFromAsync(pipe.Reader);
var consumed = ConsumeAll(input);
CollectionAssert.AreEqual(data, consumed);
}
[TestMethod]
public async Task DrainFromAsync_EmptyPipeCompleted_CallsCompleteOnInput()
{
using var input = new AsyncPipeReaderInput(64);
var pipe = new Pipe();
await pipe.Writer.CompleteAsync();
await input.DrainFromAsync(pipe.Reader);
// After drain, AsyncPipeReaderInput should be completed → TryAdvanceSegment returns false on empty
input.Initialize(out var buffer, out var position, out var bufferLength);
var hasMore = input.TryAdvanceSegment(ref buffer, ref position, ref bufferLength, 1);
Assert.IsFalse(hasMore);
}
[TestMethod]
public async Task DrainFromAsync_ConcurrentWriteDrainConsume_OrderPreserved()
{
// 3-thread pipeline: writer → pipe → drainer → input → consumer
using var input = new AsyncPipeReaderInput(64);
var pipe = new Pipe();
const int totalBytes = 4096;
var expected = new byte[totalBytes];
for (var i = 0; i < totalBytes; i++) expected[i] = (byte)(i & 0xFF);
var consumeTask = Task.Run(() => ConsumeAll(input));
var drainTask = input.DrainFromAsync(pipe.Reader);
var writeTask = Task.Run(async () =>
{
try
{
const int chunkSize = 31;
var offset = 0;
while (offset < expected.Length)
{
var take = Math.Min(chunkSize, expected.Length - offset);
await pipe.Writer.WriteAsync(WrapInChunkFrame(expected, offset, take));
offset += take;
}
}
finally
{
await pipe.Writer.CompleteAsync();
}
});
await Task.WhenAll(consumeTask, drainTask, writeTask);
var actual = consumeTask.Result;
CollectionAssert.AreEqual(expected, actual);
}
[TestMethod]
public async Task DrainFromAsync_Cancellation_PropagatesAndCallsComplete()
{
using var input = new AsyncPipeReaderInput(64);
var pipe = new Pipe();
using var cts = new CancellationTokenSource();
var drainTask = input.DrainFromAsync(pipe.Reader, cts.Token);
cts.Cancel();
await Assert.ThrowsExactlyAsync<OperationCanceledException>(async () => await drainTask);
// Verify Complete was called in the finally block
input.Initialize(out var buffer, out var position, out var bufferLength);
var hasMore = input.TryAdvanceSegment(ref buffer, ref position, ref bufferLength, 1);
Assert.IsFalse(hasMore);
}
// ====================================================================
// Step 3 — Real parallel pipeline test (ACCORE-BIN-T-V7C9)
//
// True 3-task pipeline: AcBinarySerializer writes framed chunks to pipe.Writer via
// AsyncPipeWriterOutput (framed mode under the hood) — drainer pulls from pipe.Reader
// via DrainFromAsync — deserializer reads from AsyncPipeReaderInput (framing-aware Feed).
// All three run concurrently with TRUE serialize↔deserialize overlap (the serializer is
// still writing the tail of the message while the deserializer has already consumed the
// head, courtesy of per-chunk SyncAwaitFlush in AsyncPipeWriterOutput).
//
// BufferWriterChunkSize = 256 → small payloads cross multiple [201][UINT16][data] chunk
// boundaries on the wire, exercising the framing-aware AsyncPipeReaderInput.Feed state
// machine. Wire is uniform AsyncSegment chunked format (per ADR-0003 §9).
// ====================================================================
[TestMethod]
public async Task RealParallelPipeline_SerializeViaPipeWriter_DeserializeViaPipeReader_PayloadEquals()
{
var opts = new AcBinarySerializerOptions { BufferWriterChunkSize = 256 };
var original = CreatePayload(50);
var pipe = new Pipe();
using var input = new AsyncPipeReaderInput(initialCapacity: opts.BufferWriterChunkSize * 2);
var deserTask = Task.Run(() => AcBinaryDeserializer.Deserialize<TestParentWithDateTimeItemCollection>(input, opts));
var drainTask = input.DrainFromAsync(pipe.Reader);
var serTask = Task.Run(async () =>
{
try
{
// SerializeChunkedFramed — writes [201][UINT16][data] per chunk on the wire.
// AsyncPipeReaderInput.Feed strips framing internally on the receive side
// (default multiMessage = true).
AcBinarySerializer.SerializeChunkedFramed(original, pipe.Writer, opts);
}
finally
{
await pipe.Writer.CompleteAsync();
}
});
await Task.WhenAll(serTask, drainTask, deserTask);
var result = deserTask.Result;
Assert.IsNotNull(result);
AssertPayloadEquals(original, result);
}
[TestMethod]
public async Task RealParallelPipeline_LargeScalePayload_ChunkSize4096_StructuralEquality()
{
// Production-scale payload via TestDataFactory: 100 root items × 3 pallets × 3 measurements × 4 points
// = ~3700 deeply-nested objects with shared references. Serialized size ~few hundred KB →
// many chunks at chunkSize=4096 → real backpressure-driven streaming (PipeWriter pauseThreshold
// ~64KB, bytes flow incrementally as drainer + deserializer task pulls them out).
// This is the most-realistic real-parallel-pipeline test: in-memory Pipe + 3-task overlap +
// production-scale payload + production-scale chunk size.
var opts = new AcBinarySerializerOptions { BufferWriterChunkSize = 4096 };
var original = TestDataFactory.CreateLargeScaleBenchmarkOrder(rootItemCount: 100);
var pipe = new Pipe();
using var input = new AsyncPipeReaderInput(initialCapacity: opts.BufferWriterChunkSize * 2);
var deserTask = Task.Run(() => AcBinaryDeserializer.Deserialize<TestOrder_All_True>(input, opts));
var drainTask = input.DrainFromAsync(pipe.Reader);
var serTask = Task.Run(async () =>
{
try { AcBinarySerializer.SerializeChunkedFramed(original, pipe.Writer, opts); }
finally { await pipe.Writer.CompleteAsync(); }
});
await Task.WhenAll(serTask, drainTask, deserTask);
var result = deserTask.Result;
Assert.IsNotNull(result);
Assert.AreEqual(original.Id, result.Id);
Assert.AreEqual(original.OrderNumber, result.OrderNumber);
Assert.AreEqual(original.Status, result.Status);
Assert.AreEqual(original.TotalAmount, result.TotalAmount);
var origCounts = CountTestOrderHierarchy(original);
var resultCounts = CountTestOrderHierarchy(result);
Assert.AreEqual(origCounts.items, resultCounts.items, "Items count mismatch");
Assert.AreEqual(origCounts.pallets, resultCounts.pallets, "Pallets count mismatch");
Assert.AreEqual(origCounts.measurements, resultCounts.measurements, "Measurements count mismatch");
Assert.AreEqual(origCounts.points, resultCounts.points, "Points count mismatch");
}
private static (int items, int pallets, int measurements, int points) CountTestOrderHierarchy(TestOrder_All_True order)
{
var items = order.Items.Count;
int pallets = 0, measurements = 0, points = 0;
foreach (var item in order.Items)
{
pallets += item.Pallets.Count;
foreach (var p in item.Pallets)
{
measurements += p.Measurements.Count;
points += p.Measurements.Sum(m => m.Points.Count);
}
}
return (items, pallets, measurements, points);
}
// ====================================================================
// Step 4 — AsyncPipeWriterOutput runtime type detect — sanity pinning
// ====================================================================
//
// Guards the architectural assumption that PipeWriter.Create(Stream).GetType() resolves to a
// different runtime type than new Pipe().Writer.GetType(). This is what makes
// AsyncPipeWriterOutput._serializeFlushAndAcquire auto-select between sequential
// (Stream-backed) and parallel (Pipe-based) flush strategies safe — without touching internal
// BCL type names directly. If a future .NET unifies the two writer impls or renames the
// internal type in a way that breaks the detect, these tests fail before prod.
[TestMethod]
public void StreamPipeWriter_AndPipeWriter_AreDistinctTypes()
{
var pipeBased = new Pipe().Writer.GetType();
var streamBased = PipeWriter.Create(Stream.Null).GetType();
// Cornerstone of the runtime detect — must NEVER unify, else _serializeFlushAndAcquire
// would either always-true or always-false, both of which break correctness.
Assert.AreNotEqual(pipeBased, streamBased,
$"Runtime types unified — pipe-based and stream-backed PipeWriter must remain distinct. " +
$"pipeBased={pipeBased.FullName}, streamBased={streamBased.FullName}");
// Living documentation — typenames printed for debugging on future .NET upgrades.
Console.WriteLine($"Pipe.Writer typename: {pipeBased.FullName}");
Console.WriteLine($"PipeWriter.Create(Stream) typename: {streamBased.FullName}");
}
[TestMethod]
public void StreamPipeWriterTypeField_MatchesFactoryResult()
{
// The static field caches the StreamPipeWriter type via PipeWriter.Create(Stream.Null).GetType()
// at class-load time. A second call to the factory MUST yield the same Type instance —
// otherwise the cache is stale and the runtime detect mis-classifies all stream writers.
var freshType = PipeWriter.Create(Stream.Null).GetType();
Assert.AreSame(freshType, AsyncPipeWriterOutput.StreamPipeWriterType,
"Cached StreamPipeWriterType differs from a fresh factory result — the BCL is " +
"behaving non-deterministically (or the test was loaded before AsyncPipeWriterOutput).");
}
[TestMethod]
public void IsAssignableFrom_PipeBasedWriter_ReturnsFalse()
{
// The Pipe.Writer impl must NOT be a StreamPipeWriter (or subclass thereof) — else
// sequential mode would be wrongly selected and we'd lose the parallelism feature.
var pipeBasedType = new Pipe().Writer.GetType();
Assert.IsFalse(AsyncPipeWriterOutput.StreamPipeWriterType.IsAssignableFrom(pipeBasedType),
$"Pipe.Writer typename={pipeBasedType.FullName} is unexpectedly a StreamPipeWriter " +
$"(or subclass) — runtime detect would mis-classify it as sequential.");
}
[TestMethod]
public void IsAssignableFrom_StreamBackedWriters_ReturnsTrue()
{
// PipeWriter.Create(stream) must always yield a StreamPipeWriter (or subclass) —
// even for unusual stream types (file, memory, null).
Type[] writerTypes =
[
PipeWriter.Create(Stream.Null).GetType(),
PipeWriter.Create(new MemoryStream()).GetType(),
];
foreach (var t in writerTypes)
{
Assert.IsTrue(AsyncPipeWriterOutput.StreamPipeWriterType.IsAssignableFrom(t),
$"PipeWriter.Create(<stream>) returned typename={t.FullName} which is not " +
$"assignable to StreamPipeWriterType — the BCL changed its factory contract.");
}
}
// ====================================================================
// Test helpers
// ====================================================================
/// <summary>
/// Wraps a raw payload in a single AsyncSegment chunk frame: <c>[201][UINT16 LE size][data]</c>.
/// Matches the wire format produced by <see cref="AsyncPipeWriterOutput"/> per chunk.
/// </summary>
private static byte[] WrapInChunkFrame(byte[] data) => WrapInChunkFrame(data, 0, data.Length);
private static byte[] WrapInChunkFrame(byte[] data, int offset, int length)
{
var result = new byte[3 + length];
result[0] = 201; // CHUNK_DATA marker
result[1] = (byte)(length & 0xFF); // UINT16 LE size, low byte
result[2] = (byte)((length >> 8) & 0xFF); // UINT16 LE size, high byte
Array.Copy(data, offset, result, 3, length);
return result;
}
/// <summary>
/// Drains the input fully via the IBinaryInputBase contract, returning all consumed bytes.
/// Mimics the consumer pattern that <c>DeserializeSequence&lt;TInput&gt;</c> uses internally.
/// </summary>
private static byte[] ConsumeAll(AsyncPipeReaderInput input)
{
var consumed = new List<byte>();
input.Initialize(out var buffer, out var position, out var bufferLength);
while (true)
{
while (position < bufferLength)
{
consumed.Add(buffer[position]);
position++;
}
if (!input.TryAdvanceSegment(ref buffer, ref position, ref bufferLength, 1))
break;
}
input.Release();
return consumed.ToArray();
}
private static TestParentWithDateTimeItemCollection CreatePayload(int itemCount)
{
var now = DateTime.UtcNow;
var items = new List<TestEntityWithDateTimeAndInt>(itemCount);
for (var i = 0; i < itemCount; i++)
{
items.Add(new TestEntityWithDateTimeAndInt
{
Id = i + 1,
IntValue = i * 3,
Created = now.AddMinutes(-i),
Modified = now.AddMinutes(i),
StatusCode = i % 4,
Name = $"item-{i}"
});
}
return new TestParentWithDateTimeItemCollection
{
Id = 11,
Name = "real-parallel-pipeline",
Created = now,
Items = items
};
}
private static void AssertPayloadEquals(TestParentWithDateTimeItemCollection expected, TestParentWithDateTimeItemCollection actual)
{
Assert.AreEqual(expected.Id, actual.Id);
Assert.AreEqual(expected.Name, actual.Name);
Assert.AreEqual(expected.Created, actual.Created);
Assert.IsNotNull(expected.Items);
Assert.IsNotNull(actual.Items);
Assert.AreEqual(expected.Items.Count, actual.Items.Count);
for (var i = 0; i < expected.Items.Count; i++)
{
var e = expected.Items[i];
var a = actual.Items[i];
Assert.AreEqual(e.Id, a.Id);
Assert.AreEqual(e.IntValue, a.IntValue);
Assert.AreEqual(e.Created, a.Created);
Assert.AreEqual(e.Modified, a.Modified);
Assert.AreEqual(e.StatusCode, a.StatusCode);
Assert.AreEqual(e.Name, a.Name);
}
}
}

View File

@ -0,0 +1,206 @@
using AyCode.Core.Serializers.Binaries;
using AyCode.Core.Tests.TestModels;
namespace AyCode.Core.Tests.Serialization;
[TestClass]
public class AcBinarySerializerSGenNullComplexPropertyTests
{
[TestMethod]
[DataRow(true, true)]
[DataRow(true, false)]
[DataRow(false, false)]
[DataRow(false, true)]
public void Serialize_SGenComplexPropertyNull_DoesNotThrow_AndRoundTripsAsNull(bool useSgen, bool fastMode)
{
var model = new SGenNullComplexParent
{
Id = 7,
Customer = null!,
Note = "regression"
};
var options = fastMode ? AcBinarySerializerOptions.FastMode: AcBinarySerializerOptions.Default;
options.UseGeneratedCode = useSgen;
var bytes = AcBinarySerializer.Serialize(model, options);
var roundTrip = AcBinaryDeserializer.Deserialize<SGenNullComplexParent>(bytes, options);
Assert.IsNotNull(roundTrip);
Assert.AreEqual(model.Id, roundTrip.Id);
Assert.AreEqual(model.Note, roundTrip.Note);
Assert.IsNull(roundTrip.Customer,
"complex reference property must round-trip as null when source was null " +
"(regression for SGen WriteObjectGenerated fallback else-branch null-check)");
Assert.IsTrue(System.Array.IndexOf(bytes, (byte)BinaryTypeCode.PropertySkip) >= 0,
"writer must emit PropertySkip marker on the null Customer slot " +
"(deeper verification: confirms the fix took the PropertySkip path, " +
"not an unrelated null-safe code path)");
}
[TestMethod]
[DataRow(true, true)]
[DataRow(true, false)]
[DataRow(false, false)]
[DataRow(false, true)]
public void Serialize_SGenComplexPropertyNonNull_RoundTripsCorrectly(bool useSgen, bool fastMode)
{
var model = new SGenNullComplexParent
{
Id = 13,
Customer = new NonGeneratedComplexCustomer { Id = 42, Name = "child" },
Note = "positive"
};
var options = fastMode ? AcBinarySerializerOptions.FastMode: AcBinarySerializerOptions.Default;
options.UseGeneratedCode = useSgen;
var bytes = AcBinarySerializer.Serialize(model, options);
var roundTrip = AcBinaryDeserializer.Deserialize<SGenNullComplexParent>(bytes, options);
Assert.IsNotNull(roundTrip);
Assert.AreEqual(model.Id, roundTrip.Id);
Assert.AreEqual(model.Note, roundTrip.Note);
Assert.IsNotNull(roundTrip.Customer,
"non-null complex reference property must round-trip (null-check fix must not break the non-null path)");
Assert.AreEqual(model.Customer.Id, roundTrip.Customer.Id);
Assert.AreEqual(model.Customer.Name, roundTrip.Customer.Name);
}
[TestMethod]
[DataRow(true, true)]
[DataRow(true, false)]
[DataRow(false, false)]
[DataRow(false, true)]
public void Serialize_SGenCollectionPropertyNull_DoesNotThrow_AndRoundTripsAsNull(bool useSgen, bool fastMode)
{
var model = new SGenNullCollectionParent
{
Id = 11,
Items = null!,
Note = "regression-collection"
};
var options = fastMode ? AcBinarySerializerOptions.FastMode: AcBinarySerializerOptions.Default;
options.UseGeneratedCode = useSgen;
var bytes = AcBinarySerializer.Serialize(model, options);
var roundTrip = AcBinaryDeserializer.Deserialize<SGenNullCollectionParent>(bytes, options);
Assert.IsNotNull(roundTrip);
Assert.AreEqual(model.Id, roundTrip.Id);
Assert.AreEqual(model.Note, roundTrip.Note);
Assert.IsNull(roundTrip.Items,
"collection property (with non-SGen element type) must round-trip as null when source was null " +
"(regression for SGen Collection fallback WriteValueGenerated else-branch null-check)");
Assert.IsTrue(System.Array.IndexOf(bytes, (byte)BinaryTypeCode.PropertySkip) >= 0,
"writer must emit PropertySkip marker on the null Items slot " +
"(confirms the fix took the PropertySkip path, not an unrelated null-safe code path)");
}
[TestMethod]
[DataRow(true, true)]
[DataRow(true, false)]
[DataRow(false, false)]
[DataRow(false, true)]
public void Serialize_SGenCollectionPropertyNonNull_RoundTripsCorrectly(bool useSgen, bool fastMode)
{
var model = new SGenNullCollectionParent
{
Id = 17,
Items = new List<NonGeneratedComplexCustomer>
{
new() { Id = 1, Name = "first" },
new() { Id = 2, Name = "second" }
},
Note = "positive-collection"
};
var options = fastMode ? AcBinarySerializerOptions.FastMode: AcBinarySerializerOptions.Default;
options.UseGeneratedCode = useSgen;
var bytes = AcBinarySerializer.Serialize(model, options);
var roundTrip = AcBinaryDeserializer.Deserialize<SGenNullCollectionParent>(bytes, options);
Assert.IsNotNull(roundTrip);
Assert.AreEqual(model.Id, roundTrip.Id);
Assert.AreEqual(model.Note, roundTrip.Note);
Assert.IsNotNull(roundTrip.Items,
"non-null collection property must round-trip (null-check fix must not break the non-null path)");
Assert.AreEqual(model.Items.Count, roundTrip.Items.Count);
Assert.AreEqual(model.Items[0].Id, roundTrip.Items[0].Id);
Assert.AreEqual(model.Items[0].Name, roundTrip.Items[0].Name);
Assert.AreEqual(model.Items[1].Id, roundTrip.Items[1].Id);
Assert.AreEqual(model.Items[1].Name, roundTrip.Items[1].Name);
}
[TestMethod]
[DataRow(true, true)]
[DataRow(true, false)]
[DataRow(false, false)]
[DataRow(false, true)]
public void Serialize_SGenDictionaryPropertyNull_DoesNotThrow_AndRoundTripsAsNull(bool useSgen, bool fastMode)
{
var model = new SGenNullDictionaryParent
{
Id = 23,
Mapping = null!,
Note = "regression-dictionary"
};
var options = fastMode ? AcBinarySerializerOptions.FastMode: AcBinarySerializerOptions.Default;
options.UseGeneratedCode = useSgen;
var bytes = AcBinarySerializer.Serialize(model, options);
var roundTrip = AcBinaryDeserializer.Deserialize<SGenNullDictionaryParent>(bytes, options);
Assert.IsNotNull(roundTrip);
Assert.AreEqual(model.Id, roundTrip.Id);
Assert.AreEqual(model.Note, roundTrip.Note);
Assert.IsNull(roundTrip.Mapping,
"dictionary property must round-trip as null when source was null " +
"(pins EmitDirectDictionaryWrite line ~1037 null-check against future regression)");
Assert.IsTrue(System.Array.IndexOf(bytes, (byte)BinaryTypeCode.PropertySkip) >= 0,
"writer must emit PropertySkip marker on the null Mapping slot " +
"(confirms the PropertySkip path, not an unrelated null-safe code path)");
}
[TestMethod]
[DataRow(true, true)]
[DataRow(true, false)]
[DataRow(false, false)]
[DataRow(false, true)]
public void Serialize_SGenDictionaryPropertyNonNull_RoundTripsCorrectly(bool useSgen, bool fastMode)
{
var model = new SGenNullDictionaryParent
{
Id = 29,
Mapping = new Dictionary<string, NonGeneratedComplexCustomer>
{
["alpha"] = new() { Id = 1, Name = "first" },
["beta"] = new() { Id = 2, Name = "second" }
},
Note = "positive-dictionary"
};
var options = fastMode ? AcBinarySerializerOptions.FastMode: AcBinarySerializerOptions.Default;
options.UseGeneratedCode = useSgen;
var bytes = AcBinarySerializer.Serialize(model, options);
var roundTrip = AcBinaryDeserializer.Deserialize<SGenNullDictionaryParent>(bytes, options);
Assert.IsNotNull(roundTrip);
Assert.AreEqual(model.Id, roundTrip.Id);
Assert.AreEqual(model.Note, roundTrip.Note);
Assert.IsNotNull(roundTrip.Mapping,
"non-null dictionary property must round-trip (null-check pin must not break the non-null path)");
Assert.AreEqual(model.Mapping.Count, roundTrip.Mapping.Count);
Assert.AreEqual(model.Mapping["alpha"].Id, roundTrip.Mapping["alpha"].Id);
Assert.AreEqual(model.Mapping["alpha"].Name, roundTrip.Mapping["alpha"].Name);
Assert.AreEqual(model.Mapping["beta"].Id, roundTrip.Mapping["beta"].Id);
Assert.AreEqual(model.Mapping["beta"].Name, roundTrip.Mapping["beta"].Name);
}
}

View File

@ -0,0 +1,221 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using AyCode.Core.Serializers;
using AyCode.Core.Serializers.Binaries;
using AyCode.Core.Tests.TestModels;
namespace AyCode.Core.Tests.Serialization;
[TestClass]
public class AcBinarySerializerSGenRuntimeCompatibilityTests
{
private static readonly JsonSerializerOptions StjOptions = new()
{
ReferenceHandler = ReferenceHandler.IgnoreCycles
};
[TestMethod]
public void SerializeWithSGen_DeserializeWithRuntime_LargeAndDeepData_MultipleOptions_RoundTrip()
{
foreach (var dataSet in GetTargetDataSets())
{
foreach (var optionFactory in GetOptionFactories())
{
var serializeOptions = optionFactory();
serializeOptions.UseGeneratedCode = true;
var deserializeOptions = optionFactory();
deserializeOptions.UseGeneratedCode = false;
var expectedJson = JsonSerializer.Serialize(dataSet.Order, StjOptions);
var bytes = AcBinarySerializer.Serialize(dataSet.Order, serializeOptions);
var roundTrip = AcBinaryDeserializer.Deserialize<TestOrder_All_True>(bytes, deserializeOptions);
var actualJson = JsonSerializer.Serialize(roundTrip, StjOptions);
Assert.AreEqual(expectedJson, actualJson, $"STJ mismatch. Dataset={dataSet.Name}, WireMode={serializeOptions.WireMode}, BaseOptions={serializeOptions.ReferenceHandling}/{serializeOptions.UseStringInterning}");
AssertOrderEquivalent(dataSet.Order, roundTrip, $"Dataset={dataSet.Name}, WireMode={serializeOptions.WireMode}, BaseOptions={serializeOptions.ReferenceHandling}/{serializeOptions.UseStringInterning}");
}
}
}
[TestMethod]
public void SerializeWithRuntime_DeserializeWithSGen_LargeAndDeepData_MultipleOptions_RoundTrip()
{
foreach (var dataSet in GetTargetDataSets())
{
foreach (var optionFactory in GetOptionFactories())
{
var serializeOptions = optionFactory();
serializeOptions.UseGeneratedCode = false;
var deserializeOptions = optionFactory();
deserializeOptions.UseGeneratedCode = true;
var expectedJson = JsonSerializer.Serialize(dataSet.Order, StjOptions);
var bytes = AcBinarySerializer.Serialize(dataSet.Order, serializeOptions);
var roundTrip = AcBinaryDeserializer.Deserialize<TestOrder_All_True>(bytes, deserializeOptions);
var actualJson = JsonSerializer.Serialize(roundTrip, StjOptions);
Assert.AreEqual(expectedJson, actualJson, $"STJ mismatch. Dataset={dataSet.Name}, WireMode={serializeOptions.WireMode}, BaseOptions={serializeOptions.ReferenceHandling}/{serializeOptions.UseStringInterning}");
AssertOrderEquivalent(dataSet.Order, roundTrip, $"Dataset={dataSet.Name}, WireMode={serializeOptions.WireMode}, BaseOptions={serializeOptions.ReferenceHandling}/{serializeOptions.UseStringInterning}");
}
}
}
/// <summary>
/// Regression test: SGen ↔ SGen round-trip with non-ASCII multi-byte ProductName above the
/// StringSmall threshold (utf8Len &gt; 255 byte). Engages the StringMedium tier (marker 94,
/// fixed-width header [marker:1][charLen:16][utf8Len:16][bytes]). After ProductName in
/// TestOrderItemBase come Quantity (int) + UnitPrice (decimal) — any writer/reader byte-count
/// asymmetry in the StringMedium path surfaces as a UnitPrice corruption (DECIMAL_DRIFT) or
/// Quantity skew. The [AcStringIntern(true)] attribute on ProductName means the first occurrence
/// emits StringInternFirstMedium (marker 105) for the InternFirst tier.
/// </summary>
[TestMethod]
public void Serialize_MediumStringUtf8_OnProductName_SGenRoundTrip()
{
// 300 chars × 2 byte (Hungarian 'á' = 2 byte UTF-8) = 600 byte UTF-8 → StringMedium (or
// StringInternFirstMedium for the first occurrence under interning).
var mediumUtf8 = new string('á', 300);
foreach (var optionFactory in GetOptionFactories())
{
var options = optionFactory();
options.UseGeneratedCode = true;
var order = BenchmarkTestDataProvider
.CreateTestDataSets()
.Cast<TestDataSet<TestOrder_All_True>>()
.First(x => x.Name.StartsWith("Small")).Order;
foreach (var item in order.Items) item.ProductName = mediumUtf8;
var bytes = AcBinarySerializer.Serialize(order, options);
var roundTrip = AcBinaryDeserializer.Deserialize<TestOrder_All_True>(bytes, options);
AssertOrderEquivalent(order, roundTrip,
$"WireMode={options.WireMode}, Refs={options.ReferenceHandling}, Interning={options.UseStringInterning}");
}
}
/// <summary>
/// Regression test: SGen ↔ SGen round-trip with pure ASCII ProductName above the FixStrAscii inline
/// limit (&gt;31 chars). Engages StringAscii (marker 167) — writer detects ASCII via
/// bytesWritten == charLength post-encode, reader byte→char widens directly without UTF-8 decode.
/// Same drift-surface as the UTF-8 variant: UnitPrice / Quantity after ProductName in TestOrderItemBase.
/// </summary>
[TestMethod]
public void Serialize_MediumStringAscii_OnProductName_SGenRoundTrip()
{
// 500 chars × 1 byte = 500 byte ASCII → StringAscii (167) tier.
var mediumAscii = new string('X', 500);
foreach (var optionFactory in GetOptionFactories())
{
var options = optionFactory();
options.UseGeneratedCode = true;
var order = BenchmarkTestDataProvider
.CreateTestDataSets()
.Cast<TestDataSet<TestOrder_All_True>>()
.First(x => x.Name.StartsWith("Small")).Order;
foreach (var item in order.Items) item.ProductName = mediumAscii;
var bytes = AcBinarySerializer.Serialize(order, options);
var roundTrip = AcBinaryDeserializer.Deserialize<TestOrder_All_True>(bytes, options);
AssertOrderEquivalent(order, roundTrip,
$"WireMode={options.WireMode}, Refs={options.ReferenceHandling}, Interning={options.UseStringInterning}");
}
}
private static IEnumerable<TestDataSet<TestOrder_All_True>> GetTargetDataSets()
{
// SGen↔Runtime compatibility test depends on TestOrder_All_True graphs (the AssertOrderEquivalent
// signature + JSON canonicalisation are typed for _All_True). The bare-name BenchmarkTestDataProvider
// alias closes the generic provider on _All_True — Phase 1 benchmark uses the sibling
// BenchmarkTestDataProvider_All_False alias instead.
return BenchmarkTestDataProvider
.CreateTestDataSets()
.Cast<TestDataSet<TestOrder_All_True>>()
.Where(x => x.Name.StartsWith("Large") || x.Name.StartsWith("Deep"));
}
private static IEnumerable<Func<AcBinarySerializerOptions>> GetOptionFactories()
{
yield return static () =>
{
var options = AcBinarySerializerOptions.FastMode;
options.WireMode = WireMode.Compact;
return options;
};
yield return static () =>
{
var options = AcBinarySerializerOptions.FastMode;
options.WireMode = WireMode.Fast;
return options;
};
yield return static () =>
{
var options = AcBinarySerializerOptions.Default;
options.WireMode = WireMode.Compact;
return options;
};
}
private static void AssertOrderEquivalent(TestOrder_All_True expected, TestOrder_All_True? actual, string context)
{
Assert.IsNotNull(actual, context);
Assert.AreEqual(expected.Id, actual.Id, context);
Assert.AreEqual(expected.OrderNumber, actual.OrderNumber, context);
Assert.AreEqual(expected.Status, actual.Status, context);
Assert.AreEqual(expected.Items.Count, actual.Items.Count, context);
for (var itemIndex = 0; itemIndex < expected.Items.Count; itemIndex++)
{
var expectedItem = expected.Items[itemIndex];
var actualItem = actual.Items[itemIndex];
Assert.AreEqual(expectedItem.Id, actualItem.Id, context);
Assert.AreEqual(expectedItem.ProductName, actualItem.ProductName, context);
Assert.AreEqual(expectedItem.Status, actualItem.Status, context);
Assert.AreEqual(expectedItem.Pallets.Count, actualItem.Pallets.Count, context);
for (var palletIndex = 0; palletIndex < expectedItem.Pallets.Count; palletIndex++)
{
var expectedPallet = expectedItem.Pallets[palletIndex];
var actualPallet = actualItem.Pallets[palletIndex];
Assert.AreEqual(expectedPallet.Id, actualPallet.Id, context);
Assert.AreEqual(expectedPallet.PalletCode, actualPallet.PalletCode, context);
Assert.AreEqual(expectedPallet.Measurements.Count, actualPallet.Measurements.Count, context);
for (var measurementIndex = 0; measurementIndex < expectedPallet.Measurements.Count; measurementIndex++)
{
var expectedMeasurement = expectedPallet.Measurements[measurementIndex];
var actualMeasurement = actualPallet.Measurements[measurementIndex];
Assert.AreEqual(expectedMeasurement.Id, actualMeasurement.Id, context);
Assert.AreEqual(expectedMeasurement.Name, actualMeasurement.Name, context);
Assert.AreEqual(expectedMeasurement.Points.Count, actualMeasurement.Points.Count, context);
for (var pointIndex = 0; pointIndex < expectedMeasurement.Points.Count; pointIndex++)
{
var expectedPoint = expectedMeasurement.Points[pointIndex];
var actualPoint = actualMeasurement.Points[pointIndex];
Assert.AreEqual(expectedPoint.Id, actualPoint.Id, context);
Assert.AreEqual(expectedPoint.Label, actualPoint.Label, context);
}
}
}
}
}
}

View File

@ -22,7 +22,7 @@ public class AcExpressionNodeSerializationTests
public void AcJsonSerializer_WithAcExpressionNode_RoundTrip_Works()
{
// Arrange - Create an expression with a constant value
System.Linq.Expressions.Expression<Func<TestOrderItem, bool>> filterExpression =
System.Linq.Expressions.Expression<Func<TestOrderItem_All_True, bool>> filterExpression =
item => item.Quantity > 5;
var expressionNode = AcExpressionConverter.ToNode(filterExpression);
@ -39,11 +39,11 @@ public class AcExpressionNodeSerializationTests
Assert.IsNotNull(deserialized, "Deserialized node should not be null");
// Rebuild and test
var rebuiltExpression = AcExpressionRebuilder.FromNode<TestOrderItem, bool>(deserialized);
var rebuiltExpression = AcExpressionRebuilder.FromNode<TestOrderItem_All_True, bool>(deserialized);
var compiled = rebuiltExpression.Compile();
var matchingItem = new TestOrderItem { Id = 1, Quantity = 10 };
var nonMatchingItem = new TestOrderItem { Id = 2, Quantity = 3 };
var matchingItem = new TestOrderItem_All_True { Id = 1, Quantity = 10 };
var nonMatchingItem = new TestOrderItem_All_True { Id = 2, Quantity = 3 };
Assert.IsTrue(compiled(matchingItem), "Matching item should pass filter");
Assert.IsFalse(compiled(nonMatchingItem), "Non-matching item should fail filter");
@ -121,7 +121,7 @@ public class AcExpressionNodeSerializationTests
public void AcBinarySerializer_WithAcExpressionNode_RoundTrip_Works()
{
// Arrange
System.Linq.Expressions.Expression<Func<TestOrderItem, bool>> filterExpression =
System.Linq.Expressions.Expression<Func<TestOrderItem_All_True, bool>> filterExpression =
item => item.Quantity > 5;
var originalNode = AcExpressionConverter.ToNode(filterExpression);
@ -137,11 +137,11 @@ public class AcExpressionNodeSerializationTests
Assert.AreEqual(originalNode.NodeType, deserialized.NodeType);
// Rebuild and test
var rebuiltExpression = AcExpressionRebuilder.FromNode<TestOrderItem, bool>(deserialized);
var rebuiltExpression = AcExpressionRebuilder.FromNode<TestOrderItem_All_True, bool>(deserialized);
var compiled = rebuiltExpression.Compile();
var matchingItem = new TestOrderItem { Id = 1, Quantity = 10 };
var nonMatchingItem = new TestOrderItem { Id = 2, Quantity = 3 };
var matchingItem = new TestOrderItem_All_True { Id = 1, Quantity = 10 };
var nonMatchingItem = new TestOrderItem_All_True { Id = 2, Quantity = 3 };
Assert.IsTrue(compiled(matchingItem), "Matching item should pass filter");
Assert.IsFalse(compiled(nonMatchingItem), "Non-matching item should fail filter");
@ -183,7 +183,7 @@ public class AcExpressionNodeSerializationTests
{
// Arrange - Expression with captured decimal: item => item.UnitPrice > 99.99m
var minPrice = 99.99m;
System.Linq.Expressions.Expression<Func<TestOrderItem, bool>> filterExpression =
System.Linq.Expressions.Expression<Func<TestOrderItem_All_True, bool>> filterExpression =
item => item.UnitPrice > minPrice;
var originalNode = AcExpressionConverter.ToNode(filterExpression);
@ -195,11 +195,11 @@ public class AcExpressionNodeSerializationTests
// Assert - Rebuild and verify it still works with decimal comparison
Assert.IsNotNull(deserializedNode);
var rebuiltExpression = AcExpressionRebuilder.FromNode<TestOrderItem, bool>(deserializedNode);
var rebuiltExpression = AcExpressionRebuilder.FromNode<TestOrderItem_All_True, bool>(deserializedNode);
var compiledFilter = rebuiltExpression.Compile();
var expensiveItem = new TestOrderItem { UnitPrice = 150m };
var cheapItem = new TestOrderItem { UnitPrice = 50m };
var expensiveItem = new TestOrderItem_All_True { UnitPrice = 150m };
var cheapItem = new TestOrderItem_All_True { UnitPrice = 50m };
Assert.IsTrue(compiledFilter(expensiveItem), "Expensive item should pass filter");
Assert.IsFalse(compiledFilter(cheapItem), "Cheap item should fail filter");
@ -212,7 +212,7 @@ public class AcExpressionNodeSerializationTests
public void AcBinarySerializer_WithEnumValue_PreservesType()
{
// Arrange - Expression with enum comparison
System.Linq.Expressions.Expression<Func<TestOrderItem, bool>> filterExpression =
System.Linq.Expressions.Expression<Func<TestOrderItem_All_True, bool>> filterExpression =
item => item.Status == TestStatus.Completed;
var originalNode = AcExpressionConverter.ToNode(filterExpression);
@ -224,11 +224,11 @@ public class AcExpressionNodeSerializationTests
// Assert
Assert.IsNotNull(deserializedNode);
var rebuiltExpression = AcExpressionRebuilder.FromNode<TestOrderItem, bool>(deserializedNode);
var rebuiltExpression = AcExpressionRebuilder.FromNode<TestOrderItem_All_True, bool>(deserializedNode);
var compiledFilter = rebuiltExpression.Compile();
var completedItem = new TestOrderItem { Status = TestStatus.Completed };
var pendingItem = new TestOrderItem { Status = TestStatus.Pending };
var completedItem = new TestOrderItem_All_True { Status = TestStatus.Completed };
var pendingItem = new TestOrderItem_All_True { Status = TestStatus.Pending };
Assert.IsTrue(compiledFilter(completedItem), "Completed item should pass filter");
Assert.IsFalse(compiledFilter(pendingItem), "Pending item should fail filter");

View File

@ -46,25 +46,25 @@ public class AcJsonSerializerIIdReferenceTests
public void SameInstance_Json_SerializeAndDeserialize()
{
// Arrange: SAME instance used 4 times
var sharedTag = new SharedTag { Id = 1, Name = "ImportantTag", Color = "#FF0000" };
var sharedTag = new SharedTag_All_True { Id = 1, Name = "ImportantTag", Color = "#FF0000" };
var order = new TestOrder
var order = new TestOrder_All_True
{
Id = 1,
OrderNumber = "ORD-001",
PrimaryTag = sharedTag,
Items =
[
new TestOrderItem { Id = 1, ProductName = "Product-A", Tag = sharedTag },
new TestOrderItem { Id = 2, ProductName = "Product-B", Tag = sharedTag },
new TestOrderItem { Id = 3, ProductName = "Product-C", Tag = sharedTag }
new TestOrderItem_All_True { Id = 1, ProductName = "Product-A", Tag = sharedTag },
new TestOrderItem_All_True { Id = 2, ProductName = "Product-B", Tag = sharedTag },
new TestOrderItem_All_True { Id = 3, ProductName = "Product-C", Tag = sharedTag }
]
};
// Act
var json = order.ToJson();
Console.WriteLine(json);
var result = json.JsonTo<TestOrder>();
var result = json.JsonTo<TestOrder_All_True>();
// Assert 1: JSON contains $ref markers (reference handling is active)
var refCount = CountOccurrences(json, "{\"$ref\":\"1\"}");
@ -118,43 +118,43 @@ public class AcJsonSerializerIIdReferenceTests
{
// Arrange: DIFFERENT instances but SAME IId.Id
// CRITICAL: Multiple DIFFERENT TYPES all have Id=1 - must not be confused!
var order = new TestOrder
var order = new TestOrder_All_True
{
Id = 1,
OrderNumber = "ORD-001",
// All three types have Id=1 - tests (Type, Id) keying, not just Id
PrimaryTag = new SharedTag { Id = 1, Name = "Tag_Id1", Color = "#FF0000" },
Owner = new SharedUser { Id = 1, Username = "User_Id1", Email = "user1@test.com" },
Category = new SharedCategory { Id = 1, Name = "Category_Id1", SortOrder = 10 },
PrimaryTag = new SharedTag_All_True { Id = 1, Name = "Tag_Id1", Color = "#FF0000" },
Owner = new SharedUser_All_True { Id = 1, Username = "User_Id1", Email = "user1@test.com" },
Category = new SharedCategory_All_True { Id = 1, Name = "Category_Id1", SortOrder = 10 },
Items =
[
new TestOrderItem
new TestOrderItem_All_True
{
Id = 1,
ProductName = "Product-A",
Tag = new SharedTag { Id = 1, Name = "Tag_Id1", Color = "#FF0000" },
Assignee = new SharedUser { Id = 1, Username = "User_Id1", Email = "user1@test.com" }
Tag = new SharedTag_All_True { Id = 1, Name = "Tag_Id1", Color = "#FF0000" },
Assignee = new SharedUser_All_True { Id = 1, Username = "User_Id1", Email = "user1@test.com" }
},
new TestOrderItem
new TestOrderItem_All_True
{
Id = 2,
ProductName = "Product-B",
Tag = new SharedTag { Id = 1, Name = "Tag_Id1", Color = "#FF0000" },
Assignee = new SharedUser { Id = 1, Username = "User_Id1", Email = "user1@test.com" }
Tag = new SharedTag_All_True { Id = 1, Name = "Tag_Id1", Color = "#FF0000" },
Assignee = new SharedUser_All_True { Id = 1, Username = "User_Id1", Email = "user1@test.com" }
},
new TestOrderItem
new TestOrderItem_All_True
{
Id = 3,
ProductName = "Product-C",
Tag = new SharedTag { Id = 1, Name = "Tag_Id1", Color = "#FF0000" },
Assignee = new SharedUser { Id = 1, Username = "User_Id1", Email = "user1@test.com" }
Tag = new SharedTag_All_True { Id = 1, Name = "Tag_Id1", Color = "#FF0000" },
Assignee = new SharedUser_All_True { Id = 1, Username = "User_Id1", Email = "user1@test.com" }
}
]
};
// Act
var json = order.ToJson();
var result = json.JsonTo<TestOrder>();
var result = json.JsonTo<TestOrder_All_True>();
// Assert 1: Check if $ref is used (IId-based deduplication active)
var refCount = CountOccurrences(json, "\"$ref\"");
@ -208,11 +208,11 @@ public class AcJsonSerializerIIdReferenceTests
// Assert 3: Reference identity - same TYPE with same Id should be same reference
// Tags with Id=1 should all be same reference
Assert.AreSame(result.PrimaryTag, result.Items[0].Tag,
"CRITICAL: Item[0].Tag should be same reference as PrimaryTag (same SharedTag.Id=1)");
"CRITICAL: Item[0].Tag should be same reference as PrimaryTag (same SharedTag_All_True.Id=1)");
Assert.AreSame(result.PrimaryTag, result.Items[1].Tag,
"CRITICAL: Item[1].Tag should be same reference as PrimaryTag (same SharedTag.Id=1)");
"CRITICAL: Item[1].Tag should be same reference as PrimaryTag (same SharedTag_All_True.Id=1)");
Assert.AreSame(result.PrimaryTag, result.Items[2].Tag,
"CRITICAL: Item[2].Tag should be same reference as PrimaryTag (same SharedTag.Id=1)");
"CRITICAL: Item[2].Tag should be same reference as PrimaryTag (same SharedTag_All_True.Id=1)");
// Users with Id=1 should all be same reference
Assert.AreSame(result.Owner, result.Items[0].Assignee,
@ -238,36 +238,36 @@ public class AcJsonSerializerIIdReferenceTests
public void DifferentInstances_SameIId_SmallerJsonWithDataIntegrity()
{
// Arrange: 10 different instances with SAME IId
var orderWithSameIId = new TestOrder
var orderWithSameIId = new TestOrder_All_True
{
Id = 1,
OrderNumber = "SAME-IID",
Items = Enumerable.Range(1, 10).Select(i => new TestOrderItem
Items = Enumerable.Range(1, 10).Select(i => new TestOrderItem_All_True
{
Id = i,
ProductName = $"Product-{i}",
Assignee = new SharedUser { Id = 1, Username = "shared_user_name", Email = "shared@test.com" }
Assignee = new SharedUser_All_True { Id = 1, Username = "shared_user_name", Email = "shared@test.com" }
}).ToList()
};
// Arrange: 10 different instances with DIFFERENT IIds
var orderWithDifferentIIds = new TestOrder
var orderWithDifferentIIds = new TestOrder_All_True
{
Id = 1,
OrderNumber = "DIFF-IID",
Items = Enumerable.Range(1, 10).Select(i => new TestOrderItem
Items = Enumerable.Range(1, 10).Select(i => new TestOrderItem_All_True
{
Id = i,
ProductName = $"Product-{i}",
Assignee = new SharedUser { Id = i * 100, Username = "unique_user_name", Email = "unique@test.com" }
Assignee = new SharedUser_All_True { Id = i * 100, Username = "unique_user_name", Email = "unique@test.com" }
}).ToList()
};
// Act
var sameIIdJson = orderWithSameIId.ToJson();
var diffIIdJson = orderWithDifferentIIds.ToJson();
var sameIIdResult = sameIIdJson.JsonTo<TestOrder>();
var diffIIdResult = diffIIdJson.JsonTo<TestOrder>();
var sameIIdResult = sameIIdJson.JsonTo<TestOrder_All_True>();
var diffIIdResult = diffIIdJson.JsonTo<TestOrder_All_True>();
// Assert 1: Size comparison
Console.WriteLine($"Same IId JSON size: {sameIIdJson.Length} chars");
@ -416,7 +416,7 @@ public class AcJsonSerializerIIdReferenceTests
[TestMethod]
public void SharedCategory_DataIntegrity()
{
var categories = new List<SharedCategory>
var categories = new List<SharedCategory_All_True>
{
new() { Id = 1, Name = "Category1", SortOrder = 1, IsDefault = true },
new() { Id = 2, Name = "Category2", SortOrder = 2, ParentCategoryId = 1 },
@ -424,7 +424,7 @@ public class AcJsonSerializerIIdReferenceTests
};
var json = categories.ToJson();
var result = json.JsonTo<List<SharedCategory>>();
var result = json.JsonTo<List<SharedCategory_All_True>>();
Assert.IsNotNull(result);
Assert.AreEqual(3, result.Count);

View File

@ -0,0 +1,63 @@
using AyCode.Core.Serializers.Binaries;
using System;
using System.IO.Pipelines;
using System.Threading;
using System.Threading.Tasks;
namespace AyCode.Core.Tests.Serialization;
/// <summary>
/// Test/benchmark-only extension methods for populating <see cref="AsyncPipeReaderInput"/>
/// from <see cref="System.IO.Pipelines.PipeReader"/>-backed transports (NamedPipe, FileStream,
/// custom pipe sources).
///
/// <para><b>Why test-only:</b> in real production, the consuming application already has its own
/// reader-task that reads from the pipe and pushes bytes via <c>AsyncPipeReaderInput.Feed</c>
/// — providing this drain extension publicly would duplicate that responsibility and confuse
/// the canonical push-pattern. The extension is kept here for unit-test scaffolding and the
/// streaming benchmark; production NuGet consumers should write their own drain logic in their
/// own reader-task following the application's threading model.</para>
/// </summary>
public static class AsyncPipeReaderInputExtensions
{
/// <summary>
/// Drains a <see cref="PipeReader"/> end-to-end into the <see cref="AsyncPipeReaderInput"/>:
/// calls <see cref="AsyncPipeReaderInput.Feed"/> on each segment and
/// <see cref="AsyncPipeReaderInput.Complete"/> when the pipe completes.
///
/// <para>Typical usage (test-only): NamedPipe IPC and FileStream-via-PipeReader transports
/// schedule this on a background task while the deserialization context reads from the same
/// input on another thread.</para>
///
/// <para><see cref="AsyncPipeReaderInput.Complete"/> is invoked in a <c>finally</c> block —
/// ensures the consumer always wakes up even if the pipe read throws or the operation is
/// cancelled. Exceptions (including <see cref="OperationCanceledException"/>) propagate to
/// the caller after <c>Complete</c> runs.</para>
/// </summary>
/// <param name="input">The receive-side input to feed.</param>
/// <param name="reader">The pipe reader to drain.</param>
/// <param name="cancellationToken">Optional cancellation token.</param>
/// <exception cref="ArgumentNullException">If <paramref name="input"/> or <paramref name="reader"/> is <c>null</c>.</exception>
public static async Task DrainFromAsync(this AsyncPipeReaderInput input, PipeReader reader, CancellationToken cancellationToken = default)
{
if (input is null) throw new ArgumentNullException(nameof(input));
if (reader is null) throw new ArgumentNullException(nameof(reader));
try
{
while (true)
{
var result = await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
foreach (var segment in result.Buffer) input.Feed(segment.Span);
reader.AdvanceTo(result.Buffer.End);
if (result.IsCompleted) break;
}
}
finally
{
input.Complete();
}
}
}

View File

@ -13,7 +13,7 @@ public class ChainReferenceDebugTest
// Test ChainReferenceTracker directly
var tracker = new AcSerializerCommon.ChainReferenceTracker();
var category = new SharedCategory { Id = 100, Name = "TestCategory" };
var category = new SharedCategory_All_True { Id = 100, Name = "TestCategory" };
// Register using reflection (like ThenPopulate does)
tracker.TryRegisterIIdObject(category);
@ -32,17 +32,17 @@ public class ChainReferenceDebugTest
[TestMethod]
public void DebugSimpleChainPopulate()
{
var list1 = new List<SharedCategory>();
var list2 = new List<SharedCategory>();
var list1 = new List<SharedCategory_All_True>();
var list2 = new List<SharedCategory_All_True>();
var serverData = new List<SharedCategory>
var serverData = new List<SharedCategory_All_True>
{
new() { Id = 1, Name = "Cat1", SortOrder = 10 }
};
var binary = serverData.ToBinary();
using var chain = binary.BinaryToChain<List<SharedCategory>>();
using var chain = binary.BinaryToChain<List<SharedCategory_All_True>>();
// First populate
chain.ThenPopulate(list1);

View File

@ -103,7 +103,7 @@ public class GeneratedSerializerIntegrationTests
public void GeneratedWriter_ComplexHierarchy_RoundTrip()
{
TestDataFactory.ResetIdCounter();
var sharedTag = TestDataFactory.CreateTag("SharedTag");
var sharedTag = TestDataFactory.CreateTag("SharedTag_All_True");
var sharedUser = TestDataFactory.CreateUser("shareduser");
var order = TestDataFactory.CreateOrder(
@ -116,7 +116,7 @@ public class GeneratedSerializerIntegrationTests
var options = AcBinarySerializerOptions.FastMode;
var bytes = AcBinarySerializer.Serialize(order, options);
var deserialized = AcBinaryDeserializer.Deserialize<TestOrder>(bytes, options);
var deserialized = AcBinaryDeserializer.Deserialize<TestOrder_All_True>(bytes, options);
Assert.IsNotNull(deserialized);
Assert.AreEqual(order.Id, deserialized.Id);

View File

@ -83,12 +83,12 @@ public class QuickBenchmark
Console.WriteLine($"[WARN] Deserialize: AcBinary is {deserRatio:F2}x slower");
}
private static TestOrder CreatePopulateTarget(TestOrder source)
private static TestOrder_All_True CreatePopulateTarget(TestOrder_All_True source)
{
var target = new TestOrder { Id = source.Id };
var target = new TestOrder_All_True { Id = source.Id };
foreach (var item in source.Items)
{
target.Items.Add(new TestOrderItem { Id = item.Id });
target.Items.Add(new TestOrderItem_All_True { Id = item.Id });
}
return target;
}
@ -105,7 +105,7 @@ public class QuickBenchmark
for (int i = 0; i < 10; i++)
{
var bytes = order.ToBinary();
var result = bytes.BinaryTo<TestOrder>();
var result = bytes.BinaryTo<TestOrder_All_True>();
}
// Measure serialize
@ -121,10 +121,10 @@ public class QuickBenchmark
// Measure deserialize
sw.Restart();
TestOrder? deserialized = null;
TestOrder_All_True? deserialized = null;
for (int i = 0; i < iterations; i++)
{
deserialized = serialized.BinaryTo<TestOrder>();
deserialized = serialized.BinaryTo<TestOrder_All_True>();
}
sw.Stop();
var deserializeMs = sw.Elapsed.TotalMilliseconds;
@ -143,7 +143,7 @@ public class QuickBenchmark
sw.Restart();
for (int i = 0; i < iterations; i++)
{
var _ = json.JsonTo<TestOrder>(jsonOptions);
var _ = json.JsonTo<TestOrder_All_True>(jsonOptions);
}
sw.Stop();
var jsonDeserializeMs = sw.Elapsed.TotalMilliseconds;
@ -234,9 +234,9 @@ public class QuickBenchmark
for (int i = 0; i < 20; i++)
{
var binBytes = AcBinarySerializer.Serialize(order, AcBinarySerializerOptions.Default);
var binResult = AcBinaryDeserializer.Deserialize<TestOrder>(binBytes);
var binResult = AcBinaryDeserializer.Deserialize<TestOrder_All_True>(binBytes);
var msgBytes = MessagePackSerializer.Serialize(order, MsgPackOptions);
var msgResult = MessagePackSerializer.Deserialize<TestOrder>(msgBytes, MsgPackOptions);
var msgResult = MessagePackSerializer.Deserialize<TestOrder_All_True>(msgBytes, MsgPackOptions);
}
const int iterations = DefaultIterations;
@ -263,20 +263,20 @@ public class QuickBenchmark
// === AcBinary Deserialize ===
sw.Restart();
TestOrder? acBinaryResult = null;
TestOrder_All_True? acBinaryResult = null;
for (int i = 0; i < iterations; i++)
{
acBinaryResult = AcBinaryDeserializer.Deserialize<TestOrder>(acBinaryData);
acBinaryResult = AcBinaryDeserializer.Deserialize<TestOrder_All_True>(acBinaryData);
}
sw.Stop();
var acBinaryDeserMs = sw.Elapsed.TotalMilliseconds;
// === MessagePack Deserialize ===
sw.Restart();
TestOrder? msgPackResult = null;
TestOrder_All_True? msgPackResult = null;
for (int i = 0; i < iterations; i++)
{
msgPackResult = MessagePackSerializer.Deserialize<TestOrder>(msgPackData, MsgPackOptions);
msgPackResult = MessagePackSerializer.Deserialize<TestOrder_All_True>(msgPackData, MsgPackOptions);
}
sw.Stop();
var msgPackDeserMs = sw.Elapsed.TotalMilliseconds;
@ -382,7 +382,7 @@ public class QuickBenchmark
public void GetAnalyzeStringInternCandidatesLog()
{
TestDataFactory.ResetIdCounter();
var sharedTag = TestDataFactory.CreateTag("SharedTag");
var sharedTag = TestDataFactory.CreateTag("SharedTag_All_True");
var sharedUser = TestDataFactory.CreateUser("shareduser");
var sharedMeta = TestDataFactory.CreateMetadata("shared", withChild: true);
@ -413,7 +413,7 @@ public class QuickBenchmark
{
// Create test data with shared references
TestDataFactory.ResetIdCounter();
var sharedTag = TestDataFactory.CreateTag("SharedTag");
var sharedTag = TestDataFactory.CreateTag("SharedTag_All_True");
var sharedUser = TestDataFactory.CreateUser("shareduser");
var sharedMeta = TestDataFactory.CreateMetadata("shared", withChild: true);
@ -492,7 +492,7 @@ public class QuickBenchmark
// Create test data with shared references
TestDataFactory.ResetIdCounter();
var sharedTag = TestDataFactory.CreateTag("SharedTag");
var sharedTag = TestDataFactory.CreateTag("SharedTag_All_True");
var sharedUser = TestDataFactory.CreateUser("shareduser");
var sharedMeta = TestDataFactory.CreateMetadata("shared", withChild: true);
@ -532,10 +532,10 @@ public class QuickBenchmark
_ = MessagePackSerializer.Serialize(testOrder, MsgPackOptions);
Console.WriteLine("acBinaryWithRef");
_ = AcBinaryDeserializer.Deserialize<TestOrder>(acBinaryWithRef);
_ = AcBinaryDeserializer.Deserialize<TestOrder_All_True>(acBinaryWithRef);
Console.WriteLine("acBinaryNoRef");
_ = AcBinaryDeserializer.Deserialize<TestOrder>(acBinaryNoRef);
_ = MessagePackSerializer.Deserialize<TestOrder>(msgPackData, MsgPackOptions);
_ = AcBinaryDeserializer.Deserialize<TestOrder_All_True>(acBinaryNoRef);
_ = MessagePackSerializer.Deserialize<TestOrder_All_True>(msgPackData, MsgPackOptions);
}
// Wait for tiered JIT background compilation to complete
@ -573,19 +573,19 @@ public class QuickBenchmark
// === Deserialize WithRef ===
sw.Restart();
for (int i = 0; i < DefaultIterations; i++)
_ = AcBinaryDeserializer.Deserialize<TestOrder>(acBinaryWithRef);
_ = AcBinaryDeserializer.Deserialize<TestOrder_All_True>(acBinaryWithRef);
var acWithRefDeserMs = sw.Elapsed.TotalMilliseconds;
// === Deserialize NoRef ===
sw.Restart();
for (int i = 0; i < DefaultIterations; i++)
_ = AcBinaryDeserializer.Deserialize<TestOrder>(acBinaryNoRef);
_ = AcBinaryDeserializer.Deserialize<TestOrder_All_True>(acBinaryNoRef);
var acNoRefDeserMs = sw.Elapsed.TotalMilliseconds;
// === MessagePack Deserialize ===
sw.Restart();
for (int i = 0; i < DefaultIterations; i++)
_ = MessagePackSerializer.Deserialize<TestOrder>(msgPackData, MsgPackOptions);
_ = MessagePackSerializer.Deserialize<TestOrder_All_True>(msgPackData, MsgPackOptions);
var msgPackDeserMs = sw.Elapsed.TotalMilliseconds;
// === Populate (AcBinary only) ===
@ -602,7 +602,7 @@ public class QuickBenchmark
for (int i = 0; i < DefaultIterations; i++)
{
var target = CreatePopulateTarget(testOrder);
AcBinaryDeserializer.PopulateMerge(acBinaryNoRef.AsSpan(), target);
AcBinaryDeserializer.PopulateMerge(acBinaryNoRef, target);
}
var acMergeMs = sw.Elapsed.TotalMilliseconds;
@ -632,7 +632,7 @@ public class QuickBenchmark
// Create test data WITH shared references (to show WithRef advantage)
TestDataFactory.ResetIdCounter();
var sharedTag = TestDataFactory.CreateTag("SharedTag");
var sharedTag = TestDataFactory.CreateTag("SharedTag_All_True");
var sharedUser = TestDataFactory.CreateUser("shareduser");
var sharedMeta = TestDataFactory.CreateMetadata("shared", withChild: true);
@ -685,13 +685,13 @@ public class QuickBenchmark
// Deserialize WithRef
sw.Restart();
for (int i = 0; i < DefaultIterations; i++)
_ = AcBinaryDeserializer.Deserialize<TestOrder>(withRefData);
_ = AcBinaryDeserializer.Deserialize<TestOrder_All_True>(withRefData);
var withRefDeserMs = sw.Elapsed.TotalMilliseconds;
// Deserialize NoRef
sw.Restart();
for (int i = 0; i < DefaultIterations; i++)
_ = AcBinaryDeserializer.Deserialize<TestOrder>(noRefData);
_ = AcBinaryDeserializer.Deserialize<TestOrder_All_True>(noRefData);
var noRefDeserMs = sw.Elapsed.TotalMilliseconds;
PrintBanner("PERFORMANCE COMPARISON (ms)");
@ -709,8 +709,8 @@ public class QuickBenchmark
}
// Verify correctness
var resultWithRef = AcBinaryDeserializer.Deserialize<TestOrder>(withRefData);
var resultNoRef = AcBinaryDeserializer.Deserialize<TestOrder>(noRefData);
var resultWithRef = AcBinaryDeserializer.Deserialize<TestOrder_All_True>(withRefData);
var resultNoRef = AcBinaryDeserializer.Deserialize<TestOrder_All_True>(noRefData);
Assert.IsNotNull(resultWithRef);
Assert.IsNotNull(resultNoRef);
Assert.AreEqual(testOrder.Id, resultWithRef.Id);
@ -744,7 +744,7 @@ public class QuickBenchmark
AcBinaryDeserializer.Populate(binaryData, target);
//Console.WriteLine("PopulateMerge");
AcBinaryDeserializer.PopulateMerge(binaryData.AsSpan(), target);
AcBinaryDeserializer.PopulateMerge(binaryData, target);
}
Console.WriteLine($"Iterations: {DefaultIterations:N0}");
@ -755,7 +755,7 @@ public class QuickBenchmark
// Deserialize (creates new object)
sw.Restart();
for (int i = 0; i < DefaultIterations; i++)
_ = AcBinaryDeserializer.Deserialize<TestOrder>(binaryData);
_ = AcBinaryDeserializer.Deserialize<TestOrder_All_True>(binaryData);
var deserializeMs = sw.Elapsed.TotalMilliseconds;
// Populate (reuses existing object)
@ -772,7 +772,7 @@ public class QuickBenchmark
for (int i = 0; i < DefaultIterations; i++)
{
var target = CreatePopulateTarget(testOrder);
AcBinaryDeserializer.PopulateMerge(binaryData.AsSpan(), target);
AcBinaryDeserializer.PopulateMerge(binaryData, target);
}
var mergeMs = sw.Elapsed.TotalMilliseconds;
@ -782,7 +782,7 @@ public class QuickBenchmark
for (int i = 0; i < DefaultIterations; i++)
{
var target = CreatePopulateTarget(testOrder);
AcBinaryDeserializer.PopulateMerge(binaryData.AsSpan(), target, mergeWithRemoveOptions);
AcBinaryDeserializer.PopulateMerge(binaryData, target, mergeWithRemoveOptions);
}
var mergeWithRemoveMs = sw.Elapsed.TotalMilliseconds;

View File

@ -30,7 +30,3 @@ Comprehensive test suite for binary and JSON serialization: round-trips, referen
- **`GeneratedSerializerIntegrationTests.cs`** — Verifies generated writer types implement IGeneratedBinaryWriter.
- **`QuickBenchmark.cs`** — Performance comparison: AcBinary vs MessagePack.
- **`AcSerializerTestHelper.cs`** — Factory methods for test data.
---
> **LLM Maintenance:** If you modify code in this folder, update this README to reflect the changes. If you notice the README content does not match the current code, automatically update the README to match the code.

View File

@ -0,0 +1,531 @@
using System.Text;
using AyCode.Core.Serializers.Binaries;
namespace AyCode.Core.Tests.Serialization;
/// <summary>
/// Round-trip and correctness tests for <see cref="Utf8Transcoder"/>'s SIMD path tiers.
///
/// <para><b>Critical coverage</b>: each path tier (Vector512 / Vector256 / Vector128 / scalar) has
/// minimum-size and boundary-crossing inputs to ensure the path is actually exercised. The
/// Hungarian benchmark in <c>BenchmarkTestDataProvider</c> bails out of the AVX2 ASCII-prefix
/// path early (first non-ASCII byte at position 4-5), so it cannot validate the long-ASCII path
/// on its own. These tests fill that gap.</para>
/// </summary>
[TestClass]
public class Utf8TranscoderTests
{
private static readonly Encoding Utf8 = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true);
// ──────────────────────────────────────────────────────────────────────
// CountUtf8Chars — content classes
// ──────────────────────────────────────────────────────────────────────
[TestMethod]
public void CountUtf8Chars_AsciiOnly_MatchesStringLength()
{
var s = "Hello, World! This is plain ASCII.";
var bytes = Utf8.GetBytes(s);
Assert.AreEqual(s.Length, Utf8Transcoder.CountUtf8Chars(bytes));
}
[TestMethod]
public void CountUtf8Chars_HungarianMixed_MatchesStringLength()
{
var s = "árvíztűrő tükörfúrógép";
var bytes = Utf8.GetBytes(s);
Assert.AreEqual(s.Length, Utf8Transcoder.CountUtf8Chars(bytes));
}
[TestMethod]
public void CountUtf8Chars_CjkBmp_MatchesStringLength()
{
var s = "你好世界 こんにちは 안녕하세요";
var bytes = Utf8.GetBytes(s);
Assert.AreEqual(s.Length, Utf8Transcoder.CountUtf8Chars(bytes));
}
[TestMethod]
public void CountUtf8Chars_SupplementaryPlane_CountsSurrogatePairs()
{
// Each emoji is U+1F600-range (4-byte UTF-8 → 2-char surrogate pair in UTF-16)
var s = "😀😁😂🎉"; // 4 codepoints, but 8 chars in UTF-16
var bytes = Utf8.GetBytes(s);
Assert.AreEqual(s.Length, Utf8Transcoder.CountUtf8Chars(bytes));
Assert.AreEqual(8, s.Length, "Sanity check: each emoji is a surrogate pair");
}
[TestMethod]
public void CountUtf8Chars_MixedAllClasses_MatchesStringLength()
{
var s = "ASCII Magyar:árvíz CJK:你好 Emoji:😀";
var bytes = Utf8.GetBytes(s);
Assert.AreEqual(s.Length, Utf8Transcoder.CountUtf8Chars(bytes));
}
[TestMethod]
public void CountUtf8Chars_Empty_ReturnsZero()
{
Assert.AreEqual(0, Utf8Transcoder.CountUtf8Chars(ReadOnlySpan<byte>.Empty));
}
// ──────────────────────────────────────────────────────────────────────
// EncodeUtf8SinglePass + DecodeUtf8SinglePass — round-trip per content class
// ──────────────────────────────────────────────────────────────────────
[TestMethod]
public void EncodeDecode_AsciiShort_RoundTrip()
{
AssertRoundTrip("Hello");
}
[TestMethod]
public void EncodeDecode_AsciiExactly31Bytes_RoundTrip()
{
// Boundary: just below FixStr 31-byte limit, just below Vector256 threshold (32)
AssertRoundTrip(new string('a', 31));
}
[TestMethod]
public void EncodeDecode_AsciiExactly32Bytes_RoundTrip()
{
// Boundary: exactly Vector256<byte>.Count — Phase 1 AVX2 widen path triggers
// CRITICAL: this validates the Vector256.Widen upper-half store offset bug-fix.
AssertRoundTrip(new string('a', 32));
}
[TestMethod]
public void EncodeDecode_AsciiLong_64Bytes_RoundTrip()
{
// Boundary: Vector512 threshold for the encoder; 2× Vector256 iter for the decoder
AssertRoundTrip(new string('x', 64));
}
[TestMethod]
public void EncodeDecode_AsciiVeryLong_500Bytes_RoundTrip()
{
// Multi-iter SIMD widen on the decoder; AVX-512 path on capable hosts
AssertRoundTrip(new string('z', 500));
}
[TestMethod]
public void EncodeDecode_HungarianShort_RoundTrip()
{
AssertRoundTrip("Termék");
}
[TestMethod]
public void EncodeDecode_HungarianMedium_RoundTrip()
{
AssertRoundTrip("árvíztűrő tükörfúrógép");
}
[TestMethod]
public void EncodeDecode_HungarianLong_RoundTrip()
{
// Long enough to span multiple Vector128/256 iterations
AssertRoundTrip(string.Concat(Enumerable.Repeat("árvíztűrő tükörfúrógép ", 20)));
}
[TestMethod]
public void EncodeDecode_CjkBmp_RoundTrip()
{
AssertRoundTrip("你好世界 こんにちは 안녕하세요");
}
[TestMethod]
public void EncodeDecode_CjkBmpLong_RoundTrip()
{
AssertRoundTrip(string.Concat(Enumerable.Repeat("你好世界 ", 30)));
}
[TestMethod]
public void EncodeDecode_SupplementaryPlane_RoundTrip()
{
AssertRoundTrip("😀😁😂🎉🌟");
}
[TestMethod]
public void EncodeDecode_MixedAllClasses_RoundTrip()
{
AssertRoundTrip("Plain ASCII + Magyar (árvíztűrő) + CJK (你好世界) + Emoji (😀🎉)");
}
[TestMethod]
public void EncodeDecode_LongMixed_RoundTrip()
{
// Long mixed content forcing all SIMD tiers + scalar tail to engage
var sb = new StringBuilder();
for (var i = 0; i < 50; i++)
{
sb.Append("ASCII run-").Append(i).Append(" Magyar:árvíz CJK:你好 ");
}
AssertRoundTrip(sb.ToString());
}
[TestMethod]
public void EncodeDecode_BoundaryAsciiToHungarian_RoundTrip()
{
// ASCII prefix exactly at common boundaries, then non-ASCII switch
for (var asciiLen = 0; asciiLen <= 64; asciiLen++)
{
var s = new string('a', asciiLen) + "árvíz";
AssertRoundTrip(s, $"asciiLen={asciiLen}");
}
}
[TestMethod]
public void EncodeDecode_BoundaryAsciiToCjk_RoundTrip()
{
// 3-byte sequence boundary stress
for (var asciiLen = 0; asciiLen <= 64; asciiLen++)
{
var s = new string('a', asciiLen) + "你好世界";
AssertRoundTrip(s, $"asciiLen={asciiLen}");
}
}
[TestMethod]
public void EncodeDecode_BoundaryAsciiToEmoji_RoundTrip()
{
// 4-byte sequence boundary (surrogate pair in UTF-16)
for (var asciiLen = 0; asciiLen <= 64; asciiLen++)
{
var s = new string('a', asciiLen) + "😀";
AssertRoundTrip(s, $"asciiLen={asciiLen}");
}
}
[TestMethod]
public void EncodeDecode_Empty_RoundTrip()
{
AssertRoundTrip(string.Empty);
}
[TestMethod]
public void EncodeDecode_SingleAsciiChar_RoundTrip()
{
AssertRoundTrip("X");
}
[TestMethod]
public void EncodeDecode_SingleHungarianChar_RoundTrip()
{
AssertRoundTrip("é");
}
[TestMethod]
public void EncodeDecode_SingleCjkChar_RoundTrip()
{
AssertRoundTrip("好");
}
[TestMethod]
public void EncodeDecode_SingleEmoji_RoundTrip()
{
AssertRoundTrip("😀");
}
// ──────────────────────────────────────────────────────────────────────
// GetUtf8ByteCount — content classes
// ──────────────────────────────────────────────────────────────────────
[TestMethod]
public void GetUtf8ByteCount_AsciiOnly_MatchesBcl()
{
AssertGetUtf8ByteCountMatchesBcl("Hello, World! Plain ASCII text.");
}
[TestMethod]
public void GetUtf8ByteCount_AsciiExactly7Bytes_MatchesBcl()
{
// Boundary: just below Vector128<ushort>.Count (8) — scalar tail only
AssertGetUtf8ByteCountMatchesBcl(new string('a', 7));
}
[TestMethod]
public void GetUtf8ByteCount_AsciiExactly8Bytes_MatchesBcl()
{
// Boundary: exactly Vector128<ushort>.Count — Vector128 path triggers
AssertGetUtf8ByteCountMatchesBcl(new string('a', 8));
}
[TestMethod]
public void GetUtf8ByteCount_AsciiExactly16Bytes_MatchesBcl()
{
// Boundary: exactly Vector256<ushort>.Count — Vector256 path triggers
AssertGetUtf8ByteCountMatchesBcl(new string('a', 16));
}
[TestMethod]
public void GetUtf8ByteCount_AsciiExactly32Bytes_MatchesBcl()
{
// Boundary: exactly Vector512<ushort>.Count — Vector512 path triggers on AVX-512BW
AssertGetUtf8ByteCountMatchesBcl(new string('a', 32));
}
[TestMethod]
public void GetUtf8ByteCount_AsciiVeryLong_500Chars_MatchesBcl()
{
AssertGetUtf8ByteCountMatchesBcl(new string('z', 500));
}
[TestMethod]
public void GetUtf8ByteCount_HungarianShort_MatchesBcl()
{
AssertGetUtf8ByteCountMatchesBcl("Termék");
}
[TestMethod]
public void GetUtf8ByteCount_HungarianMedium_MatchesBcl()
{
AssertGetUtf8ByteCountMatchesBcl("árvíztűrő tükörfúrógép");
}
[TestMethod]
public void GetUtf8ByteCount_HungarianLong_MatchesBcl()
{
AssertGetUtf8ByteCountMatchesBcl(string.Concat(Enumerable.Repeat("árvíztűrő tükörfúrógép ", 20)));
}
[TestMethod]
public void GetUtf8ByteCount_CjkBmp_MatchesBcl()
{
AssertGetUtf8ByteCountMatchesBcl("你好世界 こんにちは 안녕하세요");
}
[TestMethod]
public void GetUtf8ByteCount_CjkBmpLong_MatchesBcl()
{
AssertGetUtf8ByteCountMatchesBcl(string.Concat(Enumerable.Repeat("你好世界 ", 30)));
}
[TestMethod]
public void GetUtf8ByteCount_SupplementaryPlane_MatchesBcl()
{
// Each emoji is 2 UTF-16 chars (surrogate pair) → 4 UTF-8 bytes total
AssertGetUtf8ByteCountMatchesBcl("😀😁😂🎉🌟");
}
[TestMethod]
public void GetUtf8ByteCount_MixedAllClasses_MatchesBcl()
{
AssertGetUtf8ByteCountMatchesBcl("ASCII Magyar:árvíz CJK:你好 Emoji:😀");
}
[TestMethod]
public void GetUtf8ByteCount_LongMixed_MatchesBcl()
{
var sb = new StringBuilder();
for (var i = 0; i < 50; i++)
{
sb.Append("ASCII run-").Append(i).Append(" Magyar:árvíz CJK:你好 ");
}
AssertGetUtf8ByteCountMatchesBcl(sb.ToString());
}
[TestMethod]
public void GetUtf8ByteCount_Empty_ReturnsZero()
{
Assert.AreEqual(0, Utf8Transcoder.GetUtf8ByteCount(ReadOnlySpan<char>.Empty));
}
[TestMethod]
public void GetUtf8ByteCount_SingleAsciiChar_MatchesBcl()
{
AssertGetUtf8ByteCountMatchesBcl("X");
}
[TestMethod]
public void GetUtf8ByteCount_SingleHungarianChar_MatchesBcl()
{
AssertGetUtf8ByteCountMatchesBcl("é");
}
[TestMethod]
public void GetUtf8ByteCount_SingleCjkChar_MatchesBcl()
{
AssertGetUtf8ByteCountMatchesBcl("好");
}
[TestMethod]
public void GetUtf8ByteCount_SingleEmoji_MatchesBcl()
{
// Single emoji = surrogate pair, exact 4 bytes
AssertGetUtf8ByteCountMatchesBcl("😀");
}
[TestMethod]
public void GetUtf8ByteCount_BoundaryAsciiToHungarian_MatchesBcl()
{
// Exercises split between SIMD ASCII region and 2-byte tail
for (var asciiLen = 0; asciiLen <= 64; asciiLen++)
{
var s = new string('a', asciiLen) + "árvíz";
var expected = Utf8.GetByteCount(s);
var actual = Utf8Transcoder.GetUtf8ByteCount(s.AsSpan());
Assert.AreEqual(expected, actual, $"asciiLen={asciiLen}");
}
}
[TestMethod]
public void GetUtf8ByteCount_BoundaryAsciiToCjk_MatchesBcl()
{
// 3-byte sequence boundary stress
for (var asciiLen = 0; asciiLen <= 64; asciiLen++)
{
var s = new string('a', asciiLen) + "你好世界";
var expected = Utf8.GetByteCount(s);
var actual = Utf8Transcoder.GetUtf8ByteCount(s.AsSpan());
Assert.AreEqual(expected, actual, $"asciiLen={asciiLen}");
}
}
[TestMethod]
public void GetUtf8ByteCount_BoundaryAsciiToEmoji_MatchesBcl()
{
// CRITICAL: tests that surrogate pairs split across SIMD chunks still produce correct count.
// High surrogate may land in chunk N, low surrogate in chunk N+1; total must remain 4 bytes.
for (var asciiLen = 0; asciiLen <= 64; asciiLen++)
{
var s = new string('a', asciiLen) + "😀";
var expected = Utf8.GetByteCount(s);
var actual = Utf8Transcoder.GetUtf8ByteCount(s.AsSpan());
Assert.AreEqual(expected, actual, $"asciiLen={asciiLen}");
}
}
[TestMethod]
public void GetUtf8ByteCount_MultipleEmojiBoundary_MatchesBcl()
{
// Surrogate pair split-stress: many emojis at varying offsets
for (var prefixLen = 0; prefixLen <= 32; prefixLen++)
{
var s = new string('a', prefixLen) + "😀😁😂🎉🌟😀😁😂🎉🌟";
var expected = Utf8.GetByteCount(s);
var actual = Utf8Transcoder.GetUtf8ByteCount(s.AsSpan());
Assert.AreEqual(expected, actual, $"prefixLen={prefixLen}");
}
}
[TestMethod]
public void GetUtf8ByteCount_AgreesWithEncodeUtf8SinglePass_AllContentClasses()
{
// Round-trip contract: the byte count returned must equal the bytesWritten by EncodeUtf8SinglePass.
// This is the load-bearing invariant for two-pass [VarUInt][bytes] writes in cold-fallback paths.
var samples = new[]
{
"Hello",
"árvíztűrő tükörfúrógép",
"你好世界",
"😀🎉🌟",
"ASCII Magyar:árvíz CJK:你好 Emoji:😀",
new string('z', 500),
string.Concat(Enumerable.Repeat("árvíztűrő tükörfúrógép ", 20))
};
foreach (var s in samples)
{
var byteCountFromCounter = Utf8Transcoder.GetUtf8ByteCount(s.AsSpan());
var dst = new byte[s.Length * 4];
var bytesWritten = Utf8Transcoder.EncodeUtf8SinglePass(s.AsSpan(), dst.AsSpan());
Assert.AreEqual(bytesWritten, byteCountFromCounter,
$"GetUtf8ByteCount disagrees with EncodeUtf8SinglePass for [{s.Substring(0, Math.Min(20, s.Length))}...]");
}
}
// ──────────────────────────────────────────────────────────────────────
// Decoder-side cross-check: BCL Encoding.UTF8.GetString reference
// ──────────────────────────────────────────────────────────────────────
[TestMethod]
public void DecodeUtf8SinglePass_MatchesBclGetString_Ascii()
{
AssertDecodeMatchesBcl("ASCII test string with spaces and digits 0123456789.");
}
[TestMethod]
public void DecodeUtf8SinglePass_MatchesBclGetString_LongAscii32Plus()
{
// CRITICAL — exercises the Vector256 ASCII prefix widen path that had the offset bug
AssertDecodeMatchesBcl(new string('A', 32));
AssertDecodeMatchesBcl(new string('A', 33));
AssertDecodeMatchesBcl(new string('A', 64));
AssertDecodeMatchesBcl(new string('A', 65));
AssertDecodeMatchesBcl(new string('B', 100));
AssertDecodeMatchesBcl(new string('C', 256));
}
[TestMethod]
public void DecodeUtf8SinglePass_MatchesBclGetString_Hungarian()
{
AssertDecodeMatchesBcl("árvíztűrő tükörfúrógép");
}
[TestMethod]
public void DecodeUtf8SinglePass_MatchesBclGetString_Mixed()
{
AssertDecodeMatchesBcl("Plain ASCII + Magyar (árvíz) + CJK (你好) + Emoji (😀)");
}
// ──────────────────────────────────────────────────────────────────────
// Helpers
// ──────────────────────────────────────────────────────────────────────
/// <summary>
/// Verifies that EncodeUtf8SinglePass produces bytes identical to <see cref="Encoding.UTF8.GetBytes"/>,
/// and that DecodeUtf8SinglePass on those bytes reconstructs the original string exactly.
/// </summary>
private static void AssertRoundTrip(string original, string? context = null)
{
var ctx = context is null ? string.Empty : $" [{context}]";
// 1. Encoder produces bytes identical to BCL Encoding.UTF8
var dst = new byte[original.Length * 4]; // worst-case UTF-8
var bytesWritten = Utf8Transcoder.EncodeUtf8SinglePass(original.AsSpan(), dst.AsSpan());
var encoded = dst.AsSpan(0, bytesWritten).ToArray();
var bclEncoded = Utf8.GetBytes(original);
CollectionAssert.AreEqual(bclEncoded, encoded, $"Encoder output mismatch{ctx}");
// 2. CountUtf8Chars matches the original char count
var charCount = Utf8Transcoder.CountUtf8Chars(encoded);
Assert.AreEqual(original.Length, charCount, $"Char count mismatch{ctx}");
// 3. DecodeUtf8SinglePass reconstructs the original string exactly
var decoded = string.Create(charCount, encoded, static (chars, bytes) =>
{
Utf8Transcoder.DecodeUtf8SinglePass(bytes, chars);
});
Assert.AreEqual(original, decoded, $"Decoder output mismatch{ctx}");
}
/// <summary>
/// Verifies that <see cref="Utf8Transcoder.GetUtf8ByteCount"/> matches
/// <see cref="Encoding.GetByteCount(string)"/> for the same input. This is the BCL parity
/// invariant — any divergence means the SIMD byte counter is producing wrong values that
/// would corrupt VarUInt length prefixes in <c>WriteStringUtf8Internal</c>.
/// </summary>
private static void AssertGetUtf8ByteCountMatchesBcl(string original)
{
var expected = Utf8.GetByteCount(original);
var actual = Utf8Transcoder.GetUtf8ByteCount(original.AsSpan());
Assert.AreEqual(expected, actual, $"GetUtf8ByteCount mismatch for input length {original.Length}");
}
/// <summary>
/// Verifies that DecodeUtf8SinglePass produces output identical to <see cref="Encoding.UTF8.GetString"/>
/// for the same byte input. Catches silent decoder bugs that pass the round-trip test
/// (e.g. write-overlap that happens to land back on the right value by accident).
/// </summary>
private static void AssertDecodeMatchesBcl(string original)
{
var bytes = Utf8.GetBytes(original);
var bclDecoded = Utf8.GetString(bytes);
var charCount = Utf8Transcoder.CountUtf8Chars(bytes);
var ourDecoded = string.Create(charCount, bytes, static (chars, b) =>
{
Utf8Transcoder.DecodeUtf8SinglePass(b, chars);
});
Assert.AreEqual(bclDecoded, ourDecoded, $"Decoder mismatch for input length {bytes.Length}");
}
}

View File

@ -0,0 +1,548 @@
using AyCode.Core.Serializers.Binaries;
using System.Collections;
using System.Reflection;
using System.Runtime.CompilerServices;
namespace AyCode.Core.Tests.TestModels;
/// <summary>
/// Charset suffix presets for the per-property string augmentation in
/// <c>BenchmarkStringSupport.ToLongString</c>. The benchmark applies the configured suffix to every
/// short (≤ <c>FixStrMaxLength</c>) string property across the test data graph (via reflection in
/// <c>BenchmarkStringSupport.EnsureAllStringsBypassFixStr</c>), producing long-string benchmark payloads
/// with a controlled UTF-8 content profile.
///
/// Switch by assigning to <see cref="BenchmarkTestDataProvider.LongStringSuffix"/> from the interactive
/// Settings → Charset submenu (or programmatically). The active charset is recorded in the .LLM
/// markdown output header so per-charset bench files are self-documenting.
/// </summary>
public static class CharsetSuffixes
{
// ─────────────────────────────────────────────────────────────────────────
// Consistent length across all charsets (UTF-16 char count, NOT UTF-8 byte count):
// *Short = 40 char (5-char base × 8 repetitions) → StringSmall / StringAscii tier
// *Long = 280 char (Short × 7) → StringMedium / StringAscii tier
//
// Same length across charsets isolates the workload variable to UTF-8 byte content
// (1-byte ASCII vs 2-byte Latin1 / Cyrillic vs 3-byte CJK vs mixed) — wire-size and
// encode/decode cost differences are pure charset effects, not length effects.
//
// Const-concat for compile-time evaluation (usable as attribute / DataRow source).
// ─────────────────────────────────────────────────────────────────────────
/// <summary>Empty suffix — baseline string property values stay short, hitting the
/// <c>FixStrAscii</c> / short-string fast-path. Stress-test for short-string code paths.</summary>
public const string AsciiFix = "";
// ── Pure ASCII (every byte < 0x80) ──
// Tier: StringAscii (167) — byte→char SIMD widening, zero UTF-8 decode.
// UTF-8 byte count: 40 byte (Short), 280 byte (Long) — 1:1 char:byte.
private const string AsciiBase = " quic"; // 5 char ASCII
public const string AsciiShort = AsciiBase + AsciiBase + AsciiBase + AsciiBase
+ AsciiBase + AsciiBase + AsciiBase + AsciiBase; // 40 char
public const string AsciiLong = AsciiShort + AsciiShort + AsciiShort + AsciiShort
+ AsciiShort + AsciiShort + AsciiShort; // 280 char
// ── Latin1 (Hungarian proxy — ISO-8859-1 + Latin-2 ő/ű) ──
// Tier: StringSmall (91) Short / StringMedium (94) Long.
// UTF-8 byte count: ~72 byte Short (5 char base = 9 byte UTF-8: space+á+r+v+í), ~504 byte Long.
private const string Latin1Base = " árví"; // 5 char (space + á + r + v + í) — multi-byte mix
public const string Latin1Fix = Latin1Base; // 5 char (FixStr-lean profile)
public const string Latin1Short = Latin1Base + Latin1Base + Latin1Base + Latin1Base
+ Latin1Base + Latin1Base + Latin1Base + Latin1Base; // 40 char
public const string Latin1Long = Latin1Short + Latin1Short + Latin1Short + Latin1Short
+ Latin1Short + Latin1Short + Latin1Short; // 280 char
// ── CJK BMP (Chinese / Japanese / Korean Basic Multilingual Plane) ──
// Tier: StringSmall (91) Short / StringMedium (94) Long.
// UTF-8 byte count: ~104 byte Short (5 char base = 13 byte UTF-8: 1 ASCII space + 4×3-byte CJK),
// ~728 byte Long. Homogeneous 3-byte runs — primary win region for SIMD multi-byte transcoder.
private const string CjkBmpBase = " 你好世界"; // 5 char (space + 4 Chinese)
public const string CjkBmpShort = CjkBmpBase + CjkBmpBase + CjkBmpBase + CjkBmpBase
+ CjkBmpBase + CjkBmpBase + CjkBmpBase + CjkBmpBase; // 40 char
public const string CjkBmpLong = CjkBmpShort + CjkBmpShort + CjkBmpShort + CjkBmpShort
+ CjkBmpShort + CjkBmpShort + CjkBmpShort; // 280 char
// ── Cyrillic (Russian / Ukrainian) ──
// Tier: StringSmall (91) Short / StringMedium (94) Long.
// UTF-8 byte count: ~72 byte Short (5 char base = 9 byte UTF-8: 1 ASCII + 4×2-byte Cyrillic),
// ~504 byte Long. Homogeneous 2-byte runs — different shape than Latin1 interspersed.
private const string CyrillicBase = " Прив"; // 5 char (space + 4 Cyrillic)
public const string CyrillicShort = CyrillicBase + CyrillicBase + CyrillicBase + CyrillicBase
+ CyrillicBase + CyrillicBase + CyrillicBase + CyrillicBase; // 40 char
public const string CyrillicLong = CyrillicShort + CyrillicShort + CyrillicShort + CyrillicShort
+ CyrillicShort + CyrillicShort + CyrillicShort; // 280 char
// ── Mixed (multi-codepage in one payload) ──
// Tier: StringSmall (91) Short / StringMedium (94) Long.
// UTF-8 byte count: ~88 byte Short (5 char base = 11 byte UTF-8: 1 ASCII + 1×2-byte Hungarian
// + 1×3-byte CJK + 2×2-byte Cyrillic), ~616 byte Long. No surrogate pairs (keeps UTF-16
// length predictable); cross-tier transcoder coverage in one payload.
private const string MixedBase = " á你Пй"; // 5 char (space + Hungarian + Chinese + 2× Cyrillic)
public const string MixedShort = MixedBase + MixedBase + MixedBase + MixedBase
+ MixedBase + MixedBase + MixedBase + MixedBase; // 40 char
public const string MixedLong = MixedShort + MixedShort + MixedShort + MixedShort
+ MixedShort + MixedShort + MixedShort; // 280 char
}
// ============================================================================================
// Cross-family shared state. The charset suffix is a global benchmark configuration — settable
// once via the interactive Menu, applied uniformly to every family's data construction. Lives in
// a non-generic helper so it ISN'T per-closed-generic (which would cause the Menu setter to affect
// only one family). The <see cref="BenchmarkTestDataProvider.LongStringSuffix"/> forwarding
// property preserves the existing Menu.cs API surface.
// ============================================================================================
internal static class BenchmarkStringSupport
{
internal const int FixStrMaxLength = 31;
internal static string LongStringSuffix = CharsetSuffixes.Latin1Long;
private sealed class ReferenceComparer : IEqualityComparer<object>
{
public static readonly ReferenceComparer Instance = new();
public new bool Equals(object? x, object? y) => ReferenceEquals(x, y);
public int GetHashCode(object obj) => RuntimeHelpers.GetHashCode(obj);
}
internal static void EnsureAllStringsBypassFixStr(object? root)
{
if (root == null) return;
var visited = new HashSet<object>(ReferenceComparer.Instance);
var stack = new Stack<object>();
stack.Push(root);
while (stack.Count > 0)
{
var current = stack.Pop();
if (!visited.Add(current)) continue;
if (current is IEnumerable enumerable && current is not string)
{
foreach (var item in enumerable)
{
if (item != null)
stack.Push(item);
}
continue;
}
var type = current.GetType();
foreach (var property in type.GetProperties(BindingFlags.Instance | BindingFlags.Public))
{
if (!property.CanRead) continue;
if (property.PropertyType == typeof(string))
{
if (!property.CanWrite) continue;
var value = (string?)property.GetValue(current);
property.SetValue(current, ToLongString(value));
continue;
}
if (property.PropertyType.IsValueType || property.PropertyType.IsEnum)
continue;
var child = property.GetValue(current);
if (child != null)
stack.Push(child);
}
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static string ToLongString(string? value)
{
if (string.IsNullOrEmpty(value))
return "Benchmark_String_Value" + LongStringSuffix;
if (value.Length > FixStrMaxLength)
return value;
return value + LongStringSuffix;
}
}
// ============================================================================================
// Generic test-data provider. One closing-generic alias per family — see
// <see cref="BenchmarkTestDataProvider"/> (the <c>_All_True</c> family, MSTEST-compatible name) and
// <see cref="BenchmarkTestDataProvider_All_False"/> (the <c>_All_False</c> family, Phase 1 benchmark
// target). The five cell-creator methods + ClearDeepLevelRefs are written once on the generic base,
// using the constrained <c>TestDataFactory&lt;TOrder, ...&gt;</c> for per-family element creation.
// ============================================================================================
public abstract class BenchmarkTestDataProvider<TOrder, TItem, TPallet, TMeasurement, TPoint, TTag, TUser, TCategory, TMetadata, TPreferences>
where TOrder : TestOrderBase<TItem, TTag, TUser, TCategory, TMetadata, TPreferences>, new()
where TItem : TestOrderItemBase<TPallet, TTag, TUser, TMetadata, TOrder, TPreferences>, new()
where TPallet : TestPalletBase<TMeasurement, TTag, TUser, TCategory, TMetadata, TItem, TPreferences>, new()
where TMeasurement : TestMeasurementBase<TPoint, TTag, TUser, TPallet, TPreferences>, new()
where TPoint : TestMeasurementPointBase<TTag, TUser, TMeasurement, TPreferences>, new()
where TTag : SharedTagBase, new()
where TUser : SharedUserBase<TPreferences>, new()
where TCategory : SharedCategoryBase, new()
where TMetadata : MetadataInfoBase<TMetadata>, new()
where TPreferences : UserPreferencesBase, new()
{
/// <summary>
/// Active long-string suffix appended to short string properties during benchmark data construction.
/// Forwards to <see cref="BenchmarkStringSupport.LongStringSuffix"/> (a non-generic shared field) so
/// the setter is family-agnostic — both <c>BenchmarkTestDataProvider.LongStringSuffix = …</c> and
/// <c>BenchmarkTestDataProvider_All_False.LongStringSuffix = …</c> route to the same backing value.
/// Without this forwarding, a per-closed-generic static field on the base would store the suffix
/// independently per family — the Menu setter would only affect whichever alias it addressed.
/// </summary>
public static string LongStringSuffix
{
get => BenchmarkStringSupport.LongStringSuffix;
set => BenchmarkStringSupport.LongStringSuffix = value;
}
// Shortcut alias for the matching factory closing-generic. Saves typing the 10-param cluster
// on every Create* call inside this class.
private static class Factory
{
public static void ResetIdCounter() =>
TestDataFactory<TOrder, TItem, TPallet, TMeasurement, TPoint, TTag, TUser, TCategory, TMetadata, TPreferences>.ResetIdCounter();
public static TTag CreateTag(string? name = null) =>
TestDataFactory<TOrder, TItem, TPallet, TMeasurement, TPoint, TTag, TUser, TCategory, TMetadata, TPreferences>.CreateTag(name);
public static TUser CreateUser(string? username = null) =>
TestDataFactory<TOrder, TItem, TPallet, TMeasurement, TPoint, TTag, TUser, TCategory, TMetadata, TPreferences>.CreateUser(username);
public static TCategory CreateCategory(string? name = null) =>
TestDataFactory<TOrder, TItem, TPallet, TMeasurement, TPoint, TTag, TUser, TCategory, TMetadata, TPreferences>.CreateCategory(name);
public static TMetadata CreateMetadata(string? key = null, bool withChild = false) =>
TestDataFactory<TOrder, TItem, TPallet, TMeasurement, TPoint, TTag, TUser, TCategory, TMetadata, TPreferences>.CreateMetadata(key, withChild);
public static TOrder CreateOrder(
int itemCount, int palletsPerItem, int measurementsPerPallet, int pointsPerMeasurement,
TTag? sharedTag = null, TUser? sharedUser = null, TMetadata? sharedMetadata = null,
TPreferences? sharedPreferences = null, TCategory? sharedCategory = null) =>
TestDataFactory<TOrder, TItem, TPallet, TMeasurement, TPoint, TTag, TUser, TCategory, TMetadata, TPreferences>.CreateOrder(
itemCount, palletsPerItem, measurementsPerPallet, pointsPerMeasurement,
sharedTag, sharedUser, sharedMetadata, sharedPreferences, sharedCategory);
}
public static List<TestDataSet> CreateTestDataSets(bool resetId = true)
{
return new List<TestDataSet>
{
CreateSmallTestData(resetId),
CreateMediumTestData(resetId),
CreateLargeTestData(resetId),
CreateRepeatedStringsTestData(resetId),
CreateDeepNestedTestData(resetId)
};
}
private static TestDataSet<TOrder> CreateSmallTestData(bool resetId = true)
{
if (resetId) Factory.ResetIdCounter();
var sharedTag = Factory.CreateTag("SharedTag");
var sharedUser = Factory.CreateUser("shareduser");
var order = Factory.CreateOrder(
itemCount: 2,
palletsPerItem: 2,
measurementsPerPallet: 2,
pointsPerMeasurement: 2,
sharedTag: sharedTag,
sharedUser: sharedUser);
BenchmarkStringSupport.EnsureAllStringsBypassFixStr(order);
ClearDeepLevelRefs(order);
return new TestDataSet<TOrder>("Small (2x2x2x2)", order, iidRefPercent: 20);
}
private static TestDataSet<TOrder> CreateMediumTestData(bool resetId = true)
{
if (resetId) Factory.ResetIdCounter();
var sharedTag = Factory.CreateTag("SharedTag");
var sharedUser = Factory.CreateUser("shareduser");
var sharedMeta = Factory.CreateMetadata("shared", withChild: true);
var sharedPreferences = new TPreferences
{
Theme = "dark",
Language = "hungarian",
NotificationsEnabled = true,
EmailDigestFrequency = "weekly"
};
sharedUser.Preferences = sharedPreferences;
var order = Factory.CreateOrder(
itemCount: 3,
palletsPerItem: 3,
measurementsPerPallet: 3,
pointsPerMeasurement: 4,
sharedTag: sharedTag,
sharedUser: sharedUser,
sharedMetadata: sharedMeta,
sharedPreferences: sharedPreferences);
BenchmarkStringSupport.EnsureAllStringsBypassFixStr(order);
ClearDeepLevelRefs(order);
return new TestDataSet<TOrder>("Medium (3x3x3x4)", order, iidRefPercent: 20);
}
private static TestDataSet<TOrder> CreateLargeTestData(bool resetId = true)
{
if (resetId) Factory.ResetIdCounter();
var sharedTag = Factory.CreateTag("SharedTag");
var sharedUser = Factory.CreateUser("shareduser");
var sharedPreferences = new TPreferences
{
Theme = "light",
Language = "german",
NotificationsEnabled = false,
EmailDigestFrequency = "daily"
};
sharedUser.Preferences = sharedPreferences;
var order = Factory.CreateOrder(
itemCount: 5,
palletsPerItem: 5,
measurementsPerPallet: 5,
pointsPerMeasurement: 10,
sharedTag: sharedTag,
sharedUser: sharedUser,
sharedPreferences: sharedPreferences);
BenchmarkStringSupport.EnsureAllStringsBypassFixStr(order);
ClearDeepLevelRefs(order);
return new TestDataSet<TOrder>("Large (5x5x5x10)", order, iidRefPercent: 20);
}
private static TestDataSet<TOrder> CreateRepeatedStringsTestData(bool resetId = true)
{
if (resetId) Factory.ResetIdCounter();
var sharedTag = Factory.CreateTag("RepeatedTag");
var sharedUser = Factory.CreateUser("repeateduser");
var sharedPreferences = new TPreferences
{
Theme = "dark",
Language = "hungarian",
NotificationsEnabled = true,
EmailDigestFrequency = "weekly"
};
sharedUser.Preferences = sharedPreferences;
var order = Factory.CreateOrder(
itemCount: 10,
palletsPerItem: 2,
measurementsPerPallet: 2,
pointsPerMeasurement: 2,
sharedTag: sharedTag,
sharedUser: sharedUser,
sharedPreferences: sharedPreferences);
// Repeated string fields — ProductName on items + PalletCode on pallets. Both are common
// across the hierarchy, exercising string-interning deduplication on the Default preset
// (which has UseStringInterning = All). Targeting ~20% repeated-string share overall.
// Baselines are short ASCII (≤ FixStrMaxLength) so EnsureAllStringsBypassFixStr appends the
// active CharsetSuffix — the resulting payload's UTF-8 content profile is governed entirely
// by the selected charset (not contaminated by hard-coded Hungarian baseline values).
foreach (var item in order.Items)
{
item.Status = TestStatus.Processing;
item.ProductName = "ProductName";
foreach (var pallet in item.Pallets)
{
pallet.PalletCode = "PalletCode";
}
}
BenchmarkStringSupport.EnsureAllStringsBypassFixStr(order);
ClearDeepLevelRefs(order);
return new TestDataSet<TOrder>("Repeated Strings (10 items)", order, iidRefPercent: 20);
}
private static TestDataSet<TOrder> CreateDeepNestedTestData(bool resetId = true)
{
if (resetId) Factory.ResetIdCounter();
var sharedTag = Factory.CreateTag("DeepTag");
var sharedUser = Factory.CreateUser("deepuser");
var sharedCategory = Factory.CreateCategory("DeepCategory");
var sharedPreferences = new TPreferences
{
Theme = "light",
Language = "french",
NotificationsEnabled = false,
EmailDigestFrequency = "monthly"
};
sharedUser.Preferences = sharedPreferences;
var order = Factory.CreateOrder(
itemCount: 2,
palletsPerItem: 4,
measurementsPerPallet: 4,
pointsPerMeasurement: 8,
sharedTag: sharedTag,
sharedUser: sharedUser,
sharedPreferences: sharedPreferences,
sharedCategory: sharedCategory);
BenchmarkStringSupport.EnsureAllStringsBypassFixStr(order);
ClearDeepLevelRefs(order);
return new TestDataSet<TOrder>("Deep Nested (2x4x4x8)", order, iidRefPercent: 20);
}
private static void ClearDeepLevelRefs(TOrder order)
{
// Keep shared IId refs at the pallet level (Tag + Inspector) — these contribute the bulk of
// the ~20% IId-ref share that the test data targets. Only Category is cleared at this level
// (one-of-three clears keep the share moderate). The deeper measurement / point levels are
// cleared entirely so deep-tree ref noise does not skew the share upward beyond ~20%.
foreach (var item in order.Items)
{
foreach (var pallet in item.Pallets)
{
// pallet.Tag = null; // KEEP for ~20% IId-ref share (was cleared)
// pallet.Inspector = null; // KEEP for ~20% IId-ref share (was cleared)
pallet.Category = null;
foreach (var measurement in pallet.Measurements)
{
measurement.Tag = null;
measurement.Operator = null;
foreach (var point in measurement.Points)
{
point.Tag = null;
point.Verifier = null;
}
}
}
}
}
}
// ============================================================================================
// Closing-generic aliases for the provider. Same pattern as the factory: a bare-name class for
// MSTEST backward compatibility (kept on _All_True), and a _All_False suffix variant for the
// Phase 1 benchmark target. The static <c>LongStringSuffix</c> forwarding property lives on the
// generic base above — accessible identically through either alias (<c>BenchmarkTestDataProvider.LongStringSuffix</c>
// or <c>BenchmarkTestDataProvider_All_False.LongStringSuffix</c>), both routing to the same
// <see cref="BenchmarkStringSupport.LongStringSuffix"/> shared field. Symmetric API surface across
// families — no per-alias asymmetry.
// ============================================================================================
/// <summary>
/// <c>_All_True</c> family provider — preserves the bare-name API surface
/// (<c>BenchmarkTestDataProvider.CreateTestDataSets()</c>) that the SGen-vs-runtime compatibility
/// test depends on. <c>LongStringSuffix</c> is inherited from the generic base.
/// </summary>
public sealed class BenchmarkTestDataProvider : BenchmarkTestDataProvider<
TestOrder_All_True, TestOrderItem_All_True, TestPallet_All_True, TestMeasurement_All_True, TestMeasurementPoint_All_True,
SharedTag_All_True, SharedUser_All_True, SharedCategory_All_True, MetadataInfo_All_True, UserPreferences_All_True>
{
}
/// <summary>
/// <c>_All_False</c> family provider — Phase 1 benchmark target. Inherits the generic cell-creator
/// methods unchanged; the closed-generic <c>new TOrder()</c> calls inside the cell methods construct
/// <c>TestOrder_All_False</c> graphs.
/// </summary>
public sealed class BenchmarkTestDataProvider_All_False : BenchmarkTestDataProvider<
TestOrder_All_False, TestOrderItem_All_False, TestPallet_All_False, TestMeasurement_All_False, TestMeasurementPoint_All_False,
SharedTag_All_False, SharedUser_All_False, SharedCategory_All_False, MetadataInfo_All_False, UserPreferences_All_False>
{
}
// ============================================================================================
// TestDataSet — abstract metadata base + generic-ordered concrete. Orchestration code iterates
// over the base type (Name/DisplayName/TypeName/IIdRefPercent only); concrete consumers
// (CreateSerializers, Output binary-output dump) downcast to TestDataSet<TOrder> to access the
// typed Order.
// ============================================================================================
public abstract class TestDataSet
{
public string Name { get; }
/// <summary>
/// Percentage of IId shared references in the data (0-100).
/// Higher values mean more deduplication benefit for Default mode.
/// </summary>
public int IIdRefPercent { get; }
// Type-keyed variant registry. Phase 2 multi-variant dispatch: AcBinary's options preset
// decides which variant graph it serializes (FastMode → _All_False, Default → _All_True),
// while MemPack/MsgPack canonically use one (typically _All_True). The cells build all
// known variants upfront and register them here so CreateSerializers can hand each benchmark
// its matching graph instance.
private readonly Dictionary<Type, object> _variants = new();
protected TestDataSet(string name, int iidRefPercent)
{
Name = name;
IIdRefPercent = iidRefPercent;
}
public abstract string TypeName { get; }
/// <summary>
/// Gets display name including IId ref percentage if set.
/// </summary>
public string DisplayName => IIdRefPercent > 0
? $"{Name} [{IIdRefPercent}% IId refs]"
: Name;
/// <summary>
/// Register a variant graph for this cell. Called by builders. Idempotent on the same type
/// (last-write-wins, no error) so an alias's primary registration is harmless even if
/// cross-registration adds the same variant later.
/// </summary>
public void RegisterVariant<T>(T variant) where T : class => _variants[typeof(T)] = variant;
/// <summary>
/// Get a registered variant by type. Throws <see cref="InvalidOperationException"/> if not
/// registered — fail-fast surfaces a mismatch between the variant a benchmark expects and
/// what the cell-builder populated.
/// </summary>
public T GetOrder<T>() where T : class
{
if (_variants.TryGetValue(typeof(T), out var v)) return (T)v;
throw new InvalidOperationException($"Variant '{typeof(T).Name}' not registered for cell '{Name}' (registered: {string.Join(", ", _variants.Keys.Select(k => k.Name))})");
}
/// <summary>
/// Check whether a variant is registered. Use to gate optional benchmarks that may not have
/// their variant prepared in every cell.
/// </summary>
public bool HasOrder<T>() where T : class => _variants.ContainsKey(typeof(T));
}
public sealed class TestDataSet<TOrder> : TestDataSet
where TOrder : class
{
public TOrder Order { get; }
public TestDataSet(string name, TOrder order, int iidRefPercent = 0)
: base(name, iidRefPercent)
{
Order = order;
RegisterVariant(order); // primary registers itself
}
public override string TypeName => Order.GetType().Name;
}

View File

@ -11,7 +11,3 @@ Shared test entities, enums, data factories, and SignalR test infrastructure. Us
- **`TestDataFactory.cs`** — Centralized factory with ID sequencing: CreateTag(), CreateCategory(), CreateUser(), CreateOrder(), CreateOrderItem().
- **`SignalRTestInfrastructure.cs`** — SignalRMessageFactory, DTOs, CommonSignalRTags, SignalRBenchmarkData.
- **`TestLogger.cs`** — Logger with capture for assertions: HasErrorLogs, HasWarningLogs, GetErrorMessages().
---
> **LLM Maintenance:** If you modify code in this folder, update this README to reflect the changes. If you notice the README content does not match the current code, automatically update the README to match the code.

View File

@ -0,0 +1,58 @@
using AyCode.Core.Serializers.Attributes;
namespace AyCode.Core.Tests.TestModels;
/// <summary>
/// Intentionally NOT marked with [AcBinarySerializable].
/// Used to reproduce the generated-writer path where the parent has a complex reference property
/// without a generated writer on the child type.
/// </summary>
public class NonGeneratedComplexCustomer
{
public int Id { get; set; }
public string? Name { get; set; }
}
/// <summary>
/// Regression model for SGen complex-property null handling.
/// The Customer property is non-nullable in signature, but runtime data can still contain null.
/// Serializer must emit PropertySkip instead of dereferencing null.
/// </summary>
[AcBinarySerializable]
public class SGenNullComplexParent
{
public int Id { get; set; }
public NonGeneratedComplexCustomer Customer { get; set; } = null!;
public string? Note { get; set; }
}
/// <summary>
/// Regression model for SGen Collection-property null handling — the fallback WriteValueGenerated
/// branch in GenWriter.cs PropertyTypeKind.Collection case (~line 321), when the element type has
/// no generated writer (cross-assembly / unattributed). The Items property is non-nullable in
/// signature, but runtime data can still contain null. Serializer must emit PropertySkip instead
/// of forwarding null into the WriteValueGenerated bridge.
/// </summary>
[AcBinarySerializable]
public class SGenNullCollectionParent
{
public int Id { get; set; }
public List<NonGeneratedComplexCustomer> Items { get; set; } = null!;
public string? Note { get; set; }
}
/// <summary>
/// Regression model for SGen Dictionary-property null handling — the EmitDirectDictionaryWrite
/// branch in GenWriter.cs (~line 1031). The branch was already null-safe at the time of the
/// N4P8 audit (explicit `if (a == null) PropertySkip` at line ~1037), but no regression test
/// existed to pin the behaviour. The Mapping property is non-nullable in signature, but runtime
/// data can still contain null — same pattern as <see cref="SGenNullComplexParent"/> /
/// <see cref="SGenNullCollectionParent"/>.
/// </summary>
[AcBinarySerializable]
public class SGenNullDictionaryParent
{
public int Id { get; set; }
public Dictionary<string, NonGeneratedComplexCustomer> Mapping { get; set; } = null!;
public string? Note { get; set; }
}

View File

@ -0,0 +1,333 @@
using AyCode.Core.Extensions;
using AyCode.Core.Interfaces;
using AyCode.Core.Serializers.Attributes;
using AyCode.Core.Serializers.Binaries;
using AyCode.Core.Serializers.Jsons;
using MemoryPack;
using MessagePack;
using MongoDB.Bson.Serialization.Attributes;
using Newtonsoft.Json;
namespace AyCode.Core.Tests.TestModels;
#region Shared Reference Base Types
public abstract class SharedTagBase : IId<int>
{
[Key(0)]
public int Id { get; set; }
[Key(1)]
public string Name { get; set; } = "";
[AcStringIntern(true)]
[Key(2)]
public string Color { get; set; } = "#000000";
[Key(3)]
public int Priority { get; set; }
[Key(4)]
public bool IsActive { get; set; } = true;
[Key(5)]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
[Key(6)]
public string? Description { get; set; }
}
public abstract class SharedCategoryBase : IId<int>
{
[Key(0)]
public int Id { get; set; }
[Key(1)]
public string Name { get; set; } = "";
[Key(2)]
public string? Description { get; set; }
[Key(3)]
public int SortOrder { get; set; }
[Key(4)]
public bool IsDefault { get; set; }
[Key(5)]
public int? ParentCategoryId { get; set; }
[Key(6)]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
[Key(7)]
public DateTime? UpdatedAt { get; set; }
}
public abstract class SharedUserBase<TPreferences> : IId<int>
where TPreferences : UserPreferencesBase
{
[Key(0)]
public int Id { get; set; }
[Key(1)]
public string Username { get; set; } = "";
[Key(2)]
public string Email { get; set; } = "";
[Key(3)]
public string FirstName { get; set; } = "";
[Key(4)]
public string LastName { get; set; } = "";
[Key(5)]
public bool IsActive { get; set; } = true;
[Key(6)]
public TestUserRole Role { get; set; } = TestUserRole.User;
[Key(7)]
public DateTime? LastLoginAt { get; set; }
[Key(8)]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
[Key(9)]
public TPreferences? Preferences { get; set; }
}
public abstract class UserPreferencesBase
{
[AcStringIntern(true)]
[Key(0)]
public string Theme { get; set; } = "light";
[AcStringIntern(true)]
[Key(1)]
public string Language { get; set; } = "en-US";
[Key(2)]
public bool NotificationsEnabled { get; set; } = true;
[AcStringIntern(true)]
[Key(3)]
public string? EmailDigestFrequency { get; set; }
}
public abstract class MetadataInfoBase<TSelf>
where TSelf : MetadataInfoBase<TSelf>
{
[AcStringIntern(true)]
[Key(0)]
public string Key { get; set; } = "";
[AcStringIntern(true)]
[Key(1)]
public string Value { get; set; } = "";
[Key(2)]
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
[Key(3)]
public TSelf? ChildMetadata { get; set; }
}
#endregion
#region Order Hierarchy Base Types
public abstract class TestOrderBase<TItem, TTag, TUser, TCategory, TMetadata, TPreferences> : IId<int>
where TItem : class
where TTag : SharedTagBase
where TUser : SharedUserBase<TPreferences>
where TCategory : SharedCategoryBase
where TMetadata : MetadataInfoBase<TMetadata>
where TPreferences : UserPreferencesBase
{
[Key(0)]
public int Id { get; set; }
[Key(1)]
public string OrderNumber { get; set; } = "";
[Key(2)]
public TestStatus Status { get; set; } = TestStatus.Pending;
[Key(3)]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
[Key(4)]
public DateTime? PaidDateUtc { get; set; }
[Key(5)]
public decimal TotalAmount { get; set; }
[Key(6)]
public List<TItem> Items { get; set; } = [];
[Key(7)]
public TTag? PrimaryTag { get; set; }
[Key(8)]
public TTag? SecondaryTag { get; set; }
[Key(9)]
public TUser? Owner { get; set; }
[Key(10)]
public TCategory? Category { get; set; }
[Key(11)]
public List<TTag> Tags { get; set; } = [];
[Key(12)]
public TMetadata? OrderMetadata { get; set; }
[Key(13)]
public TMetadata? AuditMetadata { get; set; }
[Key(14)]
public List<TMetadata> MetadataList { get; set; } = [];
[JsonNoMergeCollection]
[Key(15)]
public List<TItem> NoMergeItems { get; set; } = [];
[MemoryPackIgnore]
[JsonIgnore]
[IgnoreMember]
[BsonIgnore]
public object? Parent { get; set; }
}
public abstract class TestOrderItemBase<TPallet, TTag, TUser, TMetadata, TParentOrder, TPreferences> : IId<int>
where TPallet : class
where TTag : SharedTagBase
where TUser : SharedUserBase<TPreferences>
where TMetadata : MetadataInfoBase<TMetadata>
where TParentOrder : class
where TPreferences : UserPreferencesBase
{
[Key(0)]
public int Id { get; set; }
[AcStringIntern(true)]
[Key(1)]
public string ProductName { get; set; } = "";
[Key(2)]
public int Quantity { get; set; }
[Key(3)]
public decimal UnitPrice { get; set; }
[Key(4)]
public TestStatus Status { get; set; } = TestStatus.Pending;
[Key(5)]
public List<TPallet> Pallets { get; set; } = [];
[Key(6)]
public TTag? Tag { get; set; }
[Key(7)]
public TUser? Assignee { get; set; }
[Key(8)]
public TMetadata? ItemMetadata { get; set; }
[MemoryPackIgnore]
[JsonIgnore]
[IgnoreMember]
[BsonIgnore]
public TParentOrder? ParentOrder { get; set; }
}
public abstract class TestPalletBase<TMeasurement, TTag, TUser, TCategory, TMetadata, TParentItem, TPreferences> : IId<int>
where TMeasurement : class
where TTag : SharedTagBase
where TUser : SharedUserBase<TPreferences>
where TCategory : SharedCategoryBase
where TMetadata : MetadataInfoBase<TMetadata>
where TParentItem : class
where TPreferences : UserPreferencesBase
{
[Key(0)]
public int Id { get; set; }
[Key(1)]
public string PalletCode { get; set; } = "";
[Key(2)]
public int TrayCount { get; set; }
[Key(3)]
public TestStatus Status { get; set; } = TestStatus.Pending;
[Key(4)]
public double Weight { get; set; }
[Key(5)]
public List<TMeasurement> Measurements { get; set; } = [];
[Key(6)]
public TTag? Tag { get; set; }
[Key(7)]
public TUser? Inspector { get; set; }
[Key(8)]
public TCategory? Category { get; set; }
[Key(9)]
public TMetadata? PalletMetadata { get; set; }
[MemoryPackIgnore]
[JsonIgnore]
[IgnoreMember]
[BsonIgnore]
public TParentItem? ParentItem { get; set; }
}
public abstract class TestMeasurementBase<TPoint, TTag, TUser, TParentPallet, TPreferences> : IId<int>
where TPoint : class
where TTag : SharedTagBase
where TUser : SharedUserBase<TPreferences>
where TParentPallet : class
where TPreferences : UserPreferencesBase
{
[Key(0)]
public int Id { get; set; }
[Key(1)]
public string Name { get; set; } = "";
[Key(2)]
public double TotalWeight { get; set; }
[Key(3)]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
[Key(4)]
public List<TPoint> Points { get; set; } = [];
[Key(5)]
public TTag? Tag { get; set; }
[Key(6)]
public TUser? Operator { get; set; }
[MemoryPackIgnore]
[JsonIgnore]
[IgnoreMember]
[BsonIgnore]
public TParentPallet? ParentPallet { get; set; }
}
public abstract class TestMeasurementPointBase<TTag, TUser, TParentMeasurement, TPreferences> : IId<int>
where TTag : SharedTagBase
where TUser : SharedUserBase<TPreferences>
where TParentMeasurement : class
where TPreferences : UserPreferencesBase
{
[Key(0)]
public int Id { get; set; }
[Key(1)]
public string Label { get; set; } = "";
[Key(2)]
public double Value { get; set; }
[Key(3)]
public DateTime MeasuredAt { get; set; } = DateTime.UtcNow;
[Key(4)]
public TTag? Tag { get; set; }
[Key(5)]
public TUser? Verifier { get; set; }
[MemoryPackIgnore]
[JsonIgnore]
[IgnoreMember]
[BsonIgnore]
public TParentMeasurement? ParentMeasurement { get; set; }
}
#endregion

View File

@ -48,204 +48,8 @@ public enum TestUserRole
#endregion
#region Shared Reference Types (IId-based for $id/$ref testing)
/// <summary>
/// Shared tag/label - used across multiple entities for cross-reference testing.
/// Implements IId&lt;int&gt; for semantic $id/$ref serialization.
/// </summary>
[MemoryPackable]
[AcBinarySerializable(true)]
[MessagePackObject]
public partial class SharedTag : IId<int>
{
[Key(0)]
public int Id { get; set; }
[Key(1)]
public string Name { get; set; } = "";
[AcStringIntern(true)]
[Key(2)]
public string Color { get; set; } = "#000000";
[Key(3)]
public int Priority { get; set; }
[Key(4)]
public bool IsActive { get; set; } = true;
[Key(5)]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
[Key(6)]
public string? Description { get; set; }
}
/// <summary>
/// Shared category - for hierarchical cross-reference testing.
/// </summary>
[MemoryPackable]
[AcBinarySerializable(true)]
[MessagePackObject]
public partial class SharedCategory : IId<int>
{
[Key(0)]
public int Id { get; set; }
[Key(1)]
public string Name { get; set; } = "";
[Key(2)]
public string? Description { get; set; }
[Key(3)]
public int SortOrder { get; set; }
[Key(4)]
public bool IsDefault { get; set; }
[Key(5)]
public int? ParentCategoryId { get; set; }
[Key(6)]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
[Key(7)]
public DateTime? UpdatedAt { get; set; }
}
/// <summary>
/// Shared user reference - appears in many places to test $ref deduplication.
/// </summary>
[MemoryPackable]
[AcBinarySerializable(true)]
[MessagePackObject]
public partial class SharedUser : IId<int>
{
[Key(0)]
public int Id { get; set; }
[Key(1)]
public string Username { get; set; } = "";
[Key(2)]
public string Email { get; set; } = "";
[Key(3)]
public string FirstName { get; set; } = "";
[Key(4)]
public string LastName { get; set; } = "";
[Key(5)]
public bool IsActive { get; set; } = true;
[Key(6)]
public TestUserRole Role { get; set; } = TestUserRole.User;
[Key(7)]
public DateTime? LastLoginAt { get; set; }
[Key(8)]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
[Key(9)]
public UserPreferences? Preferences { get; set; }
}
/// <summary>
/// User preferences - non-IId nested object
/// </summary>
[MemoryPackable]
[AcBinarySerializable(true)]
[MessagePackObject]
public partial class UserPreferences
{
[AcStringIntern(true)]
[Key(0)]
public string Theme { get; set; } = "light";
[AcStringIntern(true)]
[Key(1)]
public string Language { get; set; } = "en-US";
[Key(2)]
public bool NotificationsEnabled { get; set; } = true;
[AcStringIntern(true)]
[Key(3)]
public string? EmailDigestFrequency { get; set; }
}
#endregion
#region Non-IId Metadata (Newtonsoft numeric $id/$ref testing)
/// <summary>
/// Non-IId metadata class - uses Newtonsoft PreserveReferencesHandling (numeric $id/$ref).
/// Does NOT implement IId, so uses standard Newtonsoft reference tracking.
/// </summary>
[MemoryPackable]
[AcBinarySerializable(true)]
[MessagePackObject]
public partial class MetadataInfo
{
[AcStringIntern(true)]
[Key(0)]
public string Key { get; set; } = "";
[AcStringIntern(true)]
[Key(1)]
public string Value { get; set; } = "";
[Key(2)]
public DateTime Timestamp { get; set; } = DateTime.UtcNow;
/// <summary>
/// Nested metadata for deep Newtonsoft reference testing
/// </summary>
[Key(3)]
public MetadataInfo? ChildMetadata { get; set; }
}
#endregion
#region 5-Level Test Hierarchy (Order -> Item -> Pallet -> Measurement -> Point)
/// <summary>
/// Level 1: Main order - root of the hierarchy
/// </summary>
[MemoryPackable]
[AcBinarySerializable(true)]
[MessagePackObject]
public partial class TestOrder : IId<int>
{
[Key(0)]
public int Id { get; set; }
[Key(1)]
public string OrderNumber { get; set; } = "";
[Key(2)]
public TestStatus Status { get; set; } = TestStatus.Pending;
[Key(3)]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
[Key(4)]
public DateTime? PaidDateUtc { get; set; }
[Key(5)]
public decimal TotalAmount { get; set; }
// Level 2 collection
[Key(6)]
public List<TestOrderItem> Items { get; set; } = [];
// Shared reference properties (for $id/$ref testing)
[Key(7)]
public SharedTag? PrimaryTag { get; set; }
[Key(8)]
public SharedTag? SecondaryTag { get; set; }
[Key(9)]
public SharedUser? Owner { get; set; }
[Key(10)]
public SharedCategory? Category { get; set; }
// Collection of shared references
[Key(11)]
public List<SharedTag> Tags { get; set; } = [];
// Non-IId metadata (for Newtonsoft $ref testing)
[Key(12)]
public MetadataInfo? OrderMetadata { get; set; }
[Key(13)]
public MetadataInfo? AuditMetadata { get; set; }
[Key(14)]
public List<MetadataInfo> MetadataList { get; set; } = [];
// NoMerge collection for testing replace behavior
[JsonNoMergeCollection]
[Key(15)]
public List<TestOrderItem> NoMergeItems { get; set; } = [];
// Parent reference - ignored by all serializers to prevent circular references
[MemoryPackIgnore]
[JsonIgnore]
[IgnoreMember]
[BsonIgnore]
public object? Parent { get; set; }
}
/// <summary>
/// Level 1: Main order - root of the hierarchy
/// </summary>
@ -263,16 +67,16 @@ public partial class TestOrder_Circ_Ref : IId<int>
public List<TestOrderItem_Circ_Ref> Items { get; set; } = [];
// Shared reference properties (for $id/$ref testing)
public SharedTag? PrimaryTag { get; set; }
public SharedTag? SecondaryTag { get; set; }
public SharedUser? Owner { get; set; }
public SharedCategory? Category { get; set; }
public SharedTag_All_True? PrimaryTag { get; set; }
public SharedTag_All_True? SecondaryTag { get; set; }
public SharedUser_All_True? Owner { get; set; }
public SharedCategory_All_True? Category { get; set; }
// Collection of shared references
public List<SharedTag> Tags { get; set; } = [];
public MetadataInfo? OrderMetadata { get; set; }
public MetadataInfo? AuditMetadata { get; set; }
public List<MetadataInfo> MetadataList { get; set; } = [];
public List<SharedTag_All_True> Tags { get; set; } = [];
public MetadataInfo_All_True? OrderMetadata { get; set; }
public MetadataInfo_All_True? AuditMetadata { get; set; }
public List<MetadataInfo_All_True> MetadataList { get; set; } = [];
// NoMerge collection for testing replace behavior
[JsonNoMergeCollection]
@ -280,46 +84,6 @@ public partial class TestOrder_Circ_Ref : IId<int>
public object? Parent { get; set; }
}
/// <summary>
/// Level 2: Order item with pallets
/// </summary>
[MemoryPackable]
[AcBinarySerializable(true)]
[MessagePackObject]
public partial class TestOrderItem : IId<int>
{
[Key(0)]
public int Id { get; set; }
[AcStringIntern(true)]
[Key(1)]
public string ProductName { get; set; } = "";
[Key(2)]
public int Quantity { get; set; }
[Key(3)]
public decimal UnitPrice { get; set; }
[Key(4)]
public TestStatus Status { get; set; } = TestStatus.Pending;
// Level 3 collection
[Key(5)]
public List<TestPallet> Pallets { get; set; } = [];
// Shared references
[Key(6)]
public SharedTag? Tag { get; set; }
[Key(7)]
public SharedUser? Assignee { get; set; }
[Key(8)]
public MetadataInfo? ItemMetadata { get; set; }
// Parent reference - ignored by all serializers to prevent circular references
[MemoryPackIgnore]
[JsonIgnore]
[IgnoreMember]
[BsonIgnore]
public TestOrder? ParentOrder { get; set; }
}
/// <summary>
/// Level 2: Order item with pallets
/// </summary>
@ -334,124 +98,15 @@ public partial class TestOrderItem_Circ_Ref : IId<int>
public TestStatus Status { get; set; } = TestStatus.Pending;
// Level 3 collection
public List<TestPallet> Pallets { get; set; } = [];
public List<TestPallet_All_True> Pallets { get; set; } = [];
// Shared references
public SharedTag? Tag { get; set; }
public SharedUser? Assignee { get; set; }
public MetadataInfo? ItemMetadata { get; set; }
public SharedTag_All_True? Tag { get; set; }
public SharedUser_All_True? Assignee { get; set; }
public MetadataInfo_All_True? ItemMetadata { get; set; }
public TestOrder_Circ_Ref? ParentOrder { get; set; }
}
/// <summary>
/// Level 3: Pallet containing measurements
/// </summary>
[MemoryPackable]
[AcBinarySerializable(true)]
[MessagePackObject]
public partial class TestPallet : IId<int>
{
[Key(0)]
public int Id { get; set; }
[Key(1)]
public string PalletCode { get; set; } = "";
[Key(2)]
public int TrayCount { get; set; }
[Key(3)]
public TestStatus Status { get; set; } = TestStatus.Pending;
[Key(4)]
public double Weight { get; set; }
// Level 4 collection
[Key(5)]
public List<TestMeasurement> Measurements { get; set; } = [];
// Shared IId references for better reference testing
[Key(6)]
public SharedTag? Tag { get; set; }
[Key(7)]
public SharedUser? Inspector { get; set; }
[Key(8)]
public SharedCategory? Category { get; set; }
// Non-IId shared references
[Key(9)]
public MetadataInfo? PalletMetadata { get; set; }
// Parent reference - ignored by all serializers to prevent circular references
[MemoryPackIgnore]
[JsonIgnore]
[IgnoreMember]
[BsonIgnore]
public TestOrderItem? ParentItem { get; set; }
}
/// <summary>
/// Level 4: Measurement with multiple points
/// </summary>
[MemoryPackable]
[AcBinarySerializable(true)]
[MessagePackObject]
public partial class TestMeasurement : IId<int>
{
[Key(0)]
public int Id { get; set; }
[Key(1)]
public string Name { get; set; } = "";
[Key(2)]
public double TotalWeight { get; set; }
[Key(3)]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
// Level 5 collection
[Key(4)]
public List<TestMeasurementPoint> Points { get; set; } = [];
// Shared IId references for better reference testing
[Key(5)]
public SharedTag? Tag { get; set; }
[Key(6)]
public SharedUser? Operator { get; set; }
// Parent reference - ignored by all serializers to prevent circular references
[MemoryPackIgnore]
[JsonIgnore]
[IgnoreMember]
[BsonIgnore]
public TestPallet? ParentPallet { get; set; }
}
/// <summary>
/// Level 5: Deepest level - measurement point
/// </summary>
[MemoryPackable]
[AcBinarySerializable(true)]
[MessagePackObject]
public partial class TestMeasurementPoint : IId<int>
{
[Key(0)]
public int Id { get; set; }
[Key(1)]
public string Label { get; set; } = "";
[Key(2)]
public double Value { get; set; }
[Key(3)]
public DateTime MeasuredAt { get; set; } = DateTime.UtcNow;
// Shared IId reference for better reference testing (many points share same tag/user)
[Key(4)]
public SharedTag? Tag { get; set; }
[Key(5)]
public SharedUser? Verifier { get; set; }
// Parent reference - ignored by all serializers to prevent circular references
[MemoryPackIgnore]
[JsonIgnore]
[IgnoreMember]
[BsonIgnore]
public TestMeasurement? ParentMeasurement { get; set; }
}
#endregion
#region Guid-based IId types
@ -515,7 +170,7 @@ public class TestOrderWithNullableCollections
{
public int Id { get; set; }
public string OrderNumber { get; set; } = "";
public List<TestOrderItem>? Items { get; set; }
public List<TestOrderItem_All_True>? Items { get; set; }
public List<string>? Tags { get; set; }
}
@ -567,10 +222,10 @@ public class ExtendedPrimitiveTestClass
// Nullable properties that will be null
public string? NullString { get; set; }
public TestOrderItem? NullObject { get; set; }
public TestOrderItem_All_True? NullObject { get; set; }
// Nested object for complex serialization
public SharedTag? Tag { get; set; }
public SharedTag_All_True? Tag { get; set; }
}
/// <summary>

View File

@ -0,0 +1,255 @@
using AyCode.Core.Extensions;
using AyCode.Core.Serializers.Attributes;
using AyCode.Core.Serializers.Binaries;
using MemoryPack;
using MessagePack;
namespace AyCode.Core.Tests.TestModels;
// ============================================================================
// _All_True family — every leaf marked [AcBinarySerializable(true)] (opt-out).
// All sub-references are _All_True-typed via the generic closing.
// `sealed` to enable AcBinary's non-polymorphic fast-path (no type-discriminator).
// ============================================================================
[MemoryPackable]
[AcBinarySerializable(true)]
[MessagePackObject]
public sealed partial class SharedTag_All_True : SharedTagBase
{
}
[MemoryPackable]
[AcBinarySerializable(true)]
[MessagePackObject]
public sealed partial class SharedCategory_All_True : SharedCategoryBase
{
}
[MemoryPackable]
[AcBinarySerializable(true)]
[MessagePackObject]
public sealed partial class SharedUser_All_True : SharedUserBase<UserPreferences_All_True>
{
}
[MemoryPackable]
[AcBinarySerializable(true)]
[MessagePackObject]
public sealed partial class UserPreferences_All_True : UserPreferencesBase
{
}
[MemoryPackable]
[AcBinarySerializable(true)]
[MessagePackObject]
public sealed partial class MetadataInfo_All_True : MetadataInfoBase<MetadataInfo_All_True>
{
}
/// <summary>
/// Level 1: Main order - root of the hierarchy
/// </summary>
[MemoryPackable]
[AcBinarySerializable(true)]
[MessagePackObject]
public sealed partial class TestOrder_All_True
: TestOrderBase<TestOrderItem_All_True, SharedTag_All_True, SharedUser_All_True,
SharedCategory_All_True, MetadataInfo_All_True, UserPreferences_All_True>
{
}
/// <summary>
/// Level 2: Order item with pallets
/// </summary>
[MemoryPackable]
[AcBinarySerializable(true)]
[MessagePackObject]
public sealed partial class TestOrderItem_All_True
: TestOrderItemBase<TestPallet_All_True, SharedTag_All_True, SharedUser_All_True,
MetadataInfo_All_True, TestOrder_All_True, UserPreferences_All_True>
{
}
/// <summary>
/// Level 3: Pallet containing measurements
/// </summary>
[MemoryPackable]
[AcBinarySerializable(true)]
[MessagePackObject]
public sealed partial class TestPallet_All_True
: TestPalletBase<TestMeasurement_All_True, SharedTag_All_True, SharedUser_All_True,
SharedCategory_All_True, MetadataInfo_All_True, TestOrderItem_All_True,
UserPreferences_All_True>
{
}
/// <summary>
/// Level 4: Measurement with multiple points
/// </summary>
[MemoryPackable]
[AcBinarySerializable(true)]
[MessagePackObject]
public sealed partial class TestMeasurement_All_True
: TestMeasurementBase<TestMeasurementPoint_All_True, SharedTag_All_True, SharedUser_All_True,
TestPallet_All_True, UserPreferences_All_True>
{
}
/// <summary>
/// Level 5: Deepest level - measurement point
/// </summary>
[MemoryPackable]
[AcBinarySerializable(true)]
[MessagePackObject]
public sealed partial class TestMeasurementPoint_All_True
: TestMeasurementPointBase<SharedTag_All_True, SharedUser_All_True, TestMeasurement_All_True,
UserPreferences_All_True>
{
}
// ============================================================================
// _All_False family — every leaf marked [AcBinarySerializable(false)] (opt-in).
// All sub-references are _All_False-typed via the generic closing.
// `sealed` to enable AcBinary's non-polymorphic fast-path.
// ============================================================================
[MemoryPackable]
[AcBinarySerializable(false)]
[MessagePackObject]
public sealed partial class SharedTag_All_False : SharedTagBase
{
}
[MemoryPackable]
[AcBinarySerializable(false)]
[MessagePackObject]
public sealed partial class SharedCategory_All_False : SharedCategoryBase
{
}
[MemoryPackable]
[AcBinarySerializable(false)]
[MessagePackObject]
public sealed partial class SharedUser_All_False : SharedUserBase<UserPreferences_All_False>
{
}
[MemoryPackable]
[AcBinarySerializable(false)]
[MessagePackObject]
public sealed partial class UserPreferences_All_False : UserPreferencesBase
{
}
[MemoryPackable]
[AcBinarySerializable(false)]
[MessagePackObject]
public sealed partial class MetadataInfo_All_False : MetadataInfoBase<MetadataInfo_All_False>
{
}
[MemoryPackable]
[AcBinarySerializable(false)]
[MessagePackObject]
public sealed partial class TestOrder_All_False
: TestOrderBase<TestOrderItem_All_False, SharedTag_All_False, SharedUser_All_False,
SharedCategory_All_False, MetadataInfo_All_False, UserPreferences_All_False>
{
}
[MemoryPackable]
[AcBinarySerializable(false)]
[MessagePackObject]
public sealed partial class TestOrderItem_All_False
: TestOrderItemBase<TestPallet_All_False, SharedTag_All_False, SharedUser_All_False,
MetadataInfo_All_False, TestOrder_All_False, UserPreferences_All_False>
{
}
[MemoryPackable]
[AcBinarySerializable(false)]
[MessagePackObject]
public sealed partial class TestPallet_All_False
: TestPalletBase<TestMeasurement_All_False, SharedTag_All_False, SharedUser_All_False,
SharedCategory_All_False, MetadataInfo_All_False, TestOrderItem_All_False,
UserPreferences_All_False>
{
}
[MemoryPackable]
[AcBinarySerializable(false)]
[MessagePackObject]
public sealed partial class TestMeasurement_All_False
: TestMeasurementBase<TestMeasurementPoint_All_False, SharedTag_All_False, SharedUser_All_False,
TestPallet_All_False, UserPreferences_All_False>
{
}
[MemoryPackable]
[AcBinarySerializable(false)]
[MessagePackObject]
public sealed partial class TestMeasurementPoint_All_False
: TestMeasurementPointBase<SharedTag_All_False, SharedUser_All_False, TestMeasurement_All_False,
UserPreferences_All_False>
{
}
// ============================================================================
// MIXED family — drift reproduction (SGen-emit asymmetry check).
// Mirrors the FruitBank ProductDto / OrderDto / GenericAttributeDto attribute:
// [AcBinarySerializable(false, true, false, true, false, false)]
// meta=false, IdTracking=true, RefHandling=FALSE, Intern=true, Filter=false, Poly=false
//
// Parent: EnableRefHandlingFeature=FALSE ◀ the asymmetry trigger
// Child: EnableRefHandlingFeature=true (IId<int>, all features ON)
//
// Hypothesis (confirmed): the SGen reader-emit guard for collection-element / Complex /
// dictionary-value dispatch (EmitReadCollectionElement / EmitReadComplex / EmitReadDictionary)
// used to check the PARENT-level enableRefHandling flag. The writer-emit only depends on
// CHILD-level flags (ElementNeedsRefScan / DictValueNeedsRefScan / runtime
// UseTypeReferenceHandling). With runtime ReferenceHandling=All + duplicate child instances,
// the writer runtime emits ObjectRefFirst / ObjectRef, but the reader's zero-branch path
// couldn't decode them → DECIMAL_DRIFT on MarkerDecimal after the Children list or
// ChildrenMap dictionary.
//
// Fix: parent-flag removed from reader guards; routing through RefAwareEmitPredicate
// (single source of truth shared with writer-side EmitDirectCollectionWrite).
//
// The existing _All_True family tests don't exercise this path because
// EnableRefHandlingFeature=true on the parent → reader emitted the full
// ref-aware switch → never hit the zero-branch bug.
// ============================================================================
[MemoryPackable]
[AcBinarySerializable(false, true, false, true, false, false)]
[MessagePackObject]
public sealed partial class TestRefAsymParent : AyCode.Core.Interfaces.IId<int>
{
[Key(0)]
public int Id { get; set; }
[Key(1)]
public System.Collections.Generic.List<TestRefAsymChild>? Children { get; set; }
[Key(2)]
public decimal MarkerDecimal { get; set; }
[Key(3)]
public System.Collections.Generic.Dictionary<int, TestRefAsymChild>? ChildrenMap { get; set; }
[Key(4)]
public decimal MarkerDecimal2 { get; set; }
}
[MemoryPackable]
[AcBinarySerializable(true)]
[MessagePackObject]
public sealed partial class TestRefAsymChild : AyCode.Core.Interfaces.IId<int>
{
[Key(0)]
public int Id { get; set; }
[Key(1)]
public string Name { get; set; } = "";
}

View File

@ -236,8 +236,8 @@ public class SignalRBenchmarkData
public byte[] MixedParamsMessage { get; }
// Test data
public TestOrderItem TestOrderItem { get; }
public TestOrder TestOrder { get; }
public TestOrderItem_All_True TestOrderItem { get; }
public TestOrder_All_True TestOrder { get; }
public int[] IntArray { get; }
public Guid TestGuid { get; }
@ -246,7 +246,7 @@ public class SignalRBenchmarkData
// Create test data
TestGuid = Guid.NewGuid();
IntArray = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
TestOrderItem = new TestOrderItem
TestOrderItem = new TestOrderItem_All_True
{
Id = 42,
ProductName = "Benchmark Product",

View File

@ -1,20 +1,43 @@
namespace AyCode.Core.Tests.TestModels;
/// <summary>
/// Factory for creating test data hierarchies.
/// Used by both unit tests and benchmarks.
/// Generic factory for the 5-level test-data hierarchy (Order → OrderItem → Pallet → Measurement →
/// MeasurementPoint) + cross-cutting shared types (Tag, Category, User, Metadata, UserPreferences).
/// One closing-generic alias per family — see <see cref="TestDataFactory"/> (the <c>_All_True</c>
/// family, kept on the bare class name for MSTEST backward compatibility) and
/// <see cref="TestDataFactory_All_False"/> (the <c>_All_False</c> family).
///
/// <para>The static <c>_idCounter</c> below is per-closed-generic (verified via C# smoke): each family
/// has an independent ID sequence, so calls like <c>TestDataFactory.ResetIdCounter()</c> reset only the
/// <c>_All_True</c> counter, leaving any <c>TestDataFactory_All_False.NextId()</c> sequence intact.
/// Each family's "Reset → Next" pattern stays internally consistent.</para>
///
/// <para>All placeholder strings use Hungarian (UTF-8 multi-byte) content to exercise the UTF-8
/// encoder/decoder path rather than the ASCII fast-path. This makes the benchmark reflect realistic
/// i18n payloads, not just the FixStrAscii / StringAscii marker fast-paths.</para>
/// </summary>
public static class TestDataFactory
public abstract class TestDataFactory<TOrder, TItem, TPallet, TMeasurement, TPoint, TTag, TUser, TCategory, TMetadata, TPreferences>
where TOrder : TestOrderBase<TItem, TTag, TUser, TCategory, TMetadata, TPreferences>, new()
where TItem : TestOrderItemBase<TPallet, TTag, TUser, TMetadata, TOrder, TPreferences>, new()
where TPallet : TestPalletBase<TMeasurement, TTag, TUser, TCategory, TMetadata, TItem, TPreferences>, new()
where TMeasurement : TestMeasurementBase<TPoint, TTag, TUser, TPallet, TPreferences>, new()
where TPoint : TestMeasurementPointBase<TTag, TUser, TMeasurement, TPreferences>, new()
where TTag : SharedTagBase, new()
where TUser : SharedUserBase<TPreferences>, new()
where TCategory : SharedCategoryBase, new()
where TMetadata : MetadataInfoBase<TMetadata>, new()
where TPreferences : UserPreferencesBase, new()
{
private static int _idCounter = 1;
/// <summary>
/// Reset the ID counter (call in test setup)
/// Reset the ID counter (call in test setup). Resets ONLY this family's counter — sibling families
/// keep their own independent counter state.
/// </summary>
public static void ResetIdCounter() => _idCounter = 1;
/// <summary>
/// Get the next unique ID
/// Get the next unique ID. Per-family counter — see class docs for the isolation rationale.
/// </summary>
public static int NextId() => _idCounter++;
@ -23,28 +46,28 @@ public static class TestDataFactory
/// <summary>
/// Create a shared tag for cross-reference testing
/// </summary>
public static SharedTag CreateTag(string? name = null, string? color = null)
public static TTag CreateTag(string? name = null, string? color = null)
{
var id = _idCounter++;
return new SharedTag
return new TTag
{
Id = id,
Name = name ?? $"Tag-{id}",
Color = color ?? $"#{id:X2}{(id * 10) % 256:X2}{(id * 20) % 256:X2}",
Color = color ?? $"Color-#{id:X2}{(id * 10) % 256:X2}{(id * 20) % 256:X2}",
Priority = id % 5,
IsActive = id % 2 == 0,
CreatedAt = DateTime.UtcNow.AddDays(-id),
Description = $"Description for tag {id}"
Description = $"Tag description {id}"
};
}
/// <summary>
/// Create a shared category
/// </summary>
public static SharedCategory CreateCategory(string? name = null, int? parentId = null)
public static TCategory CreateCategory(string? name = null, int? parentId = null)
{
var id = _idCounter++;
return new SharedCategory
return new TCategory
{
Id = id,
Name = name ?? $"Category-{id}",
@ -60,24 +83,24 @@ public static class TestDataFactory
/// <summary>
/// Create a shared user for cross-reference testing
/// </summary>
public static SharedUser CreateUser(string? username = null, TestUserRole role = TestUserRole.User)
public static TUser CreateUser(string? username = null, TestUserRole role = TestUserRole.User)
{
var id = _idCounter++;
return new SharedUser
return new TUser
{
Id = id,
Username = username ?? $"user{id}",
Email = $"user{id}@test.com",
FirstName = $"First{id}",
LastName = $"Last{id}",
FirstName = $"FirstName{id}",
LastName = $"LastName{id}",
IsActive = true,
Role = role,
LastLoginAt = DateTime.UtcNow.AddHours(-id),
CreatedAt = DateTime.UtcNow.AddYears(-1),
Preferences = new UserPreferences
Preferences = new TPreferences
{
Theme = id % 2 == 0 ? "dark" : "light",
Language = "en-US",
Language = "english",
NotificationsEnabled = true,
EmailDigestFrequency = "daily"
}
@ -87,13 +110,13 @@ public static class TestDataFactory
/// <summary>
/// Create metadata info (non-IId)
/// </summary>
public static MetadataInfo CreateMetadata(string? key = null, bool withChild = false)
public static TMetadata CreateMetadata(string? key = null, bool withChild = false)
{
var id = _idCounter++;
return new MetadataInfo
return new TMetadata
{
Key = key ?? $"Meta-{id}",
Value = $"MetaValue-{id}",
Key = key ?? $"Metadata-{id}",
Value = $"MetadataValue-{id}",
Timestamp = DateTime.UtcNow.AddMinutes(-id * 10),
ChildMetadata = withChild ? CreateMetadata($"Child-{id}", false) : null
};
@ -105,26 +128,26 @@ public static class TestDataFactory
/// <summary>
/// Create a deep order hierarchy with configurable depth.
/// Supports both IId-based (SharedTag, SharedUser, SharedCategory) and Non-IId (UserPreferences) shared references.
/// Supports both IId-based (Tag, User, Category) and Non-IId (Preferences) shared references.
/// </summary>
public static TestOrder CreateOrder(
public static TOrder CreateOrder(
int itemCount = 2,
int palletsPerItem = 2,
int measurementsPerPallet = 2,
int pointsPerMeasurement = 3,
SharedTag? sharedTag = null,
SharedUser? sharedUser = null,
MetadataInfo? sharedMetadata = null,
UserPreferences? sharedPreferences = null,
SharedCategory? sharedCategory = null)
TTag? sharedTag = null,
TUser? sharedUser = null,
TMetadata? sharedMetadata = null,
TPreferences? sharedPreferences = null,
TCategory? sharedCategory = null)
{
// If sharedUser is provided but no sharedPreferences, use the user's preferences as shared
sharedPreferences ??= sharedUser?.Preferences;
var order = new TestOrder
var order = new TOrder
{
Id = _idCounter++,
OrderNumber = $"ORD-{_idCounter:D4}",
OrderNumber = $"Order-{_idCounter:D4}",
Status = TestStatus.Pending,
CreatedAt = DateTime.UtcNow,
TotalAmount = 1000m + _idCounter * 100,
@ -161,20 +184,19 @@ public static class TestDataFactory
/// <summary>
/// Create an order item with pallets.
/// Supports both IId-based and Non-IId shared references.
/// </summary>
public static TestOrderItem CreateOrderItem(
public static TItem CreateOrderItem(
int palletCount = 2,
int measurementsPerPallet = 2,
int pointsPerMeasurement = 3,
SharedTag? sharedTag = null,
SharedUser? sharedUser = null,
MetadataInfo? sharedMetadata = null,
UserPreferences? sharedPreferences = null,
SharedCategory? sharedCategory = null)
TTag? sharedTag = null,
TUser? sharedUser = null,
TMetadata? sharedMetadata = null,
TPreferences? sharedPreferences = null,
TCategory? sharedCategory = null)
{
// Create assignee - if sharedUser provided, use it. Otherwise create new user with sharedPreferences
SharedUser? assignee = sharedUser;
TUser? assignee = sharedUser;
if (assignee == null && sharedPreferences != null)
{
// Create a new user but with shared preferences (Non-IId ref testing)
@ -182,7 +204,7 @@ public static class TestDataFactory
assignee.Preferences = sharedPreferences;
}
var item = new TestOrderItem
var item = new TItem
{
Id = _idCounter++,
ProductName = $"Product-{_idCounter}",
@ -211,24 +233,21 @@ public static class TestDataFactory
return item;
}
/// <summary>
/// Create a pallet with measurements
/// </summary>
public static TestPallet CreatePallet(
public static TPallet CreatePallet(
int measurementCount = 2,
int pointsPerMeasurement = 3,
MetadataInfo? sharedMetadata = null,
SharedTag? sharedTag = null,
SharedUser? sharedInspector = null,
SharedCategory? sharedCategory = null)
TMetadata? sharedMetadata = null,
TTag? sharedTag = null,
TUser? sharedInspector = null,
TCategory? sharedCategory = null)
{
var pallet = new TestPallet
var pallet = new TPallet
{
Id = _idCounter++,
PalletCode = $"PLT-{_idCounter:D4}",
PalletCode = $"PalletCode-{_idCounter:D4}",
TrayCount = 5 + _idCounter % 10,
Status = TestStatus.Pending,
Weight = 100.5 + _idCounter,
@ -251,12 +270,12 @@ public static class TestDataFactory
/// <summary>
/// Create a measurement with points
/// </summary>
public static TestMeasurement CreateMeasurement(
public static TMeasurement CreateMeasurement(
int pointCount = 3,
SharedTag? sharedTag = null,
SharedUser? sharedOperator = null)
TTag? sharedTag = null,
TUser? sharedOperator = null)
{
var measurement = new TestMeasurement
var measurement = new TMeasurement
{
Id = _idCounter++,
Name = $"Measurement-{_idCounter}",
@ -279,15 +298,15 @@ public static class TestDataFactory
/// <summary>
/// Create a measurement point
/// </summary>
public static TestMeasurementPoint CreateMeasurementPoint(
SharedTag? sharedTag = null,
SharedUser? sharedVerifier = null)
public static TPoint CreateMeasurementPoint(
TTag? sharedTag = null,
TUser? sharedVerifier = null)
{
var id = _idCounter++;
return new TestMeasurementPoint
return new TPoint
{
Id = id,
Label = $"Point-{id}",
Label = $"MeasurePoint-{id}",
Value = 10.5 + (id * 0.1),
MeasuredAt = DateTime.UtcNow,
Tag = sharedTag,
@ -296,14 +315,29 @@ public static class TestDataFactory
}
#endregion
}
#region Benchmark Data Generation
// ============================================================================================
// Closing-generic aliases. Each family carries its own static <c>_idCounter</c> (per-closed-generic
// isolation — see C# runtime semantics). The base-class generic methods are accessible through both
// aliases unchanged.
// ============================================================================================
/// <summary>
/// <c>_All_True</c> family factory — preserves the bare-name API surface
/// (<c>TestDataFactory.CreateTag(...)</c>, etc.) that the MSTEST tests and benchmark consumers depend
/// on. Adds family-specific extras (<see cref="CreateBenchmarkOrder"/>, <see cref="CreateLargeScaleBenchmarkOrder"/>,
/// <see cref="CreatePrimitiveTestData"/>) that the generic base intentionally doesn't carry.
/// </summary>
public sealed class TestDataFactory : TestDataFactory<
TestOrder_All_True, TestOrderItem_All_True, TestPallet_All_True, TestMeasurement_All_True, TestMeasurementPoint_All_True,
SharedTag_All_True, SharedUser_All_True, SharedCategory_All_True, MetadataInfo_All_True, UserPreferences_All_True>
{
/// <summary>
/// Create a large graph for benchmarking with many cross-references.
/// Creates approximately (itemCount * palletsPerItem * measurementsPerPallet * pointsPerMeasurement) objects.
/// </summary>
public static TestOrder CreateBenchmarkOrder(
public static TestOrder_All_True CreateBenchmarkOrder(
int itemCount = 5,
int palletsPerItem = 4,
int measurementsPerPallet = 3,
@ -313,20 +347,20 @@ public static class TestDataFactory
// Create shared references that will be used throughout
var sharedTags = Enumerable.Range(1, 10).Select(_ => CreateTag()).ToList();
var sharedUser = CreateUser("benchuser", TestUserRole.Admin);
var sharedMetadata = CreateMetadata("benchmark", withChild: true);
var sharedUser = CreateUser("mérőfelhasználó", TestUserRole.Admin);
var sharedMetadata = CreateMetadata("mérőteszt", withChild: true);
var order = new TestOrder
var order = new TestOrder_All_True
{
Id = _idCounter++,
OrderNumber = $"BENCH-{_idCounter:D6}",
Id = NextId(),
OrderNumber = $"MÉRŐTESZT-{NextId():D6}",
Status = TestStatus.Processing,
CreatedAt = DateTime.UtcNow,
TotalAmount = 999999.99m,
PrimaryTag = sharedTags[0],
SecondaryTag = sharedTags[0],
Owner = sharedUser,
Category = CreateCategory("Benchmark"),
Category = CreateCategory("Mérőteszt"),
OrderMetadata = sharedMetadata,
AuditMetadata = sharedMetadata,
Tags = sharedTags.Take(3).ToList()
@ -334,10 +368,10 @@ public static class TestDataFactory
for (int i = 0; i < itemCount; i++)
{
var item = new TestOrderItem
var item = new TestOrderItem_All_True
{
Id = _idCounter++,
ProductName = $"BenchProduct-{i}",
Id = NextId(),
ProductName = $"MérőTermék-{i}",
Quantity = 100 + i * 10,
UnitPrice = 25.99m + i,
Status = (TestStatus)(i % 5),
@ -349,10 +383,10 @@ public static class TestDataFactory
for (int p = 0; p < palletsPerItem; p++)
{
var pallet = new TestPallet
var pallet = new TestPallet_All_True
{
Id = _idCounter++,
PalletCode = $"PLT-{i}-{p}",
Id = NextId(),
PalletCode = $"Raklapkód-{i}-{p}",
TrayCount = 10 + p,
Status = (TestStatus)(p % 4),
Weight = 500.0 + p * 50,
@ -362,10 +396,10 @@ public static class TestDataFactory
for (int m = 0; m < measurementsPerPallet; m++)
{
var measurement = new TestMeasurement
var measurement = new TestMeasurement_All_True
{
Id = _idCounter++,
Name = $"Meas-{i}-{p}-{m}",
Id = NextId(),
Name = $"Mérés-{i}-{p}-{m}",
TotalWeight = 50.0 + m * 10,
CreatedAt = DateTime.UtcNow.AddMinutes(-m)
};
@ -373,10 +407,10 @@ public static class TestDataFactory
for (int pt = 0; pt < pointsPerMeasurement; pt++)
{
var point = new TestMeasurementPoint
var point = new TestMeasurementPoint_All_True
{
Id = _idCounter++,
Label = $"Pt-{i}-{p}-{m}-{pt}",
Id = NextId(),
Label = $"MérőPnt-{i}-{p}-{m}-{pt}",
Value = 1.0 + pt * 0.5,
MeasuredAt = DateTime.UtcNow.AddSeconds(-pt)
};
@ -397,12 +431,7 @@ public static class TestDataFactory
/// Create a large-scale benchmark order similar to production workloads.
/// Targets ~50,000-100,000+ IId objects with deep hierarchy and shared references.
/// </summary>
/// <param name="rootItemCount">Number of root items (default 500 for ~50K objects, use 2200 for production-like)</param>
/// <param name="palletsPerItem">Pallets per item</param>
/// <param name="measurementsPerPallet">Measurements per pallet</param>
/// <param name="pointsPerMeasurement">Points per measurement</param>
/// <returns>Large TestOrder with many IId references</returns>
public static TestOrder CreateLargeScaleBenchmarkOrder(
public static TestOrder_All_True CreateLargeScaleBenchmarkOrder(
int rootItemCount = 500,
int palletsPerItem = 3,
int measurementsPerPallet = 3,
@ -412,14 +441,14 @@ public static class TestDataFactory
// Create shared references - these will be heavily reused (tests $ref handling)
var sharedTags = Enumerable.Range(1, 50).Select(_ => CreateTag()).ToList();
var sharedUsers = Enumerable.Range(1, 20).Select(i => CreateUser($"user{i}", (TestUserRole)(i % 4))).ToList();
var sharedMetadata = CreateMetadata("large-scale", withChild: true);
var sharedCategories = Enumerable.Range(1, 10).Select(i => CreateCategory($"Cat-{i}")).ToList();
var sharedUsers = Enumerable.Range(1, 20).Select(i => CreateUser($"felhasználó{i}", (TestUserRole)(i % 4))).ToList();
var sharedMetadata = CreateMetadata("nagy-méretű", withChild: true);
var sharedCategories = Enumerable.Range(1, 10).Select(i => CreateCategory($"Kategória-{i}")).ToList();
var order = new TestOrder
var order = new TestOrder_All_True
{
Id = _idCounter++,
OrderNumber = $"LARGE-{_idCounter:D8}",
Id = NextId(),
OrderNumber = $"NAGYMÉRET-{NextId():D8}",
Status = TestStatus.Processing,
CreatedAt = DateTime.UtcNow,
TotalAmount = 9999999.99m,
@ -434,10 +463,10 @@ public static class TestDataFactory
for (int i = 0; i < rootItemCount; i++)
{
var item = new TestOrderItem
var item = new TestOrderItem_All_True
{
Id = _idCounter++,
ProductName = $"Product-{i}",
Id = NextId(),
ProductName = $"Termék-{i}",
Quantity = 100 + i,
UnitPrice = 10.99m + (i % 100),
Status = (TestStatus)(i % 5),
@ -449,10 +478,10 @@ public static class TestDataFactory
for (int p = 0; p < palletsPerItem; p++)
{
var pallet = new TestPallet
var pallet = new TestPallet_All_True
{
Id = _idCounter++,
PalletCode = $"P-{i}-{p}",
Id = NextId(),
PalletCode = $"Raklapkód-{i}-{p}",
TrayCount = 5 + (p % 10),
Status = (TestStatus)(p % 4),
Weight = 100.0 + p * 10,
@ -462,10 +491,10 @@ public static class TestDataFactory
for (int m = 0; m < measurementsPerPallet; m++)
{
var measurement = new TestMeasurement
var measurement = new TestMeasurement_All_True
{
Id = _idCounter++,
Name = $"M-{i}-{p}-{m}",
Id = NextId(),
Name = $"Mérés-{i}-{p}-{m}",
TotalWeight = 10.0 + m,
CreatedAt = DateTime.UtcNow
};
@ -473,10 +502,10 @@ public static class TestDataFactory
for (int pt = 0; pt < pointsPerMeasurement; pt++)
{
var point = new TestMeasurementPoint
var point = new TestMeasurementPoint_All_True
{
Id = _idCounter++,
Label = $"Pt-{i}-{p}-{m}-{pt}",
Id = NextId(),
Label = $"MérőPnt-{i}-{p}-{m}-{pt}",
Value = pt * 0.1,
MeasuredAt = DateTime.UtcNow
};
@ -518,7 +547,7 @@ public static class TestDataFactory
DecimalValue = 12345.6789m,
FloatValue = 1.5f,
BoolValue = true,
StringValue = "Test String ?? ????",
StringValue = "Teszt Szöveg árvíztűrőtükörfúrógép",
GuidValue = Guid.Parse("12345678-1234-1234-1234-123456789abc"),
DateTimeValue = new DateTime(2024, 12, 25, 12, 30, 45, DateTimeKind.Utc),
EnumValue = TestStatus.Shipped,
@ -528,6 +557,16 @@ public static class TestDataFactory
NullableIntNull = null
};
}
#endregion
}
/// <summary>
/// <c>_All_False</c> family factory — benchmark Phase 1 target. The generic-base factory methods
/// produce <c>_All_False</c>-typed graphs via the closed-generic <c>new T()</c> calls. No
/// family-specific extras here (the legacy <see cref="TestDataFactory.CreateBenchmarkOrder"/> etc.
/// stay on the <c>_All_True</c> alias because their existing consumers are <c>_All_True</c>-tied).
/// </summary>
public sealed class TestDataFactory_All_False : TestDataFactory<
TestOrder_All_False, TestOrderItem_All_False, TestPallet_All_False, TestMeasurement_All_False, TestMeasurementPoint_All_False,
SharedTag_All_False, SharedUser_All_False, SharedCategory_All_False, MetadataInfo_All_False, UserPreferences_All_False>
{
}

Some files were not shown because too many files have changed in this diff Show More