Skip to content

Commit ca7db71

Browse files
committed
Add structured logging hooks for solver diagnostics
1 parent 3fbc25b commit ca7db71

File tree

6 files changed

+239
-7
lines changed

6 files changed

+239
-7
lines changed

README.md

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ A comprehensive Go implementation of the PubGrub version solving algorithm with
1414
- 🚀 **CDCL Solver** - Conflict-driven clause learning with unit propagation
1515
- 🧪 **Well Tested** - Comprehensive test suite with strong coverage
1616
-**Production Ready** - Handles complex dependency graphs efficiently
17+
- 🪵 **Structured Debug Logging** - Plug in `log/slog` via `WithLogger` for rich solver traces
1718

1819
## Origin
1920

@@ -128,6 +129,37 @@ func main() {
128129
}
129130
```
130131

132+
### Debug Logging
133+
134+
```go
135+
package main
136+
137+
import (
138+
"log/slog"
139+
"os"
140+
141+
"github.com/contriboss/pubgrub-go"
142+
)
143+
144+
func main() {
145+
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
146+
Level: slog.LevelDebug,
147+
}))
148+
149+
root := pubgrub.NewRootSource()
150+
source := &pubgrub.InMemorySource{}
151+
152+
solver := pubgrub.NewSolverWithOptions(
153+
[]pubgrub.Source{root, source},
154+
pubgrub.WithLogger(logger),
155+
)
156+
157+
if _, err := solver.Solve(root.Term()); err != nil {
158+
logger.Error("resolution failed", "err", err)
159+
}
160+
}
161+
```
162+
131163
## Core Concepts
132164

133165
### Versions
@@ -365,7 +397,7 @@ Contributions welcome! Please:
365397
This package is derived from the tinyrange project:
366398
- **Original Repository:** https://github.com/tinyrange/tinyrange
367399
- **Original Package:** experimental/pubgrub
368-
- **Version:** v0.2.7 (Oct 27, 2025)
400+
- **Version:** v0.3.1 (Oct 31, 2025)
369401
- **Original Copyright:** Copyright 2024 The University of Queensland
370402
- **Original License:** Apache 2.0
371403

assignment.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515

1616
package pubgrub
1717

18+
import "fmt"
19+
1820
// assignmentKind distinguishes between decision and derivation assignments.
1921
// Decision assignments are explicit choices made by the solver (version selections).
2022
// Derivation assignments are constraints derived from incompatibilities via unit propagation.
@@ -49,3 +51,36 @@ type assignment struct {
4951
func (a *assignment) isDecision() bool {
5052
return a.kind == assignmentDecision
5153
}
54+
55+
// describe returns a compact string describing the assignment.
56+
// Used exclusively for debug logging to keep the hot path free of allocations.
57+
func (a *assignment) describe() string {
58+
if a == nil {
59+
return "<nil>"
60+
}
61+
62+
kind := "derivation"
63+
if a.isDecision() {
64+
kind = "decision"
65+
}
66+
67+
version := "<nil>"
68+
if a.version != nil {
69+
version = a.version.String()
70+
}
71+
72+
desc := fmt.Sprintf("%s idx=%d lvl=%d kind=%s term=%s version=%s",
73+
a.name.Value(), a.index, a.decisionLevel, kind, a.term.String(), version)
74+
75+
if a.allowed != nil {
76+
desc += fmt.Sprintf(" allowed=%s", a.allowed.String())
77+
}
78+
if a.forbidden != nil {
79+
desc += fmt.Sprintf(" forbidden=%s", a.forbidden.String())
80+
}
81+
if a.cause != nil {
82+
desc += fmt.Sprintf(" cause=\"%s\"", a.cause.String())
83+
}
84+
85+
return desc
86+
}

partial_solution.go

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,11 @@
1515

1616
package pubgrub
1717

18-
import "errors"
18+
import (
19+
"errors"
20+
"fmt"
21+
"strings"
22+
)
1923

2024
// partialSolution maintains the evolving solution during dependency resolution.
2125
// It tracks assignments (decisions and derivations) organized by package name
@@ -326,6 +330,38 @@ func (ps *partialSolution) buildSolution() Solution {
326330
return result
327331
}
328332

333+
// snapshot returns a human-readable representation of the partial solution.
334+
// Intended for debug logging to understand solver state during complex conflicts.
335+
func (ps *partialSolution) snapshot() string {
336+
var b strings.Builder
337+
fmt.Fprintf(&b, "decision_level=%d next_index=%d assignments=%d\n", ps.decisionLvl, ps.nextIndex, len(ps.assignments))
338+
for _, assign := range ps.assignments {
339+
fmt.Fprintf(&b, " %s\n", assign.describe())
340+
}
341+
return b.String()
342+
}
343+
344+
// pendingPackages lists packages that have constraints but no decided version yet.
345+
// Used for diagnostics when analysing package selection order.
346+
func (ps *partialSolution) pendingPackages() []Name {
347+
pending := make([]Name, 0)
348+
seen := make(map[Name]bool)
349+
350+
for _, assign := range ps.assignments {
351+
name := assign.name
352+
if name == ps.root || seen[name] {
353+
continue
354+
}
355+
seen[name] = true
356+
357+
if !ps.hasDecision(name) {
358+
pending = append(pending, name)
359+
}
360+
}
361+
362+
return pending
363+
}
364+
329365
// termSatisfiedBy checks if an assignment satisfies a term in an incompatibility.
330366
func termSatisfiedBy(term Term, assign *assignment) bool {
331367
if assign == nil {
@@ -341,10 +377,10 @@ func termSatisfiedBy(term Term, assign *assignment) bool {
341377
if assign.allowed == nil {
342378
return false
343379
}
344-
return assign.allowed.IsSubset(required) || assign.allowed.IsDisjoint(required)
380+
return assign.allowed.IsSubset(required)
345381
}
346382
if assign.allowed != nil {
347-
return assign.allowed.IsSubset(required) || assign.allowed.IsDisjoint(required)
383+
return assign.allowed.IsSubset(required)
348384
}
349385
return false
350386
}
@@ -358,7 +394,7 @@ func termSatisfiedBy(term Term, assign *assignment) bool {
358394
if assign.allowed == nil {
359395
return false
360396
}
361-
return assign.allowed.IsDisjoint(forbidden) || assign.allowed.IsSubset(forbidden)
397+
return assign.allowed.IsDisjoint(forbidden)
362398
}
363399

364400
if assign.forbidden == nil {

partial_solution_test.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package pubgrub
2+
3+
import "testing"
4+
5+
func TestPartialSolutionPreviousDecisionLevel(t *testing.T) {
6+
root := MakeName("root")
7+
ps := newPartialSolution(root)
8+
rootVersion := SimpleVersion("1.0.0")
9+
ps.seedRoot(root, rootVersion)
10+
11+
a := MakeName("a")
12+
aVersion := SimpleVersion("1.0.0")
13+
ps.addDecision(a, aVersion)
14+
15+
b := MakeName("b")
16+
bVersion := SimpleVersion("1.0.0")
17+
assignB := ps.addDecision(b, bVersion)
18+
19+
inc := &Incompatibility{
20+
Terms: []Term{
21+
NewTerm(a, EqualsCondition{Version: aVersion}),
22+
NewTerm(b, EqualsCondition{Version: bVersion}),
23+
},
24+
Kind: KindConflict,
25+
}
26+
27+
satisfier := ps.satisfier(inc)
28+
if satisfier == nil {
29+
t.Fatalf("expected satisfier, got nil")
30+
}
31+
if satisfier != assignB {
32+
t.Fatalf("expected satisfier to be assignment for %s, got %s", b.Value(), satisfier.name.Value())
33+
}
34+
35+
prev := ps.previousDecisionLevel(inc, satisfier)
36+
if prev != 1 {
37+
t.Fatalf("expected previous decision level 1, got %d", prev)
38+
}
39+
}

solver.go

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515

1616
package pubgrub
1717

18+
import "strings"
19+
1820
// Solver implements the PubGrub dependency resolution algorithm with CDCL.
1921
//
2022
// The solver uses Conflict-Driven Clause Learning (CDCL) to efficiently
@@ -118,6 +120,7 @@ func (s *Solver) Solve(root Term) (Solution, error) {
118120

119121
assign := state.partial.seedRoot(root.Name, version)
120122
state.markAssigned(root.Name)
123+
state.traceAssignment("seed", assign)
121124

122125
s.debug("seeded root", "package", root.Name, "version", version)
123126

@@ -179,7 +182,18 @@ func (s *Solver) Solve(root Term) (Solution, error) {
179182
return state.partial.buildSolution(), nil
180183
}
181184

182-
s.debug("selecting package", "step", steps, "package", nextPkg)
185+
allowed := state.partial.allowedSet(nextPkg)
186+
allowedStr := "<nil>"
187+
if allowed != nil {
188+
allowedStr = allowed.String()
189+
}
190+
pending := state.partial.pendingPackages()
191+
s.debug("selecting package",
192+
"step", steps,
193+
"package", nextPkg,
194+
"allowed", allowedStr,
195+
"pending", joinNameValues(pending),
196+
)
183197

184198
ver, found, err := state.pickVersion(nextPkg)
185199
if err != nil {
@@ -199,6 +213,7 @@ func (s *Solver) Solve(root Term) (Solution, error) {
199213
s.debug("making decision", "step", steps, "package", nextPkg, "version", ver)
200214

201215
assign := state.partial.addDecision(nextPkg, ver)
216+
state.traceAssignment("decision", assign)
202217
state.markAssigned(assign.name)
203218

204219
deps, err := s.Source.GetDependencies(nextPkg, ver)
@@ -217,6 +232,17 @@ func (s *Solver) Solve(root Term) (Solution, error) {
217232
}
218233
}
219234

235+
func joinNameValues(names []Name) string {
236+
if len(names) == 0 {
237+
return ""
238+
}
239+
values := make([]string, len(names))
240+
for i, name := range names {
241+
values[i] = name.Value()
242+
}
243+
return strings.Join(values, ",")
244+
}
245+
220246
func extractDecisionVersion(root Term) (Version, error) {
221247
if !root.Positive {
222248
return nil, &VersionError{Package: root.Name, Message: "root term must be positive"}

0 commit comments

Comments
 (0)