16 Commits

Author SHA1 Message Date
mvhutz 3806331768 revert: remove duplicate of analysis 2026-07-04 19:47:01 -04:00
mvhutz 2b610eaab6 chore: moved analysis over from contract adr 2026-07-04 19:42:41 -04:00
mvhutz 00ed7c8888 style: capitalization in status for adr template
CI / Check PR Title (pull_request) Successful in 43s
CI / Makefile Lint (pull_request) Successful in 1m14s
CI / Go Lint (pull_request) Successful in 1m24s
CI / Markdown Lint (pull_request) Successful in 47s
CI / Unit Tests (pull_request) Successful in 1m15s
CI / Fuzz Tests (pull_request) Successful in 1m41s
CI / Mutation Tests (pull_request) Successful in 1m22s
2026-07-03 21:41:42 -04:00
mvhutz b96d47fa1b style: no number in ADR title
CI / Check PR Title (pull_request) Successful in 43s
CI / Go Lint (pull_request) Successful in 1m26s
CI / Markdown Lint (pull_request) Successful in 46s
CI / Makefile Lint (pull_request) Successful in 1m10s
CI / Unit Tests (pull_request) Successful in 1m7s
CI / Mutation Tests (pull_request) Successful in 1m41s
CI / Fuzz Tests (pull_request) Successful in 1m42s
2026-07-03 21:30:35 -04:00
mvhutz df5a25d349 chore: move to docs/adr
CI / Check PR Title (pull_request) Successful in 43s
CI / Makefile Lint (pull_request) Successful in 1m19s
CI / Go Lint (pull_request) Successful in 1m25s
CI / Markdown Lint (pull_request) Successful in 46s
CI / Unit Tests (pull_request) Successful in 1m13s
CI / Fuzz Tests (pull_request) Successful in 1m39s
CI / Mutation Tests (pull_request) Successful in 1m40s
2026-07-03 21:29:04 -04:00
mvhutz 3f77e230a1 revert: no old adr
CI / Check PR Title (pull_request) Successful in 43s
CI / Makefile Lint (pull_request) Successful in 1m15s
CI / Go Lint (pull_request) Successful in 1m20s
CI / Markdown Lint (pull_request) Successful in 47s
CI / Unit Tests (pull_request) Successful in 1m21s
CI / Mutation Tests (pull_request) Successful in 1m52s
CI / Fuzz Tests (pull_request) Successful in 2m28s
2026-07-03 21:22:18 -04:00
mvhutz 7bc42e7101 style: worded consequences more as a checklist 2026-07-03 21:21:50 -04:00
mvhutz 56096bd83f feat: adr for design principles, template 2026-07-03 21:05:36 -04:00
mvhutz bfe2f152e1 Merge remote-tracking branch 'origin' into docs/contract-v2 2026-07-03 19:54:05 -04:00
mvhutz 3aa5be87f2 feat: progress up to this point 2026-05-16 14:28:33 -04:00
mvhutz bd25bb69bd docs: congruency target 2026-05-09 16:57:58 -04:00
mvhutz 5c84ed7794 docs: DeleteFunc, Collect 2026-05-04 19:16:42 -04:00
mvhutz cddc205fe8 docs: insert, copy 2026-05-02 11:43:08 -04:00
mvhutz a72146ca9c docs: finished congruency, started target state 2026-05-01 17:38:52 -04:00
mvhutz f18d48a3c2 fix: wording 2026-04-29 20:53:07 -04:00
mvhutz 4464af781a feat: current contract list, started similarity 2026-04-29 20:52:15 -04:00
3 changed files with 702 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
# Title
**Status**: <!-- Proposed, accepted, rejected, etc... -->
## Context
<!-- What is the issue that we're seeing that is motivating this decision or change? -->
## Decision
<!-- What is the change that we're proposing and/or doing? -->
## Consequences
<!-- What becomes easier or more difficult to do because of this change? -->
+44
View File
@@ -0,0 +1,44 @@
# Adopt Congruent and Familiar Design For `go-cuckoo`
**Status**: Proposed
## Context
I built `go-cuckoo`'s API interface without design intent.
Up until now, I paid more attention implementing the underlying functionality of the cuckoo hashing.
With the fundamentals of the algorithm built, I should revisit the interface.
The goal of this project was to create an implementation of cuckoo hashing, while adhering to Go's idioms, and being as usable as possible.
While the implementation does work, it lacks direction.
## Decision
To resolve this, I'm enforcing two new principles onto the contract of `go-cuckoo`:
- **Congruency**:
A `go-cuckoo` table should have the same core functionality as Go's built-in map.
- **Familiarity**:
A `go-cuckoo` table should behave similarly to Go's standard map, so users will intuitively know how to use it.
In effect, its users will carry less cognitive load.
These principles should _guide_ the public interface of `go-cuckoo`.
Neither should be treated absolutely, though.
The behavior of `go-cuckoo` is distinct from `map`.
Do not equate them.
## Consequences
1. The repository should support both design principles.
- [ ] Update the `README.md` and `doc.go` to reflect these principles.
- [ ] Update the contributing guide and pull request template to require these principles are met.
2. The repository should contain a living document, describing the interface differences between `go-cuckoo` and `map`.
I should prioritize limiting any incongruencies.
- [ ] Produce the first draft to uncover any current incongruencies.
- [ ] Link the document to the `README.md`.
3. Analyze the familiarity of `go-cuckoo`'s current interface.
Unlike the analysis of congruency, this should be a one time document.
Familiarity is implicit to users, and does not need to be referenced.
But, any rationale should be documented in commit messages, or future ADRs.
- [ ] Produce the analysis document.
- [ ] Resolve any issues found.
+643
View File
@@ -0,0 +1,643 @@
# Designing an Idiomatic API Interface
- [Designing an Idiomatic API Interface](#designing-an-idiomatic-api-interface)
- [Current State](#current-state)
- [Interface of the Built-in Map](#interface-of-the-built-in-map)
- [Interface of `go-cuckoo`](#interface-of-go-cuckoo)
- [Determining Congruency](#determining-congruency)
- [Determining Familiarity](#determining-familiarity)
- [Target State](#target-state)
- [Solving Congruency](#solving-congruency)
We (the maintainers) built `go-cuckoo`'s API interface without design intent.
Up until now, we paid more attention implementing the underlying functionality of the cuckoo hashing.
With the fundamentals of the algorithm built, we should revisit the interface.
It should align closer to the following principles:
- **Congruency**
A `go-cuckoo` table should have the same core functionality as Go's built-in map.
- **Familiarity**
A `go-cuckoo` table should behave similarly to Go's standard map, so users will intuitively know how to use it.
In effect, its users will carry less cognitive load.
## Current State
### Interface of the Built-in Map
Listed below is every interface provided by Go to the built-in map object.
Also included, are the functions from the package `maps` in the standard library.
<details>
<summary>Interfaces</summary>
| # | Built-in Interface | Description |
| --- | ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| 1 | `m := make(map[K]V)` | Returns an empty map using the built-in `make()` function. |
| 2 | `m := make(map[K]V, hint)` | Returns an empty map using `make()`, with a capacity 'hint'. This hint is how many items the map expects to hold, _not_ a measure of how large it is. |
| 3 | `m := map[K]V{...}` | Returns a map, which may be filled with entries in the ellipsis (optional). |
| 4 | `var m map[K]V` | Defines an empty _variable_ that holds a map. This differs from #1 because `m` is uninitialized (nil) here. |
| 5 | `m[k] := v` | Assigns the value of `k` to `v`. |
| 6 | `v := m[k]` | Returns the value of `k` if it exists. Otherwise, `v` is uninitialized. |
| 7 | `v, ok := m[k]` | Similar to #6, except `ok` is equal to whether `v` is initialized. This is comma-ok notation. |
| 8 | `for k, v := range m` | Iterates over every key-value pair in `m`. The order is random. |
| 9 | `delete(m, k)` | Unassigns the value `k`. Returns no value. |
| 10 | `clear(m)` | Unassigns all keys in `m`. Returns no value. |
| 11 | `n := len(m)` | Returns the number of entries in `m`. If nil, `m` returns 0. |
| 12 | `m2 := maps.Clone(m)` | Returns a copy of `m`. |
| 13 | `maps.Copy(dst, src)` | Assigns every entry of `src` in `dst`. |
| 14 | `ok := maps.Equal(m1, m2)` | Returns true iff `m1` and `m2` the same entries. |
| 15 | `ok := maps.EqualFunc(m1, m2, fn)` | Like #14, but with a custom comparator for non-comparable values. |
| 16 | `maps.DeleteFunc(m, fn)` | Removes every entry in `m` which satisfies `fn`. Returns no value. |
| 17 | `it2 := maps.All(m)` | Returns an 2D iterator over every key-value pair. |
| 18 | `it := maps.Keys(m)` | Returns an iterator over every key. |
| 19 | `it := maps.Values(m)` | Returns an iterator over every value. There can be duplicates. |
| 20 | `m := maps.Collect(seq)` | Returns a map, with every entry defined in a 2D iterator over key-value pairs. |
| 21 | `maps.Insert(m, seq)` | Assigns to `m` all key-value pairs in 2D iterator `seq`. Returns no value. |
</details>
### Interface of `go-cuckoo`
On the other hand, here is the current contract for `go-cuckoo`.
<details>
<summary>Interfaces</summary>
| # | `go-cuckoo` Interface | Description |
| --- | -------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| 1 | `m := New(opts...)` | Creates a table using the default hash and equal function. The options configure its behavior. Confined to comparable keys. |
| 2 | `m := NewBy(keyFunc, opts...)` | Like #1, but allows any key type. A `keyFunc` is used to derive a comparable key. |
| 3 | `m := NewCustom(hashA, hashB, equalFunc, opts...)` | Like #1, but allows control over the hashes used to allow any key type. An `equalFunc` determines key equality. |
| 4 | `seq := m.Entries()` | Returns an unordered 2D iterator of all key-value pairs in the table. |
| 5 | `v := m.Find(k)` | Removes the value for `k`. Returns true if `k` existed. |
| 6 | `v, ok := m.Get(k)` | Returns the value for `k` in the table. Also, returns true if the `k` exists, otherwise false. When false, `v` is undefined. |
| 7 | `ok := m.Has(k)` | Returns true if `k` is in the table. |
| 8 | `err := m.Put(k, v)` | Sets value `v` for key `k`. Otherwise, returns error. |
| 9 | `n := m.Size()` | Returns the number of items in `m`. |
| 10 | `str := m.String()` | Returns `m` as a string in the format "table[k1:v1 k2:v2 ...]". |
| 11 | `cap := m.TotalCapacity()` | Returns how many slots `m` has allocated. |
| 12 | `ok := m.Drop(k)` | Removes `k` from the table. Returns whether the key had existed. |
</details>
### Determining Congruency
So, how does the core functionality compare?
Listed below is an analysis of every interface in Go's standard map.
Each is compared against what `go-cuckoo` offers, and categorized into the following groups:
- ✅ Covered: an analog exists.
- ⚠️ Partial: workaround available.
- ❌ Gap: no analog yet; addressed in [Target State](#solving-congruency).
Specifically, here we are checking for functionality.
Is there functionality that this offers which `go-cuckoo` does not?
We are checking accessibility, but not discoverability.
The latter will be considered later.
<details>
<summary>✅ <code>m := make(map[K]V)</code></summary>
The analog is `m := New()`.
</details>
<details>
<summary>⚠️ <code>m := make(map[K]V, hint)</code></summary>
This has no simple analog.
It is close to `m := New(Capacity(hint))`, but it assigns starting capacity, not expected size.
For the built-in map, these are two separate things.
- Capacity is an internal measure, used to optimize space/speed.
It is hidden from the user because it depends on the underlying implementation, which may change.
- Expected size requires the map must hold a number of items before resizing.
This is tangeable and agnostic to implementation, hence why it is given to the user.
In short, this interface defines expected size, but `Capacity()` defines capacity.
</details>
<details>
<summary>❌ <code>m := map[K]V{...}</code></summary>
This has no simple analog, the closest being:
```go
m := New[K, V]()
for k, v := range startingEntries {
m.Put(k, v)
}
```
It is idiomatic, but far less ergonomic.
</details>
<details>
<summary>✅ <code>var m map[K]V</code></summary>
The analog is `var m Table[K, V]`.
</details>
<details>
<summary>✅ <code>m[k] := v</code></summary>
The analog is `err := m.Put(k, v)`.
</details>
<details>
<summary>✅ <code>v := m[k]</code></summary>
The analog is `v := m.Find(k)`.
</details>
<details>
<summary>✅ <code>v, ok := m[k]</code></summary>
The analog is `v, ok := m.Get(k)`.
</details>
<details>
<summary>✅ <code>for k, v := range m</code></summary>
The analog is `for k, v := range m.Entries()`.
</details>
<details>
<summary>✅ <code>delete(m, k)</code></summary>
The analog is `ok := m.Drop(k)`.
</details>
<details>
<summary>❌ <code>clear(m)</code></summary>
There is no analog.
The easiest may to do this is to delete all items individually:
```go
for k := range m.Entries() {
m.Drop(k)
}
```
</details>
<details>
<summary>✅ <code>n := len(m)</code></summary>
The analog is `n := m.Size()`.
</details>
<details>
<summary>❌ <code>m2 := maps.Clone(m)</code></summary>
There is no analog.
The easiest way to do this currently is to make a new map, and manually add the items.
```go
m2 := cuckoo.Table[K, V]()
for k, v := range m.Entries() {
m2.Put(k, v)
}
```
This gets complicated by the various options available to the user.
Furthermore, any custom `EqualFunc`, `keyFunc` or `Hash` is not transferred.
</details>
<details>
<summary>❌ <code>maps.Copy(dst, src)</code></summary>
There is no analog.
The simplest way to do this is with a for-loop.
```go
for k, v := range src.Entries() {
dst.Put(k, v)
}
```
</details>
<details>
<summary>❌ <code>ok := maps.Equal(m1, m2)</code></summary>
There is no analog.
Users have to manually check the key-value pairs to determine equality.
</details>
<details>
<summary>❌ <code>ok := maps.EqualFunc(m1, m2, fn)</code></summary>
There is no analog.
Users have to manually check the key-value pairs to determine equality.
</details>
<details>
<summary>❌ <code>maps.DeleteFunc(m, fn)</code></summary>
There is no analog.
Users have to manually delete keys.
</details>
<details>
<summary>✅ <code>it2 := maps.All(m)</code></summary>
The analog is `it2 := m.Entries()`.
</details>
<details>
<summary>⚠️ <code>it := maps.Keys(m)</code></summary>
There is no simple analog.
A close neighbor is `it2 := m.Entries()`.
Users can use this in a for-loop, and pick out just the keys:
```go
for k := range m.Entries() {
// ...
}
```
</details>
<details>
<summary>⚠️ <code>it := maps.Values(m)</code></summary>
There is no simple analog.
A close neighbor is `it2 := m.Entries()`.
Users can use this in a for-loop, and pick out just the values:
```go
for _, v := range m.Entries() {
// ...
}
```
</details>
<details>
<summary>❌ <code>m := maps.Collect(seq)</code></summary>
There is no analog.
</details>
<details>
<summary>❌ <code>maps.Insert(m, seq)</code></summary>
There is no analog.
</details>
### Determining Familiarity
We can categorize all existing table functionality by each interface's familiarity:
- ✅ Idiomatic: is clear, intuitive, and easily understood.
- ❌ Non-idiomatic: is misleading; addressed in [Target State](#solving-congruency).
<details>
<summary>✅ <code>m := New(opts...)</code></summary>
Criteria:
Noun/adjective form — Go constructors use NewX where X is a noun or adjective, not a verb or past participle (NewReaderSize, not NewSized)
Names what the user provides — the suffix should hint at the distinguishing parameter (NewBufferString tells you it takes a string)
Progression is readable — the three names together should imply simple → intermediate → advanced
Not misleadingly generic — NewWith or NewConfig could mean anything
</details>
<details>
<summary><code>m := NewBy(keyFunc, opts...)</code></summary>
- Use `NewKeyed()`.
</details>
<details>
<summary><code>m := NewCustom(hashA, hashB, equalFunc, opts...)</code></summary>
- Use `NewHashed()`.
</details>
<details>
<summary>❌ <code>seq := m.Entries()</code></summary>
- Call it `All()`.
</details>
<details>
<summary><code>v := m.Find(k)</code></summary>
- Call it `m.Lookup()`. The name `m.Find` implies a search algorithm.
</details>
<details>
<summary><code>v, ok := m.Get(k)</code></summary>
</details>
<details>
<summary><code>ok := m.Has(k)</code></summary>
</details>
<details>
<summary>❌ <code>err := m.Put(k, v)</code></summary>
- Call it `Set()`.
- No built-in library consensus, but 3rd party packages prefer `Set()`.
</details>
<details>
<summary>❌ <code>n := m.Size()</code></summary>
- Call it `Len()`.
- Size is a Java idiom.
</details>
<details>
<summary>✅ <code>str := m.String()</code></summary>
</details>
<details>
<summary>❌ <code>cap := m.TotalCapacity()</code></summary>
- Remove. This is an implementation detail.
</details>
<details>
<summary>❌ <code>ok := m.Drop(k)</code></summary>
- Call it `Delete()`.
</details>
## Target State
### Solving Congruency
We should make the following changes to accomodate for congruency:
<details>
<summary><code>ok := maps.EqualFunc(m1, m2, fn)</code></summary>
We should implement a new function:
```go
func EqualFunc[K, V1, V2 any](t1 *Table[K, V1], t2 *Table[K, V2], eq func(V1, V2) bool) bool
```
This function is free, and not bound as a receiver function.
(It is called `cuckoo.Equal(t1, t2)`, not `t1.Equals(t2)`.)
The latter implies `t1` has authority, when in fact neither do.
We define equality as:
1. Neither table has a key the other doesn't.
2. Each key has the same value in each table.
Parameter `eq` determines this equality.
Custom `EqualFunc`'s complicate this, as they modulate key identity in tables.
If two tables may differ on whether two keys are different, this function might break.
So, we must assume that:
- Both tables have `EqualFunc`'s which 'agree' on the identity of the keys present in the tables.
Agreement is defined as: if two keys are distinct in one table, they are distinct in the other.
The name `EqualFunc` is already taken by `EqualFunc[K, V]`: an alias for `func(a, b K) bool`.
Inlining `EqualFunc[K, V]` would solve this problem.
We will move the documentation attached to it to `DefaultEqualFunc`.
</details>
<details>
<summary><code>ok := maps.Equal(m1, m2)</code></summary>
We should implement a new function, to conform with the standard library:
```go
func Equal[K any, V comparable](t1, t2 *Table[K, V]) bool
```
It uses the same equality check as in `EqualFunc`.
Once again, the function is free because it is symmetric.
</details>
<details>
<summary><code>maps.Insert(m, seq)</code></summary>
We should implement a new receiver for the table:
```go
func (t *Table[K, V]) Insert(seq iter.Seq2[K, V]) error
```
A receiver fits better even though `maps.Insert` is a free function, because copying it is asymmetric.
Map `dst` receives entries from map `src`.
It's only free because Go's standard map is built into the language, and so cannot have receivers.
In terms of naming, `t.Extend` is more accurate, and has precedent in [Python](docs.python.org/3/tutorial/datastructures.html#more-on-lists) and [Rust](https://doc.rust-lang.org/std/iter/trait.Extend.html).
When [adding iterator function](https://github.com/golang/go/issues/61900) to the `maps` package, the Go team chose to frame it as 'sources' and 'sinks'.
With this model, `maps.Insert` made more sense than `maps.Extend`.
Ultimately, `t.Insert()` is a better choice to be consistent with `maps`.
</details>
<details>
<summary><code>maps.Copy(dst, src)</code></summary>
We should implement a new receiver for the table:
```go
func (t *Table[K, V]) Copy(src *Table[K, V]) error
```
It's functionality should match that of `t.Insert()`.
A receiver fits better even though `maps.Copy` is a free function, 'copying' it is asymmetric: `dst` is writen into by `src`.
It is only free because Go's standard map is built into the language, and so cannot have receivers.
The name `t.Merge()` might be more accurate, but it does work because:
- `t.Copy()` matches Go's built-in `copy()`, and `io.Copy()`. The Go team used [the same logic](https://github.com/golang/go/discussions/47330#discussioncomment-1167799) to name `maps.Copy()`.
In this case, `t.Merge()` would be an outlier.
- `t.Merge()` implies some sort of conflict-resolution, when there is not.
It simply overwrites the values.
</details>
<details>
<summary><code>maps.DeleteFunc(m, fn)</code></summary>
We should implement a new receiver for the table:
```go
func (t *Table[K, V]) DeleteFunc(del func(K, V) bool)
```
It would have the same functionality as `maps.DeleteFunc`.
A free function could work here, but `t` has clear authority over `del`.
Other than being consistent with the `maps` package, `t.DeleteFunc` follows the Go convention of appending `Func` to higher-order equivalents of functions.
This trumps names like `t.DeleteIf`, which lend more to [Java](https://docs.oracle.com/javase/8/docs/api/java/util/ArrayList.html#removeIf-java.util.function.Predicate-) or [C++](https://en.cppreference.com/cpp/algorithm/remove).
The word `Delete` is also convention, tying back to the built-in `delete()`.
</details>
<details>
<summary><code>m := maps.Collect(seq)</code></summary>
We should implement a new constructor.
```go
func Collect[K comparable, V any](seq iter.Seq2[K, V]) (*Table[K, V], error)
```
It would create a `New()` table, and insert all entries in `seq`.
This reveicer only supports the standard table constructor, with comparable keys.
It is tempting to add `CollectBy` or `CollectCustom` to support all table types, but doing so would pollute the public interface.
It would be just one more line to initialize the table and then call `t.Insert` directly:
```go
t := // ...
err := t.Insert(seq)
```
</details>
<details>
<summary><code>m := map[K]V{...}</code></summary>
We should make a new constructor, because entries are generic.
So, creating an option with inialized entries doesn't work.
With the previous additions, users have a few options.
If they want to use a `New()` table, `t.Collect` matches well:
```go
t, err := cuckoo.Collect(func(yield func(K, V) bool) {
yield(key1, val1)
yield(key2, val2)
})
```
For `NewCustom()` or `NewBy()` tables, users can call `t.Insert` after initialization:
```go
t := // ...
err := t.Insert(func(yield func(K, V) bool) {
yield(key1, val1)
yield(key2, val2)
})
```
It is one more line.
But, the alternative is polluting the public interface with corresponding `*WithEntries` constuctors.
</details>
<details>
<summary><code>m := make(map[K]V, hint)</code></summary>
We should add a new option:
```go
func ExpectedSize(n int) Option
```
When fed to a table, it will allocate enough space to hold `n` entries without a resize.
</details>
<details>
<summary><code>clear(m)</code></summary>
We should implement a new receiver:
```go
func (t *Table[K, V]) Clear()
```
It will remove all entries from the table.
</details>
<details>
<summary><code>m2 := maps.Clone(m)</code></summary>
We should implement a matching function:
```go
func (t *Table[K, V]) Clone() *Table[K, V]
```
Also, it will copy the hash, equality function, and options used in the table.
</details>
<details>
<summary><code>it := maps.Keys(m)</code></summary>
We should implement a matching function:
```go
func (t *Table[K, V]) Keys() iter.Seq[K]
```
It is tempting to just have `All()`, but it returns a `Seq2`, not a `Seq`.
There is no iterator adaptor between `Seq` and `Seq2`, and will not be for the foreseeable future.
This function, while it feels superfluous, is required.
</details>
<details>
<summary><code>it := maps.Values(m)</code></summary>
We should implement a matching function:
```go
func (t *Table[K, V]) Values() iter.Seq[V]
```
For the same reason we need `Keys()`, we also need `Values()`.
</details>