Skip to content

Commit 7821152

Browse files
committed
Merge branch 'resolver-correctness-test' of https://github.com/ddunbar/swift-package-manager
2 parents 0f1ff16 + 851b29f commit 7821152

File tree

2 files changed

+229
-26
lines changed

2 files changed

+229
-26
lines changed

Sources/PackageGraph/DependencyResolver.swift

Lines changed: 39 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -323,25 +323,32 @@ struct PackageContainerConstraintSet<C: PackageContainer>: Collection {
323323
/// `constraints`, but this invariant is not explicitly enforced.
324324
//
325325
// FIXME: Actually make efficient.
326-
struct VersionAssignmentSet<C: PackageContainer>: Sequence {
326+
struct VersionAssignmentSet<C: PackageContainer>: Equatable, Sequence {
327327
typealias Container = C
328328
typealias Identifier = Container.Identifier
329329

330330
/// The assignment records.
331331
//
332332
// FIXME: Does it really make sense to key on the identifier here. Should we
333333
// require referential equality of containers and use that to simplify?
334-
private var assignments: [Identifier: (container: Container, binding: BoundVersion)]
334+
fileprivate var assignments: [Identifier: (container: Container, binding: BoundVersion)]
335335

336336
/// Create an empty assignment.
337337
init() {
338338
assignments = [:]
339339
}
340340

341+
/// The assignment for the given container `identifier.
342+
subscript(identifier: Identifier) -> BoundVersion? {
343+
get {
344+
return assignments[identifier]?.binding
345+
}
346+
}
347+
341348
/// The assignment for the given `container`.
342349
subscript(container: Container) -> BoundVersion? {
343350
get {
344-
return assignments[container.identifier]?.binding
351+
return self[container.identifier]
345352
}
346353
set {
347354
// We disallow deletion.
@@ -481,6 +488,18 @@ struct VersionAssignmentSet<C: PackageContainer>: Sequence {
481488
}
482489
}
483490
}
491+
func ==<C: PackageContainer>(lhs: VersionAssignmentSet<C>, rhs: VersionAssignmentSet<C>) -> Bool {
492+
if lhs.assignments.count != rhs.assignments.count { return false }
493+
for (container, lhsBinding) in lhs {
494+
switch rhs[container] {
495+
case let rhsBinding? where lhsBinding == rhsBinding:
496+
continue
497+
default:
498+
return false
499+
}
500+
}
501+
return true
502+
}
484503

485504
/// A general purpose package dependency resolver.
486505
///
@@ -567,19 +586,29 @@ public class DependencyResolver<
567586
/// - Returns: A satisfying assignment of containers and versions.
568587
/// - Throws: DependencyResolverError, or errors from the underlying package provider.
569588
public func resolve(constraints: [Constraint]) throws -> [(container: Identifier, version: Version)] {
589+
return try resolveAssignment(constraints: constraints).map { (container, binding) in
590+
guard case .version(let version) = binding else {
591+
fatalError("unexpected exclude binding")
592+
}
593+
return (container: container.identifier, version: version)
594+
}
595+
}
596+
597+
/// Execute the resolution algorithm to find a valid assignment of versions.
598+
///
599+
/// - Parameters:
600+
/// - constraints: The contraints to solve. It is legal to supply multiple
601+
/// constraints for the same container identifier.
602+
/// - Returns: A satisfying assignment of containers and versions.
603+
/// - Throws: DependencyResolverError, or errors from the underlying package provider.
604+
func resolveAssignment(constraints: [Constraint]) throws -> AssignmentSet {
570605
// Create an assignment for the input constraints.
571606
guard let assignment = try merge(
572607
constraints: constraints, into: AssignmentSet(),
573608
subjectTo: ConstraintSet(), excluding: [:]) else {
574609
throw DependencyResolverError.unsatisfiable
575610
}
576-
577-
return assignment.map { (container, binding) in
578-
guard case .version(let version) = binding else {
579-
fatalError("unexpected exclude binding")
580-
}
581-
return (container: container.identifier, version: version)
582-
}
611+
return assignment
583612
}
584613

585614
/// Resolve an individual container dependency tree.

Tests/PackageGraphTests/DependencyResolverTests.swift

Lines changed: 190 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
import XCTest
1212

13+
import Basic
1314
import PackageGraph
1415

1516
import struct Utility.Version
@@ -52,13 +53,17 @@ private struct MockPackageContainer: PackageContainer {
5253
private struct MockPackagesProvider: PackageContainerProvider {
5354
typealias Container = MockPackageContainer
5455

55-
let containers: [MockPackageContainer]
56+
let containers: [Container]
57+
let containersByIdentifier: [Container.Identifier: Container]
58+
59+
init(containers: [MockPackageContainer]) {
60+
self.containers = containers
61+
self.containersByIdentifier = Dictionary(items: containers.map{ ($0.identifier, $0) })
62+
}
5663

5764
func getContainer(for identifier: Container.Identifier) throws -> Container {
58-
for container in containers {
59-
if container.name == identifier {
60-
return container
61-
}
65+
if let container = containersByIdentifier[identifier] {
66+
return container
6267
}
6368
throw MockLoadingError.unknownModule
6469
}
@@ -75,6 +80,7 @@ private class MockResolverDelegate: DependencyResolverDelegate {
7580
}
7681

7782
private typealias MockDependencyResolver = DependencyResolver<MockPackagesProvider, MockResolverDelegate>
83+
private typealias MockVersionAssignmentSet = VersionAssignmentSet<MockPackageContainer>
7884

7985
// Some handy ranges.
8086
//
@@ -172,7 +178,7 @@ class DependencyResolverTests: XCTestCase {
172178
let c = MockPackageContainer(name: "C", dependenciesByVersion: [
173179
v1: []])
174180

175-
var assignment = VersionAssignmentSet<MockPackageContainer>()
181+
var assignment = MockVersionAssignmentSet()
176182
XCTAssertEqual(assignment.constraints, [:])
177183
XCTAssert(assignment.isValid(binding: .version(v2), for: b))
178184
// An empty assignment is valid.
@@ -210,7 +216,7 @@ class DependencyResolverTests: XCTestCase {
210216
let d = MockPackageContainer(name: "D", dependenciesByVersion: [
211217
v1: [(container: "E", versionRequirement: v1Range)],
212218
v2: []])
213-
var assignment2 = VersionAssignmentSet<MockPackageContainer>()
219+
var assignment2 = MockVersionAssignmentSet()
214220
assignment2[d] = .version(v1)
215221
if let mergedAssignment = assignment.merging(assignment2) {
216222
assignment = mergedAssignment
@@ -222,12 +228,12 @@ class DependencyResolverTests: XCTestCase {
222228
// Check merger of an assignment with incompatible constraints.
223229
let d2 = MockPackageContainer(name: "D2", dependenciesByVersion: [
224230
v1: [(container: "E", versionRequirement: v2Range)]])
225-
var assignment3 = VersionAssignmentSet<MockPackageContainer>()
231+
var assignment3 = MockVersionAssignmentSet()
226232
assignment3[d2] = .version(v1)
227233
XCTAssertEqual(assignment.merging(assignment3), nil)
228234

229235
// Check merger of an incompatible assignment.
230-
var assignment4 = VersionAssignmentSet<MockPackageContainer>()
236+
var assignment4 = MockVersionAssignmentSet()
231237
assignment4[d] = .version(v2)
232238
XCTAssertEqual(assignment.merging(assignment4), nil)
233239
}
@@ -362,16 +368,189 @@ class DependencyResolverTests: XCTestCase {
362368
}
363369
}
364370

371+
/// Check completeness on a variety of synthetic graphs.
372+
func testCompleteness() throws {
373+
typealias ConstraintSet = MockDependencyResolver.ConstraintSet
374+
375+
// We check correctness by comparing the result to an oracle which implements a trivial brute force solver.
376+
377+
// Check respect for the input constraints on version selection.
378+
do {
379+
let provider = MockPackagesProvider(containers: [
380+
MockPackageContainer(name: "A", dependenciesByVersion: [
381+
v1: [], v1_1: []]),
382+
MockPackageContainer(name: "B", dependenciesByVersion: [
383+
v1: [], v1_1: []])
384+
])
385+
let resolver = MockDependencyResolver(provider, MockResolverDelegate())
386+
387+
// Check the maximal solution is picked.
388+
try checkResolution(resolver, constraints: [
389+
MockPackageConstraint(container: "A", versionRequirement: v1Range),
390+
MockPackageConstraint(container: "B", versionRequirement: v1Range)])
391+
}
392+
}
393+
365394
static var allTests = [
366395
("testBasics", testBasics),
367396
("testVersionSetSpecifier", testVersionSetSpecifier),
368397
("testContainerConstraintSet", testContainerConstraintSet),
369398
("testVersionAssignment", testVersionAssignment),
370399
("testResolveSubtree", testResolveSubtree),
371400
("testResolve", testResolve),
401+
("testCompleteness", testCompleteness),
372402
]
373403
}
374404

405+
/// Validate the solution made by `resolver` for the given `constraints`.
406+
///
407+
/// This checks that the solution is complete, correct, and maximal and that it
408+
/// does not contain spurious assignments.
409+
private func checkResolution(_ resolver: MockDependencyResolver, constraints: [MockPackageConstraint]) throws {
410+
// Compute the complete set of valid solution by brute force enumeration.
411+
func satisfiesConstraints(_ assignment: MockVersionAssignmentSet) -> Bool {
412+
for constraint in constraints {
413+
// FIXME: This is ambiguous, but currently the presence of a
414+
// constraint means the package is required.
415+
guard case let .version(version)? = assignment[constraint.identifier] else { return false }
416+
if !constraint.versionRequirement.contains(version) {
417+
return false
418+
}
419+
}
420+
return true
421+
}
422+
func isValidSolution(_ assignment: MockVersionAssignmentSet) -> Bool {
423+
// A solution is valid if it is consistent and complete, meets the input
424+
// constraints, and doesn't contain any unnecessary bindings.
425+
guard assignment.checkIfValidAndComplete() && satisfiesConstraints(assignment) else { return false }
426+
427+
// Check the assignment doesn't contain unnecessary bindings.
428+
let requiredContainers = transitiveClosure(constraints.map{ $0.identifier }, successors: { identifier in
429+
guard case let .version(version)? = assignment[identifier] else {
430+
fatalError("unexpected assignment")
431+
}
432+
let container = try! resolver.provider.getContainer(for: identifier)
433+
return [identifier] + container.getDependencies(at: version).map{ $0.identifier }
434+
})
435+
for (container, _) in assignment {
436+
if !requiredContainers.contains(container.identifier) {
437+
return false
438+
}
439+
}
440+
441+
return true
442+
}
443+
let validSolutions = allPossibleAssignments(for: resolver.provider).filter(isValidSolution)
444+
445+
// Compute the list of maximal solutions.
446+
var maximalSolutions = [MockVersionAssignmentSet]()
447+
for solution in validSolutions {
448+
// Eliminate any currently maximal solutions this one is greater than.
449+
let numPreviousSolutions = maximalSolutions.count
450+
maximalSolutions = maximalSolutions.filter{ !solution.isStrictlyGreater(than: $0) }
451+
452+
// If we eliminated any solution, then this is a new maximal solution.
453+
if maximalSolutions.count != numPreviousSolutions {
454+
assert(maximalSolutions.first(where: { $0.isStrictlyGreater(than: solution) }) == nil)
455+
maximalSolutions.append(solution)
456+
} else {
457+
// Otherwise, this is still a new maximal solution if it isn't comparable to any other one.
458+
if maximalSolutions.first(where: { $0.isStrictlyGreater(than: solution) }) == nil {
459+
maximalSolutions.append(solution)
460+
}
461+
}
462+
}
463+
464+
// FIXME: It is possible there are multiple maximal solutions, we don't yet
465+
// define the ordering required to establish what the "correct" answer is
466+
// here.
467+
if maximalSolutions.count > 1 {
468+
return XCTFail("unable to find a unique solution for input test case")
469+
}
470+
471+
// Get the resolver's solution.
472+
var solution: MockVersionAssignmentSet?
473+
do {
474+
solution = try resolver.resolveAssignment(constraints: constraints)
475+
} catch DependencyResolverError.unsatisfiable {
476+
solution = nil
477+
}
478+
479+
// Check the solution against our oracle.
480+
if let solution = solution {
481+
if maximalSolutions.count != 1 {
482+
return XCTFail("solver unexpectedly found: \(solution) when there are no viable solutions")
483+
}
484+
if solution != maximalSolutions[0] {
485+
return XCTFail("solver result: \(solution.map{ ($0.0.identifier, $0.1) }) does not match expected result: \(maximalSolutions[0].map{ ($0.0.identifier, $0.1) })")
486+
}
487+
} else {
488+
if maximalSolutions.count != 0 {
489+
return XCTFail("solver was unable to find the valid solution: \(validSolutions[0])")
490+
}
491+
}
492+
}
493+
494+
/// Compute a sequence of all possible assignments.
495+
private func allPossibleAssignments(for provider: MockPackagesProvider) -> AnySequence<MockVersionAssignmentSet> {
496+
func allPossibleAssignments(for containers: AnyIterator<MockPackageContainer>) -> [MockVersionAssignmentSet] {
497+
guard let container = containers.next() else {
498+
// The empty list only has one assignment.
499+
return [MockVersionAssignmentSet()]
500+
}
501+
502+
// The result is all other assignments amended with an assignment of
503+
// this container to each possible version, or not included.
504+
//
505+
// FIXME: It would be nice to be lazy here...
506+
let otherAssignments = allPossibleAssignments(for: containers)
507+
return otherAssignments + container.versions.reversed().flatMap{ version in
508+
return otherAssignments.map{ assignment in
509+
var assignment = assignment
510+
assignment[container] = .version(version)
511+
return assignment
512+
}
513+
}
514+
}
515+
516+
return AnySequence(allPossibleAssignments(for: AnyIterator(provider.containers.makeIterator())))
517+
}
518+
519+
extension VersionAssignmentSet {
520+
/// Define a partial ordering among assignments.
521+
///
522+
/// This checks if an assignment has bindings which are strictly greater (as
523+
/// semantic versions) than those of `rhs`. Binding with excluded
524+
/// assignments are incomparable when the assignments differ.
525+
func isStrictlyGreater(than rhs: VersionAssignmentSet) -> Bool {
526+
// This set is strictly greater than `rhs` if every assigned version in
527+
// it is greater than or equal to those in `rhs`, and some assignment is
528+
// strictly greater.
529+
var hasGreaterAssignment = false
530+
for (container, rhsBinding) in rhs {
531+
guard let lhsBinding = self[container] else { return false }
532+
533+
switch (lhsBinding, rhsBinding) {
534+
case (.excluded, .excluded):
535+
// If the container is excluded in both assignments, it is ok.
536+
break
537+
case (.excluded, _), (_, .excluded):
538+
// If the container is excluded in one of the assignments, they are incomparable.
539+
return false
540+
case let (.version(lhsVersion), .version(rhsVersion)):
541+
if lhsVersion < rhsVersion {
542+
return false
543+
} else if lhsVersion > rhsVersion {
544+
hasGreaterAssignment = true
545+
}
546+
default:
547+
fatalError("unreachable")
548+
}
549+
}
550+
return hasGreaterAssignment
551+
}
552+
}
553+
375554
private extension DependencyResolver {
376555
func resolveSubtree(
377556
_ container: Container,
@@ -410,14 +589,11 @@ where C.Identifier == String
410589

411590
private func XCTAssertEqual<C: PackageContainer>(
412591
_ assignment: VersionAssignmentSet<C>?,
413-
_ expected: [String: Version]?,
592+
_ expected: [String: Version],
414593
file: StaticString = #file, line: UInt = #line)
415594
where C.Identifier == String
416595
{
417596
if let assignment = assignment {
418-
guard let expected = expected else {
419-
return XCTFail("unexpected satisfying assignment (expected failure): \(assignment)", file: file, line: line)
420-
}
421597
var actual = [String: Version]()
422598
for (container, binding) in assignment {
423599
guard case .version(let version) = binding else {
@@ -427,9 +603,7 @@ where C.Identifier == String
427603
}
428604
XCTAssertEqual(actual, expected, file: file, line: line)
429605
} else {
430-
if let expected = expected {
431-
return XCTFail("unexpected missing assignment, expected: \(expected)", file: file, line: line)
432-
}
606+
return XCTFail("unexpected missing assignment, expected: \(expected)", file: file, line: line)
433607
}
434608
}
435609

0 commit comments

Comments
 (0)