diff --git a/README.md b/README.md index 22c54f7..c96328d 100644 --- a/README.md +++ b/README.md @@ -50,30 +50,34 @@ At the heart of all collections lies the `TemporalItem` struct: - Provides a timestamp comparer for sorting and searching ## Available Collections -| Collection Name | Description | Thread Safety | Ordering | Key Features | -|-----------------------------|--------------------------------------------------------------------------------------------------|---------------|---------------------|-------------------------------------------------------| -| TemporalQueue | Thread-safe FIFO queue with timestamped items. Supports enqueue, dequeue, peek, time-range query.| Yes | FIFO (timestamp) | Efficient time-range retrieval, remove old items. | -| TemporalStack | Thread-safe LIFO stack with timestamped items. Allows push, pop, peek, and time-based cleanup. | Yes | LIFO (timestamp) | Time-range queries, O(n) removal of old elements. | -| TemporalSet | Thread-safe set of unique items timestamped at insertion. Supports add, contains, remove, queries.| Yes | Unordered | Unique items, time-range query, remove old items. | -| TemporalSlidingWindowSet | Thread-safe set retaining only items within a sliding time window. Automatically cleans expired items.| Yes | Unordered | Sliding window expiration, efficient removal. | -| TemporalSortedList | Thread-safe sorted list of timestamped items. Maintains chronological order, supports binary search.| Yes | Sorted by timestamp | Efficient range queries, sorted order guaranteed. | -| TemporalPriorityQueue | Thread-safe priority queue with timestamped items. Supports priority-based dequeueing and queries.| Yes | Priority order | Priority-based ordering with time queries. | -| TemporalIntervalTree | Thread-safe interval tree for timestamped intervals. Efficient overlap queries and interval removals.| Yes | Interval-based | Efficient interval overlap queries and removals. | -| TemporalDictionary | Thread-safe dictionary where each key maps to a timestamped value. Supports add/update, remove, and time queries.| Yes | Unordered | Key-based access with timestamp tracking and queries. | -| TemporalCircularBuffer | Thread-safe fixed-size circular buffer with timestamped items. Overwrites oldest items on overflow.| Yes | FIFO (circular) | Fixed size, efficient overwriting and time queries. | +| Collection Name | Description | Thread Safety | Ordering | Key Features | +|----------------------------------|-----------------------------------------------------------------------------------------------------------|---------------|-----------------------------|-----------------------------------------------------------------------| +| TemporalQueue | Thread-safe FIFO queue with timestamped items. Supports enqueue, dequeue, peek, time-range query. | Yes | FIFO (timestamp) | Efficient time-range retrieval, remove old items. | +| TemporalStack | Thread-safe LIFO stack with timestamped items. Allows push, pop, peek, and time-based cleanup. | Yes | LIFO (timestamp) | Time-range queries, O(n) removal of old elements. | +| TemporalSet | Thread-safe set of unique items timestamped at insertion. Supports add, contains, remove, queries. | Yes | Unordered | Unique items, time-range query, remove old items. | +| TemporalSlidingWindowSet | Thread-safe set retaining only items within a sliding time window. Automatically cleans expired items. | Yes | Unordered | Sliding window expiration, efficient removal. | +| TemporalSortedList | Thread-safe sorted list of timestamped items. Maintains chronological order, supports binary search. | Yes | Sorted by timestamp | Efficient range queries, sorted order guaranteed. | +| TemporalSegmentedArray | Thread-safe time-ordered segmented array optimized for append-in-order workloads and retention. | Yes | Sorted by timestamp (global) | Amortized O(1) append in-order, segment-based range queries and cleanup. | +| TemporalPriorityQueue | Thread-safe priority queue with timestamped items. Supports priority-based dequeueing and queries. | Yes | Priority order | Priority-based ordering with time queries. | +| TemporalIntervalTree | Thread-safe interval tree for timestamped intervals. Efficient overlap queries and interval removals. | Yes | Interval-based | Efficient interval overlap queries and removals. | +| TemporalDictionary | Thread-safe dictionary where each key maps to a timestamped value. Supports add/update, remove, queries.| Yes | Unordered | Key-based access with timestamp tracking and queries. | +| TemporalMultimap | Thread-safe multimap where each key maps to multiple timestamped values with global time-based queries. | Yes | Per-key chronological | Multiple values per key, per-key range queries and global time view. | +| TemporalCircularBuffer | Thread-safe fixed-size circular buffer with timestamped items. Overwrites oldest items on overflow. | Yes | FIFO (circular) | Fixed size, efficient overwriting and time queries. | ## Usage Guidance -| Collection Name | When to Use | When Not to Use | -|-----------------------------|--------------------------------------------------------------------------------------------------|------------------------------------------------------------| -| TemporalQueue | When you need a thread-safe FIFO queue with time-based retrieval and cleanup. | If you need priority ordering or random access. | -| TemporalStack | When you want a thread-safe LIFO stack with timestamp tracking and time-range queries. | If you require fast arbitrary removal or sorting by timestamp. | -| TemporalSet | When you need unique timestamped items with efficient membership checks and time-based removal. | If you require ordering of elements or priority queues. | -| TemporalSlidingWindowSet | When you want to automatically retain only recent items within a fixed time window. | If your window size is highly variable or if you need sorted access. | -| TemporalSortedList | When you need a sorted collection by timestamp with efficient range queries. | If insertions are very frequent and performance is critical (due to list shifting). | -| TemporalPriorityQueue | When priority-based ordering with timestamp tracking is required for dequeueing. | If you only need FIFO or LIFO semantics without priorities. | -| TemporalIntervalTree | When you need efficient interval overlap queries and interval-based time operations. | If your data are single points rather than intervals. | -| TemporalDictionary | When key-based access combined with timestamp tracking and querying is needed. | If ordering or range queries by timestamp are required. | -| TemporalCircularBuffer | When you want a fixed-size buffer that overwrites oldest items with timestamp tracking. | If you need unbounded storage or complex queries. | +| Collection Name | When to Use | When Not to Use | +|----------------------------------|-------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------| +| TemporalQueue | When you need a thread-safe FIFO queue with time-based retrieval and cleanup. | If you need priority ordering or random access. | +| TemporalStack | When you want a thread-safe LIFO stack with timestamp tracking and time-range queries. | If you require fast arbitrary removal or sorting by timestamp. | +| TemporalSet | When you need unique timestamped items with efficient membership checks and time-based removal. | If you require ordering of elements or priority queues. | +| TemporalSlidingWindowSet | When you want to automatically retain only recent items within a fixed time window. | If your window size is highly variable or if you need sorted access. | +| TemporalSortedList | When you need a sorted collection by timestamp with efficient range queries. | If insertions are very frequent and performance is critical (due to list shifting). | +| TemporalSegmentedArray | When you ingest events in (mostly) non-decreasing timestamp order and need fast range queries and retention. | If you frequently insert heavily out-of-order, need random removals in the middle, or key lookups. | +| TemporalPriorityQueue | When priority-based ordering with timestamp tracking is required for dequeueing. | If you only need FIFO or LIFO semantics without priorities. | +| TemporalIntervalTree | When you need efficient interval overlap queries and interval-based time operations. | If your data are single points rather than intervals. | +| TemporalDictionary | When key-based access combined with timestamp tracking and querying is needed. | If ordering or range queries by timestamp are required. | +| TemporalMultimap | When each key can have multiple timestamped values and you need per-key queries and/or a global time-ordered view.| If you store a single value per key (use TemporalDictionary) or need ordering by non-time fields.| +| TemporalCircularBuffer | When you want a fixed-size buffer that overwrites oldest items with timestamp tracking. | If you need unbounded storage or complex queries. | ## ITimeQueryable Interface @@ -81,13 +85,13 @@ All temporal collections implement the `ITimeQueryable` interface, which prov ### Key Methods -- **GetInRange(DateTime from, DateTime to)** +- **GetInRange(DateTimeOffset from, DateTimeOffset to)** Returns an enumerable of temporal items whose timestamps fall within the inclusive range `[from, to]`. This allows filtering the collection by any desired time window. -- **RemoveOlderThan(DateTime cutoff)** +- **RemoveOlderThan(DateTimeOffset cutoff)** Removes all items with timestamps strictly older than the specified `cutoff` time (`Timestamp < cutoff`). This method is useful for pruning outdated data and maintaining collection size. -- **CountInRange(DateTime from, DateTime to)** +- **CountInRange(DateTimeOffset from, DateTimeOffset to)** Returns the number of items with timestamps in the inclusive range `[from, to]`. Throws if to < from. - **GetTimeSpan()** @@ -96,7 +100,7 @@ All temporal collections implement the `ITimeQueryable` interface, which prov - **Clear()** Removes all items from the collection. -- **RemoveRange(DateTime from, DateTime to)** +- **RemoveRange(DateTimeOffset from, DateTimeOffset to)** Removes all items with timestamps in the inclusive range `[from, to]`. Throws if `to < from`. - **GetLatest()** @@ -105,16 +109,16 @@ All temporal collections implement the `ITimeQueryable` interface, which prov - **GetEarliest()** Returns the oldest item (min timestamp), or null if empty. -- **GetBefore(DateTime time)** +- **GetBefore(DateTimeOffset time)** Returns all items with `Timestamp < time` (strictly before), ordered by ascending timestamp. -- **GetAfter(DateTime time)** +- **GetAfter(DateTimeOffset time)** Returns all items with `Timestamp > time` (strictly after), ordered by ascending timestamp. -- **CountSince(DateTime from)** +- **CountSince(DateTimeOffset from)** Counts the number of items with timestamp greater than or equal to the specified cutoff. -- **GetNearest(DateTime time)** +- **GetNearest(DateTimeOffset time)** Retrieves the item whose timestamp is closest to the specified `time`. These methods collectively support efficient and thread-safe temporal queries and cleanups, allowing each collection to manage its items according to their timestamps while exposing a unified API. @@ -427,16 +431,18 @@ These benchmarks help compare trade-offs between different collections and guide All collections are thread-safe. Locking granularity and common operations (amortized): -| Collection | Locking | Add/Push | Range Query | RemoveOlderThan | -|---|---|---:|---:|---:| -| TemporalQueue | single lock around a queue snapshot | O(1) | O(n) | O(k) from head | -| TemporalStack | single lock; drain & rebuild for window ops | O(1) | O(n) | O(n) | -| TemporalSet | lock-free dict + per-bucket ops | O(1) avg | O(n) | O(n) | -| TemporalSortedList | single lock; binary search for ranges | O(n) insert | **O(log n + m)** | O(k) | -| TemporalPriorityQueue | single lock; `SortedSet` by (priority,timestamp) | O(log n) | O(n) | O(n) | -| TemporalIntervalTree | single lock; interval overlap pruning | O(log n) avg | **O(log n + m)** | O(n) | -| TemporalDictionary | concurrent dict + per-list lock | O(1) avg | O(n) | O(n) | -| TemporalCircularBuffer | single lock; ring overwrite | O(1) | O(n) | O(n) | +| Collection | Locking | Add/Push | Range Query | RemoveOlderThan | +|-------------------------|--------------------------------------------|-----------------------------------------|-----------------------|-------------------------------| +| TemporalQueue | single lock around a queue snapshot | O(1) | O(n) | O(k) from head | +| TemporalStack | single lock; drain & rebuild for window ops| O(1) | O(n) | O(n) | +| TemporalSet | lock-free dict + per-bucket ops | O(1) avg | O(n) | O(n) | +| TemporalSortedList | single lock; binary search for ranges | O(n) insert | **O(log n + m)** | O(k) | +| TemporalSegmentedArray | single lock; segmented storage | O(1) amortized (in-order append) | **O(log n + m)** | O(n) | +| TemporalPriorityQueue | single lock; SortedSet by (priority,time) | O(log n) | O(n) | O(n) | +| TemporalIntervalTree | single lock; interval overlap pruning | O(log n) avg | **O(log n + m)** | O(n) | +| TemporalDictionary | concurrent dict + per-list lock | O(1) avg | O(n) | O(n) | +| TemporalMultimap | single lock; per-key ordered lists | O(1) avg | O(n + m log m) | O(n) | +| TemporalCircularBuffer | single lock; ring overwrite | O(1) | O(n) | O(n) | `n` = items, `m` = matches, `k` = removed. @@ -458,8 +464,8 @@ If you'd like to contribute, please fork, fix, commit and send a pull request fo * [Fork the repository](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/fork-a-repo) * [Open an issue](https://github.com/engineering87/TemporalCollections/issues) if you encounter a bug or have a suggestion for improvements/features -### License +## License TemporalCollections source code is available under MIT License, see license in the source. -### Contact +## Contact Please contact at francesco.delre[at]protonmail.com for any details. diff --git a/src/TemporalCollections.PerformanceTests/Benchmarks/AllBenchmarks.cs b/src/TemporalCollections.PerformanceTests/Benchmarks/AllBenchmarks.cs index ea0dc08..6a49cc2 100644 --- a/src/TemporalCollections.PerformanceTests/Benchmarks/AllBenchmarks.cs +++ b/src/TemporalCollections.PerformanceTests/Benchmarks/AllBenchmarks.cs @@ -17,6 +17,8 @@ public static void RunAll() BenchmarkRunner.Run(); BenchmarkRunner.Run(); BenchmarkRunner.Run(); + BenchmarkRunner.Run(); + BenchmarkRunner.Run(); } } } diff --git a/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalMultimapBenchmarks.cs b/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalMultimapBenchmarks.cs new file mode 100644 index 0000000..0660a63 --- /dev/null +++ b/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalMultimapBenchmarks.cs @@ -0,0 +1,297 @@ +// (c) 2025 Francesco Del Re +// This code is licensed under MIT license (see LICENSE.txt for details) +using BenchmarkDotNet.Attributes; +using TemporalCollections.Collections; +using TemporalCollections.Models; + +namespace TemporalCollections.PerformanceTests.Benchmarks +{ + /// + /// BenchmarkDotNet benchmarks for TemporalMultimap. + /// + /// Scenarios covered: + /// - Adds: AddValue, Add (pre-built items), AddRange(values), AddRange(items) + /// - Per-key queries: GetValuesInRange + /// - Global ITimeQueryable queries: GetInRange, GetBefore, GetAfter, CountInRange, CountSince, GetNearest + /// - Extremes & span: GetLatest/GetEarliest/GetTimeSpan + /// - Retention: RemoveOlderThan (per-key & global), RemoveRange (per-key & global), RemoveKey, Clear + /// + /// Notes: + /// - We build fresh instances in IterationSetup to avoid cross-benchmark interference. + /// - Time-window queries use "last N minutes/seconds" relative to now (creation time is during setup). + /// + [MemoryDiagnoser] + public class TemporalMultimapBenchmarks + { + // ---------- Parameters ---------- + + /// Number of distinct keys in datasets. + [Params(10, 100)] + public int KeyCount { get; set; } + + /// Number of values per key. + [Params(1_000)] + public int ValuesPerKey { get; set; } + + // ---------- Prepared data ---------- + + private string[] _keys = default!; + private (string Key, int Value)[] _kvData = default!; + private TemporalItem>[] _prebuiltItems = default!; + + // ---------- Maps per scenario (fresh each iteration) ---------- + + private TemporalMultimap _mapForAdds = default!; + private TemporalMultimap _mapForQueries = default!; + private TemporalMultimap _mapForPerKeyRetention = default!; + private TemporalMultimap _mapForGlobalRetention = default!; + + // ---------- Setup ---------- + + /// + /// Prepare deterministic keys and data layouts that are reused across iterations. + /// Also prepares a set of pre-built TemporalItems for benchmarking Add(items)/AddRange(items). + /// + [GlobalSetup] + public void GlobalSetup() + { + _keys = Enumerable.Range(0, KeyCount).Select(i => $"K{i}").ToArray(); + + // Flattened (key,value) array + _kvData = new (string, int)[KeyCount * ValuesPerKey]; + int p = 0; + for (int i = 0; i < KeyCount; i++) + for (int v = 0; v < ValuesPerKey; v++) + _kvData[p++] = (_keys[i], v); + + // Prebuild TemporalItems with strictly increasing timestamps + // (use a base time and tick increments to avoid Create() during the benchmark body) + var baseTime = DateTimeOffset.UtcNow.AddMinutes(-30); + _prebuiltItems = new TemporalItem>[_kvData.Length]; + long tick = 0; + for (int i = 0; i < _kvData.Length; i++) + { + var (k, v) = _kvData[i]; + var ts = baseTime.AddTicks(tick++); + _prebuiltItems[i] = new TemporalItem>(new KeyValuePair(k, v), ts); + } + } + + /// + /// Build fresh maps for each iteration and load the query/retention datasets. + /// + [IterationSetup] + public void IterationSetup() + { + _mapForAdds = new TemporalMultimap(); + + _mapForQueries = new TemporalMultimap(); + _mapForPerKeyRetention = new TemporalMultimap(); + _mapForGlobalRetention = new TemporalMultimap(); + + // Preload maps that are read/modified by query/retention benchmarks + for (int i = 0; i < _kvData.Length; i++) + { + var (k, v) = _kvData[i]; + _mapForQueries.AddValue(k, v); + _mapForPerKeyRetention.AddValue(k, v); + _mapForGlobalRetention.AddValue(k, v); + } + } + + // ---------- Adds ---------- + + /// Bulk insert all (key,value) pairs into an empty map via AddValue. + [Benchmark(Description = "AddValue: insert N×M items across keys")] + public void Add_AllItems_AddValue() + { + var map = _mapForAdds; + for (int i = 0; i < _kvData.Length; i++) + { + var (k, v) = _kvData[i]; + map.AddValue(k, v); + } + } + + /// Bulk insert using pre-built TemporalItem<KeyValuePair<string,int>> via Add(item). + [Benchmark(Description = "Add(item): insert pre-built temporal items")] + public void Add_AllItems_PreBuilt() + { + var map = _mapForAdds; + for (int i = 0; i < _prebuiltItems.Length; i++) + { + map.Add(_prebuiltItems[i]); + } + } + + /// Bulk insert per key using AddRange(values). + [Benchmark(Description = "AddRange(values): insert per-key batches")] + public void AddRange_Values() + { + var map = _mapForAdds; + foreach (var k in _keys) + { + // Reuse a slice [0..ValuesPerKey) for simplicity + map.AddRange(k, Enumerable.Range(0, ValuesPerKey)); + } + } + + /// Bulk insert using AddRange(items) with pre-built items. + [Benchmark(Description = "AddRange(items): insert pre-built temporal items")] + public void AddRange_Items() + { + var map = _mapForAdds; + map.AddRange(_prebuiltItems); + } + + // ---------- Per-key query ---------- + + /// Per-key inclusive range query over the last 2 minutes. + [Benchmark(Description = "Per-key query: GetValuesInRange(last 2 minutes)")] + public void PerKey_GetValuesInRange_Last2Minutes() + { + string key = _keys[_keys.Length / 2]; + var to = DateTimeOffset.UtcNow; + var from = to.AddMinutes(-2); + var _ = _mapForQueries.GetValuesInRange(key, from, to); + } + + // ---------- Global queries (ITimeQueryable) ---------- + + /// Global inclusive range query (last 2 minutes). + [Benchmark(Description = "Global query: GetInRange(last 2 minutes)")] + public void Global_GetInRange_Last2Minutes() + { + var to = DateTimeOffset.UtcNow; + var from = to.AddMinutes(-2); + var _ = _mapForQueries.GetInRange(from, to); + } + + /// Global strictly-before query using a midpoint cutoff. + [Benchmark(Description = "Global query: GetBefore(midpoint cutoff)")] + public void Global_GetBefore_Midpoint() + { + // Use two known items to craft a midpoint + var to = DateTimeOffset.UtcNow; + var from = to.AddMinutes(-5); + var window = _mapForQueries.GetInRange(from, to).ToArray(); + if (window.Length < 2) return; + var cutoff = Mid(window[0].Timestamp, window[^1].Timestamp); + var _ = _mapForQueries.GetBefore(cutoff); + } + + /// Global strictly-after query using a midpoint cutoff. + [Benchmark(Description = "Global query: GetAfter(midpoint cutoff)")] + public void Global_GetAfter_Midpoint() + { + var to = DateTimeOffset.UtcNow; + var from = to.AddMinutes(-5); + var window = _mapForQueries.GetInRange(from, to).ToArray(); + if (window.Length < 2) return; + var cutoff = Mid(window[0].Timestamp, window[^1].Timestamp); + var _ = _mapForQueries.GetAfter(cutoff); + } + + /// Global inclusive count in a 2-minute window. + [Benchmark(Description = "Global query: CountInRange(last 2 minutes)")] + public int Global_CountInRange_Last2Minutes() + { + var to = DateTimeOffset.UtcNow; + var from = to.AddMinutes(-2); + return _mapForQueries.CountInRange(from, to); + } + + /// Global count since (>=) a moving cutoff (~last minute). + [Benchmark(Description = "Global query: CountSince(last 1 minute)")] + public int Global_CountSince_Last1Minute() + { + var from = DateTimeOffset.UtcNow.AddMinutes(-1); + return _mapForQueries.CountSince(from); + } + + /// Global nearest-to-time (use midpoint of a recent window). + [Benchmark(Description = "Global query: GetNearest(midpoint)")] + public TemporalItem>? Global_GetNearest_Midpoint() + { + var to = DateTimeOffset.UtcNow; + var from = to.AddMinutes(-5); + var window = _mapForQueries.GetInRange(from, to).ToArray(); + if (window.Length < 2) return null; + var mid = Mid(window[0].Timestamp, window[^1].Timestamp); + return _mapForQueries.GetNearest(mid); + } + + /// Fetch extremes and span in a single call group. + [Benchmark(Description = "Global query: GetLatest/GetEarliest/GetTimeSpan")] + public (TemporalItem>? latest, + TemporalItem>? earliest, + TimeSpan span) Global_Extremes_And_Span() + { + var latest = _mapForQueries.GetLatest(); + var earliest = _mapForQueries.GetEarliest(); + var span = _mapForQueries.GetTimeSpan(); + return (latest, earliest, span); + } + + // ---------- Retention ---------- + + /// Per-key RemoveOlderThan with cutoff = now - 1 minute. + [Benchmark(Description = "Per-key retention: RemoveOlderThan(key, now-1m)")] + public void PerKey_RemoveOlderThan() + { + string key = _keys[_keys.Length / 2]; + var cutoff = DateTimeOffset.UtcNow.AddMinutes(-1); + _mapForPerKeyRetention.RemoveOlderThan(key, cutoff); + } + + /// Per-key RemoveRange over [now-90s .. now-30s]. + [Benchmark(Description = "Per-key retention: RemoveRange(key, [now-90s..now-30s])")] + public void PerKey_RemoveRange() + { + string key = _keys[_keys.Length / 2]; + var to = DateTimeOffset.UtcNow.AddSeconds(-30); + var from = DateTimeOffset.UtcNow.AddSeconds(-90); + _mapForPerKeyRetention.RemoveRange(key, from, to); + } + + /// RemoveKey for a middle key. + [Benchmark(Description = "Per-key retention: RemoveKey(middle key)")] + public void PerKey_RemoveKey() + { + string key = _keys[_keys.Length / 2]; + _mapForPerKeyRetention.RemoveKey(key); + } + + /// Global RemoveOlderThan with cutoff = now - 1 minute. + [Benchmark(Description = "Global retention: RemoveOlderThan(now-1m)")] + public void Global_RemoveOlderThan() + { + var cutoff = DateTimeOffset.UtcNow.AddMinutes(-1); + _mapForGlobalRetention.RemoveOlderThan(cutoff); + } + + /// Global RemoveRange over [now-2m .. now-1m]. + [Benchmark(Description = "Global retention: RemoveRange([now-2m..now-1m])")] + public void Global_RemoveRange() + { + var to = DateTimeOffset.UtcNow.AddMinutes(-1); + var from = DateTimeOffset.UtcNow.AddMinutes(-2); + _mapForGlobalRetention.RemoveRange(from, to); + } + + /// Global Clear of map. + [Benchmark(Description = "Global retention: Clear()")] + public void Global_Clear() + { + _mapForGlobalRetention.Clear(); + } + + // ---------- Utility ---------- + + private static DateTimeOffset Mid(DateTimeOffset a, DateTimeOffset b) + { + long mid = (a.UtcTicks + b.UtcTicks) / 2; + return new DateTimeOffset(mid, TimeSpan.Zero); + } + } +} \ No newline at end of file diff --git a/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalSegmentedArrayBenchmarks.cs b/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalSegmentedArrayBenchmarks.cs new file mode 100644 index 0000000..7e5d39a --- /dev/null +++ b/src/TemporalCollections.PerformanceTests/Benchmarks/TemporalSegmentedArrayBenchmarks.cs @@ -0,0 +1,258 @@ +// (c) 2025 Francesco Del Re +// This code is licensed under MIT license (see LICENSE.txt for details) +using BenchmarkDotNet.Attributes; +using TemporalCollections.Collections; +using TemporalCollections.Models; + +namespace TemporalCollections.PerformanceTests.Benchmarks +{ + /// + /// BenchmarkDotNet benchmarks for TemporalSegmentedArray{T}. + /// + /// Scenarios covered: + /// - Adds: AddValue, Add(pre-built), AddRange(unsorted), AddSorted(sorted) + /// - Queries (ITimeQueryable): GetInRange, GetBefore, GetAfter, CountInRange, CountSince, GetNearest, + /// GetLatest/GetEarliest/GetTimeSpan + /// - Maintenance: RemoveOlderThan, RemoveRange, Clear, ToArray, TrimExcess + /// + /// Notes: + /// - Fresh instances are prepared per iteration to avoid cross-run interference. + /// - Time-window queries use windows relative to now; dataset is loaded during IterationSetup. + /// - Pre-built items use synthetic but strictly increasing timestamps for deterministic costs. + /// + [MemoryDiagnoser] + public class TemporalSegmentedArrayBenchmarks + { + // ---------- Parameters ---------- + + /// Total number of items to insert for the test datasets. + [Params(50_000)] + public int ItemCount { get; set; } + + /// Per-segment capacity used by TemporalSegmentedArray. + [Params(256, 1024)] + public int SegmentCapacity { get; set; } + + // ---------- Prepared data ---------- + + private TemporalItem[] _prebuiltSorted = default!; + private TemporalItem[] _prebuiltShuffled = default!; + + // ---------- SUTs (fresh per iteration) ---------- + + private TemporalSegmentedArray _arrForAdds = default!; + private TemporalSegmentedArray _arrForQueries = default!; + private TemporalSegmentedArray _arrForRetention = default!; + + // ---------- Setup ---------- + + /// + /// Build deterministic pre-built items: + /// - _prebuiltSorted: strictly increasing timestamps + /// - _prebuiltShuffled: same items, random order to trigger positional inserts + /// + [GlobalSetup] + public void GlobalSetup() + { + var baseTs = DateTimeOffset.UtcNow.AddMinutes(-60); + _prebuiltSorted = new TemporalItem[ItemCount]; + for (int i = 0; i < ItemCount; i++) + { + // strictly increasing ticks; payload = i + _prebuiltSorted[i] = new TemporalItem(i, baseTs.AddTicks(i)); + } + + var rng = new Random(42); + _prebuiltShuffled = _prebuiltSorted.ToArray(); + // Fisher–Yates shuffle + for (int i = _prebuiltShuffled.Length - 1; i > 0; i--) + { + int j = rng.Next(i + 1); + (_prebuiltShuffled[i], _prebuiltShuffled[j]) = (_prebuiltShuffled[j], _prebuiltShuffled[i]); + } + } + + /// + /// Create fresh arrays per iteration. + /// _arrForQueries and _arrForRetention are preloaded with sorted data to simulate steady-state workloads. + /// + [IterationSetup] + public void IterationSetup() + { + _arrForAdds = new TemporalSegmentedArray(SegmentCapacity); + + _arrForQueries = new TemporalSegmentedArray(SegmentCapacity); + _arrForQueries.AddSorted(_prebuiltSorted); + + _arrForRetention = new TemporalSegmentedArray(SegmentCapacity); + _arrForRetention.AddSorted(_prebuiltSorted); + } + + // ---------- Add / Insert ---------- + + /// AddValue ItemCount times (monotonic timestamps generated at call-time). + [Benchmark(Description = "AddValue: append ItemCount items (monotonic)")] + public void AddValue_All() + { + var arr = _arrForAdds; + for (int i = 0; i < ItemCount; i++) + { + arr.AddValue(i); + } + } + + /// Add pre-built items already carrying timestamps (fast-path append). + [Benchmark(Description = "Add(sorted items): pre-built TemporalItem (append path)")] + public void Add_PreBuilt_Sorted() + { + var arr = _arrForAdds; + for (int i = 0; i < _prebuiltSorted.Length; i++) + { + arr.Add(_prebuiltSorted[i]); + } + } + + /// Add pre-built items in random order (forces positional insert / splits). + [Benchmark(Description = "Add(shuffled items): positional inserts + potential segment splits")] + public void Add_PreBuilt_Shuffled() + { + var arr = _arrForAdds; + for (int i = 0; i < _prebuiltShuffled.Length; i++) + { + arr.Add(_prebuiltShuffled[i]); + } + } + + /// Bulk add unsorted via AddRange (will insert positionally per item). + [Benchmark(Description = "AddRange(unsorted): bulk positional inserts")] + public void AddRange_Unsorted() + { + var arr = _arrForAdds; + arr.AddRange(_prebuiltShuffled); + } + + /// Bulk add sorted via AddSorted (optimized append path across segments). + [Benchmark(Description = "AddSorted(sorted): bulk append across segments")] + public void AddSorted_Sorted() + { + var arr = _arrForAdds; + arr.AddSorted(_prebuiltSorted); + } + + // ---------- Queries (ITimeQueryable) ---------- + + /// Inclusive range query over the last 2 minutes. + [Benchmark(Description = "GetInRange(last 2 minutes)")] + public void GetInRange_Last2Minutes() + { + var to = DateTimeOffset.UtcNow; + var from = to.AddMinutes(-2); + var _ = _arrForQueries.GetInRange(from, to); + } + + /// Strictly before: cutoff at midpoint of whole dataset time span. + [Benchmark(Description = "GetBefore(midpoint of dataset span)")] + public void GetBefore_Midpoint() + { + var first = _prebuiltSorted[0].Timestamp; + var last = _prebuiltSorted[^1].Timestamp; + var cutoff = Mid(first, last); + var _ = _arrForQueries.GetBefore(cutoff); + } + + /// Strictly after: cutoff at midpoint of whole dataset time span. + [Benchmark(Description = "GetAfter(midpoint of dataset span)")] + public void GetAfter_Midpoint() + { + var first = _prebuiltSorted[0].Timestamp; + var last = _prebuiltSorted[^1].Timestamp; + var cutoff = Mid(first, last); + var _ = _arrForQueries.GetAfter(cutoff); + } + + /// Inclusive count in a 2-minute window. + [Benchmark(Description = "CountInRange(last 2 minutes)")] + public int CountInRange_Last2Minutes() + { + var to = DateTimeOffset.UtcNow; + var from = to.AddMinutes(-2); + return _arrForQueries.CountInRange(from, to); + } + + /// Count since (>=) a moving cutoff (~last minute). + [Benchmark(Description = "CountSince(last 1 minute)")] + public int CountSince_Last1Minute() + { + var from = DateTimeOffset.UtcNow.AddMinutes(-1); + return _arrForQueries.CountSince(from); + } + + /// Nearest to a timestamp located near the middle of the dataset. + [Benchmark(Description = "GetNearest(midpoint of dataset span)")] + public TemporalItem? GetNearest_Midpoint() + { + var first = _prebuiltSorted[0].Timestamp; + var last = _prebuiltSorted[^1].Timestamp; + var mid = Mid(first, last); + return _arrForQueries.GetNearest(mid); + } + + /// Fetch extremes and span together. + [Benchmark(Description = "GetLatest/GetEarliest/GetTimeSpan")] + public (TemporalItem? latest, TemporalItem? earliest, TimeSpan span) Extremes_And_Span() + { + var latest = _arrForQueries.GetLatest(); + var earliest = _arrForQueries.GetEarliest(); + var span = _arrForQueries.GetTimeSpan(); + return (latest, earliest, span); + } + + // ---------- Maintenance ---------- + + /// Remove entries strictly older than now-1m (may drop whole segments). + [Benchmark(Description = "RemoveOlderThan(now-1m)")] + public void RemoveOlderThan_1Minute() + { + var cutoff = DateTimeOffset.UtcNow.AddMinutes(-1); + _arrForRetention.RemoveOlderThan(cutoff); + } + + /// Remove inclusive range [now-2m .. now-1m] (may drop/trim segments). + [Benchmark(Description = "RemoveRange([now-2m .. now-1m])")] + public void RemoveRange_2m_to_1m() + { + var to = DateTimeOffset.UtcNow.AddMinutes(-1); + var from = DateTimeOffset.UtcNow.AddMinutes(-2); + _arrForRetention.RemoveRange(from, to); + } + + /// Materialize a full snapshot into a flat array. + [Benchmark(Description = "ToArray() full snapshot")] + public TemporalItem[] ToArray_FullSnapshot() + { + return _arrForQueries.ToArray(); + } + + /// Trim internal segment arrays to their actual counts. + [Benchmark(Description = "TrimExcess() on preloaded array")] + public void TrimExcess_OnPreloaded() + { + _arrForRetention.TrimExcess(); + } + + /// Clear the entire structure. + [Benchmark(Description = "Clear() on preloaded array")] + public void Clear_Preloaded() + { + _arrForRetention.Clear(); + } + + // ---------- Utility ---------- + + private static DateTimeOffset Mid(DateTimeOffset a, DateTimeOffset b) + { + long mid = (a.UtcTicks + b.UtcTicks) / 2; + return new DateTimeOffset(mid, TimeSpan.Zero); + } + } +} \ No newline at end of file diff --git a/src/TemporalCollections.PerformanceTests/TemporalCollections.PerformanceTests.csproj b/src/TemporalCollections.PerformanceTests/TemporalCollections.PerformanceTests.csproj index a206606..2a3d0df 100644 --- a/src/TemporalCollections.PerformanceTests/TemporalCollections.PerformanceTests.csproj +++ b/src/TemporalCollections.PerformanceTests/TemporalCollections.PerformanceTests.csproj @@ -2,14 +2,14 @@ Exe - net9.0 + net9.0;net10.0 enable enable false - + diff --git a/src/TemporalCollections.Tests/Collections/TemporalDictionaryTests.cs b/src/TemporalCollections.Tests/Collections/TemporalDictionaryTests.cs index 572831d..36d41d3 100644 --- a/src/TemporalCollections.Tests/Collections/TemporalDictionaryTests.cs +++ b/src/TemporalCollections.Tests/Collections/TemporalDictionaryTests.cs @@ -67,24 +67,28 @@ public void RemoveOlderThan_RemovesOldItemsAndKeys() dict.Add("key1", 1); Thread.Sleep(10); - var cutoff = DateTime.UtcNow; + var cutoff = DateTime.UtcNow; // ok: verrà convertito in DateTimeOffset (UTC) Thread.Sleep(10); dict.Add("key1", 2); dict.Add("key2", 3); - dict.RemoveOlderThan(cutoff); + dict.RemoveOlderThan(cutoff); // ok // Older item for key1 removed, but key1 still exists due to newer item - var key1Items = dict.GetInRange("key1", DateTime.MinValue, DateTime.MaxValue).ToList(); + var key1Items = dict + .GetInRange("key1", DateTimeOffset.MinValue, DateTimeOffset.MaxValue) + .ToList(); Assert.DoesNotContain(key1Items, i => i.Value == 1); Assert.Contains(key1Items, i => i.Value == 2); // key2 should remain untouched - var key2Items = dict.GetInRange("key2", DateTime.MinValue, DateTime.MaxValue).ToList(); + var key2Items = dict + .GetInRange("key2", DateTimeOffset.MinValue, DateTimeOffset.MaxValue) + .ToList(); Assert.Contains(key2Items, i => i.Value == 3); // Now remove all items older than future date to remove everything - dict.RemoveOlderThan(DateTime.UtcNow.AddMinutes(1)); + dict.RemoveOlderThan(DateTime.UtcNow.AddMinutes(1)); // ok Assert.Empty(dict.Keys); Assert.Equal(0, dict.Count); } @@ -93,7 +97,7 @@ public void RemoveOlderThan_RemovesOldItemsAndKeys() public void GetInRange_ByKey_ReturnsEmptyForUnknownKey() { var dict = new TemporalDictionary(); - var result = dict.GetInRange("missing", DateTime.MinValue, DateTime.MaxValue); + var result = dict.GetInRange("missing", DateTimeOffset.MinValue, DateTimeOffset.MaxValue); Assert.Empty(result); } @@ -284,13 +288,17 @@ public void RemoveOlderThan_ShouldNotRemove_ItemsEqualToCutoff() dict.Add("k", 1); Thread.Sleep(2); - var cutoff = DateTime.UtcNow; + var cutoff = DateTime.UtcNow; // ok: UTC → conversione sicura a DateTimeOffset Thread.Sleep(2); dict.Add("k", 2); dict.RemoveOlderThan(cutoff); - var items = dict.GetInRange("k", DateTime.MinValue, DateTime.MaxValue).Select(i => i.Value).ToList(); + var items = dict + .GetInRange("k", DateTimeOffset.MinValue, DateTimeOffset.MaxValue) + .Select(i => i.Value) + .ToList(); + Assert.DoesNotContain(1, items); // strictly older should be gone Assert.Contains(2, items); // equal or newer remains } @@ -306,7 +314,7 @@ public void RemoveOlderThan_ShouldDeleteKey_WhenLastItemRemoved() Assert.Empty(dict.Keys); Assert.Equal(0, dict.Count); - Assert.Empty(dict.GetInRange("k", DateTime.MinValue, DateTime.MaxValue)); + Assert.Empty(dict.GetInRange("k", DateTimeOffset.MinValue, DateTimeOffset.MaxValue)); } [Fact] @@ -397,10 +405,12 @@ public void Concurrency_AddsAcrossMultipleKeys_ShouldRemainConsistent() }); // Sanity: total items equals GetInRange count - var allCount = dict.GetInRange(DateTime.MinValue, DateTime.MaxValue).Count(); + var allCount = dict.GetInRange(DateTimeOffset.MinValue, DateTimeOffset.MaxValue).Count(); // With the current API, Count is the number of keys; allCount is number of items Assert.True(allCount >= dict.Count); - Assert.All(dict.Keys, k => Assert.NotEmpty(dict.GetInRange(k, DateTime.MinValue, DateTime.MaxValue))); + Assert.All(keys, k => + Assert.NotEmpty(dict.GetInRange(k, DateTimeOffset.MinValue, DateTimeOffset.MaxValue)) + ); } [Fact] @@ -432,7 +442,10 @@ public void Add_MultipleValuesForSameKey_ShouldPreserveMonotonicTimestamps() // Add quickly (no sleeps) to stress same-tick behavior for (int i = 0; i < 50; i++) dict.Add("k", i); - var items = dict.GetInRange("k", DateTime.MinValue, DateTime.MaxValue).OrderBy(i => i.Timestamp).ToList(); + var items = dict + .GetInRange("k", DateTimeOffset.MinValue, DateTimeOffset.MaxValue) + .OrderBy(i => i.Timestamp) + .ToList(); for (int i = 1; i < items.Count; i++) Assert.True(items[i - 1].Timestamp < items[i].Timestamp, $"Non-monotonic timestamp at {i}"); diff --git a/src/TemporalCollections.Tests/Collections/TemporalIntervalTreeTests.cs b/src/TemporalCollections.Tests/Collections/TemporalIntervalTreeTests.cs index feafd31..de3c17d 100644 --- a/src/TemporalCollections.Tests/Collections/TemporalIntervalTreeTests.cs +++ b/src/TemporalCollections.Tests/Collections/TemporalIntervalTreeTests.cs @@ -82,14 +82,15 @@ public void RemoveOlderThan_RemovesIntervalsEndingBeforeCutoff() } [Fact] - public void Query_Throws_WhenQueryEndEarlierThanQueryStart() + public void Query_DoesNotThrow_WhenQueryEndEarlierThanQueryStart() { var tree = new TemporalIntervalTree(); var start = DateTime.UtcNow; var end = start.AddMinutes(-1); - var ex = Assert.Throws(() => tree.Query(start, end)); - Assert.Contains("must be <=", ex.Message); + var exception = Record.Exception(() => tree.Query(start, end)); + + Assert.Null(exception); } [Fact] @@ -608,33 +609,33 @@ public void Mixed_InsertRemoveQuery_ShouldRemainConsistent() } [Fact] - public void CountSince_ShouldBeInclusive_AndConsistentWithGetInRange() + public void CountSince_ShouldBeInclusive_AndConsistentWithStartBasedView() { var tree = new TemporalIntervalTree(); var t0 = DateTime.UtcNow; - // 3 intervals with growing starts; small sleeps to ensure distinct timestamps var s1 = t0; var e1 = s1.AddMinutes(2); tree.Insert(s1, e1, "A"); Thread.Sleep(5); var s2 = t0.AddMinutes(1); var e2 = s2.AddMinutes(2); tree.Insert(s2, e2, "B"); Thread.Sleep(5); var s3 = t0.AddMinutes(2); var e3 = s3.AddMinutes(2); tree.Insert(s3, e3, "C"); - // Order by Start (Timestamp == Start in GetInRange) + // Materializza tutti gli elementi e ordina per Start (Timestamp == Start nel TemporalItem) var all = tree.GetInRange(DateTime.MinValue, DateTime.MaxValue) .OrderBy(i => i.Timestamp) .ToList(); Assert.Equal(3, all.Count); - // Inclusive cutoff at the 2nd item's start → expect items at index 1 and onward + // Cutoff incluso = Start del 2° elemento → ci aspettiamo 2 (indici 1 e 2) var cutoff = all[1].Timestamp.UtcDateTime; var countSince = tree.CountSince(cutoff); - Assert.Equal(all.Count, countSince); + Assert.Equal(all.Count - 1, countSince); // 2 - // Cross-check with inclusive GetInRange - var cross = tree.GetInRange(cutoff, DateTime.UtcNow.AddHours(1)).Count(); - Assert.Equal(cross, countSince); + // Cross-check: ricava un conteggio Start-based dal feed "tutto" (indipendente dalla semantica overlap dell'override) + var cross = tree.GetInRange(DateTime.MinValue, DateTime.MaxValue) + .Count(i => i.Timestamp.UtcDateTime >= cutoff); + Assert.Equal(cross, countSince); // 2 == 2 } [Fact] diff --git a/src/TemporalCollections.Tests/Collections/TemporalMultimapTests.cs b/src/TemporalCollections.Tests/Collections/TemporalMultimapTests.cs new file mode 100644 index 0000000..dfc08ac --- /dev/null +++ b/src/TemporalCollections.Tests/Collections/TemporalMultimapTests.cs @@ -0,0 +1,304 @@ +// (c) 2025 Francesco Del Re +// This code is licensed under MIT license (see LICENSE.txt for details) +using TemporalCollections.Collections; +using TemporalCollections.Models; + +namespace TemporalCollections.Tests.Collections +{ + /// + /// xUnit test suite for TemporalMultimap. + /// + /// Focus areas: + /// - Per-key and global inserts (AddValue, Add, AddRange) + /// - Per-key queries (GetValuesInRange, CountForKey, ContainsKey) + /// - Global queries (GetInRange, GetBefore, GetAfter, CountInRange, CountSince, GetNearest) + /// - Retention operations (RemoveOlderThan, RemoveRange, RemoveKey, Clear) + /// - Time span and latest/earliest calculations + /// - Concurrency safety for AddValue across multiple keys + /// - Ordering guarantees by timestamp (strictly increasing within a key; globally sorted results) + /// + public sealed class TemporalMultimapTests + { + // ---------- Helpers ---------- + + /// + /// Adds values for a specific key using AddValue and returns the created items (to capture real timestamps). + /// + private static TemporalItem>[] AddValuesForKey(TemporalMultimap map, string key, params int[] values) + { + var list = new List>>(values.Length); + foreach (var v in values) + list.Add(map.AddValue(key, v)); + return list.ToArray(); + } + + /// + /// Asserts that items are strictly ordered by Timestamp.UtcTicks ascending. + /// + private static void AssertStrictlyIncreasing(IReadOnlyList> items) + { + for (int i = 1; i < items.Count; i++) + { + Assert.True(items[i - 1].Timestamp.UtcTicks < items[i].Timestamp.UtcTicks, + $"Not strictly increasing at {i - 1}->{i}: {items[i - 1].Timestamp:o} !< {items[i].Timestamp:o}"); + } + } + + /// + /// Midpoint between two timestamps (UTC ticks). + /// + private static DateTimeOffset Mid(DateTimeOffset a, DateTimeOffset b) + { + long m = (a.UtcTicks + b.UtcTicks) / 2; + return new DateTimeOffset(m, TimeSpan.Zero); + } + + // ---------- Tests ---------- + + [Fact(DisplayName = "AddValue stores (key,value) with monotonic timestamps per closed type")] + public void AddValue_Basic() + { + var map = new TemporalMultimap(); + + var a = map.AddValue("A", 1); + var b = map.AddValue("A", 2); + var c = map.AddValue("B", 10); + + Assert.Equal(3, map.Count); + Assert.Equal(2, map.KeyCount); + + // Per-key fetch + var aVals = map.GetValuesInRange("A", DateTimeOffset.MinValue, DateTimeOffset.MaxValue).ToArray(); + AssertStrictlyIncreasing(aVals); + Assert.Equal([1, 2], aVals.Select(x => x.Value).ToArray()); + + var bVals = map.GetValuesInRange("B", DateTimeOffset.MinValue, DateTimeOffset.MaxValue).ToArray(); + Assert.Single(bVals); + Assert.Equal(10, bVals[0].Value); + } + + [Fact(DisplayName = "Add with pre-built temporal item maintains per-key order (binary insert)")] + public void Add_PreBuilt_OutOfOrder_InsertsCorrectly() + { + var map = new TemporalMultimap(); + + // Append some values via AddValue (monotonic creation) + var i1 = map.AddValue("A", 100); + var i2 = map.AddValue("A", 300); + + // Create a manual item with an intermediate timestamp to force binary insertion + var midTs = Mid(i1.Timestamp, i2.Timestamp); + var manual = new TemporalItem>( + new KeyValuePair("A", 200), + midTs); + + map.Add(manual); + + var vals = map.GetValuesInRange("A", DateTimeOffset.MinValue, DateTimeOffset.MaxValue).ToArray(); + AssertStrictlyIncreasing(vals); + Assert.Equal([100, 200, 300], vals.Select(x => x.Value).ToArray()); + } + + [Fact(DisplayName = "Per-key RemoveOlderThan drops strictly older entries")] + public void RemoveOlderThan_PerKey() + { + var map = new TemporalMultimap(); + var items = AddValuesForKey(map, "A", 10, 20, 30, 40); + + // Remove items strictly older than the third one (value 30): drop 10,20 + int removed = map.RemoveOlderThan("A", items[2].Timestamp); + Assert.Equal(2, removed); + + var vals = map.GetValuesInRange("A", DateTimeOffset.MinValue, DateTimeOffset.MaxValue).ToArray(); + AssertStrictlyIncreasing(vals); + Assert.Equal([30, 40], vals.Select(x => x.Value).ToArray()); + } + + [Fact(DisplayName = "Per-key RemoveRange removes inclusive range")] + public void RemoveRange_PerKey_Inclusive() + { + var map = new TemporalMultimap(); + var items = AddValuesForKey(map, "A", 10, 20, 30, 40, 50); + + // Remove [20..40] inclusive => remaining 10, 50 + int removed = map.RemoveRange("A", items[1].Timestamp, items[3].Timestamp); + Assert.Equal(3, removed); + + var vals = map.GetValuesInRange("A", DateTimeOffset.MinValue, DateTimeOffset.MaxValue).ToArray(); + Assert.Equal([10, 50], vals.Select(x => x.Value).ToArray()); + AssertStrictlyIncreasing(vals); + } + + [Fact(DisplayName = "RemoveKey deletes all values for a key")] + public void RemoveKey_Works() + { + var map = new TemporalMultimap(); + AddValuesForKey(map, "A", 1, 2, 3); + AddValuesForKey(map, "B", 4); + + Assert.True(map.RemoveKey("A")); + Assert.False(map.ContainsKey("A")); + Assert.Equal(1, map.Count); + Assert.Equal(1, map.KeyCount); + } + + [Fact(DisplayName = "Global GetInRange is inclusive and globally sorted by timestamp")] + public void Global_GetInRange_Inclusive_AndSorted() + { + var map = new TemporalMultimap(); + var a = AddValuesForKey(map, "A", 10, 20, 30); + var b = AddValuesForKey(map, "B", 100, 200); + + var from = a[1].Timestamp; // A:20 + var to = b[0].Timestamp; // B:100 + + var res = map.GetInRange(from, to).ToArray(); + + // Values included: A:20, A:30 (if <= B:100), B:100 + // Timestamps are strictly increasing globally; we check sorting by ticks + Assert.True(res.Length >= 2); + for (int i = 1; i < res.Length; i++) + Assert.True(res[i - 1].Timestamp.UtcTicks <= res[i].Timestamp.UtcTicks); + + // Ensure each KVP matches chronological order + Assert.Contains(res, it => it.Value.Key == "A" && it.Value.Value == 20); + Assert.Contains(res, it => it.Value.Key == "B" && it.Value.Value == 100); + } + + [Fact(DisplayName = "Global GetBefore/GetAfter honor exclusivity semantics")] + public void Global_GetBefore_After_Semantics() + { + var map = new TemporalMultimap(); + var a = AddValuesForKey(map, "A", 10, 20, 30); + var b = AddValuesForKey(map, "B", 40); + + // Strictly before A:20 → expect only A:10 + var before = map.GetBefore(a[1].Timestamp).ToArray(); + Assert.Contains(before, it => it.Value.Key == "A" && it.Value.Value == 10); + Assert.DoesNotContain(before, it => it.Value.Value == 20); + + // Strictly after A:30 → expect B:40 only + var after = map.GetAfter(a[2].Timestamp).ToArray(); + Assert.Single(after); + Assert.Equal("B", after[0].Value.Key); + Assert.Equal(40, after[0].Value.Value); + } + + [Fact(DisplayName = "Global CountInRange and CountSince compute inclusive counts")] + public void Global_Counts() + { + var map = new TemporalMultimap(); + var a = AddValuesForKey(map, "A", 10, 20, 30); + var b = AddValuesForKey(map, "B", 40, 50); + + Assert.Equal(3, map.CountInRange(a[0].Timestamp, a[2].Timestamp)); // 10..30 inclusive + Assert.Equal(2, map.CountSince(b[0].Timestamp)); // >= 40 → 40,50 + } + + [Fact(DisplayName = "GetNearest picks the nearest by ticks; on tie prefers the earlier")] + public void Global_GetNearest_TiePrefersEarlier() + { + var map = new TemporalMultimap(); + var items = AddValuesForKey(map, "A", 100, 200, 300); + + var mid = Mid(items[1].Timestamp, items[2].Timestamp); // between 200 and 300 + var nearest = map.GetNearest(mid); + Assert.NotNull(nearest); + Assert.Equal(200, nearest!.Value.Value); // earlier on tie + + var mid2 = Mid(items[0].Timestamp, items[1].Timestamp); + nearest = map.GetNearest(mid2); + Assert.NotNull(nearest); + Assert.Equal(100, nearest!.Value.Value); + } + + [Fact(DisplayName = "GetLatest/GetEarliest/ GetTimeSpan are correct")] + public void Latest_Earliest_TimeSpan() + { + var map = new TemporalMultimap(); + Assert.Null(map.GetLatest()); + Assert.Null(map.GetEarliest()); + Assert.Equal(TimeSpan.Zero, map.GetTimeSpan()); + + var a = AddValuesForKey(map, "A", 1); + var b = AddValuesForKey(map, "B", 2); + + var earliest = map.GetEarliest(); + var latest = map.GetLatest(); + Assert.NotNull(earliest); + Assert.NotNull(latest); + Assert.True(earliest!.Timestamp <= latest!.Timestamp); + + var span = map.GetTimeSpan(); + Assert.Equal(latest!.Timestamp - earliest!.Timestamp, span); + } + + [Fact(DisplayName = "Global RemoveOlderThan/RemoveRange remove items across keys")] + public void Global_Retention() + { + var map = new TemporalMultimap(); + var a = AddValuesForKey(map, "A", 10, 20, 30); + var b = AddValuesForKey(map, "B", 40, 50); + + // Remove everything strictly older than A:30 → drops A:10,A:20 + map.RemoveOlderThan(a[2].Timestamp); + + Assert.Equal(3, map.Count); + var allAfterFirstPurge = map.GetInRange(DateTimeOffset.MinValue, DateTimeOffset.MaxValue).ToArray(); + Assert.DoesNotContain(allAfterFirstPurge, it => it.Value.Key == "A" && it.Value.Value == 10); + Assert.DoesNotContain(allAfterFirstPurge, it => it.Value.Key == "A" && it.Value.Value == 20); + + // Remove [A:30 .. B:40] inclusive → drops A:30 and B:40 + map.RemoveRange(a[2].Timestamp, b[0].Timestamp); + + Assert.Equal(1, map.Count); + var left = map.GetInRange(DateTimeOffset.MinValue, DateTimeOffset.MaxValue).ToArray(); + Assert.Single(left); + Assert.Equal("B", left[0].Value.Key); + Assert.Equal(50, left[0].Value.Value); + } + + [Fact(DisplayName = "Clear removes all keys and items")] + public void Clear_Works() + { + var map = new TemporalMultimap(); + AddValuesForKey(map, "A", 1, 2); + AddValuesForKey(map, "B", 3); + + map.Clear(); + Assert.Equal(0, map.Count); + Assert.Equal(0, map.KeyCount); + Assert.Empty(map.GetInRange(DateTimeOffset.MinValue, DateTimeOffset.MaxValue)); + } + + [Fact(DisplayName = "Concurrency: parallel AddValue across keys is thread-safe")] + public void Concurrency_AddValue_MultiKey() + { + var map = new TemporalMultimap(); + string[] keys = Enumerable.Range(0, 8).Select(i => $"K{i}").ToArray(); + int perKey = 300; + + Parallel.ForEach(keys, k => + { + for (int i = 0; i < perKey; i++) + map.AddValue(k, i); + }); + + Assert.Equal(keys.Length * perKey, map.Count); + Assert.Equal(keys.Length, map.KeyCount); + + // Verify each key list is strictly increasing in time + foreach (var k in keys) + { + var vals = map.GetValuesInRange(k, DateTimeOffset.MinValue, DateTimeOffset.MaxValue).ToArray(); + Assert.Equal(perKey, vals.Length); + AssertStrictlyIncreasing(vals); + } + + // Global snapshot is sorted by timestamp + var all = map.GetInRange(DateTimeOffset.MinValue, DateTimeOffset.MaxValue).ToArray(); + for (int i = 1; i < all.Length; i++) + Assert.True(all[i - 1].Timestamp.UtcTicks <= all[i].Timestamp.UtcTicks); + } + } +} \ No newline at end of file diff --git a/src/TemporalCollections.Tests/Collections/TemporalSegmentedArrayTests.cs b/src/TemporalCollections.Tests/Collections/TemporalSegmentedArrayTests.cs new file mode 100644 index 0000000..2048049 --- /dev/null +++ b/src/TemporalCollections.Tests/Collections/TemporalSegmentedArrayTests.cs @@ -0,0 +1,266 @@ +// (c) 2025 Francesco Del Re +// This code is licensed under MIT license (see LICENSE.txt for details) +using TemporalCollections.Collections; +using TemporalCollections.Models; + +namespace TemporalCollections.Tests.Collections +{ + /// + /// xUnit test suite for TemporalSegmentedArray{T} using autonomous timestamps. + /// IMPORTANT: TemporalItem{T}.Create (used by AddValue) stamps items with strictly + /// increasing UTC ticks per closed T. Tests therefore derive ranges and cutoffs + /// from the actual produced timestamps instead of fabricating DateTimeOffset values. + /// + public class TemporalSegmentedArray_AutonomousTimestamp_Tests + { + // ---------- Helpers ---------- + + /// + /// Adds values via AddValue and returns the created items (to capture real timestamps). + /// + private static TemporalItem[] AddValues(TemporalSegmentedArray col, params int[] values) + { + var list = new List>(values.Length); + foreach (var v in values) + list.Add(col.AddValue(v)); + return list.ToArray(); + } + + /// + /// Asserts that items are strictly ordered by Timestamp.UtcTicks ascending. + /// + private static void AssertStrictlyIncreasing(IReadOnlyList> items) + { + for (int i = 1; i < items.Count; i++) + { + Assert.True(items[i - 1].Timestamp.UtcTicks < items[i].Timestamp.UtcTicks, + $"Not strictly increasing at {i - 1}->{i}: {items[i - 1].Timestamp:o} !< {items[i].Timestamp:o}"); + } + } + + /// + /// Returns the midpoint (in UTC ticks) between a and b. + /// + private static DateTimeOffset Mid(DateTimeOffset a, DateTimeOffset b) + { + long mid = (a.UtcTicks + b.UtcTicks) / 2; + return new DateTimeOffset(mid, TimeSpan.Zero); + } + private static readonly int[] expected = [1, 2, 3, 4]; + + // ---------- Tests ---------- + + [Fact(DisplayName = "AddValue increments count and preserves strict timestamp monotonicity")] + public void AddValue_InOrder_Monotonic() + { + var col = new TemporalSegmentedArray(segmentCapacity: 4); + + var created = AddValues(col, 1, 2, 3, 4); + Assert.Equal(4, col.Count); + + var all = col.ToArray(); + Assert.Equal(created.Length, all.Length); + AssertStrictlyIncreasing(all); + Assert.Equal(expected, all.Select(x => x.Value).ToArray()); + } + + [Fact(DisplayName = "GetInRange is inclusive using real item timestamps")] + public void GetInRange_Inclusive_WithRealTimestamps() + { + var col = new TemporalSegmentedArray(segmentCapacity: 2); + var items = AddValues(col, 10, 20, 30, 40); + + // Inclusive range: [items[1].ts, items[2].ts] -> expect values 20 and 30 + var res = col.GetInRange(items[1].Timestamp, items[2].Timestamp).ToArray(); + Assert.Equal([20, 30], res.Select(x => x.Value).ToArray()); + + // Swap bounds should yield same result + var res2 = col.GetInRange(items[2].Timestamp, items[1].Timestamp).ToArray(); + Assert.Equal([20, 30], res2.Select(x => x.Value).ToArray()); + } + + [Fact(DisplayName = "GetBefore is strictly before (exclusive) using midpoint cutoffs")] + public void GetBefore_Exclusive_WithMidpoint() + { + var col = new TemporalSegmentedArray(segmentCapacity: 2); + var items = AddValues(col, 10, 20, 30); + + var cutoff = Mid(items[0].Timestamp, items[1].Timestamp); // strictly between 10 and 20 + var res = col.GetBefore(cutoff).ToArray(); + Assert.Single(res); + Assert.Equal(10, res[0].Value); + + // Using the exact timestamp of items[1] excludes it + var res2 = col.GetBefore(items[1].Timestamp).ToArray(); + Assert.Single(res2); + Assert.Equal(10, res2[0].Value); + } + + [Fact(DisplayName = "GetAfter is strictly after (exclusive) using midpoint cutoffs")] + public void GetAfter_Exclusive_WithMidpoint() + { + var col = new TemporalSegmentedArray(segmentCapacity: 2); + var items = AddValues(col, 10, 20, 30); + + var cutoff = Mid(items[1].Timestamp, items[2].Timestamp); // strictly between 20 and 30 + var res = col.GetAfter(cutoff).ToArray(); + Assert.Single(res); + Assert.Equal(30, res[0].Value); + + // Using the exact timestamp of items[1] excludes 20 (exclusive) + var res2 = col.GetAfter(items[1].Timestamp).ToArray(); + Assert.Equal([30], res2.Select(x => x.Value).ToArray()); + } + + [Fact(DisplayName = "CountInRange counts inclusively with autonomous timestamps")] + public void CountInRange_Inclusive_WithRealTimestamps() + { + var col = new TemporalSegmentedArray(segmentCapacity: 3); + var items = AddValues(col, 10, 20, 30, 40); + + Assert.Equal(3, col.CountInRange(items[1].Timestamp, items[3].Timestamp)); // 20..40 inclusive + Assert.Equal(2, col.CountInRange(items[0].Timestamp, items[1].Timestamp)); // 10..20 inclusive + Assert.Equal(0, col.CountInRange(items[3].Timestamp.AddSeconds(1), items[3].Timestamp.AddSeconds(2))); + } + + [Fact(DisplayName = "CountSince is >= (inclusive lower bound)")] + public void CountSince_Inclusive() + { + var col = new TemporalSegmentedArray(segmentCapacity: 4); + var items = AddValues(col, 10, 20, 30, 40); + + Assert.Equal(2, col.CountSince(items[2].Timestamp)); // from 30 inclusive: 30, 40 + Assert.Equal(4, col.CountSince(items[0].Timestamp)); // from 10 inclusive: 10, 20, 30, 40 + + // After the last item -> zero + var afterLast = new DateTimeOffset(items[^1].Timestamp.UtcTicks + 1, TimeSpan.Zero); + Assert.Equal(0, col.CountSince(afterLast)); + } + + [Fact(DisplayName = "RemoveOlderThan removes strictly older items and can drop whole segments")] + public void RemoveOlderThan_StrictAndDropsSegments() + { + var col = new TemporalSegmentedArray(segmentCapacity: 2); + var items = AddValues(col, 10, 20, 30, 40, 50); + + // Remove strictly older than items[2] (value 30) -> removes 10, 20 + col.RemoveOlderThan(items[2].Timestamp); + + var all = col.ToArray(); + Assert.Equal([30, 40, 50], all.Select(x => x.Value).ToArray()); + AssertStrictlyIncreasing(all); + } + + [Fact(DisplayName = "RemoveRange removes inclusively and can drop whole segments")] + public void RemoveRange_InclusiveAndDropsSegments() + { + var col = new TemporalSegmentedArray(segmentCapacity: 2); + var items = AddValues(col, 10, 20, 30, 40, 50, 60); + + // Remove [items[1], items[4]] inclusive -> remove 20,30,40,50 + col.RemoveRange(items[1].Timestamp, items[4].Timestamp); + + var all = col.ToArray(); + Assert.Equal([10, 60], all.Select(x => x.Value).ToArray()); + AssertStrictlyIncreasing(all); + } + + [Fact(DisplayName = "GetNearest returns nearest by ticks; in tie it prefers the earlier item")] + public void GetNearest_TiePrefersEarlier() + { + var col = new TemporalSegmentedArray(segmentCapacity: 3); + var items = AddValues(col, 100, 200, 300); + + // Midpoint between items[1] and items[2] → tie, expect earlier (items[1]) + var mid = Mid(items[1].Timestamp, items[2].Timestamp); + var nearest = col.GetNearest(mid); + Assert.NotNull(nearest); + Assert.Equal(200, nearest!.Value); + + // Midpoint between items[0] and items[1] → earlier (items[0]) + var mid2 = Mid(items[0].Timestamp, items[1].Timestamp); + nearest = col.GetNearest(mid2); + Assert.NotNull(nearest); + Assert.Equal(100, nearest!.Value); + } + + [Fact(DisplayName = "GetLatest and GetEarliest return correct items")] + public void LatestAndEarliest_Work() + { + var col = new TemporalSegmentedArray(segmentCapacity: 3); + Assert.Null(col.GetLatest()); + Assert.Null(col.GetEarliest()); + + var a = col.AddValue(10); + var b = col.AddValue(20); + var c = col.AddValue(5); // created after b, but still later in time (timestamps are monotonic by creation) + + // Earliest is 'a' (first inserted), latest is 'c' (last inserted) + var earliest = col.GetEarliest(); + var latest = col.GetLatest(); + + Assert.NotNull(earliest); + Assert.NotNull(latest); + Assert.Equal(a.Value, earliest!.Value); + Assert.Equal(c.Value, latest!.Value); + } + + [Fact(DisplayName = "GetTimeSpan returns zero for empty/singleton and correct span otherwise")] + public void GetTimeSpan_Works() + { + var col = new TemporalSegmentedArray(segmentCapacity: 2); + Assert.Equal(TimeSpan.Zero, col.GetTimeSpan()); + + var x = col.AddValue(10); + Assert.Equal(TimeSpan.Zero, col.GetTimeSpan()); + + var y = col.AddValue(20); + Assert.Equal(y.Timestamp - x.Timestamp, col.GetTimeSpan()); + } + + [Fact(DisplayName = "Clear removes all items")] + public void Clear_Works() + { + var col = new TemporalSegmentedArray(segmentCapacity: 2); + AddValues(col, 10, 20); + Assert.Equal(2, col.Count); + + col.Clear(); + Assert.Equal(0, col.Count); + Assert.Empty(col.GetInRange(DateTimeOffset.MinValue, DateTimeOffset.MaxValue)); + } + + [Fact(DisplayName = "ToArray returns full snapshot in chronological order")] + public void ToArray_ReturnsSnapshot() + { + var col = new TemporalSegmentedArray(segmentCapacity: 2); + var a = col.AddValue(20); + var b = col.AddValue(10); + var c = col.AddValue(30); + + var arr = col.ToArray(); + AssertStrictlyIncreasing(arr); + Assert.Equal([20, 10, 30], new[] { a.Value, b.Value, c.Value }); // sanity on creation + Assert.Equal([a.Value, b.Value, c.Value], arr.Select(x => x.Value).ToArray()); // creation order equals time order here + } + + [Fact(DisplayName = "Parallel AddValue is thread-safe and produces strictly increasing ticks")] + public void Concurrency_AddValue_Works() + { + var col = new TemporalSegmentedArray(segmentCapacity: 64); + int writers = 8; + int perWriter = 300; + + Parallel.For(0, writers, _ => + { + for (int i = 0; i < perWriter; i++) + col.AddValue(i); + }); + + Assert.Equal(writers * perWriter, col.Count); + + var arr = col.ToArray(); + AssertStrictlyIncreasing(arr); // TemporalItem.Create ensures monotonic UTC ticks per T + } + } +} \ No newline at end of file diff --git a/src/TemporalCollections.Tests/Collections/TemporalSetTests.cs b/src/TemporalCollections.Tests/Collections/TemporalSetTests.cs index fba3685..8fdb52e 100644 --- a/src/TemporalCollections.Tests/Collections/TemporalSetTests.cs +++ b/src/TemporalCollections.Tests/Collections/TemporalSetTests.cs @@ -277,5 +277,125 @@ public void TemporalSet_GetNearest_WorksAndTiesPreferLater() Assert.Equal(30, nearCeil!.Value); // C } } + + [Fact] + public void IsEmpty_ReflectsStateCorrectly() + { + var set = new TemporalSet(); + Assert.True(set.IsEmpty); + + set.Add("A"); + Assert.False(set.IsEmpty); + + set.Remove("A"); + Assert.True(set.IsEmpty); + } + + [Fact] + public void Add_Duplicate_MustNotUpdateTimestamp() + { + var set = new TemporalSet(); + Assert.True(set.Add("A")); + + var first = set.GetItems().Single(i => i.Value == "A"); + var firstTs = first.Timestamp; + + // Re-add duplicate + Assert.False(set.Add("A")); + + var again = set.GetItems().Single(i => i.Value == "A"); + Assert.Equal(firstTs, again.Timestamp); // timestamp unchanged + } + + [Fact] + public void GetInRange_DateTimeOverload_SwapsBounds_WhenFromAfterTo() + { + var (set, tA, _, _, tD) = CreateSetABCD(); + + // deliberately inverted bounds (to < from) + var items = set.GetInRange(tD.UtcDateTime, tA.UtcDateTime).Select(i => i.Value).ToArray(); + + Assert.Equal(new[] { "A", "B", "C", "D" }, items); + } + + [Fact] + public void CountInRange_DateTimeOverload_SwapsBounds() + { + var (set, tA, _, _, tD) = CreateSetABCD(); + + int c1 = set.CountInRange(tA.UtcDateTime, tD.UtcDateTime); + int c2 = set.CountInRange(tD.UtcDateTime, tA.UtcDateTime); // inverted + + Assert.Equal(4, c1); + Assert.Equal(c1, c2); + } + + [Fact] + public void RemoveRange_DateTimeOverload_SwapsBounds() + { + var (set, _, tB, tC, _) = CreateSetABCD(); + + // Inverted bounds: should still remove B and C + set.RemoveRange(tC.UtcDateTime, tB.UtcDateTime); + + Assert.Equal(2, set.Count); + Assert.True(set.Contains("A")); + Assert.True(set.Contains("D")); + Assert.False(set.Contains("B")); + Assert.False(set.Contains("C")); + } + + [Fact] + public void RemoveOlderThan_IsStrictlyExclusive_EqualTimestampIsKept() + { + var (set, tA, _, _, _) = CreateSetABCD(); + + set.RemoveOlderThan(tA.UtcDateTime); // strictly older than A → none removed + + Assert.Equal(4, set.Count); + Assert.True(set.Contains("A")); + } + + [Fact] + public void GetTimeSpan_EmptyOrSingleItem_ReturnsZero() + { + var empty = new TemporalSet(); + Assert.Equal(TimeSpan.Zero, empty.GetTimeSpan()); + + var single = new TemporalSet(); + single.Add("A"); + Assert.Equal(TimeSpan.Zero, single.GetTimeSpan()); + } + + [Fact] + public void GetNearest_Empty_ReturnsNull() + { + var set = new TemporalSet(); + Assert.Null(set.GetNearest(DateTime.UtcNow)); + } + + [Fact] + public void Constructor_WithComparer_EnforcesEqualityRules() + { + var set = new TemporalSet(StringComparer.OrdinalIgnoreCase); + + Assert.True(set.Add("alpha")); + Assert.False(set.Add("ALPHA")); // same item under comparer + Assert.Equal(1, set.Count); + Assert.True(set.Contains("ALPHA")); + } + + [Fact] + public void Clear_IsIdempotent() + { + var (set, _, _, _, _) = CreateSetABCD(); + + set.Clear(); + set.Clear(); // second clear should be no-op + + Assert.True(set.IsEmpty); + Assert.Equal(0, set.Count); + Assert.Empty(set.GetItems()); + } } } \ No newline at end of file diff --git a/src/TemporalCollections.Tests/Collections/TemporalValueExtensionsTests.cs b/src/TemporalCollections.Tests/Collections/TemporalValueExtensionsTests.cs index 5b3d5ed..39b6b47 100644 --- a/src/TemporalCollections.Tests/Collections/TemporalValueExtensionsTests.cs +++ b/src/TemporalCollections.Tests/Collections/TemporalValueExtensionsTests.cs @@ -114,5 +114,26 @@ public void ToReadOnlyValueCollection_MaterializesAndIsReadOnly() Assert.True(asCollection!.IsReadOnly); Assert.Throws(() => asCollection.Add(42)); } + + [Fact] + public void BucketBy_GroupsIntoSingleDailyBucket_AndAppliesAggregator() + { + var source = MakeIntQueue(1, 2, 3, 4); + + var earliest = source.GetEarliest(); + Assert.NotNull(earliest); + var dayStartUtc = new DateTimeOffset(earliest!.Timestamp.UtcDateTime.Date, TimeSpan.Zero); + + var buckets = source + .BucketBy( + interval: TimeSpan.FromDays(1), + aggregator: items => items.Sum(i => i.Value) + ) + .ToList(); + + Assert.Single(buckets); + Assert.Equal(dayStartUtc, buckets[0].BucketStart); + Assert.Equal(1 + 2 + 3 + 4, buckets[0].Result); + } } } diff --git a/src/TemporalCollections.Tests/TemporalCollections.Tests.csproj b/src/TemporalCollections.Tests/TemporalCollections.Tests.csproj index 12e4a28..818f814 100644 --- a/src/TemporalCollections.Tests/TemporalCollections.Tests.csproj +++ b/src/TemporalCollections.Tests/TemporalCollections.Tests.csproj @@ -1,7 +1,7 @@  - net9.0 + net9.0;net10.0 enable enable false @@ -12,9 +12,9 @@ all runtime; build; native; contentfiles; analyzers; buildtransitive - + - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/TemporalCollections/Abstractions/ITimeQueryable.cs b/src/TemporalCollections/Abstractions/ITimeQueryable.cs index 5dd338e..4281cc9 100644 --- a/src/TemporalCollections/Abstractions/ITimeQueryable.cs +++ b/src/TemporalCollections/Abstractions/ITimeQueryable.cs @@ -12,33 +12,49 @@ public interface ITimeQueryable /// /// Returns items whose timestamps fall within the given range (inclusive). /// - IEnumerable> GetInRange(DateTime from, DateTime to); + IEnumerable> GetInRange(DateTimeOffset from, DateTimeOffset to); /// - /// Removes all items older than the specified cutoff date. + /// Removes all items older than the specified cutoff date/time (strictly earlier than ). /// - void RemoveOlderThan(DateTime cutoff); + void RemoveOlderThan(DateTimeOffset cutoff); /// - /// Returns the total timespan covered by items in the collection - /// (difference between earliest and latest timestamp), or TimeSpan.Zero if empty. + /// Returns the number of items in the specified time range (inclusive). /// - TimeSpan GetTimeSpan(); + int CountInRange(DateTimeOffset from, DateTimeOffset to); /// - /// Returns the number of items in the specified time range. + /// Removes all items whose timestamps fall within the specified range [from, to]. /// - int CountInRange(DateTime from, DateTime to); + void RemoveRange(DateTimeOffset from, DateTimeOffset to); /// - /// Removes all items from the collection. + /// Retrieves all items with timestamp strictly before the specified time. /// - void Clear(); + IEnumerable> GetBefore(DateTimeOffset time); /// - /// Removes all items whose timestamps fall within the specified range [from, to]. + /// Retrieves all items with timestamp strictly after the specified time. /// - void RemoveRange(DateTime from, DateTime to); + IEnumerable> GetAfter(DateTimeOffset time); + + /// + /// Counts the number of items with timestamp greater than or equal to the specified cutoff. + /// + int CountSince(DateTimeOffset from); + + /// + /// Retrieves the item whose timestamp is closest to the specified . + /// Returns null if the collection is empty. + /// + TemporalItem? GetNearest(DateTimeOffset time); + + /// + /// Returns the total timespan covered by items in the collection + /// (difference between earliest and latest timestamp), or if empty. + /// + TimeSpan GetTimeSpan(); /// /// Retrieves the latest item based on timestamp, or null if empty. @@ -50,6 +66,31 @@ public interface ITimeQueryable /// TemporalItem? GetEarliest(); + /// + /// Removes all items from the collection. + /// + void Clear(); + + /// + /// Returns items whose timestamps fall within the given range (inclusive). + /// + IEnumerable> GetInRange(DateTime from, DateTime to); + + /// + /// Removes all items older than the specified cutoff date. + /// + void RemoveOlderThan(DateTime cutoff); + + /// + /// Returns the number of items in the specified time range. + /// + int CountInRange(DateTime from, DateTime to); + + /// + /// Removes all items whose timestamps fall within the specified range [from, to]. + /// + void RemoveRange(DateTime from, DateTime to); + /// /// Retrieves all items with timestamp before the specified time (exclusive). /// @@ -66,7 +107,7 @@ public interface ITimeQueryable int CountSince(DateTime from); /// - /// Retrieves the item whose timestamp is closest to the specified . + /// Retrieves the item whose timestamp is closest to the specified time. /// Returns null if the collection is empty. /// TemporalItem? GetNearest(DateTime time); diff --git a/src/TemporalCollections/Abstractions/TimeQueryableBase.cs b/src/TemporalCollections/Abstractions/TimeQueryableBase.cs new file mode 100644 index 0000000..566873a --- /dev/null +++ b/src/TemporalCollections/Abstractions/TimeQueryableBase.cs @@ -0,0 +1,166 @@ +// (c) 2025 Francesco Del Re +// This code is licensed under MIT license (see LICENSE.txt for details) +using TemporalCollections.Models; +using TemporalCollections.Utilities; + +namespace TemporalCollections.Abstractions +{ + /// + /// Base class centralizing logic on DateTimeOffset. + /// DateTime overloads delegate via TimeNormalization. + /// + public abstract class TimeQueryableBase : ITimeQueryable + { + /// + /// Policy for handling DateTimeKind.Unspecified in DateTime overloads. + /// Override if you want a different behavior (e.g., AssumeUtc or AssumeLocal). + /// + protected virtual UnspecifiedPolicy UnspecifiedPolicyForDateTime => UnspecifiedPolicy.AssumeUtc; + + /// + /// Returns items whose timestamps fall within the given range (inclusive). + /// + public abstract IEnumerable> GetInRange(DateTimeOffset from, DateTimeOffset to); + + /// + /// Removes all items older than the specified cutoff date/time (strictly earlier than ). + /// + public abstract void RemoveOlderThan(DateTimeOffset cutoff); + + /// + /// Returns the number of items in the specified time range (inclusive). + /// + public abstract int CountInRange(DateTimeOffset from, DateTimeOffset to); + + /// + /// Removes all items whose timestamps fall within the specified range [from, to]. + /// + public abstract void RemoveRange(DateTimeOffset from, DateTimeOffset to); + + /// + /// Retrieves all items with timestamp strictly before the specified time. + /// + public abstract IEnumerable> GetBefore(DateTimeOffset time); + + /// + /// Retrieves all items with timestamp strictly after the specified time. + /// + public abstract IEnumerable> GetAfter(DateTimeOffset time); + + /// + /// Counts the number of items with timestamp greater than or equal to the specified cutoff. + /// + public abstract int CountSince(DateTimeOffset from); + + /// + /// Retrieves the item whose timestamp is closest to the specified . + /// Returns null if the collection is empty. + /// + public abstract TemporalItem? GetNearest(DateTimeOffset time); + + /// + /// Returns the total timespan covered by items in the collection + /// (difference between earliest and latest timestamp), or if empty. + /// + public abstract TimeSpan GetTimeSpan(); + + /// + /// Retrieves the latest item based on timestamp, or null if empty. + /// + public abstract TemporalItem? GetLatest(); + + /// + /// Retrieves the earliest item based on timestamp, or null if empty. + /// + public abstract TemporalItem? GetEarliest(); + + /// + /// Removes all items from the collection. + /// + public abstract void Clear(); + + /// + /// Returns items whose timestamps fall within the given range (inclusive). + /// + public IEnumerable> GetInRange(DateTime from, DateTime to) + { + var (f, t) = TimeNormalization.NormalizeRange( + from, to, + unspecifiedPolicy: UnspecifiedPolicyForDateTime); + return GetInRange(f, t); + } + + /// + /// Removes all items older than the specified cutoff date. + /// + public void RemoveOlderThan(DateTime cutoff) + { + var c = TimeNormalization.ToUtcOffset( + cutoff, nameof(cutoff), UnspecifiedPolicyForDateTime); + RemoveOlderThan(c); + } + + /// + /// Returns the number of items in the specified time range. + /// + public int CountInRange(DateTime from, DateTime to) + { + var (f, t) = TimeNormalization.NormalizeRange( + from, to, + unspecifiedPolicy: UnspecifiedPolicyForDateTime); + return CountInRange(f, t); + } + + /// + /// Removes all items whose timestamps fall within the specified range [from, to]. + /// + public void RemoveRange(DateTime from, DateTime to) + { + var (f, t) = TimeNormalization.NormalizeRange( + from, to, + unspecifiedPolicy: UnspecifiedPolicyForDateTime); + RemoveRange(f, t); + } + + /// + /// Retrieves all items with timestamp before the specified time (exclusive). + /// + public IEnumerable> GetBefore(DateTime time) + { + var t = TimeNormalization.ToUtcOffset( + time, nameof(time), UnspecifiedPolicyForDateTime); + return GetBefore(t); + } + + /// + /// Retrieves all items with timestamp after the specified time (exclusive). + /// + public IEnumerable> GetAfter(DateTime time) + { + var t = TimeNormalization.ToUtcOffset( + time, nameof(time), UnspecifiedPolicyForDateTime); + return GetAfter(t); + } + + /// + /// Counts the number of items with timestamp greater than or equal to the specified cutoff. + /// + public int CountSince(DateTime from) + { + var f = TimeNormalization.ToUtcOffset( + from, nameof(from), UnspecifiedPolicyForDateTime); + return CountSince(f); + } + + /// + /// Retrieves the item whose timestamp is closest to the specified time. + /// Returns null if the collection is empty. + /// + public TemporalItem? GetNearest(DateTime time) + { + var t = TimeNormalization.ToUtcOffset( + time, nameof(time), UnspecifiedPolicyForDateTime); + return GetNearest(t); + } + } +} \ No newline at end of file diff --git a/src/TemporalCollections/Collections/TemporalCircularBuffer.cs b/src/TemporalCollections/Collections/TemporalCircularBuffer.cs index 806e3ea..3027be5 100644 --- a/src/TemporalCollections/Collections/TemporalCircularBuffer.cs +++ b/src/TemporalCollections/Collections/TemporalCircularBuffer.cs @@ -2,7 +2,6 @@ // This code is licensed under MIT license (see LICENSE.txt for details) using TemporalCollections.Abstractions; using TemporalCollections.Models; -using TemporalCollections.Utilities; namespace TemporalCollections.Collections { @@ -12,16 +11,13 @@ namespace TemporalCollections.Collections /// Public API uses DateTime; internal comparisons use DateTimeOffset (UTC). /// /// Type of items stored in the buffer. - public class TemporalCircularBuffer : ITimeQueryable + public class TemporalCircularBuffer : TimeQueryableBase { private readonly Lock _lock = new(); private readonly TemporalItem[] _buffer; private int _head; private int _count; - // Centralized policy for DateTimeKind.Unspecified handling. - private const UnspecifiedPolicy DefaultPolicy = UnspecifiedPolicy.AssumeUtc; - /// Gets the fixed capacity of the circular buffer. public int Capacity { get; } @@ -87,10 +83,11 @@ public IList> GetSnapshot() /// /// Returns all temporal items whose timestamps fall within the specified time range, inclusive. /// - public IEnumerable> GetInRange(DateTime from, DateTime to) + public override IEnumerable> GetInRange(DateTimeOffset from, DateTimeOffset to) { - var (fromUtc, toUtc) = TimeNormalization.NormalizeRange(from, to, nameof(from), nameof(to), DefaultPolicy); - long f = fromUtc.UtcTicks, t = toUtc.UtcTicks; + long f = from.UtcTicks, t = to.UtcTicks; + if (f > t) + (f, t) = (t, f); lock (_lock) { @@ -113,9 +110,9 @@ public IEnumerable> GetInRange(DateTime from, DateTime to) /// Removes all items with timestamps older than the specified cutoff time. /// Retains only items with timestamps >= cutoff. /// - public void RemoveOlderThan(DateTime cutoff) + public override void RemoveOlderThan(DateTimeOffset cutoff) { - long c = TimeNormalization.UtcTicks(cutoff, DefaultPolicy); + long c = cutoff.UtcTicks; lock (_lock) { @@ -137,7 +134,7 @@ public void RemoveOlderThan(DateTime cutoff) /// computed as (latest.Timestamp - earliest.Timestamp). /// Returns TimeSpan.Zero if there are fewer than two items. /// - public TimeSpan GetTimeSpan() + public override TimeSpan GetTimeSpan() { lock (_lock) { @@ -154,10 +151,11 @@ public TimeSpan GetTimeSpan() /// /// Returns the number of items with timestamps in the inclusive range [from, to]. /// - public int CountInRange(DateTime from, DateTime to) + public override int CountInRange(DateTimeOffset from, DateTimeOffset to) { - var (fromUtc, toUtc) = TimeNormalization.NormalizeRange(from, to, nameof(from), nameof(to), DefaultPolicy); - long f = fromUtc.UtcTicks, t = toUtc.UtcTicks; + long f = from.UtcTicks, t = to.UtcTicks; + if (f > t) + (f, t) = (t, f); lock (_lock) { @@ -176,7 +174,7 @@ public int CountInRange(DateTime from, DateTime to) /// /// Removes all items from the buffer. /// - public void Clear() + public override void Clear() { lock (_lock) { @@ -189,10 +187,11 @@ public void Clear() /// /// Removes all items whose timestamps fall within the inclusive range [from, to]. /// - public void RemoveRange(DateTime from, DateTime to) + public override void RemoveRange(DateTimeOffset from, DateTimeOffset to) { - var (fromUtc, toUtc) = TimeNormalization.NormalizeRange(from, to, nameof(from), nameof(to), DefaultPolicy); - long f = fromUtc.UtcTicks, t = toUtc.UtcTicks; + long f = from.UtcTicks, t = to.UtcTicks; + if (f > t) + (f, t) = (t, f); lock (_lock) { @@ -213,7 +212,7 @@ public void RemoveRange(DateTime from, DateTime to) /// /// Retrieves the latest (most recent) item, or null if the buffer is empty. /// - public TemporalItem? GetLatest() + public override TemporalItem? GetLatest() { lock (_lock) { @@ -226,7 +225,7 @@ public void RemoveRange(DateTime from, DateTime to) /// /// Retrieves the earliest (oldest) item, or null if the buffer is empty. /// - public TemporalItem? GetEarliest() + public override TemporalItem? GetEarliest() { lock (_lock) { @@ -240,9 +239,9 @@ public void RemoveRange(DateTime from, DateTime to) /// Retrieves all items with timestamp strictly before . /// The returned items are ordered from oldest to newest. /// - public IEnumerable> GetBefore(DateTime time) + public override IEnumerable> GetBefore(DateTimeOffset time) { - long cutoff = TimeNormalization.UtcTicks(time, DefaultPolicy); + long cutoff = time.UtcTicks; lock (_lock) { @@ -262,9 +261,9 @@ public IEnumerable> GetBefore(DateTime time) /// Retrieves all items with timestamp strictly after . /// The returned items are ordered from oldest to newest. /// - public IEnumerable> GetAfter(DateTime time) + public override IEnumerable> GetAfter(DateTimeOffset time) { - long cutoff = TimeNormalization.UtcTicks(time, DefaultPolicy); + long cutoff = time.UtcTicks; lock (_lock) { @@ -283,9 +282,9 @@ public IEnumerable> GetAfter(DateTime time) /// /// Counts the number of items with timestamp greater than or equal to the specified cutoff. /// - public int CountSince(DateTime from) + public override int CountSince(DateTimeOffset from) { - long f = TimeNormalization.UtcTicks(from, DefaultPolicy); + long f = from.UtcTicks; lock (_lock) { @@ -307,9 +306,9 @@ public int CountSince(DateTime from) /// In case of a tie (same distance before/after), the later item (timestamp ≥ time) is returned. /// Complexity: O(n) snapshot + O(log n) search. /// - public TemporalItem? GetNearest(DateTime time) + public override TemporalItem? GetNearest(DateTimeOffset time) { - long target = TimeNormalization.UtcTicks(time, DefaultPolicy); + long target = time.UtcTicks; lock (_lock) { diff --git a/src/TemporalCollections/Collections/TemporalDictionary.cs b/src/TemporalCollections/Collections/TemporalDictionary.cs index 0d76dbb..36f8f61 100644 --- a/src/TemporalCollections/Collections/TemporalDictionary.cs +++ b/src/TemporalCollections/Collections/TemporalDictionary.cs @@ -3,7 +3,6 @@ using System.Collections.Concurrent; using TemporalCollections.Abstractions; using TemporalCollections.Models; -using TemporalCollections.Utilities; namespace TemporalCollections.Collections { @@ -15,14 +14,11 @@ namespace TemporalCollections.Collections /// /// The type of keys in the dictionary (not nullable). /// The type of values stored with timestamps. - public class TemporalDictionary : ITimeQueryable> + public class TemporalDictionary : TimeQueryableBase> where TKey : notnull { private readonly ConcurrentDictionary>> _dict = new(); - // Centralized policy for DateTimeKind.Unspecified handling. - private const UnspecifiedPolicy DefaultPolicy = UnspecifiedPolicy.AssumeUtc; - private static readonly IComparer> TimestampOnlyComparer = new TimestampOnlyComparerImpl(); @@ -45,9 +41,13 @@ public void Add(TKey key, TValue value) /// Retrieves all temporal items associated with the specified whose timestamps /// fall within the inclusive range from to . /// - public IEnumerable> GetInRange(TKey key, DateTime from, DateTime to) + public IEnumerable> GetInRange(TKey key, DateTimeOffset from, DateTimeOffset to) { - var (f, t) = TimeNormalization.NormalizeRange(from, to, nameof(from), nameof(to), DefaultPolicy); + var f = from.UtcTicks; + var t = to.UtcTicks; + if (f > t) + (f, t) = (t, f); + if (!_dict.TryGetValue(key, out var list)) yield break; List> snapshot; @@ -55,26 +55,26 @@ public IEnumerable> GetInRange(TKey key, DateTime from, Dat { if (list.Count == 0) yield break; - int lo = LowerBound(list, f.UtcTicks); - int hi = UpperBound(list, t.UtcTicks) - 1; + int lo = LowerBound(list, f); + int hi = UpperBound(list, t) - 1; if (lo > hi) yield break; int count = hi - lo + 1; snapshot = new List>(count); - for (int i = lo; i <= hi; i++) + for (int i = lo; i <= hi; i++) snapshot.Add(list[i]); } - foreach (var item in snapshot) + foreach (var item in snapshot) yield return item; } /// /// Removes all timestamped values older than the specified cutoff date from all keys. /// - public void RemoveOlderThan(DateTime cutoff) + public override void RemoveOlderThan(DateTimeOffset cutoff) { - long c = TimeNormalization.UtcTicks(cutoff, DefaultPolicy); + long c = cutoff.UtcTicks; foreach (var kvp in _dict) { @@ -106,10 +106,11 @@ public void RemoveOlderThan(DateTime cutoff) /// Each item returned is wrapped as a containing /// a with the original key and value. /// - public IEnumerable>> GetInRange(DateTime from, DateTime to) + public override IEnumerable>> GetInRange(DateTimeOffset from, DateTimeOffset to) { - var (fromUtc, toUtc) = TimeNormalization.NormalizeRange(from, to, nameof(from), nameof(to), DefaultPolicy); - long f = fromUtc.UtcTicks, t = toUtc.UtcTicks; + long f = from.UtcTicks, t = to.UtcTicks; + if (f > t) + (f, t) = (t, f); var results = new List>>(); @@ -141,7 +142,7 @@ public IEnumerable>> GetInRange(DateTime /// Returns the time span between the earliest and the latest timestamp across all stored items. /// Returns if the dictionary is empty. /// - public TimeSpan GetTimeSpan() + public override TimeSpan GetTimeSpan() { bool any = false; DateTimeOffset min = DateTimeOffset.MaxValue; @@ -171,10 +172,11 @@ public TimeSpan GetTimeSpan() /// Counts how many items across all keys have a timestamp within the inclusive range /// from to . /// - public int CountInRange(DateTime from, DateTime to) + public override int CountInRange(DateTimeOffset from, DateTimeOffset to) { - var (fromUtc, toUtc) = TimeNormalization.NormalizeRange(from, to, nameof(from), nameof(to), DefaultPolicy); - long f = fromUtc.UtcTicks, t = toUtc.UtcTicks; + long f = from.UtcTicks, t = to.UtcTicks; + if (f > t) + (f, t) = (t, f); var count = 0; foreach (var kvp in _dict) @@ -195,17 +197,18 @@ public int CountInRange(DateTime from, DateTime to) /// /// Removes all keys and all their timestamped values from the dictionary. /// - public void Clear() => _dict.Clear(); + public override void Clear() => _dict.Clear(); /// /// Removes all items whose timestamps fall within the inclusive range /// from to across all keys. /// Keys left with no items are removed as well. /// - public void RemoveRange(DateTime from, DateTime to) + public override void RemoveRange(DateTimeOffset from, DateTimeOffset to) { - var (fromUtc, toUtc) = TimeNormalization.NormalizeRange(from, to, nameof(from), nameof(to), DefaultPolicy); - long f = fromUtc.UtcTicks, t = toUtc.UtcTicks; + long f = from.UtcTicks, t = to.UtcTicks; + if (f > t) + (f, t) = (t, f); foreach (var kvp in _dict) { @@ -227,7 +230,7 @@ public void RemoveRange(DateTime from, DateTime to) /// Returns the latest (most recent) item across all keys, or null if empty. /// The returned item contains the original key/value as a . /// - public TemporalItem>? GetLatest() + public override TemporalItem>? GetLatest() { DateTimeOffset bestTs = DateTimeOffset.MinValue; TKey? bestKey = default!; @@ -254,7 +257,7 @@ public void RemoveRange(DateTime from, DateTime to) return found ? new TemporalItem>( - new KeyValuePair(bestKey, bestVal), + new KeyValuePair(bestKey!, bestVal!), bestTs) : null; } @@ -263,7 +266,7 @@ public void RemoveRange(DateTime from, DateTime to) /// Returns the earliest (oldest) item across all keys, or null if empty. /// The returned item contains the original key/value as a . /// - public TemporalItem>? GetEarliest() + public override TemporalItem>? GetEarliest() { DateTimeOffset bestTs = DateTimeOffset.MaxValue; TKey? bestKey = default!; @@ -290,7 +293,7 @@ public void RemoveRange(DateTime from, DateTime to) return found ? new TemporalItem>( - new KeyValuePair(bestKey, bestVal), + new KeyValuePair(bestKey!, bestVal!), bestTs) : null; } @@ -299,9 +302,9 @@ public void RemoveRange(DateTime from, DateTime to) /// Retrieves all items strictly before the specified across all keys. /// The returned items wrap the original key/value. /// - public IEnumerable>> GetBefore(DateTime time) + public override IEnumerable>> GetBefore(DateTimeOffset time) { - long cutoff = TimeNormalization.UtcTicks(time, DefaultPolicy); + long cutoff = time.UtcTicks; var results = new List>>(); foreach (var kvp in _dict) @@ -329,9 +332,9 @@ public IEnumerable>> GetBefore(DateTime /// Retrieves all items strictly after the specified across all keys. /// The returned items wrap the original key/value. /// - public IEnumerable>> GetAfter(DateTime time) + public override IEnumerable>> GetAfter(DateTimeOffset time) { - long cutoff = TimeNormalization.UtcTicks(time, DefaultPolicy); + long cutoff = time.UtcTicks; var results = new List>>(); foreach (var kvp in _dict) @@ -358,9 +361,9 @@ public IEnumerable>> GetAfter(DateTime t /// /// Counts how many items across all keys have timestamp greater than or equal to the specified cutoff. /// - public int CountSince(DateTime from) + public override int CountSince(DateTimeOffset from) { - long f = TimeNormalization.UtcTicks(from, DefaultPolicy); + long f = from.UtcTicks; int count = 0; foreach (var kvp in _dict) @@ -385,9 +388,9 @@ public int CountSince(DateTime from) /// In case of a tie (same distance before/after), the later item (timestamp ≥ time) is returned. /// Complexity: O(K log N_k) where K is the number of keys and N_k is the items per key. /// - public TemporalItem>? GetNearest(DateTime time) + public override TemporalItem>? GetNearest(DateTimeOffset time) { - long target = TimeNormalization.UtcTicks(time, DefaultPolicy); + long target = time.UtcTicks; TemporalItem>? best = null; long bestDiff = long.MaxValue; diff --git a/src/TemporalCollections/Collections/TemporalIntervalTree.cs b/src/TemporalCollections/Collections/TemporalIntervalTree.cs index 3f2bcf1..4a2a8cf 100644 --- a/src/TemporalCollections/Collections/TemporalIntervalTree.cs +++ b/src/TemporalCollections/Collections/TemporalIntervalTree.cs @@ -2,7 +2,6 @@ // This code is licensed under MIT license (see LICENSE.txt for details) using TemporalCollections.Abstractions; using TemporalCollections.Models; -using TemporalCollections.Utilities; namespace TemporalCollections.Collections { @@ -13,11 +12,8 @@ namespace TemporalCollections.Collections /// Treap-balanced (randomized) BST over (Start, End, Value) with augmented MaxEnd for interval queries. /// /// The type of the value associated with each interval. - public class TemporalIntervalTree : ITimeQueryable + public class TemporalIntervalTree : TimeQueryableBase { - // Centralized policy for DateTimeKind.Unspecified handling. - private const UnspecifiedPolicy DefaultPolicy = UnspecifiedPolicy.AssumeUtc; - /// /// Represents a single node in the interval tree (Treap node). /// @@ -46,7 +42,7 @@ private sealed class Node /// /// Treap priority (min-heap): lower value means higher priority. /// - public int Priority; + public readonly int Priority; public Node? Left; public Node? Right; @@ -71,11 +67,11 @@ public Node(DateTimeOffset start, DateTimeOffset end, T value, int priority) /// /// Inserts a new interval with an associated value into the tree. /// - public void Insert(DateTime start, DateTime end, T value) + public void Insert(DateTimeOffset start, DateTimeOffset end, T value) { // Normalize to UTC offsets - var s = TimeNormalization.ToUtcOffset(start, nameof(start), DefaultPolicy); - var e = TimeNormalization.ToUtcOffset(end, nameof(end), DefaultPolicy); + var s = start; + var e = end; if (e < s) throw new ArgumentException("end must be >= start", nameof(end)); lock (_lock) @@ -87,10 +83,10 @@ public void Insert(DateTime start, DateTime end, T value) /// /// Removes an interval with the exact same start, end, and value from the tree. /// - public bool Remove(DateTime start, DateTime end, T value) + public bool Remove(DateTimeOffset start, DateTimeOffset end, T value) { - var s = TimeNormalization.ToUtcOffset(start, nameof(start), DefaultPolicy); - var e = TimeNormalization.ToUtcOffset(end, nameof(end), DefaultPolicy); + var s = start; + var e = end; lock (_lock) { @@ -104,9 +100,12 @@ public bool Remove(DateTime start, DateTime end, T value) /// Returns all values whose intervals overlap with the given query range. /// The returned items are wrapped as where Timestamp equals interval Start. /// - public IEnumerable> GetInRange(DateTime queryStart, DateTime queryEnd) + public override IEnumerable> GetInRange(DateTimeOffset queryStart, DateTimeOffset queryEnd) { - var (qs, qe) = TimeNormalization.NormalizeRange(queryStart, queryEnd, nameof(queryStart), nameof(queryEnd), DefaultPolicy); + var qs = queryStart; + var qe = queryEnd; + if (qs > qe) + (qs, qe) = (qe, qs); lock (_lock) { @@ -119,9 +118,9 @@ public IEnumerable> GetInRange(DateTime queryStart, DateTime que /// /// Removes all intervals that have already ended strictly before the cutoff (End < cutoff). /// - public void RemoveOlderThan(DateTime cutoff) + public override void RemoveOlderThan(DateTimeOffset cutoff) { - var c = TimeNormalization.ToUtcOffset(cutoff, nameof(cutoff), DefaultPolicy); + var c = cutoff; lock (_lock) { @@ -132,9 +131,12 @@ public void RemoveOlderThan(DateTime cutoff) /// /// Returns all values whose intervals overlap with the given query range (values only). /// - public IList Query(DateTime queryStart, DateTime queryEnd) + public IList Query(DateTimeOffset queryStart, DateTimeOffset queryEnd) { - var (qs, qe) = TimeNormalization.NormalizeRange(queryStart, queryEnd, nameof(queryStart), nameof(queryEnd), DefaultPolicy); + var qs = queryStart; + var qe = queryEnd; + if (qs > qe) + (qs, qe) = (qe, qs); lock (_lock) { @@ -148,7 +150,7 @@ public IList Query(DateTime queryStart, DateTime queryEnd) /// Returns the total timespan covered by items in the collection /// as (latest.Start - earliest.Start). Returns TimeSpan.Zero if empty or single item. /// - public TimeSpan GetTimeSpan() + public override TimeSpan GetTimeSpan() { lock (_lock) { @@ -166,9 +168,12 @@ public TimeSpan GetTimeSpan() /// /// Returns the number of items whose timestamps fall within [from, to] (inclusive). Timestamp == interval Start. /// - public int CountInRange(DateTime from, DateTime to) + public override int CountInRange(DateTimeOffset from, DateTimeOffset to) { - var (f, t) = TimeNormalization.NormalizeRange(from, to, nameof(from), nameof(to), DefaultPolicy); + var f = from; + var t = to; + if (f > t) + (f, t) = (t, f); lock (_lock) { @@ -179,9 +184,12 @@ public int CountInRange(DateTime from, DateTime to) /// /// Removes all items whose timestamps (Start) fall within [from, to] (inclusive). /// - public void RemoveRange(DateTime from, DateTime to) + public override void RemoveRange(DateTimeOffset from, DateTimeOffset to) { - var (f, t) = TimeNormalization.NormalizeRange(from, to, nameof(from), nameof(to), DefaultPolicy); + var f = from; + var t = to; + if (f > t) + (f, t) = (t, f); lock (_lock) { @@ -192,18 +200,18 @@ public void RemoveRange(DateTime from, DateTime to) /// /// Removes all items from the collection. /// - public void Clear() + public override void Clear() { - lock (_lock) - { - _root = null; + lock (_lock) + { + _root = null; } } /// /// Retrieves the latest item by timestamp (max Start) or null if empty. /// - public TemporalItem? GetLatest() + public override TemporalItem? GetLatest() { lock (_lock) { @@ -216,7 +224,7 @@ public void Clear() /// /// Retrieves the earliest item by timestamp (min Start) or null if empty. /// - public TemporalItem? GetEarliest() + public override TemporalItem? GetEarliest() { lock (_lock) { @@ -229,9 +237,9 @@ public void Clear() /// /// Retrieves all items with timestamp strictly before the specified time (Start < time). /// - public IEnumerable> GetBefore(DateTime time) + public override IEnumerable> GetBefore(DateTimeOffset time) { - var cutoff = TimeNormalization.ToUtcOffset(time, nameof(time), DefaultPolicy); + var cutoff = time; lock (_lock) { @@ -244,9 +252,9 @@ public IEnumerable> GetBefore(DateTime time) /// /// Retrieves all items with timestamp strictly after the specified time (Start > time). /// - public IEnumerable> GetAfter(DateTime time) + public override IEnumerable> GetAfter(DateTimeOffset time) { - var cutoff = TimeNormalization.ToUtcOffset(time, nameof(time), DefaultPolicy); + var cutoff = time; lock (_lock) { @@ -259,12 +267,12 @@ public IEnumerable> GetAfter(DateTime time) /// /// Counts the number of items with timestamp (Start) greater than or equal to the specified cutoff. /// - public int CountSince(DateTime since) + public override int CountSince(DateTimeOffset since) { - var s = TimeNormalization.ToUtcOffset(since, nameof(since), DefaultPolicy); + var s = since; lock (_lock) { - return CountWithEndAtOrAfter(_root, s); + return CountByStartGte(_root, s); } } @@ -274,9 +282,9 @@ public int CountSince(DateTime since) /// In case of a tie (same distance before/after), the later interval (Start ≥ time) is returned. /// Complexity: O(h). /// - public TemporalItem? GetNearest(DateTime time) + public override TemporalItem? GetNearest(DateTimeOffset time) { - var target = TimeNormalization.ToUtcOffset(time, nameof(time), DefaultPolicy); + var target = time; lock (_lock) { @@ -324,8 +332,8 @@ private static int CompareKey(DateTimeOffset s1, DateTimeOffset e1, T v1, private static void Update(Node n) { var max = n.End; - if (n.Left is not null && n.Left.MaxEnd > max) max = n.Left.MaxEnd; - if (n.Right is not null && n.Right.MaxEnd > max) max = n.Right.MaxEnd; + if (n.Left is { } l && l.MaxEnd > max) max = l.MaxEnd; + if (n.Right is { } r && r.MaxEnd > max) max = r.MaxEnd; n.MaxEnd = max; } @@ -663,6 +671,30 @@ private static int CountWithEndAtOrAfter(Node? node, DateTimeOffset cutoff) return res; } + /// + /// Counts nodes whose Start is greater than or equal to . + /// This leverages the BST ordering by Start. Without subtree sizes, + /// the complexity is O(h + visited), which is acceptable for a treap + /// and keeps the implementation simple. + /// + /// Current subtree root. + /// Start cutoff (inclusive). + /// Count of nodes with Start >= . + private static int CountByStartGte(Node? node, DateTimeOffset k) + { + if (node is null) return 0; + + if (node.Start < k) + { + // All nodes in the left subtree have Start < node.Start < k → skip them. + return CountByStartGte(node.Right, k); + } + + // node.Start >= k → count this node and continue on both sides. + // (Without subtree sizes we must explore the right subtree.) + return 1 + CountByStartGte(node.Left, k) + CountByStartGte(node.Right, k); + } + #endregion } } \ No newline at end of file diff --git a/src/TemporalCollections/Collections/TemporalMultimap.cs b/src/TemporalCollections/Collections/TemporalMultimap.cs new file mode 100644 index 0000000..6d1d698 --- /dev/null +++ b/src/TemporalCollections/Collections/TemporalMultimap.cs @@ -0,0 +1,594 @@ +// (c) 2025 Francesco Del Re +// This code is licensed under MIT license (see LICENSE.txt for details) +using TemporalCollections.Abstractions; +using TemporalCollections.Models; + +namespace TemporalCollections.Collections +{ + /// + /// A thread-safe temporal multimap that associates a key with multiple timestamped values. + /// Each insertion produces a stamped with a strictly increasing UTC timestamp. + /// + /// This collection does NOT implement IEnumerable by design; consumers should use the ITimeQueryable API. + /// Global time-based queries operate on (Key, Value) pairs using as the value type T. + /// + public class TemporalMultimap : TimeQueryableBase> + where TKey : notnull + { + private readonly Dictionary>>> _byKey = []; + private int _count; + private readonly Lock _lock = new(); + + /// Returns the total number of items currently stored (across all keys). + public int Count + { + get + { + lock (_lock) + { + return _count; + } + } + } + + /// Returns the current number of distinct keys stored. + public int KeyCount + { + get + { + lock (_lock) + { + return _byKey.Count; + } + } + } + + // ---------- Mutation API ---------- + + /// + /// Adds a new (key, value) pair, stamping it with a monotonic UTC timestamp. + /// Returns the created . + /// + public TemporalItem> AddValue(TKey key, TValue value) + { + var item = TemporalItem>.Create(new KeyValuePair(key, value)); + lock (_lock) + { + if (!_byKey.TryGetValue(key, out var list)) + { + list = new List>>(4); + _byKey[key] = list; + } + list.Add(item); // monotonic ticks → append fast path + _count++; + } + return item; + } + + /// + /// Adds a pre-built temporal item (must carry (key, value)). + /// Preserves per-key non-decreasing timestamp order (binary inserts if needed). + /// + public void Add(TemporalItem> item) + { + var key = item.Value.Key; + lock (_lock) + { + if (!_byKey.TryGetValue(key, out var list)) + { + list = new List>>(4); + _byKey[key] = list; + } + + if (list.Count == 0 || list[^1].Timestamp.UtcTicks <= item.Timestamp.UtcTicks) + list.Add(item); + else + list.Insert(LowerBound(list, item.Timestamp.UtcTicks), item); + + _count++; + } + } + + /// + /// Adds a sequence of values for the same key using . + /// + public void AddRange(TKey key, IEnumerable values) + { + foreach (var v in values) + AddValue(key, v); + } + + /// + /// Adds a sequence of pre-built temporal items (KeyValuePair of (key, value)). + /// + public void AddRange(IEnumerable>> items) + { + foreach (var it in items) + Add(it); + } + + /// + /// Removes all entries for the specified key. + /// + public bool RemoveKey(TKey key) + { + lock (_lock) + { + if (_byKey.Remove(key, out var list)) + { + _count -= list.Count; + return true; + } + return false; + } + } + + /// + /// Removes all entries for the specified key strictly older than . + /// + public int RemoveOlderThan(TKey key, DateTimeOffset cutoff) + { + lock (_lock) + { + if (!_byKey.TryGetValue(key, out var list) || list.Count == 0) + return 0; + + long c = cutoff.UtcTicks; + int idx = LowerBound(list, c); // first >= cutoff → [0..idx-1] are strictly older + if (idx <= 0) + return 0; + + list.RemoveRange(0, idx); + _count -= idx; + + if (list.Count == 0) + _byKey.Remove(key); + return idx; + } + } + + /// + /// Removes all entries for the specified key whose timestamps fall within [from, to] inclusive. + /// + public int RemoveRange(TKey key, DateTimeOffset from, DateTimeOffset to) + { + lock (_lock) + { + if (!_byKey.TryGetValue(key, out var list) || list.Count == 0) + return 0; + + long f = from.UtcTicks, t = to.UtcTicks; + if (f > t) + (f, t) = (t, f); + int i0 = LowerBound(list, f); + int i1 = UpperExclusive(list, t); + int remove = Math.Max(0, i1 - i0); + if (remove == 0) + return 0; + + list.RemoveRange(i0, remove); + _count -= remove; + + if (list.Count == 0) + _byKey.Remove(key); + return remove; + } + } + + /// + public override void Clear() + { + lock (_lock) + { + _byKey.Clear(); + _count = 0; + } + } + + // ---------- Per-key read helpers ---------- + + /// + /// Returns items for a specific key within [from, to] inclusive, ordered by timestamp. + /// + public IEnumerable> GetValuesInRange(TKey key, DateTimeOffset from, DateTimeOffset to) + { + lock (_lock) + { + if (!_byKey.TryGetValue(key, out var list) || list.Count == 0) + return []; + + long f = from.UtcTicks, t = to.UtcTicks; + if (f > t) + (f, t) = (t, f); + int i0 = LowerBound(list, f); + int i1 = UpperExclusive(list, t); + if (i0 >= i1) + return []; + + var res = new TemporalItem[i1 - i0]; + int pos = 0; + for (int i = i0; i < i1; i++) + { + var kv = list[i].Value; + res[pos++] = new TemporalItem(kv.Value, list[i].Timestamp); + } + return res; + } + } + + /// Returns the number of items stored under a specific key. + public int CountForKey(TKey key) + { + lock (_lock) + return _byKey.TryGetValue(key, out var list) ? list.Count : 0; + } + + /// Checks whether the multimap contains the specified key. + public bool ContainsKey(TKey key) + { + lock (_lock) + return _byKey.ContainsKey(key); + } + + // ---------- TimeQueryableBase (global) ---------- + + /// + public override IEnumerable>> GetInRange(DateTimeOffset from, DateTimeOffset to) + { + lock (_lock) + { + if (_count == 0) + return []; + long f = from.UtcTicks, t = to.UtcTicks; + if (f > t) + (f, t) = (t, f); + + var acc = new List>>(); + foreach (var list in _byKey.Values) + { + if (list.Count == 0) continue; + if (list[^1].Timestamp.UtcTicks < f) continue; + if (list[0].Timestamp.UtcTicks > t) continue; + + int i0 = LowerBound(list, f); + int i1 = UpperExclusive(list, t); + for (int i = i0; i < i1; i++) + acc.Add(list[i]); + } + + if (acc.Count <= 1) + return acc.ToArray(); + acc.Sort((a, b) => a.Timestamp.UtcTicks.CompareTo(b.Timestamp.UtcTicks)); + return acc.ToArray(); + } + } + + /// + public override void RemoveOlderThan(DateTimeOffset cutoff) + { + lock (_lock) + { + if (_count == 0) + return; + long c = cutoff.UtcTicks; + + var emptyKeys = new List(); + foreach (var (key, list) in _byKey) + { + if (list.Count == 0) + { + emptyKeys.Add(key); + continue; + } + + if (list[^1].Timestamp.UtcTicks < c) + { + _count -= list.Count; + emptyKeys.Add(key); + continue; + } + + if (list[0].Timestamp.UtcTicks < c) + { + int idx = LowerBound(list, c); + if (idx > 0) + { + _count -= idx; + list.RemoveRange(0, idx); + } + } + } + + foreach (var k in emptyKeys) + _byKey.Remove(k); + } + } + + /// + public override int CountInRange(DateTimeOffset from, DateTimeOffset to) + { + lock (_lock) + { + if (_count == 0) + return 0; + long f = from.UtcTicks, t = to.UtcTicks; + if (f > t) + (f, t) = (t, f); + + int total = 0; + foreach (var list in _byKey.Values) + { + if (list.Count == 0) continue; + if (list[^1].Timestamp.UtcTicks < f) continue; + if (list[0].Timestamp.UtcTicks > t) continue; + + int i0 = LowerBound(list, f); + int i1 = UpperExclusive(list, t); + total += Math.Max(0, i1 - i0); + } + return total; + } + } + + /// + public override void RemoveRange(DateTimeOffset from, DateTimeOffset to) + { + lock (_lock) + { + if (_count == 0) + return; + long f = from.UtcTicks, t = to.UtcTicks; + if (f > t) + (f, t) = (t, f); + + var emptyKeys = new List(); + foreach (var (key, list) in _byKey) + { + if (list.Count == 0) + { + emptyKeys.Add(key); + continue; + } + if (list[^1].Timestamp.UtcTicks < f) continue; + if (list[0].Timestamp.UtcTicks > t) continue; + + int i0 = LowerBound(list, f); + int i1 = UpperExclusive(list, t); + int remove = Math.Max(0, i1 - i0); + if (remove > 0) + { + list.RemoveRange(i0, remove); + _count -= remove; + } + if (list.Count == 0) + emptyKeys.Add(key); + } + + foreach (var k in emptyKeys) + _byKey.Remove(k); + } + } + + /// + public override IEnumerable>> GetBefore(DateTimeOffset time) + { + lock (_lock) + { + if (_count == 0) + return []; + long c = time.UtcTicks; + + var acc = new List>>(); + foreach (var list in _byKey.Values) + { + if (list.Count == 0) continue; + if (list[0].Timestamp.UtcTicks >= c) continue; + + int end = LowerBound(list, c); // [0..end-1] are < c + for (int i = 0; i < end; i++) + acc.Add(list[i]); + } + + if (acc.Count <= 1) + return acc.ToArray(); + acc.Sort((a, b) => a.Timestamp.UtcTicks.CompareTo(b.Timestamp.UtcTicks)); + return acc.ToArray(); + } + } + + /// + public override IEnumerable>> GetAfter(DateTimeOffset time) + { + lock (_lock) + { + if (_count == 0) + return []; + long c = time.UtcTicks; + + var acc = new List>>(); + foreach (var list in _byKey.Values) + { + if (list.Count == 0) continue; + if (list[^1].Timestamp.UtcTicks <= c) continue; + + int start = UpperExclusive(list, c); // first > c + for (int i = start; i < list.Count; i++) + acc.Add(list[i]); + } + + if (acc.Count <= 1) + return acc.ToArray(); + acc.Sort((a, b) => a.Timestamp.UtcTicks.CompareTo(b.Timestamp.UtcTicks)); + return acc.ToArray(); + } + } + + /// + public override int CountSince(DateTimeOffset from) + { + lock (_lock) + { + if (_count == 0) + return 0; + long f = from.UtcTicks; + + int total = 0; + foreach (var list in _byKey.Values) + { + if (list.Count == 0) continue; + if (list[^1].Timestamp.UtcTicks < f) continue; + + int idx = LowerBound(list, f); // first >= f + total += list.Count - idx; + } + return total; + } + } + + /// + public override TemporalItem>? GetNearest(DateTimeOffset time) + { + lock (_lock) + { + if (_count == 0) + return null; + + long target = time.UtcTicks; + TemporalItem>? best = null; + long bestDiff = long.MaxValue; + + foreach (var list in _byKey.Values) + { + if (list.Count == 0) continue; + + int idx = LowerBound(list, target); + + if (idx < list.Count) + { + var cand = list[idx]; + long diff = cand.Timestamp.UtcTicks - target; + if (diff < 0) diff = -diff; + if (diff < bestDiff || + (diff == bestDiff && cand.Timestamp.UtcTicks < (best?.Timestamp.UtcTicks ?? long.MaxValue))) + { + best = cand; + bestDiff = diff; + } + } + + if (idx > 0) + { + var cand = list[idx - 1]; + long diff = target - cand.Timestamp.UtcTicks; + if (diff < 0) diff = -diff; + if (diff < bestDiff || + (diff == bestDiff && cand.Timestamp.UtcTicks < (best?.Timestamp.UtcTicks ?? long.MaxValue))) + { + best = cand; + bestDiff = diff; + } + } + } + + return best; + } + } + + /// + public override TimeSpan GetTimeSpan() + { + lock (_lock) + { + if (_count <= 1) + return TimeSpan.Zero; + + DateTimeOffset? min = null, max = null; + foreach (var list in _byKey.Values) + { + if (list.Count == 0) continue; + var first = list[0].Timestamp; + var last = list[^1].Timestamp; + + if (min is null || first < min) min = first; + if (max is null || last > max) max = last; + } + + if (min is null || max is null || min.Value >= max.Value) + return TimeSpan.Zero; + return max.Value - min.Value; + } + } + + /// + public override TemporalItem>? GetLatest() + { + lock (_lock) + { + if (_count == 0) + return null; + + TemporalItem>? latest = null; + foreach (var list in _byKey.Values) + { + if (list.Count == 0) continue; + var last = list[^1]; + if (latest is null || last.Timestamp.UtcTicks > latest.Timestamp.UtcTicks) + latest = last; + } + return latest; + } + } + + /// + public override TemporalItem>? GetEarliest() + { + lock (_lock) + { + if (_count == 0) + return null; + + TemporalItem>? earliest = null; + foreach (var list in _byKey.Values) + { + if (list.Count == 0) continue; + var first = list[0]; + if (earliest is null || first.Timestamp.UtcTicks < earliest.Timestamp.UtcTicks) + earliest = first; + } + return earliest; + } + } + + // ---------- Private helpers ---------- + + /// Per-list lower bound: first index with timestamp ticks >= . + private static int LowerBound(List>> list, long ticks) + { + int lo = 0, hi = list.Count; + while (lo < hi) + { + int mid = lo + ((hi - lo) >> 1); + long m = list[mid].Timestamp.UtcTicks; + if (m < ticks) + lo = mid + 1; + else hi = mid; + } + return lo; + } + + /// Per-list upper exclusive: first index with timestamp ticks > . + private static int UpperExclusive(List>> list, long ticks) + { + int lo = 0, hi = list.Count; + while (lo < hi) + { + int mid = lo + ((hi - lo) >> 1); + long m = list[mid].Timestamp.UtcTicks; + if (m <= ticks) + lo = mid + 1; + else hi = mid; + } + return lo; + } + } +} \ No newline at end of file diff --git a/src/TemporalCollections/Collections/TemporalPriorityQueue.cs b/src/TemporalCollections/Collections/TemporalPriorityQueue.cs index b71e706..ea3d622 100644 --- a/src/TemporalCollections/Collections/TemporalPriorityQueue.cs +++ b/src/TemporalCollections/Collections/TemporalPriorityQueue.cs @@ -2,7 +2,6 @@ // This code is licensed under MIT license (see LICENSE.txt for details) using TemporalCollections.Abstractions; using TemporalCollections.Models; -using TemporalCollections.Utilities; namespace TemporalCollections.Collections { @@ -14,15 +13,12 @@ namespace TemporalCollections.Collections /// /// Type of the priority; must implement . /// Type of the stored values. - public class TemporalPriorityQueue : ITimeQueryable + public class TemporalPriorityQueue : TimeQueryableBase where TPriority : IComparable { private readonly Lock _lock = new(); private readonly SortedSet _set; - // Centralized policy for DateTimeKind.Unspecified handling. - private const UnspecifiedPolicy DefaultPolicy = UnspecifiedPolicy.AssumeUtc; - /// /// Initializes a new instance of the class. /// @@ -36,9 +32,9 @@ public TemporalPriorityQueue() /// public int Count { - get - { - lock (_lock) return _set.Count; + get + { + lock (_lock) return _set.Count; } } @@ -96,10 +92,11 @@ public bool TryPeek(out TValue? value) /// /// Returns items whose timestamps fall within the given range (inclusive). /// - public IEnumerable> GetInRange(DateTime from, DateTime to) + public override IEnumerable> GetInRange(DateTimeOffset from, DateTimeOffset to) { - var (fromUtc, toUtc) = TimeNormalization.NormalizeRange(from, to, unspecifiedPolicy: DefaultPolicy); - long f = fromUtc.UtcTicks, t = toUtc.UtcTicks; + long f = from.UtcTicks, t = to.UtcTicks; + if (f > t) + (f, t) = (t, f); lock (_lock) { @@ -114,9 +111,9 @@ public IEnumerable> GetInRange(DateTime from, DateTime to) /// /// Removes all items older than the specified cutoff date (strictly less). /// - public void RemoveOlderThan(DateTime cutoff) + public override void RemoveOlderThan(DateTimeOffset cutoff) { - long c = TimeNormalization.UtcTicks(cutoff, DefaultPolicy); + long c = cutoff.UtcTicks; lock (_lock) { @@ -136,7 +133,7 @@ public void RemoveOlderThan(DateTime cutoff) /// Returns the time span between the earliest and latest timestamps in the queue. /// Returns if the queue has fewer than two items. /// - public TimeSpan GetTimeSpan() + public override TimeSpan GetTimeSpan() { lock (_lock) { @@ -160,10 +157,11 @@ public TimeSpan GetTimeSpan() /// /// Counts the number of items with timestamps within the inclusive range [from, to]. /// - public int CountInRange(DateTime from, DateTime to) + public override int CountInRange(DateTimeOffset from, DateTimeOffset to) { - var (fromUtc, toUtc) = TimeNormalization.NormalizeRange(from, to, unspecifiedPolicy: DefaultPolicy); - long f = fromUtc.UtcTicks, t = toUtc.UtcTicks; + long f = from.UtcTicks, t = to.UtcTicks; + if (f > t) + (f, t) = (t, f); lock (_lock) { @@ -180,7 +178,7 @@ public int CountInRange(DateTime from, DateTime to) /// /// Removes all items from the queue. /// - public void Clear() + public override void Clear() { lock (_lock) _set.Clear(); } @@ -188,10 +186,11 @@ public void Clear() /// /// Removes all items whose timestamps fall within the inclusive range [from, to]. /// - public void RemoveRange(DateTime from, DateTime to) + public override void RemoveRange(DateTimeOffset from, DateTimeOffset to) { - var (fromUtc, toUtc) = TimeNormalization.NormalizeRange(from, to, unspecifiedPolicy: DefaultPolicy); - long f = fromUtc.UtcTicks, t = toUtc.UtcTicks; + long f = from.UtcTicks, t = to.UtcTicks; + if (f > t) + (f, t) = (t, f); lock (_lock) { @@ -209,7 +208,7 @@ public void RemoveRange(DateTime from, DateTime to) /// /// Gets the most recent item by timestamp, or null if the queue is empty. /// - public TemporalItem? GetLatest() + public override TemporalItem? GetLatest() { lock (_lock) { @@ -229,7 +228,7 @@ public void RemoveRange(DateTime from, DateTime to) /// /// Gets the oldest item by timestamp, or null if the queue is empty. /// - public TemporalItem? GetEarliest() + public override TemporalItem? GetEarliest() { lock (_lock) { @@ -249,9 +248,9 @@ public void RemoveRange(DateTime from, DateTime to) /// /// Returns all items strictly before the specified time. /// - public IEnumerable> GetBefore(DateTime time) + public override IEnumerable> GetBefore(DateTimeOffset time) { - long cutoff = TimeNormalization.UtcTicks(time, DefaultPolicy); + long cutoff = time.UtcTicks; lock (_lock) { @@ -266,9 +265,9 @@ public IEnumerable> GetBefore(DateTime time) /// /// Returns all items strictly after the specified time. /// - public IEnumerable> GetAfter(DateTime time) + public override IEnumerable> GetAfter(DateTimeOffset time) { - long cutoff = TimeNormalization.UtcTicks(time, DefaultPolicy); + long cutoff = time.UtcTicks; lock (_lock) { @@ -283,9 +282,9 @@ public IEnumerable> GetAfter(DateTime time) /// /// Counts the number of items with timestamp greater than or equal to the specified cutoff. /// - public int CountSince(DateTime from) + public override int CountSince(DateTimeOffset from) { - long f = TimeNormalization.UtcTicks(from, DefaultPolicy); + long f = from.UtcTicks; lock (_lock) { @@ -305,9 +304,9 @@ public int CountSince(DateTime from) /// In case of a tie (same distance before/after), the later item (timestamp ≥ time) is returned. /// Complexity: O(n). /// - public TemporalItem? GetNearest(DateTime time) + public override TemporalItem? GetNearest(DateTimeOffset time) { - long target = TimeNormalization.UtcTicks(time, DefaultPolicy); + long target = time.UtcTicks; lock (_lock) { diff --git a/src/TemporalCollections/Collections/TemporalQueue.cs b/src/TemporalCollections/Collections/TemporalQueue.cs index e90e533..60dafe8 100644 --- a/src/TemporalCollections/Collections/TemporalQueue.cs +++ b/src/TemporalCollections/Collections/TemporalQueue.cs @@ -2,7 +2,6 @@ // This code is licensed under MIT license (see LICENSE.txt for details) using TemporalCollections.Abstractions; using TemporalCollections.Models; -using TemporalCollections.Utilities; namespace TemporalCollections.Collections { @@ -12,23 +11,20 @@ namespace TemporalCollections.Collections /// Public API uses DateTime; internal comparisons use DateTimeOffset (UTC). /// /// Type of items stored in the queue. - public class TemporalQueue : ITimeQueryable + public class TemporalQueue : TimeQueryableBase { // Oldest -> front ; Newest -> back private readonly Queue> _queue = new(); private readonly Lock _lock = new(); - // Centralized policy for DateTimeKind.Unspecified handling. - private const UnspecifiedPolicy DefaultPolicy = UnspecifiedPolicy.AssumeUtc; - /// /// Gets the total number of items currently in the queue (O(1)). /// public int Count { - get - { - lock (_lock) return _queue.Count; + get + { + lock (_lock) return _queue.Count; } } @@ -74,10 +70,11 @@ public TemporalItem Peek() /// Retrieves all items whose timestamps are within the specified inclusive time range. /// Snapshot taken under lock for consistent semantics. /// - public IEnumerable> GetInRange(DateTime from, DateTime to) + public override IEnumerable> GetInRange(DateTimeOffset from, DateTimeOffset to) { - var (fromUtc, toUtc) = TimeNormalization.NormalizeRange(from, to, unspecifiedPolicy: DefaultPolicy); - long f = fromUtc.UtcTicks, t = toUtc.UtcTicks; + long f = from.UtcTicks, t = to.UtcTicks; + if (f > t) + (f, t) = (t, f); lock (_lock) { @@ -96,9 +93,9 @@ public IEnumerable> GetInRange(DateTime from, DateTime to) /// Removes items from the front of the queue until all remaining items /// have timestamps equal to or newer than the cutoff. /// - public void RemoveOlderThan(DateTime cutoff) + public override void RemoveOlderThan(DateTimeOffset cutoff) { - long c = TimeNormalization.UtcTicks(cutoff, DefaultPolicy); + long c = cutoff.UtcTicks; lock (_lock) { @@ -110,7 +107,7 @@ public void RemoveOlderThan(DateTime cutoff) /// /// Returns the total time span covered by the items in the queue. /// - public TimeSpan GetTimeSpan() + public override TimeSpan GetTimeSpan() { lock (_lock) { @@ -135,10 +132,11 @@ public TimeSpan GetTimeSpan() /// /// Counts the number of items whose timestamps fall within the specified range (inclusive). /// - public int CountInRange(DateTime from, DateTime to) + public override int CountInRange(DateTimeOffset from, DateTimeOffset to) { - var (fromUtc, toUtc) = TimeNormalization.NormalizeRange(from, to, unspecifiedPolicy: DefaultPolicy); - long f = fromUtc.UtcTicks, t = toUtc.UtcTicks; + long f = from.UtcTicks, t = to.UtcTicks; + if (f > t) + (f, t) = (t, f); lock (_lock) { @@ -155,7 +153,7 @@ public int CountInRange(DateTime from, DateTime to) /// /// Removes all items from the queue. /// - public void Clear() + public override void Clear() { lock (_lock) { @@ -167,10 +165,11 @@ public void Clear() /// Removes all items whose timestamps fall within the inclusive range. /// Snapshot-filter-clear-reenqueue to preserve FIFO among kept elements. /// - public void RemoveRange(DateTime from, DateTime to) + public override void RemoveRange(DateTimeOffset from, DateTimeOffset to) { - var (fromUtc, toUtc) = TimeNormalization.NormalizeRange(from, to, unspecifiedPolicy: DefaultPolicy); - long f = fromUtc.UtcTicks, t = toUtc.UtcTicks; + long f = from.UtcTicks, t = to.UtcTicks; + if (f > t) + (f, t) = (t, f); lock (_lock) { @@ -191,7 +190,7 @@ public void RemoveRange(DateTime from, DateTime to) /// /// Returns the most recent item in the queue, or null if the queue is empty. /// - public TemporalItem? GetLatest() + public override TemporalItem? GetLatest() { lock (_lock) { @@ -212,7 +211,7 @@ public void RemoveRange(DateTime from, DateTime to) /// /// Returns the earliest item in the queue, or null if the queue is empty. /// - public TemporalItem? GetEarliest() + public override TemporalItem? GetEarliest() { lock (_lock) { @@ -226,9 +225,9 @@ public void RemoveRange(DateTime from, DateTime to) /// /// Returns all items with timestamps strictly earlier than the specified time. /// - public IEnumerable> GetBefore(DateTime time) + public override IEnumerable> GetBefore(DateTimeOffset time) { - long cutoff = TimeNormalization.UtcTicks(time, DefaultPolicy); + long cutoff = time.UtcTicks; lock (_lock) { @@ -243,9 +242,9 @@ public IEnumerable> GetBefore(DateTime time) /// /// Returns all items with timestamps strictly later than the specified time. /// - public IEnumerable> GetAfter(DateTime time) + public override IEnumerable> GetAfter(DateTimeOffset time) { - long cutoff = TimeNormalization.UtcTicks(time, DefaultPolicy); + long cutoff = time.UtcTicks; lock (_lock) { @@ -260,9 +259,9 @@ public IEnumerable> GetAfter(DateTime time) /// /// Counts the number of items with timestamp greater than or equal to the specified cutoff. /// - public int CountSince(DateTime from) + public override int CountSince(DateTimeOffset from) { - long f = TimeNormalization.UtcTicks(from, DefaultPolicy); + long f = from.UtcTicks; lock (_lock) { @@ -282,9 +281,9 @@ public int CountSince(DateTime from) /// In case of a tie (same distance before/after), the later item (timestamp ≥ time) is returned. /// Complexity: O(n) due to snapshot copy, then O(log n) search. /// - public TemporalItem? GetNearest(DateTime time) + public override TemporalItem? GetNearest(DateTimeOffset time) { - long target = TimeNormalization.UtcTicks(time, DefaultPolicy); + long target = time.UtcTicks; // Take a snapshot to avoid holding the lock during the search TemporalItem[] snapshot; diff --git a/src/TemporalCollections/Collections/TemporalSegmentedArray.cs b/src/TemporalCollections/Collections/TemporalSegmentedArray.cs new file mode 100644 index 0000000..259bb67 --- /dev/null +++ b/src/TemporalCollections/Collections/TemporalSegmentedArray.cs @@ -0,0 +1,690 @@ +// (c) 2025 Francesco Del Re +// This code is licensed under MIT license (see LICENSE.txt for details) +using TemporalCollections.Abstractions; +using TemporalCollections.Models; + +namespace TemporalCollections.Collections +{ + /// + /// A thread-safe, time-ordered segmented array optimized for append-in-order workloads and + /// periodic retention (chunk dropping). Segments keep items sorted by UTC ticks; queries + /// use binary search on segment boundaries and within segments. + /// + /// Design goals + /// ------------ + /// • Append amortized O(1) when timestamps are non-decreasing. + /// • Range queries O(log S + K) where S = number of segments and K = returned items. + /// • Efficient retention: can drop whole segments. + /// + public class TemporalSegmentedArray : TimeQueryableBase + { + /// + /// Internal segment of contiguous, timestamp-ordered items. + /// + private sealed class Segment + { + public TemporalItem[] Items; + public int Count; + public long MinTicks; + public long MaxTicks; + + /// + /// Initializes a new segment with the specified capacity. + /// + /// Maximum number of items in this segment. + public Segment(int capacity) + { + Items = new TemporalItem[capacity]; + Count = 0; + MinTicks = long.MaxValue; + MaxTicks = long.MinValue; + } + + /// + /// Indicates whether the segment is full. + /// + public bool IsFull => Count == Items.Length; + + /// + /// Appends an item to the end of this segment (caller guarantees ordering). + /// + /// Item to append. + public void Append(in TemporalItem item) + { + Items[Count++] = item; + long t = item.Timestamp.UtcTicks; + if (t < MinTicks) MinTicks = t; + if (t > MaxTicks) MaxTicks = t; + } + + /// + /// Inserts an item at the specified index, shifting existing items to the right. + /// + /// Zero-based insertion index. + /// Item to insert. + public void InsertAt(int index, in TemporalItem item) + { + if (index < Count) Array.Copy(Items, index, Items, index + 1, Count - index); + Items[index] = item; + Count++; + long t = item.Timestamp.UtcTicks; + if (t < MinTicks) MinTicks = t; + if (t > MaxTicks) MaxTicks = t; + else if (index == 0) MinTicks = Items[0].Timestamp.UtcTicks; + else if (index == Count - 1) MaxTicks = Items[Count - 1].Timestamp.UtcTicks; + } + + /// + /// Removes a contiguous range from this segment. + /// + /// Start index (inclusive). + /// Number of items to remove. + public void RemoveRangeIndices(int start, int removeCount) + { + if (removeCount <= 0) return; + int tail = Count - (start + removeCount); + if (tail > 0) Array.Copy(Items, start + removeCount, Items, start, tail); + Count -= removeCount; + if (Count == 0) + { + MinTicks = long.MaxValue; + MaxTicks = long.MinValue; + } + else + { + MinTicks = Items[0].Timestamp.UtcTicks; + MaxTicks = Items[Count - 1].Timestamp.UtcTicks; + } + } + + /// + /// Binary search: first index with timestamp ticks >= . + /// + /// UTC ticks to search for. + /// Index of the lower bound within this segment. + public int LowerBound(long ticks) + { + int lo = 0, hi = Count; + while (lo < hi) + { + int mid = lo + ((hi - lo) >> 1); + long m = Items[mid].Timestamp.UtcTicks; + if (m < ticks) lo = mid + 1; else hi = mid; + } + return lo; + } + + /// + /// Binary search: first index with timestamp ticks > . + /// + /// UTC ticks to search for. + /// Index of the upper exclusive bound within this segment. + public int UpperExclusive(long ticks) + { + int lo = 0, hi = Count; + while (lo < hi) + { + int mid = lo + ((hi - lo) >> 1); + long m = Items[mid].Timestamp.UtcTicks; + if (m <= ticks) lo = mid + 1; else hi = mid; + } + return lo; + } + } + + private readonly List _segments = []; + private int _count; + private readonly int _segmentCapacity; + private readonly Lock _lock = new(); + + private const int DefaultSegmentCapacity = 1024; + + /// + /// Initializes a new with a given segment capacity. + /// + /// Maximum number of items per segment; must be > 0. + public TemporalSegmentedArray(int segmentCapacity = DefaultSegmentCapacity) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(segmentCapacity); + + _segmentCapacity = segmentCapacity; + } + + /// + /// Gets the total number of items currently stored. + /// + public int Count + { + get + { + lock(_lock) + { + return _count; + } + } + } + + /// + /// Gets the current number of segments. + /// + public int SegmentCount + { + get + { + lock (_lock) + { + return _segments.Count; + } + } + } + + /// + /// Returns an enumerator over a stable snapshot in chronological order. + /// Enumeration never holds locks. + /// + public IEnumerator> GetEnumerator() + { + var snap = ToArray(); + for (int i = 0; i < snap.Length; i++) + yield return snap[i]; + } + + #region Mutation API + + /// + /// Adds a temporal item, keeping the overall order by timestamp. + /// Fast-path appends to the last segment if timestamps are non-decreasing; + /// otherwise inserts positionally (and may split a full segment). + /// + /// Item to add. + public void Add(TemporalItem item) + { + lock (_lock) + { + if (_segments.Count == 0) + { + var s = new Segment(_segmentCapacity); + s.Append(item); + _segments.Add(s); + _count = 1; + return; + } + + var last = _segments[^1]; + long t = item.Timestamp.UtcTicks; + if (last.Count < _segmentCapacity && (last.Count == 0 || last.MaxTicks <= t)) + { + last.Append(item); + _count++; + return; + } + + var (segIdx, elemIdx) = LowerBoundGlobal(t); + InsertAt(segIdx, elemIdx, item); + _count++; + } + } + + /// + /// Creates a with a monotonic UTC timestamp and adds it. + /// + /// Value to wrap. + /// The created . + public TemporalItem AddValue(T value) + { + var it = TemporalItem.Create(value); + Add(it); + return it; + } + + /// + /// Adds a sequence of items, inserting each in sorted position. + /// If the input is globally sorted, prefer . + /// + /// Items to add. + public void AddRange(IEnumerable> items) + { + foreach (var it in items) + Add(it); + } + + /// + /// Adds items known to be globally sorted by timestamp ascending. + /// Optimizes the append path; falls back to positional insert if ordering is violated. + /// + /// Sorted items to add. + public void AddSorted(IEnumerable> sortedItems) + { + lock (_lock) + { + foreach (var it in sortedItems) + { + if (_segments.Count == 0) + { + var s = new Segment(_segmentCapacity); + s.Append(it); + _segments.Add(s); + _count = 1; + continue; + } + var last = _segments[^1]; + long t = it.Timestamp.UtcTicks; + if (last.Count < _segmentCapacity && (last.Count == 0 || last.MaxTicks <= t)) + { + last.Append(it); + _count++; + } + else + { + var (segIdx, elemIdx) = LowerBoundGlobal(t); + InsertAt(segIdx, elemIdx, it); + _count++; + } + } + } + } + #endregion + + #region TimeQueryableBase overrides + + /// + public override IEnumerable> GetInRange(DateTimeOffset from, DateTimeOffset to) + { + lock (_lock) + { + if (_count == 0) + return []; + long f = from.UtcTicks, t = to.UtcTicks; + if (f > t) + (f, t) = (t, f); + var list = new List>(); + int startSeg = FirstSegWithMaxGte(f); + if (startSeg < 0) + return []; + for (int s = startSeg; s < _segments.Count; s++) + { + var seg = _segments[s]; + if (seg.MinTicks > t) + break; + int i0 = seg.LowerBound(f); + int i1 = seg.UpperExclusive(t); + for (int i = i0; i < i1; i++) + list.Add(seg.Items[i]); + } + return list.Count == 0 ? [] : list.ToArray(); + } + } + + /// + public override void RemoveOlderThan(DateTimeOffset cutoff) + { + lock (_lock) + { + if (_count == 0) + return; + long c = cutoff.UtcTicks; + + // Drop whole segments strictly before cutoff + int drop = 0; + while (drop < _segments.Count && _segments[drop].MaxTicks < c) drop++; + if (drop > 0) _segments.RemoveRange(0, drop); + + if (_segments.Count == 0) + { + _count = 0; + return; + } + + // Trim partially the first remaining segment + var first = _segments[0]; + if (first.MinTicks < c && first.MaxTicks >= c) + { + int cut = first.LowerBound(c); + first.RemoveRangeIndices(0, cut); + } + + // Recompute count + _count = 0; + foreach (var seg in _segments) + _count += seg.Count; + } + } + + /// + public override int CountInRange(DateTimeOffset from, DateTimeOffset to) + { + lock (_lock) + { + if (_count == 0) + return 0; + long f = from.UtcTicks, t = to.UtcTicks; + if (f > t) + (f, t) = (t, f); + int total = 0; + int startSeg = FirstSegWithMaxGte(f); + if (startSeg < 0) + return 0; + for (int s = startSeg; s < _segments.Count; s++) + { + var seg = _segments[s]; + if (seg.MinTicks > t) break; + int i0 = seg.LowerBound(f); + int i1 = seg.UpperExclusive(t); + total += Math.Max(0, i1 - i0); + } + return total; + } + } + + /// + public override void RemoveRange(DateTimeOffset from, DateTimeOffset to) + { + lock (_lock) + { + if (_count == 0) return; + long f = from.UtcTicks, t = to.UtcTicks; + if (f > t) + (f, t) = (t, f); + + int firstHit = FirstSegWithMaxGte(f); + if (firstHit < 0) + return; + + for (int s = firstHit; s < _segments.Count; s++) + { + var seg = _segments[s]; + if (seg.MinTicks > t) + break; + + if (f <= seg.MinTicks && seg.MaxTicks <= t) + { + _segments.RemoveAt(s); + s--; + continue; + } + + int i0 = seg.LowerBound(f); + int i1 = seg.UpperExclusive(t); + seg.RemoveRangeIndices(i0, i1 - i0); + if (seg.Count == 0) + { + _segments.RemoveAt(s); + s--; + } + } + + // Recompute count + _count = 0; + foreach (var seg in _segments) + _count += seg.Count; + } + } + + /// + public override IEnumerable> GetBefore(DateTimeOffset time) + { + lock (_lock) + { + if (_count == 0) + return []; + long c = time.UtcTicks; + var list = new List>(); + foreach (var seg in _segments) + { + if (seg.MinTicks >= c) + break; + int end = seg.LowerBound(c); + for (int i = 0; i < end; i++) + list.Add(seg.Items[i]); + } + return list.Count == 0 ? [] : list.ToArray(); + } + } + + /// + public override IEnumerable> GetAfter(DateTimeOffset time) + { + lock (_lock) + { + if (_count == 0) + return []; + long c = time.UtcTicks; + var list = new List>(); + int sIdx = FirstSegWithMaxGte(c + 1); + if (sIdx < 0) + return []; + for (int s = sIdx; s < _segments.Count; s++) + { + var seg = _segments[s]; + int start = seg.UpperExclusive(c); + for (int i = start; i < seg.Count; i++) + list.Add(seg.Items[i]); + } + return list.Count == 0 ? [] : list.ToArray(); + } + } + + /// + public override int CountSince(DateTimeOffset from) + { + lock (_lock) + { + if (_count == 0) + return 0; + long f = from.UtcTicks; + int total = 0; + int sIdx = FirstSegWithMaxGte(f); + if (sIdx < 0) + return 0; + for (int s = sIdx; s < _segments.Count; s++) + { + var seg = _segments[s]; + int start = seg.LowerBound(f); + total += seg.Count - start; + } + return total; + } + } + + /// + public override TemporalItem? GetNearest(DateTimeOffset time) + { + lock (_lock) + { + if (_count == 0) + return null; + long x = time.UtcTicks; + int sIdx = FirstSegWithMaxGte(x); + if (sIdx < 0) + return _segments[^1].Items[_segments[^1].Count - 1]; + var seg = _segments[sIdx]; + int idx = seg.LowerBound(x); + TemporalItem? candAfter = idx < seg.Count ? seg.Items[idx] : null; + TemporalItem? candBefore = null; + if (idx > 0) candBefore = seg.Items[idx - 1]; + else if (sIdx > 0) + { + var prevSeg = _segments[sIdx - 1]; + if (prevSeg.Count > 0) + candBefore = prevSeg.Items[prevSeg.Count - 1]; + } + if (candBefore is null) + return candAfter; + if (candAfter is null) + return candBefore; + long dPrev = Math.Abs(x - candBefore.Timestamp.UtcTicks); + long dNext = Math.Abs(candAfter.Timestamp.UtcTicks - x); + return (dPrev <= dNext) ? candBefore : candAfter; + } + } + + /// + public override TimeSpan GetTimeSpan() + { + lock (_lock) + { + if (_count <= 1) + return TimeSpan.Zero; + var first = _segments[0]; + var last = _segments[^1]; + return last.Items[last.Count - 1].Timestamp - first.Items[0].Timestamp; + } + } + + /// + public override TemporalItem? GetLatest() + { + lock (_lock) + { + if (_count == 0) + return null; + var last = _segments[^1]; + return last.Count == 0 ? null : last.Items[last.Count - 1]; + } + } + + /// + public override TemporalItem? GetEarliest() + { + lock (_lock) + { + if (_count == 0) + return null; + var first = _segments[0]; + return first.Count == 0 ? null : first.Items[0]; + } + } + + /// + public override void Clear() + { + lock (_lock) + { + _segments.Clear(); + _count = 0; + } + } + #endregion + + #region Utilities + + /// + /// Materializes a compact array snapshot of the current contents in chronological order. + /// + /// An array containing all temporal items. + public TemporalItem[] ToArray() + { + lock (_lock) + { + if (_count == 0) + return []; + var arr = new TemporalItem[_count]; + int pos = 0; + foreach (var seg in _segments) + { + if (seg.Count == 0) + continue; + Array.Copy(seg.Items, 0, arr, pos, seg.Count); + pos += seg.Count; + } + return arr; + } + } + + /// + /// Shrinks internal arrays to fit their actual counts (useful after heavy purges). + /// + public void TrimExcess() + { + lock (_lock) + { + foreach (var seg in _segments) + { + if (seg.Count == seg.Items.Length) + continue; + Array.Resize(ref seg.Items, seg.Count); + } + _segments.TrimExcess(); + } + } + #endregion + + #region Private helpers + + /// + /// Finds the first segment whose MaxTicks is greater than or equal to . + /// + /// Target UTC ticks. + /// Segment index; -1 if no segment satisfies the condition. + private int FirstSegWithMaxGte(long ticks) + { + int lo = 0, hi = _segments.Count - 1; + if (hi < 0) + return -1; + if (_segments[hi].MaxTicks < ticks) + return -1; + while (lo < hi) + { + int mid = lo + ((hi - lo) >> 1); + if (_segments[mid].MaxTicks < ticks) + lo = mid + 1; else hi = mid; + } + return lo; + } + + /// + /// Global lower bound: first position whose timestamp ticks is greater than or equal to . + /// + /// UTC ticks to locate. + /// Tuple (segmentIndex, elementIndex) pointing to the insertion location. + private (int segIdx, int elemIdx) LowerBoundGlobal(long ticks) + { + int s = FirstSegWithMaxGte(ticks); + if (s < 0) + return (_segments.Count - 1, _segments[^1].Count); + var seg = _segments[s]; + int i = seg.LowerBound(ticks); + return (s, i); + } + + /// + /// Inserts an item at the global position; splits the segment when full. + /// + /// Target segment index. + /// Insertion index within the segment. + /// Item to insert. + private void InsertAt(int segIdx, int elemIdx, in TemporalItem item) + { + var seg = _segments[segIdx]; + if (!seg.IsFull) + { + seg.InsertAt(elemIdx, item); + return; + } + + // Split: move upper half to a new right segment + int move = seg.Count / 2; + var right = new Segment(Math.Max(_segmentCapacity, move)); + int startMove = seg.Count - move; + Array.Copy(seg.Items, startMove, right.Items, 0, move); + right.Count = move; + right.MinTicks = right.Items[0].Timestamp.UtcTicks; + right.MaxTicks = right.Items[move - 1].Timestamp.UtcTicks; + + // Shrink left segment + seg.Count = startMove; + seg.MinTicks = seg.Items[0].Timestamp.UtcTicks; + seg.MaxTicks = seg.Items[seg.Count - 1].Timestamp.UtcTicks; + + // Link the new segment + _segments.Insert(segIdx + 1, right); + + // Insert into the appropriate segment + if (elemIdx <= seg.Count) + seg.InsertAt(elemIdx, item); + else + right.InsertAt(elemIdx - seg.Count, item); + } + #endregion + } +} \ No newline at end of file diff --git a/src/TemporalCollections/Collections/TemporalSet.cs b/src/TemporalCollections/Collections/TemporalSet.cs index 284c11f..f5c8750 100644 --- a/src/TemporalCollections/Collections/TemporalSet.cs +++ b/src/TemporalCollections/Collections/TemporalSet.cs @@ -3,7 +3,6 @@ using System.Collections.Concurrent; using TemporalCollections.Abstractions; using TemporalCollections.Models; -using TemporalCollections.Utilities; namespace TemporalCollections.Collections { @@ -13,12 +12,19 @@ namespace TemporalCollections.Collections /// Public API uses DateTime; internal comparisons use DateTimeOffset (UTC). /// /// Type of items stored in the set; must be non-nullable. - public class TemporalSet : ITimeQueryable where T : notnull + public class TemporalSet : TimeQueryableBase where T : notnull { - private readonly ConcurrentDictionary> _dict = new(); + private readonly ConcurrentDictionary> _dict; - // Centralized policy for DateTimeKind.Unspecified handling. - private const UnspecifiedPolicy DefaultPolicy = UnspecifiedPolicy.AssumeUtc; + /// + /// Creates a new temporal set. An optional equality comparer can be supplied to + /// customize item equality (e.g., case-insensitive strings). + /// + /// Equality comparer for items; defaults to . + public TemporalSet(IEqualityComparer? comparer = null) + { + _dict = new ConcurrentDictionary>(comparer ?? EqualityComparer.Default); + } /// /// Adds an item to the set with the current timestamp if not already present. @@ -42,10 +48,11 @@ public bool Add(T item) /// /// Returns all temporal items whose timestamps fall within the specified inclusive range. /// - public IEnumerable> GetInRange(DateTime from, DateTime to) + public override IEnumerable> GetInRange(DateTimeOffset from, DateTimeOffset to) { - var (fromUtc, toUtc) = TimeNormalization.NormalizeRange(from, to, unspecifiedPolicy: DefaultPolicy); - long f = fromUtc.UtcTicks, t = toUtc.UtcTicks; + long f = from.UtcTicks, t = to.UtcTicks; + if (f > t) + (f, t) = (t, f); return _dict.Values .Where(i => f <= i.Timestamp.UtcTicks && i.Timestamp.UtcTicks <= t) @@ -56,9 +63,9 @@ public IEnumerable> GetInRange(DateTime from, DateTime to) /// /// Removes all items whose timestamps are older than the specified cutoff date (strictly less). /// - public void RemoveOlderThan(DateTime cutoff) + public override void RemoveOlderThan(DateTimeOffset cutoff) { - long c = TimeNormalization.UtcTicks(cutoff, DefaultPolicy); + long c = cutoff.UtcTicks; foreach (var kv in _dict) { @@ -82,28 +89,34 @@ public IEnumerable> GetItems() /// /// Calculates the time span between the earliest and latest items in the set. /// - public TimeSpan GetTimeSpan() + public override TimeSpan GetTimeSpan() { - if (_dict.IsEmpty) return TimeSpan.Zero; - - var snapshot = _dict.Values.ToList(); - if (snapshot.Count < 2) return TimeSpan.Zero; + var snapshot = _dict.Values; + using var e = snapshot.GetEnumerator(); + if (!e.MoveNext()) return TimeSpan.Zero; - long minTicks = snapshot.Min(i => i.Timestamp.UtcTicks); - long maxTicks = snapshot.Max(i => i.Timestamp.UtcTicks); + long min = e.Current.Timestamp.UtcTicks; + long max = min; - long delta = maxTicks - minTicks; + while (e.MoveNext()) + { + long x = e.Current.Timestamp.UtcTicks; + if (x < min) min = x; + else if (x > max) max = x; + } + long delta = max - min; return delta > 0 ? TimeSpan.FromTicks(delta) : TimeSpan.Zero; } /// /// Counts the number of items whose timestamps fall within the specified inclusive time range. /// - public int CountInRange(DateTime from, DateTime to) + public override int CountInRange(DateTimeOffset from, DateTimeOffset to) { - var (fromUtc, toUtc) = TimeNormalization.NormalizeRange(from, to, unspecifiedPolicy: DefaultPolicy); - long f = fromUtc.UtcTicks, t = toUtc.UtcTicks; + long f = from.UtcTicks, t = to.UtcTicks; + if (f > t) + (f, t) = (t, f); int count = 0; foreach (var ti in _dict.Values) @@ -117,15 +130,16 @@ public int CountInRange(DateTime from, DateTime to) /// /// Removes all items from the set. /// - public void Clear() => _dict.Clear(); + public override void Clear() => _dict.Clear(); /// /// Removes all items whose timestamps fall within the specified inclusive time range. /// - public void RemoveRange(DateTime from, DateTime to) + public override void RemoveRange(DateTimeOffset from, DateTimeOffset to) { - var (fromUtc, toUtc) = TimeNormalization.NormalizeRange(from, to, unspecifiedPolicy: DefaultPolicy); - long f = fromUtc.UtcTicks, t = toUtc.UtcTicks; + long f = from.UtcTicks, t = to.UtcTicks; + if (f > t) + (f, t) = (t, f); foreach (var kv in _dict) { @@ -138,7 +152,7 @@ public void RemoveRange(DateTime from, DateTime to) /// /// Gets the temporal item with the latest timestamp, or null if empty. /// - public TemporalItem? GetLatest() + public override TemporalItem? GetLatest() { if (_dict.IsEmpty) return null; // MaxBy on ticks to be explicit and consistent @@ -148,7 +162,7 @@ public void RemoveRange(DateTime from, DateTime to) /// /// Gets the temporal item with the earliest timestamp, or null if empty. /// - public TemporalItem? GetEarliest() + public override TemporalItem? GetEarliest() { if (_dict.IsEmpty) return null; return _dict.Values.MinBy(ti => ti.Timestamp.UtcTicks); @@ -157,9 +171,9 @@ public void RemoveRange(DateTime from, DateTime to) /// /// Gets all items whose timestamps are strictly earlier than the specified time. /// - public IEnumerable> GetBefore(DateTime time) + public override IEnumerable> GetBefore(DateTimeOffset time) { - long cutoff = TimeNormalization.UtcTicks(time, DefaultPolicy); + long cutoff = time.UtcTicks; return _dict.Values .Where(i => i.Timestamp.UtcTicks < cutoff) @@ -170,9 +184,9 @@ public IEnumerable> GetBefore(DateTime time) /// /// Gets all items whose timestamps are strictly later than the specified time. /// - public IEnumerable> GetAfter(DateTime time) + public override IEnumerable> GetAfter(DateTimeOffset time) { - long cutoff = TimeNormalization.UtcTicks(time, DefaultPolicy); + long cutoff = time.UtcTicks; return _dict.Values .Where(i => i.Timestamp.UtcTicks > cutoff) @@ -183,9 +197,9 @@ public IEnumerable> GetAfter(DateTime time) /// /// Counts the number of items with timestamp greater than or equal to the specified cutoff. /// - public int CountSince(DateTime from) + public override int CountSince(DateTimeOffset from) { - long f = TimeNormalization.UtcTicks(from, DefaultPolicy); + long f = from.UtcTicks; int count = 0; foreach (var item in _dict.Values) @@ -202,11 +216,11 @@ public int CountSince(DateTime from) /// In case of a tie (same distance before/after), the later item (timestamp ≥ time) is returned. /// Complexity: O(n). /// - public TemporalItem? GetNearest(DateTime time) + public override TemporalItem? GetNearest(DateTimeOffset time) { if (_dict.IsEmpty) return null; - long target = TimeNormalization.UtcTicks(time, DefaultPolicy); + long target = time.UtcTicks; TemporalItem? best = null; long bestDiff = long.MaxValue; @@ -245,5 +259,12 @@ public int CountSince(DateTime from) /// Returns the number of items currently in the set. /// public int Count => _dict.Count; + + /// + /// Indicates whether the set is currently empty. + /// This check is O(1) and thread-safe, as it relies on the underlying + /// property. + /// + public bool IsEmpty => _dict.IsEmpty; } } \ No newline at end of file diff --git a/src/TemporalCollections/Collections/TemporalSlidingWindowSet.cs b/src/TemporalCollections/Collections/TemporalSlidingWindowSet.cs index ac84c72..f91930d 100644 --- a/src/TemporalCollections/Collections/TemporalSlidingWindowSet.cs +++ b/src/TemporalCollections/Collections/TemporalSlidingWindowSet.cs @@ -3,25 +3,20 @@ using System.Collections.Concurrent; using TemporalCollections.Abstractions; using TemporalCollections.Models; -using TemporalCollections.Utilities; namespace TemporalCollections.Collections { /// - /// A thread-safe set of temporal items where each item is retained only within a sliding time window. - /// Items older than the configured window size are considered expired and can be removed. - /// Implements for time-based queries and cleanup. - /// Public API uses DateTime; internal comparisons are done with DateTimeOffset (UTC). + /// Thread-safe sorted list of , ordered by timestamp ascending. + /// Implements for time-based querying and cleanup. + /// Public API uses DateTime; internal comparisons are done with DateTimeOffset (UTC) internally. /// - /// Type of items stored in the set; must be non-nullable. - public class TemporalSlidingWindowSet : ITimeQueryable where T : notnull + /// The type of items stored in the list. + public class TemporalSlidingWindowSet : TimeQueryableBase where T : notnull { private readonly TimeSpan _windowSize; private readonly ConcurrentDictionary> _dict = new(); - // Centralized policy for DateTimeKind.Unspecified handling. - private const UnspecifiedPolicy DefaultPolicy = UnspecifiedPolicy.AssumeUtc; - /// /// Initializes a new instance of the class with the specified window size. /// @@ -74,10 +69,11 @@ public IEnumerable> GetItems() /// /// Retrieves all temporal items in the inclusive range [from, to], ordered by timestamp. /// - public IEnumerable> GetInRange(DateTime from, DateTime to) + public override IEnumerable> GetInRange(DateTimeOffset from, DateTimeOffset to) { - var (fromUtc, toUtc) = TimeNormalization.NormalizeRange(from, to, unspecifiedPolicy: DefaultPolicy); - long f = fromUtc.UtcTicks, t = toUtc.UtcTicks; + long f = from.UtcTicks, t = to.UtcTicks; + if (f > t) + (f, t) = (t, f); return _dict.Values .Where(i => f <= i.Timestamp.UtcTicks && i.Timestamp.UtcTicks <= t) @@ -88,9 +84,9 @@ public IEnumerable> GetInRange(DateTime from, DateTime to) /// /// Removes all items with Timestamp < cutoff (exclusive). /// - public void RemoveOlderThan(DateTime cutoff) + public override void RemoveOlderThan(DateTimeOffset cutoff) { - long c = TimeNormalization.UtcTicks(cutoff, DefaultPolicy); + long c = cutoff.UtcTicks; foreach (var kvp in _dict) { @@ -105,7 +101,7 @@ public void RemoveOlderThan(DateTime cutoff) /// Returns the total timespan covered by items, /// computed as (latest.Timestamp - earliest.Timestamp). Returns TimeSpan.Zero if < 2 items. /// - public TimeSpan GetTimeSpan() + public override TimeSpan GetTimeSpan() { bool any = false; DateTimeOffset min = DateTimeOffset.MaxValue; @@ -127,10 +123,11 @@ public TimeSpan GetTimeSpan() /// /// Returns the number of items with timestamps in the inclusive range [from, to]. /// - public int CountInRange(DateTime from, DateTime to) + public override int CountInRange(DateTimeOffset from, DateTimeOffset to) { - var (fromUtc, toUtc) = TimeNormalization.NormalizeRange(from, to, unspecifiedPolicy: DefaultPolicy); - long f = fromUtc.UtcTicks, t = toUtc.UtcTicks; + long f = from.UtcTicks, t = to.UtcTicks; + if (f > t) + (f, t) = (t, f); int count = 0; foreach (var item in _dict.Values) @@ -144,15 +141,16 @@ public int CountInRange(DateTime from, DateTime to) /// /// Removes all items from the collection. /// - public void Clear() => _dict.Clear(); + public override void Clear() => _dict.Clear(); /// /// Removes all items whose timestamps fall within the inclusive range [from, to]. /// - public void RemoveRange(DateTime from, DateTime to) + public override void RemoveRange(DateTimeOffset from, DateTimeOffset to) { - var (fromUtc, toUtc) = TimeNormalization.NormalizeRange(from, to, unspecifiedPolicy: DefaultPolicy); - long f = fromUtc.UtcTicks, t = toUtc.UtcTicks; + long f = from.UtcTicks, t = to.UtcTicks; + if (f > t) + (f, t) = (t, f); foreach (var kvp in _dict) { @@ -165,7 +163,7 @@ public void RemoveRange(DateTime from, DateTime to) /// /// Retrieves the latest (most recent) item by timestamp, or null if the set is empty. /// - public TemporalItem? GetLatest() + public override TemporalItem? GetLatest() { TemporalItem? best = null; long bestTicks = long.MinValue; @@ -181,7 +179,7 @@ public void RemoveRange(DateTime from, DateTime to) /// /// Retrieves the earliest (oldest) item by timestamp, or null if the set is empty. /// - public TemporalItem? GetEarliest() + public override TemporalItem? GetEarliest() { TemporalItem? best = null; long bestTicks = long.MaxValue; @@ -197,9 +195,9 @@ public void RemoveRange(DateTime from, DateTime to) /// /// Retrieves all items with timestamp strictly before , ordered by ascending timestamp. /// - public IEnumerable> GetBefore(DateTime time) + public override IEnumerable> GetBefore(DateTimeOffset time) { - long cutoff = TimeNormalization.UtcTicks(time, DefaultPolicy); + long cutoff = time.UtcTicks; var result = new List>(); foreach (var item in _dict.Values) @@ -213,9 +211,9 @@ public IEnumerable> GetBefore(DateTime time) /// /// Retrieves all items with timestamp strictly after , ordered by ascending timestamp. /// - public IEnumerable> GetAfter(DateTime time) + public override IEnumerable> GetAfter(DateTimeOffset time) { - long cutoff = TimeNormalization.UtcTicks(time, DefaultPolicy); + long cutoff = time.UtcTicks; var result = new List>(); foreach (var item in _dict.Values) @@ -229,9 +227,9 @@ public IEnumerable> GetAfter(DateTime time) /// /// Counts the number of items with timestamp greater than or equal to the specified cutoff. /// - public int CountSince(DateTime from) + public override int CountSince(DateTimeOffset from) { - long f = TimeNormalization.UtcTicks(from, DefaultPolicy); + long f = from.UtcTicks; int count = 0; foreach (var item in _dict.Values) @@ -248,9 +246,9 @@ public int CountSince(DateTime from) /// In case of a tie (same distance before/after), the later item (timestamp ≥ time) is returned. /// Complexity: O(n). /// - public TemporalItem? GetNearest(DateTime time) + public override TemporalItem? GetNearest(DateTimeOffset time) { - long target = TimeNormalization.UtcTicks(time, DefaultPolicy); + long target = time.UtcTicks; TemporalItem? best = null; long bestDiff = long.MaxValue; diff --git a/src/TemporalCollections/Collections/TemporalSortedList.cs b/src/TemporalCollections/Collections/TemporalSortedList.cs index 0ab63c9..5fea3f1 100644 --- a/src/TemporalCollections/Collections/TemporalSortedList.cs +++ b/src/TemporalCollections/Collections/TemporalSortedList.cs @@ -2,7 +2,6 @@ // This code is licensed under MIT license (see LICENSE.txt for details) using TemporalCollections.Abstractions; using TemporalCollections.Models; -using TemporalCollections.Utilities; namespace TemporalCollections.Collections { @@ -12,14 +11,11 @@ namespace TemporalCollections.Collections /// Public method signatures use DateTime, but all comparisons are done on DateTimeOffset (UTC) internally. /// /// The type of items stored in the list. - public class TemporalSortedList : ITimeQueryable + public class TemporalSortedList : TimeQueryableBase { private readonly List> _items = []; private readonly Lock _lock = new(); - // Centralize how Unspecified DateTimes are handled. - private const UnspecifiedPolicy DefaultPolicy = UnspecifiedPolicy.AssumeUtc; - /// /// Adds a new item to the list while preserving chronological order. /// @@ -38,14 +34,23 @@ public void Add(T item) /// /// Retrieves all temporal items in the inclusive range [from, to], ordered by timestamp. /// - public IEnumerable> GetInRange(DateTime from, DateTime to) + public override IEnumerable> GetInRange(DateTimeOffset from, DateTimeOffset to) { - var (fromUtc, toUtc) = TimeNormalization.NormalizeRange(from, to, unspecifiedPolicy: DefaultPolicy); - long f = fromUtc.UtcTicks, t = toUtc.UtcTicks; + long f = from.UtcTicks, t = to.UtcTicks; + if (f > t) + (f, t) = (t, f); lock (_lock) { - if (_items.Count == 0) return []; + int n = _items.Count; + if (n == 0) return []; + + long first = _items[0].Timestamp.UtcTicks; + long last = _items[n - 1].Timestamp.UtcTicks; + + // Fast path: the requested range covers the entire list + if (f <= first && t >= last) + return _items.ToList(); int start = FindFirstIndexAtOrAfterUtcTicks(f); if (start >= _items.Count) return []; @@ -61,9 +66,9 @@ public IEnumerable> GetInRange(DateTime from, DateTime to) /// /// Removes all items with Timestamp < cutoff (exclusive). /// - public void RemoveOlderThan(DateTime cutoff) + public override void RemoveOlderThan(DateTimeOffset cutoff) { - long c = TimeNormalization.UtcTicks(cutoff, DefaultPolicy); + long c = cutoff.UtcTicks; lock (_lock) { @@ -80,16 +85,16 @@ public void RemoveOlderThan(DateTime cutoff) /// public int Count { - get - { - lock (_lock) return _items.Count; + get + { + lock (_lock) return _items.Count; } } /// /// Returns the total timespan covered by items, or TimeSpan.Zero if fewer than two. /// - public TimeSpan GetTimeSpan() + public override TimeSpan GetTimeSpan() { lock (_lock) { @@ -102,10 +107,11 @@ public TimeSpan GetTimeSpan() /// /// Returns the number of items with timestamps in the inclusive range [from, to]. /// - public int CountInRange(DateTime from, DateTime to) + public override int CountInRange(DateTimeOffset from, DateTimeOffset to) { - var (fromUtc, toUtc) = TimeNormalization.NormalizeRange(from, to, unspecifiedPolicy: DefaultPolicy); - long f = fromUtc.UtcTicks, t = toUtc.UtcTicks; + long f = from.UtcTicks, t = to.UtcTicks; + if (f > t) + (f, t) = (t, f); lock (_lock) { @@ -124,19 +130,20 @@ public int CountInRange(DateTime from, DateTime to) /// /// Removes all items from the collection. /// - public void Clear() + public override void Clear() { - lock (_lock) + lock (_lock) _items.Clear(); } /// /// Removes all items whose timestamps fall within the inclusive range [from, to]. /// - public void RemoveRange(DateTime from, DateTime to) + public override void RemoveRange(DateTimeOffset from, DateTimeOffset to) { - var (fromUtc, toUtc) = TimeNormalization.NormalizeRange(from, to, unspecifiedPolicy: DefaultPolicy); - long f = fromUtc.UtcTicks, t = toUtc.UtcTicks; + long f = from.UtcTicks, t = to.UtcTicks; + if (f > t) + (f, t) = (t, f); lock (_lock) { @@ -156,7 +163,7 @@ public void RemoveRange(DateTime from, DateTime to) /// /// Retrieves the latest item by timestamp, or null if the collection is empty. /// - public TemporalItem? GetLatest() + public override TemporalItem? GetLatest() { lock (_lock) { @@ -168,7 +175,7 @@ public void RemoveRange(DateTime from, DateTime to) /// /// Retrieves the earliest item by timestamp, or null if the collection is empty. /// - public TemporalItem? GetEarliest() + public override TemporalItem? GetEarliest() { lock (_lock) { @@ -180,9 +187,9 @@ public void RemoveRange(DateTime from, DateTime to) /// /// Retrieves all items with timestamp strictly before . /// - public IEnumerable> GetBefore(DateTime time) + public override IEnumerable> GetBefore(DateTimeOffset time) { - long cutoff = TimeNormalization.UtcTicks(time, DefaultPolicy); + long cutoff = time.UtcTicks; lock (_lock) { @@ -196,9 +203,9 @@ public IEnumerable> GetBefore(DateTime time) /// /// Retrieves all items with timestamp strictly after . /// - public IEnumerable> GetAfter(DateTime time) + public override IEnumerable> GetAfter(DateTimeOffset time) { - long cutoff = TimeNormalization.UtcTicks(time, DefaultPolicy); + long cutoff = time.UtcTicks; lock (_lock) { @@ -213,9 +220,9 @@ public IEnumerable> GetAfter(DateTime time) /// Counts the number of items with timestamp greater than or equal to the specified cutoff. /// Runs in O(log n) using binary search on the sorted timestamps. /// - public int CountSince(DateTime from) + public override int CountSince(DateTimeOffset from) { - long f = TimeNormalization.UtcTicks(from, DefaultPolicy); + long f = from.UtcTicks; lock (_lock) { @@ -234,9 +241,9 @@ public int CountSince(DateTime from) /// In case of a tie (same distance before/after), the later item (timestamp ≥ time) is returned. /// Complexity: O(log n). /// - public TemporalItem? GetNearest(DateTime time) + public override TemporalItem? GetNearest(DateTimeOffset time) { - long target = TimeNormalization.UtcTicks(time, DefaultPolicy); + long target = time.UtcTicks; lock (_lock) { diff --git a/src/TemporalCollections/Collections/TemporalStack.cs b/src/TemporalCollections/Collections/TemporalStack.cs index 91b971f..2badd56 100644 --- a/src/TemporalCollections/Collections/TemporalStack.cs +++ b/src/TemporalCollections/Collections/TemporalStack.cs @@ -2,7 +2,6 @@ // This code is licensed under MIT license (see LICENSE.txt for details) using TemporalCollections.Abstractions; using TemporalCollections.Models; -using TemporalCollections.Utilities; namespace TemporalCollections.Collections { @@ -11,23 +10,20 @@ namespace TemporalCollections.Collections /// enabling time-based queries and cleanups while keeping public method signatures in DateTime. /// /// Type of the items stored in the stack. - public class TemporalStack : ITimeQueryable + public class TemporalStack : TimeQueryableBase { private readonly List> _items = []; private readonly Lock _lock = new(); - // Centralize how Unspecified DateTimes are handled. - private const UnspecifiedPolicy DefaultPolicy = UnspecifiedPolicy.AssumeUtc; - /// /// Gets the number of items currently in the stack (O(1)). /// public int Count { - get - { - lock (_lock) - return _items.Count; + get + { + lock (_lock) + return _items.Count; } } @@ -37,7 +33,7 @@ public int Count public void Push(T item) { var temporalItem = TemporalItem.Create(item); - lock (_lock) + lock (_lock) _items.Add(temporalItem); } @@ -73,16 +69,20 @@ public TemporalItem Peek() /// Retrieves all items whose timestamps are within the inclusive range [from, to]. /// Returned items are ordered by ascending timestamp. /// - public IEnumerable> GetInRange(DateTime from, DateTime to) + public override IEnumerable> GetInRange(DateTimeOffset from, DateTimeOffset to) { - var (fromUtc, toUtc) = TimeNormalization.NormalizeRange(from, to, unspecifiedPolicy: DefaultPolicy); + long f = from.UtcTicks; + long t = to.UtcTicks; + + if (f > t) + (f, t) = (t, f); lock (_lock) { if (_items.Count == 0) return []; return _items - .Where(i => fromUtc.UtcTicks <= i.Timestamp.UtcTicks && i.Timestamp.UtcTicks <= toUtc.UtcTicks) + .Where(i => f <= i.Timestamp.UtcTicks && i.Timestamp.UtcTicks <= t) .OrderBy(i => i.Timestamp.UtcTicks) .ToList(); } @@ -92,9 +92,9 @@ public IEnumerable> GetInRange(DateTime from, DateTime to) /// Removes all items with Timestamp < cutoff (exclusive). /// Complexity: O(n). /// - public void RemoveOlderThan(DateTime cutoff) + public override void RemoveOlderThan(DateTimeOffset cutoff) { - long c = TimeNormalization.UtcTicks(cutoff, DefaultPolicy); + long c = cutoff.UtcTicks; lock (_lock) { @@ -109,23 +109,14 @@ public void RemoveOlderThan(DateTime cutoff) /// Calculates the time span between the earliest and latest timestamps. /// Returns TimeSpan.Zero if fewer than two items. /// - public TimeSpan GetTimeSpan() + public override TimeSpan GetTimeSpan() { lock (_lock) { - if (_items.Count < 2) return TimeSpan.Zero; - - var min = _items[0].Timestamp; - var max = min; - - for (int i = 1; i < _items.Count; i++) - { - var ts = _items[i].Timestamp; - if (ts < min) min = ts; - if (ts > max) max = ts; - } + int n = _items.Count; + if (n < 2) return TimeSpan.Zero; - var span = max - min; + var span = _items[^1].Timestamp - _items[0].Timestamp; return span < TimeSpan.Zero ? TimeSpan.Zero : span; } } @@ -133,14 +124,17 @@ public TimeSpan GetTimeSpan() /// /// Counts items with timestamps in [from, to] (inclusive). /// - public int CountInRange(DateTime from, DateTime to) + public override int CountInRange(DateTimeOffset from, DateTimeOffset to) { - var (fromUtc, toUtc) = TimeNormalization.NormalizeRange(from, to, unspecifiedPolicy: DefaultPolicy); + long f = from.UtcTicks; + long t = to.UtcTicks; + + if (f > t) + (f, t) = (t, f); lock (_lock) { int count = 0; - long f = fromUtc.UtcTicks, t = toUtc.UtcTicks; for (int i = 0; i < _items.Count; i++) { @@ -154,7 +148,7 @@ public int CountInRange(DateTime from, DateTime to) /// /// Removes all items. /// - public void Clear() + public override void Clear() { lock (_lock) _items.Clear(); } @@ -162,10 +156,13 @@ public void Clear() /// /// Removes all items with timestamps in [from, to] (inclusive). /// - public void RemoveRange(DateTime from, DateTime to) + public override void RemoveRange(DateTimeOffset from, DateTimeOffset to) { - var (fromUtc, toUtc) = TimeNormalization.NormalizeRange(from, to, unspecifiedPolicy: DefaultPolicy); - long f = fromUtc.UtcTicks, t = toUtc.UtcTicks; + long f = from.UtcTicks; + long t = to.UtcTicks; + + if (f > t) + (f, t) = (t, f); lock (_lock) { @@ -181,7 +178,7 @@ public void RemoveRange(DateTime from, DateTime to) /// /// Gets the most recent item by timestamp, or null if empty. O(n). /// - public TemporalItem? GetLatest() + public override TemporalItem? GetLatest() { lock (_lock) { @@ -202,7 +199,7 @@ public void RemoveRange(DateTime from, DateTime to) /// /// Gets the earliest item by timestamp, or null if empty. O(n). /// - public TemporalItem? GetEarliest() + public override TemporalItem? GetEarliest() { lock (_lock) { @@ -223,9 +220,9 @@ public void RemoveRange(DateTime from, DateTime to) /// /// Gets all items strictly before , ordered by ascending timestamp. /// - public IEnumerable> GetBefore(DateTime time) + public override IEnumerable> GetBefore(DateTimeOffset time) { - long cutoff = TimeNormalization.UtcTicks(time, DefaultPolicy); + long cutoff = time.UtcTicks; lock (_lock) { @@ -241,9 +238,9 @@ public IEnumerable> GetBefore(DateTime time) /// /// Gets all items strictly after , ordered by ascending timestamp. /// - public IEnumerable> GetAfter(DateTime time) + public override IEnumerable> GetAfter(DateTimeOffset time) { - long cutoff = TimeNormalization.UtcTicks(time, DefaultPolicy); + long cutoff = time.UtcTicks; lock (_lock) { @@ -259,9 +256,9 @@ public IEnumerable> GetAfter(DateTime time) /// /// Counts the number of items with timestamp greater than or equal to the specified cutoff. /// - public int CountSince(DateTime from) + public override int CountSince(DateTimeOffset from) { - long f = TimeNormalization.UtcTicks(from, DefaultPolicy); + long f = from.UtcTicks; lock (_lock) { @@ -281,9 +278,9 @@ public int CountSince(DateTime from) /// In case of a tie (equal distance before/after), the later item (>= time) is returned. /// Complexity: O(log n). /// - public TemporalItem? GetNearest(DateTime time) + public override TemporalItem? GetNearest(DateTimeOffset time) { - long target = TimeNormalization.UtcTicks(time, DefaultPolicy); + long target = time.UtcTicks; lock (_lock) { diff --git a/src/TemporalCollections/Extensions/TemporalCollectionExtensions.cs b/src/TemporalCollections/Extensions/TemporalCollectionExtensions.cs index 2863945..883601b 100644 --- a/src/TemporalCollections/Extensions/TemporalCollectionExtensions.cs +++ b/src/TemporalCollections/Extensions/TemporalCollectionExtensions.cs @@ -1,6 +1,7 @@ // (c) 2025 Francesco Del Re // This code is licensed under MIT license (see LICENSE.txt for details) using TemporalCollections.Abstractions; +using TemporalCollections.Models; namespace TemporalCollections.Extensions { @@ -58,7 +59,7 @@ public static HashSet ToValueHashSet( /// /// Materializes all values into a Dictionary using a key selector. - /// Later duplicates override earlier ones. + /// When duplicate keys occur, the value from the latest item (by timestamp) overwrites earlier ones. /// public static Dictionary ToValueDictionary( this ITimeQueryable source, @@ -115,5 +116,69 @@ public static IReadOnlyCollection ToReadOnlyValueCollection(this ITimeQuer .ToList() .AsReadOnly(); } + + /// + /// Groups all temporal items into fixed-size time buckets (e.g., per minute, hour, day) + /// and applies a custom aggregation function to each bucket. + /// + /// Underlying value type. + /// Type produced by the aggregation function. + /// Temporal data source to group. + /// + /// Bucket duration (e.g., TimeSpan.FromMinutes(1) for 1-minute buckets). + /// Must be strictly greater than zero. + /// + /// + /// Function that receives all instances belonging to a bucket + /// and returns an aggregated result (e.g., average, sum, latest value). + /// + /// + /// Optional reference time used to align bucket boundaries. + /// Defaults to if not provided. + /// + /// + /// An ordered sequence of tuples containing the bucket start time and the aggregation result + /// for that bucket, sorted chronologically. + /// + /// + /// Thrown when or is null. + /// + /// + /// Thrown when is less than or equal to zero. + /// + public static IEnumerable<(DateTimeOffset BucketStart, TResult Result)> BucketBy( + this ITimeQueryable source, + TimeSpan interval, + Func>, TResult> aggregator, + DateTimeOffset? alignment = null) + { + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(aggregator); + if (interval <= TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(interval)); + + var align = alignment ?? DateTimeOffset.UnixEpoch; + + static DateTimeOffset FloorTo(DateTimeOffset ts, DateTimeOffset align, TimeSpan step) + { + var delta = ts - align; + var buckets = (long)Math.Floor(delta.Ticks / (double)step.Ticks); + return align.AddTicks(buckets * step.Ticks); + } + + var groups = new SortedDictionary>>(); + foreach (var item in source.GetInRange(DateTime.MinValue, DateTime.MaxValue)) + { + var key = FloorTo(item.Timestamp, align, interval); + if (!groups.TryGetValue(key, out var list)) + { + list = new List>(); + groups[key] = list; + } + list.Add(item); + } + + foreach (var (bucket, items) in groups) + yield return (bucket, aggregator(items)); + } } } \ No newline at end of file diff --git a/src/TemporalCollections/Models/TemporalItem.cs b/src/TemporalCollections/Models/TemporalItem.cs index 8857f82..77bdb3c 100644 --- a/src/TemporalCollections/Models/TemporalItem.cs +++ b/src/TemporalCollections/Models/TemporalItem.cs @@ -9,6 +9,7 @@ namespace TemporalCollections.Models /// - Timestamps are guaranteed to be strictly increasing monotonic *per closed generic type*, /// even under high concurrency or same-tick creations. /// - UTC is used to avoid time zone / DST ambiguity at the API boundary. + /// /// Wrapped value type. public record TemporalItem(T Value, DateTimeOffset Timestamp) { diff --git a/src/TemporalCollections/TemporalCollections.csproj b/src/TemporalCollections/TemporalCollections.csproj index 63397e6..52f60c3 100644 --- a/src/TemporalCollections/TemporalCollections.csproj +++ b/src/TemporalCollections/TemporalCollections.csproj @@ -1,11 +1,11 @@  - net9.0 + net9.0;net10.0 enable enable True - 1.1.0 + 1.2.0 TemporalCollections Francesco Del Re Thread-safe .NET collections with built-in, monotonic timestamps and a unified API for fast range queries, pruning, and temporal analytics. diff --git a/src/TemporalCollections/Utilities/TimeNormalization.cs b/src/TemporalCollections/Utilities/TimeNormalization.cs index 2f94069..a62216c 100644 --- a/src/TemporalCollections/Utilities/TimeNormalization.cs +++ b/src/TemporalCollections/Utilities/TimeNormalization.cs @@ -102,9 +102,6 @@ public static long UtcTicks(DateTime dt, UnspecifiedPolicy unspecifiedPolicy = U /// /// Normalizes a [from, to] range to UTC and validates that from <= to. /// - /// - /// Thrown when from > to after normalization. - /// public static (DateTimeOffset fromUtc, DateTimeOffset toUtc) NormalizeRange( DateTime from, DateTime to, string fromName = "from", string toName = "to", @@ -112,8 +109,10 @@ public static (DateTimeOffset fromUtc, DateTimeOffset toUtc) NormalizeRange( { var f = ToUtcOffset(from, fromName, unspecifiedPolicy); var t = ToUtcOffset(to, toName, unspecifiedPolicy); + if (f > t) - throw new ArgumentException($"'{fromName}' must be <= '{toName}' (in UTC)."); + (f, t) = (t, f); + return (f, t); }