Artifact 5ecbefbf59c205b894ac2beca8172d9d2566f5869333e063cda11fc9a79cc360:
- File
internal/horizon/heldset_test.go
— part of check-in
[75ded1b0e0]
at
2026-10-21 22:19:03
on branch feat/horizon-cones
— feat(horizon): compute the held set K under both rules
draft-fossil-horizon-00 Section 3.1 defines K as a function of C, the rule and the check-in graph, recomputed by whoever needs it and stored by no one. internal/horizon.HeldSet computes it from the plink and leaf tables: one bounded plink load, then iterative breadth-first walks.
Merge parents are followed both ways, as zit's walk does (src/sparse.zig, ClassifyState.extend and walkParents). Under the leaves rule each open leaf's ancestry is held up to where it meets C's ancestry, inclusive, so every equally near common ancestor the walk reaches is held. "Open leaf" is fossil's leaf table minus active closed tags rather than zit's "no held child", which keeps a below-C branch merged into a descendant held back to its fork, so any two open leaves keep their merge base in K. C that is not a held check-in is ErrNotHeld; phantom parents are never entered.
Section 8 requires the walk to carry visited-node bounds: the load and every walk share the 1<<20 bound of the other graph walks and fail with ErrBound, and cancellation is honoured. The conformance suite gains cases for HZ-3.1-1 and HZ-3.1-3, both PARTIAL until the horizon member and the content horizon exist. (user: [email protected], size: 9408) [annotate] [blame] [check-ins using] [more...]
package horizon import ( "context" "errors" "slices" "testing" "time" "github.com/danmestas/go-libfossil/internal/blob" "github.com/danmestas/go-libfossil/internal/deck" libfossil "github.com/danmestas/go-libfossil/internal/fsltype" "github.com/danmestas/go-libfossil/internal/manifest" "github.com/danmestas/go-libfossil/internal/repo" _ "github.com/danmestas/go-libfossil/internal/testdriver" "github.com/danmestas/go-libfossil/simio" ) // dag builds check-ins in a fresh repository, one day apart, so the plink, // tagxref and leaf rows are the ones a real commit writes. type dag struct { t *testing.T r *repo.Repo day int } func newDAG(t *testing.T) *dag { t.Helper() r, err := repo.CreateWithEnv(t.TempDir()+"/h.fossil", "test", simio.RealEnv(), "") if err != nil { t.Fatal(err) } t.Cleanup(func() { r.Close() }) return &dag{t: t, r: r} } // branchTags start the named branch on the check-in that carries them. func branchTags(name string) []deck.TagCard { return []deck.TagCard{ {Type: deck.TagPropagating, Name: "branch", UUID: "*", Value: name}, {Type: deck.TagPropagating, Name: "sym-" + name, UUID: "*"}, {Type: deck.TagCancel, Name: "sym-trunk", UUID: "*"}, } } var closedTag = deck.TagCard{Type: deck.TagSingleton, Name: "closed", UUID: "*"} // commit checks in name with parents[0] as its primary parent and the rest // as merge parents; no parents makes a root. func (d *dag) commit(name string, parents []libfossil.FslID, tags ...deck.TagCard) libfossil.FslID { d.t.Helper() d.day++ opts := manifest.CheckinOpts{ Files: []manifest.File{{Name: name + ".txt", Content: []byte(name + "\n")}}, Comment: name, User: "test", Time: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC).AddDate(0, 0, d.day), Tags: tags, } if len(parents) > 0 { opts.Parent = parents[0] opts.MergeParents = parents[1:] } rid, _, err := manifest.Checkin(d.r, opts) if err != nil { d.t.Fatalf("Checkin %s: %v", name, err) } return rid } func (d *dag) chain(names ...string) []libfossil.FslID { d.t.Helper() var rids []libfossil.FslID var parent []libfossil.FslID for _, name := range names { rid := d.commit(name, parent) rids = append(rids, rid) parent = []libfossil.FslID{rid} } return rids } func p(rids ...libfossil.FslID) []libfossil.FslID { return rids } func (d *dag) want(c libfossil.FslID, rule Rule, want ...libfossil.FslID) { d.t.Helper() got, err := HeldSet(context.Background(), d.r.DB(), c, rule) if err != nil { d.t.Fatalf("HeldSet(%d, %v): %v", c, rule, err) } slices.Sort(want) if !slices.Equal(got, want) { d.t.Errorf("HeldSet(%d, %v) = %v, want %v", c, rule, got, want) } } func TestLinearMidChain(t *testing.T) { d := newDAG(t) c := d.chain("c1", "c2", "c3", "c4", "c5") for _, rule := range []Rule{Descendants, Leaves} { d.want(c[2], rule, c[2], c[3], c[4]) } } func TestTipHoldsOnlyItself(t *testing.T) { d := newDAG(t) c := d.chain("c1", "c2", "c3") for _, rule := range []Rule{Descendants, Leaves} { d.want(c[2], rule, c[2]) } } // A branch forked below C is outside the descendants set; the leaves rule // holds it back to its fork, c1, which is the nearest common ancestor of // its leaf and C. c2, between the fork and C, is on no such path. func TestBranchForkedBelowC(t *testing.T) { d := newDAG(t) c := d.chain("c1", "c2", "c3", "c4") f1 := d.commit("f1", p(c[0]), branchTags("feat")...) f2 := d.commit("f2", p(f1)) d.want(c[2], Descendants, c[2], c[3]) d.want(c[2], Leaves, c[2], c[3], f1, f2, c[0]) } func TestBranchForkedAtC(t *testing.T) { d := newDAG(t) c := d.chain("c1", "c2", "c3", "c4") f1 := d.commit("f1", p(c[2]), branchTags("feat")...) f2 := d.commit("f2", p(f1)) for _, rule := range []Rule{Descendants, Leaves} { d.want(c[2], rule, c[2], c[3], f1, f2) } } // A below-C branch merged into a descendant of C: the merge is a descendant, // the branch is only its ancestor, so the descendants rule leaves the branch // out. fossil's leaf rule keeps f1 an open leaf (its one child is on trunk), // so the leaves rule holds it back to the fork. func TestBelowCBranchMergedIntoDescendant(t *testing.T) { d := newDAG(t) c := d.chain("c1", "c2", "c3", "c4") f1 := d.commit("f1", p(c[0]), branchTags("feat")...) m := d.commit("m", p(c[3], f1)) d.want(c[2], Descendants, c[2], c[3], m) d.want(c[2], Leaves, c[2], c[3], m, f1, c[0]) } // A below-C branch that merges a descendant of C in: following merge // parents forward makes f2 a descendant, and its open leaf being in K adds // nothing under the leaves rule. f1 is on no path from an open leaf back to // that leaf's nearest common ancestor with C, which is C itself. func TestDescendantMergedIntoBelowCBranch(t *testing.T) { d := newDAG(t) c := d.chain("c1", "c2", "c3", "c4") f1 := d.commit("f1", p(c[0]), branchTags("feat")...) f2 := d.commit("f2", p(f1, c[3])) for _, rule := range []Rule{Descendants, Leaves} { d.want(c[2], rule, c[2], c[3], f2) } } // C is a merge of x and y, and the open leaf l reaches both through its own // two parents: both are nearest common ancestors of l and C, and both are // held. r, below them, is not. func TestSeveralNearestCommonAncestors(t *testing.T) { d := newDAG(t) r := d.commit("r", nil) x := d.commit("x", p(r)) y := d.commit("y", p(r), branchTags("feat")...) cc := d.commit("c", p(x, y)) x2 := d.commit("x2", p(x), branchTags("side")...) y2 := d.commit("y2", p(y), branchTags("side")...) l := d.commit("l", p(x2, y2)) d.want(cc, Descendants, cc) d.want(cc, Leaves, cc, l, x2, y2, x, y) } func TestClosedLeafExcluded(t *testing.T) { d := newDAG(t) c := d.chain("c1", "c2", "c3", "c4") f1 := d.commit("f1", p(c[0]), branchTags("feat")...) d.commit("f2", p(f1), closedTag) g1 := d.commit("g1", p(c[1]), branchTags("gone")...) d.want(c[2], Leaves, c[2], c[3], g1, c[1]) } // A leaf whose ancestry never meets C's has no common ancestor with it; the // walk, like zit's, holds its whole ancestry rather than nothing. func TestUnrelatedRootHeldWhole(t *testing.T) { d := newDAG(t) c := d.chain("c1", "c2") u1 := d.commit("u1", nil, branchTags("other")...) u2 := d.commit("u2", p(u1)) d.want(c[1], Descendants, c[1]) d.want(c[1], Leaves, c[1], u1, u2) } // A phantom parent is not a held check-in: no walk enters it. func TestPhantomParentNeverHeld(t *testing.T) { d := newDAG(t) c := d.chain("c1", "c2", "c3") f1 := d.commit("f1", p(c[0]), branchTags("feat")...) ph, err := blob.StorePhantom(d.r.DB(), "0123456789abcdef0123456789abcdef01234567") if err != nil { t.Fatal(err) } for _, cid := range []libfossil.FslID{c[1], f1} { if _, err := d.r.DB().Exec( "INSERT INTO plink(pid, cid, isprim, mtime) VALUES(?, ?, 0, 2460000.5)", ph, cid); err != nil { t.Fatal(err) } } d.want(c[1], Descendants, c[1], c[2]) d.want(c[1], Leaves, c[1], c[2], f1, c[0]) } func TestCNotHeld(t *testing.T) { d := newDAG(t) c := d.chain("c1", "c2") ph, err := blob.StorePhantom(d.r.DB(), "0123456789abcdef0123456789abcdef01234567") if err != nil { t.Fatal(err) } var file libfossil.FslID if err := d.r.DB().QueryRow( "SELECT fid FROM mlink WHERE mid=?", c[0]).Scan(&file); err != nil { t.Fatal(err) } for _, rid := range []libfossil.FslID{c[1] + 1000, ph, file} { for _, rule := range []Rule{Descendants, Leaves} { _, err := HeldSet(context.Background(), d.r.DB(), rid, rule) if !errors.Is(err, ErrNotHeld) { t.Errorf("HeldSet(%d, %v) error = %v, want ErrNotHeld", rid, rule, err) } } } } func TestBoundExceeded(t *testing.T) { d := newDAG(t) c := d.chain("c1", "c2", "c3", "c4", "c5") f1 := d.commit("f1", p(c[0]), branchTags("feat")...) d.commit("f2", p(f1)) cases := []struct { rule Rule c libfossil.FslID limit int }{ {Descendants, c[0], 3}, // the graph's 6 edges overflow the load {Descendants, c[0], 6}, // c1's 7 descendants overflow the walk {Leaves, c[3], 6}, // c4..c1 plus f1..f2 overflow it {Leaves, c[4], 1 << 20}, // a sanity row: within bounds } for _, tc := range cases { _, err := heldSet(context.Background(), d.r.DB(), tc.c, tc.rule, tc.limit) if tc.limit == 1<<20 { if err != nil { t.Errorf("rule %v limit %d: %v", tc.rule, tc.limit, err) } continue } if !errors.Is(err, ErrBound) { t.Errorf("rule %v C %d limit %d: error = %v, want ErrBound", tc.rule, tc.c, tc.limit, err) } } } func TestCancelled(t *testing.T) { d := newDAG(t) c := d.chain("c1", "c2") ctx, cancel := context.WithCancel(context.Background()) cancel() if _, err := HeldSet(ctx, d.r.DB(), c[0], Leaves); !errors.Is(err, context.Canceled) { t.Fatalf("error = %v, want context.Canceled", err) } } func TestDeterministic(t *testing.T) { d := newDAG(t) c := d.chain("c1", "c2", "c3", "c4") f1 := d.commit("f1", p(c[0]), branchTags("feat")...) d.commit("m", p(c[3], f1)) g1 := d.commit("g1", p(c[1]), branchTags("g")...) d.commit("g2", p(g1, c[2])) for _, rule := range []Rule{Descendants, Leaves} { first, err := HeldSet(context.Background(), d.r.DB(), c[2], rule) if err != nil { t.Fatal(err) } for range 5 { again, err := HeldSet(context.Background(), d.r.DB(), c[2], rule) if err != nil { t.Fatal(err) } if !slices.Equal(first, again) { t.Fatalf("rule %v: %v then %v", rule, first, again) } } if !slices.IsSorted(first) { t.Fatalf("rule %v: %v is not sorted", rule, first) } } }