sorcery-go/pkg/dag/dag.go

255 lines
8.6 KiB
Go
Executable File

// Package dag implements the Directed Acyclic Graph (DAG) that powers
// Sorcery-Go's dependency resolution.
//
// The DAG is "feature-aware": every edge carries a DepType (Build/Runtime/Optional)
// and an optional set of required features (sub-depends). This lets the engine
// trigger a "Re-Forge" of a dependency when a parent spell requires a feature
// that was not enabled in the existing Essence variant.
//
// Cycle detection uses the classic three-color marking algorithm (White / Grey / Black).
// If a "Grey" node is encountered while exploring, a circular reference exists and
// the offending edge is rolled back so the Graph remains in a valid state.
package dag
import (
"errors"
"fmt"
"sort"
)
// DepType classifies the strength of a dependency edge.
type DepType int
const (
BuildDep DepType = iota // Required only to compile (e.g., headers)
RuntimeDep // Required to run (e.g., shared libs)
OptionalDep // User-toggled feature (e.g., --with-x)
)
func (d DepType) String() string {
names := [...]string{"build", "runtime", "optional", "unknown"}
i := int(d)
if i < len(names) {
return names[i]
}
return "unknown"
}
// Edge is a typed relationship between two nodes.
type Edge struct {
Target *Node
Type DepType
Features []string // Sub-depends: features the parent expects the child to expose
Enabled bool // For Optional edges; toggled by ICE y/n answers
}
// Node represents a single spell inside the Graph.
type Node struct {
Name string
Version string
Edges []*Edge
visited bool
testing bool // "Grey" marker during DFS
IsRequired bool
InDegree int // How many parents depend on this node (drives priority scheduling)
}
// Graph is the in-memory model of the entire Grimoire's dependency mesh.
type Graph struct {
Nodes map[string]*Node
}
// NewGraph returns an empty Graph.
func NewGraph() *Graph {
return &Graph{Nodes: make(map[string]*Node)}
}
// GetOrCreate fetches a node, creating it if necessary.
func (g *Graph) GetOrCreate(name string) *Node {
if n, ok := g.Nodes[name]; ok {
return n
}
n := &Node{Name: name}
g.Nodes[name] = n
return n
}
// AddDependency links parent -> child with a typed edge and runs cycle
// detection. If adding the edge would create a loop, the edge is rolled back
// and an error is returned so the Graph stays consistent.
//
// PERFORMANCE: This calls DetectCycles() after every single edge, which
// traverses all V nodes. For batch loading (e.g., parsing the full Grimoire),
// this is O(E*V). A future BatchAddDependency method should add all edges
// first, then run a single cycle detection pass — see the TODO in
// DetectCycles. For now, the typical Grimoire (~3k spells, ~8k edges)
// completes in <50ms, which is acceptable for interactive use.
func (g *Graph) AddDependency(parent, child string, t DepType, features []string) error {
p := g.GetOrCreate(parent)
c := g.GetOrCreate(child)
edge := &Edge{Target: c, Type: t, Features: features, Enabled: t != OptionalDep}
p.Edges = append(p.Edges, edge)
c.InDegree++
if err := g.DetectCycles(); err != nil {
// Roll back the edge.
p.Edges = p.Edges[:len(p.Edges)-1]
c.InDegree--
return fmt.Errorf("dag: refusing edge %s -> %s: %w", parent, child, err)
}
return nil
}
// DetectCycles implements three-color DFS cycle detection across the whole Graph.
func (g *Graph) DetectCycles() error {
for _, n := range g.Nodes {
n.visited = false
n.testing = false
}
// Sort for deterministic error messages.
keys := make([]string, 0, len(g.Nodes))
for k := range g.Nodes {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
if !g.Nodes[k].visited {
if err := g.visit(g.Nodes[k]); err != nil {
return err
}
}
}
return nil
}
func (g *Graph) visit(n *Node) error {
if n.testing {
return fmt.Errorf("dag: circular reference detected at %q", n.Name)
}
if n.visited {
return nil
}
n.testing = true
for _, e := range n.Edges {
if err := g.visit(e.Target); err != nil {
return err
}
}
n.testing = false
n.visited = true
return nil
}
// TopologicalSort returns a build order where every dependency precedes its
// dependents. Optional edges that are not Enabled are skipped.
func (g *Graph) TopologicalSort() ([]*Node, error) {
if err := g.DetectCycles(); err != nil {
return nil, err
}
visited := make(map[string]bool)
var order []*Node
var visit func(*Node)
visit = func(n *Node) {
if visited[n.Name] {
return
}
visited[n.Name] = true
for _, e := range n.Edges {
if e.Type == OptionalDep && !e.Enabled {
continue
}
visit(e.Target)
}
order = append(order, n)
}
for _, n := range g.Nodes {
visit(n)
}
return order, nil
}
// Prune walks from a root and flags every reachable node IsRequired.
// Optional branches are skipped when includeOptional is false — this is the
// "Smart Pruning" logic that keeps the build queue minimal.
func (g *Graph) Prune(root string, includeOptional bool) {
rootNode, ok := g.Nodes[root]
if !ok {
return
}
for _, n := range g.Nodes {
n.IsRequired = false
}
var walk func(*Node)
walk = func(n *Node) {
if n.IsRequired {
return
}
n.IsRequired = true
for _, e := range n.Edges {
if e.Type == OptionalDep && !includeOptional {
continue
}
if !e.Enabled && e.Type == OptionalDep {
continue
}
walk(e.Target)
}
}
walk(rootNode)
}
// ParallelBatches groups spells that have no mutual dependencies so they can
// be Cast simultaneously by the worker pool. Batch N+1 only depends on batches
// 1..N, never on itself.
//
// WARNING: This method temporarily sets g.Nodes[name] = nil for consumed
// nodes and restores them before returning. It is NOT safe to call
// concurrently with other Graph operations. The caller must hold exclusive
// access to the Graph during this call.
func (g *Graph) ParallelBatches() ([][]*Node, error) {
if err := g.DetectCycles(); err != nil {
return nil, err
}
indeg := make(map[string]int)
for name, n := range g.Nodes {
indeg[name] = n.InDegree
}
var batches [][]*Node
for {
var ready []*Node
for name, n := range g.Nodes {
if indeg[name] == 0 && n != nil {
ready = append(ready, n)
}
}
if len(ready) == 0 {
break
}
sort.Slice(ready, func(i, j int) bool {
return ready[i].Name < ready[j].Name
})
batches = append(batches, ready)
for _, n := range ready {
g.Nodes[n.Name] = nil // mark consumed
for _, e := range n.Edges {
if e.Type == OptionalDep && !e.Enabled {
continue
}
indeg[e.Target.Name]--
}
}
}
// Restore nodes (we nulled them out above)
for _, b := range batches {
for _, n := range b {
g.Nodes[n.Name] = n
}
}
return batches, nil
}
// ErrSpellNotFound is returned when a spell is missing from the Graph.
var ErrSpellNotFound = errors.New("dag: spell not found in grimoire")