Skip to content

Commit 9ed445e

Browse files
committed
Add decision making (most up-to-date strategy) + comments
1 parent 6a8c9bf commit 9ed445e

File tree

2 files changed

+98
-36
lines changed

2 files changed

+98
-36
lines changed

src/commands/lock.cr

Lines changed: 65 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,16 @@ module Shards
88
resolver : Resolver,
99
versions : Hash(String, Spec)
1010

11+
private def sat
12+
@sat ||= SAT.new
13+
end
14+
15+
private def pkgs
16+
@pkgs ||= {} of String => Pkg
17+
end
18+
1119
def run
20+
# 1. build dependency graph:
1221
Shards.logger.info { "Collecting dependencies..." }
1322

1423
spent = Time.measure do
@@ -21,7 +30,8 @@ module Shards
2130
"Collected #{pkgs.size} dependencies (total: #{total} specs, duration: #{spent})"
2231
end
2332

24-
Shards.logger.info { "Building SAT clauses..." }
33+
# 2. translate dependency graph as CNF clauses (conjunctive normal form):
34+
Shards.logger.debug { "Building clauses..." }
2535

2636
spent = Time.measure do
2737
# the package to install dependencies for:
@@ -39,7 +49,7 @@ module Shards
3949
end
4050
end
4151

42-
# version conflicts (we want only 1 version per package):
52+
# version conflicts (we want at most 1 version for each package):
4353
pkgs.each do |name, pkg|
4454
pkg.versions.keys.each_combination(2) do |(a, b)|
4555
sat.add_clause ["~#{name}:#{a}", "~#{name}:#{b}"]
@@ -52,33 +62,71 @@ module Shards
5262
# STDERR.puts sat.clause_to_s(clause)
5363
# end
5464

65+
# 3. distances (for decision making)
66+
# compute distance for each version from a reference version:
67+
distances = {} of String => Int32
68+
distances[spec.name] = 0
69+
70+
# TODO: should be the distance from a given version or a range of
71+
# versions, not necessarily from the latest one (e.g. for
72+
# conservative updates).
73+
# TODO: consider adding some weight (e.g. to update some dependencies).
74+
pkgs.each do |name, pkg|
75+
pkg.versions.keys.each_with_index do |version, index|
76+
distances["#{name}:#{version}"] = index
77+
end
78+
end
79+
80+
# 4. solving + decision
81+
# FIXME: some nested dependencies seem to be selected despite being extraneous (missing clauses?)
5582
Shards.logger.info { "Solving dependencies..." }
5683
count = 0
5784

85+
solution = nil
86+
solution_distance = Int32::MAX
87+
5888
spent = Time.measure do
59-
sat.solve do |solution|
60-
# p solution
89+
sat.solve do |proposal|
6190
count += 1
91+
92+
# 4 (bis). decision making
93+
# decide the proposal quality (most up-to-date):
94+
distance = proposal.reduce(0) { |a, e| a + distances[e] }
95+
96+
# better solution?
97+
if distance < solution_distance
98+
solution = proposal.dup
99+
solution_distance = distance
100+
Shards.logger.debug { "Select proposal (distance=#{solution_distance}): #{solution.sort.join(' ')}" }
101+
102+
# fewer dependencies?
103+
elsif distance == solution_distance && proposal.size < solution.not_nil!.size
104+
solution = proposal.dup
105+
solution_distance = distance
106+
Shards.logger.debug { "Select smaller proposal (distance=#{solution_distance}): #{solution.sort.join(' ')}" }
107+
end
62108
end
63109
end
64110

65-
if count == 0
66-
Shards.logger.error { "Failed to find a solution (duration: #{spent})" }
111+
# 5.
112+
if solution
113+
Shards.logger.info { "Analyzed #{count} solutions (duration: #{spent}" }
114+
Shards.logger.info { "Found solution:" }
115+
solution
116+
.compact_map { |variable| variable.split(':') if variable.index(':') }
117+
.sort { |a, b| a[0] <=> b[0] }
118+
.each { |p| puts "#{p[0]}: #{p[1]}" }
67119
else
68-
Shards.logger.info { "Found #{count} solutions (duration: #{spent}" }
120+
Shards.logger.error { "Failed to find a solution (duration: #{spent})" }
69121
end
70122
end
71123

72-
private def sat
73-
@sat ||= SAT.new
74-
end
75-
76124
private def add_dependencies(negation, dependencies)
77125
dependencies.each do |d|
78126
versions = Versions.resolve(pkgs[d.name].versions.keys, {d.version})
79127

80128
# FIXME: looks like we couldn't resolve a constraint here; maybe it's
81-
# related to a git refs, or something?
129+
# related to a git refs?
82130
next if versions.empty?
83131

84132
clause = [negation]
@@ -87,18 +135,18 @@ module Shards
87135
end
88136
end
89137

90-
private def pkgs
91-
@pkgs ||= {} of String => Pkg
92-
end
93-
138+
# TODO: try and limit versions to what's actually reachable, in order to
139+
# reduce the dependency graph, which will reduce the number of
140+
# solutions, thus reduce the overall solving time.
94141
private def dig(dependencies, resolve = false)
95142
dependencies.each do |dependency|
96143
next if pkgs.has_key?(dependency.name)
97144

98145
resolver = Shards.find_resolver(dependency)
99146
versions = resolver.available_versions
100147

101-
# resolve main spec constraints (avoids useless branches in the dependency graph):
148+
# resolve main spec constraints (avoids impossible branches in the
149+
# dependency graph):
102150
versions = Versions.resolve(versions, {dependency.version}) if resolve
103151

104152
pkg = Pkg.new(resolver, resolver.specs(Versions.sort(versions)))

src/sat.cr

Lines changed: 33 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -53,16 +53,16 @@ module Shards
5353
@clauses << clause
5454
end
5555

56-
protected def literal_to_s(literal : Literal) : String
56+
private def literal_to_s(literal : Literal) : String
5757
String.build { |str| literal_to_s(str, literal) }
5858
end
5959

60-
protected def literal_to_s(io : IO, literal : Literal) : Nil
60+
private def literal_to_s(io : IO, literal : Literal) : Nil
6161
io << '~' unless literal & 1 == 0
6262
io << @variables[literal >> 1]
6363
end
6464

65-
protected def clause_to_s(clause : Clause) : String
65+
private def clause_to_s(clause : Clause) : String
6666
String.build do |str|
6767
clause.each_with_index do |literal, index|
6868
str << ' ' unless index == 0
@@ -71,7 +71,7 @@ module Shards
7171
end
7272
end
7373

74-
protected def assignment_to_s(assignment, brief = false)
74+
private def assignment_to_s(assignment, brief = false)
7575
String.build do |str|
7676
assignment.each_with_index do |a, index|
7777
if a.selected?
@@ -83,33 +83,47 @@ module Shards
8383
end
8484
end
8585

86-
protected def to_variables(assignment, brief = false)
87-
assignment.each_with_index.compact_map do |(a, index)|
86+
private def to_variables(result, assignment, brief = false)
87+
result.clear
88+
89+
assignment.each_with_index do |a, index|
8890
if a.selected?
89-
@variables[index]
91+
result << @variables[index]
9092
elsif !brief && a.not_selected?
91-
"~#{@variables[index]}"
93+
result << "~#{@variables[index]}"
9294
end
93-
end.to_a
95+
end
9496
end
9597

96-
# Solves SAT and yields solutions.
98+
# Solves SAT and yields proposed solution.
99+
#
100+
# Reuses the yielded array for performance reasons (avoids many
101+
# allocations); you must duplicate the array if you want to memorize a
102+
# solution. For example:
103+
#
104+
# ```
105+
# solution = nil
106+
# ast.solve { |proposal| solution = proposal.dup }
107+
# ```
97108
def solve(brief = true, verbose = false) : Nil
98109
watchlist = setup_watchlist
99110
assignment = Array(Assignment).new(@variables.size) { Assignment::UNDEFINED }
100111

112+
result = [] of String
113+
101114
solve(watchlist, assignment, 0, verbose) do |solution|
102-
yield to_variables(solution, brief)
115+
to_variables(result, solution, brief)
116+
yield result
103117
end
104118
end
105119

106120
# Iteratively solve SAT by assigning to variables d, d+1, ..., n-1.
107121
# Assumes variables 0, ..., d-1 are assigned so far.
108122
private def solve(watchlist, assignment, d, verbose)
109-
# The state list wil keep track of what values for which variables
110-
# we have tried so far. A value of 0 means nothing has been tried yet,
111-
# a value of 1 means False has been tried but not True, 2 means True
112-
# but not False, and 3 means both have been tried.
123+
# The state list keeps track of what values for which variables we have
124+
# tried so far. A value of 0 means nothing has been tried yet, a value of
125+
# 1 means False has been tried but not True, 2 means True but not False,
126+
# and 3 means both have been tried.
113127
n = @variables.size
114128
state = Array(Literal).new(n) { Literal.new(0) }
115129

@@ -120,7 +134,7 @@ module Shards
120134
next
121135
end
122136

123-
# Let's try assigning a value to v. Here would be the place to insert
137+
# Let's try assigning a value to 'v'. Here would be the place to insert
124138
# heuristics of which value to try first.
125139
tried_something = false
126140

@@ -130,7 +144,7 @@ module Shards
130144

131145
tried_something = true
132146

133-
# set the bit indicating a has been tried for d:
147+
# set the bit indicating 'a' has been tried for 'd':
134148
state[d] |= 1 << a
135149
assignment[d] = Assignment.from_value(a)
136150

@@ -174,8 +188,8 @@ module Shards
174188

175189
# Updates the watch list after literal 'false_literal' was just assigned
176190
# `false`, by making any clause watching false_literal watch something else.
177-
# Returns False it is impossible to do so, meaning a clause is contradicted by
178-
# the current assignment.
191+
# Returns `false` if it's impossible to do so, meaning a clause is
192+
# contradicted by the current assignment.
179193
private def update_watchlist(watchlist, false_literal, assignment, verbose)
180194
while clause = watchlist[false_literal].first?
181195
found_alternative = false

0 commit comments

Comments
 (0)