Skip to content

feat: FrozenSet module with the vocabulary of the FSharp.Core Set module - #36

Open
xperiandri wants to merge 3 commits into
feat/hash-setfrom
feat/frozen-set
Open

xperiandri wants to merge 3 commits into
feat/hash-setfrom
feat/frozen-set

Conversation

@xperiandri

@xperiandri xperiandri commented Oct 18, 2026 •

Copy link
Copy Markdown
Collaborator

Proposed Changes

Adds the FrozenSet module over System.Collections.Frozen.FrozenSet<'T> after ADR 0001 (#23). This pull request is stacked on #29 (feat/hash-set).

  • Library files: everything is in FrozenSet/, in this compile order:

    • FrozenSet.CustomComparer.fsi and .fs: the struct that forComparer creates (D12). They nest under FrozenSet.fsi and FrozenSet.fs in Visual Studio through <DependentUpon>.
    • FrozenSet.fsi and FrozenSet.fs: 39 functions, 41 on net10.0, in the canonical category order. They are the FrozenSet column of ADR Appendix C, with the parameter names and the argument order of the FSharp.Core Set module.
    • FrozenSetOption.fsi/.fs and FrozenSetTuple.fsi/.fs: the Option and Tuple namespaces.
    • FrozenSetCE.fsi and .fs, plus the frozenSet instance in ComputationExpressions.fsi and .fs, right after flatList.
    • Each module is the root module of its file, and every file has a signature file (D2). The module keeps the name of the BCL type, so there is no type abbreviation and no types file.
    • The module coexists with the BCL class System.Collections.Frozen.FrozenSet: FrozenSet.ofSeq calls the module and FrozenSet.ToFrozenSet calls the class, whichever namespace is opened last.
  • Read and transform (D1): a frozen set is built once and read many times.

    • The module offers creation, lookup, iteration, folds, conversions and the transformations of a whole set. It has no add, remove, clear or builder.
    • Every transformation is an O(n) rebuild, and its documentation says so. It collects its result in a System.Collections.Generic.HashSet with the comparer of the result. ToFrozenSet then takes that hash set over without another copy.
    • The reading functions call the members of FrozenSet (D5). exists, forall, fold, foldBack, iter and toList read the array behind Items through the FSharp.Core Array functions, without a copy. toFlatList returns Items in O(1).
    • filter, partition and the set algebra keep the comparer of the input, or of the first set. map, choose and partitionWith use the default comparer of the result type.
    • The edge cases follow .NET (D14). Where the comparer finds two different elements equal, intersect holds the element of the second set, as ImmutableHashSet.Intersect and ImmutableSortedSet.Intersect do, and union the element of the first set. The relations of two sets answer with set semantics under the comparer of the first set; FrozenSet.IsProperSupersetOf already does.
    • symmetricDifference is an extra built on HashSet.SymmetricExceptWith, because FrozenSet has no SymmetricExcept.
    • intersectMany reuses Sets.reduce of feat: HashSet and ISet modules with the vocabulary of the FSharp.Core Set module #29. unionMany collects every set in one hash set and freezes it once.
    • Exceptions follow Set (D6). A null frozen set, sequence, list or array throws ArgumentNullException with the name of the parameter. A default flat list given as a sequence is not null and fails only when it is enumerated, so ofSeq, intersectMany, unionMany, the ofSeq member of the struct, and yield! and for of frozenSet { } reject it too, before anything else, with the name of their parameter.
  • ofReadOnlySpan span and ofSpan span:

    • They wrap FrozenSet.Create with a span, and ofSpan converts its Span<'T>. A frozen set is expensive to build, so a span needs no ofArray (span.ToArray ()) and its extra copy. An empty span gives an empty frozen set.
    • Of the elements that the comparer finds equal, the first stays. The BCL does not document which one stays, so the tests pin it, and a probe checked every size from 0 to 1000 elements, in both orders, with strings and numbers.
    • Create is in the three assets of the System.Collections.Immutable package that the library references, but not in the shipped net8.0 reference assembly. The package reference stays (D10), so both target frameworks build and run.
    • The members of CustomComparerFrozenSet of the same names wrap Create (comparer, span). The functions of the module call them with the default comparer.
    • A frozen set has no builder, so there is no build, update, addRange or removeRange (D1).
  • CustomComparerFrozenSet and forComparer (D12):

    • FrozenSet.forComparer comparer creates the struct. Its members empty, ofArray, ofList, ofReadOnlySpan, ofSeq, ofSpan and singleton create frozen sets that compare with the comparer: (FrozenSet.forComparer StringComparer.OrdinalIgnoreCase).ofSeq names. Of the elements that the comparer considers equal, the set holds the first one.
    • ofSeq also gives an existing frozen set the comparer. It returns the set itself when its comparer Equals the struct's, as ToFrozenSet does (D14).
    • The remarks of forComparer say that a null comparer throws, where the BCL methods Create and ToFrozenSet take it as the default comparer. They point to FrozenSet.ofSeq and to the default value of the struct for that meaning.
    • The struct is [<Struct; NoComparison; NoEquality>] and allocates nothing. Its constructor checks the comparer: forComparer and the constructor throw ArgumentNullException with the parameter name comparer. The default value of the struct compares with EqualityComparer<'T>.Default, the comparer that the BCL takes for null.
    • A frozen set has no builder, so the struct has no builder member.
    • The creation functions of the module are the members of the struct with the default comparer. FrozenSet.empty is FrozenSet<'T>.Empty, and the empty member returns the same instance for the default comparer.
  • Alternate lookup, net10.0 only (D11):

    • alternateLookup wraps GetAlternateLookup. It throws InvalidOperationException when the comparer does not implement IAlternateEqualityComparer<'Alternate, 'T>. tryAlternateLookup wraps TryGetAlternateLookup and returns ValueNone in that case.
    • Both return the BCL AlternateLookup<'Alternate> struct. Its Contains and TryGetValue do the lookups.
    • The type parameters are <'Alternate, 'T>, so a call reads FrozenSet.alternateLookup<int, _> set. F# does not accept explicit type arguments for only some of the type parameters.
    • Both are compiled under #if NET10_0_OR_GREATER: only the net9.0 and later assets of the package have the API.
    • An F# generic function cannot be instantiated with a byref-like type (FS0412). So the wrappers cannot look up by ReadOnlySpan<char>, the alternate type of strings under StringComparer.Ordinal. Their documentation points span lookups to set.GetAlternateLookup<ReadOnlySpan<char>> (), which F# can call. The wrappers serve alternate types that are not byref-like, such as int for int64 elements with a custom comparer.
  • Option and Tuple namespaces (D3): Option.FrozenSet contains choose, tryGetValue and, on net10.0, tryAlternateLookup. Tuple.FrozenSet contains partition and partitionWith.

  • Computation expression (D7): frozenSet { } collects its elements in a ResizeArray and freezes them once. FrozenSetCE comparer { } builds frozen sets that compare with the comparer. A null comparer throws ArgumentNullException when the builder is created. frozenSet is a type function, as hashSet is.

  • Documentation: XML documentation on every function and member. The changelog lists FrozenSet under [Unreleased] and says what a user of the BCL type does differently:

    • the struct replaces Create (comparer, ...) and ToFrozenSet (source, comparer), and a null comparer throws;
    • TryGetValue is tryGetValue, Items is toFlatList, and CopyTo is toFlatList followed by FlatList.blit or FlatList.asSpan;
    • the relations take frozen sets, as the functions of Set do. A sequence goes through (FrozenSet.forComparer (FrozenSet.comparer set)).ofSeq elements, which gives its elements the comparer of the first set, as the members do. FrozenSet.ofSeq elements gives them the default comparer, which merges the elements that it finds equal first: it answers as the member when the comparer of the first set finds equal every pair that the default comparer finds equal (the default comparer, OrdinalIgnoreCase), and can answer differently when the comparer tells such elements apart (HashIdentity.Reference on two strings with the same text). Overlaps and IsSupersetOf need no conversion: they are Seq.exists and Seq.forall with contains, for every comparer;
    • Set.add and Set.remove are union and difference with a singleton, each an O(n) rebuild;
    • the mutating members of the interfaces throw NotSupportedException on the BCL type and have no function;
    • which element stays when the comparer finds two equal, and the alternate lookups.

    The recipes of the changelog were checked against the BCL members twice. A first check ran 20 000 random inputs (120 000 comparisons), with the default comparer and with a comparer that ignores case: the conversion with the comparer of the first set and the two streaming forms never differed. A second check ran every subset of four strings (two instances of "a", "A" and "b") against every sequence of up to three of them, each as an array and as a lazy sequence, with the default comparer, a comparer that ignores case and HashIdentity.Reference: the conversion with the comparer of the first set never differed, FrozenSet.ofSeq differed only with the reference comparer (80 to 128 of 2,720 inputs per relation), and the streaming forms never differed. Tests pin the recipes (below).

  • README.md, docsSrc/index.md and docsSrc/Explanations/Background.md get a FrozenSet row in the collection table.

Tests: 5,294 on net8.0 and 5,306 on net10.0. 856 and 868 of them are new; the 12 more on net10.0 test the alternate lookups. The tests compare arrays and take their cases as data rows, so every case runs and reports as a test of its own.

  • FrozenSet derives the reading, transformation and creation contracts of feat: HashSet and ISet modules with the vocabulary of the FSharp.Core Set module #29. It has no mutation facet, because a frozen set is built once.
  • The reading contract checks every relation of two sets under the comparer of the first set: the first set of each pair comes from FrozenSet.forComparer SetInputs.SameRemainder, a comparer that considers two numbers equal when their remainders in the division by 5 are equal. The creation contract runs the members of the same struct.
  • The creation contract also runs ofReadOnlySpan and ofSpan, of the module and of the struct, on a copy of every input that the test changes after the call (20 tests). FrozenSetOps fills the two new fields of the ops records.
  • FrozenSetTests covers:
    • the comparer that each function keeps or resets, and the element that intersect, intersectMany and union keep;
    • isProperSuperset of words that the second set tells apart by case;
    • the struct: forComparer and the constructor with a comparer and with null, the first of equal elements for every member, an existing frozen set that gets the comparer or comes back unchanged, and the default value of the struct;
    • which element the span creators keep: the contract only states that one element of each class stays, so the members of the struct keep the first of "apple" and "APPLE" in both orders, and the functions of the module keep the first of two equal string instances, in both orders;
    • ofSeq of the struct returns a frozen set whose comparer is equal to its comparer, not only the same instance (D14);
    • toFlatList, which shares the array of Items;
    • the order in which seven functions visit large frozen sets of five kinds, which the BCL lays out differently from small ones;
    • null inputs of every function, with the parameter names;
    • the sequence inputs: a null sequence, a default flat list and a default flat list of strings given as a sequence of objects, for ofSeq, intersectMany, unionMany and the ofSeq member of the struct (10 rows), and a flat list that is not default, which the functions take;
    • the sequence forms of the relations, for each of the six relations with the default comparer, a comparer that ignores case and a reference comparer as the comparer of the first set (18 rows): the conversion with the comparer of the first set answers as the BCL member in all of them, and the conversion with the default comparer answers as the member for the first two comparers and differently for the reference comparer, on strings with the same text that are different instances. Overlaps and IsSupersetOf as Seq.exists and Seq.forall with contains answer as the members for all three comparers (6 rows);
    • the module and the class FrozenSet side by side, with System.Collections.Frozen opened before and after this library.
  • The rows of FrozenSetTests are printable: the names of the functions in tables of functions, and cases of FrozenSetSample for the large sets, which a test builds.
  • On net10.0, FrozenSetAlternateLookupTests look up a small and a large set of int64 values by int values. The comparer implements IAlternateEqualityComparer<int, int64>. The tests expect InvalidOperationException and ValueNone for comparers without it, such as HashIdentity.Structural, and show a span lookup through the BCL method.
  • Further tests cover frozenSet { }, FrozenSetCE and both namespaces. yield! and for reject a null sequence, a default flat list, a default flat list of strings as objects, and, for yield!, a null array and a null frozen set (8 rows), each with the parameter of the expression, items or source.
  • Every new behavior of the span creators and of the comparer rule of ofSeq was broken once in the code (10 mutations: the comparer and the element that stays of the four creators, a dropped element, and the instance test of ofSeq), and a test failed each time. So was each check of the sequence inputs (8 mutations: the check of the struct, of intersectMany, of unionMany, of yield! for a sequence, an array and a frozen set, of for, and the recognition of a flat list of strings as a sequence of objects in the helper). So was each pinned part of the sequence forms of the relations (3 mutations: ofSeq of the struct converting with the default comparer, isSubset comparing in the comparer of the second set, and contains comparing in the default comparer).

Types of changes

What types of changes does your code introduce to FSharp.Collections.Immutable?
Put an x in the boxes that apply

  • Bugfix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)

The pull request adds a module and changes no existing function.

Checklist

Put an x in the boxes that apply. You can also fill these out after creating the PR. If you're unsure about any of them, don't hesitate to ask. We're here to help! This is simply a reminder of what we are going to look for before merging your code.

  • Build and tests pass locally
  • I have added tests that prove my fix is effective or that my feature works (if appropriate)
  • I have added necessary documentation (if appropriate)

Further comments

These decisions need a review:

  1. Default comparer after a type change: map, choose and partitionWith use the default comparer of the result type, also when that type is the element type. Keeping the comparer would need a type test. HashSet does the same.
  2. Many sets: intersectMany intersects the sets one by one: an intersection never grows, so the rebuilds take linear time in total. unionMany freezes once, because a union at each step would rebuild a growing frozen set, in quadratic time. unionMany of no sets is the empty frozen set with the default comparer.
  3. Alternate lookups without spans: the wrappers cannot serve ReadOnlySpan<char>, the most common alternate type, until F# can declare allows ref struct (fslang-suggestions#1476, RFC FS-1356). They stay for the alternate types that are not byref-like. Span users call the BCL method.
  4. Buffer of the computation expression: frozenSet { } collects in a ResizeArray, as D7 prescribes where the BCL has no builder. ToFrozenSet then copies the elements into a hash set. A HashSet buffer with the comparer of the expression would save that copy.
  5. Which duplicate stays: every creation function and member documents that the first of the elements that the comparer finds equal stays. The BCL documents neither Create nor ToFrozenSet that way; it is how both behave today, and the tests pin it for the module and the struct, so a new version of the package that changed it fails them.
  6. net8.0 and FrozenSet.Create: the shipped net8.0 reference assembly has no Create. The library compiles against the package, which declares the dependency System.Collections.Immutable 10.0.13 or later for both target frameworks, as before this change (empty of the struct already called Create). A net8.0 application therefore gets the package through NuGet.

🤖 Generated with Claude Code

Copilot AI balanced review requested due to automatic review settings October 18, 2026 15:38

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The broad public API and framework-specific lookup behavior need final human validation; build and tests were not independently verified.

No open issues 🎉

What changed in this PR

Adds FrozenSet bindings following ADR 0001, extending the per-collection API with build-once, read-many sets.

Changes:

  • Adds set operations, comparer-aware creation, and .NET 10 alternate lookups.
  • Provides Option/Tuple variants and computation expressions.
  • Adds contract and feature tests, project integration, and documentation.
File Description
tests/​FSharp.Collections.Immutable.Tests/​FSharp.Collections.Immutable.Tests.fsproj Registers FrozenSet tests.
tests/​FSharp.Collections.Immutable.Tests/​FrozenSet/​FrozenSetTupleTests.fs Tests reference-tuple variants.
tests/​FSharp.Collections.Immutable.Tests/​FrozenSet/​FrozenSetTests.fs Tests comparers, lookups, ordering, and null handling.
tests/​FSharp.Collections.Immutable.Tests/​FrozenSet/​FrozenSetOptionTests.fs Tests option variants.
tests/​FSharp.Collections.Immutable.Tests/​FrozenSet/​FrozenSetOps.fs Supplies shared-contract operations.
tests/​FSharp.Collections.Immutable.Tests/​FrozenSet/​FrozenSetContractTests.fs Applies shared set contracts.
tests/​FSharp.Collections.Immutable.Tests/​FrozenSet/​FrozenSetComputationExpressionTests.fs Tests computation expressions.
src/​FSharp.Collections.Immutable/​FSharp.Collections.Immutable.fsproj Registers library files in compile order.
src/​FSharp.Collections.Immutable/​FrozenSet/​FrozenSetTuple.fsi Declares tuple variants.
src/​FSharp.Collections.Immutable/​FrozenSet/​FrozenSetTuple.fs Implements tuple variants.
src/​FSharp.Collections.Immutable/​FrozenSet/​FrozenSetOption.fsi Declares option variants.
src/​FSharp.Collections.Immutable/​FrozenSet/​FrozenSetOption.fs Implements option variants.
src/​FSharp.Collections.Immutable/​FrozenSet/​FrozenSetCE.fs Adds the computation-expression builder.
src/​FSharp.Collections.Immutable/​FrozenSet/​FrozenSet.fsi Defines and documents the public API.
src/​FSharp.Collections.Immutable/​FrozenSet/​FrozenSet.fs Implements FrozenSet operations.
src/​FSharp.Collections.Immutable/​FrozenSet/​CustomComparerFrozenSet.fsi Declares comparer-aware creation.
src/​FSharp.Collections.Immutable/​FrozenSet/​CustomComparerFrozenSet.fs Implements comparer-aware creation.
src/​FSharp.Collections.Immutable/​ComputationExpressions.fs Exposes frozenSet.
README.md Lists FrozenSet support.
docsSrc/​index.md Lists FrozenSet support.
docsSrc/​Explanations/​Background.md Adds FrozenSet to the collection table.
CHANGELOG.md Records the new APIs.

Review effort: Balanced

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The broad public API and framework-specific lookups warrant human validation, with builds and tests not independently executed.

No open issues 🎉

Review effort: Balanced

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The substantial multi-target public API and framework-specific interop warrant human validation; builds and tests were not executed during review.

No open issues 🎉

Review effort: Balanced

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

The broad public API and framework-specific alternate lookups warrant human review, with builds and tests not executed during this review.

No open issues 🎉

Review effort: Balanced

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Cross-framework compatibility and contracts relying on undocumented BCL behavior need human verification; builds and tests were not independently run.

No open issues 🎉

Review effort: Balanced

@xperiandri
xperiandri force-pushed the feat/frozen-set branch 2 times, most recently from 29a16f6 to b6a3d4b Compare October 19, 2026 11:03
@xperiandri
xperiandri requested a balanced review from Copilot October 19, 2026 11:03

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Cross-target span APIs and guarantees based on undocumented BCL behavior need human validation; builds and tests were not independently verified.

1 open issue

Review effort: Balanced

Comment thread CHANGELOG.md Outdated
xperiandri and others added 3 commits October 20, 2026 18:13
… module

Adds the `FrozenSet` module over `System.Collections.Frozen.FrozenSet<'T>`
in `FrozenSet/FrozenSet.fsi` and `FrozenSet/FrozenSet.fs`: 37 functions,
39 on `net10.0`, in the canonical category order, each documented in the
signature file, with the names, the argument order and the exceptions of
the `Set` module.

- A frozen set is built once, so the module offers creation, lookup,
  iteration, folds, conversions and the transformations of a whole set,
  but no `add`, `remove`, `clear`, builder or `withComparer`. Every
  transformation collects its result in a `HashSet` with the comparer of
  the result and freezes it once with `ToFrozenSet`, which takes such a
  hash set over without another copy.
- The reading functions call the members of `FrozenSet` and pass the
  array behind `Items` to the FSharp.Core `Array` functions without a
  copy; `toFlatList` returns `Items` in O(1).
- `filter`, `partition` and the set algebra keep the comparer of their
  input, the set algebra that of the first set; `intersect` and `union`
  hold the elements of the first set where both sets have equal ones.
  `map`, `choose` and `partitionWith` compare with the default comparer
  of the result type. `symmetricDifference` is an extra, because
  `FrozenSet` has no `SymmetricExcept`.
- `intersectMany` reuses `Sets.reduce`: an intersection never grows, so
  its rebuilds at each step stay linear in total. `unionMany` folds every
  set into one hash set and freezes it once, because rebuilding a growing
  union at each step would take quadratic time.
- On `net10.0`, `alternateLookup` and `tryAlternateLookup` wrap
  `GetAlternateLookup` and `TryGetAlternateLookup`. The alternate type
  comes first, as in `FrozenSet.alternateLookup<int, _> set`. Their
  documentation points span lookups to the BCL methods, because an F#
  generic function cannot be instantiated with `ReadOnlySpan<char>`.
- The module coexists with the BCL class `FrozenSet`: `FrozenSet.ofSeq`
  and `FrozenSet.ToFrozenSet` both resolve, whichever namespace is opened
  last.
- `tryGetValue`, `choose` and `tryAlternateLookup` use `voption`, and
  `partition` and `partitionWith` struct tuples; the `Option` and `Tuple`
  namespaces repeat them with options and reference tuples.
- `frozenSet { }` collects its elements in a `ResizeArray` and freezes
  them once; `FrozenSetCE` created with an equality comparer builds
  frozen sets that compare with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- `FrozenSetOps` records the functions of `FrozenSet` for the reading,
  transformation and creation contracts; a frozen set has no mutation
  facet. Three test classes run the shared set contracts against the
  FSharp.Core `Set` oracle.
- `FrozenSetTests` covers what the contracts leave out: the comparers
  that the functions keep or reset, `toFlatList` sharing the array of
  `Items`, the order of enumeration of large frozen sets, which the BCL
  lays out differently from small ones, `null` inputs, and the module
  resolving next to the class `System.Collections.Frozen.FrozenSet`
  whichever namespace is opened last.
- On `net10.0`, `FrozenSetAlternateLookupTests` looks up a small and a
  large set of `int64` values by `int` values through a comparer that
  implements `IAlternateEqualityComparer<int, int64>`, and expects
  `InvalidOperationException` and `ValueNone` for comparers without the
  alternate type, such as `HashIdentity.Structural`.
- Further tests cover `frozenSet { }` and the `Option` and `Tuple`
  namespaces.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants