Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ All notable changes to Celerity are documented here. This project follows [Keep

### Added

- `ContainsValue(TValue? value)` on `IntDictionary<TValue, THasher>`, `LongDictionary<TValue, THasher>`, and `CelerityDictionary<TKey, TValue, THasher>` — BCL-parity `O(n)` linear scan that returns `true` if any entry's value equals `value` under `EqualityComparer<TValue>.Default`, matching `Dictionary<TKey, TValue>.ContainsValue(TValue)`. The scan walks the probe table (skipping `EMPTY_KEY` / `default(TKey)` slots so the empty `default(TValue)` payload there is not mistaken for a real entry) and, when present, the out-of-band zero-key / default-key slot. No allocation on the hot path beyond the cached `EqualityComparer<TValue>.Default` access. Closes #73.
- `ContainsValueTests` — coverage on all three dictionaries: empty-map false return, match in a regular slot, match found only in the zero-key / default-key / null-string-key slot, missing-value false return, default-`TValue` (`0`) lookup on both empty and populated dictionaries (regression check that `EMPTY_KEY` slots are skipped and not reported as `0` matches), `null`-`TValue` lookup on a reference-type value, duplicate values short-circuiting, post-resize correctness across a 100-entry insert from a tiny initial capacity, and post-`Remove` / post-`Clear` invalidation.
- `IEnumerable<T>` constructor on `IntSet`, `IntSet<THasher>`, and `CeleritySet<T, THasher>` (and the `IntSet` convenience subclass), mirroring the dictionary `IEnumerable<KeyValuePair<,>>` ctor shipped in 1.1.2. Throws `ArgumentNullException` on a null source. Unlike the dictionary ctor, duplicate elements (including duplicate `default(T)` / zero entries) are silently deduplicated to match BCL `HashSet<T>(IEnumerable<T>)` semantics — sets do not have a duplicate-key contract. When the source implements `ICollection<T>`, its `Count` is used to size the backing storage; otherwise the caller-supplied `capacity` parameter is used. The out-of-band `default(T)` / zero slot is populated correctly when the source contains it. Closes the last set-side API-parity gap for milestone 1.1.0 and unblocks future `IReadOnlySet<T>` work. Closes #69.
- `SetIEnumerableConstructorTests` — coverage for both sets: null-source and invalid-load-factor validation, empty sources, array / list / non-collection enumerable sources, duplicate-element silent dedupe (including duplicate zero / `null` / `default(T)` entries), zero-element / null-reference-element capture, 500-entry large-source round-trip, source-independence after construction, caller-specified capacity dominating the source count, cross-set copy (`CeleritySet` from an `IntSet` enumeration), and an open-generic `IntSet<Int32WangNaiveHasher>` smoke test.
- `README.md` — new "Choosing a collection" section: a decision table mapping common workloads (`int`-keyed, `long`-keyed, `Guid` / `string` / other-keyed dictionaries, the two set shapes) onto the right Celerity type, plus a short note on picking a hasher and an honest "where Celerity is not the right answer today" list (concurrent access, mutable `IDictionary<,>` consumers, `FrozenDictionary`-style build-once lookups). Sits between the Quick start and Benchmarks sections so a reader who has scanned the API surface can pick the right type without spelunking `docs/api/`. Implements the "Document when to use which collection" item from issue #15.
Expand Down
288 changes: 288 additions & 0 deletions src/Celerity.Tests/Collections/ContainsValueTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,288 @@
using Celerity.Collections;
using Celerity.Hashing;

namespace Celerity.Tests.Collections;

public class ContainsValueTests
{
// ---------------- IntDictionary ----------------

[Fact]
public void IntDictionary_EmptyMap_ReturnsFalse()
{
var map = new IntDictionary<int>();
Assert.False(map.ContainsValue(0));
Assert.False(map.ContainsValue(42));
}

[Fact]
public void IntDictionary_FindsValueInRegularSlot()
{
var map = new IntDictionary<int> { [1] = 100, [2] = 200, [3] = 300 };
Assert.True(map.ContainsValue(200));
}

[Fact]
public void IntDictionary_ReturnsFalseForMissingValue()
{
var map = new IntDictionary<int> { [1] = 100, [2] = 200 };
Assert.False(map.ContainsValue(999));
}

[Fact]
public void IntDictionary_FindsValueOnlyInZeroKeySlot()
{
var map = new IntDictionary<int>();
map[0] = 777;
Assert.True(map.ContainsValue(777));
}

[Fact]
public void IntDictionary_DefaultValueLookup_ZeroValue()
{
// After insert, value 0 must be reachable via ContainsValue.
var map = new IntDictionary<int> { [5] = 0, [6] = 1 };
Assert.True(map.ContainsValue(0));

// But an empty dictionary must NOT report 0 — the EMPTY_KEY slots
// are filled with default(TValue) and must be skipped by the scan.
var empty = new IntDictionary<int>();
Assert.False(empty.ContainsValue(0));
}

[Fact]
public void IntDictionary_DefaultValueLookup_OnlyZeroKeyHasDefaultValue()
{
// Same trap as above but with the value sitting only in the
// out-of-band zero-key slot.
var map = new IntDictionary<int>();
map[0] = 0;
Assert.True(map.ContainsValue(0));
}

[Fact]
public void IntDictionary_NullValueLookup_ReferenceType()
{
var map = new IntDictionary<string>();
map[1] = "one";
map[2] = null;
Assert.True(map.ContainsValue(null));

var noNulls = new IntDictionary<string> { [1] = "one", [2] = "two" };
Assert.False(noNulls.ContainsValue(null));
}

[Fact]
public void IntDictionary_DuplicateValues_ReturnsTrue()
{
var map = new IntDictionary<int>
{
[1] = 42,
[2] = 42,
[3] = 42,
};
Assert.True(map.ContainsValue(42));
}

[Fact]
public void IntDictionary_SurvivesResize()
{
var map = new IntDictionary<int>(capacity: 4);
for (int i = 1; i <= 100; i++)
map[i] = i * 10;

Assert.True(map.ContainsValue(770));
Assert.False(map.ContainsValue(-1));
}

[Fact]
public void IntDictionary_AfterRemove_ReturnsFalse()
{
var map = new IntDictionary<int> { [1] = 100, [2] = 200 };
map.Remove(1);
Assert.False(map.ContainsValue(100));
Assert.True(map.ContainsValue(200));
}

[Fact]
public void IntDictionary_AfterZeroKeyRemove_ReturnsFalse()
{
var map = new IntDictionary<int>();
map[0] = 555;
map[1] = 100;
map.Remove(0);
Assert.False(map.ContainsValue(555));
Assert.True(map.ContainsValue(100));
}

// ---------------- LongDictionary ----------------

[Fact]
public void LongDictionary_EmptyMap_ReturnsFalse()
{
var map = new LongDictionary<int>();
Assert.False(map.ContainsValue(0));
Assert.False(map.ContainsValue(42));
}

[Fact]
public void LongDictionary_FindsValueInRegularSlot()
{
var map = new LongDictionary<int> { [1L] = 100, [2L] = 200, [3L] = 300 };
Assert.True(map.ContainsValue(200));
}

[Fact]
public void LongDictionary_ReturnsFalseForMissingValue()
{
var map = new LongDictionary<int> { [1L] = 100, [2L] = 200 };
Assert.False(map.ContainsValue(999));
}

[Fact]
public void LongDictionary_FindsValueOnlyInZeroKeySlot()
{
var map = new LongDictionary<int>();
map[0L] = 777;
Assert.True(map.ContainsValue(777));
}

[Fact]
public void LongDictionary_DefaultValueLookup_ZeroValue()
{
var empty = new LongDictionary<int>();
Assert.False(empty.ContainsValue(0));

var map = new LongDictionary<int> { [5L] = 0, [6L] = 1 };
Assert.True(map.ContainsValue(0));
}

[Fact]
public void LongDictionary_NullValueLookup_ReferenceType()
{
var map = new LongDictionary<string>();
map[1L] = "one";
map[2L] = null;
Assert.True(map.ContainsValue(null));
}

[Fact]
public void LongDictionary_SurvivesResize()
{
var map = new LongDictionary<int>(capacity: 4);
for (long i = 1; i <= 100; i++)
map[i] = (int)(i * 10);

Assert.True(map.ContainsValue(770));
Assert.False(map.ContainsValue(-1));
}

// ---------------- CelerityDictionary ----------------

[Fact]
public void CelerityDictionary_EmptyMap_ReturnsFalse()
{
var map = new CelerityDictionary<int, int, Int32WangNaiveHasher>();
Assert.False(map.ContainsValue(0));
Assert.False(map.ContainsValue(42));
}

[Fact]
public void CelerityDictionary_FindsValueInRegularSlot()
{
var map = new CelerityDictionary<int, int, Int32WangNaiveHasher> { [1] = 100, [2] = 200, [3] = 300 };
Assert.True(map.ContainsValue(200));
}

[Fact]
public void CelerityDictionary_ReturnsFalseForMissingValue()
{
var map = new CelerityDictionary<int, int, Int32WangNaiveHasher> { [1] = 100, [2] = 200 };
Assert.False(map.ContainsValue(999));
}

[Fact]
public void CelerityDictionary_FindsValueOnlyInDefaultKeySlot_IntKey()
{
var map = new CelerityDictionary<int, int, Int32WangNaiveHasher>();
map[0] = 777;
Assert.True(map.ContainsValue(777));
}

[Fact]
public void CelerityDictionary_FindsValueOnlyInDefaultKeySlot_NullStringKey()
{
var map = new CelerityDictionary<string, int, StringFnV1AHasher>();
map[null!] = 777;
Assert.True(map.ContainsValue(777));
}

[Fact]
public void CelerityDictionary_DefaultValueLookup_ZeroValue()
{
// EMPTY_KEY slots in the probe array are populated with default(TKey)
// and default(TValue). ContainsValue must skip those.
var empty = new CelerityDictionary<int, int, Int32WangNaiveHasher>();
Assert.False(empty.ContainsValue(0));

var map = new CelerityDictionary<int, int, Int32WangNaiveHasher> { [5] = 0, [6] = 1 };
Assert.True(map.ContainsValue(0));
}

[Fact]
public void CelerityDictionary_NullValueLookup_ReferenceType()
{
var map = new CelerityDictionary<int, string, Int32WangNaiveHasher>();
map[1] = "one";
map[2] = null;
Assert.True(map.ContainsValue(null));

var noNulls = new CelerityDictionary<int, string, Int32WangNaiveHasher> { [1] = "one", [2] = "two" };
Assert.False(noNulls.ContainsValue(null));
}

[Fact]
public void CelerityDictionary_DuplicateValues_ReturnsTrue()
{
var map = new CelerityDictionary<int, int, Int32WangNaiveHasher>
{
[1] = 42,
[2] = 42,
[3] = 42,
};
Assert.True(map.ContainsValue(42));
}

[Fact]
public void CelerityDictionary_SurvivesResize()
{
var map = new CelerityDictionary<int, int, Int32WangNaiveHasher>(capacity: 4);
for (int i = 1; i <= 100; i++)
map[i] = i * 10;

Assert.True(map.ContainsValue(770));
Assert.False(map.ContainsValue(-1));
}

[Fact]
public void CelerityDictionary_AfterDefaultKeyRemove_ReturnsFalse()
{
var map = new CelerityDictionary<int, int, Int32WangNaiveHasher>();
map[0] = 555;
map[1] = 100;
map.Remove(0);
Assert.False(map.ContainsValue(555));
Assert.True(map.ContainsValue(100));
}

[Fact]
public void CelerityDictionary_AfterClear_ReturnsFalse()
{
var map = new CelerityDictionary<int, int, Int32WangNaiveHasher> { [1] = 100, [2] = 200 };
map[0] = 300;
map.Clear();
Assert.False(map.ContainsValue(100));
Assert.False(map.ContainsValue(200));
Assert.False(map.ContainsValue(300));
}
}
36 changes: 36 additions & 0 deletions src/Celerity/Collections/CelerityDictionary.cs
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,42 @@ public bool ContainsKey(TKey key)
return ProbeForKey(key) >= 0;
}

/// <summary>
/// Determines whether the dictionary contains the specified value.
/// </summary>
/// <param name="value">
/// The value to locate. Equality is determined via
/// <see cref="EqualityComparer{T}.Default"/>, matching BCL
/// <see cref="Dictionary{TKey, TValue}.ContainsValue(TValue)"/> semantics.
/// </param>
/// <returns><c>true</c> if a matching value is found; otherwise, <c>false</c>.</returns>
/// <remarks>
/// This operation is <c>O(n)</c> in the dictionary's count: it scans the
/// probe table (skipping empty slots) and, when present, the out-of-band
/// default-key slot.
/// </remarks>
public bool ContainsValue(TValue? value)
{
var valueComparer = EqualityComparer<TValue?>.Default;

if (_hasDefaultKey && valueComparer.Equals(_defaultKeyValue, value))
return true;

var keyComparer = EqualityComparer<TKey>.Default;
TKey?[] keys = _keys;
TValue?[] values = _values;
for (int i = 0; i < keys.Length; i++)
{
if (!keyComparer.Equals(keys[i], default(TKey)) &&
valueComparer.Equals(values[i], value))
{
return true;
}
}

return false;
}

/// <summary>
/// Attempts to get the value associated with the specified key.
/// </summary>
Expand Down
32 changes: 32 additions & 0 deletions src/Celerity/Collections/IntDictionary.cs
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,38 @@ public bool ContainsKey(int key)
return ProbeForKey(key) >= 0;
}

/// <summary>
/// Determines whether the dictionary contains the specified value.
/// </summary>
/// <param name="value">
/// The value to locate. Equality is determined via
/// <see cref="EqualityComparer{T}.Default"/>, matching BCL
/// <see cref="Dictionary{TKey, TValue}.ContainsValue(TValue)"/> semantics.
/// </param>
/// <returns><c>true</c> if a matching value is found; otherwise, <c>false</c>.</returns>
/// <remarks>
/// This operation is <c>O(n)</c> in the dictionary's count: it scans the
/// probe table (skipping empty slots) and, when present, the out-of-band
/// zero-key slot.
/// </remarks>
public bool ContainsValue(TValue? value)
{
var comparer = EqualityComparer<TValue?>.Default;

if (_hasZeroKey && comparer.Equals(_zeroValue, value))
return true;

int[] keys = _keys;
TValue?[] values = _values;
for (int i = 0; i < keys.Length; i++)
{
if (keys[i] != EMPTY_KEY && comparer.Equals(values[i], value))
return true;
}

return false;
}

/// <summary>
/// Attempts to get the value associated with the specified key.
/// </summary>
Expand Down
Loading