Skip to content

Commit b70c7d4

Browse files
committed
Improve heuristics for Ruby-style dependency graphs
1 parent ca7db71 commit b70c7d4

File tree

6 files changed

+474
-14
lines changed

6 files changed

+474
-14
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -397,7 +397,7 @@ Contributions welcome! Please:
397397
This package is derived from the tinyrange project:
398398
- **Original Repository:** https://github.com/tinyrange/tinyrange
399399
- **Original Package:** experimental/pubgrub
400-
- **Version:** v0.3.1 (Oct 31, 2025)
400+
- **Version:** v0.3.2 (Nov 1, 2025)
401401
- **Original Copyright:** Copyright 2024 The University of Queensland
402402
- **Original License:** Apache 2.0
403403

partial_solution.go

Lines changed: 69 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,14 @@ import (
2121
"strings"
2222
)
2323

24+
const (
25+
constraintScoreEmpty = 0
26+
constraintScoreUnknown = 1000
27+
constraintScoreUnbounded = 1_000_000
28+
29+
maxConstraintPriority = int(^uint(0) >> 1)
30+
)
31+
2432
// partialSolution maintains the evolving solution during dependency resolution.
2533
// It tracks assignments (decisions and derivations) organized by package name
2634
// and decision level, supporting efficient backtracking and version set queries.
@@ -233,8 +241,15 @@ func (ps *partialSolution) isComplete() bool {
233241

234242
// nextDecisionCandidate finds the next package that needs a version decision.
235243
// Returns the package name and true if found, or EmptyName and false if none.
244+
//
245+
// Heuristic: Prefer packages with tighter constraints (smaller allowed sets)
246+
// to reduce search space early. This helps avoid exploring dead ends when
247+
// there are many interdependent packages.
236248
func (ps *partialSolution) nextDecisionCandidate() (Name, bool) {
237249
seen := make(map[Name]bool)
250+
bestScore := maxConstraintPriority
251+
bestName := EmptyName()
252+
found := false
238253

239254
for _, assign := range ps.assignments {
240255
name := assign.name
@@ -246,12 +261,63 @@ func (ps *partialSolution) nextDecisionCandidate() (Name, bool) {
246261
}
247262
seen[name] = true
248263

249-
if !ps.hasDecision(name) {
250-
return name, true
264+
if ps.hasDecision(name) {
265+
continue
266+
}
267+
268+
score := ps.constraintScore(name)
269+
if !found || score < bestScore || (score == bestScore && name.Value() < bestName.Value()) {
270+
bestScore = score
271+
bestName = name
272+
found = true
273+
}
274+
}
275+
276+
if !found {
277+
return EmptyName(), false
278+
}
279+
280+
return bestName, true
281+
}
282+
283+
// constraintScore estimates how constrained a package is.
284+
// Lower scores indicate tighter constraints (should be resolved earlier).
285+
// Returns a large number if unconstrained (to deprioritize).
286+
func (ps *partialSolution) constraintScore(name Name) int {
287+
return constraintScoreForSet(ps.allowedSet(name))
288+
}
289+
290+
// constraintScoreForSet scores a VersionSet using the same heuristic as constraintScore.
291+
func constraintScoreForSet(allowed VersionSet) int {
292+
if allowed == nil {
293+
return constraintScoreUnknown
294+
}
295+
296+
if intervalSet, ok := allowed.(*VersionIntervalSet); ok {
297+
numIntervals := len(intervalSet.intervals)
298+
if numIntervals == 0 {
299+
// Empty set - should fail fast, highest priority
300+
return constraintScoreEmpty
251301
}
302+
if numIntervals == 1 {
303+
interval := intervalSet.intervals[0]
304+
// Check if fully unbounded (negative infinity to positive infinity)
305+
if interval.lower.isNegInfinity() && interval.upper.isPosInfinity() {
306+
// Fully unbounded - lowest priority
307+
return constraintScoreUnbounded
308+
}
309+
}
310+
// More intervals = more holes/exclusions = typically tighter
311+
// But a single tight interval is also good
312+
// Use interval count as proxy for constraint tightness
313+
return numIntervals
252314
}
253315

254-
return EmptyName(), false
316+
// Fallback: if we can't analyze the set structure, use medium priority
317+
if allowed.IsEmpty() {
318+
return constraintScoreEmpty // Empty = impossible, resolve immediately to fail fast
319+
}
320+
return constraintScoreUnknown // Unknown structure = medium priority
255321
}
256322

257323
// hasDecision returns true if there's a decision assignment for the package.

solver.go

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -188,14 +188,18 @@ func (s *Solver) Solve(root Term) (Solution, error) {
188188
allowedStr = allowed.String()
189189
}
190190
pending := state.partial.pendingPackages()
191+
192+
// Log constraint score for the selected package (heuristic debugging)
193+
constraintScore := state.partial.constraintScore(nextPkg)
191194
s.debug("selecting package",
192195
"step", steps,
193196
"package", nextPkg,
194197
"allowed", allowedStr,
198+
"constraint_score", constraintScore,
195199
"pending", joinNameValues(pending),
196200
)
197201

198-
ver, found, err := state.pickVersion(nextPkg)
202+
ver, found, score, err := state.pickVersion(nextPkg)
199203
if err != nil {
200204
return nil, err
201205
}
@@ -210,7 +214,14 @@ func (s *Solver) Solve(root Term) (Solution, error) {
210214
continue
211215
}
212216

213-
s.debug("making decision", "step", steps, "package", nextPkg, "version", ver)
217+
// Log dependency score for the chosen version (heuristic debugging)
218+
depScore := score
219+
s.debug("making decision",
220+
"step", steps,
221+
"package", nextPkg,
222+
"version", ver,
223+
"dep_score", depScore,
224+
)
214225

215226
assign := state.partial.addDecision(nextPkg, ver)
216227
state.traceAssignment("decision", assign)

solver_complex_test.go

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
package pubgrub
2+
3+
import (
4+
"testing"
5+
)
6+
7+
// TestComplexRubyGemsScenario tests a more realistic scenario with multiple
8+
// packages that all transitively depend on shared dependencies.
9+
//
10+
// This simulates what happens in a real Rails project where many gems
11+
// depend on common utilities like rubyzip, and wrong version choices
12+
// early in the search can lead to dead ends.
13+
//
14+
// Package structure:
15+
// - root → [roo, rubyXL, caxlsx, another_gem]
16+
// - All four packages use rubyzip with different constraints
17+
// - PubGrub must choose rubyzip version that works for ALL of them
18+
//
19+
// This test will help identify if PubGrub's unit propagation and
20+
// conflict learning are working efficiently.
21+
func TestComplexRubyGemsScenario(t *testing.T) {
22+
source := NewMapSource()
23+
24+
// Add rubyzip versions
25+
source.Add("rubyzip", "1.3.0", nil)
26+
source.Add("rubyzip", "2.3.0", nil)
27+
source.Add("rubyzip", "2.4.0", nil)
28+
source.Add("rubyzip", "2.4.1", nil)
29+
source.Add("rubyzip", "3.0.0", nil)
30+
source.Add("rubyzip", "3.1.0", nil)
31+
32+
// Add roo versions - note that OLD versions require rubyzip >= 3.0
33+
source.Add("roo", "2.1.0", []Dependency{
34+
{Name: "rubyzip", Constraint: ">= 3.0.0, < 4.0.0"},
35+
})
36+
source.Add("roo", "2.5.0", []Dependency{
37+
{Name: "rubyzip", Constraint: ">= 3.0.0, < 4.0.0"},
38+
})
39+
source.Add("roo", "2.9.0", []Dependency{
40+
{Name: "rubyzip", Constraint: ">= 3.0.0, < 4.0.0"},
41+
})
42+
// Only 2.10.1 and 3.0.0 are compatible with rubyzip 2.x
43+
source.Add("roo", "2.10.1", []Dependency{
44+
{Name: "rubyzip", Constraint: ">= 1.3.0, < 3.0.0"},
45+
})
46+
source.Add("roo", "3.0.0", []Dependency{
47+
{Name: "rubyzip", Constraint: ">= 3.0.0, < 4.0.0"},
48+
})
49+
50+
// Add rubyXL versions - all require rubyzip ~> 2.4
51+
source.Add("rubyXL", "3.4.14", []Dependency{
52+
{Name: "rubyzip", Constraint: ">= 2.4.0, < 3.0.0"},
53+
})
54+
source.Add("rubyXL", "3.4.25", []Dependency{
55+
{Name: "rubyzip", Constraint: ">= 2.4.0, < 3.0.0"},
56+
})
57+
source.Add("rubyXL", "3.4.34", []Dependency{
58+
{Name: "rubyzip", Constraint: ">= 2.4.0, < 3.0.0"},
59+
})
60+
61+
// Add caxlsx which also depends on rubyzip
62+
source.Add("caxlsx", "3.3.0", []Dependency{
63+
{Name: "rubyzip", Constraint: ">= 1.6.0, < 3.0.0"},
64+
})
65+
source.Add("caxlsx", "4.0.0", []Dependency{
66+
{Name: "rubyzip", Constraint: ">= 2.3.0, < 4.0.0"},
67+
})
68+
69+
// Add another gem that prefers older rubyzip
70+
source.Add("zip_tricks", "5.6.0", []Dependency{
71+
{Name: "rubyzip", Constraint: ">= 1.3.0, < 3.0.0"},
72+
})
73+
74+
// Root depends on all four packages
75+
rootSource := NewRootSource()
76+
rootSource.AddPackage(MakeName("roo"), NewAnyVersionCondition())
77+
rootSource.AddPackage(MakeName("rubyXL"), NewAnyVersionCondition())
78+
rootSource.AddPackage(MakeName("caxlsx"), NewAnyVersionCondition())
79+
rootSource.AddPackage(MakeName("zip_tricks"), NewAnyVersionCondition())
80+
81+
// Create solver
82+
solver := NewSolver(rootSource, source)
83+
84+
// Solve
85+
solution, err := solver.Solve(rootSource.Term())
86+
if err != nil {
87+
t.Fatalf("Expected solution but got error: %v", err)
88+
}
89+
90+
// Verify solution
91+
solutionMap := make(map[string]string)
92+
for _, pkg := range solution {
93+
if pkg.Name.Value() != "$$root" {
94+
solutionMap[pkg.Name.Value()] = pkg.Version.String()
95+
}
96+
}
97+
98+
// The only valid solution should use rubyzip 2.4.x
99+
// because that's the intersection of all constraints:
100+
// - roo 2.10.1: >= 1.3.0, < 3.0.0
101+
// - rubyXL: >= 2.4.0, < 3.0.0
102+
// - caxlsx: depends on which version, but should work with 2.4.x
103+
// - zip_tricks: >= 1.3.0, < 3.0.0
104+
//
105+
// Intersection: >= 2.4.0, < 3.0.0 → rubyzip 2.4.1
106+
107+
if solutionMap["roo"] != "2.10.1" {
108+
t.Errorf("Expected roo 2.10.1, got %s", solutionMap["roo"])
109+
}
110+
if solutionMap["rubyzip"] < "2.4.0" || solutionMap["rubyzip"] >= "3.0.0" {
111+
t.Errorf("Expected rubyzip in [2.4.0, 3.0.0), got %s", solutionMap["rubyzip"])
112+
}
113+
114+
// Print solution for debugging
115+
t.Logf("Solution found:")
116+
for name, version := range solutionMap {
117+
t.Logf(" %s = %s", name, version)
118+
}
119+
}

0 commit comments

Comments
 (0)