|
| 1 | +# Description Of Solver Algorithm |
| 2 | + |
| 3 | +## Overview |
| 4 | + |
| 5 | +* A **phase** is a current state of the solve. It contains a list of **scopes**. |
| 6 | +* A **scope** is a package request. If the request isn't a conflict, then a scope |
| 7 | + also contains the actual list of variants that match the request. |
| 8 | + |
| 9 | +The solve loop performs 5 different types of operations: |
| 10 | + |
| 11 | +* **EXTRACT**. This happens when a common dependency is found in all the variants |
| 12 | + in a scope. For example if every version of pkg 'foo' depends on some version |
| 13 | + of python, the 'extracted' dependency might be "python-2.6|2.7". |
| 14 | + |
| 15 | +* **MERGE-EXTRACTIONS**. When one or more scopes are successfully *extracted*, |
| 16 | + this results in a list of package requests. This list is then merged into a new |
| 17 | + list, which may be unchanged, or simpler, or may cause a conflict. If a conflict |
| 18 | + occurs then the phase is in conflict, and fails. |
| 19 | + |
| 20 | +* **INTERSECT**: This happens when an extracted dependency overlaps with an existing |
| 21 | + scope. For example "python-2" might be a current scope. Pkg foo's common dependency |
| 22 | + python-2.6|2.7 would be 'intersected' with this scope. This might result in a |
| 23 | + conflict, which would cause the whole phase to fail (and possibly the whole solve). |
| 24 | + Or, as in this case, it narrows an existing scope to 'python-2.6|2.7'. |
| 25 | + |
| 26 | +* **ADD**: This happens when an extraction is a new pkg request. A new scope is |
| 27 | + created and added to the current list of scopes. |
| 28 | + |
| 29 | +* **REDUCE**: This is when a scope iterates over all of its variants and removes those |
| 30 | + that conflict with another scope. If this removes all the variants in the scope, |
| 31 | + the phase has failed - this is called a "total reduction". This type of failure |
| 32 | + is not common - usually it's a conflicting INTERSECT that causes a failure. |
| 33 | + |
| 34 | +* **SPLIT**: Once a phase has been extracted/intersected/added/reduced as much as |
| 35 | + possible (this is called 'exhausted'), we are left with either a solution (each |
| 36 | + scope contains only a single variant), or an unsolved phase. This is when the |
| 37 | + algorithm needs to recurse (although it doesn't actually recurse, it uses a stack |
| 38 | + instead). A SPLIT occurs at this point. The first scope with more than one |
| 39 | + variant is found. This scope is split in two (let us say ScopeA and ScopeB), |
| 40 | + where ScopeA has at least one common dependency (worst case scenario, ScopeA |
| 41 | + contains a single variant). This is done because it guarantees a later extraction, |
| 42 | + which hopefully gets us closer to a solution. Now, two phases are created (let us |
| 43 | + say PhaseA and PhaseB) - identical to the current phase, except that PhaseA has |
| 44 | + ScopeA instead of the original, and PhaseB has ScopeB instead of the original. |
| 45 | + Now, we attempt to solve PhaseA, and if that fails, we attempt to solve PhaseB. |
| 46 | + |
| 47 | +Following the process above, we maintain a 'phase stack'. We run a loop, and in |
| 48 | +each loop, we attempt to solve the phase at the top of the stack. If the phase |
| 49 | +becomes exhaused, then it is split, and replaced with 2 phases (so the stack |
| 50 | +grows by 1). If the phase is solved, then we have the solution, and the other |
| 51 | +phases are discarded. If the phase fails to solve, then it is removed from the |
| 52 | +stack - if the stack is then empty, then there is no solution. |
| 53 | + |
| 54 | +## Pseudocode |
| 55 | + |
| 56 | +The pseudocode for a solve looks like this (and yes, you will have to read the |
| 57 | +solver code for full appreciation of what's going on here): |
| 58 | + |
| 59 | + def solve(requests): |
| 60 | + phase = create_initial_phase(requests) |
| 61 | + phase_stack = stack() |
| 62 | + phase_stack.push(phase) |
| 63 | + |
| 64 | + while not solved(): |
| 65 | + phase = phase_stack.pop() |
| 66 | + |
| 67 | + if phase.failed: |
| 68 | + phase = phase_stack.pop() # discard previous failed phase |
| 69 | + |
| 70 | + if phase.exhausted: |
| 71 | + phase, next_phase = phase.split() |
| 72 | + phase_stack.push(next_phase) |
| 73 | + |
| 74 | + new_phase = solve_phase(phase) |
| 75 | + |
| 76 | + if new_phase.failed: |
| 77 | + phase_stack.push(new_phase) # we keep last fail on the stack |
| 78 | + elif new_phase.solved: |
| 79 | + # some housekeeping here, like checking for cycles |
| 80 | + final_phase = finalise_phase(new_phase) |
| 81 | + phase_stack.push(final_phase) |
| 82 | + else: |
| 83 | + phase_stack.push(new_phase) # phase is exhausted |
| 84 | + |
| 85 | + def solve_phase(phase): |
| 86 | + while True: |
| 87 | + changed_scopes = [] |
| 88 | + added_scopes = [] |
| 89 | + widened_scopes = [] |
| 90 | + |
| 91 | + while True: |
| 92 | + extractions = [] |
| 93 | + |
| 94 | + foreach phase.scope as scope: |
| 95 | + extractions |= collect_extractions(scope) |
| 96 | + |
| 97 | + if not extractions: |
| 98 | + break |
| 99 | + |
| 100 | + merge(extractions) |
| 101 | + if in_conflict(extractions): |
| 102 | + set_fail() |
| 103 | + return |
| 104 | + |
| 105 | + foreach phase.scope as scope: |
| 106 | + intersect(scope, extractions) |
| 107 | + |
| 108 | + if failed(scope): |
| 109 | + set_fail() |
| 110 | + return |
| 111 | + |
| 112 | + if was_intersected(scope): |
| 113 | + changed_scopes.add(scope) |
| 114 | + |
| 115 | + if was_widened(scope): |
| 116 | + widened_scopes.add(scope) |
| 117 | + |
| 118 | + # get those extractions involving new packages |
| 119 | + new_extractions = get_new_extractions(extractions) |
| 120 | + |
| 121 | + # add them as new scopes |
| 122 | + foreach request in new_extractions: |
| 123 | + scope = new_scope(request) |
| 124 | + added_scopes.add(scope) |
| 125 | + phase.add(scope) |
| 126 | + |
| 127 | + if no (changed_scopes or added_scopes or widened_scopes): |
| 128 | + break |
| 129 | + |
| 130 | + pending_reductions = convert_to_reduction_set( |
| 131 | + changed_scopes, added_scopes, widened_scopes) |
| 132 | + |
| 133 | + while pending_reductions: |
| 134 | + scope_a, scope_b = pending_reductions.pop() |
| 135 | + scope_a.reduce_by(scope_b) |
| 136 | + |
| 137 | + if totally_reduced(scope_a): |
| 138 | + set_fail() |
| 139 | + return |
| 140 | + |
| 141 | + # scope_a changed so other scopes need to reduce against it again |
| 142 | + if was_reduced(scope_a): |
| 143 | + foreach phase.scope as scope: |
| 144 | + if scope is not scope_a: |
| 145 | + pending_reductions.add(scope, scope_a) |
| 146 | + |
| 147 | +There are 2 notable points missing from the pseudocode, related to optimisations: |
| 148 | + |
| 149 | +* Scopes keep a set of package families so that they can quickly skip unnecessary |
| 150 | + reductions. For example, all 'foo' pkgs may depend only on the set (python, bah), |
| 151 | + so when reduced against 'maya', this becomes basically a no-op. |
| 152 | + |
| 153 | +* Objects in the solver (phases, scopes etc) are immutable. Whenever a change |
| 154 | + occurs - such as a scope being narrowed as a result of an intersect - what |
| 155 | + actually happens is that a new object is created, often based on a shallow copy |
| 156 | + of the previous object. This is basically implementing copy-on-demand - lots of |
| 157 | + scopes are shared between phases in the stack, if objects were not immutable |
| 158 | + then creating a new phase would involve a deep copy of the entire state of the |
| 159 | + solver. |
| 160 | + |
| 161 | +## Interpreting Debugging Output |
| 162 | + |
| 163 | +Solver debugging is enabled using the *rez-env* *-v* flag. Repeat for more |
| 164 | +vebosity, to a max of *-vvv*. |
| 165 | + |
| 166 | +### Scope Syntax |
| 167 | + |
| 168 | +Before describing all the sections of output during a solve, we need to explain |
| 169 | +the scope syntax. This describes the state of a scope, and you'll see it a lot |
| 170 | +in solver output. |
| 171 | + |
| 172 | +* `[foo==1.2.0]` This is a scope containing exactly one variant. In this case it |
| 173 | + is a *null* variant (a package that has no variants). |
| 174 | + |
| 175 | +* `[foo-1.2.0[1]]` This is a scope containing exactly one variant. This example |
| 176 | + shows the 1-index variant of the package foo-1.2.0 |
| 177 | + |
| 178 | +* `[foo-1.2.0[0,1]]` This is a scope containing two variants from one package version. |
| 179 | + |
| 180 | +* `foo[1.2.0..1.3.5(6)]` This is a scope containing 6 variants from 6 different |
| 181 | + package versions, where the packages are all >= 1.2.0 and <= 1.3.5. |
| 182 | + |
| 183 | +* `foo[1.2.0..1.3.5(6:8)]` This is a scope containing 8 variants from 6 different |
| 184 | + package versions. |
| 185 | + |
| 186 | +In all of the above cases, you may see a trailing `*`, eg `[foo-1.2.0[0,1]]*`. |
| 187 | +This indicates that there are still outstanding *extractions* for this scope. |
| 188 | + |
| 189 | +### Output Steps |
| 190 | + |
| 191 | + request: foo-1.2 bah-3 ~foo-1 |
| 192 | + |
| 193 | +You will see this once, at the start of the solve. It simply prints the initial |
| 194 | +request list. |
| 195 | + |
| 196 | + merged request: foo-1.2 bah-3 |
| 197 | + |
| 198 | +You will see this once and immediately after the `request:` output. It shows a |
| 199 | +simplified (merged) version of the initial request. Notice here how `~foo-1` is |
| 200 | +gone - this is because the intersection of `foo-1.2` and `~foo-1` is simply |
| 201 | +`foo-1.2`. |
| 202 | + |
| 203 | + pushed {0,0}: [foo==1.2.0[0,1]]* bah[3.0.5..3.4.0(6)]* |
| 204 | + |
| 205 | +This is pushing the initial *phase* onto the *phase stack*. The `{0,0}` means |
| 206 | +that: |
| 207 | + |
| 208 | +* There is 1 phase in the stack (this is the zeroeth phase - phases are pushed |
| 209 | + and popped from the bottom of the stack); |
| 210 | +* Zero other phases have already been solved (or failed) at this depth so far. |
| 211 | + |
| 212 | + -------------------------------------------------------------------------------- |
| 213 | + SOLVE #1... |
| 214 | + -------------------------------------------------------------------------------- |
| 215 | + |
| 216 | +This output indicates that a phase is starting. The number indicates the number |
| 217 | +of phases that have been solved so far (1-indexed), regardless of how many have |
| 218 | +failed or succeeded. |
| 219 | + |
| 220 | + popped {0,0}: [foo==1.2.0[0,1]]* bah[3.0.5..3.4.0(6)]* |
| 221 | + |
| 222 | +This is always the first thing you see after the `SOLVE #1...` output. The |
| 223 | +topmost phase is being retrieved from the phase stack. |
| 224 | + |
| 225 | + EXTRACTING: |
| 226 | + extracted python-2 from [foo==1.2.0[0,1]]* |
| 227 | + extracted utils-1.2+ from bah[3.0.5..3.4.0(6)]* |
| 228 | + |
| 229 | +This lists extractions that have occurred from current scopes. |
| 230 | + |
| 231 | + MERGE-EXTRACTIONS: |
| 232 | + merged extractions are: python-2 utils-1.2+ |
| 233 | + |
| 234 | +This shows the result of merging a set of extracted package requests into a |
| 235 | +potentially simpler (or conflicting) set of requests. |
| 236 | + |
| 237 | + INTERSECTING: |
| 238 | + python[2.7.3..3.3.0(3)] was intersected to [python==2.7.3] by range '2' |
| 239 | + |
| 240 | +This shows scopes that were intersected by previous extractions. |
| 241 | + |
| 242 | + ADDING: |
| 243 | + added utils[1.2.0..5.2.0(12:14)]* |
| 244 | + |
| 245 | +This shows scopes that were added for new extractions (ie, extractions that |
| 246 | +introduce a new package into the solve). |
| 247 | + |
| 248 | + REDUCING: |
| 249 | + removed blah-35.0.2[1] (dep(python-3.6) <--!--> python==2.7.3) |
| 250 | + [blah==35.0.2[0,1]] was reduced to [blah==35.0.2[0]]* by python==2.7.3 |
| 251 | + |
| 252 | +This shows any reductions and the scopes that have changed as a result. |
0 commit comments