diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/actingwaits.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/actingwaits.go index 13a6e22d..2297a750 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/actingwaits.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/actingwaits.go @@ -2,12 +2,16 @@ package main import ( "context" + "maps" + "slices" + "strings" meshwants "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshwantsclient" seat "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshwants" "mesh-delivery-assigner/internal/acting" "mesh-delivery-assigner/internal/assign" + "mesh-delivery-assigner/internal/assigngraph" "mesh-delivery-assigner/internal/assignment" "mesh-delivery-assigner/internal/buckets" ) @@ -22,6 +26,9 @@ import ( func (p *passer) actingOf(module string, released map[string]bool) func(node string) []assign.ActingEdge { return func(node string) []assign.ActingEdge { var out []assign.ActingEdge + if p.engineOn(node) == module { + return nil // the node-engine's own want is held by no acting edge, so it is written first (§268, A-323, A-330) + } for _, e := range p.entryEdges(node, module) { if acting.Held(e.Step) && !released[acting.Key(node, module, e.Module)+"@"+e.Fp] { out = append(out, assign.ActingEdge{Node: e.Node, Part: e.Module, Fp: e.Fp, Step: e.Step}) @@ -31,6 +38,15 @@ func (p *passer) actingOf(module string, released map[string]bool) func(node str } } +// engineOn is the module that holds the seat node-engine on node in the revision the pass computed, read from the acting edges' +// seat (A-330); "" when none is read. +func (p *passer) engineOn(node string) string { + if p.graph == nil { + return "" + } + return assigngraph.Engines(slices.Collect(maps.Values(p.graph.computed.Entries)))[node] +} + // entryEdges are the acting edges of the entry of module on node in the revision the pass computed. func (p *passer) entryEdges(node, module string) []seat.Edge { g := p.graph @@ -72,11 +88,16 @@ func (p *passer) actingWaits(ctx context.Context, n assign.Nodes, wanted map[str now := p.clock() kept := p.w.read(buckets.Waits()) standing := map[string]bool{} + engines := assigngraph.Engines(slices.Collect(maps.Values(g.computed.Entries))) + held := map[string]bool{} // `/` of each entry held for a part's version for _, ref := range g.computed.Revision.Entries { entry := g.computed.Entries[ref.Digest] if w, ok := wanted[ref.Module][ref.Node]; ok && w.Fp == entry.Fp { continue // the entry's version is wanted already: nothing moves, so nothing waits } + if engines[ref.Node] == ref.Module { + continue // the node-engine's own want waits for no acting part (§268, A-323, A-330) + } for _, e := range entry.Edges { if e.Kind != "acting" || !acting.Held(e.Step) { continue @@ -86,6 +107,7 @@ func (p *passer) actingWaits(ctx context.Context, n assign.Nodes, wanted map[str continue } standing[key] = true + held[ref.Node+"/"+ref.Module] = true if raw := kept[key]; raw != nil { if w, err := acting.Decode(raw); err == nil && w.Entry == entry.Fp && w.Fp == e.Fp { p.actingReleaseSaid(ctx, key, w) @@ -104,6 +126,7 @@ func (p *passer) actingWaits(ctx context.Context, n assign.Nodes, wanted map[str p.put(ctx, buckets.Waits(), key, func() ([]byte, error) { return acting.Encode(w) }) } } + p.actingCycles(g.computed.Entries, engines, held, n) for key, raw := range kept { if !acting.Is(key) || standing[key] { continue @@ -150,3 +173,53 @@ func (p *passer) actingReleaseSaid(ctx context.Context, key string, w acting.Wai func (p *passer) edgeOf(w acting.Wait) (assign.ActingEdge, bool) { return assign.ActingEdge{Node: w.PartNode, Part: w.Part, Fp: w.Fp, Step: w.Step}, true } + +// actingCycles writes the finding acting-cycle of each held want whose holding edges lie on a cycle no rule breaks, and clears it of +// every other want, a finding a pass before or another process wrote included (design 47 G A-323, A-330). An edge holds while its +// step is the assigner's, its part's version does not run and its wait was not released; the node-engine's own want is held by none. +func (p *passer) actingCycles(entries map[string]seat.AssignmentEntry, engines map[string]string, held map[string]bool, n assign.Nodes) { + if p.findings.Put == nil { + return + } + released := p.actingReleased() + cycles := assigngraph.HeldCycles(slices.Collect(maps.Values(entries)), func(e seat.AssignmentEntry, edge seat.Edge) bool { + return held[e.Node+"/"+e.Module] && engines[e.Node] != e.Module && edge.Kind == "acting" && acting.Held(edge.Step) && + !released[acting.Key(e.Node, e.Module, edge.Module)+"@"+edge.Fp] && !n.Runs(assign.ActingEdge{Node: edge.Node, Part: edge.Module, Fp: edge.Fp, Step: edge.Step}) + }) + raised := map[string]bool{} + for _, at := range slices.Sorted(maps.Keys(cycles)) { + node, module, _ := strings.Cut(at, "/") + o := assign.ActingCycleFinding(node, module, cycles[at]) + key := mcseatKey(o) + raised[key] = true + if p.said[key+"="+o.Summary] { + continue // written already as it stands + } + if err := p.findings.Put(o.Finding(p.clock())); err != nil { + p.logf("the finding %s is not written: %v", key, err) + continue + } + p.said[key+"="+o.Summary] = true + } + if p.findings.Keys == nil || p.findings.Delete == nil { + return + } + keys, err := p.findings.Keys() + if err != nil { + p.logf("the findings %s are not read, so none is cleared this pass: %v", assign.KindActingCycle, err) + return + } + for _, key := range keys { + if strings.HasSuffix(key, "."+assign.KindActingCycle) && !raised[key] { + if err := p.findings.Delete(key); err != nil { + p.logf("the finding %s is not cleared: %v", key, err) + continue + } + for said := range p.said { + if strings.HasPrefix(said, key+"=") { + delete(p.said, said) + } + } + } + } +} diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/actingwaits_test.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/actingwaits_test.go index 188bce6f..762332aa 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/actingwaits_test.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/actingwaits_test.go @@ -8,6 +8,7 @@ import ( "time" "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" + mcseat "git.novox.be/novox/mesh-contracts/health-and-repair/go/seats/meshconditions" mwc "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/clients/meshwantsclient" meshwants "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshwants" @@ -116,3 +117,68 @@ func TestTheToolsReadAndReleaseAnActingWait(t *testing.T) { t.Errorf("wait = %+v", r) } } + +// Design 47 G §268, A-323, A-330: the assigner and the node-engine on a node are one step: the node-engine's own want waits for no +// acting part, so it moves first, and the node's other wants, the assigner's own included, wait for the new node-engine; a want held +// on a cycle no rule breaks is the finding acting-cycle (scope module, id .) naming each edge's step, cleared by the +// next pass that finds it on none. +func TestTheNodeEngineIsWantedFirstAndAWantHeldOnACycleIsAFinding(t *testing.T) { + p := newPasser(kvSet{buckets.Waits(): &memKV{values: map[string][]byte{}}}, time.Date(2026, 11, 13, 2, 0, 0, 0, time.UTC)) + p.publishWants = func(string, any, string) error { return nil } + findings := map[string]mcseat.Finding{} + p.findings = assign.Findings{ + Put: func(f mcseat.Finding) error { findings[f.Scope+"."+f.ID+"."+f.Kind] = f; return nil }, + Delete: func(key string) error { delete(findings, key); return nil }, + Keys: func() ([]string, error) { + var keys []string + for k := range findings { + keys = append(keys, k) + } + return keys, nil + }} + compose := meshwants.Edge{Kind: "acting", Node: "ace", Module: "mesh-host", Fp: "src2:engine", Seat: "node-engine", Step: "compose"} + place := meshwants.Edge{Kind: "acting", Node: "ace", Module: "assigner", Fp: "src2:assigner", Seat: "mesh-wants", Step: "place"} + entries := map[string]meshwants.AssignmentEntry{ + "d1": {Node: "ace", Module: "mesh-host", Fp: "src2:engine", Edges: []meshwants.Edge{place}}, + "d2": {Node: "ace", Module: "assigner", Fp: "src2:assigner", Edges: []meshwants.Edge{compose}}, + "d3": {Node: "ace", Module: "app", Fp: "src2:app", Edges: []meshwants.Edge{compose, place}}, + } + p.graph = &graphKeeper{} + for d, e := range entries { + p.graph.computed.Revision.Entries = append(p.graph.computed.Revision.Entries, meshwants.NodeEntryRef{Node: e.Node, Module: e.Module, Digest: d}) + } + p.graph.computed.Entries = entries + n := assign.Nodes{Online: map[string]bool{"ace": true}, Running: map[string]map[string]nodeengine.Running{"ace": { + "mesh-host": {Fingerprint: "src2:old"}, "assigner": {Fingerprint: "src2:old"}}}} + wanted := map[string]map[string]mwc.Want{} + if got := p.actingOf("mesh-host", nil)("ace"); len(got) != 0 { + t.Errorf("the node-engine's want is held by %+v, want by no acting edge", got) + } + if got := p.actingOf("assigner", nil)("ace"); len(got) != 1 || got[0].Part != "mesh-host" { + t.Errorf("the assigner's own want is held by %+v, want by the new node-engine", got) + } + p.actingWaits(context.Background(), n, wanted) + kept := p.w.read(buckets.Waits()) + if kept[acting.Key("ace", "mesh-host", "assigner")] != nil || kept[acting.Key("ace", "assigner", "mesh-host")] == nil || kept[acting.Key("ace", "app", "mesh-host")] == nil { + t.Errorf("the waits kept: %v", kept) + } + if len(findings) != 0 { + t.Errorf("the cycle the node-engine's want breaks is a finding: %v", findings) + } + // Two parts holding each other across compose and place, neither the node-engine: no rule breaks it. + entries["d4"] = meshwants.AssignmentEntry{Node: "ace", Module: "x", Fp: "src2:x", Edges: []meshwants.Edge{{Kind: "acting", Node: "ace", Module: "y", Fp: "src2:y", Seat: "s-y", Step: "place"}}} + entries["d5"] = meshwants.AssignmentEntry{Node: "ace", Module: "y", Fp: "src2:y", Edges: []meshwants.Edge{{Kind: "acting", Node: "ace", Module: "x", Fp: "src2:x", Seat: "s-x", Step: "compose"}}} + p.graph.computed.Revision.Entries = append(p.graph.computed.Revision.Entries, meshwants.NodeEntryRef{Node: "ace", Module: "x", Digest: "d4"}, + meshwants.NodeEntryRef{Node: "ace", Module: "y", Digest: "d5"}) + p.actingWaits(context.Background(), n, wanted) + f, ok := findings["module.ace.x.acting-cycle"] + if len(findings) != 2 || !ok || !strings.Contains(f.Summary, "ace/x -[place]-> ace/y -[compose]-> ace/x") { + t.Fatalf("the findings of a cycle no rule breaks: %v", findings) + } + // x's new version runs: y no longer waits, so neither is held on a cycle, and both findings clear. + n.Running["ace"]["x"] = nodeengine.Running{Fingerprint: "src2:x"} + p.actingWaits(context.Background(), n, wanted) + if len(findings) != 0 { + t.Errorf("the findings after the cycle broke: %v", findings) + } +} diff --git a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/graphkept.go b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/graphkept.go index 125e6b3b..0040b801 100644 --- a/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/graphkept.go +++ b/modules/mesh-delivery-assigner/cmd/mesh-delivery-assigner/graphkept.go @@ -61,7 +61,7 @@ func (p *passer) keepGraph(ctx context.Context) { if cycles := strings.Join(c.Cycles, "; "); cycles != g.cycled { g.cycled = cycles if cycles != "" { - p.logf("the assignment graph holds a cycle among the waits within one step, and its entries wait for each other: %s", cycles) + p.logf("the assignment graph holds a cycle among the acting waits, within one step or across steps: %s", cycles) } } if !g.started { diff --git a/modules/mesh-delivery-assigner/internal/assign/findings.go b/modules/mesh-delivery-assigner/internal/assign/findings.go index 260ba6ea..c69387d9 100644 --- a/modules/mesh-delivery-assigner/internal/assign/findings.go +++ b/modules/mesh-delivery-assigner/internal/assign/findings.go @@ -154,3 +154,18 @@ func (f Findings) Say(say func(oc.Condition) error) func(oc.Condition) error { return errors.Join(errs...) } } + +// KindActingCycle is the finding of a want held on a cycle of acting waits no rule breaks (design 47 G A-323, A-330): scope module, +// id `.` of the held want, naming the cycle with each edge's step; cleared by the next pass that finds it held on no +// cycle. +const KindActingCycle = "acting-cycle" + +// ActingCycleFinding is the finding acting-cycle of module's want on node, held on cycle. +func ActingCycleFinding(node, module, cycle string) Observed { + return Observed{Scope: mcc.ScopeModule, ID: node + "." + module, Kind: KindActingCycle, Machine: node, Severity: "warning", + Summary: fmt.Sprintf("the want of %s on %s is held on a cycle of acting waits: %s", module, node, cycle), + Headline: fmt.Sprintf("%s on %s waits on a cycle of acting parts", module, node), + Explanation: fmt.Sprintf("Each acting part on the cycle %s waits for another's new version to run, so none moves by itself. "+ + "assigner_release lets one move with the version running.", cycle), + Source: "assigner." + KindActingCycle} +} diff --git a/modules/mesh-delivery-assigner/internal/assigngraph/compute.go b/modules/mesh-delivery-assigner/internal/assigngraph/compute.go index ae6915b8..b58a63ee 100644 --- a/modules/mesh-delivery-assigner/internal/assigngraph/compute.go +++ b/modules/mesh-delivery-assigner/internal/assigngraph/compute.go @@ -53,7 +53,8 @@ type Computed struct { // Uncomputed is each placed module whose entry could not be computed, as "/: why", sorted: it is said, // never left out in silence. Uncomputed []string - // Cycles are the cycles among the waits within one step (Cycles, design 47 G §272): said, never left in silence. + // Cycles are the cycles among the acting waits, within one step or across steps (Cycles, design 47 G §272, A-323): said, never + // left in silence. Cycles []string } diff --git a/modules/mesh-delivery-assigner/internal/assigngraph/cycle.go b/modules/mesh-delivery-assigner/internal/assigngraph/cycle.go index a8d691f5..014da0e6 100644 --- a/modules/mesh-delivery-assigner/internal/assigngraph/cycle.go +++ b/modules/mesh-delivery-assigner/internal/assigngraph/cycle.go @@ -6,14 +6,15 @@ import ( "slices" "strings" + "git.novox.be/novox/mesh-contracts/go/seats/nodeengine" "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshwants" ) -// Cycles are the cycles among the waits within one step (design 47 G §272, ADR 0656): an entry waits for its acting parts in the step an -// edge names, so two acting parts acting on each other in one step wait for each other for ever. Each is `: a -> b -> a`, by -// node and module, sorted. Edges of two steps never close a cycle together: the indexer taking in the checker and the checker checking -// the indexer are none, and a holder's own inputs are no edge here (the graph gives an acting edge only from a part to the entries it -// acts on, never to what the part is built from). +// Cycles are the cycles among the acting waits (design 47 G §272, ADR 0656, A-323): an entry waits for its acting parts in the step +// an edge names, so acting parts that wait for each other, in one step or across steps, wait for ever unless a rule breaks the +// cycle. A cycle within one step is `: a -> b -> a`; one across steps names each edge's step, `a -[compose]-> b -[place]-> a`; +// each by node and module, said once from its smallest member, sorted. A holder's own inputs are no edge here (the graph gives an +// acting edge only from a part to the entries it acts on, never to what the part is built from). func Cycles(entries []meshwants.AssignmentEntry) []string { type key struct{ step, at string } next := map[key][]string{} @@ -68,6 +69,166 @@ func Cycles(entries []meshwants.AssignmentEntry) []string { } } } + // Across steps (A-323): the shortest cycle through each entry over every acting edge, said when its edges name two steps or more. + for _, hops := range cyclesThrough(entries, func(_ meshwants.AssignmentEntry, e meshwants.Edge) bool { return e.Kind == EdgeActing }) { + if crossesSteps(hops) { + out = append(out, hopWords(rotated(hops))) + } + } slices.Sort(out) return slices.Compact(out) } + +// HeldCycles is, by `/`, the cycle each entry lies on among the edges holds says hold it (A-323, A-330): a want held +// on such a cycle is held for ever, since no rule breaks it. Each cycle starts at the entry and names each edge's step. +func HeldCycles(entries []meshwants.AssignmentEntry, holds func(e meshwants.AssignmentEntry, edge meshwants.Edge) bool) map[string]string { + out := map[string]string{} + for at, hops := range cyclesThrough(entries, holds) { + out[at] = hopWords(hops) + } + return out +} + +// Engines is, by node, the module that holds the seat node-engine there, read from the acting edges' seat (A-330). +func Engines(entries []meshwants.AssignmentEntry) map[string]string { + out := map[string]string{} + for _, e := range entries { + for _, edge := range e.Edges { + if edge.Kind == EdgeActing && edge.Seat == nodeengine.Seat { + out[edge.Node] = edge.Module + } + } + } + return out +} + +// hop is one edge of a cycle: from the entry at, in step, to the next hop's entry. +type hop struct{ at, step string } + +// cyclesThrough is, by `/`, the shortest cycle through each entry over the edges keep takes, found within the +// entry's strongly connected part, each starting at the entry. +func cyclesThrough(entries []meshwants.AssignmentEntry, keep func(meshwants.AssignmentEntry, meshwants.Edge) bool) map[string][]hop { + type out struct{ to, step string } + next := map[string][]out{} + for _, e := range entries { + at := e.Node + "/" + e.Module + for _, edge := range e.Edges { + if keep(e, edge) { + next[at] = append(next[at], out{edge.Node + "/" + edge.Module, edge.Step}) + } + } + } + for at := range next { + slices.SortFunc(next[at], func(a, b out) int { return strings.Compare(a.to+"\x00"+a.step, b.to+"\x00"+b.step) }) + } + part := strongParts(slices.Sorted(maps.Keys(next)), func(at string) []string { + var to []string + for _, o := range next[at] { + to = append(to, o.to) + } + return to + }) + found := map[string][]hop{} + for _, start := range slices.Sorted(maps.Keys(next)) { + // breadth first from start back to it, within its part + prev := map[string]hop{} + queue := []string{start} + seen := map[string]bool{} + for len(queue) > 0 && found[start] == nil { + at := queue[0] + queue = queue[1:] + for _, o := range next[at] { + if part[o.to] != part[start] { + continue + } + if o.to == start { + hops := []hop{{at, o.step}} + for cur := at; cur != start; { + h := prev[cur] + hops = append(hops, h) + cur = h.at + } + slices.Reverse(hops) + found[start] = hops + break + } + if !seen[o.to] { + seen[o.to] = true + prev[o.to] = hop{at, o.step} + queue = append(queue, o.to) + } + } + } + } + return found +} + +// strongParts numbers each vertex by its strongly connected part (Tarjan). +func strongParts(vertices []string, succ func(string) []string) map[string]int { + index, low, part := map[string]int{}, map[string]int{}, map[string]int{} + on := map[string]bool{} + var stack []string + n, parts := 0, 0 + var visit func(v string) + visit = func(v string) { + index[v], low[v] = n, n + n++ + stack = append(stack, v) + on[v] = true + for _, w := range succ(v) { + if _, ok := index[w]; !ok { + visit(w) + low[v] = min(low[v], low[w]) + } else if on[w] { + low[v] = min(low[v], index[w]) + } + } + if low[v] == index[v] { + parts++ + for { + w := stack[len(stack)-1] + stack = stack[:len(stack)-1] + on[w] = false + part[w] = parts + if w == v { + break + } + } + } + } + for _, v := range vertices { + if _, ok := index[v]; !ok { + visit(v) + } + } + return part +} + +func crossesSteps(hops []hop) bool { + for _, h := range hops[1:] { + if h.step != hops[0].step { + return true + } + } + return false +} + +// rotated is the cycle started at its smallest member, so one cycle is said once. +func rotated(hops []hop) []hop { + lowest := 0 + for i, h := range hops { + if h.at < hops[lowest].at { + lowest = i + } + } + return append(slices.Clone(hops[lowest:]), hops[:lowest]...) +} + +// hopWords is a cycle with each edge's step: `a -[compose]-> b -[place]-> a`. +func hopWords(hops []hop) string { + var b strings.Builder + for _, h := range hops { + fmt.Fprintf(&b, "%s -[%s]-> ", h.at, h.step) + } + return b.String() + hops[0].at +} diff --git a/modules/mesh-delivery-assigner/internal/assigngraph/cycle_test.go b/modules/mesh-delivery-assigner/internal/assigngraph/cycle_test.go index 78cc4c82..522dfec1 100644 --- a/modules/mesh-delivery-assigner/internal/assigngraph/cycle_test.go +++ b/modules/mesh-delivery-assigner/internal/assigngraph/cycle_test.go @@ -7,9 +7,9 @@ import ( "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-contracts/go/seats/meshwants" ) -// Design 47 G §272 (AS10): a cycle among the waits within one step is found; an acting part acting on itself's other module, two parts -// acting on each other in different steps, and a chain are none. -func TestACycleAmongTheWaitsWithinOneStepIsFound(t *testing.T) { +// Design 47 G §272 (AS10), A-323: a cycle among the waits is found within one step and across steps, each edge's step named across +// steps; a chain and provisioning edges are none. +func TestACycleAmongTheWaitsWithinOrAcrossStepsIsFound(t *testing.T) { act := func(node, module, step string) meshwants.Edge { return meshwants.Edge{Kind: EdgeActing, Node: node, Module: module, Step: step} } @@ -25,7 +25,11 @@ func TestACycleAmongTheWaitsWithinOneStepIsFound(t *testing.T) { []string{"compose: ace/a -> ace/b -> ace/a"}}, {"three in a ring, said once", []meshwants.AssignmentEntry{entry("c", act("ace", "a", "build")), entry("a", act("ace", "b", "build")), entry("b", act("ace", "c", "build"))}, []string{"build: ace/a -> ace/b -> ace/c -> ace/a"}}, - {"the indexer taking in the checker, the checker checking the indexer", []meshwants.AssignmentEntry{entry("checker", act("ace", "indexer", "take-in")), entry("indexer", act("ace", "checker", "check"))}, nil}, + {"the indexer taking in the checker, the checker checking the indexer", []meshwants.AssignmentEntry{entry("checker", act("ace", "indexer", "take-in")), entry("indexer", act("ace", "checker", "check"))}, + []string{"ace/checker -[take-in]-> ace/indexer -[check]-> ace/checker"}}, + {"the assigner and the node-engine, across compose and place", []meshwants.AssignmentEntry{entry("assigner", act("ace", "engine", "compose")), + entry("engine", act("ace", "assigner", "place")), entry("app", act("ace", "engine", "compose"), act("ace", "assigner", "place"))}, + []string{"ace/assigner -[compose]-> ace/engine -[place]-> ace/assigner"}}, {"a chain", []meshwants.AssignmentEntry{entry("a", act("ace", "b", "compose")), entry("b", act("ace", "c", "compose")), entry("c")}, nil}, {"provisioning edges are no wait of a step", []meshwants.AssignmentEntry{entry("a", meshwants.Edge{Kind: EdgeProvisioning, Node: "ace", Module: "b"}), entry("b", meshwants.Edge{Kind: EdgeProvisioning, Node: "ace", Module: "a"})}, nil}, } { @@ -34,3 +38,23 @@ func TestACycleAmongTheWaitsWithinOneStepIsFound(t *testing.T) { } } } + +// A-323, A-330: the node-engine on a node is read from the acting edges' seat, and the cycles that hold a want are those of the +// edges said to hold it, each from the entry, naming each edge's step. +func TestTheHeldCyclesAreThoseOfTheHoldingEdges(t *testing.T) { + entries := []meshwants.AssignmentEntry{ + {Node: "ace", Module: "assigner", Edges: []meshwants.Edge{{Kind: EdgeActing, Node: "ace", Module: "engine", Step: "compose", Seat: "node-engine"}}}, + {Node: "ace", Module: "engine", Edges: []meshwants.Edge{{Kind: EdgeActing, Node: "ace", Module: "assigner", Step: "place", Seat: "mesh-wants"}}}, + } + if got := Engines(entries); len(got) != 1 || got["ace"] != "engine" { + t.Errorf("the node-engines %v, want engine on ace", got) + } + all := func(meshwants.AssignmentEntry, meshwants.Edge) bool { return true } + if got := HeldCycles(entries, all); len(got) != 2 || got["ace/engine"] != "ace/engine -[place]-> ace/assigner -[compose]-> ace/engine" { + t.Errorf("held cycles %v", got) + } + // the node-engine's want held by no acting edge: no cycle holds anything + if got := HeldCycles(entries, func(e meshwants.AssignmentEntry, _ meshwants.Edge) bool { return e.Module != "engine" }); len(got) != 0 { + t.Errorf("held cycles with the node-engine's edges holding nothing: %v", got) + } +}