Files
meta-core/internal/storage/merge.go
T

103 lines
2.8 KiB
Go

package storage
import (
"strconv"
"strings"
"github.com/metazla/meta-core/internal/claim"
)
// Vote is one signer's claim on one key, as the merge sees it. Pid identifies
// the signer (one vote per signer per key per group); Uid is carried for the
// stateless /api/merge, where claims arrive without a local pid.
type Vote struct {
Pid int64
Uid string
Value claim.Value
TS uint64
}
// MergeResult is the merged value of one key.
type MergeResult struct {
Value string
Nsrc int // signers agreeing on the winning value
OK bool
}
// Merge is the one merge rule (spec §7), shared by the apply loop, the
// ?as=<uid> overlay and POST /api/merge:
//
// 1. a signer counts once per key: its newest vote wins (it may have voted on
// several cids of the group);
// 2. the viewer's own live text vote wins outright (viewer == "" for the
// shared view);
// 3. retractions and ciphertext drop out — only text merges;
// 4. support: votes grouped by normalised value, most signers wins;
// 5. tie → the value first seen (oldest ts) — stable, and rewriting does not
// win;
// 6. tie → byte-wise smallest value, so every peer agrees.
//
// The stored value is the exact text of the oldest vote of the winning group.
func Merge(votes []Vote, viewer string) MergeResult {
newest := make(map[string]Vote, len(votes))
for _, v := range votes {
id := signerID(v)
if cur, ok := newest[id]; !ok || v.TS > cur.TS {
newest[id] = v
}
}
type bucket struct {
n int
minTS uint64
exemplar string
}
buckets := make(map[string]*bucket)
for _, v := range newest {
if v.Value.Kind != claim.KindText {
continue
}
if viewer != "" && v.Uid == viewer {
return MergeResult{Value: v.Value.Text, Nsrc: 1, OK: true}
}
norm := normalizeValue(v.Value.Text)
b := buckets[norm]
if b == nil {
buckets[norm] = &bucket{n: 1, minTS: v.TS, exemplar: v.Value.Text}
continue
}
b.n++
if v.TS < b.minTS || (v.TS == b.minTS && v.Value.Text < b.exemplar) {
b.minTS, b.exemplar = v.TS, v.Value.Text
}
}
var best *bucket
for _, b := range buckets {
switch {
case best == nil,
b.n > best.n,
b.n == best.n && b.minTS < best.minTS,
b.n == best.n && b.minTS == best.minTS && b.exemplar < best.exemplar:
best = b
}
}
if best == nil {
return MergeResult{}
}
return MergeResult{Value: best.exemplar, Nsrc: best.n, OK: true}
}
func signerID(v Vote) string {
if v.Uid != "" {
return v.Uid
}
return "#" + strconv.FormatInt(v.Pid, 10)
}
// normalizeValue folds case and surrounding whitespace so "The Matrix" and
// "the matrix " count as the same value. (Unicode NFC folding is not applied:
// the module carries no normalisation table; values written by one plugin
// family are already consistent.)
func normalizeValue(s string) string {
return strings.ToLower(strings.TrimSpace(s))
}