Repository navigation
fix(collections): hash sets independently of element order - #11
Conversation
Equal sets such as {1,2} and {2,1} used to get different hash codes,
because GetHashCode hashed ToString(), which lists elements in the
order they were written. Any set of sets could then miss a lookup:
{{1,2}}.Contains({2,1}) was False and {{1,2},{2,1}} kept both.
Sets now combine their element hashes with an order-independent sum,
lists hash their elements in order, and values hash their text.
Equals now also checks that both sides are the same kind (value, list
or set). Before, an empty set compared equal to an empty list and to
any plain value.
Adds a test project (tests/SPADE.Tests, xUnit) with tests for both.
Closes ReduxISU#9
rbs runs audit, format-check, lint (dotnet build) and unit tests with a coverage floor inside the repo's devcontainer, the same way it does for Redux. It reports without failing (soft) while ci.yml stays the gate; publish.yml keeps doing the NuGet release, since rbs has no NuGet push. - SPADE.slnx: the dotnet engine needs a solution. It names only DiscreteParser.csproj for now; the test project joins it with the first fix PR. ci.yml and publish.yml name DiscreteParser.csproj explicitly, so the MSB1011 a bare `dotnet build` now gives does not affect them. - rbs.toml: coverage floor 70; the goal is 80%+. - .devcontainer: SDK 10 (reads .slnx, builds the net6.0 library) plus the .NET 8 runtime the tests run on. - .gitignore: rbs's .rbs/ work directory and report.md.
Whitespace only: dotnet format SPADE.slnx, so rbs's format-check starts clean. Brace placement, indentation and blank lines; no code changes.
SPADE.slnx now names tests/SPADE.Tests, so rbs's unit-test stage runs it. coverlet.collector writes the cobertura report the coverage floor reads; without it the project gets no coverage gate at all. Same version as Redux's test project.
norkish
left a comment
There was a problem hiding this comment.
Looks great. I suggest we go with .NET 10 to keep consistent with other Redux repos. But let me know if you see a good reason to do otherwise.
| dotnet test tests/SPADE.Tests | ||
| ``` | ||
|
|
||
| The test project targets .NET 8, so you need the .NET 8 SDK or newer. The library itself still targets .NET 6. |
There was a problem hiding this comment.
Is there a reason you didn't go with .NET 10 since that's what the rest of Redux is using?
| <Project Sdk="Microsoft.NET.Sdk"> | ||
|
|
||
| <PropertyGroup> | ||
| <TargetFramework>net8.0</TargetFramework> |
There was a problem hiding this comment.
Could we make this net10.0?
| <TargetFramework>net8.0</TargetFramework> | |
| <TargetFramework>net10.0</TargetFramework> |
There was a problem hiding this comment.
Shoot! Great catch.
There was a problem hiding this comment.
Honestly, it's pure laziness. I couldn't run the tests because I only had .NET 10 installed and asking you to change it was easier than installing .NET 8 :). But I did test it with .NET 10 and all the tests passed.
The tests move to net10.0, so the container no longer needs the .NET 8 runtime feature next to the SDK 10 image. SDK 10 still reads SPADE.slnx and builds the net6.0 library.
The test project moves from net8.0 to net10.0, and ci.yml and publish.yml install only the 10.0.x SDK, which still builds the net6.0 library. DiscreteParser.csproj stays on net6.0, the NuGet package's target. The test packages (xunit.v3 3.2.2, xunit.runner.visualstudio 4.0.0, Microsoft.NET.Test.Sdk 18.9.0, coverlet.collector 10.1.0) run on net10.0 unchanged. development.md now says the tests need the .NET 10 SDK.
The measured figure changes with every PR in the stack, so the comment states only the floor and the goal; the rbs report shows the current number.
A HashSet or Dictionary files each element under the hash it had when it went in. UtilCollection is mutable, so changing a collection (or anything nested in it) after it has been hashed left it under a stale hash: Contains and equality lookups missed it and duplicates slipped in, with no error. The first GetHashCode() call now freezes the collection. A list's or set's hash is built from its elements' GetHashCode(), so they freeze too, all the way down. Add and the indexer setter throw InvalidOperationException on a frozen collection. The hash is computed once and cached. The List<UtilCollection> and HashSet<UtilCollection> constructors copy their argument instead of keeping the caller's reference, and ToList() on a list returns a copy, so a frozen collection can't be changed from outside either.
|
Added the freeze-on-hash guard (dbc816b): once a collection is hashed (for example, added to a set), it and everything inside it can't be changed, so a set can't end up holding an element under a stale hash. See "What changed" for details; Redux's full suite behaves identically with and without it. Merged up through the stack; #17 adds the same guard for multisets. |
GetHashCode writes the hash and then sets frozen. Without a barrier, another thread could see frozen before the hash and return 0. frozen is now volatile, so a thread that sees it also sees the hash; two threads computing it at once get the same value. A frozen collection can't change, so it is safe to share between threads; building one still isn't, as with List and HashSet. Documented, with a test that races threads to hash a fresh nested set.
|
Made the cached hash thread-safe: |
… threads Freezing used to happen only as a side effect of hashing, so a list that was built but never hashed could still be changed while other threads read it. Freeze() freezes a collection and everything inside it on purpose and returns it; IsFrozen() reports it. Documentation/threads.md says what is safe to share (frozen collections, sets) and what isn't (a StringParser while it parses, a collection still being built), with the parse-once, freeze, then share pattern.
|
@norkish: following up on the mutability concern before you review. Here's what changed in this PR since you last looked, and I'd like to check it matches what you had in mind. The problem. What this PR does now:
What stays the same: building a collection fully and then adding it to a set works exactly as before. Collections that are never hashed or frozen can still be changed. What changes in behaviour:
Testing:
The longer-term fix is fully immutable collections: builders, and no |
|
Filed the longer-term fix mentioned above as #42 (fully immutable collections: builders, no |
|
This looks good and reading over the changes it seems sufficiently thorough to address the stale hashing issue. Nice work. Also worth highlighting here that rbs tests and formatting have been enforced in this PR, as well. |
norkish
left a comment
There was a problem hiding this comment.
The PR grew to include essentially 5 main additions:
- Fixing a bug where identical sets that differed in order were not recognized as identical
- Fixing a bug (discovered during review process) to avoid stale hashes if collections changed (e.g., through added elements) by freezing collections
- Adding the testing suite
- Adding rbs
- Updating formatting of the entire repo to conform with required formatting
Despite there being potentially a scope crawl issue with bundling this all in one PR, this needs to move forward and I don't readily see any persisting issues.
Summary
Sets that contain other sets could miss lookups, depending only on the order their elements were written in.
{{1,2}}.Contains({2,1})returned False, and{{1,2},{2,1}}kept both copies. This fixes that, plus a related equality bug, and adds a test project so these stay fixed.Stacked on #31 (rbs report). Until #31 merges, this PR also shows its commits.
Merge order
#11, #13, #15 and #16 are stacked. Merge them into
mainin this order:dotnet format(the new base)unorderedcrossedgesis intandis setconstraints don't check their type #10):is int/is set/is listtype checksAfter each merge, the next PR shows only its own commits. If one is squash-merged, I'll rebase the next one onto
mainbefore it's reviewed.Releasing for Redux: publish a new version only after all four are merged. Redux's grammar fixes (ReduxISU/Redux#628, ReduxISU/Redux#629, ReduxISU/Redux#630) work with 0.1.2 and can land any time before the upgrade; only ReduxISU/Redux#630's test changes go in the same Redux PR that upgrades the SPADE package. Tracked in ReduxISU/Redux#627.
What changed
UtilCollection.GetHashCode: sets now combine their elements' hash codes with an order-independent sum instead of hashingToString(), which follows insertion order. Lists still hash their elements in order, and values hash their text.UtilCollection.Equals: now checks that both sides are the same kind (value, list or set) before comparing. Before, an empty set{}compared equal to an empty list()and to any plain value such asx. Equality and hashing have to agree, so this goes in the same change.tests/SPADE.Tests(xUnit, .NET 10), with tests covering reordered sets, sets of sets and tuples,Contains,Count,UnionandIntersect, and inequality across kinds. Before the fix, 12 of them fail.HashSetkeeps each element under the hash it had when it went in, andUtilCollectionis mutable. So changing a collection, or anything nested in it, after it was added to a set left it under a stale hash:Containsmissed it and duplicates slipped in, with no error. Now:GetHashCode()freezes the collection. Its elements freeze too, all the way down, because the hash is built from theirs.Addand the indexer setter throwInvalidOperationException, so the mistake is loud instead of silent.Freeze()freezes a collection and everything in it on purpose, andIsFrozen()reports it. Frozen collections are safe to read from several threads;Documentation/threads.mdexplains what is safe to share.List/HashSetconstructors copy their argument, andToList()returns a copy, so nothing outside can change a frozen collection.Documentation/utilcollection.mddescribes it.DiscreteParser.csprojexcludestests/so the library doesn't compile the test files.Documentation/development.mdexplains how to run the tests.SPADE.slnxandcoverlet.collector, so the rbs report measures coverage.ci.ymlandpublish.ymlinstall only the .NET 10 SDK. The library stays on net6.0.Testing
dotnet test tests/SPADE.Tests: 34 passed.Worth knowing before the next release
The NuGet package 0.1.2 was published on 2025-05-01, so it doesn't include the three commits after
5745eb7. One of those,d7e982e, makes everyunorderedcrossmembership check fail for normal edges like{a,b}: the new line checks whether each element of the edge is a list, rather than whether the edge itself is. Built from currentmain, with or without this PR, 236 of Redux's tests fail on that. That's tracked separately in #12, since it isn't related to hashing.Closes #9