sim: the two-box baseline's three causes: INV-10 exempts files keys a park kept live (the at-rest sweep owns them once obsolete), a region's park-replica waits for every box's stop evidence, and an operator-named source that is also the target is replaced when it is fenced
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
5 files changed,
+60
−10
internal/sim/hostlife/moves_test.go+3 −3internal/sim/hostlife/protocol_test.go+25 −4internal/sim/hostlife/regionha_test.go+29 −2internal/sim/hostlife/sim_test.go+1 −1internal/sim/hostlife/world_test.go+2 −0
diff --git a/internal/sim/hostlife/moves_test.go b/internal/sim/hostlife/moves_test.go| index fd657a53a..5c25972ad 100644 |
| --- a/internal/sim/hostlife/moves_test.go |
| +++ b/internal/sim/hostlife/moves_test.go |
| @@ -1051,7 +1051,7 @@ func (w *world) landTarget(b *box, c *claim) { |
| w.violate("INV-9: the cut of %s waits on quiet_after[%d] until %d", op.ID, b.Region, c.Quiet[b.Region]) |
| } |
| // an earlier stay of this incarnation, in its location here |
| - if w.parkStep(b, loc, fmt.Sprintf("superseded/%s/%s/install/", op.ID, host), c.Quiet[b.Region], w.opScope(c, loc), w.keepFilesLive(confine(loc))) { |
| + if w.parkStep(b, loc, fmt.Sprintf("superseded/%s/%s/install/", op.ID, host), c.Quiet[b.Region], w.opScope(c, loc), w.keepFilesLive(b.Region, confine(loc))) { |
| w.cas(c, func(n *claim) bool { n.Op.LandPhase = "install-copy"; return sameOp(n.Op.ID, op.ID) }) |
| } |
| case "install-copy": |
| @@ -1538,7 +1538,7 @@ func (w *world) recoverSource(b *box, c *claim) { |
| if !w.evidence(b, op.RollbackID) { |
| return |
| } |
| - if w.parkStep(b, c.Holder.Location, fmt.Sprintf("superseded/%s/%s/return/", op.ID, host), op.StopAt+horizonH, w.opScope(c, c.Holder.Location), w.keepFilesLive(confine(c.Holder.Location))) { |
| + if w.parkStep(b, c.Holder.Location, fmt.Sprintf("superseded/%s/%s/return/", op.ID, host), op.StopAt+horizonH, w.opScope(c, c.Holder.Location), w.keepFilesLive(b.Region, confine(c.Holder.Location))) { |
| w.cas(c, func(n *claim) bool { n.Op.SrcPhase = "install-copy"; return sameOp(n.Op.ID, op.ID) }) |
| } |
| case "install-copy": |
| @@ -1670,7 +1670,7 @@ func (w *world) pushStep(b *box, c *claim) { |
| return true |
| }) |
| case "park": |
| - if w.parkStep(b, loc, fmt.Sprintf("superseded/%s/%s/push/", op.ID, host), op.StopAt+horizonH, w.opScope(c, loc), w.keepFilesLive(confine(loc))) { |
| + if w.parkStep(b, loc, fmt.Sprintf("superseded/%s/%s/push/", op.ID, host), op.StopAt+horizonH, w.opScope(c, loc), w.keepFilesLive(b.Region, confine(loc))) { |
| w.cas(c, func(n *claim) bool { n.Op.Phase = "install"; return sameOp(n.Op.ID, op.ID) }) |
| } |
| case "install": |
diff --git a/internal/sim/hostlife/protocol_test.go b/internal/sim/hostlife/protocol_test.go| index 3f442bcdb..3efe4951e 100644 |
| --- a/internal/sim/hostlife/protocol_test.go |
| +++ b/internal/sim/hostlife/protocol_test.go |
| @@ -157,10 +157,14 @@ func (w *world) evidence(b *box, opID string) bool { |
| return false |
| } |
| } |
| - if on("evidence-whole-tree") { |
| - return len(b.Procs) == 0 |
| + ok := !on("evidence-whole-tree") || len(b.Procs) == 0 |
| + if ok { |
| + if b.StopEvidence == nil { |
| + b.StopEvidence = map[string]bool{} |
| + } |
| + b.StopEvidence[opID] = true |
| } |
| - return true // the mutation certifies an empty cgroup, blind to escapees |
| + return ok // the mutation certifies an empty cgroup, blind to escapees |
| } |
| // ---------- restore and spawn ---------- |
| @@ -2281,6 +2285,16 @@ func (w *world) doObligation(b *box, c *claim, o obligation) { |
| if !w.evidence(b, o.Op) { |
| return |
| } |
| + if w.perRegion > 1 { |
| + // every box of the region publishes its own stop evidence |
| + // (hostops collectEvidence over the inventory); the owner's |
| + // alone proves nothing about its sibling |
| + for _, x := range w.regionBoxes(b.Region) { |
| + if x != b && !contains(c.Lost, x.ID) && !x.StopEvidence[o.Op] { |
| + return |
| + } |
| + } |
| + } |
| } |
| // a replicator's PUT signed before that evidence can land up to |
| // the horizon later: verify only after it (this model's finding). |
| @@ -2371,7 +2385,14 @@ func (w *world) doObligation(b *box, c *claim, o obligation) { |
| } |
| inner := keep |
| keep = func(k string) bool { |
| - return genOf(o.Prefix, k) != retire || w.filesLive(w.claim, k) || inner != nil && inner(k) |
| + if genOf(o.Prefix, k) != retire || inner != nil && inner(k) { |
| + return true |
| + } |
| + if w.filesLive(w.claim, k) { |
| + w.markSweepOwed(b.Region, k) |
| + return true |
| + } |
| + return false |
| } |
| } |
| if !w.parkStep(b, o.Prefix, o.Dest(), quiet, o.Scope, keep) { |
diff --git a/internal/sim/hostlife/regionha_test.go b/internal/sim/hostlife/regionha_test.go| index 1d4677fea..a32a4705f 100644 |
| --- a/internal/sim/hostlife/regionha_test.go |
| +++ b/internal/sim/hostlife/regionha_test.go |
| @@ -1640,6 +1640,12 @@ func (w *world) fence(b *box, c *claim, x *box) { |
| return false |
| case n.Op.Kind == "relocating" && n.Op.Src == x.ID: |
| w.fenceSource(n, x) |
| + if n.Op.Tgt == x.ID && !w.replaceTarget(n, x) { |
| + // an operator's replay made the source eligible and named it |
| + // the target (model ruling 13): its fence is a target loss |
| + // too, or pinning waits on a lost target for ever |
| + return false // no spare yet |
| + } |
| case n.Op.Kind == "relocating" && n.Op.Tgt == x.ID: |
| if !w.replaceTarget(n, x) { |
| return false // no spare yet |
| @@ -2297,9 +2303,30 @@ func (w *world) objectReferenced(c *claim, k string) bool { |
| // binds every park; moves' parks predate region HA's files tree). Only |
| // the two-box shape has files keys, so the one-box shape is unchanged. |
| // The mutation of files-live-set-moves-parks archives them with the rest. |
| -func (w *world) keepFilesLive(keep func(string) bool) func(string) bool { |
| +func (w *world) keepFilesLive(r int, keep func(string) bool) func(string) bool { |
| return func(k string) bool { |
| - return keep(k) || on("files-live-set-moves-parks") && w.filesLive(w.claim, k) |
| + if keep(k) { |
| + return true |
| + } |
| + if on("files-live-set-moves-parks") && w.filesLive(w.claim, k) { |
| + w.markSweepOwed(r, k) |
| + return true |
| + } |
| + return false |
| + } |
| +} |
| + |
| +// markSweepOwed: a park kept k only because the files live set named it; |
| +// once obsolete it is the at-rest sweep's (claim §6.2a, 1b-next ruling 21), |
| +// so INV-10 at an operation's end exempts it as it exempts a straggler. |
| +func (w *world) markSweepOwed(r int, k string) { |
| + for i := range w.buckets { |
| + if r >= 0 && i != r { |
| + continue |
| + } |
| + for _, v := range w.buckets[i].objs[k] { |
| + v.SweepOwed = true |
| + } |
| } |
| } |
diff --git a/internal/sim/hostlife/sim_test.go b/internal/sim/hostlife/sim_test.go| index acce63db3..f99260ce4 100644 |
| --- a/internal/sim/hostlife/sim_test.go |
| +++ b/internal/sim/hostlife/sim_test.go |
| @@ -462,7 +462,7 @@ func (w *world) residueOf(exceptStragglers bool) []string { |
| continue // a pending chore of another operation covers it |
| } |
| for _, v := range bk.objs[k] { |
| - if !v.Marker && !(exceptStragglers && v.Straggler) { |
| + if !v.Marker && !(exceptStragglers && (v.Straggler || v.SweepOwed)) { |
| out = append(out, fmt.Sprintf("r%d:%s", r, k)) |
| break |
| } |
diff --git a/internal/sim/hostlife/world_test.go b/internal/sim/hostlife/world_test.go| index 7d5827e72..0049356e7 100644 |
| --- a/internal/sim/hostlife/world_test.go |
| +++ b/internal/sim/hostlife/world_test.go |
| @@ -124,6 +124,7 @@ type version struct { |
| B *blob |
| Marker bool |
| Straggler bool // created by a request that completed after H |
| + SweepOwed bool // a dead generation's files key its park kept while live: the at-rest sweep's once obsolete (claim §6.2a) |
| } |
| type bucket struct { |
| @@ -450,6 +451,7 @@ type box struct { |
| Epoch int |
| plans []*plan |
| stopped map[string]bool |
| + StopEvidence map[string]bool // published stop evidence, by operation (survives a restart) |
| mem map[string]bool |
| evidAt map[string]int |
| pendingTimers []int |