Compare commits

...

359 Commits

Author SHA1 Message Date
Loretta 101929b89e SignalR binary protocol: doc updates, WASM threading guide
- Closed and documented TODOs for V3P9 wire-format breaking changes and protocol-layer VarUInt/generic-inference fixes (ACCORE-SBP-T-W7K4).
- Added and referenced SIGNALR_BINARY_PROTOCOL_WASMTHREADING.md, detailing how to enable and test true pipeline streaming on Blazor-WASM via WasmEnableThreads.
- Updated README and issues to clarify WASM fallback behavior, technical limitations, and future multi-threading path.
- Improved cross-references and added acceptance/regression notes for maintainability.
2026-05-27 14:54:12 +02:00
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
Loretta cdcb200643 Add LLM onboarding docs and standardize project READMEs
- Introduced `.github/copilot-instructions.md` as the single source of truth for domain rules, conventions, and pitfalls in each solution.
- Added `CLAUDE.md` to guide Claude to read domain rules, glossary, and README before code generation.
- Updated all solution and project `README.md` files to document project purpose, structure, key files, and LLM context (Copilot/Claude/Cursor).
- Added or revised `docs/ARCHITECTURE.md`, `docs/CONVENTIONS.md`, and `docs/GLOSSARY.md` to clarify dependency graphs, naming, patterns, and terminology.
- For FruitBankHybridApp, added `docs/SCHEMA.md` (Toon format) and expanded the glossary with business/measurement terms and common traps.
- Updated all subfolder READMEs to list key files, conventions, and LLM maintenance notes.
- Ensured all documentation is cross-referenced, up-to-date, and includes explicit instructions for LLMs to keep docs in sync with code and avoid suggesting removal/rollback as a solution.
- Standardized documentation and onboarding for maintainability and LLM/code quality across all solutions.
2026-03-28 22:38:23 +01:00
Loretta fbe142c6f3 Add detailed README.md files for all projects and folders
Added comprehensive README.md documentation to every project and subfolder in the solution. Each README describes the purpose, key files, structure, dependencies, and design patterns for its area. This improves codebase navigability and maintainability, and includes a maintenance note to keep docs in sync with future code changes.
2026-03-28 17:24:56 +01:00
Loretta e606cd171b Add comprehensive README docs for all core subfolders
Added detailed README files for the root AyCode.Core library and each major subfolder (Compression, Consts, Enums, Extensions, Helpers, Interfaces, Loggers). Each README outlines folder purpose, key files, APIs, and critical notes, with tables and architecture diagrams where relevant. Included explicit instructions for LLM-based maintenance. This greatly improves project documentation and developer onboarding.
2026-03-28 16:24:03 +01:00
Loretta 0e912891b1 Update dependencies and expand README documentation
Updated test and benchmark project dependencies to latest versions (BenchmarkDotNet, coverlet.collector, MSTest, etc.). Reformatted source generator project references for consistency. Commented out most AcBinary benchmark variants in Program.cs, leaving only FastMode+Default enabled. Significantly expanded README with detailed architecture, file descriptions, and configuration for each serializer subfolder. Added LLM maintenance notes to ensure documentation stays in sync with code changes.
2026-03-20 16:42:51 +01:00
Loretta 16daad2917 Optimize scan codegen with compile-time property checks
Added HasScanWork and related methods to determine at compile time if a property requires scan work. EmitScanProp now skips code generation for properties proven to not need scan logic, improving efficiency and reducing unnecessary code output.
2026-03-10 18:35:25 +01:00
Loretta 2f99b4e3b7 Refactor SGen: property/object marker bridges, FixObj support
Major refactor of binary serialization codegen and runtime:
- Added property writer bridge methods for markerless/metadata paths
- Centralized object marker logic via new bridge methods
- Simplified SGen output: single bridge call replaces branching
- FixObj slot markers now supported in serialization/deserialization
- Refactored collection/dictionary element serialization
- Removed redundant WritePropertyMarkerless method
- Improved tests: use BinaryTypeCode constants, FixObj parsing
- Added InternalsVisibleTo for test project access
- Annotated TestSimpleClass for SGen support

Reduces generated code size, improves maintainability, and ensures correct handling of new binary format features.
2026-03-10 17:32:00 +01:00
Loretta c84c26048c Refactor property writing logic in AcBinarySerializer
Split WriteObjectProperties into markerless and metadata variants for clarity and performance. Adjust method inlining attributes to favor hot path optimization. Comment out WritePropertyValue and some AcBinaryBenchmark variants to streamline code and benchmarks. Improves maintainability and serialization efficiency.
2026-03-09 22:06:58 +01:00
Loretta 76ce60b7f0 Refactor polymorphic and ref handling in serializer
Split hot/cold paths for polymorphic and reference-tracked object serialization. Introduce dedicated cold-path methods for polymorphic and IId reference handling, and new helpers for writing reference indices. Extract property writing loop for reuse. Simplify WriteObject and update property dispatch logic to reduce branching and clarify marker handling.
2026-03-09 16:13:10 +01:00
Loretta 68c25b2381 Polymorphic serialization: slot-based prefix system overhaul
Refactored BinaryTypeCode to reserve 0..63 for FixObj slot indices, enabling direct array access for object wrappers. Introduced a new polymorphic type prefix system for properties whose runtime type differs from their declared type, with first/repeated occurrence markers and combined ref-tracking support. Unified wrapper slot caching for SGen and runtime types, improving performance and eliminating dictionary lookups in hot paths. Updated code generation, tests, and constants to use the new slot system. Added new settings and utility scripts. Overall, serialization is now faster, more robust, and extensible.
2026-03-09 15:04:46 +01:00
Loretta 11a15bfa64 Update serializer defaults and add MemoryPack fetch scripts
Changed default WireMode to Compact and string interning to Attribute in AcBinarySerializerOptions. Added Bash commands in settings.local.json for fetching and processing Cysharp/MemoryPack files via GitHub API and curl/python scripts.
2026-03-07 20:50:32 +01:00
Loretta e0f546dde6 Improve property ordering, null handling, and string interning
Refactored property enumeration in AcBinarySourceGenerator to match runtime ordering and filtering using a new helper. Null checks for reference types are now unconditional in generated code. Changed default string interning mode to All. Added InternalsVisibleTo for FruitBank.Common. Writer attribute checks now only apply to source-defined types.
2026-03-07 13:37:49 +01:00
Loretta d900442468 Switch to binary serialization; add IEntityComment interface
- Introduced IEntityComment interface for entity comments.
- Changed SignalR client message serialization to binary.
- Updated SignalResponseDataMessage docs for GZip (was Brotli).
- Refactored GetResponseData<T> for GZip decompression and improved error handling.
- Added necessary using statements for new interface and features.
2026-03-06 14:51:06 +01:00
Loretta 4ab8ede6ca Switch to binary serialization, update compression to GZip
- Changed default SignalR message serialization from JSON to binary.
- Updated AcSerializerType enum values: Binary=0, Json=1.
- Disabled string caching when intern table is present.
- Replaced Brotli with GZip for JSON compression in comments and logic.
- Refactored SignalResponseDataMessage deserialization for improved error handling.
2026-03-06 14:26:48 +01:00
Loretta bbed1caf44 Enable FastWire mode for binary string serialization
Activate FastWire encoding for both serialization and deserialization, using UTF-16 and fixed-width lengths for strings. WireMode is now public and defaults to FastWire. Removes unused ObjectEnd marker, clarifying object end handling. This improves string (de)serialization speed at the cost of larger output.
2026-03-03 13:53:58 +01:00
Loretta a1a2a90ef7 Add ReadProperties to binary reader for inline deserialization
Introduce ReadProperties to IGeneratedBinaryReader and generated readers, enabling property population into existing instances. Refactor ReadObject to delegate to ReadProperties. Update codegen for complex objects, collections, and dictionaries to use inline property deserialization. Improves efficiency and flexibility for parent-driven instance creation and cache management. Update documentation and comments to reflect new pattern.
2026-03-02 06:43:30 +01:00
Adam 155cef4500 Enityt comment 2026-03-01 21:09:31 +01:00
Loretta 7e3fbe7a52 Optimize ref/interning checks; add ScanOnly for benchmarking
Refactor serialization context to use precomputed boolean flags
(HasRefHandling, HasAllRefHandling, HasStringInterning) for faster
reference and string interning checks, replacing repeated enum
comparisons. Update source generator to emit code using these flags.
Add AcBinarySerializer.ScanOnly for isolated scan benchmarking.
Set MaxDepth in test options. Improves performance and maintainability.
2026-03-01 19:27:38 +01:00
Loretta 97ece85ee1 Refactor write plan entry tracking to use VisitIndex
Switch from caching next entry object to caching VisitIndex for write plan tracking in binary serialization. Updates TryConsumeWritePlanEntry and related logic for improved simplicity and performance. Comments revised to match new approach.
2026-03-01 13:53:04 +01:00
Loretta 15da68fe25 Optimize string interning and context state checks
Pre-compute InternBit and reference handling flags in context to avoid repeated field access and shifting. Refactor all intern mode checks to use InternBit, improving performance and code clarity in generated code, property accessors, and scan/write passes. Optimize write plan entry access for faster serialization.
2026-03-01 13:31:45 +01:00
Loretta 0ff40a6777 Refactor string read logic; remove UseGeneratedCode option
Refactored EmitReadString to use a switch for O(1) dispatch of string wire formats, improving performance and clarity. Updated comments to document the new approach and its benefits. Removed the UseGeneratedCode property from AcBinarySerializerOptions.
2026-03-01 07:22:13 +01:00
Loretta 7902922195 Inline dictionary serialization and scan codegen
Optimized AcBinarySourceGenerator to emit direct code for dictionary property serialization and scanning. Added PropInfo fields for dictionary value type metadata, scan requirements, and FNV-1a hashes. Specialized code now handles string interning, complex value reference tracking, and metadata inline, reducing runtime overhead and improving performance. Fallback to runtime methods only for unsupported types.
2026-02-28 15:06:17 +01:00
Loretta 2aa2eecccd Add inline dictionary read support to AcBinary generator
Enable optimized codegen for Dictionary<TKey, TValue> and IDictionary<TKey, TValue> properties. The generator now analyzes key/value types and emits direct binary read loops for primitive, string, enum, and complex types with generated readers. PropInfo is extended with dictionary metadata. Improves performance and type safety by eliminating reflection and runtime type dispatch for supported dictionary types. Also refactors collection reading logic and updates dependency graph and scanning for dictionary value types.
2026-02-28 14:38:50 +01:00
Loretta 8f665c5c4d Detect circular references in source generator; depth fixes
Added compile-time cycle detection for [AcBinarySerializable] types in AcBinarySourceGenerator, reporting ACBIN001 warnings to guide reference handling. Increased depth increment for nested serialization to improve max depth enforcement. Refactored collection element depth checks for clarity. Updated tests to conditionally assign Parent and commented out a redundant assertion. Simplified slot bounds check in AcSerializerContextBase.

Detect circular references at compile time in source gen

Added DFS-based cycle detection to AcBinarySourceGenerator, reporting ACBIN001 warnings for circular reference chains among [AcBinarySerializable] types. Increased depth increment for nested serialization from +1 to +2 to improve handling of deep/circular structures. Adjusted null-check logic for collections to respect MaxDepth. Updated tests to conditionally set circular references and commented out assertions for Parent when not applicable. Minor slot bounds check fix in AcSerializerContextBase.
2026-02-28 07:17:50 +01:00
Loretta 686424b813 Update serializer defaults and enhance IId reference tests
Changed AcBinarySerializerOptions defaults: UseMetadata is now false, UseGeneratedCode is now true. Improved IId reference tests by adding UseMetadata option, new null and reference identity assertions, and refactored assertion order for clarity. These changes ensure more robust test coverage and align default serializer behavior with expected usage.
2026-02-26 07:20:53 +01:00
Loretta 60f963bb36 Update serializer tests for param options; swap option defaults
Refactor test methods to use [DataRow] for parameterized testing of UseSgen and UseMeta combinations. Dynamically set AcBinarySerializerOptions in tests and add diagnostic output. Swap default values in AcBinarySerializerOptions: UseMetadata is now true, UseGeneratedCode is now false. This affects default serializer behavior across tests and usages.
2026-02-25 09:43:55 +01:00
Loretta 03e5cd9f29 Handle System.Object properties with runtime type dispatch
- Emit special serialization logic for properties declared as System.Object, using value.GetType() and writing type name metadata for correct polymorphic deserialization.
- Add IsObjectDeclaredType to PropInfo to support this logic.
- Update scan pass to use runtime type for object properties.
- Adjust IId reference test to ensure circular reference coverage.
- Default UseGeneratedCode to true in serializer options.
2026-02-25 09:28:44 +01:00
Loretta 8eeaa6725e Add polymorphic support for System.Object properties
Enable serialization of runtime type info for System.Object properties using new ObjectWithTypeName markers. Serializer now writes the runtime type name inline; deserializer resolves and instantiates the correct type. Added IsObjectDeclaredType property for detection, refactored WriteObject methods, and registered new deserialization logic. This ensures robust polymorphic (de)serialization even without metadata. Also includes minor cleanup of unused usings.
2026-02-25 09:13:56 +01:00
Loretta b5680bc0e4 Improve circular reference handling in binary serializer
Introduce new test models for circular refs, update tests to stress reference handling, and enhance deserializer to support ObjectRefFirst/WithMetadataRefFirst type codes. Fix intern cache index assignment, track generated readers in TypeMetadataWrapper, and disable UseGeneratedCode by default. Update benchmarks for reliability and diagnostics. These changes strengthen reference resolution, circular ref support, and performance.
2026-02-23 16:01:37 +01:00
Loretta e6afd21fef Improve collection handling in generator; enable all test models
- Refined collection kind mapping: IList<T>/IReadOnlyList<T> now treated as "IndexedCollection" for codegen, distinct from List<T>
- Generated code uses CollectionsMarshal.AsSpan for List<T> iteration, improving performance
- Updated generated read/write logic for collections to match new distinctions
- Added System.Runtime.InteropServices to generated code for span support
- Increased test iteration count in Program.cs for more robust benchmarks
- Enabled source-generated binary serialization for all test models by setting [AcBinarySerializable(true)]
2026-02-22 09:09:42 +01:00
Loretta 5ebcd03e87 Add unsafe write methods and MinWriteSize for serialization
Added capacity-check-free "unsafe" write methods to BinarySerializationContext for all primitive and specialized types, plus ReserveCapacity for bulk writes. Introduced MinWriteSize property in BinarySerializeTypeMetadata to precompute worst-case buffer requirements per type. Improved documentation and added Copilot instructions to discourage code removal as a solution.
2026-02-21 19:41:37 +01:00
Loretta 19b15554cf Add source-generated binary reader support
Introduced source-generated binary readers for all serializable types, alongside writers. Added IGeneratedBinaryReader interface and registry for fast lookup in deserializer. Generated readers eliminate runtime overhead by directly reading properties, bypassing wrapper and delegate calls. Updated test models to disable metadata for markerless mode. Integrated fast path in deserializer for improved performance.
2026-02-21 17:01:47 +01:00
Loretta 48c737024f Unify SGen wrapper slot tracking for metadata and refs
Refactored binary serializer to use a single wrapper slot per SGen type for both metadata registration and reference tracking. Removed slot-based IdentityMap arrays and scan pass tracking methods, replacing them with wrapper-based TryTrack logic. Updated generated code to use wrapper slots for all slot-based operations. Changed UseMetadata registration to use a MetadataSeen flag on the wrapper. Added fast slot-indexed wrapper access in context base. Default UseMetadata option is now false. Simplifies and optimizes SGen tracking, reducing dictionary lookups and unifying tracking logic.
2026-02-21 11:50:23 +01:00
Loretta fe35e60649 Enable AcBinary serialization for all test models
- Set `[AcBinarySerializable(true)]` on all SharedTestModels types
- Default `UseMetadata` to true for property hash footer
- Reset slotted ID maps and cache indices in serializer context
- Reduce test iterations for DEBUG builds to speed up runs
- Add debug comments in context `Clear()` method
2026-02-21 08:54:50 +01:00
Loretta cb2ee24a4c Optimize scan codegen with compile-time scan analysis
Added compile-time scan requirement flags to SerializableClassInfo and PropInfo, and implemented recursive analysis to determine if scan work is needed for reference tracking and string interning. Updated code generation to emit scan code only when necessary, with runtime guards based on compile-time analysis. Changed AcBinarySerializerOptions.UseMetadata default to false. Increased JIT wait in Program.cs for more reliable benchmarking. These changes reduce unnecessary scan calls and improve performance.
2026-02-20 15:57:20 +01:00
Loretta 3e935cad2f Per-type metadata control for binary serialization
Adds EnableMetadataFeature to [AcBinarySerializable], allowing types to opt out of inline metadata even when global UseMetadata is enabled. Source generator, serializer, and deserializer now respect this flag for child and element types. Default UseMetadata is set to true. Enables fine-grained control over serialization overhead and compatibility.
2026-02-20 09:55:21 +01:00
Loretta dcd9783b3b Feature flags for serialization: fine-grained control
AcBinarySourceGenerator now reads feature flags from [AcBinarySerializable], enabling selective code generation for ID tracking, reference handling, and string interning. Property ordering is always alphabetical, removing "Id"-first sorting for IId types. Reference tracking code is only emitted when features are enabled. TypeMetadataBase and AcBinarySerializer runtime logic now respect these flags. Default options updated: ReferenceHandlingMode is All, UseMetadata is false. Test models explicitly disable all features. Comments and code structure improved for clarity.
2026-02-20 08:48:16 +01:00
Loretta 77ea512c1f Source-generated scan pass for SGen binary serialization
Implements a fully source-generated scan pass for duplicate/reference tracking in SGen binary serialization. The generator now emits ScanObject and ScanForDuplicates methods for each writer, handling null/depth checks, slot-based ref tracking (by Id or object hash), and recursive property scanning (strings, complex types, collections). String interning and reference tracking are feature-flagged via attributes. The runtime scan path now delegates to generated code when available, eliminating reflection and delegate overhead. Adds slot-based IdentityMap arrays to the serialization context for efficient duplicate detection. Also updates metadata, attributes, and test stubs to support these features.
2026-02-19 16:40:16 +01:00
Loretta deffb77de4 Add full UseMetadata support to SGen with inline hashes
Implements inline type metadata emission in the source generator, matching runtime TypeMetadataBase. Computes FNV-1a hashes for type and property names, stores them in generated code, and emits metadata when UseMetadata is enabled. Adds per-type slot allocation and tracking for first/repeated metadata writes. Removes runtime fallback for UseMetadata, ensuring all logic is handled inline. Updates property filtering/order to match runtime, and optimizes Int32/Int64 skip logic. Thread-safe slot allocation is used for metadata tracking.
2026-02-18 18:43:21 +01:00
Loretta e2269d3ecf Refine AcBinary gen: metadata, ref tracking alignment
Improve generated serialization code to match runtime behavior for metadata emission and reference tracking. Markerless types now respect UseMetadata, ensuring type markers are written when required. Ref tracking guards for IId and non-IId types are unified to match scan pass logic. Generated writers are always used when available. Default UseMetadata is now true for consistent metadata output.
2026-02-18 13:07:26 +01:00
Loretta d40e40a45a Support nested types in source gen; improve prop filtering
- Generate writers for nested types using flat class names (Outer_Inner_Leaf) to ensure uniqueness and validity.
- Apply property filters in generated code for all non-markerless properties, matching runtime behavior.
- Emit skip labels for each property in generated code for correct control flow.
- Remove PropertyFilter check from IsDirectObjectWrite; generated code now handles filtering.
- Change default ReferenceHandlingMode to All.
- Make BinaryPropertyFilterContext constructor public.
- Increase release warmup iterations in Program.cs from 3000 to 5000.
2026-02-17 21:07:19 +01:00
Loretta 418d9f839a Disable FastWire mode; enforce compact encoding only
All FastWire-related code paths, fields, and options have been commented out in both AcBinarySerializer and AcBinaryDeserializer. This removes support for fixed-width integer and UTF-16 encoding, forcing the use of compact VarInt and UTF-8 encoding exclusively. The WireMode option is also commented out, so FastWire can no longer be selected. This change reduces output size and simplifies the codebase, but may impact serialization/deserialization speed for some scenarios.
2026-02-17 15:25:51 +01:00
Loretta 98d7a27245 Add WireMode for fast/compact binary serialization
Introduces a WireMode enum to select between Compact (VarInt + UTF-8) and Fast (fixed-width + UTF-16) wire formats for binary serialization. Updates AcBinarySerializerOptions to include a WireMode property (default: Fast). Serialization and deserialization logic now conditionally uses fixed-width or variable-length encoding for integers and strings based on the selected mode, enabling a tradeoff between output size and performance.
2026-02-17 09:53:15 +01:00
Loretta b244d9219a Add fast-path support for more primitive collections
Extend serializer/deserializer to efficiently handle arrays and lists of short, ushort, uint, ulong, sbyte, char, DateTimeOffset, and TimeSpan. Improve enum collection handling and support ICollection-based collections. Update source generator to distinguish between indexable and counted collections, generating optimal serialization code for each. These changes enhance performance and coverage for a broader range of collection types.
2026-02-16 21:26:51 +01:00
Loretta 7977feb36a Optimize collection serialization for complex element types
Enhance AcBinarySourceGenerator to emit direct write loops for List<T>/T[] where T is a complex type with a generated writer. This bypasses per-element runtime dispatch, improving serialization performance. PropInfo now tracks collection element metadata, enabling this optimization. Falls back to generic handling for other cases.
2026-02-16 19:37:02 +01:00
Loretta 9973b6be12 Update string serialization to always write length prefix
- Always write length prefix before ASCII and UTF-8 strings in AcBinarySerializer for consistency and correctness.
- Increase release warmup iterations from 2000 to 3000 in Program.cs.
2026-02-16 18:13:14 +01:00
Loretta 11e71336b0 Direct object write mode for binary serialization generator
Introduces direct object write mode in source-generated binary serialization, allowing generated writers to bypass the runtime WriteObject pipeline for eligible complex properties. Adds detection of generated writer presence and IId<T> implementation, inlines reference tracking logic, and replaces .GetType() with typeof() for type safety. String interning attribute detection and precomputed interning flags ensure safe cursor alignment. Expands PropInfo structure and enables fast path for all generated writers. Improves serialization performance, correctness, and reduces runtime overhead. Adds new web fetch domains to settings.
2026-02-16 15:06:01 +01:00
Loretta 03f5809e8a Optimize complex object serialization in source gen
Add WriteObjectGenerated to AcBinarySerializer for direct, efficient serialization of complex objects in source-generated code. Update AcBinarySourceGenerator to use this method, including proper handling of nullable properties, to reduce runtime overhead and avoid unnecessary type dispatch.
2026-02-16 13:33:15 +01:00
Loretta 7284856dda Unify and optimize primitive array/list serialization
Refactor AcBinarySerializer to use ReadOnlySpan<T> for bulk writing of primitive arrays and List<T>, replacing multiple specialized methods with a single TryWritePrimitiveCollection. This improves efficiency and reduces code duplication. Change default string interning mode to Attribute (opt-in). Update generated code path to allow reference tracking but not string interning. Adjust benchmarks to test correct serializer options. Reorder options for clarity.
2026-02-16 13:18:13 +01:00
Loretta dcd44cf705 Add MemoryPack benchmarks and model support
- Integrated MemoryPack as a serializer in the benchmark suite
- Added [MemoryPackable] and [MemoryPackIgnore] to test models
- Enabled AcBinary source generation by default
- Updated benchmark app to include MemoryPack and focus on key serializers
- Added MemoryPack NuGet references to projects
- Refactored AcBinarySerializer.WriteString flag handling
2026-02-16 07:59:24 +01:00
Loretta e30efff56c Enable property-level string interning via attribute
Introduce [AcStringIntern] for selective string interning on properties. Update serializer to use a per-property eligibility flag, improving efficiency and control over which string fields participate in interning during binary serialization. Update test models and internal context accordingly.
2026-02-15 19:01:21 +01:00
Loretta 6f88306e54 Optimize serializer with write plan for interning & refs
Implement write plan mechanism for string interning and IId object reference tracking. Scan pass now builds pre-computed WriteDuplicateEntry instructions, eliminating hot path IdentityMap lookups and redundant getter calls in the write pass. Update BinarySerializationContext, tracking visit indices and managing write plan array. Refactor ScanInternString and TryTrack methods to record visit indices and build write instructions for all duplicate occurrences. Update write pass logic to consume write plan entries. Add debug validation for scan/write pass order. Update benchmarks and test harness. Set UseGeneratedCode default to false. Improves performance for scenarios with interning and reference tracking.
2026-02-15 17:28:06 +01:00
Loretta 1af939ac4d Support markerless serialization for bool and enum types
Extend markerless serialization to Boolean and Enum types in AcBinarySourceGenerator and generated writers. Updated IsMarkerlessType and EmitMarkerlessWrite to handle these types directly. Refactored generated code to write DateTime, int, enum, and decimal properties without type markers or property skip checks. Simplified OnStringInterned debug callback in AcBinarySerializer. These changes reduce serialization overhead and improve performance for supported types.
2026-02-15 10:57:09 +01:00
Loretta 58cf9578c7 Optimize bool/enum serialization and ASCII string decoding
Improves binary serializer/deserializer performance by adding fast-path handling for boolean and enum properties, mapping their type codes directly for efficient read/write. Introduces a fast ASCII-only string decoding path for short strings, bypassing UTF8 overhead. Refactors array/list population to reduce redundant marker reads. Also applies aggressive inlining to core populate logic for further speedup.
2026-02-15 10:37:12 +01:00
Loretta e50dca93fa Refactor deserializer marker handling; add UseGeneratedCode opt
Refactored AcBinaryDeserializer to read the type code marker byte only once per property, eliminating redundant PeekByte/ReadByte calls and improving efficiency. Updated all property population branches to use the already-consumed type code. Adjusted handling of nested complex objects to rewind the marker byte when needed. Modified TryReadAndSetTypedValue to assume the marker is already consumed, removing unnecessary reads. Exception messages now report the actual type code read.

Added UseGeneratedCode option (default true) to AcBinarySerializerOptions and exposed it in the serialization context. The generated code fast path is now gated by this option, allowing users to enable or disable source-generated serialization. These changes improve deserialization performance, code clarity, and configurability.
2026-02-15 09:50:16 +01:00
Loretta 12b3244aa3 Refactor WriteValueGenerated for clarity and efficiency
Update WriteValueGenerated to require non-null values and call WriteValueNonPrimitive directly, clarifying its use for complex types in generated writers. Improve XML docs to reflect new behavior and intent.
2026-02-14 21:04:31 +01:00
Loretta 4ef65ee501 Generate IGeneratedBinaryWriter for fast serialization
Refactor source generator to emit per-type IGeneratedBinaryWriter classes for [AcBinarySerializable] types, with auto-registration at startup. Integrate generated writers into AcBinarySerializer for direct, delegate-free property writing, bypassing the runtime property loop when possible. Add registry, bridge methods, and update TypeMetadataWrapper for fast lookup. Expand tests to verify generated writers and round-trip correctness. This enables major serialization performance gains and reduces code size for supported types.
2026-02-14 20:50:38 +01:00
Loretta 896f720109 Add JIT disassembly benchmark and improve string interning
Introduce JitDisassemblyBenchmark for analyzing JIT-generated x64 assembly of AcBinarySerializer hot paths, accessible via --jitasm. Refactor string interning logic to support per-property and string collection interning, adding IsStringCollectionProperty and ScanStringCollection. Update ScanPass and WriteString for finer-grained control. Remove DEBUG-only CurrentPropertyPath in favor of a more robust property tracking approach. Update usage instructions and clean up related code.
2026-02-14 19:53:23 +01:00
Loretta bfab7c16b9 Improve string interning logic in AcBinarySerializer
- Respect both global and property-level [AcStringIntern] settings for string interning
- Add UseStringPropertyInterning method and flag-based caching in property accessors for fast runtime checks
- Update scan and write passes to use property-level interning decisions
- Introduce FilteredReferenceProperties for efficient scan filtering in TypeMetadataWrapper
- Refactor benchmarks to use correct serializer options
- Add TODOs and minor cleanups for clarity and future improvements
2026-02-14 11:07:26 +01:00
Loretta 7e7918e071 Optimize AcBinary hot paths with pre-cached type wrappers
Pre-caches TypeMetadataWrapper instances for complex properties,
eliminating repeated GetWrapper dictionary lookups in serialization
and deserialization. Adds ComplexPropertyIndex and ComplexPropertyCount
fields, and PropertyTypeWrappers array to TypeMetadataWrapper. Refactors
scan, write, and populate passes to use cached wrappers, improving
performance for deep and polymorphic object graphs. Updates benchmarks
to focus on FastMode variants. No breaking changes; internal efficiency
improved.
2026-02-14 01:41:54 +01:00
Loretta a0a6ac8ef4 Optimize AcBinary scan, string caching, and benchmarks
- Refactor collection scan to pre-cache element wrappers and optimize ScanItem for polymorphic types
- Add DisableStringCaching to deserializer; call it on first interned string marker
- Update benchmarks to restore default and no-ref variants, clarify string interning options
- Ensure property scanning respects property filters, skipping filtered properties
2026-02-13 22:58:07 +01:00
Loretta f84dcb773d Refactor Populate and add PopulateMerge for byte[] input
Refactored Populate<T>(byte[], T) to add null checks, early returns, and proper context management. Added PopulateMerge<T>(byte[], T) for merge semantics with IId collections, including merge mode and orphaned item removal.
2026-02-13 09:46:44 +01:00
Loretta 96409fe321 Refactor: generic binary input for deserialization
Refactored deserialization to use IBinaryInputBase abstraction, supporting both ArrayBinaryInput (byte[]) and SequenceBinaryInput (ReadOnlySequence<byte>). All context and methods are now generic over TInput, enabling zero-copy for arrays and true streaming for multi-segment sources. Internal logic (ReadValue, Populate, Skip, etc.) is specialized per input type, improving performance and flexibility. This enables future extensibility for other input sources and optimizes handling of large or segmented payloads.
2026-02-13 09:32:32 +01:00
Loretta 4c6342aa2b Refactor: unify deserialization context into single class
Eliminate BinaryDeserializationContextClass and merge all buffer, pooling, and cache logic into a single sealed BinaryDeserializationContext. All pooling and state management is now handled directly by the context, reducing allocations and indirection. All deserialization entry points and internal logic are updated to use the new context class and pool. String interning, metadata, and array pooling are now managed within the unified context, improving performance and maintainability.
2026-02-12 16:24:58 +01:00
Loretta 97e4315d12 Replace Newtonsoft.Json benchmark with AcBinary BufferWriter
Removed Newtonsoft.Json from benchmarks and codebase. Added AcBinaryBufferWriterBenchmark using ArrayBufferWriter and AcBinarySerializer's buffer writer API. Optimized WriteStringUtf8 for ASCII fast path. Improved ArrayBinaryOutput buffer reuse and memory management. Introduced Reset method to IBinaryOutputBase and implemented it in outputs. Streamlined serializer benchmarks to focus on AcBinary and System.Text.Json.
2026-02-11 17:29:19 +01:00
Loretta 991e8f6038 Refactor: move buffer/position to context for zero dispatch
Major serialization pipeline refactor: all hot-path buffer and position management is now owned by BinarySerializationContext<TOutput>, with all write methods inlined for zero virtual dispatch. TOutput (now struct, IBinaryOutputBase) handles only cold-path buffer management (Initialize, Grow, GetTotalPosition). ArrayBinaryOutput and BufferWriterBinaryOutput are simplified to buffer managers. IBinaryOutput and BinaryOutputBase are removed. All serialization logic now uses context write methods (~130 call sites updated). This yields significant performance gains by eliminating virtual/interface calls on the serialization hot path.
2026-02-11 13:02:24 +01:00
Loretta 270f1b8265 Refactor: make AcBinarySerializer fully generic on output
Major internal refactor: AcBinarySerializer and BinarySerializationContext are now generic on TOutput : BinaryOutputBase, enabling JIT devirtualization and eliminating virtual dispatch in hot serialization loops. All serialization logic (WriteValue, WriteObject, WriteArray, etc.) is now generic on TOutput and delegates buffer operations to the output instance (ArrayBinaryOutput or BufferWriterBinaryOutput). Context pooling is now per output type. All buffer management is moved to output classes. The public API is unchanged, but the internal architecture is now fully generic and ready for further JIT optimizations. Also disables the source generator and sets UseMetadata=false by default.
2026-02-10 20:24:47 +01:00
Loretta 0bde311aa1 Add high-performance binary output abstraction
Introduce ArrayBinaryOutput, BufferWriterBinaryOutput, BinaryOutputBase, and IBinaryOutput for flexible, efficient serialization to pooled arrays and streaming buffers. Refactor AcBinaryHubProtocol to use new output for SignalR. Make UseMetadata runtime-settable in AcBinarySerializerOptions. Update QuickBenchmark for new options. Enables allocation-free, extensible binary serialization infrastructure.
2026-02-10 07:12:11 +01:00
Loretta 97b7813633 Markerless serialization for value types (UseMetadata=false)
Introduced markerless serialization/deserialization for non-nullable value type properties when UseMetadata is false, eliminating type marker bytes for int, long, double, etc. Added ExpectedTypeCode to property accessors/setters to enable this optimization. Refactored property loops in serializer/deserializer for performance and clarity. Default UseMetadata is now false. Improves speed and reduces stream size for common value types while maintaining compatibility for complex types.
2026-02-09 08:38:47 +01:00
Loretta b38fd480d8 Refactor test data, MessagePack, and serializer logic
- Moved test data creation to BenchmarkTestDataProvider.cs and removed from Program.cs for better organization.
- Added [MessagePackObject]/[Key] attributes to all test models for explicit MessagePack serialization.
- Updated MessagePack benchmark to use MessagePackSerializerOptions.Standard.
- Improved AcBinaryDeserializer string cache with ASCII byte match to prevent hash collision bugs.
- Optimized AcBinarySerializer/Deserializer for string property handling and non-primitive writes.
- Set AcBinarySerializerOptions.UseMetadata default to true for safer deserialization.
2026-02-08 10:25:23 +01:00
Loretta b37d873792 Optimize serialization: precompute type metadata, remove caches
Refactored serialization for performance:
- Precompute type metadata (primitive, collection, element info) in TypeMetadataBase
- Remove runtime type caches from JsonUtilities
- Rewrite primitive/collection checks to use direct logic or metadata
- Update scan pass and serialization hot path to use wrappers/metadata
- Improve buffer management (halve oversized buffers)
- Increase profiler warmup iterations, comment out deserialization in hot path
- Clean up code and clarify documentation/comments

Reduces runtime overhead and memory usage, streamlines hot path execution.
2026-02-08 08:13:34 +01:00
Loretta 5a174ced4c Refactor: add pooled context for zero-alloc deserialization
Refactored binary deserialization to use a pooled BinaryDeserializationContextClass, eliminating per-call heap allocations and enabling cache reuse for string and intern caches. Introduced DeserializationContextClassPool for efficient context management. Updated all deserialization entry points to use the pool with proper disposal. Added efficient ReadOnlySequence<byte> support. Changed AcBinarySerializerOptions.UseMetadata default to false. These changes reduce GC pressure and improve performance, especially for high-throughput and WASM scenarios.
2026-02-07 18:21:10 +01:00
Loretta c1dc203dad Refactor header writing; add PooledBufferWriter & IBufferWriter support
Refactored AcBinarySerializer to write the binary header directly after scanning for duplicates, removing the placeholder and patching logic for improved performance and simplicity. Introduced a high-performance, ArrayPool-backed PooledBufferWriter for efficient buffer management and pooling. Added a new Serialize<T> overload supporting direct serialization to IBufferWriter<byte>, enabling zero-copy and high-throughput scenarios. These changes streamline serialization, reduce memory copying, and enhance extensibility.
2026-02-07 15:26:39 +01:00
Loretta 6b7f4bf44f Set SignalR client log level to Warning
Changed minimum log level from Trace to Warning in SignalR client configuration to reduce log verbosity and record only warnings, errors, and critical logs.
2026-02-06 19:00:57 +01:00
Loretta 9b4fa1159a Optimize cache index assignment during scan pass
Refactored AcBinarySerializer to assign cache indices immediately upon detecting duplicates during the scan pass, eliminating the need for a separate post-processing step. Updated TryTrack methods to take a ref nextCacheIndex for inline assignment. Removed AssignCacheIndicesInOrder and related code, simplified string interning, and made RegisterMetadataType static. This reduces allocations and improves performance by making cache index assignment a single-pass operation.
2026-02-06 15:48:48 +01:00
Loretta e5d4b1091f Two-pass serialization for string/object interning
Refactor binary serializer to use a true two-pass process for string interning and object reference tracking. Adds a scan pass to identify duplicates and assigns cache indices deterministically in first-occurrence order. Updates wire format to write explicit cache indices after *First markers. Refactors InternEntry, removes marker rewriting, and updates deserializer to match new format. Improves performance, correctness, and robustness for complex object graphs with shared references and repeated strings.
2026-02-06 09:55:28 +01:00
Loretta a87dc37b8b Add ReferenceProperties for efficient reference tracking
Introduce a lazily-computed ReferenceProperties array to AcBinarySerializer, containing only complex and string properties. This enables efficient iteration and reference tracking during serialization by filtering relevant properties on first access.
2026-02-05 17:11:24 +01:00
Loretta e8a0d36e43 Switch to marker-based interned value serialization
Refactor interned string/object tracking from footer-based to marker-based format.
- Serializer rewrites type code markers for first occurrences (StringInternFirst, ObjectRefFirst, ObjectWithMetadataRefFirst).
- Header now stores VarUInt cache count instead of footer position.
- Deserializer registers interned values sequentially as markers are encountered, eliminating footer parsing.
- Simplified registration logic and updated BinaryTypeCode constants.
- Improves cache locality, format compactness, and performance.
2026-02-05 08:03:44 +01:00
Loretta 097c1e8efe Refactor deserialization property cache construction
Move CacheMap building to dedicated method for efficiency.
Remove incremental cache logic and SourceHashes field.
Simplify property population and update documentation.
Improves performance and code clarity.
2026-02-05 07:12:08 +01:00
Loretta cd3d65b5f4 Refactor UseMetadata to support inline metadata entries
Refactored deserialization logic to use inline metadata for UseMetadata mode, replacing footer-based property hash lookup. Context now tracks inline metadata entries and builds cache maps incrementally per source type. Unified property population for both metadata modes. Updated TypeMetadataWrapper to manage cache map and source hashes per context. Improved robustness for runtime source type changes and streamlined object skipping logic. Updated method signatures to use BinaryPropertySetterBase abstraction.
2026-02-04 20:29:08 +01:00
Loretta 1410ee71f0 Migrate UseMetadata to inline format, remove metadata footer
Refactored AcBinarySerializer to write type property metadata inline
after the ObjectWithMetadata marker, eliminating the need for a
separate metadata footer section. Updated serialization, deserialization,
and diagnostic test logic to support the new inline metadata format.
Also updated settings.local.json to allow "Bash(git stash:*)" commands.
2026-02-04 16:04:53 +01:00
Loretta 18370879ec Add pure managed LZ4 compression to serializers
Implemented LZ4 compression/decompression in pure managed code, compatible with all platforms including WASM. Added new helpers (`Lz4`, `Lz4Compressor`, `Lz4Decompressor`) and a `Lz4CompressionMode` enum. Updated `AcBinarySerializerOptions` to support compression, and modified all relevant serializer methods to apply LZ4 when enabled. Benchmarks and buffer handling updated to support zero-allocation compression. No native dependencies required.
2026-02-04 14:36:16 +01:00
Loretta 3da902b575 Enable cross-type deserialization via property hashes
Introduce UseMetadata mode with FNV-1a property name hashing.
Write per-type property hashes to metadata footer for robust
property matching during deserialization. Remove legacy property
name table logic. Add ObjectWithMetadata marker and cachemap
logic for nested objects. Enable duplicate hash detection and
make UseMetadata default. Improves schema evolution support.
2026-02-04 09:38:49 +01:00
Loretta b7cb6256a0 Refactor list factory to support capacity for deserialization
Update list factory cache and GetOrCreateListFactory to accept a capacity parameter, enabling preallocation of lists during deserialization. Adjust deserializer code to pass collection size where available, improving performance and memory usage. Also, pre-size dictionaries on creation and add Bash(find:*) to allowed commands in settings.local.json.
2026-02-02 19:02:17 +01:00
Loretta bc62488965 Pool arrays for dup data and intern cache in deserializer
Reduce allocations by pooling int[] and object?[] arrays used for duplicate data and interned references during binary deserialization. Arrays are now rented and reused via ArrayPool<T>, with logic to clear or return them as appropriate. This improves performance and reduces GC pressure in steady-state scenarios.
2026-02-02 10:11:45 +01:00
Loretta 9b151fd6cf Unify string interning and object reference tracking
Major refactor: merges string interning and object reference tracking (IId/Non-IId) into a unified position-based cache and footer in binary serialization. Wire format now uses cache indices for all references; hashcode/Id prefixes removed. Serialization and deserialization logic simplified, improving performance and maintainability. Legacy code paths and redundant dictionary lookups eliminated.
2026-02-01 12:18:27 +01:00
Loretta 23af1fc98b Refactor string interning to use IdentityMap and InternEntry
Replaces Dictionary-based string interning in AcBinarySerializer.BinarySerializationContext with a new IdentityMap<string, InternEntry> approach. Introduces the InternEntry struct for efficient tracking of stream position and cache index. Updates all related logic and iteration to use the new IdentityMap API, improving performance, memory usage, and code clarity for interned string and identity tracking during serialization.
2026-02-01 10:52:53 +01:00
Loretta 1c41eba96e Refactor IdentityMap to be generic over key and value types
IdentityMap is now fully generic as IdentityMap<TKey, TValue>, enabling type-safe value storage and improved flexibility. All internal logic and method signatures are updated to use TKey and TValue. The small int optimization for value storage is removed, and the _useSmallInt flag is disabled by default. Legacy IIdentityMap code is deleted. TypeMetadataWrapper is updated to use the new generic IdentityMap signatures. This refactor improves type safety, eliminates boxing, and prepares the code for value-type scenarios.
2026-02-01 10:02:48 +01:00
Loretta 056a66d713 Refactor small int optimization in IdentityMap
Clarified and restricted the "small int" optimization path to tracking (serialization) only, not value storage. Made _useSmallInt a constant, removed unnecessary _smallValues initialization, and improved comments for clarity. Updated code to safely handle uninitialized _smallValues and documented that hash table is preferred for value storage.
2026-02-01 09:15:41 +01:00
Loretta c7f44906e7 Reduce pool sizes, optimize IdentityMap, add config option
Reduced default pool sizes from 16 to 8 for serializers and object pools, now configurable via AcSerializerOptions.MaxContextPoolSize. Improved IdentityMap<TId> memory usage and cache locality by shrinking small int bitmap/array. Refactored hash table logic to use cached bucket length. Optimized Reset to clear only used entries and adjusted array pooling. String key equality now always uses ordinal comparison. Updated context pool logic to respect per-serializer pool size. Includes minor code cleanups and comments.
2026-01-31 17:17:51 +01:00
Loretta dbacc2da80 High-perf IId tracking: custom IdentityMap, async cleanup
Refactor IId reference tracking with a new allocation-free, high-performance IdentityMap<TId> using bitmaps and pooled hash tables. Add async context cleanup for serializers, with pre-rented arrays for improved hot-path performance. Update AcSerializerOptions and context classes for better pooling, immutability, and platform support. Centralize and optimize array pooling and clearing to reduce memory pressure and GC impact.
2026-01-30 18:12:45 +01:00
Loretta 946148cc3d Refactor identity map for pooled, high-perf reference tracking
Move IdentityMap and IdAccessorType to dedicated file and update all usages. Introduce pooling and cache-friendly optimizations for small int keys and hash tables, minimizing allocations and speeding up resets. Update buffer sizes and profiling loops. Add extensive comments and preserve old implementation for reference. This prepares the codebase for more efficient serialization reference tracking.
2026-01-29 14:13:31 +01:00
Loretta 2eca18ca3f Track buffer growth stats in DEBUG; disable IId merge helper
Add DEBUG-only tracking of buffer growth in AcBinarySerializer for benchmarking, with stats output in console app. Expose stats via static properties and reset at serialization start. Add InternalsVisibleTo for console access. Comment out IIdCollectionMergeHelper.cs. Minor code cleanups included.
2026-01-29 09:41:53 +01:00
Loretta f778d4faa9 Add buffer growth diagnostics to serialization output
Added output of GrowBufferCount and GrowBufferTotalBytes from BinarySerializationContext after the results table. This provides visibility into how often and how much the buffer grows during binary serialization.
2026-01-28 19:26:28 +01:00
Loretta c766a83178 Comment out IdentityMap<TId> implementation
The entire IdentityMap<TId> class and related types have been commented out, leaving the code inactive but preserved for reference. No functional code remains in the file; all logic is now present only as comments.
2026-01-28 17:23:53 +01:00
Loretta 94dfa1b5f5 Switch interned string footer to VarUInt encoding
Update serializer and deserializer to use VarUInt for (position, cacheIndex) pairs in interned string footers, replacing fixed int32 format. This reduces serialized size and improves efficiency for small values. Adjust deserializer to read VarUInt pairs into a flat int[] array. Update comments and docs accordingly.
2026-01-27 19:01:03 +01:00
Loretta d0e2637741 Refactor string interning to use flat int[] for perf
Replaces DupEntry[] with flat int[] for position-based string interning in AcBinaryDeserializer and AcBinarySerializer. Serializer now writes (position, cacheIndex) pairs as fixed int32s in bulk, and deserializer reads them with MemoryMarshal.Cast for ultra-fast, cache-friendly access. This eliminates per-pair parsing overhead and streamlines the hot path for string interning.
2026-01-27 18:49:04 +01:00
Loretta f313d5d9ea Optimize string interning hot path in deserializer
Refactored AcBinaryDeserializer to use a cached _nextDupPosition
for ultra-fast string interning (single int comparison). Updated
initialization/reset logic and streamlined RegisterInternedString
to avoid unnecessary array access and branching. Commented out
MinStringInternLength threshold checks to always register interned
strings. Simplified GetInternedString and made a minor update to
the serializer's analysis report wording. These changes improve
performance and reliability of string interning during
deserialization.
2026-01-27 17:30:37 +01:00
Loretta 466782007d Refactor string interning to use position-based cache
Implement a new position-based string interning mechanism in AcBinarySerializer/AcBinaryDeserializer. This approach tracks stream positions for interned strings, ensuring 100% reliable cache matching during deserialization, even when strings are skipped or reordered. The serializer now writes (position, cacheIndex) pairs in the footer for all repeated strings, and the deserializer uses this mapping for robust cache population. Removes the old buffer-based interned string logic, updates all relevant code paths, and simplifies interned string handling for greater correctness and maintainability. Also updates benchmarks and test data construction to use the new interning mode.
2026-01-27 13:02:16 +01:00
Loretta 11ac2beb71 Add IsStringInternProperty to BinaryPropertyAccessorBase
Introduce IsStringInternProperty to cache the [AcStringIntern] attribute value for each property. Update the constructor to initialize this property, and revise class documentation to reflect the new addition. This enables efficient access to string interning settings per property.
2026-01-26 11:53:08 +01:00
Loretta ff73901ba8 Remove property name index caching from binary serializer
Simplifies property name registration by removing per-accessor
index caching and related reset logic. Deletes
RegisterPropertyNameAndCache and CachedPropertyNameIndex,
switching to direct property name registration. Also comments
out the global metadata cache in TypeMetadataBase, signaling
a move away from global caching. This reduces complexity and
potential for stale cache issues during context reuse.
2026-01-26 11:37:31 +01:00
Loretta e73fd7ae4a Refactor string interning to use enum and attribute
Replaces boolean UseStringInterning with StringInterningMode enum for more granular control (None, Attribute, All). Introduces AcStringInternAttribute for per-property interning configuration. Updates all usages and documentation to reflect the new approach, ensuring explicit and flexible string interning behavior in serialization. Default mode is now All to preserve legacy behavior.
2026-01-26 11:04:25 +01:00
Loretta 1a77ee4bf9 Refactor serializer options, string fast paths & analysis
- Refactor all serializer options to use properties returning new instances (no shared mutable state); update all usages accordingly
- Extract AcSerializerOptions, BinaryTypeCode, and BinaryPropertyFilterContext to dedicated files for clarity and reuse
- Add DEBUG-only string interning analysis/reporting tools to AcBinarySerializer
- Improve AcBinarySerializer string property serialization with direct typed getter and SIMD-optimized ASCII path
- Increase benchmark/test warmup iterations and add JIT warmup delays for more reliable performance measurements
- Remove redundant usings and update documentation/comments throughout
- No breaking API changes, but static readonly options fields are now properties
2026-01-25 16:40:40 +01:00
Loretta 145cc0a493 Add profiler mode & optimize AcBinary string interning
- Add "profiler" mode for memory profiling AcBinary serialization
- Reduce warmup iterations from 10 to 5 for faster benchmarks
- Save large test binary output to separate .output file (hex dump)
- Improve robustness of AcBinary vs MessagePack result comparison
- Use DisplayName for test data in result output for clarity
- Optimize AcBinary string interning: use single contiguous buffer
- Update WriteFooterStrings to avoid per-string allocations
- Clarify WithoutReferenceHandling() disables string interning for speed
2026-01-24 13:18:33 +01:00
Loretta 6df5c53937 Improve shared reference handling & benchmark realism
- Test data now controls IId shared ref % for realistic deduplication benchmarks; display names include IId ref ratio.
- Added deep-level clearing of IId refs for realistic object graphs.
- Pallet, Measurement, and Point models now support shared IId refs.
- TestDataFactory passes shared refs to all hierarchy levels.
- Refactored TypeMetadataWrapper for type-specific Id getters, identity maps, and registration—removes hot path type checks/switches.
- AcBinary deserializer now uses new typed methods for reference tracking and registration.
- SerializationContextBase uses pre-cast Id getters for zero-overhead tracking.
- Reduced quick benchmark warmup iterations for faster startup.
- Improves performance, clarity, and maintainability of reference handling and benchmarks.
2026-01-24 01:39:30 +01:00
Loretta 40fb4950a6 Refactor AcBinary reference handling and wire format
- Unify and clarify object reference tracking for IId and non-IId types
- Always write/read Id as a normal property with type marker for IId types
- For non-IId types (All mode), use hashcode prefix for reference tracking
- Remove special Id prefix logic; all properties use type markers
- Centralize hashcode registration logic in deserializer
- Improve error handling for missing references
- Refactor tests to cover all ReferenceHandlingMode values and verify both data integrity and reference identity
- Add hex dump utility for debugging serialized bytes
- Make TypeMetadataBase.SourceType public for better diagnostics
2026-01-23 23:42:39 +01:00
Loretta de2727ac8a Improve reference handling for serializers and IId types
- Make ReferenceHandlingMode type-aware; OnlyId fully supported for binary, All is default for JSON
- ReferenceHandling is now settable; add ThrowOnCircularReference option
- Always sort Id property first for IId types to optimize tracking
- Serialize/deserialize IId.Id without type marker when reference handling is enabled
- Contexts now delegate options-derived properties to Options
- Improve skip logic and property filter performance in binary serializer
- Update tests to explicitly set ReferenceHandlingMode.All
- Refactor internal APIs for clarity, safety, and efficiency
2026-01-23 20:32:51 +01:00
Loretta 852ab53af3 Refactor serializer contexts to use generic options type
Refactored AcSerializerContextBase and all derived context classes to accept a generic TOptions parameter, ensuring type-safe access to serializer/deserializer options. Updated Reset methods and option-dependent properties to use the strongly-typed Options property. Added helper methods for reference handling checks and performed minor code cleanups for consistency. This improves type safety, reduces runtime errors, and clarifies context usage across serialization formats.
2026-01-23 11:21:42 +01:00
Loretta cdf3cf34f8 Refactor: unify reference handling, footer string interning, benchmarks
- Replace UseReferenceHandling bool with ReferenceHandlingMode enum across all serializers and options
- Move AcBinary string interning to footer (no header shifting); remove bloom filter
- Overhaul benchmark console: multi-serializer, grouped/colorized results, CSV/log output, more test data
- Set UseMetadata = false by default (header property names unused)
- Update all context Reset/Pool logic for new options signature
- Update AcJson/Toon serializers for new reference handling
- Update tests and usages for new enum-based options
- Add Newtonsoft.Json to benchmark dependencies
- Misc: code cleanups, improved comments, clarify logic
2026-01-23 10:50:19 +01:00
Loretta 905b1c404d Refactor property metadata; add console perf profiler
- Introduced PropertyMetadataBase to unify property metadata and dynamic getter logic, now shared by PropertyAccessorBase and PropertySetterBase.
- Moved PropertyAccessorType enum to PropertyMetadataBase.
- Replaced all GetDynamicValue usages with GetValue for consistent property access in serializers/deserializers.
- Refactored PropertyAccessorBase and PropertySetterBase inheritance and responsibilities.
- Added MaxStringInternLength option to AcBinarySerializerOptions for configurable string interning.
- Improved collection deserialization: clear destination if source is empty.
- Added AyCode.Core.Serializers.Console project for performance profiling (with MessagePack comparison).
- Updated solution file to include new project and build configs.
- Minor code cleanups and documentation improvements.
2026-01-21 16:47:40 +01:00
Loretta 75823d593b Refactor: centralize strongly-typed property accessors
Move strongly-typed getter/setter logic and PropertyAccessorType enum into PropertyAccessorBase and PropertySetterBase, eliminating duplication in binary accessor classes. Expose direct typed getter/setter methods and new SetValueTyped/SetToDefault helpers. Rename ObjectGetter to DynamicGetter and update all serializers/deserializers to use GetDynamicValue. Centralize default value logic and improve performance by reducing boxing/unboxing. This unifies and streamlines property accessor infrastructure across all serializers.
2026-01-21 10:36:06 +01:00
Loretta 8f35f172f0 Refactor serialization/deserialization context base classes
Introduce SerializationContextBase and DeserializationContextBase to separate serialization and deserialization logic. Move tracking API from AcSerializerContextBase to SerializationContextBase, and add generic identity map methods to TypeMetadataWrapper for type-safe object tracking. Update all context classes to inherit from the new base classes. Comment out shared reference tracker and property mapping cache classes. Clean up unused usings and obsolete code. This improves code organization, performance, and maintainability.
2026-01-20 19:49:55 +01:00
Loretta 7d133a4b24 Add IId-aware reference tracking to serializer/deserializer
Enhances both binary deserialization and JSON serialization to support IId-based reference tracking and handling. Refactors object reference reading in AcBinaryDeserializer to use a unified TryGetValue approach. Adds IId-aware tracking, ID writing, and reference lookup methods to AcJsonSerializer and updates ScanReferences and WriteObject to use them. Improves support for custom identity types and robust reference management.
2026-01-20 08:58:07 +01:00
Loretta 6dbe4d76c1 Refactor reference tracking to use per-type identity maps
Replaces flat object reference dictionary with per-type identity maps in deserializer, improving type safety and efficiency for IId types. TypeMetadataWrapper now uses cached typed delegates for reference ID access. Centralizes complex type detection and exposes IsComplexType and TypedIdGetter in metadata. Updates all registration and lookup logic to use wrappers, removes obsolete metadata cache, and ensures thread-safe, type-aware reference handling throughout. Comments out legacy code for easier review and rollback.
2026-01-20 07:23:02 +01:00
Loretta dc2526da7e Refactor IId reference tracking for binary serialization
Unifies IId-based reference handling for binary serialization and deserialization. Introduces BinaryDeserializationContextClass for heap-based IId tracking, refactors IdentityMap<TId> for unified object storage, and removes legacy IIdReferenceTracker logic. Updates deserializer to use the new infrastructure for all IId types (int, long, Guid) and correct wire formats. Enhances tests for reference identity and object graph integrity. Improves code clarity and maintainability.
2026-01-19 14:37:42 +01:00
Loretta 09a61539fa Refactor: single-pass ref tracking, restrict IId<T> types
Refactored serialization reference tracking to a single-pass, inline approach, removing the previous two-pass scan. Only int, long, and Guid are now supported as IId<T> types; exotic ID types are no longer allowed. Cleaned up related enum values and code paths, defaulting non-IId types to int-based reference IDs. This simplifies the codebase and improves performance and type safety.
2026-01-18 16:25:09 +01:00
Loretta 8161ddade4 Refactor: unify metadata and tracking for serializer contexts
Major refactor of serialization infrastructure:
- Removed AcSerializeBase; replaced with AcSerializerContextBase<TMetadata> for unified context management.
- Added TypeMetadataWrapper<TMetadata> to combine metadata and per-context tracking state.
- All serializer contexts now inherit from AcSerializerContextBase and use context.GetWrapper(type) for metadata and tracking.
- Reference tracking for IId types is now type-safe and efficient (bitmaps for small int IDs, generic identity maps for others).
- Removed generic ThreadLocal caching from TypeMetadataBase; caching now uses global ConcurrentDictionary.
- Updated all type metadata classes to inherit from non-generic base.
- Added IdPropertyInfo and MetadataType to TypeMetadataBase.
- Added stub context base classes for JSON and Toon.
This centralizes and optimizes metadata/tracking, improves performance, and prepares for future extensibility.
2026-01-18 15:31:45 +01:00
Loretta 2ab640b375 Refactor serialization reference tracking and contexts
- Rework SerializationReferenceTracker to use a unified Bloom filter + HashSet for both IId and reference-based tracking, improving efficiency and reducing allocations.
- Introduce AcSerializeBase as a common base class for serialization contexts; update Binary, JSON, and Toon contexts to inherit from it.
- Move AcBinaryDeserializationException, AcJsonDeserializationException, and TypeConversionInfo to separate files for better organization.
- Remove obsolete code and update documentation to reflect new reference tracking logic.
2026-01-17 10:06:46 +01:00
Loretta 858d43b881 Refactor: add SerializeTypeMetadataBase for serializers
Introduce SerializeTypeMetadataBase<TMetadata> as a new abstract base class for serializer type metadata, extending TypeMetadataBase. Update Binary, JSON, and Toon serializer metadata classes to inherit from this new base, enabling shared serializer-specific logic and improving code organization. No functional changes to serialization behavior.
2026-01-17 09:42:06 +01:00
Loretta 63ab695a0b Refactor: Rename and reorganize serializer metadata classes
Replaces BinaryTypeMetadata, JsonTypeMetadata, and ToonTypeMetadata with BinarySerializeTypeMetadata, JsonSerializeTypeMetadata, and ToonSerializeTypeMetadata, moving each to its own file. Updates all references and documentation to use new names. Property accessor classes are retained and relocated. Also sets SignalR logging minimum level to Error. No changes to serialization logic; this is a structural/naming refactor for clarity and separation of concerns.
2026-01-17 09:20:06 +01:00
Loretta e3a66857aa IId-based reference deduplication for serialization
Adds robust IId<T>-based reference deduplication to both serialization and deserialization. Objects with the same type and Id are now treated as the same reference, reducing output size and ensuring correct reference identity in complex graphs.

Key changes:
- TypeMetadataBase: Adds IdAccessorType, typed Id getters, and precomputed property arrays for zero-boxing and fast access.
- AcSerializerCommon: Introduces IIdReferenceTracker for efficient (Type, Id) → object mapping.
- SerializationReferenceTracker: Now supports both ReferenceEquals and IId-based tracking for scanning and writing.
- AcBinarySerializer: Reference scan uses IId-aware deduplication, skipping types that don't need tracking.
- AcBinaryDeserializer: Adds per-context IId caches to ensure reference identity on deserialization.
- PropertyAccessorBase: Adds IsComplexType for fast scan decisions.
- Populate/Chain: Integrated with new IId cache for correct deduplication.
- Tests: Adds/updates tests for IId identity, cross-type safety, and diagnostics.
- Benchmarks: Adds WithRef/NoRef and AcJson vs System.Text.Json.
- Documentation: Includes detailed implementation plan and rationale.

No breaking changes for types that do not use IId. Zero-boxing for int/long/Guid Ids. Reference identity is now preserved for all IId objects.
2026-01-16 22:55:52 +01:00
Loretta a4c99853ce Add domain context to Toon meta; IId ref tests for JSON/Bin
- Support optional domain context in Toon serializer metadata (@meta section now includes "context" property if provided).
- Update internal and public APIs to accept and propagate domain context.
- Add overloads for Serialize, SerializeTypeMetadata, and SerializeMetadata to allow specifying domain context.
- Add AcBinarySerializerIIdReferenceTests and AcJsonSerializerIIdReferenceTests to verify IId-based reference deduplication and reference identity for both binary and JSON serializers.
- Tests cover both int and Guid IId scenarios, data integrity, and output size efficiency.
2026-01-16 09:27:59 +01:00
Loretta ba23251644 Add ToonDescription metadata to entities and DTOs
Expanded use of the ToonDescription attribute across core entities and DTOs to provide rich metadata, including business rules, purposes, and status flags. Added class-level and property-level annotations to improve self-documentation and support automated tooling. Introduced new computed properties with ToonDescription in ProductDto, StockQuantityHistoryDto, and StockTakingItem. Updated ToonTypeRelation with an Entity constant. Enhanced test coverage for Toon metadata. Cleaned up imports and removed obsolete test_debug.ps1 script. Updated settings.local.json to support additional Bash commands for tooling. These changes improve introspectability and support for serialization, UI, and API documentation.
2026-01-15 11:33:34 +01:00
Loretta 024b19b830 Add business-logic metadata to DTO serialization
Introduce "business-logic" field in AcToonSerializer type metadata output, sourced from ToonDescription attributes on DTO properties. Annotate relevant OrderDto and OrderItemDto properties with business rules and constraints. Expand allowed Bash commands in settings.local.json. Add test script to verify business-logic metadata presence. Temporarily disable HasToonIgnoreAttribute logic in JsonUtilities.
2026-01-14 22:16:49 +01:00
Loretta c631006303 Use C# 12 [] syntax for empty ToonPropertyAccessor array
Replaced Array.Empty<ToonPropertyAccessor>() with the new C# 12 collection expression [] for initializing the Properties field as an empty array, resulting in more concise and modern code.
2026-01-14 21:04:00 +01:00
Loretta de532c3bc7 Refactor Toon serializer: modularize metadata & relations
Major refactor: split AcToonSerializer.MetaSection.cs into focused modules for meta writing, type/enum definitions, navigation, foreign key, validation, descriptions, placeholders, topological sort, and attribute detection. Extend ToonDescriptionAttribute with BusinessRule, TypeRelation, and RelatedTypes for richer metadata. Add ToonTypeRelation constants. Annotate all DTOs with ToonDescription for type relationships. Refactor TypeMetadataBase for customizable ignore filters. Update tests and settings. Improves maintainability, extensibility, and metadata accuracy.
2026-01-14 15:39:03 +01:00
Loretta 93d38d427f Refactor Toon serializer: robust navigation/type metadata
- Introduce AcNavigationPropertyInfo for unified, cached relationship metadata per property (primary key, navigation type, FK, other key, inverse, target type)
- Refactor relationship detection to use AcNavigationPropertyInfo, replacing ad-hoc logic and old RelationshipMetadata
- Add AcSerializerCommon.GetCSharpTypeName for consistent, C#-style type name formatting (handles primitives, generics, nullables, enums, collections)
- Use topological sort for @types output to ensure dependency order
- Improve enum handling: avoid redundant constraints, use new type name formatter for underlying types
- Output navigation, foreign-key, other-key, and inverse-property metadata consistently in meta section
- Enhance convention-based detection for inverse properties and "other key", including unidirectional/polymorphic support
- Add comprehensive test for navigation metadata completeness and demo test entities
- Add "source-code-language: C#" to meta section
- Misc: code cleanup, remove unused cache, improve property filtering
2026-01-14 08:00:32 +01:00
Loretta 9312298032 Refactor AcToonSerializer metadata extraction & DTO tables
- Remove redundant constraints (nullable, numeric, boolean) from metadata; only explicit [Required] is documented.
- Exclude enum values from constraints; add "readonly" for readonly/init-only properties.
- Filter out primitives from documented types; only complex types and enums are included.
- Detect and document enum backing fields with "enum-type" constraint.
- Only output descriptions if explicitly provided; no fallback/inferred text.
- Add "not-mapped" constraint for [NotMapped]/[NotColumn] properties.
- Switch FruitBankHybrid.Shared.Tests.csproj to direct AyCode.Core project reference.
- Add both LinqToDB and DataAnnotations [Table] attributes to DTOs for ORM compatibility.
2026-01-13 08:25:28 +01:00
Loretta 3400cbc65a Improve AcToonSerializer type metadata handling & tests
- Refined type metadata serialization: collections and dictionaries are now detected and described more accurately, avoiding generic type names (e.g., List`1) and redundant "object" element types.
- Added circular reference protection to type name generation to prevent stack overflows and duplicate type names.
- Updated AcToonSerializerOptions.Compact to use indentation for better readability.
- Introduced ToonTests with unit tests to ensure type metadata correctness, uniqueness, and clarity.
- Added AyCode.Core project to the solution and adjusted namespaces/usings for consistency.
2026-01-12 08:36:23 +01:00
Loretta 223036f8e9 Add Toon serializer: LLM-optimized format & logging
Introduced AcToonSerializer, a new high-performance, LLM-friendly serialization format (Toon) with explicit meta/types/data sections, rich type metadata, and smart inference for property documentation. Added ToonDescriptionAttribute for custom schema annotations and relationship metadata. Implemented fallback/placeholder system merging custom, Microsoft, and inferred metadata. Enhanced logging: AcLoggerBase now implements ILogger, with provider/extensions for DI integration. Updated SignalR client to use AcLogger. Added ToonExtendedInfo.txt with full documentation. No breaking changes to existing serialization.
2026-01-12 07:16:41 +01:00
Loretta d2caa2234d Enhance Toon serialization with relationship & table metadata
- Add automatic detection of primary/foreign keys, navigation types, and table names via ToonDescription, EF Core/Linq2Db attributes, or convention
- Extend ToonDescriptionAttribute with IsPrimaryKey, ForeignKey, Navigation, InverseProperty, and TableName
- Output relationship and table metadata in @types section (enhanced and simple modes)
- Document enums in @types with numeric values and descriptions
- Optimize token usage: compact output when indentation is off
- Show dictionary key/value types in output
- Add SerializeMetadata(IEnumerable<Type>) API for multi-type docs
- Refactor and improve documentation throughout
2026-01-12 07:11:57 +01:00
Loretta bbb21dbb67 Add Toon serializer: LLM-optimized format & rich metadata
Introduces Toon (Token-Oriented Object Notation), a new serialization format designed for LLM readability and token efficiency. Adds core Toon serializer, options, attribute system (ToonDescriptionAttribute), and comprehensive documentation. Supports explicit type metadata, smart fallback/placeholder logic, multi-turn workflows, reference handling, and multi-line strings. No breaking changes; Toon is opt-in and complements existing JSON/binary serializers.
2026-01-10 20:13:54 +01:00
Loretta 028c80db94 Integrate AcLogger with Microsoft.Extensions.Logging
AyCode.Core loggers now implement the ILogger interface, enabling direct integration with Microsoft.Extensions.Logging. Added AcLoggerProvider and extension methods for easy DI registration. Internal LogLevel usages are now AcLogLevel to avoid confusion. This allows seamless use of AyCode loggers in ASP.NET Core and other .NET apps using standard logging abstractions.
2026-01-09 11:12:35 +01:00
Loretta 46c12bf5be Add /reports to .gitignore
Now the /reports directory will be ignored by Git, preventing it from being tracked in the repository. This helps keep generated or report files out of version control.
2026-01-06 08:59:38 +01:00
Loretta 05e91aab60 Add AcBinary Source Generator for fast serialization
Introduce Roslyn Source Generator for AcBinary serialization, generating optimized Serialize/Deserialize methods for types marked with [AcBinarySerializable]. Integrate generator as analyzer in core, tests, and benchmarks. Update metadata to detect generated serializers. Add benchmarks and integration tests to validate performance and correctness. Update project files and internals visibility for testing. Existing runtime serialization remains as fallback.
2026-01-06 08:58:34 +01:00
Loretta ceb8c3d886 Add /reports to .gitignore
Now the /reports directory will be ignored by Git, preventing it from being tracked in the repository. This helps keep generated or report files out of version control.
2026-01-06 08:58:03 +01:00
Loretta 1f2f06ff8c Refactor and unify object/collection deserialization logic
Refactored deserialization to use a unified PopulateObjectCore method, replacing PopulateObjectMerge and PopulateObjectNested to reduce duplication. Improved and centralized IId collection merge logic, with automatic detection and handling in merge mode. Reference ID registration is now consistent for reused objects and collections. Simplified SetPropertyToDefault to set collections to null. Updated documentation and clarified merge mode handling throughout. These changes improve maintainability, consistency, and robustness of deserialization.
2026-01-05 10:30:38 +01:00
Loretta fd3487c12b Update enum values, PropertySkip code, and add int tests
- Changed TestStatus enum to use non-sequential values (5, 10, 20, ...)
- Updated related tests and deserialization logic for new enum values
- Changed PropertySkip marker from 253 to 191 to avoid TinyInt conflicts
- PropertySkip now only written for null references, not empty values
- Improved handling of skipped enum and nullable properties in deserializer
- Enhanced compiled property setter for nullable types
- Added comprehensive int serialization tests, including edge cases
- Fixed namespace casing and added missing using directive
2026-01-05 09:44:02 +01:00
Loretta 65a1d25586 Optimize binary serialization with PropertySkip marker
Refactored object serialization to use fixed property order and a new PropertySkip marker for default/null values, removing property count and index overhead. Updated deserialization logic to handle skip markers and set defaults efficiently. Added WritePropertyOrSkip and SetPropertyToDefault methods for single-pass, boxing-free property handling. SkipObject now throws for new format. Updated BinaryTypeCode, .gitignore, and settings.local.json.
2026-01-04 20:14:41 +01:00
Loretta 4b9e1490ef ... 2026-01-04 09:40:32 +01:00
Loretta 9ad84ec21e Refactor type metadata caching for serializers/deserializers
Major overhaul of type metadata caching:
- Introduced generic TypeMetadataBase<T> with built-in ThreadLocal and global caching, replacing all per-class static caches.
- Updated all serializer/deserializer metadata classes to use the new base, unifying and simplifying cache logic.
- Increased ThreadLocal cache size for better performance with many types.
- Standardized property collection order and improved property array usage.
- Updated documentation and comments to reflect new caching strategy.

These changes improve performance, scalability, and maintainability while reducing code duplication.
2026-01-03 09:50:30 +01:00
Loretta 60ca154c6f Refactor JSON/Binary context & metadata class structure
Renamed and split internal context and metadata classes for JSON and binary serialization/deserialization.
- Replaced CrossTypeDeserializerBase with DeserializeCrossTypeBase; updated all usages.
- Renamed DeserializationContextPool to JsonDeserializationContextPool; SerializationContextPool now uses JsonSerializationContext.
- Renamed DeserializeTypeMetadata to JsonDeserializeTypeMetadata; TypeMetadata to JsonTypeMetadata, each in their own files.
- Updated all method signatures and internal references to use new names.
- Simplified BinaryDeserializeTypeMetadata by removing internal property dictionary.
No changes to public APIs; improves code clarity and maintainability.
2026-01-03 08:34:49 +01:00
Loretta f875738b08 Refactor SignalR dynamic method lookup with static registry
Introduce AcDynamicMethodRegistry<TAttribute> for efficient, static caching and lookup of SignalR methods by messageTag and type. Replace per-instance method lists with a high-performance registry, update all registration and invocation logic to use the new approach, and make method metadata caching type-based and immutable. Also expand Bash permissions in settings.local.json and rename ReadVarUIntFromBytes for consistency. This improves performance, maintainability, and code clarity for dynamic SignalR method invocation.
2026-01-02 15:51:50 +01:00
Loretta f388afcede Add cross-type deserialization and property mapping support
- Enable deserialization and population from TSource to TDest with automatic or custom property mapping (by name or via PropertyMapper).
- Add PropertyMapperDelegate and options for custom mapping logic.
- Implement PropertyMappingCache and BinaryIndexMappingCache for efficient, thread-safe mapping reuse.
- Ensure stable, inheritance-aware property ordering in TypeMetadataBase for reliable cross-type mapping.
- Add CrossTypeDeserializerBase utility for shared cross-type logic.
- Add AcBinaryDeserializer.CrossType with new Deserialize<TSource, TDest> and Populate<TSource, TDest> overloads.
- Update settings.local.json to allow additional Bash commands for development.
- Improve documentation and add missing using directives.
2026-01-02 07:45:42 +01:00
Loretta 28a818b1ae Refactor serialization infra, add perf benchmarks
- Unified reference tracking for JSON/Binary serializers using int IDs
- Added ThreadLocalCache and shared SerializationReferenceTracker
- Refactored IId metadata caching into DeserializeTypeMetadataBase
- Optimized array/list iteration (span, index, ref) in serializers
- Added RefForeachBenchmark and ValueTypePassingBenchmark
- Enhanced AcObservableCollection with SynchronizationContext support
- Added LargeScaleBinaryBenchmark for production-like scenarios
- Improved benchmark result directory handling
- Skipped two complex JSON reference tests
- Miscellaneous code cleanup and documentation updates
2025-12-30 19:29:39 +01:00
Loretta a72f9883b4 Refactor: property index-based binary serialization
Switch to deterministic property index-based serialization/deserialization for improved performance and cross-platform consistency. Properties are now ordered alphabetically and accessed by index, enabling O(1) lookups and eliminating string/dictionary overhead. Introduce thread-local metadata caches, refactor metadata and populate logic, optimize string interning and enum handling, and remove legacy name-based code paths. Update diagnostics and documentation for clarity and maintainability.
2025-12-30 12:34:06 +01:00
Loretta 0552268ac1 Refactor: Add high-performance Chain API for serializers
Major overhaul of serialization/deserialization infrastructure:
- Introduced unified Chain API for binary/JSON, enabling multi-deserialization/population with strong IId reference identity (critical for Blazor/DXGrid).
- Added base classes for property accessors/setters and centralized type metadata.
- Implemented ChainReferenceTracker and shared IIdCollectionMergeHelper for reference tracking and collection merging.
- Refactored property access logic to use typed delegates for primitives/enums.
- Updated extension methods and replaced legacy chain/populate interfaces.
- Improved error handling and diagnostics.
- Added comprehensive tests for chain API and reference preservation.
- Minor fixes and performance optimizations throughout.
2025-12-29 22:41:28 +01:00
Loretta 9f8c027366 Add expression serialization & chain API for JSON deserialization
- Introduced a high-performance, reusable "chain" API for JSON deserialization and object population, enabling parsed JSON to be reused for multiple deserializations without reparsing. Exposed via new extension methods and interfaces (`IDeserializeChain<T>`, `IPopulateChain`).
- Added comprehensive infrastructure for serializing and deserializing .NET Expression trees to a universal DTO (`AcExpressionNode`), with full round-trip support and robust handling of constants, closures, and queryable expressions.
- Centralized all property accessor and expression utilities in `AcSerializerCommon` to avoid duplication and improve maintainability.
- Enhanced both JSON and binary serializers to support Expression and IQueryable types, with automatic conversion to/from `AcExpressionNode`.
- Refactored type metadata and property accessor logic for performance and code reuse.
- Added extensive unit tests for the new chain API and expression serialization, and reorganized test models and namespaces for clarity.
- Improved logging, error handling, and test infrastructure.
- Misc: Added settings for local builds, updated project files, and cleaned up obsolete code.
2025-12-29 15:28:46 +01:00
Loretta 9fad870960 Support optional/default params in SignalR method calls
Added support for optional/default parameters in SignalR method invocation. The hub now fills in missing arguments with default values when not provided, and throws if required parameters are missing. Added comprehensive tests for default parameter handling, a new handler method for testing, and a tag constant. Also improved code style with C# pattern matching and made UseStringCaching immutable.
2025-12-24 17:30:28 +01:00
Loretta a2f392a247 Use 'var' for local variables in AcWebSignalRHubBase
Replaced explicit 'int' declarations with 'var' for local variables in for loops and variable initializations. This improves code conciseness and maintains consistency without altering functionality.
2025-12-23 11:11:51 +01:00
Loretta d35c7bd066 Increase log level for missing dynamic method to Warning
Changed the log level from Debug to Warning when a dynamic method
is not found for a given tag, making these events more visible
in the logs and easier to monitor.
2025-12-22 14:37:44 +01:00
Loretta f839013b5b Fix string cache hash collisions in WASM deserialization
Previously, the string cache hash function only used the first 4 bytes and length, causing collisions for property names like "Creator" and "Created" in WASM. This led to incorrect property assignments and deserialization errors. The new ComputeStringHashFull function now hashes all bytes for strings up to 32 bytes, eliminating these collisions. Extensive comments document the bug, its impact, and the necessity of the fix.
2025-12-20 10:20:48 +01:00
Loretta 4b2d3f4e75 Add opt-in binary diagnostics for SignalR serialization
Introduces detailed, opt-in diagnostic logging for binary serialization and deserialization in SignalR server and client code. Adds type and binary-level diagnostics, including hex dumps and header parsing, to aid debugging of protocol and type mismatches. Improves string hash function in the binary deserializer to avoid collisions. Diagnostics are consistent and structured, and can be enabled independently on server, client, and response message classes. No impact on normal operation unless diagnostics are enabled.
2025-12-20 08:43:22 +01:00
Loretta cde2b5e529 Refactor serializer tests, fix deserializer bugs, add Gzip
Major overhaul of binary serializer/deserializer tests: split and expand test coverage for primitives, objects, navigation, generics, circular refs, and edge cases. Fix critical bugs in property skipping, string interning, type mismatch diagnostics, nullable assignment, and VarInt decoding. Add WASM-optimized deserialization options with string caching. Switch SignalR compression from Brotli to Gzip and introduce GzipHelper. Add comprehensive StockTaking test models and real-world bug reproductions. Improve diagnostics, test discovery, and add benchmark/utility scripts.
2025-12-19 19:29:12 +01:00
Loretta 762088caf7 Refactor and expand SignalR data source tests
Split SignalRDataSourceTests into multiple focused files and introduce an abstract base test class for comprehensive coverage. Add concrete test classes for all serializer and collection type combinations. Move data source implementations to separate files. Test coverage now includes edge cases, interface compliance, and advanced scenarios.
2025-12-15 19:28:20 +01:00
Loretta b8143e4897 Add FixStr encoding for short strings; SIMD bulk copy
Introduces FixStr encoding (type codes 34–65) for short ASCII/UTF8 strings up to 31 bytes, combining type and length in one byte for improved space and speed. Adds SIMD-optimized bulk copy methods for double, float, and Guid arrays. Updates deserializer to handle FixStr codes efficiently. Adjusts tiny int encoding range to free up FixStr space. Disables metadata and string interning in shallow copy options. Improves performance and reduces overhead for common serialization scenarios.
2025-12-15 17:21:18 +01:00
Loretta a832d8e86d Expand QuickBenchmark suite & add CLI scripts
Majorly enhanced QuickBenchmark.cs with new helper methods, standardized iteration count, and several comprehensive benchmarks comparing AcBinary (with/without reference handling) to MessagePack. Improved output formatting for clarity. Added RunQuickBenchmark.ps1 and .bat scripts for easy CLI execution and registered them as solution items. These changes make benchmarking more robust, readable, and user-friendly.
2025-12-15 12:00:03 +01:00
Loretta bc30a3aede Refactor: Add high-perf JSON serializer & merge support
- Introduced AcJsonSerializer/AcJsonDeserializer in AyCode.Core.Serializers.Jsons, optimized for IId<T> reference and circular reference handling.
- Added AcJsonSerializerOptions/AcSerializerOptions for configurable reference handling and max depth.
- Implemented fast-path streaming (Utf8JsonReader/Writer) with fallback to DOM for reference scenarios.
- Added type metadata/property accessor caching for performance.
- Provided robust object/collection population with merge semantics for IId<T> collections.
- Added AcJsonDeserializationException for detailed error reporting.
- Implemented UnifiedMergeContractResolver for Newtonsoft.Json, supporting JsonNoMergeCollectionAttribute to control merge behavior.
- Added IdAwareCollectionMergeConverter<TItem, TId> for merging IId<T> collections by ID.
- Included helpers for ID extraction and semantic ID generation.
- Added DeepPopulateWithMerge extension for deep merging.
- Optimized with frozen dictionaries, pre-encoded property names, and context pooling.
- Ensured compatibility with both System.Text.Json and Newtonsoft.Json.
2025-12-14 19:34:49 +01:00
Loretta b17c2df6c2 Add header-based string interning to AcBinarySerializer
- Support preloaded string intern table in binary header for efficient string interning and reduced output size.
- Add HeaderFlag_StringInternTable to BinaryTypeCode and update serializer/deserializer to handle intern tables.
- Simplify string interning logic: always intern eligible strings, remove candidate tracking.
- Refactor property name table and buffer management for clarity and efficiency.
- Remove obsolete interning/property name methods from serializer context.
- Add new output methods (ToArray, WriteTo, DetachResult) for buffer/result handling.
- Introduce QuickBenchmark.cs with benchmarks comparing AcBinarySerializer (with/without interning), JSON, and MessagePack, including repeated string scenarios.
2025-12-14 15:12:11 +01:00
Loretta 271f23d0f6 Enhance AcBinary: property filter, string interning, arrays
- Add property-level filtering via BinaryPropertyFilter delegate and context
- Improve string interning with new StringInternNew type code and promotion logic
- Optimize array and dictionary serialization for primitive types
- Expose strongly-typed property accessors for primitives and enums
- Add new benchmarks for serialization modes
- Refactor buffer pooling and cleanup code
- All new features are opt-in; maintains backward compatibility
2025-12-14 12:45:29 +01:00
Loretta 5601c0d3e2 Refactor serialization core, add pooled buffer support
Centralize serialization steps into a new SerializeCore method for consistency and maintainability. Rework property metadata registration to operate on types instead of instances, improving efficiency. Replace property index tracking with stack-allocated or pooled buffers to reduce allocations. Add SerializeToPooledBuffer and BinarySerializationResult for zero-copy serialization with proper buffer pooling and disposal. Simplify string writing logic and use GC.AllocateUninitializedArray for result arrays. Refactor and add helper methods for buffer management and metadata handling.
2025-12-14 10:20:07 +01:00
Loretta 3b5a895fbc Add BSON to benchmarks; optimize AcBinary deserializer
- Add BSON (MongoDB.Bson) as a serialization format in benchmarks, enabling direct comparison with AcBinary, MessagePack, and JSON.
- Update all AcBinary benchmarks to use options without reference handling for fair comparison.
- Show BSON results and ratios in benchmark output and size comparisons.
- Refactor AcBinaryDeserializer to use a fixed-size array for type reader dispatch, improving lookup speed and reducing allocations.
- Add a concurrent cache for type conversion info to optimize enum and nullable conversions.
- Use a cached UTF8Encoding instance for string decoding.
- Use FrozenDictionary for property lookup in BinaryDeserializeTypeMetadata.
- Remove legacy WithRef code paths and clean up formatting and comments.
- Improve error handling and fallback logic for BSON serialization/deserialization.
2025-12-14 04:47:16 +01:00
Loretta 74b4bbfd30 Add size_output.txt to .gitignore
Prevent size_output.txt from being tracked by Git by adding it to the .gitignore file. This helps keep the repository clean of generated or temporary files.
2025-12-14 04:46:54 +01:00
Loretta 2f1c00fd5c Remove MessagePack; unify SignalR serialization model
Major refactor to eliminate MessagePack from SignalR messaging.
All serialization now uses explicit binary methods (.ToBinary/.BinaryTo)
and Brotli-compressed JSON, managed via a new SignalRSerializationHelper.
Custom stream classes and MessagePack attributes are removed.
API is now consistent, type-safe, and easier to maintain.
Test code and all message handling updated to use the new model.
2025-12-14 01:45:17 +01:00
Loretta 489ef7486c Optimize JSON/Brotli serialization for zero-allocation
Major performance improvements for SignalR message transport:
- BrotliHelper now uses ArrayPool and stackalloc for zero-allocation compression/decompression; added span/IBufferWriter overloads and pooled buffer support.
- AcJsonDeserializer supports direct deserialization from UTF-8 spans, with fast path for no reference handling.
- SignalResponseDataMessage uses pooled buffers for Brotli decompression and zero-copy deserialization; implements IDisposable for buffer return.
- IdMessage serialization optimized for primitives and Guids to avoid unnecessary allocations.
- Added JsonTo<T> span/byte[] extension methods for zero-allocation deserialization.
- All changes are backward compatible and reduce GC pressure for high-throughput scenarios.
2025-12-13 23:23:16 +01:00
Loretta ac6735ebd8 Unify SignalR response serialization (JSON/Binary/Brotli)
Major refactor: all SignalR responses now use a single unified `SignalResponseDataMessage` type with binary payloads. JSON responses are Brotli-compressed for efficiency. Removed legacy JSON/Binary response types and MessagePack server-to-client serialization. Updated all serialization extensions for zero-allocation binary ops. Refactored client/server/data source/test code to use new message and serialization model. Improved deserialization robustness for primitives. Modernized and streamlined test suite.
2025-12-13 23:01:18 +01:00
Loretta 60238952d8 Rename BenchmarkSuite1 to AyCode.Benchmark project
Renamed the benchmark project from BenchmarkSuite1 to AyCode.Benchmark, updating the solution reference and moving all benchmark source files under the new project and namespace. No functional changes were made; this is a structural and naming reorganization for clarity and maintainability.
2025-12-13 10:11:39 +01:00
Loretta 9f1c31bd15 Centralize test/benchmark results; optimize deserializer
Introduce a unified Test_Benchmark_Results directory for all test, benchmark, and coverage artifacts, with MSBuild properties and MSTest runsettings for consistent output. Update .gitignore to exclude results. Refactor BenchmarkSuite1 to ensure all logs and artifacts are stored in versioned subfolders, and add logic for coverage file management.

Majorly optimize AcBinaryDeserializer: reuse existing nested objects and collections during deserialization, add PopulateListOptimized for in-place list updates, and use String.Create for efficient UTF8 decoding. Extend property metadata to track complex/collection types. Update MessagePack and BenchmarkDotNetDiagnosers package versions. Remove obsolete benchmark-report.html.
2025-12-13 09:59:18 +01:00
Loretta 056aae97a5 Optimize AcBinarySerializer: typed accessors & array writers
- Add typed property accessors to avoid boxing and speed up value type serialization
- Implement bulk array writers for primitives (int, long, double, etc.) for efficient, zero-copy serialization
- Add zero-copy IBufferWriter serialization and size estimation methods
- Refactor array/dictionary serialization for fast paths and memory efficiency
- Improve context pool memory management and reduce initial dictionary/set capacities
- Fix benchmark to avoid state accumulation between runs
- Downgrade MessagePack dependency for compatibility
2025-12-13 03:25:02 +01:00
Loretta f69b14c195 AcBinary: Major perf/memory optimizations & new benchmarks
- Zero-allocation hot paths for primitive (de)serialization using MemoryMarshal/Unsafe
- FrozenDictionary-based type dispatch for fast deserialization
- Optimized span-based UTF8 string handling, stackalloc for small strings
- Specialized fast-paths for primitive arrays (int, double, etc.)
- Binary header now uses flag-based format (48+) for metadata/ref handling
- Improved buffer management with ArrayPool and minimum size
- Property access via for-loops for better JIT and less overhead
- SignalR test infra supports full serializer options (WithRef/NoRef)
- Added comprehensive AcBinary vs MessagePack benchmarks (speed, memory, size)
- Added rich HTML benchmark report (benchmark-report.html)
- Updated JsonUtilities for new header detection
- Improved documentation and code comments throughout
2025-12-13 01:24:48 +01:00
Loretta 6faed09f9f Fix binary deserializer string interning and add regressions
- Fix: Ensure property names and skipped strings are interned during binary deserialization, preventing interned string index mismatches.
- Add: Extensive regression tests for string interning edge cases, including property mismatch (server has more properties than client), deeply nested objects, repeated/unique strings, and empty/null handling.
- Add: New test DTOs and infrastructure for property mismatch scenarios.
- Update: SignalR test helpers and services to support both JSON and Binary serialization in all tests.
- Improve: SignalRRequestModelPool now initializes models on Get().
- These changes address production bugs and ensure robust, consistent string interning and property skipping in AcBinarySerializer.
2025-12-13 00:12:21 +01:00
Loretta 1a9e760b68 Fix Binary serialization of string DateTimes in attributes
Add regression tests for string-stored DateTime values in GenericAttribute-like collections, ensuring exact value preservation in Binary serialization. Fix AcBinaryDeserializer to only intern strings >= 4 chars, matching serializer logic and preventing intern table index mismatches. Refactor SignalR tests to run under both JSON and Binary modes, and add comprehensive tests for edge cases and production bug scenarios. Clean up test code and comments for clarity.
2025-12-12 22:59:54 +01:00
Loretta 09a4604e52 Add binary serialization support for SignalR responses
Introduces SignalResponseBinaryMessage for efficient binary serialization of response data alongside existing JSON support. Adds utilities to detect serializer type and updates both server and client logic to handle JSON or binary formats automatically. Refactors response creation, logging, and deserialization for consistency and performance. Updates benchmarks and ensures all MessagePack operations use ContractlessStandardResolver.Options. Improves robustness and backward compatibility in client callback handling.
2025-12-12 21:40:48 +01:00
Loretta 2147d981db Refactor: new high-performance binary serializer/deserializer
Major overhaul of binary serialization:
- Rewrote AcBinarySerializer as a static, optimized, feature-rich serializer with VarInt encoding, string interning, property name tables, reference handling, and optional metadata.
- Added AcBinaryDeserializer with matching features, including merge/populate support and robust error handling.
- Introduced AcBinarySerializerOptions and AcSerializerOptions base class for unified serializer configuration (JSON/binary).
- Added generic extension methods for "any" serialization/deserialization based on options.
- Updated tests and benchmarks for new APIs; fixed null byte code and added DateTimeKind test.
- Fixed namespace typos and improved code style and documentation.
2025-12-12 21:03:39 +01:00
Loretta b9e83e2ef8 Add AcBinarySerializer tests, helpers, and benchmark updates
- Introduce AcBinarySerializerTests with full coverage for primitives, objects, collections, merge/populate, and size comparisons
- Add AcBinarySerializer class stub as a placeholder for implementation
- Extend serialization extension methods with binary helpers (ToBinary, BinaryTo, BinaryCloneTo, etc.)
- Update test models to ignore parent references for all major serializers ([IgnoreMember], [BsonIgnore])
- Refactor benchmarks: split into minimal, simple, complex, and MessagePack comparison; add command-line switches and improved size reporting
- Optimize AcJsonDeserializer with fast UTF-8 property lookup and direct primitive setting
- Add MessagePack and MongoDB.Bson dependencies to test and benchmark projects
- Add (accidentally) a summary of less commands as a documentation artifact
2025-12-12 20:06:00 +01:00
Loretta a945db9b09 High-perf streaming JSON (de)serialization, refactor
Major upgrade to AcJsonSerializer/AcJsonDeserializer:
- Add Utf8JsonReader/Writer fast-paths for streaming, allocation-free (de)serialization when reference handling is not needed, matching STJ performance.
- Populate/merge now uses streaming for in-place updates.
- Type metadata caches TypeCode, UnderlyingType, and uses frozen dictionaries for hot property lookup.
- Context pooling reduces allocations and GC pressure.
- Primitive (de)serialization uses TypeCode-based fast paths; improved enum, Guid, DateTime, etc. handling.
- Shared UTF8 buffer pool for efficient encoding/decoding.
- Pre-encoded property names and STJ writer for output.
- Improved validation, error handling, and double-serialization detection.
- Expanded benchmarks: small/medium/large, with/without refs, AyCode vs STJ vs Newtonsoft, grouped by scenario.
- General code modernization: aggressive inlining, ref params, ReferenceEquals, improved naming, and comments.
- Unified IId<T> and collection element detection; consistent $id/$ref handling.

Brings AyCode JSON (de)serializer to near-parity with STJ for non-ref scenarios, while retaining advanced features and improving maintainability and performance.
2025-12-12 16:23:54 +01:00
Loretta ad426feba4 Refactor JSON (de)serialization: options, depth, utilities
Major overhaul of JSON serialization/deserialization:
- Introduce AcJsonSerializerOptions for reference handling and max depth
- Centralize type checks, primitive/collection logic in JsonUtilities
- Add depth limiting to serializer/deserializer (MaxDepth support)
- Make $id/$ref reference handling optional via options
- Unify and simplify public API (ToJson, JsonTo, CloneTo, CopyTo, etc.)
- Improve primitive, enum, and collection handling and caching
- Refactor contract resolver and merge logic to use new utilities
- Remove redundant code, centralize string escaping/unescaping
- Update all tests and benchmarks to use new API and options
- Fix minor bugs and improve error handling and validation

This modernizes and unifies the JSON infrastructure for better performance, flexibility, and maintainability.
2025-12-12 11:30:55 +01:00
Loretta 8e7869b3da Improve JSON deserialization and observable collection handling
- Add batch update support for IAcObservableCollection during deserialization to suppress per-item notifications and fire a single reset event.
- Throw AcJsonDeserializationException with a clear message if a type lacks a parameterless constructor during deserialization.
- Enhance IsGenericCollectionType to recognize more collection types, including ObservableCollection<T> and custom IList<T> implementations.
- Improve array and collection deserialization logic to better handle target types and fall back to List<T> if needed.
- In AcObservableCollection, catch and ignore ObjectDisposedException in event handlers to prevent errors from disposed subscribers.
- Remove redundant batch update logic from AcSignalRDataSource; rely on deserializer's handling.
- Set SkipNegotiation = true in SignalR client options for WebSockets-only optimization and comment out automatic HubConnection.StartAsync() for more controlled connection management.
2025-12-11 23:46:30 +01:00
Loretta c29b3daa0e Refactor JSON/SignalR infra; add full test & benchmark suite
Major overhaul of JSON serialization/deserialization:
- Added AcJsonDeserializationException for clearer error reporting.
- Robust JSON validation, type checking, and double-serialization detection.
- Fast-path primitive (de)serialization for all common .NET types.
- Direct support for Dictionary<TKey, TValue> and improved collection handling.
- Enhanced $id/$ref reference resolution and in-place merging.
- Optimized property metadata caching and filtering.
- Comprehensive error handling with detailed messages.
- Extensive new tests for primitives, collections, references, and error cases.
- Benchmarks for serialization, deserialization, and merge scenarios.

SignalR infrastructure improvements:
- Refactored AcSignalRClientBase/AcWebSignalRHubBase for testability (virtual methods, test constructors).
- Added SignalRRequestModelPool for efficient request/response tracking.
- Improved parameter deserialization and IdMessage handling.
- New tags (PingTag, EchoTag) for SignalR messaging.

New test and benchmark infrastructure for SignalR:
- Shared test models and data factories for DTOs, primitives, and complex graphs.
- In-memory, dependency-free SignalR client/hub for round-trip testing.
- Exhaustive test suites for message processing, method invocation, and edge cases (including prior production bugs with Task<T>).
- Benchmarks for SignalR serialization, deserialization, and round-trip performance.

Other changes:
- Improved TaskHelper.WaitTo/WaitToAsync with more accurate polling and cancellation support.
- ExtensionMethods.InvokeMethod now properly unwraps Task/ValueTask results.
- General code cleanup, improved comments, and removal of obsolete code.
- Project references updated for shared test/benchmark infrastructure.

These changes deliver a robust, high-performance, and fully tested JSON/SignalR (de)serialization system, ready for production and advanced testing scenarios.
2025-12-11 21:25:50 +01:00
Loretta 5abff05031 lot of improvements and new mstests... 2025-12-11 21:24:32 +01:00
Loretta a0445e6d1e Improve JSON (de)serializer: WASM, SignalR, perf, tests
- Use compiled expression tree accessors for property get/set (AOT/WASM compatible, much faster than reflection)
- Add comprehensive WASM/AOT compatibility and SignalR parameter array tests
- Correctly handle $id/$ref for shared references; optimize reference resolution
- Always serialize empty collections as [], omit null collections
- Optimize primitive reading and type metadata caching
- Fix edge cases in array, primitive, and reference deserialization
- Ensure output matches Newtonsoft.Json for arrays and primitives
- Greatly expand test coverage for all major scenarios
2025-12-09 11:26:55 +01:00
Loretta f9dc9a65fb High-performance, thread-safe JSON and data source overhaul
- Introduced AcJsonSerializer/Deserializer: fast, reflection-free, streaming JSON with optimized $id/$ref handling and Base62 IDs.
- Default serialization now uses new serializers; falls back to Newtonsoft for complex cases.
- Extensive type/property caching for performance and thread safety.
- Refactored MergeContractResolver and collection merge logic; all merge/populate operations use centralized caches.
- AcObservableCollection and AcSignalRDataSource are now fully thread-safe and support batch operations.
- SignalResponseMessage<T> supports lazy deserialization and direct JSON access.
- Added comprehensive unit tests and benchmarks for serialization, deserialization, and collection operations.
- Updated .gitignore and solution files; refactored core classes for clarity and performance.
2025-12-09 03:24:51 +01:00
Loretta 166d97106d Enhance JSON handling and add hybrid reference support
- Updated all projects to use `Newtonsoft.Json` v13.0.3 for consistency.
- Introduced `HybridReferenceResolver` for semantic and numeric ID handling.
- Refactored `SerializeObjectExtensions` to support deep JSON merging.
- Simplified `IId<T>` interface by removing `IEquatable<T>` constraint.
- Improved `AcSignalRDataSource` with robust `AddRange` and `CopyTo` methods.
- Added `JsonExtensionTests` for deep hierarchy, reference, and edge cases.
- Implemented `UnifiedMergeContractResolver` for custom JSON behavior.
- Optimized type/property caching with `TypeCache` and `CachedPropertyInfo`.
- Enhanced SignalR integration to fix primitive array deserialization issues.
- Introduced `JsonNoMergeCollection` attribute for replace-only collections.
- Added test DTOs and `TestDataFactory` for real-world scenario simulations.
- Improved performance with `ConcurrentDictionary` and `ObjectPool`.
- Fixed `$id`/`$ref` handling for non-semantic references and arrays.
2025-12-08 15:50:48 +01:00
Loretta f3ec941774 microsoft packages update 2025-12-01 16:18:36 +01:00
Loretta da5ba340f7 ... 2025-11-26 10:14:56 +01:00
Loretta 2ecc7b1a7a improvements, fixes 2025-11-26 09:42:00 +01:00
Loretta 1131b5675b improvements, fixes, etc... 2025-11-24 08:26:55 +01:00
Loretta a67bd4f698 improvements 2025-11-21 16:29:05 +01:00
Loretta a724fce2f6 improvements 2025-11-21 07:20:35 +01:00
Loretta e0666027b3 improvements, fixes 2025-11-20 08:30:37 +01:00
Loretta dd5dc68862 TransportSendTimeout fix 2025-11-16 19:21:24 +01:00
Loretta 545b543abe fixes 2025-11-12 17:19:45 +01:00
Loretta bb3cc3c37c CollectionExtensions UpdateCollectionById<TId> 2025-11-07 14:10:30 +01:00
Loretta c1a707139c Implement IgnoreAndRenamePropertySerializerContractResolver 2025-10-31 13:51:39 +01:00
Loretta a24f0c1681 SignalR improvements; 2025-10-30 14:55:47 +01:00
Loretta 6f4f87ddd9 Logger improvements 2025-10-23 06:35:32 +02:00
Loretta ecdd922be2 AcLoggerBase get AppConfiguration fix 2025-10-22 06:46:30 +02:00
Loretta 0ee715ed97 improvements 2025-10-21 15:15:15 +02:00
Loretta a56f96903e nuget packages update 2025-10-20 16:45:31 +02:00
Loretta 981f86c701 improvements, fixes, etc... 2025-10-18 08:43:57 +02:00
Loretta f26f04eec5 fixes 2025-10-12 18:07:53 +02:00
Loretta e865a0535a CopyPublicValueTypeProperties fixes 2025-10-12 07:47:39 +02:00
Loretta 7609e94f18 PrpertyHelper; ICustomForeignKey 2025-10-11 17:51:58 +02:00
Loretta 9657e7449f CollectionExtensionsInt 2025-10-06 07:46:17 +02:00
Loretta 1a73253867 nuget packages update 2025-09-19 14:42:54 +02:00
Loretta d8360a3bd5 AcModelDtoExtension improvements 2025-09-12 13:36:11 +02:00
Loretta 9673b629c6 Implement SignalRLoggerClient 2025-09-02 11:48:05 +02:00
Loretta 77a6f26a5c AcSignalRClient; AcBrowserLogWriter... 2025-09-01 16:19:01 +02:00
497 changed files with 82384 additions and 1312 deletions

143
.claude/settings.local.json Normal file

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.

169
.github/copilot-instructions.md vendored Normal file

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.

10
.gitignore vendored
View File

@ -18,6 +18,9 @@ bin/
/.vs/*
/.vs/**
/.claude/*
/.claude/**
# User-specific files
*.rsuser
*.suo
@ -372,4 +375,9 @@ MigrationBackup/
.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd
FodyWeavers.xsd
/BenchmarkSuite1/Results
/CoverageReport
/Test_Benchmark_Results
/size_output.txt
/reports

210
.plan Normal file
View File

@ -0,0 +1,210 @@
# Buffer-in-Context terv: `_buffer`/`_position` visszahelyezése a context-be
## Probléma
A TOutput generic refaktorálás ~30-40%-os serialization regressziót okozott.
Ok: .NET JIT reference type generikusoknál SHARED kódot generál → minden `output.WriteByte()` virtuális dispatch, még sealed osztályoknál is.
## Megoldás
`_buffer` + `_position` visszakerül a `BinarySerializationContext<TOutput>`-ba.
Minden hot path write metódus inline context metódus lesz: `_buffer[_position++] = value`.
A `TOutput Output` kizárólag cold path Grow/Flush-t kezel.
---
## 1. Új BinaryOutputBase (3 absztrakt metódus)
A jelenlegi 19 abstract + 9 virtual metódus helyett:
```csharp
public abstract class BinaryOutputBase
{
public abstract void Initialize(out byte[] buffer, out int position, out int bufferEnd);
public abstract void Grow(ref byte[] buffer, ref int position, ref int bufferEnd, int needed);
public abstract int GetTotalPosition(int currentPosition);
}
```
- `Initialize`: kezdeti buffer kiadása
- `Grow`: cold path — buffer betelik → ArrayPool.Rent/copy VAGY Advance+GetMemory
- `GetTotalPosition`: Position property-hez (cold path, 1x hívás per serialize)
- `IBinaryOutput.cs` törlése (nem implementálja többé senki)
## 2. BinarySerializationContext<TOutput> — új mezők + write metódusok
### Új mezők:
```csharp
internal byte[] _buffer = null!;
internal int _position;
internal int _bufferEnd; // writeable terület vége (_position < _bufferEnd)
```
### Position property:
```csharp
public int Position => Output.GetTotalPosition(_position);
// ArrayBinaryOutput: return currentPosition (CommittedBytes=0, bufferStart=0)
// BufferWriterBinaryOutput: return _committedBytes + (currentPosition - _chunkStart)
```
### EnsureCapacity (privát):
```csharp
[AggressiveInlining]
private void EnsureCapacity(int additionalBytes)
{
if (_position + additionalBytes > _bufferEnd)
Output.Grow(ref _buffer, ref _position, ref _bufferEnd, additionalBytes);
}
```
### Write metódusok (mind [AggressiveInlining], az ArrayBinaryOutput implementációiból portolva):
1. WriteByte(byte)
2. WriteTwoBytes(byte, byte)
3. WriteBytes(ReadOnlySpan<byte>)
4. WriteRaw<T>(T) where T : unmanaged
5. WriteTypeCodeAndRaw<T>(byte, T)
6. WriteVarUInt(uint) — fast path < 0x80
7. WriteVarInt(int) — ZigZag + WriteVarUInt
8. WriteVarULong(ulong)
9. WriteVarLong(long)
10. WriteDecimalBits(decimal)
11. WriteDateTimeBits(DateTime)
12. WriteGuidBits(Guid)
13. WriteDateTimeOffsetBits(DateTimeOffset)
14. WriteStringUtf8(string)
15. WriteFixStr(string)
16. WriteFixStrDirect(string)
17. WriteFixStrBytes(ReadOnlySpan<byte>)
18. WritePreencodedPropertyName(ReadOnlySpan<byte>)
19. WriteDoubleArrayBulk(double[])
20. WriteFloatArrayBulk(float[])
21. WriteGuidArrayBulk(Guid[])
22. WriteInt32ArrayOptimized(int[])
23. WriteLongArrayOptimized(long[])
24. WriteBytesSimd(ReadOnlySpan<byte>)
Mind `_buffer[_position++]` pattern-nel — NULLA virtual dispatch a hot path-on.
### WriteHeader / WriteInlineMetadata:
- `Output.WriteByte()` → `WriteByte()` (self)
- `WriteInlineMetadata` signature: `output` param eltávolítása
## 3. ArrayBinaryOutput (~430 → ~120 sor)
```csharp
public sealed class ArrayBinaryOutput : BinaryOutputBase, IDisposable
{
private byte[] _rentedBuffer;
public override void Initialize(out byte[] buffer, out int position, out int bufferEnd)
{
buffer = _rentedBuffer; position = 0; bufferEnd = _rentedBuffer.Length;
}
[NoInlining]
public override void Grow(ref byte[] buffer, ref int position, ref int bufferEnd, int needed)
{
// ArrayPool.Rent bigger + copy + return old
// position marad, bufferEnd = newBuffer.Length
_rentedBuffer = newBuffer;
}
public override int GetTotalPosition(int currentPosition) => currentPosition;
// Eredmény metódusok — buffer/position paramétert kapnak a context-ből:
public ReadOnlySpan<byte> AsSpan(byte[] buffer, int position);
public byte[] ToArray(byte[] buffer, int position);
public BinarySerializationResult DetachResult(byte[] buffer, int position);
public void WriteTo(IBufferWriter<byte> writer, byte[] buffer, int position);
}
```
## 4. BufferWriterBinaryOutput (~350 → ~100 sor)
```csharp
public sealed class BufferWriterBinaryOutput : BinaryOutputBase
{
private readonly IBufferWriter<byte> _writer;
private int _committedBytes;
private int _currentChunkStart;
private bool _ownedBuffer;
public override void Initialize(out byte[] buffer, out int position, out int bufferEnd)
{
_committedBytes = 0;
AcquireChunk(MinChunkRequest, out buffer, out position, out bufferEnd);
_currentChunkStart = position;
}
[NoInlining]
public override void Grow(ref byte[] buffer, ref int position, ref int bufferEnd, int needed)
{
// 1. Advance current chunk: _writer.Advance(position - _currentChunkStart)
// 2. _committedBytes += bytesInChunk
// 3. AcquireChunk(needed, out buffer, out position, out bufferEnd)
// 4. _currentChunkStart = position
}
public override int GetTotalPosition(int currentPosition)
=> _committedBytes + (currentPosition - _currentChunkStart);
public void Flush(byte[] buffer, int position)
{
// Utolsó chunk commit-ja
}
private void AcquireChunk(int requestSize, out byte[] buffer, out int position, out int bufferEnd)
{
// GetMemory() + TryGetArray() → buffer=segment.Array, position=segment.Offset
// Fallback: ArrayPool.Rent owned buffer
}
}
```
## 5. AcBinarySerializer.cs (~130 call site változás)
### Signature változások:
- `WriteInt32<TOutput>(int, TOutput output)` → `WriteInt32<TOutput>(int, BinarySerializationContext<TOutput> context)`
- `WriteString<TOutput>(string, TOutput output, context)` → `WriteString<TOutput>(string, BinarySerializationContext<TOutput> context)`
- Minden helper: `output` param eltávolítása, `context` marad
- `var output = context.Output;` sorok törlése
- `output.WriteByte(...)` → `context.WriteByte(...)`
### TryWritePrimitiveArrayCore:
- Jelenleg non-generic `BinaryOutputBase output` param
- Új: generic `BinarySerializationContext<TOutput> context` param (2 JIT copy elfogadható, per-array hívás)
### Public API metódusok:
```csharp
// Serialize<T> (byte[]):
context.Output.Initialize(out context._buffer, out context._position, out context._bufferEnd);
// ... serialize ...
return context.Output.ToArray(context._buffer, context._position);
// Serialize<T> (IBufferWriter):
output.Initialize(out context._buffer, out context._position, out context._bufferEnd);
// ... serialize ...
output.Flush(context._buffer, context._position);
```
## 6. ScanPass.cs — NINCS VÁLTOZÁS
Már most is csak context-et kap, nem ír semmit.
## 7. IBinaryOutput.cs — TÖRLÉS
Senki nem implementálja többé.
## 8. Implementációs sorrend
1. Context: `_buffer`/`_position`/`_bufferEnd` mezők + 24 write metódus hozzáadása
2. BinaryOutputBase: 28 metódus → 3 (Initialize, Grow, GetTotalPosition)
3. ArrayBinaryOutput: egyszerűsítés (Grow + result metódusok)
4. BufferWriterBinaryOutput: egyszerűsítés (Grow + Flush + AcquireChunk)
5. AcBinarySerializer.cs: ~130 hívás átírása output→context
6. Public API: Initialize/Flush/ToArray hívások buffer/position paraméterekkel
7. Context WriteHeader/WriteInlineMetadata: output param eltávolítása
8. IBinaryOutput.cs törlése
9. Build + teszt
## Várható eredmény
- Hot path: `_buffer[_position++]` — nulla virtual dispatch (baseline szintű teljesítmény)
- Cold path (Grow): 1 virtual call buffer beteltkor → elhanyagolható
- Position: 1 virtual call, de csak 1x hívódik per serialize → elhanyagolható
- IBufferWriter streaming: 100% megmarad (Grow = Advance + GetMemory)

View File

@ -0,0 +1,82 @@
<!DOCTYPE html>
<html>
<head>
<meta charset='utf-8'>
<title>BenchmarkDotNet Riportok (Dropdown)</title>
<style>
body { font-family: Segoe UI, Arial, sans-serif; margin: 2em; background: #f8f9fa; }
select { font-size: 1.1em; padding: 0.2em; }
.report-content { background: #fff; border: 1px solid #ccc; padding: 1em; margin-top: 1em; border-radius: 6px; box-shadow: 0 2px 8px #0001; min-height: 200px; }
h1 { font-size: 1.5em; }
.filename { color: #888; font-size: 0.95em; }
.compare { color: #007700; font-size: 1.1em; margin-bottom: 1em; }
.compare.negative { color: #bb2222; }
iframe { width: 100%; min-height: 600px; border: none; background: #fff; }
</style>
</head>
<body>
<h1>BenchmarkDotNet Riportok</h1>
<label for='reportSelect'>Válassz riportot:</label>
<select id='reportSelect'>
<option value='AyCode.Core.Benchmarks.AcBinaryOptionsDeserializeBenchmark-report_486670772'>AyCode.Core.Benchmarks.AcBinaryOptionsDeserializeBenchmark-report</option>
<option value='AyCode.Core.Benchmarks.AcBinaryOptionsDeserializeBenchmark-report_2144380973'>SwitcherRun_20251215T194244_217</option>
<option value='AyCode.Core.Benchmarks.MessagePackComparisonBenchmark-report_742792311'>SwitcherRun_20251214T182029_626</option>
<option value='AyCode.Core.Benchmarks.MessagePackComparisonBenchmark-report_889027207'>AyCode.Core.Benchmarks.MessagePackComparisonBenchmark-report</option>
<option value='AyCode.Core.Benchmarks.AcBinaryVsMessagePackFullBenchmark-report_839282233'>SwitcherRun_20251214T182029_626</option>
</select>
<div class='filename' id='filename'></div>
<div class='compare' id='compare'></div>
<div class='report-content'><iframe id='reportFrame'></iframe></div>
<script>
// Relatív riport fájlok
const reports = {
'AyCode.Core.Benchmarks.AcBinaryOptionsDeserializeBenchmark-report_486670772': 'Test_Benchmark_Results/Benchmark/results/AyCode.Core.Benchmarks.AcBinaryOptionsDeserializeBenchmark-report.html',
'AyCode.Core.Benchmarks.AcBinaryOptionsDeserializeBenchmark-report_2144380973': 'Test_Benchmark_Results/MemDiag/SwitcherRun_20251215T194244_217/results/AyCode.Core.Benchmarks.AcBinaryOptionsDeserializeBenchmark-report.html',
'AyCode.Core.Benchmarks.MessagePackComparisonBenchmark-report_742792311': 'AyCode.Benchmark/Test_Benchmark_Results/MemDiag/SwitcherRun_20251214T182029_626/results/AyCode.Core.Benchmarks.MessagePackComparisonBenchmark-report.html',
'AyCode.Core.Benchmarks.MessagePackComparisonBenchmark-report_889027207': 'AyCode.Benchmark/Test_Benchmark_Results/Benchmark/results/AyCode.Core.Benchmarks.MessagePackComparisonBenchmark-report.html',
'AyCode.Core.Benchmarks.AcBinaryVsMessagePackFullBenchmark-report_839282233': 'AyCode.Benchmark/Test_Benchmark_Results/MemDiag/SwitcherRun_20251214T182029_626/results/AyCode.Core.Benchmarks.AcBinaryVsMessagePackFullBenchmark-report.html',
};
const means = {
'AyCode.Core.Benchmarks.AcBinaryOptionsDeserializeBenchmark-report_486670772': '',
'AyCode.Core.Benchmarks.AcBinaryOptionsDeserializeBenchmark-report_2144380973': '',
'AyCode.Core.Benchmarks.MessagePackComparisonBenchmark-report_742792311': '',
'AyCode.Core.Benchmarks.MessagePackComparisonBenchmark-report_889027207': '',
'AyCode.Core.Benchmarks.AcBinaryVsMessagePackFullBenchmark-report_839282233': '',
};
const select = document.getElementById('reportSelect');
const frame = document.getElementById('reportFrame');
const filename = document.getElementById('filename');
const compare = document.getElementById('compare');
function showReport() {
const id = select.value;
if (reports[id]) {
frame.src = reports[id];
} else {
frame.srcdoc = '<i>Nincs tartalom.</i>';
}
const opt = select.options[select.selectedIndex];
filename.textContent = opt ? opt.text : '';
// Összehasonlítás az eggyel korábbival
const idx = select.selectedIndex;
if (idx < select.options.length - 1) {
const prevId = select.options[idx + 1].value;
const currMean = parseFloat(means[id].replace(',','.'));
const prevMean = parseFloat(means[prevId].replace(',','.'));
if (!isNaN(currMean) && !isNaN(prevMean) && prevMean > 0) {
const diff = currMean - prevMean;
const percent = (diff / prevMean * 100).toFixed(2);
const sign = percent > 0 ? '+' : '';
compare.textContent = Eltérés az előzőhöz képest: % ( vs );
compare.className = 'compare' + (percent > 0 ? ' negative' : '');
} else {
compare.textContent = '';
}
} else {
compare.textContent = '';
}
}
select.addEventListener('change', showReport);
window.onload = showReport;
</script>
</body>
</html>

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

@ -0,0 +1,38 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<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\**" />
<Content Remove="Test_Benchmark_Results\**" />
<Compile Remove="Test_Benchmark_Results\**" />
<EmbeddedResource Remove="Test_Benchmark_Results\**" />
</ItemGroup>
<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" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\AyCode.Core\AyCode.Core.csproj" />
<ProjectReference Include="..\AyCode.Core.Tests\AyCode.Core.Tests.csproj" />
<ProjectReference Include="..\AyCode.Services\AyCode.Services.csproj" />
<ProjectReference Include="..\AyCode.Services.Server\AyCode.Services.Server.csproj" />
<ProjectReference Include="..\AyCode.Models.Server\AyCode.Models.Server.csproj" />
<!-- Source Generator for [AcBinarySerializable] marked types -->
<ProjectReference Include="..\AyCode.Core.Serializers.SourceGenerator\AyCode.Core.Serializers.SourceGenerator.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
</ItemGroup>
<ItemGroup>
<Folder Include="Results\" />
</ItemGroup>
</Project>

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

@ -0,0 +1,59 @@
using AyCode.Core.Serializers;
using AyCode.Core.Serializers.Binaries;
using AyCode.Core.Tests.TestModels;
namespace AyCode.Core.Benchmarks;
/// <summary>
/// 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).
///
/// <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>
///
/// <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>
public sealed class JitDisassemblyBenchmark
{
/// <summary>
/// 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>
public void Run()
{
// 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;
var allTestData = BenchmarkTestDataProvider_All_False.CreateTestDataSets();
var largeSet = (TestDataSet<TestOrder_All_False>)allTestData.First(t => t.Name.StartsWith("Large"));
var order = largeSet.Order;
var options = AcBinarySerializerOptions.FastMode;
options.WireMode = WireMode.Compact;
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 ===");
}
}

564
AyCode.Benchmark/Program.cs Normal file
View File

@ -0,0 +1,564 @@
using BenchmarkDotNet.Running;
using AyCode.Core.Benchmarks;
using AyCode.Core.Extensions;
using AyCode.Core.Tests.TestModels;
using System.Text;
using MessagePack;
using MessagePack.Resolvers;
using BenchmarkDotNet.Configs;
using System.IO;
using AyCode.Core.Serializers.Jsons;
using AyCode.Core.Serializers.Binaries;
using System.Diagnostics;
namespace AyCode.Benchmark
{
internal class Program
{
static void Main(string[] args)
{
// Ensure centralized results directory is at the SOLUTION ROOT level (not benchmark project level)
// This navigates from AyCode.Benchmark\bin\Debug\net9.0 up to AyCode.Core
var currentDir = Directory.GetCurrentDirectory();
var solutionRoot = FindSolutionRoot(currentDir);
var baseResultsDir = Path.Combine(solutionRoot, "Test_Benchmark_Results");
var mstestDir = Path.Combine(baseResultsDir, "MSTest");
var benchmarkDir = Path.Combine(baseResultsDir, "Benchmark");
var coverageDir = Path.Combine(baseResultsDir, "CoverageReport");
var memDiagDir = Path.Combine(baseResultsDir, "MemDiag");
Directory.CreateDirectory(mstestDir);
Directory.CreateDirectory(benchmarkDir);
Directory.CreateDirectory(coverageDir);
Directory.CreateDirectory(memDiagDir);
// Create .gitignore in results folder to keep it out of source control except the file itself
var gitignorePath = Path.Combine(baseResultsDir, ".gitignore");
if (!File.Exists(gitignorePath))
{
File.WriteAllText(gitignorePath, "*\n!.gitignore\n");
}
// If requested, save/move a coverage file into the CoverageReport folder
if (args.Length > 0 && args[0] == "--save-coverage")
{
if (args.Length < 2)
{
Console.Error.WriteLine("Usage: --save-coverage <coverage-file-path>");
return;
}
var src = args[1];
if (!File.Exists(src))
{
Console.Error.WriteLine("Coverage file not found: " + src);
return;
}
try
{
var dest = Path.Combine(coverageDir, Path.GetFileName(src));
File.Copy(src, dest, overwrite: true);
Console.WriteLine("Coverage file saved to: " + dest);
}
catch (Exception ex)
{
Console.Error.WriteLine("Failed to save coverage file: " + ex.Message);
}
return;
}
// 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(bdnArtifactsDir);
if (args.Length > 0 && args[0] == "--quick")
{
RunQuickBenchmark();
return;
}
if (args.Length > 0 && args[0] == "--test")
{
var (inDir, outDir) = CreateMSTestDeployDirs(mstestDir);
RunQuickTest(outDir);
return;
}
if (args.Length > 0 && args[0] == "--testmsgpack")
{
var (inDir, outDir) = CreateMSTestDeployDirs(mstestDir);
RunMessagePackTest(outDir);
return;
}
if (args.Length > 0 && args[0] == "--serializers")
{
// 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")
{
// 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;
}
Console.WriteLine("Usage:");
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(" --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");
// 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(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}");
}
}
try
{
action();
}
finally
{
if (stabilizationApplied && (OperatingSystem.IsWindows() || OperatingSystem.IsLinux()))
{
try { process.ProcessorAffinity = origAffinity; } catch { /* best-effort */ }
try { process.PriorityClass = origPriority; } catch { /* best-effort */ }
}
}
}
/// <summary>
/// Quick benchmark comparing AcBinary vs MessagePack with tabular output.
/// Tests: WithRef, NoRef, Serialize, Deserialize, Populate, Merge
/// </summary>
static void RunQuickBenchmark(int iterations = 1000)
{
Console.WriteLine();
Console.WriteLine("????????????????????????????????????????????????????????????????????????????????");
Console.WriteLine("? AcBinary vs MessagePack Quick Benchmark ?");
Console.WriteLine("????????????????????????????????????????????????????????????????????????????????");
Console.WriteLine();
// Create test data with shared references
TestDataFactory.ResetIdCounter();
var sharedTag = TestDataFactory.CreateTag("SharedTag_All_True");
var sharedUser = TestDataFactory.CreateUser("shareduser");
var sharedMeta = TestDataFactory.CreateMetadata("shared", withChild: true);
var testOrder = TestDataFactory.CreateOrder(
itemCount: 3,
palletsPerItem: 3,
measurementsPerPallet: 3,
pointsPerMeasurement: 4,
sharedTag: sharedTag,
sharedUser: sharedUser,
sharedMetadata: sharedMeta);
// Options
var withRefOptions = new AcBinarySerializerOptions();
var noRefOptions = AcBinarySerializerOptions.WithoutReferenceHandling;
var msgPackOptions = ContractlessStandardResolver.Options.WithCompression(MessagePackCompression.None);
// Warm up
Console.WriteLine("Warming up...");
for (int i = 0; i < 100; i++)
{
_ = AcBinarySerializer.Serialize(testOrder, withRefOptions);
_ = AcBinarySerializer.Serialize(testOrder, noRefOptions);
_ = MessagePackSerializer.Serialize(testOrder, msgPackOptions);
}
// Pre-serialize data for deserialization tests
var acBinaryWithRef = AcBinarySerializer.Serialize(testOrder, withRefOptions);
var acBinaryNoRef = AcBinarySerializer.Serialize(testOrder, noRefOptions);
var msgPackData = MessagePackSerializer.Serialize(testOrder, msgPackOptions);
Console.WriteLine($"Iterations: {iterations:N0}");
Console.WriteLine();
// Size comparison
Console.WriteLine("???????????????????????????????????????????????????????????????????????????????");
Console.WriteLine("? SIZE COMPARISON ?");
Console.WriteLine("???????????????????????????????????????????????????????????????????????????????");
Console.WriteLine("? Format ? Size (bytes) ? vs MessagePack ? Savings ?");
Console.WriteLine("???????????????????????????????????????????????????????????????????????????????");
Console.WriteLine($"? AcBinary (WithRef) ? {acBinaryWithRef.Length,14:N0} ? {100.0 * acBinaryWithRef.Length / msgPackData.Length,13:F1}% ? {msgPackData.Length - acBinaryWithRef.Length,14:N0} ?");
Console.WriteLine($"? AcBinary (NoRef) ? {acBinaryNoRef.Length,14:N0} ? {100.0 * acBinaryNoRef.Length / msgPackData.Length,13:F1}% ? {msgPackData.Length - acBinaryNoRef.Length,14:N0} ?");
Console.WriteLine($"? MessagePack ? {msgPackData.Length,14:N0} ? {100.0,13:F1}% ? {"(baseline)",14} ?");
Console.WriteLine("???????????????????????????????????????????????????????????????????????????????");
Console.WriteLine();
// Benchmark results storage
var results = new List<(string Operation, string Mode, double AcBinaryMs, double MsgPackMs)>();
// Serialize benchmarks
var sw = Stopwatch.StartNew();
// AcBinary WithRef Serialize
sw.Restart();
for (int i = 0; i < iterations; i++)
_ = AcBinarySerializer.Serialize(testOrder, withRefOptions);
var acWithRefSerialize = sw.Elapsed.TotalMilliseconds;
// AcBinary NoRef Serialize
sw.Restart();
for (int i = 0; i < iterations; i++)
_ = AcBinarySerializer.Serialize(testOrder, noRefOptions);
var acNoRefSerialize = sw.Elapsed.TotalMilliseconds;
// MessagePack Serialize
sw.Restart();
for (int i = 0; i < iterations; i++)
_ = MessagePackSerializer.Serialize(testOrder, msgPackOptions);
var msgPackSerialize = sw.Elapsed.TotalMilliseconds;
results.Add(("Serialize", "WithRef", acWithRefSerialize, msgPackSerialize));
results.Add(("Serialize", "NoRef", acNoRefSerialize, msgPackSerialize));
// Deserialize benchmarks
// AcBinary WithRef Deserialize
sw.Restart();
for (int i = 0; i < iterations; i++)
_ = 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_All_True>(acBinaryNoRef);
var acNoRefDeserialize = sw.Elapsed.TotalMilliseconds;
// MessagePack Deserialize
sw.Restart();
for (int i = 0; i < iterations; i++)
_ = MessagePackSerializer.Deserialize<TestOrder_All_True>(msgPackData, msgPackOptions);
var msgPackDeserialize = sw.Elapsed.TotalMilliseconds;
results.Add(("Deserialize", "WithRef", acWithRefDeserialize, msgPackDeserialize));
results.Add(("Deserialize", "NoRef", acNoRefDeserialize, msgPackDeserialize));
// Populate benchmark (AcBinary only)
sw.Restart();
for (int i = 0; i < iterations; i++)
{
var target = CreatePopulateTarget(testOrder);
AcBinaryDeserializer.Populate(acBinaryNoRef, target);
}
var acPopulate = sw.Elapsed.TotalMilliseconds;
results.Add(("Populate", "NoRef", acPopulate, 0)); // MessagePack doesn't have Populate
// PopulateMerge benchmark (AcBinary only)
sw.Restart();
for (int i = 0; i < iterations; i++)
{
var target = CreatePopulateTarget(testOrder);
AcBinaryDeserializer.PopulateMerge(acBinaryNoRef, target);
}
var acMerge = sw.Elapsed.TotalMilliseconds;
results.Add(("Merge", "NoRef", acMerge, 0));
// Round-trip
var acWithRefRoundTrip = acWithRefSerialize + acWithRefDeserialize;
var acNoRefRoundTrip = acNoRefSerialize + acNoRefDeserialize;
var msgPackRoundTrip = msgPackSerialize + msgPackDeserialize;
results.Add(("Round-trip", "WithRef", acWithRefRoundTrip, msgPackRoundTrip));
results.Add(("Round-trip", "NoRef", acNoRefRoundTrip, msgPackRoundTrip));
// Print performance table
Console.WriteLine("???????????????????????????????????????????????????????????????????????????????");
Console.WriteLine("? PERFORMANCE COMPARISON (lower is better) ?");
Console.WriteLine("???????????????????????????????????????????????????????????????????????????????");
Console.WriteLine("? Operation ? AcBinary (ms) ? MessagePack ? Ratio ?");
Console.WriteLine("???????????????????????????????????????????????????????????????????????????????");
foreach (var r in results)
{
var opName = $"{r.Operation} ({r.Mode})";
if (r.MsgPackMs > 0)
{
var ratio = r.AcBinaryMs / r.MsgPackMs;
var ratioStr = ratio < 1 ? $"{ratio:F2}x faster" : $"{ratio:F2}x slower";
Console.WriteLine($"? {opName,-24} ? {r.AcBinaryMs,14:F2} ? {r.MsgPackMs,14:F2} ? {ratioStr,14} ?");
}
else
{
Console.WriteLine($"? {opName,-24} ? {r.AcBinaryMs,14:F2} ? {"N/A",14} ? {"(unique)",14} ?");
}
}
Console.WriteLine("???????????????????????????????????????????????????????????????????????????????");
Console.WriteLine();
// Summary
Console.WriteLine("???????????????????????????????????????????????????????????????????????????????");
Console.WriteLine("? SUMMARY ?");
Console.WriteLine("???????????????????????????????????????????????????????????????????????????????");
var sizeAdvantage = 100.0 - (100.0 * acBinaryNoRef.Length / msgPackData.Length);
Console.WriteLine($"? Size advantage: AcBinary is {sizeAdvantage:F1}% smaller than MessagePack ?");
var serializeRatio = acNoRefSerialize / msgPackSerialize;
var deserializeRatio = acNoRefDeserialize / msgPackDeserialize;
Console.WriteLine($"? Serialize (NoRef): AcBinary is {(serializeRatio < 1 ? $"{1/serializeRatio:F2}x faster" : $"{serializeRatio:F2}x slower"),-20} ?");
Console.WriteLine($"? Deserialize (NoRef): AcBinary is {(deserializeRatio < 1 ? $"{1/deserializeRatio:F2}x faster" : $"{deserializeRatio:F2}x slower"),-18} ?");
Console.WriteLine("???????????????????????????????????????????????????????????????????????????????");
Console.WriteLine();
}
static TestOrder_All_True CreatePopulateTarget(TestOrder_All_True source)
{
var target = new TestOrder_All_True { Id = source.Id };
foreach (var item in source.Items)
{
target.Items.Add(new TestOrderItem_All_True { Id = item.Id });
}
return target;
}
static (string InDir, string OutDir) CreateMSTestDeployDirs(string mstestBase)
{
var user = Environment.UserName ?? "Deploy";
var ts = DateTime.UtcNow.ToString("yyyyMMddTHHmmss_ffff");
var deployBase = Path.Combine(mstestBase, $"Deploy_{user} {ts}");
var inDir = Path.Combine(deployBase, "In");
var outDir = Path.Combine(deployBase, "Out");
Directory.CreateDirectory(inDir);
Directory.CreateDirectory(outDir);
// Create an ETA placeholder folder seen in existing structure
Directory.CreateDirectory(Path.Combine(inDir, "ETA001"));
return (inDir, outDir);
}
static void RunQuickTest(string outDir)
{
Console.WriteLine("=== Quick AcBinary Test ===\n");
try
{
Console.WriteLine("Creating test data...");
var order = TestDataFactory.CreateBenchmarkOrder(
itemCount: 3,
palletsPerItem: 2,
measurementsPerPallet: 2,
pointsPerMeasurement: 5);
Console.WriteLine($"Created order with {order.Items.Count} items");
Console.WriteLine("\nTesting JSON serialization...");
var jsonOptions = AcJsonSerializerOptions.WithoutReferenceHandling;
var json = AcJsonSerializer.Serialize(order, jsonOptions);
// Log a quick summary to Out folder for convenience
var logPath = Path.Combine(outDir, "quick_test_log.txt");
File.WriteAllText(logPath, $"QuickTest: Order items={order.Items.Count}, JsonLength={json.Length}\n");
Console.WriteLine("Quick test completed. Log written to: " + logPath);
}
catch (Exception ex)
{
Console.Error.WriteLine("Quick test failed: " + ex.Message);
}
}
static void RunMessagePackTest(string outDir)
{
Console.WriteLine("=== Quick MessagePack Test ===\n");
try
{
var order = TestDataFactory.CreateBenchmarkOrder(2,1,1,3);
var bytes = MessagePackSerializer.Serialize(order, MessagePackSerializerOptions.Standard.WithResolver(ContractlessStandardResolver.Instance));
var logPath = Path.Combine(outDir, "quick_msgpack_test_log.txt");
File.WriteAllText(logPath, $"MessagePack quick test: bytes={bytes.Length}\n");
Console.WriteLine("Quick MessagePack test completed. Log written to: " + logPath);
}
catch (Exception ex)
{
Console.Error.WriteLine("Quick MessagePack test failed: " + ex.Message);
}
}
static void RunSizeComparison()
{
Console.WriteLine("Running size comparisons (output to console)...");
// Existing implementation
}
static void RunBenchmark<T>(ManualConfig config, string benchmarkDir, string memDiagDir, string name)
{
// Run benchmark and then collect artifacts into MemDiag folder
try
{
var summary = BenchmarkRunner.Run<T>(config);
}
finally
{
CollectBenchmarkArtifacts(benchmarkDir, memDiagDir, name);
}
}
static void CollectBenchmarkArtifacts(string benchmarkDir, string memDiagDir, string runName)
{
try
{
if (!Directory.Exists(benchmarkDir)) return;
var ts = DateTime.UtcNow.ToString("yyyyMMddTHHmmss_fff");
var destDir = Path.Combine(memDiagDir, $"{runName}_{ts}");
Directory.CreateDirectory(destDir);
foreach (var file in Directory.GetFiles(benchmarkDir))
{
try
{
var dest = Path.Combine(destDir, Path.GetFileName(file));
File.Copy(file, dest, overwrite: true);
}
catch { /* ignore individual copy failures */ }
}
// Also copy subdirectories (artifact folders)
foreach (var dir in Directory.GetDirectories(benchmarkDir))
{
try
{
var name = Path.GetFileName(dir);
var target = Path.Combine(destDir, name);
CopyDirectory(dir, target);
}
catch { }
}
Console.WriteLine($"Benchmark artifacts copied to: {destDir}");
}
catch (Exception ex)
{
Console.Error.WriteLine("Failed to collect benchmark artifacts: " + ex.Message);
}
}
static void CopyDirectory(string sourceDir, string destDir)
{
Directory.CreateDirectory(destDir);
foreach (var file in Directory.GetFiles(sourceDir))
{
var dest = Path.Combine(destDir, Path.GetFileName(file));
File.Copy(file, dest, overwrite: true);
}
foreach (var dir in Directory.GetDirectories(sourceDir))
{
CopyDirectory(dir, Path.Combine(destDir, Path.GetFileName(dir)));
}
}
/// <summary>
/// Finds the solution root directory by looking for the .sln file or known markers.
/// Walks up the directory tree from the current directory.
/// </summary>
static string FindSolutionRoot(string startDir)
{
var dir = startDir;
// Walk up the directory tree looking for solution markers
while (!string.IsNullOrEmpty(dir))
{
// Check for .sln file
if (Directory.GetFiles(dir, "*.sln").Length > 0)
{
return dir;
}
// Check for known solution root markers (Directory.Build.props, AyCode.Core folder)
if (File.Exists(Path.Combine(dir, "Directory.Build.props")) ||
Directory.Exists(Path.Combine(dir, "AyCode.Core")) ||
Directory.Exists(Path.Combine(dir, "AyCode.Benchmark")))
{
// Verify this looks like the solution root
if (Directory.Exists(Path.Combine(dir, "AyCode.Core")) &&
Directory.Exists(Path.Combine(dir, "AyCode.Benchmark")))
{
return dir;
}
}
var parent = Directory.GetParent(dir);
if (parent == null) break;
dir = parent.FullName;
}
// Fallback: return the current directory if solution root not found
Console.WriteLine($"Warning: Could not find solution root, using current directory: {startDir}");
return startDir;
}
}
}

129
AyCode.Benchmark/README.md Normal file
View File

@ -0,0 +1,129 @@
# AyCode.Benchmark
BenchmarkDotNet performance suite **plus** the shared workload / reporting infrastructure used by both BDN and the Console runner. Targets .NET 9.
## Role: dual-purpose project
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` | 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,158 @@
using BenchmarkDotNet.Attributes;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using Microsoft.VSDiagnostics;
namespace AyCode.Benchmark;
[CPUUsageDiagnoser]
public class RefForeachBenchmark
{
// Simulates BinaryPropertyAccessor (large struct ~80 bytes)
public struct PropertyAccessor
{
public int PropertyIndex;
public TypeCode PropertyTypeCode;
public int AccessorType;
public long Field1, Field2, Field3, Field4, Field5, Field6, Field7, Field8;
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public readonly int GetValue() => PropertyIndex + (int)PropertyTypeCode + AccessorType;
}
private PropertyAccessor[] _properties = null !;
private List<PropertyAccessor> _propertiesList = null !;
[GlobalSetup]
public void Setup()
{
_properties = new PropertyAccessor[20]; // Typical property count
_propertiesList = new List<PropertyAccessor>(20);
for (int i = 0; i < 20; i++)
{
var prop = new PropertyAccessor
{
PropertyIndex = i,
PropertyTypeCode = TypeCode.Int32,
AccessorType = i % 5,
Field1 = i,
Field2 = i,
Field3 = i,
Field4 = i,
Field5 = i,
Field6 = i,
Field7 = i,
Field8 = i
};
_properties[i] = prop;
_propertiesList.Add(prop);
}
}
// ============ ARRAY ITERATION ============
[Benchmark(Baseline = true)]
public int Array_ForEach_ByValue()
{
int total = 0;
for (int iter = 0; iter < 1000; iter++)
{
foreach (var prop in _properties)
{
total += prop.GetValue();
}
}
return total;
}
[Benchmark]
public int Array_ForEach_RefReadonly()
{
int total = 0;
for (int iter = 0; iter < 1000; iter++)
{
foreach (ref readonly var prop in _properties.AsSpan())
{
total += prop.GetValue();
}
}
return total;
}
[Benchmark]
public int Array_ForLoop_Index()
{
int total = 0;
var props = _properties;
for (int iter = 0; iter < 1000; iter++)
{
for (int i = 0; i < props.Length; i++)
{
total += props[i].GetValue();
}
}
return total;
}
[Benchmark]
public int Array_ForLoop_Span()
{
int total = 0;
for (int iter = 0; iter < 1000; iter++)
{
var span = _properties.AsSpan();
for (int i = 0; i < span.Length; i++)
{
total += span[i].GetValue();
}
}
return total;
}
// ============ LIST ITERATION ============
[Benchmark]
public int List_ForEach_ByValue()
{
int total = 0;
for (int iter = 0; iter < 1000; iter++)
{
foreach (var prop in _propertiesList)
{
total += prop.GetValue();
}
}
return total;
}
[Benchmark]
public int List_CollectionsMarshal_RefReadonly()
{
int total = 0;
for (int iter = 0; iter < 1000; iter++)
{
foreach (ref readonly var prop in CollectionsMarshal.AsSpan(_propertiesList))
{
total += prop.GetValue();
}
}
return total;
}
[Benchmark]
public int List_ForLoop_Index()
{
int total = 0;
var props = _propertiesList;
for (int iter = 0; iter < 1000; iter++)
{
for (int i = 0; i < props.Count; i++)
{
total += props[i].GetValue();
}
}
return total;
}
}

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

@ -0,0 +1,189 @@
using AyCode.Core.Extensions;
using AyCode.Core.Serializers.Jsons;
using AyCode.Core.Tests.TestModels;
using BenchmarkDotNet.Attributes;
using MessagePack;
namespace AyCode.Core.Benchmarks;
/// <summary>
/// SignalR communication benchmarks measuring the full serialization workflow:
/// Client ? IdMessage ? MessagePack ? Server ? Deserialize ? Response ? MessagePack ? Client
/// </summary>
[MemoryDiagnoser]
public class SignalRCommunicationBenchmarks
{
// Shared test data
private SignalRBenchmarkData _data = null !;
// Pre-serialized messages for deserialization benchmarks
private byte[] _singleIntMessage = null !;
private byte[] _twoIntMessage = null !;
private byte[] _fiveParamsMessage = null !;
private byte[] _complexOrderItemMessage = null !;
private byte[] _complexOrderMessage = null !;
private byte[] _intArrayMessage = null !;
private byte[] _mixedParamsMessage = null !;
// Pre-serialized response for client-side deserialization
private byte[] _successResponseMessage = null !;
private byte[] _complexResponseMessage = null !;
[GlobalSetup]
public void Setup()
{
_data = new SignalRBenchmarkData();
// Copy pre-serialized messages
_singleIntMessage = _data.SingleIntMessage;
_twoIntMessage = _data.TwoIntMessage;
_fiveParamsMessage = _data.FiveParamsMessage;
_complexOrderItemMessage = _data.ComplexOrderItemMessage;
_complexOrderMessage = _data.ComplexOrderMessage;
_intArrayMessage = _data.IntArrayMessage;
_mixedParamsMessage = _data.MixedParamsMessage;
// Pre-serialize response messages
_successResponseMessage = SignalRMessageFactory.CreateSuccessResponse(CommonSignalRTags.SingleIntParam, "Received: 42");
_complexResponseMessage = SignalRMessageFactory.CreateSuccessResponse(CommonSignalRTags.TestOrderParam, _data.TestOrder);
Console.WriteLine("=== SignalR Message Size Comparison ===");
Console.WriteLine($"Single int message: {_singleIntMessage.Length} bytes");
Console.WriteLine($"Two int message: {_twoIntMessage.Length} bytes");
Console.WriteLine($"Five params message: {_fiveParamsMessage.Length} bytes");
Console.WriteLine($"Complex OrderItem message: {_complexOrderItemMessage.Length} bytes");
Console.WriteLine($"Complex Order message: {_complexOrderMessage.Length} bytes");
Console.WriteLine($"Int array message: {_intArrayMessage.Length} bytes");
Console.WriteLine($"Mixed params message: {_mixedParamsMessage.Length} bytes");
Console.WriteLine($"Success response: {_successResponseMessage.Length} bytes");
Console.WriteLine($"Complex response: {_complexResponseMessage.Length} bytes");
}
#region Client-Side: Message Creation (IdMessage + MessagePack Serialization)
[Benchmark(Description = "Client: Create single int message")]
[BenchmarkCategory("Client", "Create")]
public byte[] Client_CreateSingleIntMessage() => SignalRMessageFactory.CreateSingleParamMessage(42);
[Benchmark(Description = "Client: Create two int message")]
[BenchmarkCategory("Client", "Create")]
public byte[] Client_CreateTwoIntMessage() => SignalRMessageFactory.CreateIdMessage(10, 20);
[Benchmark(Description = "Client: Create five params message")]
[BenchmarkCategory("Client", "Create")]
public byte[] Client_CreateFiveParamsMessage() => SignalRMessageFactory.CreateIdMessage(42, "hello", true, _data.TestGuid, 99.99m);
[Benchmark(Description = "Client: Create complex OrderItem message")]
[BenchmarkCategory("Client", "Create")]
public byte[] Client_CreateComplexOrderItemMessage() => SignalRMessageFactory.CreateComplexObjectMessage(_data.TestOrderItem);
[Benchmark(Description = "Client: Create complex Order message")]
[BenchmarkCategory("Client", "Create")]
public byte[] Client_CreateComplexOrderMessage() => SignalRMessageFactory.CreateComplexObjectMessage(_data.TestOrder);
#endregion
#region Server-Side: Message Deserialization (MessagePack + JSON)
[Benchmark(Description = "Server: Deserialize single int")]
[BenchmarkCategory("Server", "Deserialize")]
public int Server_DeserializeSingleInt()
{
var postMessage = MessagePackSerializer.Deserialize<SignalRPostMessageDto>(_singleIntMessage, SignalRMessageFactory.ContractlessOptions);
var idMessage = postMessage.PostDataJson!.JsonTo<SignalRIdMessageDto>()!;
return AcJsonDeserializer.Deserialize<int>(idMessage.Ids[0]);
}
[Benchmark(Description = "Server: Deserialize two ints")]
[BenchmarkCategory("Server", "Deserialize")]
public (int, int) Server_DeserializeTwoInts()
{
var postMessage = MessagePackSerializer.Deserialize<SignalRPostMessageDto>(_twoIntMessage, SignalRMessageFactory.ContractlessOptions);
var idMessage = postMessage.PostDataJson!.JsonTo<SignalRIdMessageDto>()!;
var a = AcJsonDeserializer.Deserialize<int>(idMessage.Ids[0]);
var b = AcJsonDeserializer.Deserialize<int>(idMessage.Ids[1]);
return (a, b);
}
[Benchmark(Description = "Server: Deserialize five params")]
[BenchmarkCategory("Server", "Deserialize")]
public (int, string, bool, Guid, decimal) Server_DeserializeFiveParams()
{
var postMessage = MessagePackSerializer.Deserialize<SignalRPostMessageDto>(_fiveParamsMessage, SignalRMessageFactory.ContractlessOptions);
var idMessage = postMessage.PostDataJson!.JsonTo<SignalRIdMessageDto>()!;
var a = AcJsonDeserializer.Deserialize<int>(idMessage.Ids[0]);
var b = AcJsonDeserializer.Deserialize<string>(idMessage.Ids[1])!;
var c = AcJsonDeserializer.Deserialize<bool>(idMessage.Ids[2]);
var d = AcJsonDeserializer.Deserialize<Guid>(idMessage.Ids[3]);
var e = AcJsonDeserializer.Deserialize<decimal>(idMessage.Ids[4]);
return (a, b, c, d, e);
}
[Benchmark(Description = "Server: Deserialize complex OrderItem")]
[BenchmarkCategory("Server", "Deserialize")]
public TestOrderItem_All_True Server_DeserializeComplexOrderItem()
{
var postMessage = MessagePackSerializer.Deserialize<SignalRPostMessageDto>(_complexOrderItemMessage, SignalRMessageFactory.ContractlessOptions);
return postMessage.PostDataJson!.JsonTo<TestOrderItem_All_True>()!;
}
[Benchmark(Description = "Server: Deserialize complex Order")]
[BenchmarkCategory("Server", "Deserialize")]
public TestOrder_All_True Server_DeserializeComplexOrder()
{
var postMessage = MessagePackSerializer.Deserialize<SignalRPostMessageDto>(_complexOrderMessage, SignalRMessageFactory.ContractlessOptions);
return postMessage.PostDataJson!.JsonTo<TestOrder_All_True>()!;
}
#endregion
#region Server-Side: Response Creation (JSON + MessagePack Serialization)
[Benchmark(Description = "Server: Create success response (string)")]
[BenchmarkCategory("Server", "Response")]
public byte[] Server_CreateSuccessStringResponse() => SignalRMessageFactory.CreateSuccessResponse(CommonSignalRTags.SingleIntParam, "Received: 42");
[Benchmark(Description = "Server: Create success response (OrderItem)")]
[BenchmarkCategory("Server", "Response")]
public byte[] Server_CreateSuccessOrderItemResponse() => SignalRMessageFactory.CreateSuccessResponse(CommonSignalRTags.TestOrderItemParam, _data.TestOrderItem);
[Benchmark(Description = "Server: Create success response (Order)")]
[BenchmarkCategory("Server", "Response")]
public byte[] Server_CreateSuccessOrderResponse() => SignalRMessageFactory.CreateSuccessResponse(CommonSignalRTags.TestOrderParam, _data.TestOrder);
#endregion
#region Client-Side: Response Deserialization
[Benchmark(Description = "Client: Deserialize string response")]
[BenchmarkCategory("Client", "Response")]
public string? Client_DeserializeStringResponse()
{
var response = SignalRMessageFactory.DeserializeResponse(_successResponseMessage);
return response?.ResponseData;
}
[Benchmark(Description = "Client: Deserialize complex Order response")]
[BenchmarkCategory("Client", "Response")]
public TestOrder_All_True? Client_DeserializeOrderResponse()
{
var response = SignalRMessageFactory.DeserializeResponse(_complexResponseMessage);
return response?.ResponseData?.JsonTo<TestOrder_All_True>();
}
#endregion
#region Full Round-Trip Benchmarks
[Benchmark(Description = "Full: Single int round-trip")]
[BenchmarkCategory("Full")]
public string? Full_SingleIntRoundTrip()
{
// Client creates message
var requestBytes = SignalRMessageFactory.CreateSingleParamMessage(42);
// Server deserializes
var postMessage = MessagePackSerializer.Deserialize<SignalRPostMessageDto>(requestBytes, SignalRMessageFactory.ContractlessOptions);
var idMessage = postMessage.PostDataJson!.JsonTo<SignalRIdMessageDto>()!;
var value = AcJsonDeserializer.Deserialize<int>(idMessage.Ids[0]);
// Server creates response
var responseBytes = SignalRMessageFactory.CreateSuccessResponse(CommonSignalRTags.SingleIntParam, $"Received: {value}");
// Client deserializes response
var response = SignalRMessageFactory.DeserializeResponse(responseBytes);
return response?.ResponseData;
}
[Benchmark(Description = "Full: Complex Order round-trip")]
[BenchmarkCategory("Full")]
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_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_All_True>();
}
#endregion
}

View File

@ -0,0 +1,308 @@
using System.Security.Claims;
using AyCode.Core.Extensions;
using AyCode.Core.Tests.TestModels;
using AyCode.Models.Server.DynamicMethods;
using AyCode.Services.Server.SignalRs;
using AyCode.Services.SignalRs;
using BenchmarkDotNet.Attributes;
using Microsoft.AspNetCore.SignalR.Client;
using Microsoft.Extensions.Configuration;
using Microsoft.VSDiagnostics;
namespace AyCode.Core.Benchmarks;
/// <summary>
/// Benchmarks for SignalR round-trip communication using the same infrastructure as SignalRClientToHubTest.
/// Measures: Client -> Server -> Service -> Response -> Client
/// </summary>
[MemoryDiagnoser]
[CPUUsageDiagnoser]
public class SignalRRoundTripBenchmarks
{
private BenchmarkSignalRClient _client = null!;
private BenchmarkSignalRHub _hub = null!;
private BenchmarkSignalRService _service = null!;
// Pre-created test data
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;
[GlobalSetup]
public void Setup()
{
var logger = new TestLogger();
_hub = new BenchmarkSignalRHub(logger);
_service = new BenchmarkSignalRService();
_client = new BenchmarkSignalRClient(_hub, logger);
_hub.RegisterService(_service, _client);
// Pre-create test data
_testOrderItem = new TestOrderItem_All_True { Id = 1, ProductName = "Widget", Quantity = 5, UnitPrice = 10.50m };
_testOrder = TestDataFactory.CreateOrder(itemCount: 3);
_sharedTag = new SharedTag_All_True { Id = 1, Name = "Important", Color = "#FF0000" };
_intArray = [1, 2, 3, 4, 5];
_stringList = ["apple", "banana", "cherry"];
_testGuid = Guid.NewGuid();
}
#region Primitive Parameter Benchmarks
[Benchmark(Description = "RoundTrip: Single int")]
[BenchmarkCategory("Primitives")]
public string? RoundTrip_SingleInt()
{
return _client.PostDataSync<int, string>(BenchmarkSignalRTags.SingleIntParam, 42);
}
[Benchmark(Description = "RoundTrip: Two ints")]
[BenchmarkCategory("Primitives")]
public int RoundTrip_TwoInts()
{
return _client.PostSync<int>(BenchmarkSignalRTags.TwoIntParams, [10, 20]);
}
[Benchmark(Description = "RoundTrip: Bool")]
[BenchmarkCategory("Primitives")]
public bool RoundTrip_Bool()
{
return _client.PostDataSync<bool, bool>(BenchmarkSignalRTags.BoolParam, true);
}
[Benchmark(Description = "RoundTrip: String")]
[BenchmarkCategory("Primitives")]
public string? RoundTrip_String()
{
return _client.PostDataSync<string, string>(BenchmarkSignalRTags.StringParam, "Hello");
}
[Benchmark(Description = "RoundTrip: Guid")]
[BenchmarkCategory("Primitives")]
public Guid RoundTrip_Guid()
{
return _client.PostDataSync<Guid, Guid>(BenchmarkSignalRTags.GuidParam, _testGuid);
}
[Benchmark(Description = "RoundTrip: No params")]
[BenchmarkCategory("Primitives")]
public string? RoundTrip_NoParams()
{
return _client.GetAllSync<string>(BenchmarkSignalRTags.NoParams);
}
[Benchmark(Description = "RoundTrip: Multiple types (3 params)")]
[BenchmarkCategory("Primitives")]
public string? RoundTrip_MultipleTypes()
{
return _client.PostSync<string>(BenchmarkSignalRTags.MultipleTypesParams, [true, "test", 42]);
}
#endregion
#region Complex Object Benchmarks
[Benchmark(Description = "RoundTrip: TestOrderItem_All_True")]
[BenchmarkCategory("Complex")]
public TestOrderItem_All_True? RoundTrip_TestOrderItem()
{
return _client.PostDataSync<TestOrderItem_All_True, TestOrderItem_All_True>(BenchmarkSignalRTags.TestOrderItemParam, _testOrderItem);
}
[Benchmark(Description = "RoundTrip: TestOrder_All_True (3 items)")]
[BenchmarkCategory("Complex")]
public TestOrder_All_True? RoundTrip_TestOrder()
{
return _client.PostDataSync<TestOrder_All_True, TestOrder_All_True>(BenchmarkSignalRTags.TestOrderParam, _testOrder);
}
[Benchmark(Description = "RoundTrip: SharedTag_All_True")]
[BenchmarkCategory("Complex")]
public SharedTag_All_True? RoundTrip_SharedTag()
{
return _client.PostDataSync<SharedTag_All_True, SharedTag_All_True>(BenchmarkSignalRTags.SharedTagParam, _sharedTag);
}
#endregion
#region Collection Benchmarks
[Benchmark(Description = "RoundTrip: int[] (5 elements)")]
[BenchmarkCategory("Collections")]
public int[]? RoundTrip_IntArray()
{
return _client.PostDataSync<int[], int[]>(BenchmarkSignalRTags.IntArrayParam, _intArray);
}
[Benchmark(Description = "RoundTrip: List<string> (3 elements)")]
[BenchmarkCategory("Collections")]
public List<string>? RoundTrip_StringList()
{
return _client.PostDataSync<List<string>, List<string>>(BenchmarkSignalRTags.StringListParam, _stringList);
}
#endregion
#region Mixed Parameter Benchmarks
[Benchmark(Description = "RoundTrip: Int + DTO")]
[BenchmarkCategory("Mixed")]
public string? RoundTrip_IntAndDto()
{
return _client.PostSync<string>(BenchmarkSignalRTags.IntAndDtoParam, [42, _testOrderItem]);
}
[Benchmark(Description = "RoundTrip: 5 mixed params")]
[BenchmarkCategory("Mixed")]
public string? RoundTrip_FiveParams()
{
return _client.PostSync<string>(BenchmarkSignalRTags.FiveParams, [42, "hello", true, _testGuid, 99.99m]);
}
#endregion
}
#region Benchmark Infrastructure (minimal, reuses production code)
/// <summary>
/// SignalR tags for benchmarks - matches TestSignalRTags structure
/// </summary>
public abstract class BenchmarkSignalRTags : AcSignalRTags
{
public const int SingleIntParam = 100;
public const int TwoIntParams = 101;
public const int BoolParam = 102;
public const int StringParam = 103;
public const int GuidParam = 104;
public const int NoParams = 107;
public const int MultipleTypesParams = 109;
public const int TestOrderItemParam = 120;
public const int TestOrderParam = 121;
public const int SharedTagParam = 122;
public const int IntArrayParam = 130;
public const int StringListParam = 132;
public const int IntAndDtoParam = 160;
public const int FiveParams = 164;
}
/// <summary>
/// Benchmark-optimized SignalR client with synchronous methods for accurate timing
/// </summary>
public class BenchmarkSignalRClient : AcSignalRClientBase, IAcSignalRHubItemServer
{
private readonly BenchmarkSignalRHub _hub;
public BenchmarkSignalRClient(BenchmarkSignalRHub hub, TestLogger logger) : base(logger)
{
_hub = hub;
// Eliminate polling delay for benchmarks
MsDelay = 0;
MsFirstDelay = 0;
}
// Synchronous wrappers for benchmarking (avoids async overhead measurement)
public TResponse? PostDataSync<TPost, TResponse>(int tag, TPost data)
=> PostDataAsync<TPost, TResponse>(tag, data).GetAwaiter().GetResult();
public TResponse? PostSync<TResponse>(int tag, object[] parameters)
=> PostAsync<TResponse>(tag, parameters).GetAwaiter().GetResult();
public TResponse? GetAllSync<TResponse>(int tag)
=> GetAllAsync<TResponse>(tag).GetAwaiter().GetResult();
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, int? requestId, SignalParams signalParams, object? data)
{
await _hub.OnReceiveMessage(messageTag, requestId, signalParams, data ?? Array.Empty<byte>());
}
}
/// <summary>
/// Benchmark-optimized SignalR hub
/// </summary>
public class BenchmarkSignalRHub : AcWebSignalRHubBase<BenchmarkSignalRTags, TestLogger>
{
private IAcSignalRHubItemServer _callerClient = null!;
public BenchmarkSignalRHub(TestLogger logger) : base(new ConfigurationBuilder().Build(), logger)
{
}
public void RegisterService(object service, IAcSignalRHubItemServer client)
{
_callerClient = client;
DynamicMethodRegistry.Register(service);
}
protected override string GetConnectionId() => "benchmark-connection";
protected override bool IsConnectionAborted() => false;
protected override string? GetUserIdentifier() => "benchmark-user";
protected override ClaimsPrincipal? GetUser() => null;
protected override Task ResponseToCaller(int messageTag, SignalResponseStatus status, object? responseData, int? requestId, SignalParams? clientSignalParams = null)
=> SendMessageToClient(_callerClient, messageTag, status, responseData, requestId, clientSignalParams);
}
/// <summary>
/// Benchmark service handlers - same logic as TestSignalRService2
/// </summary>
public class BenchmarkSignalRService
{
[SignalR(BenchmarkSignalRTags.SingleIntParam)]
public string HandleSingleInt(int value) => $"{value}";
[SignalR(BenchmarkSignalRTags.TwoIntParams)]
public int HandleTwoInts(int a, int b) => a + b;
[SignalR(BenchmarkSignalRTags.BoolParam)]
public bool HandleBool(bool value) => value;
[SignalR(BenchmarkSignalRTags.StringParam)]
public string HandleString(string text) => $"Echo: {text}";
[SignalR(BenchmarkSignalRTags.GuidParam)]
public Guid HandleGuid(Guid id) => id;
[SignalR(BenchmarkSignalRTags.NoParams)]
public string HandleNoParams() => "OK";
[SignalR(BenchmarkSignalRTags.MultipleTypesParams)]
public string HandleMultipleTypes(bool flag, string text, int number) => $"{flag}-{text}-{number}";
[SignalR(BenchmarkSignalRTags.TestOrderItemParam)]
public TestOrderItem_All_True HandleTestOrderItem(TestOrderItem_All_True item) => new()
{
Id = item.Id,
ProductName = $"Processed: {item.ProductName}",
Quantity = item.Quantity * 2,
UnitPrice = item.UnitPrice * 2,
};
[SignalR(BenchmarkSignalRTags.TestOrderParam)]
public TestOrder_All_True HandleTestOrder(TestOrder_All_True order) => order;
[SignalR(BenchmarkSignalRTags.SharedTagParam)]
public SharedTag_All_True HandleSharedTag(SharedTag_All_True tag) => tag;
[SignalR(BenchmarkSignalRTags.IntArrayParam)]
public int[] HandleIntArray(int[] values) => values.Select(x => x * 2).ToArray();
[SignalR(BenchmarkSignalRTags.StringListParam)]
public List<string> HandleStringList(List<string> items) => items.Select(x => x.ToUpper()).ToList();
[SignalR(BenchmarkSignalRTags.IntAndDtoParam)]
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}";
}
#endregion

View File

@ -0,0 +1,294 @@
using AyCode.Core.Serializers.Attributes;
using AyCode.Core.Serializers.Binaries;
using BenchmarkDotNet.Attributes;
using MessagePack;
using MessagePack.Resolvers;
namespace AyCode.Core.Benchmarks;
/// <summary>
/// Pure contractless model - NO MessagePack attributes.
/// This tests TRUE runtime serialization without any source generation.
/// </summary>
[AcBinarySerializable]
public class PureContractlessModel
{
public int Id { get; set; }
public string Name { get; set; } = "";
public string Description { get; set; } = "";
public bool IsActive { get; set; }
public double Value { get; set; }
public decimal Price { get; set; }
public long BigNumber { get; set; }
public DateTime CreatedAt { get; set; }
public Guid UniqueId { get; set; }
public int Count { get; set; }
public string Category { get; set; } = "";
public string Status { get; set; } = "";
}
/// <summary>
/// Benchmark model with only primitive types - fully supported by Source Generator.
/// MessagePack attributes added for fair comparison with Source Generator.
/// </summary>
[AcBinarySerializable]
[MessagePackObject]
public class PrimitiveBenchmarkModel
{
[Key(0)] public int Id { get; set; }
[Key(1)] public string Name { get; set; } = "";
[Key(2)] public string Description { get; set; } = "";
[Key(3)] public bool IsActive { get; set; }
[Key(4)] public double Value { get; set; }
[Key(5)] public decimal Price { get; set; }
[Key(6)] public long BigNumber { get; set; }
[Key(7)] public DateTime CreatedAt { get; set; }
[Key(8)] public Guid UniqueId { get; set; }
[Key(9)] public int Count { get; set; }
[Key(10)] public string Category { get; set; } = "";
[Key(11)] public string Status { get; set; } = "";
}
/// <summary>
/// TRUE Contractless benchmark - no attributes, pure runtime serialization.
/// </summary>
[ShortRunJob]
[MemoryDiagnoser]
[RankColumn]
public class PureContractlessBenchmark
{
private PureContractlessModel _testData = null!;
private byte[] _acBinaryData = null!;
private byte[] _msgPackContractlessData = null!;
private AcBinarySerializerOptions _binaryOptions = null!;
private MessagePackSerializerOptions _msgPackContractlessOptions = null!;
[GlobalSetup]
public void Setup()
{
_testData = new PureContractlessModel
{
Id = 12345,
Name = "Test Product Name",
Description = "This is a longer description for testing string serialization performance",
IsActive = true,
Value = 123.456789,
Price = 99.99m,
BigNumber = 9876543210L,
CreatedAt = DateTime.UtcNow,
UniqueId = Guid.NewGuid(),
Count = 42,
Category = "Electronics",
Status = "Available"
};
_binaryOptions = AcBinarySerializerOptions.WithoutReferenceHandling;
_msgPackContractlessOptions = ContractlessStandardResolver.Options.WithCompression(MessagePackCompression.None);
_acBinaryData = AcBinarySerializer.Serialize(_testData, _binaryOptions);
_msgPackContractlessData = MessagePackSerializer.Serialize(_testData, _msgPackContractlessOptions);
Console.WriteLine($"=== Pure Contractless (NO attributes) ===");
Console.WriteLine($"AcBinary: {_acBinaryData.Length} bytes");
Console.WriteLine($"MsgPack Contractless: {_msgPackContractlessData.Length} bytes");
}
[Benchmark(Description = "AcBinary Serialize")]
public byte[] Serialize_AcBinary()
=> AcBinarySerializer.Serialize(_testData, _binaryOptions);
[Benchmark(Description = "MsgPack Contractless Serialize", Baseline = true)]
public byte[] Serialize_MsgPack_Contractless()
=> MessagePackSerializer.Serialize(_testData, _msgPackContractlessOptions);
[Benchmark(Description = "AcBinary Deserialize")]
public PureContractlessModel? Deserialize_AcBinary()
=> AcBinaryDeserializer.Deserialize<PureContractlessModel>(_acBinaryData);
[Benchmark(Description = "MsgPack Contractless Deserialize")]
public PureContractlessModel? Deserialize_MsgPack_Contractless()
=> MessagePackSerializer.Deserialize<PureContractlessModel>(_msgPackContractlessData, _msgPackContractlessOptions);
}
/// <summary>
/// Benchmark comparing Source Generator vs Runtime serialization for primitive-only types.
/// Uses MessagePack with [MessagePackObject] attributes for fair Source Generator comparison.
/// </summary>
[ShortRunJob]
[MemoryDiagnoser]
[RankColumn]
public class SourceGeneratorVsRuntimeBenchmark
{
private PrimitiveBenchmarkModel _testData = null!;
private byte[] _runtimeSerializedData = null!;
private byte[] _msgPackData = null!;
private byte[] _msgPackContractlessData = null!;
private AcBinarySerializerOptions _binaryOptions = null!;
private MessagePackSerializerOptions _msgPackOptions = null!;
private MessagePackSerializerOptions _msgPackContractlessOptions = null!;
[GlobalSetup]
public void Setup()
{
_testData = new PrimitiveBenchmarkModel
{
Id = 12345,
Name = "Test Product Name",
Description = "This is a longer description for testing string serialization performance",
IsActive = true,
Value = 123.456789,
Price = 99.99m,
BigNumber = 9876543210L,
CreatedAt = DateTime.UtcNow,
UniqueId = Guid.NewGuid(),
Count = 42,
Category = "Electronics",
Status = "Available"
};
_binaryOptions = AcBinarySerializerOptions.WithoutReferenceHandling;
// MessagePack with Source Generator (uses [MessagePackObject] + [Key] attributes)
_msgPackOptions = MessagePackSerializerOptions.Standard;
// MessagePack without Source Generator (Contractless - reflection based, like AcBinary runtime)
_msgPackContractlessOptions = ContractlessStandardResolver.Options.WithCompression(MessagePackCompression.None);
// Pre-serialize for deserialize benchmarks
_runtimeSerializedData = AcBinarySerializer.Serialize(_testData, _binaryOptions);
_msgPackData = MessagePackSerializer.Serialize(_testData, _msgPackOptions);
_msgPackContractlessData = MessagePackSerializer.Serialize(_testData, _msgPackContractlessOptions);
// Print sizes
Console.WriteLine($"=== Primitive Model Serialization ===");
Console.WriteLine($"AcBinary Runtime: {_runtimeSerializedData.Length} bytes");
Console.WriteLine($"MessagePack SourceGen: {_msgPackData.Length} bytes");
Console.WriteLine($"MessagePack Contractless:{_msgPackContractlessData.Length} bytes");
// Verify generated serializer exists
var generatedType = typeof(PrimitiveBenchmarkModel).Assembly.GetType(
$"{typeof(PrimitiveBenchmarkModel).FullName}_AcBinarySerializer");
Console.WriteLine($"AcBinary Generated serializer found: {generatedType != null}");
}
#region Serialize Benchmarks
[Benchmark(Description = "AcBinary Runtime Serialize")]
public byte[] Serialize_AcBinary_Runtime()
=> AcBinarySerializer.Serialize(_testData, _binaryOptions);
[Benchmark(Description = "MsgPack SourceGen Serialize", Baseline = true)]
public byte[] Serialize_MsgPack_SourceGen()
=> MessagePackSerializer.Serialize(_testData, _msgPackOptions);
[Benchmark(Description = "MsgPack Contractless Serialize")]
public byte[] Serialize_MsgPack_Contractless()
=> MessagePackSerializer.Serialize(_testData, _msgPackContractlessOptions);
#endregion
#region Deserialize Benchmarks
[Benchmark(Description = "AcBinary Runtime Deserialize")]
public PrimitiveBenchmarkModel? Deserialize_AcBinary_Runtime()
=> AcBinaryDeserializer.Deserialize<PrimitiveBenchmarkModel>(_runtimeSerializedData);
[Benchmark(Description = "MsgPack SourceGen Deserialize")]
public PrimitiveBenchmarkModel? Deserialize_MsgPack_SourceGen()
=> MessagePackSerializer.Deserialize<PrimitiveBenchmarkModel>(_msgPackData, _msgPackOptions);
[Benchmark(Description = "MsgPack Contractless Deserialize")]
public PrimitiveBenchmarkModel? Deserialize_MsgPack_Contractless()
=> MessagePackSerializer.Deserialize<PrimitiveBenchmarkModel>(_msgPackContractlessData, _msgPackContractlessOptions);
#endregion
}
/// <summary>
/// Repeated string benchmark model - tests string interning performance.
/// MessagePack attributes added for fair comparison.
/// </summary>
[AcBinarySerializable]
[MessagePackObject]
public class RepeatedStringBenchmarkModel
{
[Key(0)] public int Id { get; set; }
[Key(1)] public string Status { get; set; } = "";
[Key(2)] public string Category { get; set; } = "";
[Key(3)] public string Priority { get; set; } = "";
[Key(4)] public string Type { get; set; } = "";
}
/// <summary>
/// Benchmark for types with repeated string values - where AcBinary string interning helps.
/// Compares against both MessagePack SourceGen and Contractless modes.
/// </summary>
[ShortRunJob]
[MemoryDiagnoser]
[RankColumn]
public class RepeatedStringBenchmark
{
private List<RepeatedStringBenchmarkModel> _items = null!;
private byte[] _acBinaryData = null!;
private byte[] _msgPackData = null!;
private byte[] _msgPackContractlessData = null!;
private AcBinarySerializerOptions _binaryOptions = null!;
private MessagePackSerializerOptions _msgPackOptions = null!;
private MessagePackSerializerOptions _msgPackContractlessOptions = null!;
[Params(100, 500)]
public int ItemCount { get; set; }
[GlobalSetup]
public void Setup()
{
_items = Enumerable.Range(0, ItemCount).Select(i => new RepeatedStringBenchmarkModel
{
Id = i,
Status = i % 3 == 0 ? "Pending" : i % 3 == 1 ? "Processing" : "Completed",
Category = $"Category_{i % 5}",
Priority = i % 2 == 0 ? "High" : "Low",
Type = i % 4 == 0 ? "TypeA" : i % 4 == 1 ? "TypeB" : i % 4 == 2 ? "TypeC" : "TypeD"
}).ToList();
_binaryOptions = AcBinarySerializerOptions.WithoutReferenceHandling;
_msgPackOptions = MessagePackSerializerOptions.Standard;
_msgPackContractlessOptions = ContractlessStandardResolver.Options.WithCompression(MessagePackCompression.None);
_acBinaryData = AcBinarySerializer.Serialize(_items, _binaryOptions);
_msgPackData = MessagePackSerializer.Serialize(_items, _msgPackOptions);
_msgPackContractlessData = MessagePackSerializer.Serialize(_items, _msgPackContractlessOptions);
Console.WriteLine($"=== Repeated Strings ({ItemCount} items) ===");
Console.WriteLine($"AcBinary: {_acBinaryData.Length} bytes");
Console.WriteLine($"MsgPack SourceGen: {_msgPackData.Length} bytes");
Console.WriteLine($"MsgPack Contractless: {_msgPackContractlessData.Length} bytes");
}
[Benchmark(Description = "AcBinary Serialize")]
public byte[] Serialize_AcBinary()
=> AcBinarySerializer.Serialize(_items, _binaryOptions);
[Benchmark(Description = "MsgPack SourceGen Serialize", Baseline = true)]
public byte[] Serialize_MsgPack_SourceGen()
=> MessagePackSerializer.Serialize(_items, _msgPackOptions);
[Benchmark(Description = "MsgPack Contractless Serialize")]
public byte[] Serialize_MsgPack_Contractless()
=> MessagePackSerializer.Serialize(_items, _msgPackContractlessOptions);
[Benchmark(Description = "AcBinary Deserialize")]
public List<RepeatedStringBenchmarkModel>? Deserialize_AcBinary()
=> AcBinaryDeserializer.Deserialize<List<RepeatedStringBenchmarkModel>>(_acBinaryData);
[Benchmark(Description = "MsgPack SourceGen Deserialize")]
public List<RepeatedStringBenchmarkModel>? Deserialize_MsgPack_SourceGen()
=> MessagePackSerializer.Deserialize<List<RepeatedStringBenchmarkModel>>(_msgPackData, _msgPackOptions);
[Benchmark(Description = "MsgPack Contractless Deserialize")]
public List<RepeatedStringBenchmarkModel>? Deserialize_MsgPack_Contractless()
=> MessagePackSerializer.Deserialize<List<RepeatedStringBenchmarkModel>>(_msgPackContractlessData, _msgPackContractlessOptions);
}

View File

@ -0,0 +1,66 @@
using AyCode.Core.Helpers;
using BenchmarkDotNet.Attributes;
using Microsoft.VSDiagnostics;
namespace AyCode.Core.Benchmarks;
[CPUUsageDiagnoser]
public class TaskHelperBenchmarks
{
private volatile bool _flag;
private int _counter;
private Action _incrementAction = null !;
private Func<int> _incrementFunc = null !;
private Func<Task<int>> _incrementAsyncFunc = null !;
[GlobalSetup]
public void Setup()
{
_incrementAction = () => _counter++;
_incrementFunc = () => ++_counter;
_incrementAsyncFunc = async () =>
{
await Task.Yield();
return ++_counter;
};
}
[IterationSetup]
public void IterationSetup()
{
_flag = true; // Pre-set for immediate success
_counter = 0;
}
#region WaitToAsync Benchmarks
[Benchmark(Description = "WaitToAsync - immediate success")]
[BenchmarkCategory("WaitToAsync")]
public Task<bool> WaitToAsync_ImmediateSuccess() => TaskHelper.WaitToAsync(() => _flag, 1000, 1);
[Benchmark(Description = "WaitToAsync - short timeout (100ms)")]
[BenchmarkCategory("WaitToAsync")]
public Task<bool> WaitToAsync_ShortTimeout() => TaskHelper.WaitToAsync(() => true, 100, 1);
#endregion
#region ToThreadPoolTask Benchmarks
[Benchmark(Description = "ToThreadPoolTask - Action")]
[BenchmarkCategory("ThreadPool")]
public Task ToThreadPoolTask_Action() => _incrementAction.ToThreadPoolTask();
[Benchmark(Description = "ToThreadPoolTask - Func<T>")]
[BenchmarkCategory("ThreadPool")]
public Task<int> ToThreadPoolTask_FuncT() => _incrementFunc.ToThreadPoolTask();
[Benchmark(Description = "ToThreadPoolTask - Func<Task<T>>")]
[BenchmarkCategory("ThreadPool")]
public Task<int> ToThreadPoolTask_FuncTaskT() => _incrementAsyncFunc.ToThreadPoolTask();
[Benchmark(Description = "Task.Run baseline - Action")]
[BenchmarkCategory("ThreadPool")]
public Task TaskRun_Action_Baseline() => Task.Run(_incrementAction);
#endregion
#region Timing Method Comparison
[Benchmark(Description = "DateTime.UtcNow.Ticks")]
[BenchmarkCategory("Timing")]
public long DateTimeUtcNow_Ticks() => DateTime.UtcNow.Ticks;
[Benchmark(Description = "Environment.TickCount64")]
[BenchmarkCategory("Timing")]
public long EnvironmentTickCount64() => Environment.TickCount64;
[Benchmark(Description = "DateTime.UtcNow.AddMilliseconds")]
[BenchmarkCategory("Timing")]
public long DateTimeUtcNow_AddMilliseconds() => DateTime.UtcNow.AddMilliseconds(1000).Ticks;
#endregion
}

View File

@ -0,0 +1,149 @@
using BenchmarkDotNet.Attributes;
using System.Runtime.CompilerServices;
namespace AyCode.Benchmark;
/// <summary>
/// Benchmarks comparing value-by-copy vs 'in' parameter passing for large value types.
/// Tests decimal (16 bytes), DateTimeOffset (16 bytes), and Guid (16 bytes).
/// </summary>
[MemoryDiagnoser]
public class ValueTypePassingBenchmark
{
private decimal _decimal;
private DateTimeOffset _dateTimeOffset;
private Guid _guid;
private byte[] _buffer = null !;
private int _position;
[GlobalSetup]
public void Setup()
{
_decimal = 12345.6789m;
_dateTimeOffset = DateTimeOffset.Now;
_guid = Guid.NewGuid();
_buffer = new byte[1024];
}
// ============ DECIMAL (16 bytes) ============
[Benchmark]
public void WriteDecimal_ByValue()
{
_position = 0;
for (int i = 0; i < 1000; i++)
{
WriteDecimalByValue(_decimal);
}
}
[Benchmark]
public void WriteDecimal_ByIn()
{
_position = 0;
for (int i = 0; i < 1000; i++)
{
WriteDecimalByIn(in _decimal);
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void WriteDecimalByValue(decimal value)
{
Span<int> bits = stackalloc int[4];
decimal.TryGetBits(value, bits, out _);
System.Runtime.InteropServices.MemoryMarshal.AsBytes(bits).CopyTo(_buffer.AsSpan(_position, 16));
_position += 16;
if (_position > 900)
_position = 0;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void WriteDecimalByIn(in decimal value)
{
Span<int> bits = stackalloc int[4];
decimal.TryGetBits(value, bits, out _);
System.Runtime.InteropServices.MemoryMarshal.AsBytes(bits).CopyTo(_buffer.AsSpan(_position, 16));
_position += 16;
if (_position > 900)
_position = 0;
}
// ============ DATETIMEOFFSET (16 bytes) ============
[Benchmark]
public void WriteDateTimeOffset_ByValue()
{
_position = 0;
for (int i = 0; i < 1000; i++)
{
WriteDateTimeOffsetByValue(_dateTimeOffset);
}
}
[Benchmark]
public void WriteDateTimeOffset_ByIn()
{
_position = 0;
for (int i = 0; i < 1000; i++)
{
WriteDateTimeOffsetByIn(in _dateTimeOffset);
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void WriteDateTimeOffsetByValue(DateTimeOffset value)
{
Unsafe.WriteUnaligned(ref _buffer[_position], value.UtcTicks);
Unsafe.WriteUnaligned(ref _buffer[_position + 8], (short)value.Offset.TotalMinutes);
_position += 10;
if (_position > 900)
_position = 0;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void WriteDateTimeOffsetByIn(in DateTimeOffset value)
{
Unsafe.WriteUnaligned(ref _buffer[_position], value.UtcTicks);
Unsafe.WriteUnaligned(ref _buffer[_position + 8], (short)value.Offset.TotalMinutes);
_position += 10;
if (_position > 900)
_position = 0;
}
// ============ GUID (16 bytes) ============
[Benchmark]
public void WriteGuid_ByValue()
{
_position = 0;
for (int i = 0; i < 1000; i++)
{
WriteGuidByValue(_guid);
}
}
[Benchmark]
public void WriteGuid_ByIn()
{
_position = 0;
for (int i = 0; i < 1000; i++)
{
WriteGuidByIn(in _guid);
}
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void WriteGuidByValue(Guid value)
{
value.TryWriteBytes(_buffer.AsSpan(_position, 16));
_position += 16;
if (_position > 900)
_position = 0;
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void WriteGuidByIn(in Guid value)
{
value.TryWriteBytes(_buffer.AsSpan(_position, 16));
_position += 16;
if (_position > 900)
_position = 0;
}
}

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

@ -0,0 +1,53 @@
<Project Sdk="Microsoft.NET.Sdk">
<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>
<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

@ -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

@ -0,0 +1,179 @@
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>
/// Comprehensive benchmark application for all serializers.
/// Compares: AcBinary (all options), MemoryPack, MessagePack, Newtonsoft.Json, System.Text.Json
///
/// Usage:
/// dotnet run # Run all benchmarks
/// dotnet run -- quick # Quick mode (fewer iterations)
/// dotnet run -- serialize # Serialize only
/// dotnet run -- deserialize # Deserialize only
/// </summary>
public static class Program
{
// 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;
// Setup validation — abort BEFORE any benchmark logic if MemoryPack baseline is invalid.
// Done early so user is told immediately, not after warmup.
BenchmarkLoop.ValidateMemoryPackSetup();
// CLI mode (args provided): run once, parse args, exit. Backward-compatible behaviour.
if (args.Length > 0)
{
if (!TryParseCliArgs(args, out var layer, out var opMode, out var serializerMode))
return; // invalid args
BenchmarkLoop.RunBenchmark(layer, opMode, serializerMode);
return;
}
// 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)
{
var selection = Menu.ShowInteractiveMenu();
if (selection == null) return; // user pressed Q
BenchmarkLoop.RunBenchmark(selection.Value.layer, BenchmarkOpMode.All, selection.Value.serializerMode);
System.Console.WriteLine();
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();
}
}
/// <summary>
/// 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 bool TryParseCliArgs(string[] args, out BenchmarkLayer layer, out BenchmarkOpMode opMode, out SerializerSelectionMode serializerMode)
{
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)
{
// 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))
{
Configuration.WarmupIterations = 5;
Configuration.TestIterations = 100;
Configuration.BenchmarkSamples = 3;
continue;
}
// Serializer-selection (AsyncPipe/FastestByte/Standard).
if (Enum.TryParse<SerializerSelectionMode>(arg, ignoreCase: true, out var sm))
{
serializerMode = sm;
continue;
}
// 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 true;
}
/// <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

@ -0,0 +1,89 @@
# AyCode.Core.Serializers.Console
Interactive console runner for the serializer benchmark suite. Targets .NET 9.
> **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.
## Role
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.`.
## Compared serializers
- **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` (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
- `<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

@ -0,0 +1,79 @@
using System.Collections.Immutable;
using System.Linq;
using System.Text;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.Text;
namespace AyCode.Core.Serializers.SourceGenerator;
/// <summary>
/// Incremental source generator for <c>[AcBinarySerializable]</c> types. Emits an
/// <c>IGeneratedBinaryWriter</c> + <c>IGeneratedBinaryReader</c> implementation for every annotated
/// type, plus a <c>[ModuleInitializer]</c>-based registry hook that wires the generated instances
/// into the runtime serializer at startup.
///
/// <para><b>Source organization</b> — this generator class is split across multiple partial files
/// for navigational clarity:</para>
/// <list type="bullet">
/// <item><c>AcBinarySourceGenerator.cs</c> (this file) — entry point: <c>[Generator]</c> attribute,
/// <c>Initialize</c> + <c>Execute</c> orchestration.</item>
/// <item><c>AcBinarySourceGenerator.Models.cs</c> — non-partial model types
/// (<c>SerializableClassInfo</c>, <c>PropInfo</c>, <c>PropertyTypeKind</c>).</item>
/// <item><c>AcBinarySourceGenerator.TypeAnalysis.cs</c> — Roslyn-symbol utility passes
/// (kind detection, FNV hashing, name flattening, scan-need recursion).</item>
/// <item><c>AcBinarySourceGenerator.Diagnostics.cs</c> — ACBIN001 (cycle warning) + ACBIN002
/// (polymorph misuse error) descriptors and detection methods.</item>
/// <item><c>AcBinarySourceGenerator.GetClassInfo.cs</c> — class-info extraction pass (attribute
/// flags + property metadata building).</item>
/// <item><c>AcBinarySourceGenerator.GenWriter.cs</c> — writer-side emit (WriteProperties, ScanObject,
/// ScanForDuplicates, per-property emit helpers).</item>
/// <item><c>AcBinarySourceGenerator.GenReader.cs</c> — reader-side emit (ReadProperties, ReadObject,
/// per-property read helpers).</item>
/// <item><c>AcBinarySourceGenerator.GenInit.cs</c> — ModuleInitializer-based registry hook emit.</item>
/// </list>
/// </summary>
[Generator]
public partial class AcBinarySourceGenerator : IIncrementalGenerator
{
private const string AttributeName = "AyCode.Core.Serializers.Attributes.AcBinarySerializableAttribute";
// Feature gates on the SGen-emitted writer / scan code are driven by `[AcBinarySerializable]`
// attribute flags. Two such gates are wired through SerializableClassInfo:
// • EnablePropertyFilter → omits the per-property `HasPropertyFilter` branch when false.
// • EnablePolymorphDetect → omits the `ObjectWithTypeName + AQN` prefix on `System.Object`-
// declared properties when false (then ACBIN002 guards misuse).
// • EnableInternString → omits StringInterned* case-emit in the reader switch when false.
// All default `true`; opt-out is per type via the attribute ctor parameters.
public void Initialize(IncrementalGeneratorInitializationContext context)
{
var classDeclarations = context.SyntaxProvider
.ForAttributeWithMetadataName(
AttributeName,
predicate: static (node, _) => node is ClassDeclarationSyntax || node is StructDeclarationSyntax,
transform: static (ctx, _) => GetClassInfo(ctx))
.Where(static info => info != null);
context.RegisterSourceOutput(classDeclarations.Collect(),
static (spc, classes) => Execute(classes!, spc));
}
private static void Execute(ImmutableArray<SerializableClassInfo?> classes, SourceProductionContext context)
{
if (classes.IsDefaultOrEmpty) return;
var valid = classes.Where(c => c != null).Cast<SerializableClassInfo>().ToList();
if (valid.Count == 0) return;
DetectAndReportCycles(valid, context);
DetectAndReportPolymorphicMisuse(valid, context);
foreach (var ci in valid)
{
context.AddSource($"{ci.ClassName}_GeneratedWriter.g.cs", SourceText.From(GenWriter(ci), Encoding.UTF8));
context.AddSource($"{ci.ClassName}_GeneratedReader.g.cs", SourceText.From(GenReader(ci), Encoding.UTF8));
}
context.AddSource("AcBinaryGeneratedWriters_Init.g.cs", SourceText.From(GenInit(valid), Encoding.UTF8));
}
}

View File

@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<LangVersion>latest</LangVersion>
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
<IsRoslynComponent>true</IsRoslynComponent>
</PropertyGroup>
<Import Project="..\AyCode.Core.targets" />
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.8.0" PrivateAssets="all" />
<PackageReference Include="Microsoft.CodeAnalysis.Analyzers" Version="3.3.4" PrivateAssets="all" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,39 @@
# AyCode.Core.Serializers.SourceGenerator
Roslyn incremental source generator that produces optimized `IGeneratedBinaryWriter` and `IGeneratedBinaryReader` implementations for types marked with `[AcBinarySerializable]`. Eliminates runtime reflection on serialization hot paths.
Targets **netstandard2.0** (required for Roslyn analyzers/generators).
## Key Files
- **`AcBinarySourceGenerator.cs`** — Single-file `IIncrementalGenerator` (~2100 lines). Generates:
- `{ClassName}_GeneratedWriter` — Per-type writer with `ScanObject()` + `WriteProperties()` methods. Handles primitives, strings (with interning), collections, dictionaries, complex nested types, and polymorphic objects.
- `{ClassName}_GeneratedReader` — Per-type reader with `ReadProperties()` method.
- `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:
- `enableMetadata` — Property hash metadata for cross-type deserialization
- `enableIdTracking` — IId-based reference tracking
- `enableRefHandling` — General reference tracking
- `enableInternString` — String interning/deduplication
Disabled features eliminate corresponding code blocks from generated output (zero dead code).
## Dependencies
| Dependency | Purpose |
|---|---|
| `Microsoft.CodeAnalysis.CSharp` | Roslyn syntax/semantic APIs |
| `Microsoft.CodeAnalysis.Analyzers` | Analyzer best practices |

View File

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

View File

@ -0,0 +1,9 @@
# Loggers
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 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

@ -0,0 +1,33 @@
# 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 |
|---|---|
| `Loggers/` | Server-side global logger singleton |
## Key Files
### Loggers/
- **`GlobalLogger.cs`** — Static singleton facade for server-side logging. Wraps `AcGlobalLoggerBase` (sealed, derives from `AcLoggerBase`). Provides static methods (`Detail`, `Debug`, `Info`, `Warning`, `Suggest`, `Error`, `Write`) with `[CallerMemberName]` support. Default category: `"GLOBAL_LOGGER"`.
## Dependencies
| Dependency | Purpose |
|---|---|
| `AyCode.Core` | Core library (loggers, enums, serializers) |
| `MessagePack` | MessagePack serialization |
| `Newtonsoft.Json` | JSON serialization |
| `Microsoft.Extensions.Logging.Abstractions` | Logging abstractions |

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

@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsPackable>false</IsPackable>
@ -8,15 +8,15 @@
<Import Project="..//AyCode.Core.targets" />
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4">
<PackageReference Include="coverlet.collector" Version="8.0.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.8" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.8" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="MSTest.TestAdapter" Version="3.10.3" />
<PackageReference Include="MSTest.TestFramework" Version="3.10.3" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.11" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.11" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.3.0" />
<PackageReference Include="MSTest.TestAdapter" Version="4.1.0" />
<PackageReference Include="MSTest.TestFramework" Version="4.1.0" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
</ItemGroup>

View File

@ -0,0 +1,18 @@
# Entities
Concrete entity implementations inheriting from AyCode.Entities abstract generics. Used by database integration tests.
## Key Files
- **`User.cs`** — `AcUser<Profile, Company, UserToCompany, Address>`.
- **`Company.cs`** — `AcCompany<User, UserToCompany, Profile, Address>`.
- **`UserToCompany.cs`** — `AcUserToCompany<User, Company>` junction entity.
- **`Profile.cs`** — `AcProfile<Address>`.
- **`Address.cs`** — `AcAddress` + `IAcAddressDtoBase` with DTO support.
- **`UserToken.cs`** — `AcUserTokenBase` authentication token.
- **`EmailMessage.cs`** — `AcEmailMessage<EmailRecipient>`.
- **`EmailRecipient.cs`** — `AcEmailRecipient<EmailMessage>`.
## Relationships
User ↔ Company (many-to-many via UserToCompany), User → Profile → Address (one-to-one chain), EmailMessage → EmailRecipient (one-to-many).

View File

@ -0,0 +1,17 @@
# AyCode.Core.Tests.Internal
Concrete entity implementations for database integration testing. Exposes types to `AyCode.Database.Tests*` via `[InternalsVisibleTo]`.
## Folder Structure
| Folder | Purpose |
|---|---|
| [`Entities/`](Entities/README.md) | Concrete entity implementations (User, Company, Profile, Address, etc.) |
## Dependencies
| Dependency | Purpose |
|---|---|
| `MSTest` | Test framework |
| `AyCode.Core.Tests` | Shared test utilities |
| `AyCode.Entities` / `AyCode.Entities.Server` | Abstract entity base classes |

View File

@ -1,27 +1,35 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<!-- Enable generated files output for debugging -->
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
<CompilerGeneratedFilesOutputPath>$(BaseIntermediateOutputPath)Generated</CompilerGeneratedFilesOutputPath>
</PropertyGroup>
<Import Project="..//AyCode.Core.targets" />
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.8" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.8" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="MSTest.TestAdapter" Version="3.10.3" />
<PackageReference Include="MSTest.TestFramework" Version="3.10.3" />
<PackageReference Include="coverlet.collector" Version="6.0.4">
<PackageReference Include="MemoryPack" Version="1.21.4" />
<PackageReference Include="MessagePack" Version="3.1.4" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.11" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.11" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.3.0" />
<PackageReference Include="MongoDB.Bson" Version="3.5.2" />
<PackageReference Include="MSTest.TestAdapter" Version="4.1.0" />
<PackageReference Include="MSTest.TestFramework" Version="4.1.0" />
<PackageReference Include="coverlet.collector" Version="8.0.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\AyCode.Core.Server\AyCode.Core.Server.csproj" />
<ProjectReference Include="..\AyCode.Core\AyCode.Core.csproj" />
<ProjectReference Include="..\AyCode.Core.Serializers.SourceGenerator\AyCode.Core.Serializers.SourceGenerator.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
<ProjectReference Include="..\AyCode.Entities.Server\AyCode.Entities.Server.csproj" />
<ProjectReference Include="..\AyCode.Entities\AyCode.Entities.csproj" />
<ProjectReference Include="..\AyCode.Interfaces.Server\AyCode.Interfaces.Server.csproj" />

View File

@ -0,0 +1,52 @@
using System.Buffers;
using System.Text;
using AyCode.Core.Compression;
namespace AyCode.Core.Tests.Compression;
[TestClass]
public class GzipHelperTests
{
[TestMethod]
public void CompressAndDecompress_StringRoundTrip_Succeeds()
{
var original = "SignalR payload for gzip";
var compressed = GzipHelper.Compress(original);
var decompressed = GzipHelper.DecompressToString(compressed);
Assert.IsNotNull(compressed);
Assert.AreNotEqual(0, compressed.Length);
Assert.AreEqual(original, decompressed);
}
[TestMethod]
public void DecompressToRentedBuffer_ReturnsOriginalBytes()
{
var payload = "{\"message\":\"gzip\"}";
var compressed = GzipHelper.Compress(payload);
var (buffer, length) = GzipHelper.DecompressToRentedBuffer(compressed);
try
{
Assert.IsTrue(length > 0);
var text = Encoding.UTF8.GetString(buffer, 0, length);
Assert.AreEqual(payload, text);
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
}
[TestMethod]
public void IsGzipCompressed_ReturnsExpectedValues()
{
var compressed = GzipHelper.Compress("ping");
var nonCompressed = Encoding.UTF8.GetBytes("plain text");
Assert.IsTrue(GzipHelper.IsGzipCompressed(compressed));
Assert.IsFalse(GzipHelper.IsGzipCompressed(nonCompressed));
Assert.IsFalse(GzipHelper.IsGzipCompressed(Array.Empty<byte>()));
}
}

View File

@ -0,0 +1,7 @@
# Compression Tests
GZip compression utility tests.
## Key Files
- **`GzipHelperTests.cs`** — Tests GzipHelper.Compress(), DecompressToString(), DecompressToRentedBuffer() (ArrayPool), IsGzipCompressed() (magic byte detection).

View File

@ -0,0 +1,7 @@
# GeneratedWriters
Hand-written examples of the code pattern that the AcBinarySerializable source generator produces.
## 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).

View File

@ -0,0 +1,114 @@
using System.Runtime.CompilerServices;
using AyCode.Core.Serializers.Binaries;
using AyCode.Core.Tests.TestModels;
namespace AyCode.Core.Tests.GeneratedWriters;
/// <summary>
/// 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:
/// - Direct obj.Property access instead of Func&lt;&gt;.Invoke()
/// - No switch dispatch per property
/// - No boxing for value types
/// - Small method (~500B native) vs 27KB WriteObject — better ICache
///
/// Properties are written in alphabetical order to match the runtime serializer.
/// Complex/Collection properties fall back to the runtime serializer via WriteValue.
/// </summary>
internal sealed class TestOrderWriter : IGeneratedBinaryWriter
{
internal static readonly TestOrderWriter Instance = new();
public void WriteProperties<TOutput>(object value,
AcBinarySerializer.BinarySerializationContext<TOutput> context)
where TOutput : struct, IBinaryOutputBase
{
var obj = Unsafe.As<TestOrder_All_True>(value);
// Properties in alphabetical order (matching runtime serializer):
// AuditMetadata: MetadataInfo_All_True? (complex, nullable)
WriteComplexOrNull(obj.AuditMetadata, context);
// Category: SharedCategory_All_True? (complex, nullable)
WriteComplexOrNull(obj.Category, context);
// CreatedAt: DateTime (markerless)
context.WriteDateTimeBits(obj.CreatedAt);
// Id: int (markerless)
context.WriteVarInt(obj.Id);
// Items: List<TestOrderItem_All_True> (collection)
WriteComplexOrNull(obj.Items, context);
// MetadataList: List<MetadataInfo_All_True> (collection)
WriteComplexOrNull(obj.MetadataList, context);
// NoMergeItems: List<TestOrderItem_All_True> (collection)
WriteComplexOrNull(obj.NoMergeItems, context);
// 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);
// PaidDateUtc: DateTime? (nullable)
var paidDate = obj.PaidDateUtc;
if (paidDate.HasValue)
{
context.WriteByte(BinaryTypeCode.DateTime);
context.WriteDateTimeBits(paidDate.Value);
}
else
{
context.WriteByte(BinaryTypeCode.Null);
}
// PrimaryTag: SharedTag_All_True? (complex, nullable)
WriteComplexOrNull(obj.PrimaryTag, context);
// SecondaryTag: SharedTag_All_True? (complex, nullable)
WriteComplexOrNull(obj.SecondaryTag, context);
// Status: TestStatus (enum, markerless)
context.WriteVarInt((int)obj.Status);
// 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) where TOutput : struct, IBinaryOutputBase
{
throw new NotImplementedException();
}
public void ScanForDuplicates<TOutput>(object value, AcBinarySerializer.BinarySerializationContext<TOutput> context) where TOutput : struct, IBinaryOutputBase
{
if (!context.HasCaching) return;
ScanObject(value, context);
context.SortWritePlan();
}
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private static void WriteComplexOrNull<TOutput>(object? value, AcBinarySerializer.BinarySerializationContext<TOutput> context)
where TOutput : struct, IBinaryOutputBase
{
if (value == null)
{
context.WriteByte(BinaryTypeCode.PropertySkip);
return;
}
AcBinarySerializer.WriteValueGenerated(value, value.GetType(), context);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,27 @@
# AyCode.Core.Tests
MSTest unit tests for AyCode.Core serialization, compression, and utilities. Covers binary/JSON round-trips, reference handling, nullable types, source generator integration, and performance benchmarks.
## Folder Structure
| Folder | Purpose |
|---|---|
| [`Serialization/`](Serialization/README.md) | Binary and JSON serialization tests (20+ test classes) |
| [`Compression/`](Compression/README.md) | GZip compression tests |
| [`TestModels/`](TestModels/README.md) | Shared test entities, enums, data factories, SignalR infrastructure |
| [`GeneratedWriters/`](GeneratedWriters/README.md) | Hand-written source generator output examples |
## Key Files (Root)
- **`GlobalUsings.cs`** — Global MSTest using.
- **`TestModelBase.cs`** — Abstract base for test models with configuration support.
- **`JsonExtensionTests.cs`** — JSON extension method tests.
## Dependencies
| Dependency | Purpose |
|---|---|
| `MSTest` | Test framework |
| `MessagePack` | Serialization comparison |
| `MemoryPack` | Serialization comparison |
| `MongoDB.Bson` | BSON comparison |

View File

@ -0,0 +1,474 @@
using AyCode.Core.Extensions;
using AyCode.Core.Serializers.Binaries;
namespace AyCode.Core.Tests.Serialization;
/// <summary>
/// Tests for DateTime serialization in Binary format.
/// Covers edge cases like string-stored DateTime values in GenericAttribute-like scenarios.
/// </summary>
[TestClass]
public class AcBinaryDateTimeSerializationTests
{
#region DateTime Direct Serialization
[TestMethod]
public void DateTime_RoundTrip_PreservesValue()
{
var original = new DateTime(2025, 10, 24, 0, 27, 0, DateTimeKind.Utc);
var binary = AcBinarySerializer.Serialize(original);
var result = binary.BinaryTo<DateTime>();
Assert.AreEqual(original, result);
Assert.AreEqual(DateTimeKind.Utc, result.Kind);
}
[TestMethod]
public void NullableDateTime_RoundTrip_PreservesValue()
{
DateTime? original = new DateTime(2025, 10, 24, 0, 27, 0, DateTimeKind.Utc);
var binary = AcBinarySerializer.Serialize(original);
var result = binary.BinaryTo<DateTime?>();
Assert.AreEqual(original, result);
}
[TestMethod]
public void NullableDateTime_Null_RoundTrip_PreservesNull()
{
DateTime? original = null;
var binary = AcBinarySerializer.Serialize(original);
var result = binary.BinaryTo<DateTime?>();
Assert.IsNull(result);
}
#endregion
#region Object with DateTime Property
[TestMethod]
public void ObjectWithDateTime_RoundTrip_PreservesValue()
{
var original = new TestObjectWithDateTime
{
Id = 1,
CreatedAt = new DateTime(2025, 10, 24, 0, 27, 0, DateTimeKind.Utc),
UpdatedAt = new DateTime(2025, 12, 31, 23, 59, 59, DateTimeKind.Utc)
};
var binary = original.ToBinary();
var result = binary.BinaryTo<TestObjectWithDateTime>();
Assert.IsNotNull(result);
Assert.AreEqual(original.Id, result.Id);
Assert.AreEqual(original.CreatedAt, result.CreatedAt);
Assert.AreEqual(original.UpdatedAt, result.UpdatedAt);
}
[TestMethod]
public void ObjectWithNullableDateTime_Null_RoundTrip_PreservesNull()
{
var original = new TestObjectWithNullableDateTime
{
Id = 1,
DateOfReceipt = null
};
var binary = original.ToBinary();
var result = binary.BinaryTo<TestObjectWithNullableDateTime>();
Assert.IsNotNull(result);
Assert.AreEqual(original.Id, result.Id);
Assert.IsNull(result.DateOfReceipt);
}
[TestMethod]
public void ObjectWithNullableDateTime_HasValue_RoundTrip_PreservesValue()
{
var original = new TestObjectWithNullableDateTime
{
Id = 1,
DateOfReceipt = new DateTime(2025, 10, 24, 0, 27, 0, DateTimeKind.Utc)
};
var binary = original.ToBinary();
var result = binary.BinaryTo<TestObjectWithNullableDateTime>();
Assert.IsNotNull(result);
Assert.AreEqual(original.Id, result.Id);
Assert.AreEqual(original.DateOfReceipt, result.DateOfReceipt);
}
#endregion
#region GenericAttribute-like Scenario (String stored DateTime)
[TestMethod]
public void GenericAttributeScenario_DateTimeAsString_PreservesValue()
{
var original = new TestGenericAttribute
{
Key = "DateOfReceipt",
Value = "10/24/2025 00:27:00"
};
var binary = original.ToBinary();
var result = binary.BinaryTo<TestGenericAttribute>();
Assert.IsNotNull(result);
Assert.AreEqual(original.Key, result.Key);
Assert.AreEqual(original.Value, result.Value);
}
[TestMethod]
public void GenericAttributeScenario_ZeroValue_PreservesValue()
{
var original = new TestGenericAttribute
{
Key = "DateOfReceipt",
Value = "0"
};
var binary = original.ToBinary();
var result = binary.BinaryTo<TestGenericAttribute>();
Assert.IsNotNull(result);
Assert.AreEqual(original.Key, result.Key);
Assert.AreEqual("0", result.Value);
}
[TestMethod]
public void GenericAttributeList_RoundTrip_PreservesAllValues()
{
var original = new List<TestGenericAttribute>
{
new() { Key = "DateOfReceipt", Value = "10/24/2025 00:27:00" },
new() { Key = "SomeNumber", Value = "42" },
new() { Key = "EmptyValue", Value = "" },
new() { Key = "ZeroValue", Value = "0" }
};
var binary = original.ToBinary();
var result = binary.BinaryTo<List<TestGenericAttribute>>();
Assert.IsNotNull(result);
Assert.AreEqual(4, result.Count);
Assert.AreEqual("DateOfReceipt", result[0].Key);
Assert.AreEqual("10/24/2025 00:27:00", result[0].Value);
Assert.AreEqual("SomeNumber", result[1].Key);
Assert.AreEqual("42", result[1].Value);
Assert.AreEqual("EmptyValue", result[2].Key);
Assert.AreEqual("", result[2].Value);
Assert.AreEqual("ZeroValue", result[3].Key);
Assert.AreEqual("0", result[3].Value);
}
[TestMethod]
public void ObjectWithGenericAttributes_RoundTrip_PreservesAllValues()
{
var original = new TestDtoWithGenericAttributes
{
Id = 123,
Name = "Test Order",
GenericAttributes =
[
new() { Key = "DateOfReceipt", Value = "10/24/2025 00:27:00" },
new() { Key = "Priority", Value = "1" }
]
};
var binary = original.ToBinary();
var result = binary.BinaryTo<TestDtoWithGenericAttributes>();
Assert.IsNotNull(result);
Assert.AreEqual(123, result.Id);
Assert.AreEqual("Test Order", result.Name);
Assert.AreEqual(2, result.GenericAttributes.Count);
var dateAttr = result.GenericAttributes.FirstOrDefault(x => x.Key == "DateOfReceipt");
Assert.IsNotNull(dateAttr);
Assert.AreEqual("10/24/2025 00:27:00", dateAttr.Value);
}
#endregion
#region JSON vs Binary Comparison
[TestMethod]
public void GenericAttribute_JsonAndBinary_ProduceSameResult()
{
var original = new TestGenericAttribute
{
Key = "DateOfReceipt",
Value = "10/24/2025 00:27:00"
};
var json = original.ToJson();
var jsonResult = json.JsonTo<TestGenericAttribute>();
var binary = original.ToBinary();
var binaryResult = binary.BinaryTo<TestGenericAttribute>();
Assert.IsNotNull(jsonResult);
Assert.IsNotNull(binaryResult);
Assert.AreEqual(jsonResult.Key, binaryResult.Key);
Assert.AreEqual(jsonResult.Value, binaryResult.Value);
}
[TestMethod]
public void DtoWithGenericAttributes_JsonAndBinary_ProduceSameResult()
{
var original = new TestDtoWithGenericAttributes
{
Id = 123,
Name = "Test Order",
GenericAttributes =
[
new() { Key = "DateOfReceipt", Value = "10/24/2025 00:27:00" },
new() { Key = "ZeroValue", Value = "0" }
]
};
var json = original.ToJson();
var jsonResult = json.JsonTo<TestDtoWithGenericAttributes>();
var binary = original.ToBinary();
var binaryResult = binary.BinaryTo<TestDtoWithGenericAttributes>();
Assert.IsNotNull(jsonResult);
Assert.IsNotNull(binaryResult);
Assert.AreEqual(jsonResult.Id, binaryResult.Id);
Assert.AreEqual(jsonResult.Name, binaryResult.Name);
Assert.AreEqual(jsonResult.GenericAttributes.Count, binaryResult.GenericAttributes.Count);
for (int i = 0; i < jsonResult.GenericAttributes.Count; i++)
{
Assert.AreEqual(jsonResult.GenericAttributes[i].Key, binaryResult.GenericAttributes[i].Key);
Assert.AreEqual(jsonResult.GenericAttributes[i].Value, binaryResult.GenericAttributes[i].Value);
}
}
#endregion
#region Test Models
public class TestObjectWithDateTime
{
public int Id { get; set; }
public DateTime CreatedAt { get; set; }
public DateTime UpdatedAt { get; set; }
}
public class TestObjectWithNullableDateTime
{
public int Id { get; set; }
public DateTime? DateOfReceipt { get; set; }
}
public class TestGenericAttribute
{
public string Key { get; set; } = "";
public string Value { get; set; } = "";
}
public class TestDtoWithGenericAttributes
{
public int Id { get; set; }
public string Name { get; set; } = "";
public List<TestGenericAttribute> GenericAttributes { get; set; } = [];
}
#endregion
#region Exact Production Scenario Test
/// <summary>
/// This test reproduces the exact production bug scenario:
/// 1. Server sends Binary serialized response with GenericAttributes
/// 2. Client deserializes the Binary response
/// 3. Client accesses DateOfReceipt property which reads from GenericAttributes
/// 4. CommonHelper2.To<DateTime> fails to parse the string value
/// </summary>
[TestMethod]
public void ProductionScenario_GenericAttributeWithDateString_PreservesExactFormat()
{
// Arrange: Create DTO with GenericAttributes like in production
var original = new TestDtoWithGenericAttributes
{
Id = 123,
Name = "Test Order",
GenericAttributes =
[
new() { Key = "DateOfReceipt", Value = "10/24/2025 00:27:00" },
new() { Key = "Priority", Value = "1" },
new() { Key = "SomeFlag", Value = "true" }
]
};
// Act: Binary round-trip (simulates server->client communication)
var binary = original.ToBinary();
var result = binary.BinaryTo<TestDtoWithGenericAttributes>();
// Assert: The exact string value must be preserved
Assert.IsNotNull(result);
var dateAttr = result.GenericAttributes.FirstOrDefault(x => x.Key == "DateOfReceipt");
Assert.IsNotNull(dateAttr, "DateOfReceipt attribute should exist");
// This is the critical assertion - the EXACT string must be preserved
Assert.AreEqual("10/24/2025 00:27:00", dateAttr.Value,
$"Expected '10/24/2025 00:27:00' but got '{dateAttr.Value}'");
// Verify it can be parsed with US culture (which is how it was stored)
Assert.IsTrue(DateTime.TryParse(dateAttr.Value, new System.Globalization.CultureInfo("en-US"),
System.Globalization.DateTimeStyles.None, out var parsedDate),
$"Value '{dateAttr.Value}' should be parseable as US date format");
Assert.AreEqual(new DateTime(2025, 10, 24, 0, 27, 0), parsedDate);
}
/// <summary>
/// Test that verifies the exact bytes of the string are preserved.
/// </summary>
[TestMethod]
public void ProductionScenario_StringWithSlashes_BytesArePreserved()
{
var original = "10/24/2025 00:27:00";
var originalBytes = System.Text.Encoding.UTF8.GetBytes(original);
// Serialize and deserialize
var binary = AcBinarySerializer.Serialize(original);
var result = binary.BinaryTo<string>();
Assert.IsNotNull(result);
var resultBytes = System.Text.Encoding.UTF8.GetBytes(result);
// Compare byte-by-byte
Assert.AreEqual(originalBytes.Length, resultBytes.Length, "String length changed after serialization");
for (int i = 0; i < originalBytes.Length; i++)
{
Assert.AreEqual(originalBytes[i], resultBytes[i],
$"Byte at position {i} differs: expected {originalBytes[i]:X2} ('{(char)originalBytes[i]}'), got {resultBytes[i]:X2} ('{(char)resultBytes[i]}')");
}
}
/// <summary>
/// Test with large list of GenericAttributes to catch any edge cases.
/// </summary>
[TestMethod]
public void ProductionScenario_ManyGenericAttributes_AllPreserved()
{
var original = new TestDtoWithGenericAttributes
{
Id = 999,
Name = "Large Order",
GenericAttributes = Enumerable.Range(0, 50).Select(i => new TestGenericAttribute
{
Key = $"Attribute_{i}",
Value = i % 5 == 0 ? $"{i}/24/2025 00:00:00" : i.ToString()
}).ToList()
};
var binary = original.ToBinary();
var result = binary.BinaryTo<TestDtoWithGenericAttributes>();
Assert.IsNotNull(result);
Assert.AreEqual(50, result.GenericAttributes.Count);
for (int i = 0; i < 50; i++)
{
var expectedValue = i % 5 == 0 ? $"{i}/24/2025 00:00:00" : i.ToString();
Assert.AreEqual($"Attribute_{i}", result.GenericAttributes[i].Key);
Assert.AreEqual(expectedValue, result.GenericAttributes[i].Value,
$"Attribute_{i} value mismatch: expected '{expectedValue}', got '{result.GenericAttributes[i].Value}'");
}
}
#endregion
#region CommonHelper2.To<DateTime> Simulation Tests
/// <summary>
/// This test simulates what CommonHelper2.To<DateTime> does with various string values.
/// It helps identify which values will cause the FormatException.
/// </summary>
[TestMethod]
public void CommonHelperSimulation_ValidDateString_ParsesSuccessfully()
{
var dateString = "10/24/2025 00:27:00";
// This is what CommonHelper2.To<DateTime> does internally
var converter = System.ComponentModel.TypeDescriptor.GetConverter(typeof(DateTime));
Assert.IsTrue(converter.CanConvertFrom(typeof(string)));
// With InvariantCulture
var result = converter.ConvertFrom(null, System.Globalization.CultureInfo.InvariantCulture, dateString);
Assert.IsNotNull(result);
Assert.IsInstanceOfType(result, typeof(DateTime));
var dt = (DateTime)result;
// InvariantCulture interprets 10/24/2025 as October 24, 2025
Assert.AreEqual(2025, dt.Year);
Assert.AreEqual(10, dt.Month);
Assert.AreEqual(24, dt.Day);
}
/// <summary>
/// This test shows that "0" cannot be parsed as DateTime - this is the actual bug!
/// </summary>
[TestMethod]
public void CommonHelperSimulation_ZeroString_ThrowsFormatException()
{
var invalidValue = "0";
var converter = System.ComponentModel.TypeDescriptor.GetConverter(typeof(DateTime));
// This should throw FormatException - exactly what we see in production
var threw = false;
try
{
converter.ConvertFrom(null, System.Globalization.CultureInfo.InvariantCulture, invalidValue);
}
catch (FormatException)
{
threw = true;
}
Assert.IsTrue(threw, "Converting '0' to DateTime should throw FormatException");
}
/// <summary>
/// Test various invalid DateTime strings that might be stored in GenericAttributes.
/// </summary>
[TestMethod]
[DataRow("0")]
[DataRow("null")]
[DataRow("undefined")]
[DataRow("N/A")]
public void CommonHelperSimulation_InvalidDateStrings_ThrowFormatException(string invalidValue)
{
var converter = System.ComponentModel.TypeDescriptor.GetConverter(typeof(DateTime));
var threw = false;
try
{
converter.ConvertFrom(null, System.Globalization.CultureInfo.InvariantCulture, invalidValue);
}
catch (FormatException)
{
threw = true;
}
Assert.IsTrue(threw, $"Converting '{invalidValue}' to DateTime should throw FormatException");
}
#endregion
}

View File

@ -0,0 +1,124 @@
using AyCode.Core.Serializers.Binaries;
namespace AyCode.Core.Tests.Serialization;
/// <summary>
/// Basic serialization tests for primitive types.
/// </summary>
[TestClass]
public class AcBinarySerializerBasicTests
{
[TestMethod]
public void Serialize_Null_ReturnsSingleNullByte()
{
var result = AcBinarySerializer.Serialize<object?>(null);
Assert.AreEqual(1, result.Length);
Assert.AreEqual(BinaryTypeCode.Null, result[0]);
}
[TestMethod]
public void Serialize_Int32_RoundTrip()
{
var value = 12345;
var binary = AcBinarySerializer.Serialize(value);
var result = AcBinaryDeserializer.Deserialize<int>(binary);
Assert.AreEqual(value, result);
}
[TestMethod]
public void Serialize_Int64_RoundTrip()
{
var value = 123456789012345L;
var binary = AcBinarySerializer.Serialize(value);
var result = AcBinaryDeserializer.Deserialize<long>(binary);
Assert.AreEqual(value, result);
}
[TestMethod]
public void Serialize_Double_RoundTrip()
{
var value = 3.14159265358979;
var binary = AcBinarySerializer.Serialize(value);
var result = AcBinaryDeserializer.Deserialize<double>(binary);
Assert.AreEqual(value, result);
}
[TestMethod]
public void Serialize_String_RoundTrip()
{
var value = "Hello, Binary World!";
var binary = AcBinarySerializer.Serialize(value);
var result = AcBinaryDeserializer.Deserialize<string>(binary);
Assert.AreEqual(value, result);
}
[TestMethod]
public void Serialize_Boolean_RoundTrip()
{
var trueResult = AcBinaryDeserializer.Deserialize<bool>(AcBinarySerializer.Serialize(true));
var falseResult = AcBinaryDeserializer.Deserialize<bool>(AcBinarySerializer.Serialize(false));
Assert.IsTrue(trueResult);
Assert.IsFalse(falseResult);
}
[TestMethod]
public void Serialize_DateTime_RoundTrip()
{
var value = new DateTime(2024, 12, 25, 10, 30, 45, DateTimeKind.Utc);
var binary = AcBinarySerializer.Serialize(value);
var result = AcBinaryDeserializer.Deserialize<DateTime>(binary);
Assert.AreEqual(value, result);
}
[TestMethod]
[DataRow(DateTimeKind.Unspecified)]
[DataRow(DateTimeKind.Utc)]
[DataRow(DateTimeKind.Local)]
public void Serialize_DateTime_PreservesKind(DateTimeKind kind)
{
var value = new DateTime(2024, 12, 25, 10, 30, 45, kind);
var binary = AcBinarySerializer.Serialize(value);
var result = AcBinaryDeserializer.Deserialize<DateTime>(binary);
Assert.AreEqual(value.Ticks, result.Ticks);
Assert.AreEqual(value.Kind, result.Kind);
}
[TestMethod]
public void Serialize_Guid_RoundTrip()
{
var value = Guid.NewGuid();
var binary = AcBinarySerializer.Serialize(value);
var result = AcBinaryDeserializer.Deserialize<Guid>(binary);
Assert.AreEqual(value, result);
}
[TestMethod]
public void Serialize_Decimal_RoundTrip()
{
var value = 123456.789012m;
var binary = AcBinarySerializer.Serialize(value);
var result = AcBinaryDeserializer.Deserialize<decimal>(binary);
Assert.AreEqual(value, result);
}
[TestMethod]
public void Serialize_TimeSpan_RoundTrip()
{
var value = TimeSpan.FromHours(2.5);
var binary = AcBinarySerializer.Serialize(value);
var result = AcBinaryDeserializer.Deserialize<TimeSpan>(binary);
Assert.AreEqual(value, result);
}
[TestMethod]
public void Serialize_DateTimeOffset_RoundTrip()
{
var value = new DateTimeOffset(2024, 12, 25, 10, 30, 45, TimeSpan.FromHours(2));
var binary = AcBinarySerializer.Serialize(value);
var result = AcBinaryDeserializer.Deserialize<DateTimeOffset>(binary);
Assert.AreEqual(value.UtcTicks, result.UtcTicks);
Assert.AreEqual(value.Offset, result.Offset);
}
}

View File

@ -0,0 +1,82 @@
using AyCode.Core.Serializers.Binaries;
using AyCode.Core.Tests.TestModels;
namespace AyCode.Core.Tests.Serialization;
[TestClass]
public class AcBinarySerializerBenchmarkTests
{
[TestMethod]
public void Serialize_BenchmarkOrder_RoundTrip()
{
var order = TestDataFactory.CreateBenchmarkOrder(itemCount: 3, palletsPerItem: 2, measurementsPerPallet: 2, pointsPerMeasurement: 5);
var binary = AcBinarySerializer.Serialize(order);
Assert.IsTrue(binary.Length > 0, "Binary data should not be empty");
var result = AcBinaryDeserializer.Deserialize<TestOrder_All_True>(binary);
Assert.IsNotNull(result);
Assert.AreEqual(order.Id, result.Id);
Assert.AreEqual(order.OrderNumber, result.OrderNumber);
Assert.AreEqual(order.Items.Count, result.Items.Count);
}
[TestMethod]
public void Serialize_BenchmarkOrder_SmallData_RoundTrip()
{
var order = TestDataFactory.CreateBenchmarkOrder(itemCount: 1, palletsPerItem: 1, measurementsPerPallet: 1, pointsPerMeasurement: 1);
var binary = AcBinarySerializer.Serialize(order);
Assert.IsTrue(binary.Length > 0);
var result = AcBinaryDeserializer.Deserialize<TestOrder_All_True>(binary);
Assert.IsNotNull(result);
Assert.AreEqual(order.Id, result.Id);
}
[TestMethod]
public void Serialize_BenchmarkOrder_LargeData_RoundTrip()
{
var order = TestDataFactory.CreateBenchmarkOrder(itemCount: 10, palletsPerItem: 5, measurementsPerPallet: 3, pointsPerMeasurement: 10);
var binary = AcBinarySerializer.Serialize(order);
Assert.IsTrue(binary.Length > 0, "Binary data should not be empty");
var result = AcBinaryDeserializer.Deserialize<TestOrder_All_True>(binary);
Assert.IsNotNull(result);
Assert.AreEqual(order.Id, result.Id);
Assert.AreEqual(order.OrderNumber, result.OrderNumber);
Assert.AreEqual(order.Items.Count, result.Items.Count);
// Verify nested structure
for (int i = 0; i < order.Items.Count; i++)
{
Assert.AreEqual(order.Items[i].Id, result.Items[i].Id);
Assert.AreEqual(order.Items[i].Pallets.Count, result.Items[i].Pallets.Count);
}
}
[TestMethod]
public void Serialize_BenchmarkOrder_WithStringInterning_SmallerThanWithout()
{
var order = TestDataFactory.CreateBenchmarkOrder(itemCount: 5, palletsPerItem: 3, measurementsPerPallet: 2, pointsPerMeasurement: 5);
var binaryWithInterning = AcBinarySerializer.Serialize(order, AcBinarySerializerOptions.Default);
var binaryWithoutInterning = AcBinarySerializer.Serialize(order, new AcBinarySerializerOptions { UseStringInterning = StringInterningMode.None });
// Note: String interning may not always result in smaller size due to header overhead
// The primary benefit is for larger datasets with many repeated strings
Console.WriteLine($"With interning: {binaryWithInterning.Length}, Without: {binaryWithoutInterning.Length}");
// Both should deserialize correctly regardless of size
var result1 = AcBinaryDeserializer.Deserialize<TestOrder_All_True>(binaryWithInterning);
var result2 = AcBinaryDeserializer.Deserialize<TestOrder_All_True>(binaryWithoutInterning);
Assert.IsNotNull(result1);
Assert.IsNotNull(result2);
Assert.AreEqual(order.Id, result1.Id);
Assert.AreEqual(order.Id, result2.Id);
Assert.AreEqual(order.Items.Count, result1.Items.Count);
Assert.AreEqual(order.Items.Count, result2.Items.Count);
}
}

View File

@ -0,0 +1,233 @@
using AyCode.Core.Extensions;
using AyCode.Core.Tests.TestModels;
namespace AyCode.Core.Tests.Serialization;
/// <summary>
/// Tests for Chain API reference preservation with IId objects.
/// This is the critical feature for DevExpress DXGrid GridCustomDataSource scenario.
/// </summary>
[TestClass]
public class AcBinarySerializerChainReferenceTests
{
/// <summary>
/// CRITICAL TEST: DevExpress DXGrid scenario with Chain API.
/// Server returns List&lt;Item&gt; for grid display, but we also have internal cache List&lt;Item&gt;.
/// When using ThenPopulate, the grid's visible items MUST be the same object references
/// from the cache to ensure Blazor binding works correctly.
/// </summary>
[TestMethod]
public void ChainPopulate_IIdObjects_PreservesReferences()
{
// Setup: Create internal cache with 5 categories
var internalCache = new List<SharedCategory_All_True>
{
new() { Id = 1, Name = "Category1", SortOrder = 1 },
new() { Id = 2, Name = "Category2", SortOrder = 2 },
new() { Id = 3, Name = "Category3", SortOrder = 3 },
new() { Id = 4, Name = "Category4", SortOrder = 4 },
new() { Id = 5, Name = "Category5", SortOrder = 5 }
};
// Server returns subset of categories (like grid pagination - page 2: items 3-5)
var serverData = new List<SharedCategory_All_True>
{
new() { Id = 3, Name = "Category3_Updated", SortOrder = 33 },
new() { Id = 4, Name = "Category4_Updated", SortOrder = 44 },
new() { Id = 5, Name = "Category5_Updated", SortOrder = 55 }
};
// Serialize server response
var binary = serverData.ToBinary();
// Grid's visible list (empty initially)
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_All_True>>();
// First: Update internal cache (will become 3 items: 3-5 updated)
chain.ThenPopulate(internalCache);
// Second: Populate grid's visible list
chain.ThenPopulate(gridVisibleList);
// VERIFICATION: After ThenPopulate, internalCache contains the 3 items from server
Assert.AreEqual(3, gridVisibleList.Count);
Assert.AreEqual(3, internalCache.Count, "ThenPopulate replaces list contents with server data");
// CRITICAL ASSERTION: Grid items MUST be same object references as cache items!
Assert.AreSame(internalCache[0], gridVisibleList[0],
"Grid item MUST be same reference as cache item for Blazor binding!");
Assert.AreSame(internalCache[1], gridVisibleList[1],
"Grid item MUST be same reference as cache item for Blazor binding!");
Assert.AreSame(internalCache[2], gridVisibleList[2],
"Grid item MUST be same reference as cache item for Blazor binding!");
// Verify data was updated correctly
Assert.AreEqual(3, internalCache[0].Id);
Assert.AreEqual("Category3_Updated", internalCache[0].Name);
Assert.AreEqual(33, internalCache[0].SortOrder);
}
/// <summary>
/// Test JSON Chain API reference preservation.
/// </summary>
[TestMethod]
public void JsonChainPopulate_IIdObjects_PreservesReferences()
{
// Setup: Create internal cache
var internalCache = new List<SharedCategory_All_True>
{
new() { Id = 1, Name = "Category1", SortOrder = 1 },
new() { Id = 2, Name = "Category2", SortOrder = 2 },
new() { Id = 3, Name = "Category3", SortOrder = 3 }
};
// Server returns subset
var serverData = new List<SharedCategory_All_True>
{
new() { Id = 2, Name = "Category2_Updated", SortOrder = 22 },
new() { Id = 3, Name = "Category3_Updated", SortOrder = 33 }
};
// Serialize server response
var json = serverData.ToJson();
// Grid's visible list
var gridVisibleList = new List<SharedCategory_All_True>();
// Use JSON Chain API
using var chain = json.JsonToChain<List<SharedCategory_All_True>>();
// Update internal cache (will replace with 2 items)
chain.ThenPopulate(internalCache);
// Populate grid's visible list
chain.ThenPopulate(gridVisibleList);
// VERIFICATION
Assert.AreEqual(2, gridVisibleList.Count);
Assert.AreEqual(2, internalCache.Count, "ThenPopulate replaces list contents");
// CRITICAL: Same references!
Assert.AreSame(internalCache[0], gridVisibleList[0]);
Assert.AreSame(internalCache[1], gridVisibleList[1]);
// Verify updates
Assert.AreEqual(2, internalCache[0].Id);
Assert.AreEqual("Category2_Updated", internalCache[0].Name);
Assert.AreEqual(22, internalCache[0].SortOrder);
}
/// <summary>
/// Test with Guid-based IId implementation.
/// </summary>
[TestMethod]
public void ChainPopulate_GuidIId_PreservesReferences()
{
var cache = new List<TestGuidOrder>
{
new() { Id = Guid.NewGuid(), Code = "ORD-001", Count = 10 },
new() { Id = Guid.NewGuid(), Code = "ORD-002", Count = 20 }
};
var id1 = cache[0].Id;
var id2 = cache[1].Id;
var serverData = new List<TestGuidOrder>
{
new() { Id = id1, Code = "ORD-001-UPDATED", Count = 11 },
new() { Id = id2, Code = "ORD-002-UPDATED", Count = 22 }
};
var binary = serverData.ToBinary();
var gridList = new List<TestGuidOrder>();
using var chain = binary.BinaryToChain<List<TestGuidOrder>>();
chain.ThenPopulate(cache);
chain.ThenPopulate(gridList);
Assert.AreEqual(2, gridList.Count);
Assert.AreSame(cache[0], gridList[0], "Guid-based IId should also preserve references");
Assert.AreSame(cache[1], gridList[1]);
Assert.AreEqual("ORD-001-UPDATED", cache[0].Code);
}
/// <summary>
/// Test multiple chain operations with different subsets.
/// </summary>
[TestMethod]
public void ChainPopulate_MultipleSubsets_PreservesReferencesAcrossAll()
{
// Large internal cache
var internalCache = Enumerable.Range(1, 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_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_All_True>();
var gridPage2 = new List<SharedCategory_All_True>();
var gridPage3 = new List<SharedCategory_All_True>();
using var chain = binary.BinaryToChain<List<SharedCategory_All_True>>();
// Update cache first
chain.ThenPopulate(internalCache);
// Populate different grid pages
chain.ThenPopulate(gridPage1);
chain.ThenPopulate(gridPage2);
chain.ThenPopulate(gridPage3);
// All pages should have same references
Assert.AreEqual(5, gridPage1.Count);
Assert.AreEqual(5, gridPage2.Count);
Assert.AreEqual(5, gridPage3.Count);
// All three pages point to the SAME objects
for (int i = 0; i < 5; i++)
{
Assert.AreSame(gridPage1[i], gridPage2[i], $"Page1 and Page2 item {i} must be same reference");
Assert.AreSame(gridPage2[i], gridPage3[i], $"Page2 and Page3 item {i} must be same reference");
Assert.AreSame(internalCache[i], gridPage1[i], $"Cache and Page1 item {i} must be same reference");
}
}
/// <summary>
/// Simple debug test to verify chain reference tracking works.
/// </summary>
[TestMethod]
public void ChainPopulate_SimpleCase_Works()
{
var list1 = new List<SharedCategory_All_True>();
var list2 = new List<SharedCategory_All_True>();
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_All_True>>();
// First populate
chain.ThenPopulate(list1);
Assert.AreEqual(1, list1.Count);
Assert.AreEqual(1, list1[0].Id);
// Second populate - should reuse same object
chain.ThenPopulate(list2);
Assert.AreEqual(1, list2.Count);
Assert.AreSame(list1[0], list2[0], "Should be same object reference!");
}
}

View File

@ -0,0 +1,345 @@
using AyCode.Core.Extensions;
using AyCode.Core.Serializers.Binaries;
using AyCode.Core.Tests.TestModels;
using static AyCode.Core.Tests.TestModels.AcSerializerModels;
namespace AyCode.Core.Tests.Serialization;
/// <summary>
/// Tests for Binary Chain API (CreateDeserializeChain and CreatePopulateChain).
/// </summary>
[TestClass]
public class AcBinarySerializerChainTests
{
[TestMethod]
public void DeserializeChain_SingleDeserialization_WorksCorrectly()
{
// Arrange
var original = new TestSimpleClass { Id = 42, Name = "John", Value = 3.14, IsActive = true };
var binary = original.ToBinary();
// Act
using var chain = binary.BinaryToChain<TestSimpleClass>();
var result = chain.Value;
// Assert
Assert.IsNotNull(result);
Assert.AreEqual(42, result.Id);
Assert.AreEqual("John", result.Name);
Assert.AreEqual(3.14, result.Value);
Assert.AreEqual(true, result.IsActive);
}
[TestMethod]
public void DeserializeChain_MultipleDeserializations_ParsesOnlyOnce()
{
// Arrange
var original = new TestSimpleClass { Id = 100, Name = "Test", Value = 99.9, IsActive = false };
var binary = original.ToBinary();
// Act
using var chain = binary.BinaryToChain<TestSimpleClass>();
var result1 = chain.Value;
var result2 = chain.ThenDeserialize<TestSimpleClass>();
var result3 = chain.ThenDeserialize<TestSimpleClass>();
// Assert - All three deserializations should work
Assert.IsNotNull(result1);
Assert.AreEqual(100, result1.Id);
Assert.IsNotNull(result2);
Assert.AreEqual(100, result2.Id);
Assert.AreEqual("Test", result2.Name);
Assert.IsNotNull(result3);
Assert.AreEqual(99.9, result3.Value);
Assert.AreEqual(false, result3.IsActive);
}
[TestMethod]
public void DeserializeChain_NestedObjects_WorksCorrectly()
{
// Arrange
var original = new TestNestedClass
{
Id = 1,
Name = "Parent",
Child = new TestSimpleClass { Id = 2, Name = "Child", Value = 10.5 }
};
var binary = original.ToBinary();
// Act
using var chain = binary.BinaryToChain<TestNestedClass>();
var result1 = chain.Value;
var result2 = chain.ThenDeserialize<TestNestedClass>();
// Assert
Assert.IsNotNull(result1);
Assert.AreEqual("Parent", result1.Name);
Assert.IsNotNull(result1.Child);
Assert.AreEqual("Child", result1.Child.Name);
Assert.IsNotNull(result2);
Assert.AreEqual(1, result2.Id);
Assert.IsNotNull(result2.Child);
Assert.AreEqual(10.5, result2.Child.Value);
}
[TestMethod]
public void DeserializeChain_WithList_WorksCorrectly()
{
// Arrange
var original = new TestClassWithList
{
Id = 5,
Items = new List<string> { "Apple", "Banana", "Cherry" }
};
var binary = original.ToBinary();
// Act
using var chain = binary.BinaryToChain<TestClassWithList>();
var result = chain.Value;
// Assert
Assert.IsNotNull(result);
Assert.AreEqual(5, result.Id);
Assert.AreEqual(3, result.Items.Count);
Assert.AreEqual("Apple", result.Items[0]);
Assert.AreEqual("Banana", result.Items[1]);
Assert.AreEqual("Cherry", result.Items[2]);
}
[TestMethod]
public void PopulateChain_SinglePopulate_UpdatesObject()
{
// Arrange
var original = new TestSimpleClass { Id = 99, Name = "Updated", Value = 123.45, IsActive = true };
var binary = original.ToBinary();
var target = new TestSimpleClass { Id = 1, Name = "Old", Value = 0, IsActive = false };
// Act
using var chain = binary.BinaryToChain(target);
// Assert
Assert.AreEqual(99, target.Id);
Assert.AreEqual("Updated", target.Name);
Assert.AreEqual(123.45, target.Value);
Assert.AreEqual(true, target.IsActive);
}
[TestMethod]
public void PopulateChain_MultiplePopulates_UpdatesAllObjects()
{
// Arrange
var original = new TestSimpleClass { Id = 100, Name = "Shared", Value = 50.0 };
var binary = original.ToBinary();
var target1 = new TestSimpleClass { Id = 1, Name = "Old1" };
var target2 = new TestSimpleClass { Id = 2, Name = "Old2" };
var target3 = new TestSimpleClass { Id = 3, Name = "Old3" };
// Act
using var chain = binary.BinaryToChain(target1);
chain.ThenPopulate(target2);
chain.ThenPopulate(target3);
// Assert
Assert.AreEqual(100, target1.Id);
Assert.AreEqual("Shared", target1.Name);
Assert.AreEqual(50.0, target1.Value);
Assert.AreEqual(100, target2.Id);
Assert.AreEqual("Shared", target2.Name);
Assert.AreEqual(50.0, target2.Value);
Assert.AreEqual(100, target3.Id);
Assert.AreEqual("Shared", target3.Name);
Assert.AreEqual(50.0, target3.Value);
}
[TestMethod]
public void PopulateChain_NestedObjects_MergesCorrectly()
{
// Arrange
var original = new TestNestedClass
{
Id = 10,
Name = "UpdatedParent",
Child = new TestSimpleClass { Id = 20, Name = "UpdatedChild", Value = 99.9 }
};
var binary = original.ToBinary();
var target = new TestNestedClass
{
Id = 1,
Name = "OldParent",
Child = new TestSimpleClass { Id = 2, Name = "OldChild", Value = 1.0 }
};
// Act
using var chain = binary.BinaryToChain(target);
// Assert
Assert.AreEqual(10, target.Id);
Assert.AreEqual("UpdatedParent", target.Name);
Assert.IsNotNull(target.Child);
Assert.AreEqual(20, target.Child.Id);
Assert.AreEqual("UpdatedChild", target.Child.Name);
Assert.AreEqual(99.9, target.Child.Value);
}
[TestMethod]
public void PopulateChain_WithList_UpdatesCollection()
{
// Arrange
var original = new TestClassWithList
{
Id = 7,
Items = new List<string> { "New1", "New2", "New3" }
};
var binary = original.ToBinary();
var target = new TestClassWithList
{
Id = 1,
Items = new List<string> { "Old1" }
};
// Act
using var chain = binary.BinaryToChain(target);
// Assert
Assert.AreEqual(7, target.Id);
Assert.AreEqual(3, target.Items.Count);
Assert.AreEqual("New1", target.Items[0]);
Assert.AreEqual("New2", target.Items[1]);
Assert.AreEqual("New3", target.Items[2]);
}
[TestMethod]
public void DeserializeChain_EmptyBinary_ReturnsEmpty()
{
// Arrange
var binary = Array.Empty<byte>();
// Act
using var chain = binary.BinaryToChain<TestSimpleClass>();
// Assert
Assert.IsNull(chain.Value);
}
[TestMethod]
public void PopulateChain_EmptyBinary_DoesNothing()
{
// Arrange
var binary = Array.Empty<byte>();
var target = new TestSimpleClass { Id = 42, Name = "Original" };
// Act
using var chain = binary.BinaryToChain(target);
// Assert - Should remain unchanged
Assert.AreEqual(42, target.Id);
Assert.AreEqual("Original", target.Name);
}
[TestMethod]
public void DeserializeChain_Dispose_CannotReuseAfterDispose()
{
// Arrange
var original = new TestSimpleClass { Id = 1, Name = "Test" };
var binary = original.ToBinary();
var chain = binary.BinaryToChain<TestSimpleClass>();
var value = chain.Value;
// Act
chain.Dispose();
// Assert
Assert.IsNotNull(value); // Value from before dispose should still exist
_ = Assert.ThrowsExactly<ObjectDisposedException>(() => chain.ThenDeserialize<TestSimpleClass>());
}
[TestMethod]
public void PopulateChain_Dispose_CannotReuseAfterDispose()
{
// Arrange
var original = new TestSimpleClass { Id = 1, Name = "Test" };
var binary = original.ToBinary();
var target1 = new TestSimpleClass();
var chain = binary.BinaryToChain(target1);
// Act
chain.Dispose();
// Assert
var target2 = new TestSimpleClass();
_ = Assert.ThrowsExactly<ObjectDisposedException>(() => chain.ThenPopulate(target2));
}
[TestMethod]
public void DeserializeChain_WithOptions_UsesCorrectOptions()
{
// Arrange
var original = new TestSimpleClass { Id = 1, Name = "Test", Value = 10.5 };
var binary = original.ToBinary();
var options = new AcBinarySerializerOptions();
// Act
using var chain = binary.BinaryToChain<TestSimpleClass>(options);
var result = chain.Value;
// Assert
Assert.IsNotNull(result);
Assert.AreEqual(1, result.Id);
Assert.AreEqual("Test", result.Name);
}
[TestMethod]
public void PopulateChain_WithOptions_UsesCorrectOptions()
{
// Arrange
var original = new TestSimpleClass { Id = 99, Name = "Updated" };
var binary = original.ToBinary();
var target = new TestSimpleClass { Id = 1, Name = "Old" };
var options = new AcBinarySerializerOptions();
// Act
using var chain = binary.BinaryToChain(target, options);
// Assert
Assert.AreEqual(99, target.Id);
Assert.AreEqual("Updated", target.Name);
}
[TestMethod]
public void DeserializeChain_ByteArray_WorksCorrectly()
{
// Arrange
var original = new TestSimpleClass { Id = 42, Name = "Memory Test" };
var binary = original.ToBinary();
// Act
using var chain = binary.BinaryToChain<TestSimpleClass>();
var result = chain.Value;
// Assert
Assert.IsNotNull(result);
Assert.AreEqual(42, result.Id);
Assert.AreEqual("Memory Test", result.Name);
}
[TestMethod]
public void PopulateChain_ByteArray_WorksCorrectly()
{
// Arrange
var original = new TestSimpleClass { Id = 99, Name = "Memory Update" };
var binary = original.ToBinary();
var target = new TestSimpleClass { Id = 1, Name = "Old" };
// Act
using var chain = binary.BinaryToChain(target);
// Assert
Assert.AreEqual(99, target.Id);
Assert.AreEqual("Memory Update", target.Name);
}
}

View File

@ -0,0 +1,184 @@
using AyCode.Core.Extensions;
using AyCode.Core.Serializers;
using AyCode.Core.Serializers.Binaries;
using static AyCode.Core.Tests.TestModels.AcSerializerModels;
namespace AyCode.Core.Tests.Serialization;
/// <summary>
/// Tests for circular reference handling with back-navigation properties.
/// </summary>
[TestClass]
public class AcBinarySerializerCircularReferenceTests
{
/// <summary>
/// CRITICAL TEST: Circular references with back-navigation properties.
/// This simulates the exact production scenario where:
/// - StockTaking has StockTakingItems collection
/// - StockTakingItem has StockTaking back-reference (circular!)
/// - StockTakingItem has Product navigation property
/// </summary>
[TestMethod]
[DataRow(true, true)]
[DataRow(false, true)]
[DataRow(true, false)]
[DataRow(false, false)]
public void Deserialize_CircularReference_ParentChildBackReference(bool useSgen, bool useMeta)
{
var parent = new CircularParent
{
Id = 1,
Name = "Parent",
Created = new DateTime(2025, 1, 24, 15, 25, 0, DateTimeKind.Utc),
Modified = DateTime.UtcNow,
Creator = 6,
Children = new List<CircularChild>()
};
var child = new CircularChild
{
Id = 10,
ParentId = 1,
Name = "Child",
Created = DateTime.UtcNow.AddHours(-1),
Modified = DateTime.UtcNow,
Parent = parent,
GrandChildren = new List<CircularGrandChild>()
};
var grandChild = new CircularGrandChild
{
Id = 100,
ChildId = 10,
CreatorId = 6,
ModifierId = null,
Created = DateTime.UtcNow,
Modified = DateTime.UtcNow,
Child = child
};
child.GrandChildren.Add(grandChild);
parent.Children.Add(child);
var option = AcBinarySerializerOptions.Default;
option.ReferenceHandling = ReferenceHandlingMode.All;
option.UseGeneratedCode = useSgen;
option.UseMetadata = useMeta;
Console.WriteLine($"\n========== ReferenceHandling: {option.ReferenceHandling}, UseSgen: {option.UseGeneratedCode}, UseMeta: {option.UseMetadata} ==========");
var binary = parent.ToBinary(option);
var result = binary.BinaryTo<CircularParent>();
Assert.IsNotNull(result);
Assert.AreEqual(1, result.Id);
Assert.AreEqual(6, result.Creator, "Creator should be 6");
Assert.AreEqual(parent.Created.Ticks, result.Created.Ticks,
$"Created mismatch. Expected: {parent.Created}, Got: {result.Created}");
Assert.IsNotNull(result.Children);
Assert.AreEqual(1, result.Children.Count);
var resultChild = result.Children[0];
Assert.AreEqual(10, resultChild.Id);
Assert.AreEqual(resultChild.Created.Ticks, child.Created.Ticks, "Child.Created should match");
Assert.IsNotNull(resultChild.Parent, "Child.Parent back-reference should be resolved");
Assert.AreEqual(1, resultChild.Parent.Id, "Back-reference should point to same parent");
}
/// <summary>
/// Test list of parents with circular references.
/// </summary>
[TestMethod]
[DataRow(true, true)]
[DataRow(false, true)]
[DataRow(true, false)]
[DataRow(false, false)]
public void Deserialize_ListOfCircularReferences_AllItemsCorrect(bool useSgen, bool useMeta)
{
var parents = Enumerable.Range(1, 5).Select(p =>
{
var parent = new CircularParent
{
Id = p,
Name = $"Parent_{p}",
Created = DateTime.UtcNow.AddDays(-p),
Modified = DateTime.UtcNow,
Creator = p,
Children = new List<CircularChild>()
};
for (int c = 1; c <= 2; c++)
{
var child = new CircularChild
{
Id = p * 100 + c,
ParentId = p,
Name = $"Child_{p}_{c}",
Created = DateTime.UtcNow.AddHours(-c),
Modified = DateTime.UtcNow,
Parent = parent,
GrandChildren = new List<CircularGrandChild>()
};
for (int g = 1; g <= 2; g++)
{
child.GrandChildren.Add(new CircularGrandChild
{
Id = p * 1000 + c * 100 + g,
ChildId = child.Id,
CreatorId = g % 2 == 0 ? p : null,
ModifierId = g % 2 == 1 ? p * 2 : null,
Created = DateTime.UtcNow,
Modified = DateTime.UtcNow,
Child = child
});
}
parent.Children.Add(child);
}
return parent;
}).ToList();
var option = AcBinarySerializerOptions.Default;
option.ReferenceHandling = ReferenceHandlingMode.All;
option.UseGeneratedCode = useSgen;
option.UseMetadata = useMeta;
Console.WriteLine($"\n========== ReferenceHandling: {option.ReferenceHandling}, UseSgen: {option.UseGeneratedCode}, UseMeta: {option.UseMetadata} ==========");
var binary = parents.ToBinary(option);
var result = binary.BinaryTo<List<CircularParent>>();
Assert.IsNotNull(result);
Assert.AreEqual(5, result.Count);
for (int p = 0; p < 5; p++)
{
var original = parents[p];
var deserialized = result[p];
Assert.AreEqual(original.Id, deserialized.Id, $"Parent[{p}].Id mismatch");
Assert.AreEqual(original.Creator, deserialized.Creator, $"Parent[{p}].Creator mismatch");
Assert.AreEqual(original.Created.Ticks, deserialized.Created.Ticks,
$"Parent[{p}].Created mismatch. Expected: {original.Created}, Got: {deserialized.Created}");
Assert.IsNotNull(deserialized.Children, $"Parent[{p}].Children is null");
Assert.AreEqual(2, deserialized.Children.Count, $"Parent[{p}].Children.Count mismatch");
for (int c = 0; c < 2; c++)
{
var origChild = original.Children![c];
var deserChild = deserialized.Children[c];
Assert.AreEqual(origChild.Id, deserChild.Id, $"Parent[{p}].Children[{c}].Id mismatch");
Assert.AreEqual(origChild.Created.Ticks, deserChild.Created.Ticks,
$"Parent[{p}].Children[{c}].Created mismatch");
Assert.IsNotNull(deserChild.Parent, $"Parent[{p}].Children[{c}].Parent should not be null");
}
}
}
}

View File

@ -0,0 +1,188 @@
using AyCode.Core.Extensions;
using static AyCode.Core.Tests.TestModels.AcSerializerModels;
using static AyCode.Core.Tests.Serialization.AcSerializerTestHelper;
namespace AyCode.Core.Tests.Serialization;
/// <summary>
/// Tests for DateTime type handling and potential type mismatch issues.
/// </summary>
[TestClass]
public class AcBinarySerializerDateTimeTests
{
[TestMethod]
public void Deserialize_DateTimeProperty_FromDifferentPropertyOrder_RoundTrip()
{
var entity = new TestEntityWithDateTimeAndInt
{
Id = 42,
IntValue = 100,
Created = new DateTime(2024, 12, 25, 10, 30, 45, DateTimeKind.Utc),
Modified = new DateTime(2024, 12, 26, 11, 45, 30, DateTimeKind.Utc),
StatusCode = 5,
Name = "TestEntity"
};
var binary = entity.ToBinary();
var result = binary.BinaryTo<TestEntityWithDateTimeAndInt>();
Assert.IsNotNull(result);
Assert.AreEqual(42, result.Id);
Assert.AreEqual(100, result.IntValue);
Assert.AreEqual(entity.Created, result.Created, "Created DateTime should match");
Assert.AreEqual(entity.Modified, result.Modified, "Modified DateTime should match");
Assert.AreEqual(5, result.StatusCode);
Assert.AreEqual("TestEntity", result.Name);
}
[TestMethod]
public void Deserialize_ListOfEntitiesWithDateTimeProperties_RoundTrip()
{
var entities = CreateDateTimeEntities(10);
var binary = entities.ToBinary();
var result = binary.BinaryTo<List<TestEntityWithDateTimeAndInt>>();
Assert.IsNotNull(result);
Assert.AreEqual(10, result.Count);
for (int i = 0; i < 10; i++)
{
var original = entities[i];
var deserialized = result[i];
Assert.AreEqual(original.Id, deserialized.Id, $"Id mismatch at index {i}");
Assert.AreEqual(original.IntValue, deserialized.IntValue, $"IntValue mismatch at index {i}");
Assert.AreEqual(original.Created.Ticks, deserialized.Created.Ticks, $"Created mismatch at index {i}");
Assert.AreEqual(original.Modified.Ticks, deserialized.Modified.Ticks, $"Modified mismatch at index {i}");
Assert.AreEqual(original.StatusCode, deserialized.StatusCode, $"StatusCode mismatch at index {i}");
Assert.AreEqual(original.Name, deserialized.Name, $"Name mismatch at index {i}");
}
}
[TestMethod]
public void Deserialize_EntityWithManyIntPropertiesBeforeDateTime_RoundTrip()
{
var entity = new TestEntityWithManyIntsBeforeDateTime
{
Id = 1,
Value1 = 10,
Value2 = 20,
Value3 = 30,
Value4 = 40,
Value5 = 50,
FirstDateTime = new DateTime(2024, 1, 15, 10, 0, 0, DateTimeKind.Utc),
SecondDateTime = new DateTime(2024, 6, 20, 15, 30, 0, DateTimeKind.Utc),
FinalValue = 999
};
var binary = entity.ToBinary();
var result = binary.BinaryTo<TestEntityWithManyIntsBeforeDateTime>();
Assert.IsNotNull(result);
Assert.AreEqual(1, result.Id);
Assert.AreEqual(10, result.Value1);
Assert.AreEqual(20, result.Value2);
Assert.AreEqual(30, result.Value3);
Assert.AreEqual(40, result.Value4);
Assert.AreEqual(50, result.Value5);
Assert.AreEqual(entity.FirstDateTime, result.FirstDateTime, "FirstDateTime should match");
Assert.AreEqual(entity.SecondDateTime, result.SecondDateTime, "SecondDateTime should match");
Assert.AreEqual(999, result.FinalValue);
}
[TestMethod]
public void Deserialize_NestedEntityWithDateTimeInChild_RoundTrip()
{
var parent = new TestParentEntityWithDateTimeChild
{
ParentId = 1,
ParentName = "Parent",
Child = new TestEntityWithDateTimeAndInt
{
Id = 100,
IntValue = 200,
Created = DateTime.UtcNow.AddDays(-5),
Modified = DateTime.UtcNow,
StatusCode = 3,
Name = "Child"
}
};
var binary = parent.ToBinary();
var result = binary.BinaryTo<TestParentEntityWithDateTimeChild>();
Assert.IsNotNull(result);
Assert.AreEqual(1, result.ParentId);
Assert.AreEqual("Parent", result.ParentName);
Assert.IsNotNull(result.Child);
Assert.AreEqual(100, result.Child.Id);
Assert.AreEqual(200, result.Child.IntValue);
Assert.AreEqual(parent.Child.Created.Ticks, result.Child.Created.Ticks, "Child.Created should match");
Assert.AreEqual(parent.Child.Modified.Ticks, result.Child.Modified.Ticks, "Child.Modified should match");
}
[TestMethod]
public void Deserialize_EntityWithCollectionContainingDateTimeItems_RoundTrip()
{
var parent = new TestParentWithDateTimeItemCollection
{
Id = 1,
Name = "Parent",
Created = DateTime.UtcNow.AddDays(-10),
Items = CreateDateTimeEntities(5)
};
var binary = parent.ToBinary();
var result = binary.BinaryTo<TestParentWithDateTimeItemCollection>();
Assert.IsNotNull(result);
Assert.AreEqual(1, result.Id);
Assert.AreEqual("Parent", result.Name);
Assert.AreEqual(parent.Created.Ticks, result.Created.Ticks, "Parent.Created should match");
Assert.IsNotNull(result.Items);
Assert.AreEqual(5, result.Items.Count);
for (int i = 0; i < 5; i++)
{
var originalItem = parent.Items[i];
var deserializedItem = result.Items[i];
Assert.AreEqual(originalItem.Id, deserializedItem.Id, $"Items[{i}].Id should match");
Assert.AreEqual(originalItem.IntValue, deserializedItem.IntValue, $"Items[{i}].IntValue should match");
Assert.AreEqual(originalItem.Created.Ticks, deserializedItem.Created.Ticks, $"Items[{i}].Created should match");
Assert.AreEqual(originalItem.Modified.Ticks, deserializedItem.Modified.Ticks, $"Items[{i}].Modified should match");
}
}
[TestMethod]
public void Deserialize_ListOfParentEntitiesWithDateTimeChildCollections_RoundTrip()
{
var parents = CreateParentWithDateTimeItems(3, 3);
var binary = parents.ToBinary();
var result = binary.BinaryTo<List<TestParentWithDateTimeItemCollection>>();
Assert.IsNotNull(result);
Assert.AreEqual(3, result.Count);
for (int p = 0; p < 3; p++)
{
var originalParent = parents[p];
var deserializedParent = result[p];
Assert.AreEqual(originalParent.Id, deserializedParent.Id, $"Parent[{p}].Id should match");
Assert.AreEqual(originalParent.Name, deserializedParent.Name, $"Parent[{p}].Name should match");
Assert.AreEqual(originalParent.Created.Ticks, deserializedParent.Created.Ticks, $"Parent[{p}].Created should match");
Assert.IsNotNull(deserializedParent.Items);
Assert.AreEqual(3, deserializedParent.Items.Count, $"Parent[{p}].Items.Count should match");
for (int i = 0; i < 3; i++)
{
var originalItem = originalParent.Items![i];
var deserializedItem = deserializedParent.Items[i];
Assert.AreEqual(originalItem.Id, deserializedItem.Id, $"Parent[{p}].Items[{i}].Id should match");
Assert.AreEqual(originalItem.Created.Ticks, deserializedItem.Created.Ticks, $"Parent[{p}].Items[{i}].Created should match. Expected: {originalItem.Created}, Got: {deserializedItem.Created}");
}
}
}
}

View File

@ -0,0 +1,486 @@
using AyCode.Core.Extensions;
using AyCode.Core.Serializers.Binaries;
using System.Reflection;
using static AyCode.Core.Tests.TestModels.AcSerializerModels;
namespace AyCode.Core.Tests.Serialization;
/// <summary>
/// Diagnostic tests to help debug serialization issues.
/// </summary>
[TestClass]
public class AcBinarySerializerDiagnosticTests
{
/// <summary>
/// Diagnostic test to understand the exact binary structure.
/// This test outputs the binary bytes to help debug production issues.
/// </summary>
[TestMethod]
public void Diagnostic_StockTaking_BinaryStructure()
{
var stockTaking = new TestStockTakingWithInheritance
{
Id = 1,
StartDateTime = new DateTime(2025, 1, 24, 10, 0, 0, DateTimeKind.Utc),
IsClosed = false,
Creator = 6,
Created = new DateTime(2025, 1, 24, 15, 25, 0, DateTimeKind.Utc),
Modified = new DateTime(2025, 1, 24, 16, 0, 0, DateTimeKind.Utc),
StockTakingItems = null
};
var binary = stockTaking.ToBinary();
var hexDump = string.Join(" ", binary.Select(b => b.ToString("X2")));
Console.WriteLine($"Binary length: {binary.Length}");
Console.WriteLine($"Binary hex: {hexDump}");
for (int i = 0; i < binary.Length; i++)
{
if (binary[i] == 214)
{
Console.WriteLine($"Found 0xD6 at position {i}");
}
}
var result = binary.BinaryTo<TestStockTakingWithInheritance>();
Assert.IsNotNull(result);
Assert.AreEqual(6, result.Creator);
Assert.AreEqual(stockTaking.Created.Ticks, result.Created.Ticks);
}
/// <summary>
/// Test with nested list to ensure proper stream positioning.
/// </summary>
[TestMethod]
public void Diagnostic_StockTaking_WithNestedItems()
{
var stockTaking = new TestStockTakingWithInheritance
{
Id = 1,
StartDateTime = new DateTime(2025, 1, 24, 10, 0, 0, DateTimeKind.Utc),
IsClosed = false,
Creator = 6,
Created = new DateTime(2025, 1, 24, 15, 25, 0, DateTimeKind.Utc),
Modified = new DateTime(2025, 1, 24, 16, 0, 0, DateTimeKind.Utc),
StockTakingItems = new List<TestStockTakingItemWithInheritance>
{
new()
{
Id = 10,
StockTakingId = 1,
ProductId = 100,
IsMeasured = true,
OriginalStockQuantity = 50,
MeasuredStockQuantity = 48,
Created = new DateTime(2025, 1, 24, 14, 0, 0, DateTimeKind.Utc),
Modified = new DateTime(2025, 1, 24, 14, 30, 0, DateTimeKind.Utc),
StockTakingItemPallets = null
}
}
};
var binary = stockTaking.ToBinary();
Console.WriteLine($"Binary length with 1 item: {binary.Length}");
var result = binary.BinaryTo<TestStockTakingWithInheritance>();
Assert.IsNotNull(result);
Assert.AreEqual(6, result.Creator, "Creator should be 6");
Assert.AreEqual(stockTaking.Created.Ticks, result.Created.Ticks,
$"Created mismatch. Expected: {stockTaking.Created}, Got: {result.Created}");
Assert.IsNotNull(result.StockTakingItems);
Assert.AreEqual(1, result.StockTakingItems.Count);
}
/// <summary>
/// CRITICAL TEST: Verify property order is consistent.
/// This test checks that the reflection-based property order matches
/// what's expected for serialization/deserialization.
/// </summary>
[TestMethod]
public void Diagnostic_PropertyOrder_InheritanceHierarchy()
{
var type = typeof(SimStockTaking);
var props = type.GetProperties(BindingFlags.Public | BindingFlags.Instance);
Console.WriteLine($"Properties of {type.Name} (count: {props.Length}):");
for (int i = 0; i < props.Length; i++)
{
var prop = props[i];
Console.WriteLine($" [{i}] {prop.Name} : {prop.PropertyType.Name} (declared in: {prop.DeclaringType?.Name})");
}
// The exact order may vary by platform!
// Log it so we can compare server vs client
Assert.IsTrue(props.Length >= 7, "Should have at least 7 properties");
// Check that all expected properties exist
var propNames = props.Select(p => p.Name).ToHashSet();
Assert.IsTrue(propNames.Contains("Id"), "Should have Id");
Assert.IsTrue(propNames.Contains("StartDateTime"), "Should have StartDateTime");
Assert.IsTrue(propNames.Contains("IsClosed"), "Should have IsClosed");
Assert.IsTrue(propNames.Contains("Creator"), "Should have Creator");
Assert.IsTrue(propNames.Contains("Created"), "Should have Created");
Assert.IsTrue(propNames.Contains("Modified"), "Should have Modified");
Assert.IsTrue(propNames.Contains("StockTakingItems"), "Should have StockTakingItems");
}
/// <summary>
/// CRITICAL REGRESSION TEST: Simulates exact production hierarchy.
/// StockTaking : MgStockTaking&lt;StockTakingItem&gt; : MgEntityBase : BaseEntity
/// </summary>
[TestMethod]
public void Diagnostic_SimStockTaking_RoundTrip()
{
var stockTaking = new SimStockTaking
{
Id = 1,
StartDateTime = new DateTime(2025, 1, 24, 10, 0, 0, DateTimeKind.Utc),
IsClosed = false,
Creator = 6, // The exact value from production error
Created = new DateTime(2025, 1, 24, 15, 25, 0, DateTimeKind.Utc),
Modified = new DateTime(2025, 1, 24, 16, 0, 0, DateTimeKind.Utc),
StockTakingItems = null // loadRelations = false means no items
};
var binary = stockTaking.ToBinary();
// Log the property names in the header
Console.WriteLine($"Binary length: {binary.Length}");
var result = binary.BinaryTo<SimStockTaking>();
Assert.IsNotNull(result);
Assert.AreEqual(1, result.Id, "Id should be 1");
Assert.AreEqual(6, result.Creator, "Creator should be 6 - this is where the bug occurs!");
Assert.AreEqual(stockTaking.Created.Ticks, result.Created.Ticks,
$"Created mismatch. Expected: {stockTaking.Created}, Got: {result.Created}");
Assert.AreEqual(stockTaking.StartDateTime, result.StartDateTime);
Assert.IsFalse(result.IsClosed);
}
/// <summary>
/// Test List of SimStockTaking - exact production scenario.
/// </summary>
[TestMethod]
public void Diagnostic_ListOfSimStockTaking_RoundTrip()
{
var stockTakings = Enumerable.Range(1, 3).Select(i => new SimStockTaking
{
Id = i,
StartDateTime = DateTime.UtcNow.AddDays(-i),
IsClosed = i % 2 == 0,
Creator = i,
Created = DateTime.UtcNow.AddDays(-i),
Modified = DateTime.UtcNow,
StockTakingItems = null
}).ToList();
var binary = stockTakings.ToBinary();
var result = binary.BinaryTo<List<SimStockTaking>>();
Assert.IsNotNull(result);
Assert.AreEqual(3, result.Count);
for (int i = 0; i < 3; i++)
{
var original = stockTakings[i];
var deserialized = result[i];
Assert.AreEqual(original.Id, deserialized.Id, $"[{i}] Id mismatch");
Assert.AreEqual(original.Creator, deserialized.Creator, $"[{i}] Creator mismatch");
Assert.AreEqual(original.Created.Ticks, deserialized.Created.Ticks,
$"[{i}] Created mismatch. Expected: {original.Created}, Got: {deserialized.Created}");
}
}
/// <summary>
/// Diagnostic: Check what PropertyType the reflection returns for generic type parameter.
/// </summary>
[TestMethod]
public void Diagnostic_GenericProperty_ReflectionType()
{
var parentType = typeof(ConcreteParent);
var itemsProp = parentType.GetProperty("Items");
Assert.IsNotNull(itemsProp);
var propType = itemsProp.PropertyType;
Console.WriteLine($"PropertyType: {propType}");
Console.WriteLine($"PropertyType.FullName: {propType.FullName}");
Console.WriteLine($"IsGenericType: {propType.IsGenericType}");
if (propType.IsGenericType)
{
var args = propType.GetGenericArguments();
Console.WriteLine($"GenericArguments.Length: {args.Length}");
foreach (var arg in args)
{
Console.WriteLine($" GenericArgument: {arg.FullName}");
}
}
Assert.IsTrue(propType.IsGenericType);
var elementType = propType.GetGenericArguments()[0];
Assert.AreEqual(typeof(GenericItemImpl), elementType,
"Element type should be GenericItemImpl, not IGenericItem");
}
/// <summary>
/// CRITICAL BUG REPRODUCTION: StockTakingItems is null (loadRelations = false)
/// This test verifies that property-index-based serialization correctly handles null properties.
/// </summary>
[TestMethod]
public void Diagnostic_NullStockTakingItems_VerifyPropertyIndices()
{
var stockTaking = new SimStockTaking
{
Id = 1,
StartDateTime = new DateTime(2025, 1, 24, 10, 0, 0, DateTimeKind.Utc),
IsClosed = false,
Creator = 6, // The exact value from production error (becomes TinyInt 0xD6)
Created = new DateTime(2025, 1, 24, 15, 25, 0, DateTimeKind.Utc),
Modified = new DateTime(2025, 1, 24, 16, 0, 0, DateTimeKind.Utc),
StockTakingItems = null // THIS IS THE KEY - loadRelations = false
};
var binary = stockTaking.ToBinary();
// Log the binary structure
Console.WriteLine($"Binary length: {binary.Length}");
Console.WriteLine($"Binary hex: {string.Join(" ", binary.Select(b => b.ToString("X2")))}");
// === HEADER PARSING (using BinaryTypeCode constants) ===
var pos = 0;
var version = binary[pos++];
Console.WriteLine($"Version: {version}");
var headerFlags = binary[pos++];
Console.WriteLine($"Header flags: 0x{headerFlags:X2}");
bool hasMetadata = (headerFlags & BinaryTypeCode.HeaderFlag_Metadata) != 0;
bool hasRefOnlyId = (headerFlags & BinaryTypeCode.HeaderFlag_RefHandling_OnlyId) != 0;
bool hasRefAll = (headerFlags & BinaryTypeCode.HeaderFlag_RefHandling_All) != 0;
bool hasCacheCount = (headerFlags & BinaryTypeCode.HeaderFlag_HasCacheCount) != 0;
Console.WriteLine($" Metadata={hasMetadata}, RefOnlyId={hasRefOnlyId}, RefAll={hasRefAll}, HasCacheCount={hasCacheCount}");
if (hasCacheCount)
{
var ccByte = binary[pos];
int cacheCount = (ccByte & 0x80) == 0 ? ccByte : (ccByte & 0x7F) | (binary[pos + 1] << 7);
pos += (ccByte & 0x80) == 0 ? 1 : 2;
Console.WriteLine($"Cache count: {cacheCount}");
}
Console.WriteLine($"\n=== BODY (starts at position {pos}) ===");
// Read the object marker — can be FixObj slot (0..SlotCount-1) or explicit marker
var objectMarker = binary[pos++];
bool isFixObj = objectMarker < BinaryTypeCode.SlotCount;
Console.WriteLine($"Object marker: 0x{objectMarker:X2} (FixObj={isFixObj}, " +
$"Object=0x{BinaryTypeCode.Object:X2}, ObjectRefFirst=0x{BinaryTypeCode.ObjectRefFirst:X2}, " +
$"ObjectWithMetadata=0x{BinaryTypeCode.ObjectWithMetadata:X2})");
Assert.IsTrue(
isFixObj
|| objectMarker == BinaryTypeCode.Object
|| objectMarker == BinaryTypeCode.ObjectWithMetadata
|| objectMarker == BinaryTypeCode.ObjectRefFirst
|| objectMarker == BinaryTypeCode.ObjectWithMetadataRefFirst,
$"Expected an object marker, got 0x{objectMarker:X2}");
// If ObjectWithMetadata, skip inline metadata
if (objectMarker is BinaryTypeCode.ObjectWithMetadata or BinaryTypeCode.ObjectWithMetadataRefFirst)
{
var propNameHash = BitConverter.ToInt32(binary, pos);
pos += 4;
Console.WriteLine($"PropNameHash: 0x{propNameHash:X8}");
var pcByte = binary[pos];
int inlinePropCount = (pcByte & 0x80) == 0 ? pcByte : (pcByte & 0x7F) | (binary[pos + 1] << 7);
pos += (pcByte & 0x80) == 0 ? 1 : 2;
Console.WriteLine($"Inline metadata propCount: {inlinePropCount}");
for (int h = 0; h < inlinePropCount; h++)
{
var hash = BitConverter.ToInt32(binary, pos);
Console.WriteLine($" Property hash [{h}]: 0x{hash:X8}");
pos += 4;
}
}
// If RefFirst marker, read VarUInt cache index
if (objectMarker is BinaryTypeCode.ObjectRefFirst or BinaryTypeCode.ObjectWithMetadataRefFirst)
{
var rByte = binary[pos];
int refCacheIndex = (rByte & 0x80) == 0 ? rByte : (rByte & 0x7F) | (binary[pos + 1] << 7);
pos += (rByte & 0x80) == 0 ? 1 : 2;
Console.WriteLine($"RefCacheIndex: {refCacheIndex}");
}
// Markerless format: properties are written in order, no property count header
Console.WriteLine($"\n=== BODY PROPERTIES (remaining {binary.Length - pos} bytes) ===");
int propIdx = 0;
while (pos < binary.Length)
{
var b = binary[pos];
if (b == BinaryTypeCode.DateTime)
{
Console.WriteLine($" Property [{propIdx}]: DateTime (1+8 bytes)");
pos += 9; // marker + 8 bytes ticks
}
else if (BinaryTypeCode.IsTinyInt(b))
{
Console.WriteLine($" Property [{propIdx}]: TinyInt value={BinaryTypeCode.DecodeTinyInt(b)} (0x{b:X2})");
pos += 1;
}
else if (b == BinaryTypeCode.False)
{
Console.WriteLine($" Property [{propIdx}]: Boolean: false");
pos += 1;
}
else if (b == BinaryTypeCode.True)
{
Console.WriteLine($" Property [{propIdx}]: Boolean: true");
pos += 1;
}
else if (b == BinaryTypeCode.Null)
{
Console.WriteLine($" Property [{propIdx}]: Null");
pos += 1;
}
else if (b == BinaryTypeCode.PropertySkip)
{
Console.WriteLine($" Property [{propIdx}]: PropertySkip (default/null)");
pos += 1;
}
else
{
Console.WriteLine($" Property [{propIdx}]: Unknown type: 0x{b:X2}");
break;
}
propIdx++;
}
// Deserialize and verify
var result = binary.BinaryTo<SimStockTaking>();
Assert.IsNotNull(result);
Assert.AreEqual(1, result.Id, "Id should be 1");
Assert.AreEqual(6, result.Creator,
$"Creator should be 6. Got: {result.Creator}. " +
$"If this fails with a very large number, it means DateTime bytes were interpreted as int!");
Assert.AreEqual(stockTaking.Created, result.Created,
$"Created mismatch. Expected: {stockTaking.Created}, Got: {result.Created}. " +
$"If Created has wrong value, deserializer read wrong bytes!");
Assert.AreEqual(stockTaking.StartDateTime, result.StartDateTime);
Assert.IsFalse(result.IsClosed);
Assert.IsNull(result.StockTakingItems, "StockTakingItems should remain null");
}
/// <summary>
/// Test to verify property order consistency between serializer and deserializer.
/// </summary>
[TestMethod]
public void Diagnostic_VerifyPropertyOrderConsistency()
{
// Get serializer's property order
var serializerType = typeof(AcBinarySerializer);
var metadataCacheField = serializerType.GetField("TypeMetadataCache",
BindingFlags.NonPublic | BindingFlags.Static);
// Clear cache to force fresh metadata creation
// (This helps ensure we're testing the actual order)
var type = typeof(SimStockTaking);
var props = type.GetProperties(BindingFlags.Public | BindingFlags.Instance)
.Where(p => p.CanRead && p.GetIndexParameters().Length == 0)
.ToArray();
Console.WriteLine($"Properties of {type.Name} (reflection order):");
for (int i = 0; i < props.Length; i++)
{
var prop = props[i];
Console.WriteLine($" [{i}] {prop.Name} : {prop.PropertyType.Name}");
}
// Verify Creator comes BEFORE Created in the reflection order
var creatorIndex = Array.FindIndex(props, p => p.Name == "Creator");
var createdIndex = Array.FindIndex(props, p => p.Name == "Created");
var stockTakingItemsIndex = Array.FindIndex(props, p => p.Name == "StockTakingItems");
Console.WriteLine($"\nKey indices:");
Console.WriteLine($" StockTakingItems: {stockTakingItemsIndex}");
Console.WriteLine($" Creator: {creatorIndex}");
Console.WriteLine($" Created: {createdIndex}");
// The bug scenario: if StockTakingItems is skipped during serialization,
// but the deserializer still expects it at the original index position,
// then Creator (index 3) would be read when expecting StockTakingItems (index 2)
// and Created (index 4) would be read when expecting Creator (index 3)
Assert.IsTrue(stockTakingItemsIndex >= 0, "StockTakingItems should exist");
Assert.IsTrue(creatorIndex >= 0, "Creator should exist");
Assert.IsTrue(createdIndex >= 0, "Created should exist");
// In the class definition order:
// StockTakingItems comes BEFORE Creator and Created
Assert.IsTrue(stockTakingItemsIndex < creatorIndex,
"StockTakingItems should come before Creator");
Assert.IsTrue(creatorIndex < createdIndex,
"Creator should come before Created");
}
/// <summary>
/// Test multiple StockTakings with null StockTakingItems - exact production scenario.
/// </summary>
[TestMethod]
public void Diagnostic_MultipleStockTakings_NullItems()
{
var stockTakings = new List<SimStockTaking>
{
new()
{
Id = 1,
StartDateTime = new DateTime(2025, 1, 24, 10, 0, 0, DateTimeKind.Utc),
IsClosed = false,
Creator = 6,
Created = new DateTime(2025, 1, 24, 15, 25, 0, DateTimeKind.Utc),
Modified = new DateTime(2025, 1, 24, 16, 0, 0, DateTimeKind.Utc),
StockTakingItems = null
},
new()
{
Id = 2,
StartDateTime = new DateTime(2025, 1, 23, 9, 0, 0, DateTimeKind.Utc),
IsClosed = true,
Creator = 12,
Created = new DateTime(2025, 1, 23, 14, 0, 0, DateTimeKind.Utc),
Modified = new DateTime(2025, 1, 23, 15, 30, 0, DateTimeKind.Utc),
StockTakingItems = null
}
};
var binary = stockTakings.ToBinary();
Console.WriteLine($"Binary length for 2 StockTakings: {binary.Length}");
var result = binary.BinaryTo<List<SimStockTaking>>();
Assert.IsNotNull(result);
Assert.AreEqual(2, result.Count);
// First item
Assert.AreEqual(1, result[0].Id);
Assert.AreEqual(6, result[0].Creator, "First item Creator should be 6");
Assert.AreEqual(stockTakings[0].Created, result[0].Created,
$"First item Created mismatch. Expected: {stockTakings[0].Created}, Got: {result[0].Created}");
// Second item
Assert.AreEqual(2, result[1].Id);
Assert.AreEqual(12, result[1].Creator, "Second item Creator should be 12");
Assert.AreEqual(stockTakings[1].Created, result[1].Created,
$"Second item Created mismatch. Expected: {stockTakings[1].Created}, Got: {result[1].Created}");
}
}

View File

@ -0,0 +1,161 @@
using AyCode.Core.Extensions;
using static AyCode.Core.Tests.TestModels.AcSerializerModels;
namespace AyCode.Core.Tests.Serialization;
/// <summary>
/// Tests for generic type parameter handling in serialization.
/// </summary>
[TestClass]
public class AcBinarySerializerGenericTypeTests
{
/// <summary>
/// CRITICAL REGRESSION TEST: Generic type parameter causing metadata mismatch.
///
/// The bug pattern:
/// 1. Parent class uses generic type parameter: GenericParent&lt;TItem&gt; where TItem : IGenericItem
/// 2. RegisterMetadataForType uses GetCollectionElementType which returns TItem (the interface/constraint)
/// 3. But serialization uses runtime type (GenericItemImpl) which has MORE properties
/// 4. Property indices in metadata table don't match what's being serialized
/// 5. Deserialization reads wrong property indices ? type mismatch!
/// </summary>
[TestMethod]
public void Deserialize_GenericTypeParameter_RuntimeTypeHasMoreProperties()
{
var parent = new ConcreteParent
{
Id = 1,
Name = "Parent",
Creator = 6,
Created = new DateTime(2025, 1, 24, 15, 25, 0, DateTimeKind.Utc),
Modified = DateTime.UtcNow,
Items = new List<GenericItemImpl>
{
new()
{
Id = 10,
Name = "Item1",
ExtraInt = 100,
Created = DateTime.UtcNow.AddHours(-1),
Modified = DateTime.UtcNow,
Description = "Description1"
},
new()
{
Id = 20,
Name = "Item2",
ExtraInt = 200,
Created = DateTime.UtcNow.AddHours(-2),
Modified = DateTime.UtcNow,
Description = "Description2"
}
}
};
var binary = parent.ToBinary();
var result = binary.BinaryTo<ConcreteParent>();
Assert.IsNotNull(result);
Assert.AreEqual(1, result.Id);
Assert.AreEqual(6, result.Creator, "Creator should be 6");
Assert.AreEqual(parent.Created.Ticks, result.Created.Ticks,
$"Created mismatch. Expected: {parent.Created}, Got: {result.Created}");
Assert.IsNotNull(result.Items);
Assert.AreEqual(2, result.Items.Count);
Assert.AreEqual(10, result.Items[0].Id);
Assert.AreEqual(100, result.Items[0].ExtraInt, "ExtraInt should be preserved");
Assert.AreEqual(parent.Items[0].Created.Ticks, result.Items[0].Created.Ticks,
"Item Created should match");
}
/// <summary>
/// Test with list of generic parents.
/// </summary>
[TestMethod]
public void Deserialize_ListOfGenericParents_AllItemsCorrect()
{
var parents = Enumerable.Range(1, 5).Select(p => new ConcreteParent
{
Id = p,
Name = $"Parent_{p}",
Creator = p,
Created = DateTime.UtcNow.AddDays(-p),
Modified = DateTime.UtcNow,
Items = Enumerable.Range(1, 3).Select(i => new GenericItemImpl
{
Id = p * 100 + i,
Name = $"Item_{p}_{i}",
ExtraInt = p * 10 + i,
Created = DateTime.UtcNow.AddHours(-i),
Modified = DateTime.UtcNow,
Description = $"Desc_{p}_{i}"
}).ToList()
}).ToList();
var binary = parents.ToBinary();
var result = binary.BinaryTo<List<ConcreteParent>>();
Assert.IsNotNull(result);
Assert.AreEqual(5, result.Count);
for (int p = 0; p < 5; p++)
{
var original = parents[p];
var deserialized = result[p];
Assert.AreEqual(original.Id, deserialized.Id, $"Parent[{p}].Id mismatch");
Assert.AreEqual(original.Creator, deserialized.Creator, $"Parent[{p}].Creator mismatch");
Assert.AreEqual(original.Created.Ticks, deserialized.Created.Ticks,
$"Parent[{p}].Created mismatch. Expected: {original.Created}, Got: {deserialized.Created}");
Assert.IsNotNull(deserialized.Items, $"Parent[{p}].Items is null");
Assert.AreEqual(3, deserialized.Items.Count);
for (int i = 0; i < 3; i++)
{
var origItem = original.Items![i];
var deserItem = deserialized.Items[i];
Assert.AreEqual(origItem.Id, deserItem.Id, $"Parent[{p}].Items[{i}].Id mismatch");
Assert.AreEqual(origItem.ExtraInt, deserItem.ExtraInt,
$"Parent[{p}].Items[{i}].ExtraInt mismatch");
Assert.AreEqual(origItem.Created.Ticks, deserItem.Created.Ticks,
$"Parent[{p}].Items[{i}].Created mismatch");
}
}
}
/// <summary>
/// Diagnostic: Check what PropertyType the reflection returns for generic type parameter.
/// </summary>
[TestMethod]
public void Diagnostic_GenericProperty_ReflectionType()
{
var parentType = typeof(ConcreteParent);
var itemsProp = parentType.GetProperty("Items");
Assert.IsNotNull(itemsProp);
var propType = itemsProp.PropertyType;
Console.WriteLine($"PropertyType: {propType}");
Console.WriteLine($"PropertyType.FullName: {propType.FullName}");
Console.WriteLine($"IsGenericType: {propType.IsGenericType}");
if (propType.IsGenericType)
{
var args = propType.GetGenericArguments();
Console.WriteLine($"GenericArguments.Length: {args.Length}");
foreach (var arg in args)
{
Console.WriteLine($" GenericArgument: {arg.FullName}");
}
}
Assert.IsTrue(propType.IsGenericType);
var elementType = propType.GetGenericArguments()[0];
Assert.AreEqual(typeof(GenericItemImpl), elementType,
"Element type should be GenericItemImpl, not IGenericItem");
}
}

View File

@ -0,0 +1,653 @@
using System;
using System.Collections.Generic;
using System.Linq;
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>
/// Tests for IId-based reference handling in Binary serializer.
/// Two scenarios:
/// 1. Same instance referenced multiple times (object identity)
/// 2. Different instances with same IId.Id (IId-based deduplication)
///
/// Tests verify BOTH:
/// - Serialized output uses ObjectRef (not redundant full objects)
/// - Deserialized result maintains reference identity
/// </summary>
[TestClass]
public class AcBinarySerializerIIdReferenceTests
{
#region Helper Methods
/// <summary>
/// Counts occurrences of ObjectRef in binary data.
/// Uses BinaryTypeCode.ObjectRef constant to stay in sync with format changes.
/// </summary>
private static int CountObjectRefs(byte[] binary, bool writeBinaryToConsole = true)
{
if (writeBinaryToConsole) WriteBinaryToConsole(binary);
var count = 0;
for (var i = 0; i < binary.Length; i++)
{
if (binary[i] == BinaryTypeCode.ObjectRef)
count++;
}
return count;
}
private static void WriteBinaryToConsole(byte[] binary)
{
Console.WriteLine();
Console.WriteLine(BitConverter.ToString(binary));
Console.WriteLine();
}
/// <summary>
/// Counts occurrences of a string in binary data (UTF8).
/// </summary>
private static int CountStringOccurrences(byte[] binary, string searchString)
{
var searchBytes = System.Text.Encoding.UTF8.GetBytes(searchString);
var count = 0;
for (var i = 0; i <= binary.Length - searchBytes.Length; i++)
{
var match = true;
for (var j = 0; j < searchBytes.Length; j++)
{
if (binary[i + j] != searchBytes[j])
{
match = false;
break;
}
}
if (match) count++;
}
return count;
}
#endregion
#region Scenario 1: Same Instance (Object Identity)
/// <summary>
/// SCENARIO 1: Same instance referenced multiple times.
/// Tests all ReferenceHandling modes: None, OnlyId, All
/// </summary>
[TestMethod]
[DataRow(true, true)]
[DataRow(false, true)]
[DataRow(true, false)]
[DataRow(false, false)]
public void SameInstance_SerializeAndDeserialize(bool useSgen, bool useMeta)
{
var modes = new[]
{
ReferenceHandlingMode.None,
ReferenceHandlingMode.OnlyId,
ReferenceHandlingMode.All
};
foreach (var mode in modes)
{
// Arrange: SAME instance used multiple times
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
{
Id = 1,
OrderNumber = "ORD-001",
PrimaryTag = sharedTag,
Owner = sharedUser,
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_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 } }
]
};
//order.Parent = order.Items[1];
if (mode != ReferenceHandlingMode.None) order.Parent = order.Items[1];
else order.Parent = userPreferences;
order.Items[1].ParentOrder = order;
var options = new AcBinarySerializerOptions
{
ReferenceHandling = mode,
UseGeneratedCode = useSgen,
UseMetadata = useMeta,
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);
if (mode == ReferenceHandlingMode.None) WriteBinaryToConsole(binary);
var result = binary.BinaryTo<TestOrder_Circ_Ref>(); // Options from header
var objectRefCount = CountObjectRefs(binary, false);
Console.WriteLine($"Binary size: {binary.Length} bytes");
Console.WriteLine($"ObjectRef count: {objectRefCount}");
Assert.IsNotNull(result, $"[{mode}] Deserialized result is null");
//Assert.IsNotNull(result.Parent);
Assert.IsNotNull(result.Owner);
// Assert based on mode
switch (mode)
{
case ReferenceHandlingMode.None:
// 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:
// sharedTag (Id=1) 4x → 3 ObjectRefs, sharedUser (Id=1) 2x → 1 ObjectRef = 4 total
Assert.IsTrue(objectRefCount >= 4, $"[{mode}] Expected at least 4 ObjectRefs, found {objectRefCount}");
// IId types should have reference identity
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");
Assert.AreSame(result, result.Items[1].ParentOrder);
Assert.AreSame(result.Parent, result.Items[1]);
break;
case ReferenceHandlingMode.All:
// 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");
Assert.AreSame(result, result.Items[1].ParentOrder);
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_All_True reference identity failed - Non-IId should work in All mode!");
break;
}
// Data integrity - always check
Assert.IsNotNull(result.PrimaryTag, $"[{mode}] PrimaryTag is null");
Assert.AreEqual(1, result.PrimaryTag.Id, $"[{mode}] PrimaryTag.Id incorrect");
Assert.AreEqual("ImportantTag", result.PrimaryTag.Name, $"[{mode}] PrimaryTag.Name incorrect");
Assert.AreEqual(3, result.Items.Count, $"[{mode}] Items count incorrect");
Console.WriteLine($"[{mode}] PASSED ✓");
}
}
#endregion
#region Scenario 2: Different Instances with Same IId (IId-Based Deduplication)
/// <summary>
/// SCENARIO 2: DIFFERENT instances with SAME IId.Id value.
/// CRITICAL test - if IId-based deduplication works:
/// - ObjectRef should be used in binary
/// - Data should be complete after deserialize
/// - References should be identical (AreSame)
/// - Different TYPES with same int Id should NOT be confused!
/// </summary>
[TestMethod]
public void DifferentInstances_SameIId_SerializeAndDeserialize()
{
// Arrange: DIFFERENT instances but SAME IId.Id
// CRITICAL: Multiple DIFFERENT TYPES all have Id=1 - must not be confused!
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_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_All_True
{
Id = 1,
ProductName = "Product-A",
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_All_True
{
Id = 2,
ProductName = "Product-B",
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_All_True
{
Id = 3,
ProductName = "Product-C",
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_All_True>();
// Assert 1: Check if ObjectRef is used (IId-based deduplication active)
var objectRefCount = CountObjectRefs(binary);
Console.WriteLine($"\nBinary size: {binary.Length} bytes (WithRef)");
Console.WriteLine($"ObjectRef count: {objectRefCount}");
// 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_All_True.Id=1)");
Assert.AreSame(result.PrimaryTag, result.Items[1].Tag,
"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_All_True.Id=1)");
// Users with Id=1 should all be same reference
Assert.AreSame(result.Owner, result.Items[0].Assignee,
"CRITICAL: Item[0].Assignee should be same reference as Owner (same SharedUser.Id=1)");
Assert.AreSame(result.Owner, result.Items[1].Assignee,
"CRITICAL: Item[1].Assignee should be same reference as Owner (same SharedUser.Id=1)");
Assert.AreSame(result.Owner, result.Items[2].Assignee,
"CRITICAL: Item[2].Assignee should be same reference as Owner (same SharedUser.Id=1)");
// Assert 4: Different TYPES with same Id should NOT be same reference!
Assert.AreNotSame<object>(result.PrimaryTag, result.Owner,
"CRITICAL BUG: Tag and User are same reference! Types with same int Id were confused!");
Assert.AreNotSame<object>(result.PrimaryTag, result.Category,
"CRITICAL BUG: Tag and Category are same reference! Types with same int Id were confused!");
Assert.AreNotSame<object>(result.Owner, result.Category,
"CRITICAL BUG: User and Category are same reference! Types with same int Id were confused!");
// 4 Tags, 4 Users - each should have 3 ObjectRefs = 6 total minimum
Assert.IsTrue(objectRefCount >= 6,
$"CRITICAL: Expected at least 6 ObjectRef entries (3 per type for Tag and User), found {objectRefCount}. " +
"IId-based reference deduplication is NOT working!");
// Assert 2: Data integrity - ALL data present and correct
Assert.IsNotNull(result, "Deserialized result is null");
// Tag data
Assert.IsNotNull(result.PrimaryTag, "PrimaryTag is null - data lost!");
Assert.AreEqual(1, result.PrimaryTag.Id, "PrimaryTag.Id incorrect");
Assert.AreEqual("Tag_Id1", result.PrimaryTag.Name, "PrimaryTag.Name incorrect - might be confused with User!");
Assert.AreEqual("#FF0000", result.PrimaryTag.Color, "PrimaryTag.Color incorrect");
// User data - MUST NOT be confused with Tag (both have Id=1)
Assert.IsNotNull(result.Owner, "Owner is null - data lost!");
Assert.AreEqual(1, result.Owner.Id, "Owner.Id incorrect");
Assert.AreEqual("User_Id1", result.Owner.Username, "Owner.Username incorrect - might be confused with Tag!");
Assert.AreEqual("user1@test.com", result.Owner.Email, "Owner.Email incorrect");
// Category data - MUST NOT be confused with Tag or User (all have Id=1)
Assert.IsNotNull(result.Category, "Category is null - data lost!");
Assert.AreEqual(1, result.Category.Id, "Category.Id incorrect");
Assert.AreEqual("Category_Id1", result.Category.Name, "Category.Name incorrect - might be confused with Tag!");
Assert.AreEqual(10, result.Category.SortOrder, "Category.SortOrder incorrect");
Assert.IsNotNull(result.Items, "Items is null");
Assert.AreEqual(3, result.Items.Count, "Items count incorrect");
for (var i = 0; i < 3; i++)
{
// Tag in items
Assert.IsNotNull(result.Items[i].Tag, $"Items[{i}].Tag is null - data lost!");
Assert.AreEqual(1, result.Items[i].Tag!.Id, $"Items[{i}].Tag.Id incorrect");
Assert.AreEqual("Tag_Id1", result.Items[i].Tag.Name, $"Items[{i}].Tag.Name incorrect - confused with User?");
// User in items - MUST NOT be confused with Tag
Assert.IsNotNull(result.Items[i].Assignee, $"Items[{i}].Assignee is null - data lost!");
Assert.AreEqual(1, result.Items[i].Assignee!.Id, $"Items[{i}].Assignee.Id incorrect");
Assert.AreEqual("User_Id1", result.Items[i].Assignee.Username, $"Items[{i}].Assignee.Username incorrect - confused with Tag?");
}
}
/// <summary>
/// Size comparison: Same IId should result in smaller binary + verify data integrity.
/// </summary>
[TestMethod]
public void DifferentInstances_SameIId_SmallerBinaryWithDataIntegrity()
{
// Arrange: 10 different instances with SAME IId
var orderWithSameIId = new TestOrder_All_True
{
Id = 1,
OrderNumber = "SAME-IID",
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_All_True { Id = 1, Username = "shared_user_name", Email = "shared@test.com" }
}).ToList()
};
// Arrange: 10 different instances with DIFFERENT IIds
var orderWithDifferentIIds = new TestOrder_All_True
{
Id = 1,
OrderNumber = "DIFF-IID",
Items = Enumerable.Range(1, 10).Select(i => new TestOrderItem_All_True
{
Id = i,
ProductName = $"Product-{i}",
// All have DIFFERENT IId.Id
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_All_True>();
var diffIIdResult = diffIIdBinary.BinaryTo<TestOrder_All_True>();
// Assert 1: Size comparison
Console.WriteLine($"Same IId binary size: {sameIIdBinary.Length} bytes");
Console.WriteLine($"Different IId binary size: {diffIIdBinary.Length} bytes");
Console.WriteLine($"Size difference: {diffIIdBinary.Length - sameIIdBinary.Length} bytes");
Assert.IsTrue(sameIIdBinary.Length < diffIIdBinary.Length,
$"Same IId ({sameIIdBinary.Length}b) should be smaller than different IIds ({diffIIdBinary.Length}b). " +
"IId-based deduplication NOT working!");
// Assert 2: Data integrity for sameIId result
Assert.IsNotNull(sameIIdResult, "sameIIdResult is null");
Assert.IsNotNull(sameIIdResult.Items, "sameIIdResult.Items is null");
Assert.AreEqual(10, sameIIdResult.Items.Count, "sameIIdResult should have 10 items");
for (var i = 0; i < 10; i++)
{
Assert.IsNotNull(sameIIdResult.Items[i].Assignee,
$"sameIIdResult.Items[{i}].Assignee is null - data lost!");
Assert.AreEqual(1, sameIIdResult.Items[i].Assignee!.Id,
$"sameIIdResult.Items[{i}].Assignee.Id should be 1");
Assert.AreEqual("shared_user_name", sameIIdResult.Items[i].Assignee.Username,
$"sameIIdResult.Items[{i}].Assignee.Username incorrect");
}
// Assert 3: Reference identity for sameIId
var firstAssignee = sameIIdResult.Items[0].Assignee;
for (var i = 1; i < 10; i++)
{
Assert.AreSame(firstAssignee, sameIIdResult.Items[i].Assignee,
$"CRITICAL: Items[{i}].Assignee should be same reference as Items[0].Assignee (same IId.Id=1)");
}
}
#endregion
#region Guid-Based IId Tests
/// <summary>
/// Guid IId with same instance - validates ObjectRef + data integrity + reference identity.
/// </summary>
[TestMethod]
public void GuidIId_SameInstance_SerializeAndDeserialize()
{
// Arrange: SAME instance
var sharedGuid = Guid.NewGuid();
var sharedItem = new TestGuidItem { Id = sharedGuid, Name = "SharedGuidItem" };
var order = new TestGuidOrder
{
Id = Guid.NewGuid(),
Code = "GUID-001",
Count = 3,
Items = [sharedItem, sharedItem, sharedItem]
};
// Act
var binary = order.ToBinary();
var result = binary.BinaryTo<TestGuidOrder>();
// Assert 1: ObjectRef should be present
var objectRefCount = CountObjectRefs(binary);
Console.WriteLine($"Binary size: {binary.Length} bytes");
Console.WriteLine($"ObjectRef count: {objectRefCount}");
Assert.IsTrue(objectRefCount >= 2, $"Expected at least 2 ObjectRefs, found {objectRefCount}");
// Assert 2: Data integrity - all items present and correct
Assert.IsNotNull(result, "Result is null");
Assert.IsNotNull(result.Items, "Items is null");
Assert.AreEqual(3, result.Items.Count, "Items count incorrect");
for (var i = 0; i < 3; i++)
{
Assert.IsNotNull(result.Items[i], $"Items[{i}] is null");
Assert.AreEqual(sharedGuid, result.Items[i].Id, $"Items[{i}].Id incorrect");
Assert.AreEqual("SharedGuidItem", result.Items[i].Name, $"Items[{i}].Name incorrect");
}
// Assert 3: Reference identity
Assert.AreSame(result.Items[0], result.Items[1], "Items[0] and Items[1] should be same reference");
Assert.AreSame(result.Items[1], result.Items[2], "Items[1] and Items[2] should be same reference");
}
/// <summary>
/// CRITICAL: Guid IId with different instances but same Id - tests IId-based deduplication.
/// </summary>
[TestMethod]
public void GuidIId_DifferentInstances_SameId_SerializeAndDeserialize()
{
// Arrange: DIFFERENT instances but SAME Guid
var sharedGuid = Guid.NewGuid();
var order = new TestGuidOrder
{
Id = Guid.NewGuid(),
Code = "GUID-001",
Count = 3,
Items =
[
new TestGuidItem { Id = sharedGuid, Name = "SharedGuidItem" },
new TestGuidItem { Id = sharedGuid, Name = "SharedGuidItem" },
new TestGuidItem { Id = sharedGuid, Name = "SharedGuidItem" }
]
};
// Act
var binary = order.ToBinary();
var result = binary.BinaryTo<TestGuidOrder>();
// Assert 1: ObjectRef should be present if IId-based dedup works
var objectRefCount = CountObjectRefs(binary);
Console.WriteLine($"Binary size: {binary.Length} bytes");
Console.WriteLine($"ObjectRef count: {objectRefCount}");
Assert.IsTrue(objectRefCount >= 2,
$"CRITICAL: Expected at least 2 ObjectRefs for same Guid IId, found {objectRefCount}. " +
"Guid-based IId deduplication NOT working!");
// Assert 2: Data integrity - all items present and correct
Assert.IsNotNull(result, "Result is null");
Assert.IsNotNull(result.Items, "Items is null");
Assert.AreEqual(3, result.Items.Count, "Items count incorrect");
for (var i = 0; i < 3; i++)
{
Assert.IsNotNull(result.Items[i], $"Items[{i}] is null - data lost!");
Assert.AreEqual(sharedGuid, result.Items[i].Id, $"Items[{i}].Id incorrect");
Assert.AreEqual("SharedGuidItem", result.Items[i].Name, $"Items[{i}].Name incorrect");
}
// Assert 3: Reference identity - if IId-based, should be same reference
Assert.AreSame(result.Items[0], result.Items[1],
"CRITICAL: Items[0] and Items[1] should be same reference (same Guid)");
Assert.AreSame(result.Items[1], result.Items[2],
"CRITICAL: Items[1] and Items[2] should be same reference (same Guid)");
}
#endregion
#region Data Integrity Tests
/// <summary>
/// Diagnostic test to verify IId detection works correctly.
/// </summary>
[TestMethod]
public void IIdDetection_Diagnostic()
{
// Test GetIdInfo directly
var sharedTagType = typeof(SharedTag_All_True);
var idInfo = AyCode.Core.Helpers.JsonUtilities.GetIdInfo(sharedTagType);
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_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>");
// Test TestGuidItem
var guidItemType = typeof(TestGuidItem);
var guidIdInfo = AyCode.Core.Helpers.JsonUtilities.GetIdInfo(guidItemType);
Console.WriteLine($"TestGuidItem GetIdInfo: IsId={guidIdInfo.IsId}, IdType={guidIdInfo.IdType?.Name}");
Assert.IsTrue(guidIdInfo.IsId, "TestGuidItem should be detected as IId<Guid>");
}
/// <summary>
/// Verify data is correct regardless of reference handling.
/// </summary>
[TestMethod]
public void SharedCategory_DataIntegrity()
{
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 },
new() { Id = 3, Name = "Category3", SortOrder = 3, ParentCategoryId = 1 }
};
var binary = categories.ToBinary();
var result = binary.BinaryTo<List<SharedCategory_All_True>>();
Assert.IsNotNull(result);
Assert.AreEqual(3, result.Count);
Assert.AreEqual(1, result[0].Id);
Assert.AreEqual("Category1", result[0].Name);
Assert.IsTrue(result[0].IsDefault);
Assert.AreEqual(2, result[1].Id);
Assert.AreEqual(1, result[1].ParentCategoryId);
Assert.AreEqual(3, result[2].Id);
Assert.AreEqual(1, result[2].ParentCategoryId);
}
#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,370 @@
using AyCode.Core.Extensions;
using static AyCode.Core.Tests.TestModels.AcSerializerModels;
namespace AyCode.Core.Tests.Serialization;
/// <summary>
/// Tests for navigation property serialization issues.
///
/// CRITICAL BUG REPRODUCTION:
/// When a navigation property (like StockTakingItem.Product) is populated,
/// the serializer writes properties of the navigation target (Product),
/// but these property names were NOT registered in the metadata header!
///
/// The bug pattern:
/// 1. RegisterMetadataForType walks List&lt;StockTakingItem&gt; and registers StockTakingItem properties
/// 2. StockTakingItem has a "Product" property of type Product - this property NAME is registered
/// 3. BUT Product's own properties (Name, Description, Price, CategoryId) are NOT registered!
/// 4. When Product is NOT NULL at runtime, WriteObject writes Product's property indices
/// 5. GetPropertyNameIndex returns NEW indices that weren't in the header!
/// 6. Deserializer reads property indices that don't exist in its table ? crash/type mismatch
/// </summary>
[TestClass]
public class AcBinarySerializerNavigationPropertyTests
{
/// <summary>
/// CRITICAL REGRESSION TEST: Navigation properties causing metadata mismatch.
/// This is the EXACT production scenario:
/// - StockTakingItem.Product is populated by the database query
/// - Product's properties are serialized with wrong indices
/// - Deserializer fails with type mismatch
/// </summary>
[TestMethod]
public void Deserialize_NavigationPropertyPopulated_MetadataIncludesNestedType()
{
var parent = new ParentWithNavigatingItems
{
Id = 1,
Name = "Parent",
Creator = 6, // The exact value from production error
Created = new DateTime(2025, 1, 24, 15, 25, 0, DateTimeKind.Utc),
Modified = DateTime.UtcNow,
Items = new List<ItemWithNavigationProperty>
{
new()
{
Id = 10,
ParentId = 1,
ProductId = 100,
IsMeasured = true,
Quantity = 50,
Created = DateTime.UtcNow.AddHours(-1),
Modified = DateTime.UtcNow,
// Navigation property IS populated - this is the key!
Product = new ProductEntity
{
Id = 100,
Name = "TestProduct",
Description = "Product description with long text",
Price = 99.99,
CategoryId = 5,
Created = DateTime.UtcNow.AddDays(-30)
}
}
}
};
var binary = parent.ToBinary();
var result = binary.BinaryTo<ParentWithNavigatingItems>();
Assert.IsNotNull(result);
Assert.AreEqual(1, result.Id);
Assert.AreEqual(6, result.Creator, "Creator should be 6");
Assert.AreEqual(parent.Created.Ticks, result.Created.Ticks,
$"Created mismatch. Expected: {parent.Created}, Got: {result.Created}");
Assert.IsNotNull(result.Items);
Assert.AreEqual(1, result.Items.Count);
var item = result.Items[0];
Assert.AreEqual(10, item.Id);
Assert.AreEqual(100, item.ProductId);
// Navigation property should be deserialized correctly
Assert.IsNotNull(item.Product, "Product navigation property should not be null");
Assert.AreEqual(100, item.Product.Id);
Assert.AreEqual("TestProduct", item.Product.Name);
Assert.AreEqual(5, item.Product.CategoryId);
}
/// <summary>
/// Test with multiple items, some with Product populated, some without.
/// This creates a mixed scenario where some items have navigation properties.
/// </summary>
[TestMethod]
public void Deserialize_MixedNavigationProperties_AllItemsCorrect()
{
var parent = new ParentWithNavigatingItems
{
Id = 1,
Name = "Parent",
Creator = 6,
Created = DateTime.UtcNow,
Modified = DateTime.UtcNow,
Items = Enumerable.Range(1, 5).Select(i => new ItemWithNavigationProperty
{
Id = i * 10,
ParentId = 1,
ProductId = 100 + i,
IsMeasured = i % 2 == 0,
Quantity = i * 10,
Created = DateTime.UtcNow.AddHours(-i),
Modified = DateTime.UtcNow,
// Only populate Product for even items
Product = i % 2 == 0 ? new ProductEntity
{
Id = 100 + i,
Name = $"Product_{i}",
Description = $"Description for product {i}",
Price = i * 10.5,
CategoryId = i % 3,
Created = DateTime.UtcNow.AddDays(-i)
} : null
}).ToList()
};
var binary = parent.ToBinary();
var result = binary.BinaryTo<ParentWithNavigatingItems>();
Assert.IsNotNull(result);
Assert.AreEqual(1, result.Id);
Assert.AreEqual(6, result.Creator);
Assert.IsNotNull(result.Items);
Assert.AreEqual(5, result.Items.Count);
for (int i = 1; i <= 5; i++)
{
var item = result.Items[i - 1];
Assert.AreEqual(i * 10, item.Id, $"Item {i} Id mismatch");
if (i % 2 == 0)
{
Assert.IsNotNull(item.Product, $"Item {i} should have Product");
Assert.AreEqual($"Product_{i}", item.Product.Name);
}
else
{
Assert.IsNull(item.Product, $"Item {i} should not have Product");
}
}
}
/// <summary>
/// Test with list of parents, each with items with navigation properties.
/// This is the exact production scenario - multiple StockTaking entities
/// each with StockTakingItems that have Product navigation properties.
/// </summary>
[TestMethod]
public void Deserialize_ListOfParentsWithNavigationProperties_AllCorrect()
{
var parents = Enumerable.Range(1, 3).Select(p => new ParentWithNavigatingItems
{
Id = p,
Name = $"Parent_{p}",
Creator = p,
Created = DateTime.UtcNow.AddDays(-p),
Modified = DateTime.UtcNow,
Items = Enumerable.Range(1, 2).Select(i => new ItemWithNavigationProperty
{
Id = p * 100 + i,
ParentId = p,
ProductId = 1000 + i,
IsMeasured = true,
Quantity = 10 * i,
Created = DateTime.UtcNow.AddHours(-i),
Modified = DateTime.UtcNow,
Product = new ProductEntity
{
Id = 1000 + i,
Name = $"Product_{p}_{i}",
Description = $"Description {p}_{i}",
Price = (p * 10) + (i * 1.5),
CategoryId = i % 3,
Created = DateTime.UtcNow.AddDays(-10)
}
}).ToList()
}).ToList();
var binary = parents.ToBinary();
var result = binary.BinaryTo<List<ParentWithNavigatingItems>>();
Assert.IsNotNull(result);
Assert.AreEqual(3, result.Count);
for (int p = 0; p < 3; p++)
{
var parent = result[p];
Assert.AreEqual(p + 1, parent.Id, $"Parent[{p}].Id mismatch");
Assert.AreEqual(p + 1, parent.Creator, $"Parent[{p}].Creator mismatch");
Assert.IsNotNull(parent.Items);
Assert.AreEqual(2, parent.Items.Count);
for (int i = 0; i < 2; i++)
{
var item = parent.Items[i];
Assert.IsNotNull(item.Product, $"Parent[{p}].Items[{i}].Product should not be null");
Assert.AreEqual($"Product_{p + 1}_{i + 1}", item.Product.Name);
}
}
}
/// <summary>
/// Test deeply nested navigation properties.
/// Product has a Category, Category has a Parent, etc.
/// </summary>
[TestMethod]
public void Deserialize_DeeplyNestedNavigationProperties_AllCorrect()
{
// This tests that the serializer correctly handles navigation properties
// even when they are deeply nested (Product -> Category -> Parent)
var parent = new ParentWithNavigatingItems
{
Id = 1,
Name = "Parent",
Creator = 6,
Created = DateTime.UtcNow,
Modified = DateTime.UtcNow,
Items = new List<ItemWithNavigationProperty>
{
new()
{
Id = 10,
ParentId = 1,
ProductId = 100,
IsMeasured = true,
Quantity = 50,
Created = DateTime.UtcNow,
Modified = DateTime.UtcNow,
Product = new ProductEntity
{
Id = 100,
Name = "ProductWithDetails",
Description = "Very long description that should be interned",
Price = 123.45,
CategoryId = 10,
Created = DateTime.UtcNow.AddMonths(-6)
}
},
new()
{
Id = 20,
ParentId = 1,
ProductId = 200,
IsMeasured = false,
Quantity = 25,
Created = DateTime.UtcNow,
Modified = DateTime.UtcNow,
Product = new ProductEntity
{
Id = 200,
Name = "AnotherProduct",
Description = "Another description",
Price = 67.89,
CategoryId = 20,
Created = DateTime.UtcNow.AddMonths(-3)
}
}
}
};
var binary = parent.ToBinary();
// Log binary size for debugging
Console.WriteLine($"Binary size: {binary.Length} bytes");
var result = binary.BinaryTo<ParentWithNavigatingItems>();
Assert.IsNotNull(result);
Assert.AreEqual(1, result.Id);
Assert.AreEqual(6, result.Creator);
Assert.IsNotNull(result.Items);
Assert.AreEqual(2, result.Items.Count);
// First item
Assert.AreEqual(10, result.Items[0].Id);
Assert.IsNotNull(result.Items[0].Product);
Assert.AreEqual("ProductWithDetails", result.Items[0].Product.Name);
Assert.AreEqual(123.45, result.Items[0].Product.Price);
// Second item
Assert.AreEqual(20, result.Items[1].Id);
Assert.IsNotNull(result.Items[1].Product);
Assert.AreEqual("AnotherProduct", result.Items[1].Product.Name);
Assert.AreEqual(67.89, result.Items[1].Product.Price);
}
/// <summary>
/// Test with same Product instance referenced multiple times.
/// This tests the reference handling with navigation properties.
/// </summary>
[TestMethod]
public void Deserialize_SharedNavigationProperty_ReferencesPreserved()
{
// Create a shared Product that is referenced by multiple items
var sharedProduct = new ProductEntity
{
Id = 999,
Name = "SharedProduct",
Description = "This product is shared across items",
Price = 50.00,
CategoryId = 1,
Created = DateTime.UtcNow.AddYears(-1)
};
var parent = new ParentWithNavigatingItems
{
Id = 1,
Name = "Parent",
Creator = 6,
Created = DateTime.UtcNow,
Modified = DateTime.UtcNow,
Items = new List<ItemWithNavigationProperty>
{
new()
{
Id = 10,
ParentId = 1,
ProductId = 999,
IsMeasured = true,
Quantity = 50,
Created = DateTime.UtcNow,
Modified = DateTime.UtcNow,
Product = sharedProduct // Same reference
},
new()
{
Id = 20,
ParentId = 1,
ProductId = 999,
IsMeasured = false,
Quantity = 75,
Created = DateTime.UtcNow,
Modified = DateTime.UtcNow,
Product = sharedProduct // Same reference
}
}
};
var binary = parent.ToBinary();
var result = binary.BinaryTo<ParentWithNavigatingItems>();
Assert.IsNotNull(result);
Assert.IsNotNull(result.Items);
Assert.AreEqual(2, result.Items.Count);
// Both items should have the same Product values
Assert.IsNotNull(result.Items[0].Product);
Assert.IsNotNull(result.Items[1].Product);
Assert.AreEqual(999, result.Items[0].Product.Id);
Assert.AreEqual(999, result.Items[1].Product.Id);
Assert.AreEqual("SharedProduct", result.Items[0].Product.Name);
Assert.AreEqual("SharedProduct", result.Items[1].Product.Name);
// With reference handling, they should be the same instance
// (This depends on ReferenceHandling being enabled)
Console.WriteLine($"Same instance: {ReferenceEquals(result.Items[0].Product, result.Items[1].Product)}");
}
}

View File

@ -0,0 +1,237 @@
using AyCode.Core.Extensions;
using static AyCode.Core.Tests.TestModels.AcSerializerModels;
using static AyCode.Core.Tests.Serialization.AcSerializerTestHelper;
namespace AyCode.Core.Tests.Serialization;
/// <summary>
/// Tests for nullable value type serialization.
/// </summary>
[TestClass]
public class AcBinarySerializerNullableTests
{
[TestMethod]
public void Deserialize_NullableIntProperty_WithValue_RoundTrip()
{
var obj = new TestClassWithNullableProperties { Id = 42, NullableInt = 123, NullableIntNull = null };
var binary = obj.ToBinary();
var result = binary.BinaryTo<TestClassWithNullableProperties>();
Assert.IsNotNull(result);
Assert.AreEqual(42, result.Id);
Assert.AreEqual(123, result.NullableInt);
Assert.IsNull(result.NullableIntNull);
}
[TestMethod]
public void Deserialize_NullableDoubleProperty_WithValue_RoundTrip()
{
var obj = new TestClassWithNullableProperties { Id = 1, NullableDouble = 3.14159, NullableDoubleNull = null };
var binary = obj.ToBinary();
var result = binary.BinaryTo<TestClassWithNullableProperties>();
Assert.IsNotNull(result);
Assert.AreEqual(3.14159, result.NullableDouble);
Assert.IsNull(result.NullableDoubleNull);
}
[TestMethod]
public void Deserialize_NullableDateTimeProperty_WithValue_RoundTrip()
{
var testDate = new DateTime(2024, 12, 25, 10, 30, 45, DateTimeKind.Utc);
var obj = new TestClassWithNullableProperties { Id = 1, NullableDateTime = testDate, NullableDateTimeNull = null };
var binary = obj.ToBinary();
var result = binary.BinaryTo<TestClassWithNullableProperties>();
Assert.IsNotNull(result);
Assert.AreEqual(testDate, result.NullableDateTime);
Assert.IsNull(result.NullableDateTimeNull);
}
[TestMethod]
public void Deserialize_NullableGuidProperty_WithValue_RoundTrip()
{
var testGuid = Guid.NewGuid();
var obj = new TestClassWithNullableProperties { Id = 1, NullableGuid = testGuid, NullableGuidNull = null };
var binary = obj.ToBinary();
var result = binary.BinaryTo<TestClassWithNullableProperties>();
Assert.IsNotNull(result);
Assert.AreEqual(testGuid, result.NullableGuid);
Assert.IsNull(result.NullableGuidNull);
}
[TestMethod]
public void Deserialize_NullableDecimalProperty_WithValue_RoundTrip()
{
var obj = new TestClassWithNullableProperties { Id = 1, NullableDecimal = 123456.789m, NullableDecimalNull = null };
var binary = obj.ToBinary();
var result = binary.BinaryTo<TestClassWithNullableProperties>();
Assert.IsNotNull(result);
Assert.AreEqual(123456.789m, result.NullableDecimal);
Assert.IsNull(result.NullableDecimalNull);
}
[TestMethod]
public void Deserialize_NullableBoolProperty_WithValue_RoundTrip()
{
var obj = new TestClassWithNullableProperties { Id = 1, NullableBool = true, NullableBoolNull = null };
var binary = obj.ToBinary();
var result = binary.BinaryTo<TestClassWithNullableProperties>();
Assert.IsNotNull(result);
Assert.AreEqual(true, result.NullableBool);
Assert.IsNull(result.NullableBoolNull);
}
[TestMethod]
public void Deserialize_NullableLongProperty_WithValue_RoundTrip()
{
var obj = new TestClassWithNullableProperties { Id = 1, NullableLong = 9876543210L, NullableLongNull = null };
var binary = obj.ToBinary();
var result = binary.BinaryTo<TestClassWithNullableProperties>();
Assert.IsNotNull(result);
Assert.AreEqual(9876543210L, result.NullableLong);
Assert.IsNull(result.NullableLongNull);
}
[TestMethod]
public void Deserialize_AllNullablePropertiesWithValues_RoundTrip()
{
var testDate = new DateTime(2024, 6, 15, 14, 30, 0, DateTimeKind.Utc);
var testGuid = Guid.Parse("12345678-1234-1234-1234-123456789abc");
var obj = new TestClassWithNullableProperties
{
Id = 999,
NullableInt = int.MaxValue,
NullableIntNull = 42,
NullableLong = long.MaxValue,
NullableLongNull = 100L,
NullableDouble = double.MaxValue,
NullableDoubleNull = 2.5,
NullableDecimal = decimal.MaxValue,
NullableDecimalNull = 1.1m,
NullableDateTime = testDate,
NullableDateTimeNull = DateTime.UtcNow,
NullableGuid = testGuid,
NullableGuidNull = Guid.NewGuid(),
NullableBool = false,
NullableBoolNull = true
};
var binary = obj.ToBinary();
var result = binary.BinaryTo<TestClassWithNullableProperties>();
Assert.IsNotNull(result);
Assert.AreEqual(obj.Id, result.Id);
Assert.AreEqual(obj.NullableInt, result.NullableInt);
Assert.AreEqual(obj.NullableIntNull, result.NullableIntNull);
Assert.AreEqual(obj.NullableLong, result.NullableLong);
Assert.AreEqual(obj.NullableLongNull, result.NullableLongNull);
Assert.AreEqual(obj.NullableDouble, result.NullableDouble);
Assert.AreEqual(obj.NullableDecimalNull, result.NullableDecimalNull);
Assert.AreEqual(obj.NullableDateTime, result.NullableDateTime);
Assert.AreEqual(obj.NullableGuid, result.NullableGuid);
Assert.AreEqual(obj.NullableBool, result.NullableBool);
Assert.AreEqual(obj.NullableBoolNull, result.NullableBoolNull);
}
[TestMethod]
public void Deserialize_ObjectWithNestedNullableProperties_RoundTrip()
{
var obj = new TestParentWithNullableChild
{
Id = 1,
Name = "Parent",
Child = new TestClassWithNullableProperties
{
Id = 2,
NullableInt = 100,
NullableDouble = 5.5,
NullableDateTime = DateTime.UtcNow,
NullableGuid = Guid.NewGuid()
}
};
var binary = obj.ToBinary();
var result = binary.BinaryTo<TestParentWithNullableChild>();
Assert.IsNotNull(result);
Assert.AreEqual(obj.Id, result.Id);
Assert.AreEqual(obj.Name, result.Name);
Assert.IsNotNull(result.Child);
Assert.AreEqual(obj.Child.NullableInt, result.Child.NullableInt);
Assert.AreEqual(obj.Child.NullableDouble, result.Child.NullableDouble);
}
[TestMethod]
public void Deserialize_ListOfObjectsWithNullableProperties_RoundTrip()
{
var items = CreateNullablePropertyItems(10);
var binary = items.ToBinary();
var result = binary.BinaryTo<List<TestClassWithNullableProperties>>();
Assert.IsNotNull(result);
Assert.AreEqual(10, result.Count);
for (int i = 0; i < 10; i++)
{
var original = items[i];
var deserialized = result[i];
Assert.AreEqual(original.Id, deserialized.Id, $"Id mismatch at index {i}");
Assert.AreEqual(original.NullableInt, deserialized.NullableInt, $"NullableInt mismatch at index {i}");
Assert.AreEqual(original.NullableDouble, deserialized.NullableDouble, $"NullableDouble mismatch at index {i}");
Assert.AreEqual(original.NullableGuid, deserialized.NullableGuid, $"NullableGuid mismatch at index {i}");
}
}
[TestMethod]
public void Deserialize_StockTakingLikeHierarchy_WithNullableProperties_RoundTrip()
{
var stockTaking = CreateStockTaking(2, 2);
var binary = stockTaking.ToBinary();
var result = binary.BinaryTo<TestStockTaking>();
Assert.IsNotNull(result);
Assert.AreEqual(stockTaking.Id, result.Id);
Assert.AreEqual(stockTaking.IsClosed, result.IsClosed);
Assert.AreEqual(stockTaking.Creator, result.Creator);
Assert.IsNotNull(result.StockTakingItems);
Assert.AreEqual(2, result.StockTakingItems.Count);
var item0 = result.StockTakingItems[0];
Assert.IsNotNull(item0.StockTakingItemPallets);
Assert.AreEqual(2, item0.StockTakingItemPallets.Count);
}
[TestMethod]
public void Deserialize_ListOfStockTakingLikeEntities_RoundTrip()
{
var stockTakings = CreateStockTakingList(2, 1, 1);
var binary = stockTakings.ToBinary();
var result = binary.BinaryTo<List<TestStockTaking>>();
Assert.IsNotNull(result);
Assert.AreEqual(2, result.Count);
Assert.AreEqual(1, result[0].Id);
Assert.IsNotNull(result[0].StockTakingItems);
Assert.AreEqual(1, result[0].StockTakingItems.Count);
Assert.AreEqual(2, result[1].Id);
Assert.IsNotNull(result[1].StockTakingItems);
}
}

View File

@ -0,0 +1,185 @@
using AyCode.Core.Extensions;
using static AyCode.Core.Tests.TestModels.AcSerializerModels;
namespace AyCode.Core.Tests.Serialization;
/// <summary>
/// Tests for object serialization including nested objects, lists, and dictionaries.
/// </summary>
[TestClass]
public class AcBinarySerializerObjectTests
{
[TestMethod]
public void Serialize_SimpleObject_RoundTrip()
{
var obj = new TestSimpleClass { Id = 42, Name = "Test Object", Value = 3.14, IsActive = true };
var binary = obj.ToBinary();
var result = binary.BinaryTo<TestSimpleClass>();
Assert.IsNotNull(result);
Assert.AreEqual(obj.Id, result.Id);
Assert.AreEqual(obj.Name, result.Name);
Assert.AreEqual(obj.Value, result.Value);
Assert.AreEqual(obj.IsActive, result.IsActive);
}
[TestMethod]
public void Serialize_NestedObject_RoundTrip()
{
var obj = new TestNestedClass
{
Id = 1,
Name = "Parent",
Child = new TestSimpleClass { Id = 2, Name = "Child", Value = 2.5, IsActive = true }
};
var binary = obj.ToBinary();
var result = binary.BinaryTo<TestNestedClass>();
Assert.IsNotNull(result);
Assert.AreEqual(obj.Id, result.Id);
Assert.AreEqual(obj.Name, result.Name);
Assert.IsNotNull(result.Child);
Assert.AreEqual(obj.Child.Id, result.Child.Id);
Assert.AreEqual(obj.Child.Name, result.Child.Name);
}
[TestMethod]
public void Serialize_List_RoundTrip()
{
var list = new List<int> { 1, 2, 3, 4, 5 };
var binary = list.ToBinary();
var result = binary.BinaryTo<List<int>>();
Assert.IsNotNull(result);
CollectionAssert.AreEqual(list, result);
}
[TestMethod]
public void Serialize_ObjectWithList_RoundTrip()
{
var obj = new TestClassWithList { Id = 1, Items = ["Item1", "Item2", "Item3"] };
var binary = obj.ToBinary();
var result = binary.BinaryTo<TestClassWithList>();
Assert.IsNotNull(result);
Assert.AreEqual(obj.Id, result.Id);
Assert.IsNotNull(result.Items);
CollectionAssert.AreEqual(obj.Items, result.Items);
}
[TestMethod]
public void Serialize_Dictionary_RoundTrip()
{
var dict = new Dictionary<string, int> { ["one"] = 1, ["two"] = 2, ["three"] = 3 };
var binary = dict.ToBinary();
var result = binary.BinaryTo<Dictionary<string, int>>();
Assert.IsNotNull(result);
Assert.AreEqual(dict.Count, result.Count);
foreach (var kvp in dict)
{
Assert.IsTrue(result.ContainsKey(kvp.Key));
Assert.AreEqual(kvp.Value, result[kvp.Key]);
}
}
[TestMethod]
public void Populate_UpdatesExistingObject()
{
var target = new TestSimpleClass { Id = 0, Name = "Original" };
var source = new TestSimpleClass { Id = 42, Name = "Updated", Value = 3.14 };
var binary = source.ToBinary();
binary.BinaryTo(target);
Assert.AreEqual(42, target.Id);
Assert.AreEqual("Updated", target.Name);
Assert.AreEqual(3.14, target.Value);
}
[TestMethod]
public void PopulateMerge_MergesNestedObjects()
{
var target = new TestNestedClass
{
Id = 1,
Name = "Original",
Child = new TestSimpleClass { Id = 10, Name = "OriginalChild", Value = 1.0 }
};
var source = new TestNestedClass
{
Id = 2,
Name = "Updated",
Child = new TestSimpleClass { Id = 20, Name = "UpdatedChild", Value = 2.0 }
};
var binary = source.ToBinary();
binary.BinaryToMerge(target);
Assert.AreEqual(2, target.Id);
Assert.AreEqual("Updated", target.Name);
Assert.IsNotNull(target.Child);
Assert.AreEqual(20, target.Child.Id);
Assert.AreEqual("UpdatedChild", target.Child.Name);
}
[TestMethod]
public void Serialize_SimpleId45_RoundTrip()
{
// Simple test to debug Id=45 serialization issue
var item = new TestHighReuseDto { Id = 45, CategoryCode = "test"};
var binary = item.ToBinary();
var result = binary.BinaryTo<TestHighReuseDto>();
Assert.IsNotNull(result);
Assert.AreEqual(45, result.Id, "Id should be 45 after deserialization");
Assert.AreEqual("test", result.CategoryCode);
}
[TestMethod]
public void Serialize_SimpleId0_RoundTrip()
{
// Test with Id=0 to see if SKIP marker is used correctly
var item = new TestHighReuseDto { Id = 0 };
var binary = item.ToBinary();
var result = binary.BinaryTo<TestHighReuseDto>();
Assert.IsNotNull(result);
Assert.AreEqual(0, result.Id, "Id should be 0 after deserialization");
}
[TestMethod]
[DataRow(-16, DisplayName = "TinyInt min: -16")]
[DataRow(-1, DisplayName = "Negative: -1")]
[DataRow(0, DisplayName = "Zero: 0")]
[DataRow(1, DisplayName = "Small: 1")]
[DataRow(45, DisplayName = "Was buggy: 45")]
[DataRow(47, DisplayName = "TinyInt max: 47")]
[DataRow(48, DisplayName = "Above TinyInt: 48")]
[DataRow(66, DisplayName = "Was PropertySkip v2: 66")]
[DataRow(100, DisplayName = "Medium: 100")]
[DataRow(191, DisplayName = "Current PropertySkip value: 191")]
[DataRow(192, DisplayName = "TinyInt code start: 192")]
[DataRow(253, DisplayName = "Was PropertySkip v1: 253")]
[DataRow(255, DisplayName = "Max byte: 255")]
[DataRow(1000, DisplayName = "Large: 1000")]
[DataRow(int.MaxValue, DisplayName = "MaxValue")]
[DataRow(int.MinValue, DisplayName = "MinValue")]
public void Serialize_VariousIntIds_PropertySkipTest(int id)
{
var item = new TestHighReuseDto { Id = id, CategoryCode = "test" };
var binary = item.ToBinary();
var result = binary.BinaryTo<TestHighReuseDto>();
Assert.IsNotNull(result);
Assert.AreEqual(id, result.Id, $"Id should be {id} after deserialization");
}
}

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);
}
}

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