From 78612606c8e7fe56e8abd6fdb44e0520564a28bf Mon Sep 17 00:00:00 2001 From: Thomas Honeyman Date: Sat, 10 Oct 2026 17:06:32 -0400 Subject: [PATCH 1/6] Add dependency solver correctness and benchmark coverage --- lib/test/Registry/Solver.purs | 93 ++++++++++++++++- scripts/bench-solver.md | 90 ++++++++++++++++ scripts/bench-solver.mjs | 189 ++++++++++++++++++++++++++++++++++ 3 files changed, 370 insertions(+), 2 deletions(-) create mode 100644 scripts/bench-solver.md create mode 100644 scripts/bench-solver.mjs diff --git a/lib/test/Registry/Solver.purs b/lib/test/Registry/Solver.purs index ac593264..45270606 100644 --- a/lib/test/Registry/Solver.purs +++ b/lib/test/Registry/Solver.purs @@ -4,8 +4,9 @@ import Prelude import Data.Array as Array import Data.Either (Either(..)) -import Data.Foldable (for_) +import Data.Foldable (all, for_) import Data.FoldableWithIndex (foldMapWithIndex) +import Data.Int as Int import Data.List.NonEmpty as NonEmptyList import Data.Map (Map, SemigroupMap(..)) import Data.Map as Map @@ -138,7 +139,7 @@ spec = do Spec.it "Backtracks from broken ranges to find the highest valid range." do -- 'fixed-broken' works at version 0 and 1, but is broken at version 2. shouldSucceed - [ fixedBroken.package /\ range 0 2 ] + [ fixedBroken.package /\ range 0 3 ] [ fixedBroken.package /\ version 1, prelude.package /\ version 1 ] Spec.describe "Does not solve when no versions exist for the specified range" do @@ -287,6 +288,73 @@ spec = do } ] + Spec.describe "Resolution contracts" do + Spec.it "Prefers the alphabetically first package, not the largest total versions" do + let + index = Map.fromFoldable + [ package "a" /\ Map.fromFoldable + [ version 1 /\ Map.singleton (package "z") (range 1 2) + , version 2 /\ Map.singleton (package "z") (range 2 3) + ] + , package "b" /\ Map.fromFoldable + [ version 1 /\ Map.singleton (package "z") (range 2 3) + , version 9 /\ Map.singleton (package "z") (range 1 2) + ] + , package "z" /\ Map.fromFoldable [ version 1 /\ Map.empty, version 2 /\ Map.empty ] + ] + solve index (Map.fromFoldable [ package "a" /\ range 1 3, package "b" /\ range 1 10 ]) + `Assert.shouldContain` Map.fromFoldable [ package "a" /\ version 2, package "b" /\ version 1, package "z" /\ version 2 ] + + Spec.it "Matches exhaustive assignments on 4096 small cyclic version graphs" do + -- Each of six package versions independently has no dependency, an exact + -- low/high dependency, or a broad dependency on the next package in a + -- three-package ring. Enumerating assignments (including absent packages) + -- is independent of the solver's propagation and backtracking algorithm. + let + names = map package [ "a", "b", "c" ] + assignments = do + a <- [ Nothing, Just (version 1), Just (version 2) ] + b <- [ Nothing, Just (version 1), Just (version 2) ] + c <- [ Nothing, Just (version 1), Just (version 2) ] + pure $ Map.fromFoldable $ Array.catMaybes $ Array.zipWith (\p -> map (Tuple p)) names [ a, b, c ] + goals = Map.singleton (package "a") (range 1 3) + satisfies plan deps = all + ( \(Tuple p bounds) -> case Map.lookup p plan of + Nothing -> false + Just v -> Range.includes bounds v + ) + (Map.toUnfoldable deps :: Array (Tuple PackageName Range)) + valid index plan = satisfies plan goals && all + ( \(Tuple p v) -> case Map.lookup p index >>= Map.lookup v of + Nothing -> false + Just deps -> satisfies plan deps + ) + (Map.toUnfoldable plan :: Array (Tuple PackageName Version)) + for_ (Array.range 0 4095) \seed -> do + let + index = Map.fromFoldable $ Array.mapWithIndex + ( \i p -> p /\ Map.fromFoldable + ( map + ( \j -> do + let + digit = (seed / Int.pow 4 (i * 2 + j - 1)) `mod` 4 + next = package (if i == 0 then "b" else if i == 1 then "c" else "a") + deps = case digit of + 0 -> Map.empty + 1 -> Map.singleton next (range 1 2) + 2 -> Map.singleton next (range 2 3) + _ -> Map.singleton next (range 1 3) + version j /\ deps + ) + [ 1, 2 ] + ) + ) + names + solutions = Array.filter (valid index) assignments + case solve index goals of + Left _ -> unless (Array.null solutions) $ Assert.fail $ "False unsatisfiability for graph " <> show seed + Right plan -> unless (valid index plan) $ Assert.fail $ "Invalid resolution for graph " <> show seed + Spec.describe "CompilerIndex" do let -- Package graph: @@ -347,6 +415,27 @@ spec = do Map.lookup (package "prelude") resolutions `Assert.shouldEqual` Just (unsafeVersion "6.0.1") Map.lookup (package "effect") resolutions `Assert.shouldEqual` Just (unsafeVersion "4.0.0") + Spec.it "Backtracks package versions for an exact compiler without changing flexible selection" do + let + metadata = Map.fromFoldable + [ unsafeMetadata "prelude" [ Tuple "6.0.1" [ "0.15.15", "0.15.16" ] ] + , unsafeMetadata "effect" + [ Tuple "4.0.0" [ "0.15.16" ] + , Tuple "5.0.0" [ "0.15.15" ] + ] + , unsafeMetadata "my-pkg" [ Tuple "1.0.0" [ "0.15.15" ] ] + ] + index = buildCompilerIndex compilers manifestIndex metadata + goals = Map.singleton (package "effect") (fromRight "bad range" (Range.parse ">=4.0.0 <6.0.0")) + expected compiler effect = Tuple (unsafeVersion compiler) $ Map.fromFoldable + [ package "effect" /\ unsafeVersion effect + , package "prelude" /\ unsafeVersion "6.0.1" + ] + solveWithCompiler (Range.exact (unsafeVersion "0.15.16")) index goals + `Assert.shouldContain` expected "0.15.16" "4.0.0" + solveWithCompiler (fromRight "bad compiler range" (Range.parse ">=0.15.15 <0.15.17")) index goals + `Assert.shouldContain` expected "0.15.15" "5.0.0" + Spec.it "updateCompilerIndex produces same result as full rebuild" do -- Setup: effect has two versions (4.0.0 and 5.0.0) both supporting -- only 0.15.15. The incremental update adds 0.15.16 to effect@4.0.0; diff --git a/scripts/bench-solver.md b/scripts/bench-solver.md new file mode 100644 index 00000000..15a4225d --- /dev/null +++ b/scripts/bench-solver.md @@ -0,0 +1,90 @@ +# Dependency solver benchmarks + +Run from the repository root, using the pinned development environment: + +```sh +nix develop --command npm ci +nix develop --command spago build +nix develop --command spago test -p registry-lib +nix develop --command node scripts/bench-solver.mjs > before.json +``` + +The benchmark calls the real `Registry.Solver.solve` and `solveWithCompiler` +exports from compiled PureScript. It uses Node's built-in timing, assertion, +and profiling APIs; no benchmark dependency or public library API is added. +The generated-module interop is confined to this script. + +Each case has three warmups, then seven samples of three solves. JSON on stdout +contains every sample, the median milliseconds per solve, complete resolutions +or formatted errors, runtime/CPU information, and the compiled solver SHA-256. +Progress goes to stderr. `SAMPLES`, `ITERATIONS`, and `CASE` (a label substring) +override the defaults. Use more iterations for very short cases. Timings are +not CI assertions: JIT, GC, CPU frequency, and other local processes affect them. + +Index construction, input decoding, compiler-index construction, IO, and result +validation are outside the timed region. Solver-internal preparation, including +reachability and diagnostic initialization, is inside it. Each sample retains +its results until validation; keep the same iteration count in both runs. + +## Workloads and correctness + +Six deterministic synthetic cases cover a long dependency chain, a wide shared +dependency diamond, irrelevant index entries, a narrow interval in a package +with many versions, conflicting newest choices requiring backtracking, and an +unsatisfiable pair of roots. Successes must equal independently specified plans; +failure must be unsatisfiable. Every success is also checked for dependency +closure, range satisfaction, existing versions, and absence of extra packages. + +An optional snapshot adds the latest manifests' dependency sets for `prelude`, +`effect`, `aff`, `web-storage`, `halogen`, `spec`, `tidy`, and +`language-cst-parser`, each with and without compiler 0.15.16: + +```sh +nix develop --command node scripts/bench-solver.mjs snapshot.json > before.json +``` + +The input is a local JSON object with `manifests` (an array of registry manifest +objects), `metadata` (package name to registry metadata object), and `compilers` +(a nonempty array of version strings). Optional `commits` records snapshot +provenance. The script never fetches a registry or contacts production. Input +bytes are hashed so comparisons cannot silently switch snapshots. Use the same +saved file for both runs. These are dependency solves, not builds of the named +packages, and `prelude` deliberately provides an empty-dependency control. +Compiler results are checked against the metadata's min/max support bounds, +matching the existing compiler-index contract, not a discrete support set. + +The library tests separately enumerate all assignments for 4,096 small cyclic +version graphs to detect invalid plans and false unsatisfiability. Targeted tests +check alphabetical/latest selection, backtracking, exact/flexible compilers, +and structured error provenance. Benchmarks complement these tests, not replace +them; real-snapshot satisfiability alone is not an independent correctness oracle. + +## Before/after and CPU profiles + +Preserve a baseline build before changing the solver, then rebuild normally: + +```sh +mkdir -p scratch +cp -a output scratch/solver-before +# After editing and rebuilding: +SOLVER_OUTPUT=scratch/solver-before nix develop --command \ + node scripts/bench-solver.mjs snapshot.json > before.json +BASELINE=before.json nix develop --command \ + node scripts/bench-solver.mjs snapshot.json > after.json +``` + +Keep the preserved output inside this checkout (for example in ignored `scratch/`) +so its FFI can resolve this checkout's `node_modules`. `BASELINE` requires identical +workload labels, snapshot provenance, complete plans, and formatted diagnostics. +Repeat in before/after/after/before order without concurrent builds or test runs. +Report distributions as well as medians; don't compare one cold run to warm runs. + +```sh +CASE=halogen SAMPLES=1 ITERATIONS=100 PROFILE=halogen.cpuprofile \ + nix develop --command node scripts/bench-solver.mjs snapshot.json > profile.json +``` + +Open the profile in a V8 CPU-profile viewer. Profiling starts after fixture setup +and includes warmups, solving, and validation. Do not use profiled timings as the +headline measurements. A snapshot result is not a production latency, package +compilation, server request, or matrix-scheduling benchmark. diff --git a/scripts/bench-solver.mjs b/scripts/bench-solver.mjs new file mode 100644 index 00000000..7c0953cd --- /dev/null +++ b/scripts/bench-solver.mjs @@ -0,0 +1,189 @@ +// Run after `spago build`: node scripts/bench-solver.mjs [snapshot.json] +// PureScript's generated modules are intentionally used only at this benchmark +// boundary. No network, database, scheduling, or registry IO occurs in a sample. +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { readFileSync, writeFileSync } from "node:fs"; +import { Session } from "node:inspector/promises"; +import { cpus } from "node:os"; +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +const output = resolve(process.env.SOLVER_OUTPUT || "output"); +const load = (name) => import(pathToFileURL(`${output}/${name}/index.js`)); +const [Solver, Map, Foldable, Unfoldable, Tuple, Either, Name, Version, Range, CJ, Manifest, Metadata, NEL] = + await Promise.all([ + "Registry.Solver", "Data.Map", "Data.Foldable", "Data.Unfoldable", "Data.Tuple", + "Data.Either", "Registry.PackageName", "Registry.Version", "Registry.Range", + "Data.Codec.JSON", "Registry.Manifest", "Registry.Metadata", "Data.List.NonEmpty", + ].map(load)); +const right = (value) => { + assert(value instanceof Either.Right, "Invalid benchmark input"); + return value.value0; +}; +const version = (s) => right(Version.parse(s)); +const name = (s) => right(Name.parse(s)); +const range = (s) => right(Range.parse(s)); +const pairs = Map.toUnfoldable(Unfoldable.unfoldableArray); +const from = (ord, entries) => Map.fromFoldable(ord)(Foldable.foldableArray)( + entries.map(([k, v]) => new Tuple.Tuple(k, v)), +); +const packages = (entries) => from(Name.ordPackageName, entries); +const versions = (entries) => from(Version.ordVersion, entries); +const requirements = (deps) => packages(Object.entries(deps).map(([p, r]) => [name(p), range(r)])); +const indexFrom = (index) => packages(Object.entries(index).map(([p, vs]) => [ + name(p), versions(Object.entries(vs).map(([v, deps]) => [version(v), requirements(deps)])), +])); +const printPlan = (plan) => Object.fromEntries(pairs(plan).map((t) => [Name.print(t.value0), Version.print(t.value1)])); +const cmp = (a, b) => { + const av = a.split(".").map(Number), bv = b.split(".").map(Number); + return av[0] - bv[0] || av[1] - bv[1] || av[2] - bv[2]; +}; +// Independent of the solver's range propagation and sourced intersections. +function includes(r, v) { + const [, lo, hi] = /^>=(\d+\.\d+\.\d+) <(\d+\.\d+\.\d+)$/.exec(r); + return cmp(lo, v) <= 0 && cmp(v, hi) < 0; +} +function validate(index, goals, plan) { + const seen = new Set(); + function visit(deps) { + for (const [p, r] of Object.entries(deps)) { + assert(plan[p] && includes(r, plan[p]), `${p}@${plan[p]} violates ${r}`); + assert(index[p]?.[plan[p]], `Unknown ${p}@${plan[p]}`); + if (!seen.has(p)) { + seen.add(p); + visit(index[p][plan[p]]); + } + } + } + visit(goals); + assert.deepEqual(Object.keys(plan).sort(), [...seen].sort(), "Extraneous packages"); +} +const cases = []; +const indexes = new WeakMap(); +function add(label, raw, goals, expected, compiler = null) { + if (!indexes.has(raw)) indexes.set(raw, indexFrom(raw)); + const index = indexes.get(raw), required = requirements(goals); + const compilerRange = Range.exact(version("0.15.16")); + const run = compiler + ? () => Solver.solveWithCompiler(compilerRange)(compiler.index)(required) + : () => Solver.solve(index)(required); + const check = (result) => { + if (expected === null) { + assert(result instanceof Either.Left, `${label}: expected unsatisfiable`); + return { errors: NEL.toUnfoldable(Unfoldable.unfoldableArray)(result.value0).map(Solver.printSolverError) }; + } + assert(result instanceof Either.Right, `${label}: unexpectedly unsatisfiable`); + let resolved = result.value0; + if (compiler) { + assert.equal(Version.print(resolved.value0), "0.15.16"); + resolved = resolved.value1; + } + const plan = printPlan(resolved); + validate(raw, goals, plan); + if (compiler) { + for (const [p, v] of Object.entries(plan)) { + const supported = compiler.metadata[p].published[v]?.compilers; + if (supported?.length) { + const sorted = [...supported].sort(cmp); + assert(cmp(sorted[0], "0.15.16") <= 0 && cmp("0.15.16", sorted.at(-1)) <= 0, + `${p}@${v}: compiler outside metadata bounds`); + } + } + } + if (expected) assert.deepEqual(plan, expected); + return plan; + }; + cases.push({ label, run, check, packages: Object.keys(raw).length, + versions: Object.values(raw).reduce((n, vs) => n + Object.keys(vs).length, 0) }); +} +const v = (i) => `${i}.0.0`; +const r = (lo, hi) => `>=${v(lo)} <${v(hi)}`; +const chain = {}; +for (let i = 0; i < 60; i++) { + chain[`chain-${i}`] = Object.fromEntries([1, 2, 3].map((j) => [v(j), i === 59 ? {} : { [`chain-${i + 1}`]: r(1, 4) }])); +} +add("chain-60", chain, { "chain-0": r(1, 4) }, Object.fromEntries(Object.keys(chain).map((p) => [p, v(3)]))); +const diamond = { shared: { [v(1)]: {}, [v(2)]: {} }, root: { [v(1)]: {} } }; +for (let i = 0; i < 40; i++) { + const p = `branch-${i}`; + diamond.root[v(1)][p] = r(1, 11); + diamond[p] = Object.fromEntries(Array.from({ length: 10 }, (_, j) => [v(j + 1), { shared: r(1, 3) }])); +} +add("diamond-40x10", diamond, { root: r(1, 2) }, { root: v(1), shared: v(2), ...Object.fromEntries(Array.from({ length: 40 }, (_, i) => [`branch-${i}`, v(10)])) }); +const unrelated = { target: { [v(1)]: {} } }; +for (let i = 0; i < 2000; i++) unrelated[`unused-${i}`] = { [v(1)]: { target: r(1, 2) } }; +add("unreachable-2000", unrelated, { target: r(1, 2) }, { target: v(1) }); +const many = { releases: Object.fromEntries(Array.from({ length: 1000 }, (_, i) => [v(i), {}])) }; +add("narrow-1000", many, { releases: r(498, 500) }, { releases: v(499) }); +// Each branch alone is viable; their shared dependency forces backtracking. +const conflict = { + a: { [v(1)]: { z: r(1, 2) }, [v(2)]: { z: r(2, 3) } }, + b: { [v(1)]: { z: r(2, 3) }, [v(2)]: { z: r(1, 2) } }, + z: { [v(1)]: {}, [v(2)]: {} }, +}; +add("selection-backtrack", conflict, { a: r(1, 3), b: r(1, 3) }, { a: v(2), b: v(1), z: v(2) }); +add("incompatible-roots", conflict, { a: r(2, 3), b: r(2, 3) }, null); + +let snapshotInfo = null; +if (process.argv[2]) { + const bytes = readFileSync(process.argv[2]); + const snapshot = JSON.parse(bytes); + const raw = {}, decoded = {}; + for (const m of snapshot.manifests) { + (raw[m.name] ??= {})[m.version] = m.dependencies; + (decoded[m.name] ??= {})[m.version] = right(CJ.decode(Manifest.codec)(m)); + } + const manifests = packages(Object.entries(decoded).map(([p, vs]) => [name(p), versions(Object.entries(vs).map(([s, m]) => [version(s), m]))])); + const metadata = packages(Object.entries(snapshot.metadata).map(([p, m]) => [name(p), right(CJ.decode(Metadata.codec)(m))])); + const ci = Solver.buildCompilerIndex(snapshot.compilers.map(version))(manifests)(metadata); + snapshotInfo = { sha256: createHash("sha256").update(bytes).digest("hex"), commits: snapshot.commits, manifests: snapshot.manifests.length }; + for (const p of ["prelude", "effect", "aff", "web-storage", "halogen", "spec", "tidy", "language-cst-parser"]) { + assert(raw[p], `Snapshot missing ${p}`); + const latest = Object.keys(raw[p]).sort(cmp).at(-1); + const deps = raw[p][latest]; + add(`${p}@${latest}/solve`, raw, deps); + add(`${p}@${latest}/compiler`, raw, deps, undefined, { index: ci, metadata: snapshot.metadata }); + } +} +const samples = Number(process.env.SAMPLES || 7); +const iterations = Number(process.env.ITERATIONS || 3); +assert(Number.isInteger(samples) && samples > 0 && Number.isInteger(iterations) && iterations > 0); +const profiler = process.env.PROFILE ? new Session() : null; +if (profiler) { + profiler.connect(); + await profiler.post("Profiler.enable"); + await profiler.post("Profiler.start"); +} +const results = []; +for (const c of cases) { + if (process.env.CASE && !c.label.includes(process.env.CASE)) continue; + let canonical; + for (let i = 0; i < 3; i++) canonical = c.check(c.run()); + const times = []; + for (let s = 0; s < samples; s++) { + const start = performance.now(); + const answers = Array.from({ length: iterations }, c.run); + times.push((performance.now() - start) / iterations); + for (const answer of answers) assert.deepEqual(c.check(answer), canonical); + } + const sorted = [...times].sort((a, b) => a - b); + results.push({ label: c.label, packages: c.packages, versions: c.versions, medianMs: sorted[Math.floor(samples / 2)], samplesMs: times, result: canonical }); + console.error(`${c.label}: ${results.at(-1).medianMs.toFixed(3)} ms`); +} +assert(results.length, "CASE matched no workloads"); +if (profiler) { + const { profile } = await profiler.post("Profiler.stop"); + writeFileSync(process.env.PROFILE, JSON.stringify(profile)); + profiler.disconnect(); +} +if (process.env.BASELINE) { + const before = JSON.parse(readFileSync(process.env.BASELINE)); + assert.deepEqual(snapshotInfo, before.snapshot, "Different snapshots"); + assert.deepEqual(results.map((r) => r.label), before.results.map((r) => r.label), "Different workloads"); + for (const [i, r] of results.entries()) { + assert.deepEqual(r.result, before.results[i].result, `${r.label}: changed resolution or diagnostics`); + } +} +const solverSha256 = createHash("sha256").update(readFileSync(`${output}/Registry.Solver/index.js`)).digest("hex"); +console.log(JSON.stringify({ node: process.version, cpu: cpus()[0].model, output, solverSha256, samples, iterations, snapshot: snapshotInfo, results }, null, 2)); From f4ae5af69dd0c140231c6e2642f67c828def751c Mon Sep 17 00:00:00 2001 From: Thomas Honeyman Date: Sat, 10 Oct 2026 17:06:32 -0400 Subject: [PATCH 2/6] Avoid redundant dependency solver reachability work --- lib/src/Solver.purs | 93 ++++++++++++++++++++------------------------- 1 file changed, 42 insertions(+), 51 deletions(-) diff --git a/lib/src/Solver.purs b/lib/src/Solver.purs index 89703b1a..8a4a5c17 100644 --- a/lib/src/Solver.purs +++ b/lib/src/Solver.purs @@ -37,6 +37,7 @@ import Data.Foldable (fold, foldMap, intercalate) import Data.FoldableWithIndex (anyWithIndex, foldMapWithIndex, foldlWithIndex, forWithIndex_) import Data.Functor.App (App(..)) import Data.FunctorWithIndex (mapWithIndex) +import Data.List (List(..)) import Data.List.NonEmpty as NEL import Data.Map (Map, SemigroupMap(..)) import Data.Map as Map @@ -134,7 +135,7 @@ updateCompilerIndex (CompilerIndex index) (Manifest m) (Metadata { published }) -- | a compiler range accepted by all dependencies. solveWithCompiler :: Range -> CompilerIndex -> Map PackageName Range -> Either SolverErrors (Tuple Version (Map PackageName Version)) solveWithCompiler pursRange (CompilerIndex index) required = do - results <- solveFull { registry: initializeRegistry index, required: initializeRequired (Map.insert purs pursRange required) } + results <- solve index (Map.insert purs pursRange required) let pursVersion = Maybe.fromMaybe' (\_ -> Partial.unsafeCrashWith "Produced a compiler-derived build plan with no compiler!") $ Map.lookup purs results pure $ Tuple pursVersion $ Map.delete purs results @@ -149,10 +150,15 @@ type DependencyIndex = Map PackageName (Map Version (Map PackageName Range)) -- | Solve a map of requirements given a registry index. solve :: DependencyIndex -> Map PackageName Range -> Either SolverErrors (Map PackageName Version) -solve index required = +solve index required = do + let + initializedRequired = initializeRequired required + -- Attach diagnostic provenance only to reachable versions, not the entire + -- registry. Reachability depends on raw ranges, not on that provenance. + registry = initializeRegistry (gatherReachable index required) solveFull - { registry: initializeRegistry index - , required: initializeRequired required + { registry: map (addFrom initializedRequired) <$> registry + , required: initializedRequired } -------------------------------------------------------------------------------- @@ -300,7 +306,7 @@ type RRI = RR (inRange :: TransitivizedRegistry) -- | latest version of the alphabetically-smallest package that isn't pinned to -- | a single version yet. solveFull :: RR () -> Either SolverErrors (Map PackageName Version) -solveFull = solveAux <<< solveSeed <<< withReachable +solveFull = solveAux <<< solveSeed where solveAux :: RRU -> Either SolverErrors (Map PackageName Version) @@ -575,17 +581,24 @@ requirementUpdates { registry: SemigroupMap registry, required: SemigroupMap req Just versions -> SemigroupMap $ Map.singleton package versions Nothing -> mempty --- | Trim the registry down to only package versions that are reachable from --- | the initial set of requirements. Only done once. -gatherReachable :: forall r. RR r -> TransitivizedRegistry -gatherReachable { registry, required } = - let - reachable0 :: SemigroupMap PackageName (SemigroupMap Version (SemigroupMap PackageName Intersection)) - reachable0 = mapWithIndex (getPackageRange registry) required - moreReachable = (foldMap <<< foldMap) (mapWithIndex (getPackageRange registry)) - reachable = fixEqM moreReachable reachable0 - in - reachable +-- | Visit each reachable package version once. Dependencies are immutable at +-- | this point, so revisiting a version cannot discover any new edges. Track +-- | versions, not just package names: a later edge may reach a disjoint range. +gatherReachable :: DependencyIndex -> Map PackageName Range -> DependencyIndex +gatherReachable registry required = go Map.empty (Map.toUnfoldable required) + where + go seen Nil = seen + go seen (Cons (Tuple package range) pending) = do + let + visited = fromMaybe Map.empty (Map.lookup package seen) + versions = fromMaybe Map.empty (Map.lookup package registry) + fresh = Map.filterKeys (\version -> Range.includes range version && not (Map.member version visited)) versions + if Map.isEmpty fresh then go seen pending + else do + let + next = foldMap Map.toUnfoldable fresh <> pending + seen' = Map.insert package (Map.union fresh visited) seen + go seen' next -- | Also helps with efficiency: remove package versions from the registry -- | that are outside of the global requirements. Done regularly. @@ -632,22 +645,22 @@ instance Semigroup SolverPosition where append (Pos l1 g1) (Pos l2 g2) = Pos (l1 <> l2) (g1 <> g2) -dependency :: SolverPosition -> SolverPosition -> SolverPosition -dependency (Pos _ g1) (Pos l2 g2) = Pos l2 (g1 <> g2) - -dependencyOf :: forall z. Newtype z Sourced => SolverPosition -> z -> z -dependencyOf p1 = coerce \(Sourced v p2) -> - Sourced v (dependency p1 p2) +dependencyOf :: forall z. Newtype z Sourced => Set PackageName -> z -> z +dependencyOf roots = coerce \(Sourced v (Pos local global)) -> + Sourced v (Pos local (roots <> global)) asDependencyOf :: Intersection -> Intersection -> Intersection -asDependencyOf (Intersection i1) (Intersection i2) = +asDependencyOf (Intersection i1) (Intersection i2) = do let - pos = getPos i1.lower <> getPos i1.upper - in - Intersection - { lower: dependencyOf pos i2.lower - , upper: dependencyOf pos i2.upper - } + -- Only roots propagate from the parent. Combining its local provenance + -- would merge potentially large sets just to discard them immediately. + Pos _ lowerRoots = getPos i1.lower + Pos _ upperRoots = getPos i1.upper + roots = lowerRoots <> upperRoots + Intersection + { lower: dependencyOf roots i2.lower + , upper: dependencyOf roots i2.upper + } data Sourced = Sourced Version SolverPosition @@ -742,9 +755,6 @@ fromLoose = coerce -- Data management -------------------------------------------------------------------------------- -withReachable :: forall r. RR r -> RR r -withReachable r = r { registry = map (addFrom r.required) <$> gatherReachable r } - withInRange :: RR () -> RRI withInRange r = { registry: r.registry @@ -790,24 +800,5 @@ doubleton package version dat = coerce $ Map.alter (Just <<< helper) package accumulated :: forall a. Monoid a => Endo (->) a -> a accumulated (Endo f) = f mempty -fixEq :: forall a. Eq a => (a -> a) -> (a -> a) -fixEq f a = let b = f a in if b == a then a else fixEq f b - --- | An optimized fixpoint for semilattice closure operations, accumulating a --- | full result while only running the function on the newly added bit. --- | --- | Invariant: f (acc <> lastAdded) = acc <> f lastAdded -fixEqM :: forall a. Semigroup a => Eq a => (a -> a) -> (a -> a) -fixEqM f = join go - where - go acc lastAdded = - let - moreAdded = f lastAdded - moreAcc = acc <> moreAdded - in - if moreAcc == acc then acc - else - go moreAcc moreAdded - noUpdates :: forall r k v. { updated :: SemigroupMap k v | r } -> Boolean noUpdates { updated: SemigroupMap updated } = Map.isEmpty updated From 3e92dcf31c1177e145fcc778350a25627b011daa Mon Sep 17 00:00:00 2001 From: Thomas Honeyman Date: Sat, 10 Oct 2026 17:53:30 -0400 Subject: [PATCH 3/6] Tighten solver regression tests and benchmark comparisons --- lib/test/Registry/Solver.purs | 27 +++++++++++++++++++++++++-- scripts/bench-solver.mjs | 2 +- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/lib/test/Registry/Solver.purs b/lib/test/Registry/Solver.purs index 45270606..8253a8ed 100644 --- a/lib/test/Registry/Solver.purs +++ b/lib/test/Registry/Solver.purs @@ -289,6 +289,30 @@ spec = do ] Spec.describe "Resolution contracts" do + Spec.it "Excludes broken dependencies belonging only to rejected versions" do + let + index = Map.fromFoldable + [ package "a" /\ Map.fromFoldable + [ version 1 /\ Map.singleton (package "orphan") (range 1 2) + , version 2 /\ Map.empty + ] + , package "orphan" /\ Map.singleton (version 1) (Map.singleton (package "missing") (range 1 2)) + ] + solve index (Map.singleton (package "a") (range 1 3)) + `Assert.shouldContain` Map.singleton (package "a") (version 2) + + Spec.it "Preserves both bound roots and the child's local source in downstream errors" do + let + index = Map.fromFoldable + [ package "a" /\ Map.singleton (version 1) (Map.singleton (package "middle") (range 2 5)) + , package "b" /\ Map.singleton (version 1) (Map.singleton (package "middle") (range 1 4)) + , package "middle" /\ Map.singleton (version 2) (Map.singleton (package "missing") (range 1 2)) + ] + goals = Map.fromFoldable [ package "a" /\ range 1 2, package "b" /\ range 1 2 ] + source = via "middle" 2 [ "a", "b" ] + solve index goals `Assert.shouldEqual` + Left (pure (Conflicts (Map.singleton (package "missing") (intersection 1 source 2 source)))) + Spec.it "Prefers the alphabetically first package, not the largest total versions" do let index = Map.fromFoldable @@ -350,9 +374,8 @@ spec = do ) ) names - solutions = Array.filter (valid index) assignments case solve index goals of - Left _ -> unless (Array.null solutions) $ Assert.fail $ "False unsatisfiability for graph " <> show seed + Left _ -> when (Array.any (valid index) assignments) $ Assert.fail $ "False unsatisfiability for graph " <> show seed Right plan -> unless (valid index plan) $ Assert.fail $ "Invalid resolution for graph " <> show seed Spec.describe "CompilerIndex" do diff --git a/scripts/bench-solver.mjs b/scripts/bench-solver.mjs index 7c0953cd..c9a0b035 100644 --- a/scripts/bench-solver.mjs +++ b/scripts/bench-solver.mjs @@ -179,7 +179,7 @@ if (profiler) { } if (process.env.BASELINE) { const before = JSON.parse(readFileSync(process.env.BASELINE)); - assert.deepEqual(snapshotInfo, before.snapshot, "Different snapshots"); + assert.equal(snapshotInfo?.sha256, before.snapshot?.sha256, "Different snapshots"); assert.deepEqual(results.map((r) => r.label), before.results.map((r) => r.label), "Different workloads"); for (const [i, r] of results.entries()) { assert.deepEqual(r.result, before.results[i].result, `${r.label}: changed resolution or diagnostics`); From c9db273438030c4a4207a0183996efd8a01c290a Mon Sep 17 00:00:00 2001 From: Thomas Honeyman Date: Sat, 10 Oct 2026 18:30:54 -0400 Subject: [PATCH 4/6] Use Minibench for PureScript solver benchmarks --- CONTRIBUTING.md | 8 ++ lib/test/Registry/Solver.purs | 39 +++--- scripts/bench-solver.md | 90 ------------- scripts/bench-solver.mjs | 189 --------------------------- scripts/spago.yaml | 2 + scripts/src/BenchSolver.purs | 234 ++++++++++++++++++++++++++++++++++ spago.lock | 15 +++ 7 files changed, 280 insertions(+), 297 deletions(-) delete mode 100644 scripts/bench-solver.md delete mode 100644 scripts/bench-solver.mjs create mode 100644 scripts/src/BenchSolver.purs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1214a486..00235a4b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -181,6 +181,14 @@ There are also a number of checks run by the Nix flake for non-PureScript code, nix flake check -L ``` +### Solver Benchmarks + +Run the local Minibench workloads with `spago run -p registry-scripts -m Registry.Scripts.BenchSolver -- --samples 40 > before.json`. +Use `--baseline before.json` to check identical resolutions and diagnostics, and `--case NAME` to filter workloads. +Optional `--snapshot PATH` adds registry cases; its JSON contains `manifests` (manifest array), `metadata` (package-name map), and `compilers` (nonempty version array). +Reports contain mean, standard deviation, minimum, and maximum in nanoseconds; input preparation and validation are untimed. +Compare repeated before/after runs on the same machine and snapshot, without concurrent builds; these measure dependency solving, not compilation or scheduling. + ### Testing Guidelines The PureScript code in the registry is well-tested, ranging from tests for individual functions to full end-to-end tests for the registry server running in a NixOS machine configured the same way as the deployed machine. The smaller and more pure the test, the easier it is to write and maintain; most code is tested via unit tests written with `spec`, and only the core pipelines are run in the integration test. diff --git a/lib/test/Registry/Solver.purs b/lib/test/Registry/Solver.purs index 8253a8ed..0617ab43 100644 --- a/lib/test/Registry/Solver.purs +++ b/lib/test/Registry/Solver.purs @@ -336,44 +336,47 @@ spec = do -- is independent of the solver's propagation and backtracking algorithm. let names = map package [ "a", "b", "c" ] + assignments = do a <- [ Nothing, Just (version 1), Just (version 2) ] b <- [ Nothing, Just (version 1), Just (version 2) ] c <- [ Nothing, Just (version 1), Just (version 2) ] pure $ Map.fromFoldable $ Array.catMaybes $ Array.zipWith (\p -> map (Tuple p)) names [ a, b, c ] + goals = Map.singleton (package "a") (range 1 3) + satisfies plan deps = all ( \(Tuple p bounds) -> case Map.lookup p plan of Nothing -> false Just v -> Range.includes bounds v ) (Map.toUnfoldable deps :: Array (Tuple PackageName Range)) + valid index plan = satisfies plan goals && all ( \(Tuple p v) -> case Map.lookup p index >>= Map.lookup v of Nothing -> false Just deps -> satisfies plan deps ) (Map.toUnfoldable plan :: Array (Tuple PackageName Version)) + for_ (Array.range 0 4095) \seed -> do let - index = Map.fromFoldable $ Array.mapWithIndex - ( \i p -> p /\ Map.fromFoldable - ( map - ( \j -> do - let - digit = (seed / Int.pow 4 (i * 2 + j - 1)) `mod` 4 - next = package (if i == 0 then "b" else if i == 1 then "c" else "a") - deps = case digit of - 0 -> Map.empty - 1 -> Map.singleton next (range 1 2) - 2 -> Map.singleton next (range 2 3) - _ -> Map.singleton next (range 1 3) - version j /\ deps - ) - [ 1, 2 ] - ) - ) - names + versionEntry i j = do + let + digit = (seed / Int.pow 4 (i * 2 + j - 1)) `mod` 4 + next = package (if i == 0 then "b" else if i == 1 then "c" else "a") + deps = case digit of + 0 -> Map.empty + 1 -> Map.singleton next (range 1 2) + 2 -> Map.singleton next (range 2 3) + _ -> Map.singleton next (range 1 3) + + version j /\ deps + + versions i = Map.fromFoldable $ map (versionEntry i) [ 1, 2 ] + + index = Map.fromFoldable $ Array.mapWithIndex (\i p -> p /\ versions i) names + case solve index goals of Left _ -> when (Array.any (valid index) assignments) $ Assert.fail $ "False unsatisfiability for graph " <> show seed Right plan -> unless (valid index plan) $ Assert.fail $ "Invalid resolution for graph " <> show seed diff --git a/scripts/bench-solver.md b/scripts/bench-solver.md deleted file mode 100644 index 15a4225d..00000000 --- a/scripts/bench-solver.md +++ /dev/null @@ -1,90 +0,0 @@ -# Dependency solver benchmarks - -Run from the repository root, using the pinned development environment: - -```sh -nix develop --command npm ci -nix develop --command spago build -nix develop --command spago test -p registry-lib -nix develop --command node scripts/bench-solver.mjs > before.json -``` - -The benchmark calls the real `Registry.Solver.solve` and `solveWithCompiler` -exports from compiled PureScript. It uses Node's built-in timing, assertion, -and profiling APIs; no benchmark dependency or public library API is added. -The generated-module interop is confined to this script. - -Each case has three warmups, then seven samples of three solves. JSON on stdout -contains every sample, the median milliseconds per solve, complete resolutions -or formatted errors, runtime/CPU information, and the compiled solver SHA-256. -Progress goes to stderr. `SAMPLES`, `ITERATIONS`, and `CASE` (a label substring) -override the defaults. Use more iterations for very short cases. Timings are -not CI assertions: JIT, GC, CPU frequency, and other local processes affect them. - -Index construction, input decoding, compiler-index construction, IO, and result -validation are outside the timed region. Solver-internal preparation, including -reachability and diagnostic initialization, is inside it. Each sample retains -its results until validation; keep the same iteration count in both runs. - -## Workloads and correctness - -Six deterministic synthetic cases cover a long dependency chain, a wide shared -dependency diamond, irrelevant index entries, a narrow interval in a package -with many versions, conflicting newest choices requiring backtracking, and an -unsatisfiable pair of roots. Successes must equal independently specified plans; -failure must be unsatisfiable. Every success is also checked for dependency -closure, range satisfaction, existing versions, and absence of extra packages. - -An optional snapshot adds the latest manifests' dependency sets for `prelude`, -`effect`, `aff`, `web-storage`, `halogen`, `spec`, `tidy`, and -`language-cst-parser`, each with and without compiler 0.15.16: - -```sh -nix develop --command node scripts/bench-solver.mjs snapshot.json > before.json -``` - -The input is a local JSON object with `manifests` (an array of registry manifest -objects), `metadata` (package name to registry metadata object), and `compilers` -(a nonempty array of version strings). Optional `commits` records snapshot -provenance. The script never fetches a registry or contacts production. Input -bytes are hashed so comparisons cannot silently switch snapshots. Use the same -saved file for both runs. These are dependency solves, not builds of the named -packages, and `prelude` deliberately provides an empty-dependency control. -Compiler results are checked against the metadata's min/max support bounds, -matching the existing compiler-index contract, not a discrete support set. - -The library tests separately enumerate all assignments for 4,096 small cyclic -version graphs to detect invalid plans and false unsatisfiability. Targeted tests -check alphabetical/latest selection, backtracking, exact/flexible compilers, -and structured error provenance. Benchmarks complement these tests, not replace -them; real-snapshot satisfiability alone is not an independent correctness oracle. - -## Before/after and CPU profiles - -Preserve a baseline build before changing the solver, then rebuild normally: - -```sh -mkdir -p scratch -cp -a output scratch/solver-before -# After editing and rebuilding: -SOLVER_OUTPUT=scratch/solver-before nix develop --command \ - node scripts/bench-solver.mjs snapshot.json > before.json -BASELINE=before.json nix develop --command \ - node scripts/bench-solver.mjs snapshot.json > after.json -``` - -Keep the preserved output inside this checkout (for example in ignored `scratch/`) -so its FFI can resolve this checkout's `node_modules`. `BASELINE` requires identical -workload labels, snapshot provenance, complete plans, and formatted diagnostics. -Repeat in before/after/after/before order without concurrent builds or test runs. -Report distributions as well as medians; don't compare one cold run to warm runs. - -```sh -CASE=halogen SAMPLES=1 ITERATIONS=100 PROFILE=halogen.cpuprofile \ - nix develop --command node scripts/bench-solver.mjs snapshot.json > profile.json -``` - -Open the profile in a V8 CPU-profile viewer. Profiling starts after fixture setup -and includes warmups, solving, and validation. Do not use profiled timings as the -headline measurements. A snapshot result is not a production latency, package -compilation, server request, or matrix-scheduling benchmark. diff --git a/scripts/bench-solver.mjs b/scripts/bench-solver.mjs deleted file mode 100644 index c9a0b035..00000000 --- a/scripts/bench-solver.mjs +++ /dev/null @@ -1,189 +0,0 @@ -// Run after `spago build`: node scripts/bench-solver.mjs [snapshot.json] -// PureScript's generated modules are intentionally used only at this benchmark -// boundary. No network, database, scheduling, or registry IO occurs in a sample. -import assert from "node:assert/strict"; -import { createHash } from "node:crypto"; -import { readFileSync, writeFileSync } from "node:fs"; -import { Session } from "node:inspector/promises"; -import { cpus } from "node:os"; -import { resolve } from "node:path"; -import { pathToFileURL } from "node:url"; - -const output = resolve(process.env.SOLVER_OUTPUT || "output"); -const load = (name) => import(pathToFileURL(`${output}/${name}/index.js`)); -const [Solver, Map, Foldable, Unfoldable, Tuple, Either, Name, Version, Range, CJ, Manifest, Metadata, NEL] = - await Promise.all([ - "Registry.Solver", "Data.Map", "Data.Foldable", "Data.Unfoldable", "Data.Tuple", - "Data.Either", "Registry.PackageName", "Registry.Version", "Registry.Range", - "Data.Codec.JSON", "Registry.Manifest", "Registry.Metadata", "Data.List.NonEmpty", - ].map(load)); -const right = (value) => { - assert(value instanceof Either.Right, "Invalid benchmark input"); - return value.value0; -}; -const version = (s) => right(Version.parse(s)); -const name = (s) => right(Name.parse(s)); -const range = (s) => right(Range.parse(s)); -const pairs = Map.toUnfoldable(Unfoldable.unfoldableArray); -const from = (ord, entries) => Map.fromFoldable(ord)(Foldable.foldableArray)( - entries.map(([k, v]) => new Tuple.Tuple(k, v)), -); -const packages = (entries) => from(Name.ordPackageName, entries); -const versions = (entries) => from(Version.ordVersion, entries); -const requirements = (deps) => packages(Object.entries(deps).map(([p, r]) => [name(p), range(r)])); -const indexFrom = (index) => packages(Object.entries(index).map(([p, vs]) => [ - name(p), versions(Object.entries(vs).map(([v, deps]) => [version(v), requirements(deps)])), -])); -const printPlan = (plan) => Object.fromEntries(pairs(plan).map((t) => [Name.print(t.value0), Version.print(t.value1)])); -const cmp = (a, b) => { - const av = a.split(".").map(Number), bv = b.split(".").map(Number); - return av[0] - bv[0] || av[1] - bv[1] || av[2] - bv[2]; -}; -// Independent of the solver's range propagation and sourced intersections. -function includes(r, v) { - const [, lo, hi] = /^>=(\d+\.\d+\.\d+) <(\d+\.\d+\.\d+)$/.exec(r); - return cmp(lo, v) <= 0 && cmp(v, hi) < 0; -} -function validate(index, goals, plan) { - const seen = new Set(); - function visit(deps) { - for (const [p, r] of Object.entries(deps)) { - assert(plan[p] && includes(r, plan[p]), `${p}@${plan[p]} violates ${r}`); - assert(index[p]?.[plan[p]], `Unknown ${p}@${plan[p]}`); - if (!seen.has(p)) { - seen.add(p); - visit(index[p][plan[p]]); - } - } - } - visit(goals); - assert.deepEqual(Object.keys(plan).sort(), [...seen].sort(), "Extraneous packages"); -} -const cases = []; -const indexes = new WeakMap(); -function add(label, raw, goals, expected, compiler = null) { - if (!indexes.has(raw)) indexes.set(raw, indexFrom(raw)); - const index = indexes.get(raw), required = requirements(goals); - const compilerRange = Range.exact(version("0.15.16")); - const run = compiler - ? () => Solver.solveWithCompiler(compilerRange)(compiler.index)(required) - : () => Solver.solve(index)(required); - const check = (result) => { - if (expected === null) { - assert(result instanceof Either.Left, `${label}: expected unsatisfiable`); - return { errors: NEL.toUnfoldable(Unfoldable.unfoldableArray)(result.value0).map(Solver.printSolverError) }; - } - assert(result instanceof Either.Right, `${label}: unexpectedly unsatisfiable`); - let resolved = result.value0; - if (compiler) { - assert.equal(Version.print(resolved.value0), "0.15.16"); - resolved = resolved.value1; - } - const plan = printPlan(resolved); - validate(raw, goals, plan); - if (compiler) { - for (const [p, v] of Object.entries(plan)) { - const supported = compiler.metadata[p].published[v]?.compilers; - if (supported?.length) { - const sorted = [...supported].sort(cmp); - assert(cmp(sorted[0], "0.15.16") <= 0 && cmp("0.15.16", sorted.at(-1)) <= 0, - `${p}@${v}: compiler outside metadata bounds`); - } - } - } - if (expected) assert.deepEqual(plan, expected); - return plan; - }; - cases.push({ label, run, check, packages: Object.keys(raw).length, - versions: Object.values(raw).reduce((n, vs) => n + Object.keys(vs).length, 0) }); -} -const v = (i) => `${i}.0.0`; -const r = (lo, hi) => `>=${v(lo)} <${v(hi)}`; -const chain = {}; -for (let i = 0; i < 60; i++) { - chain[`chain-${i}`] = Object.fromEntries([1, 2, 3].map((j) => [v(j), i === 59 ? {} : { [`chain-${i + 1}`]: r(1, 4) }])); -} -add("chain-60", chain, { "chain-0": r(1, 4) }, Object.fromEntries(Object.keys(chain).map((p) => [p, v(3)]))); -const diamond = { shared: { [v(1)]: {}, [v(2)]: {} }, root: { [v(1)]: {} } }; -for (let i = 0; i < 40; i++) { - const p = `branch-${i}`; - diamond.root[v(1)][p] = r(1, 11); - diamond[p] = Object.fromEntries(Array.from({ length: 10 }, (_, j) => [v(j + 1), { shared: r(1, 3) }])); -} -add("diamond-40x10", diamond, { root: r(1, 2) }, { root: v(1), shared: v(2), ...Object.fromEntries(Array.from({ length: 40 }, (_, i) => [`branch-${i}`, v(10)])) }); -const unrelated = { target: { [v(1)]: {} } }; -for (let i = 0; i < 2000; i++) unrelated[`unused-${i}`] = { [v(1)]: { target: r(1, 2) } }; -add("unreachable-2000", unrelated, { target: r(1, 2) }, { target: v(1) }); -const many = { releases: Object.fromEntries(Array.from({ length: 1000 }, (_, i) => [v(i), {}])) }; -add("narrow-1000", many, { releases: r(498, 500) }, { releases: v(499) }); -// Each branch alone is viable; their shared dependency forces backtracking. -const conflict = { - a: { [v(1)]: { z: r(1, 2) }, [v(2)]: { z: r(2, 3) } }, - b: { [v(1)]: { z: r(2, 3) }, [v(2)]: { z: r(1, 2) } }, - z: { [v(1)]: {}, [v(2)]: {} }, -}; -add("selection-backtrack", conflict, { a: r(1, 3), b: r(1, 3) }, { a: v(2), b: v(1), z: v(2) }); -add("incompatible-roots", conflict, { a: r(2, 3), b: r(2, 3) }, null); - -let snapshotInfo = null; -if (process.argv[2]) { - const bytes = readFileSync(process.argv[2]); - const snapshot = JSON.parse(bytes); - const raw = {}, decoded = {}; - for (const m of snapshot.manifests) { - (raw[m.name] ??= {})[m.version] = m.dependencies; - (decoded[m.name] ??= {})[m.version] = right(CJ.decode(Manifest.codec)(m)); - } - const manifests = packages(Object.entries(decoded).map(([p, vs]) => [name(p), versions(Object.entries(vs).map(([s, m]) => [version(s), m]))])); - const metadata = packages(Object.entries(snapshot.metadata).map(([p, m]) => [name(p), right(CJ.decode(Metadata.codec)(m))])); - const ci = Solver.buildCompilerIndex(snapshot.compilers.map(version))(manifests)(metadata); - snapshotInfo = { sha256: createHash("sha256").update(bytes).digest("hex"), commits: snapshot.commits, manifests: snapshot.manifests.length }; - for (const p of ["prelude", "effect", "aff", "web-storage", "halogen", "spec", "tidy", "language-cst-parser"]) { - assert(raw[p], `Snapshot missing ${p}`); - const latest = Object.keys(raw[p]).sort(cmp).at(-1); - const deps = raw[p][latest]; - add(`${p}@${latest}/solve`, raw, deps); - add(`${p}@${latest}/compiler`, raw, deps, undefined, { index: ci, metadata: snapshot.metadata }); - } -} -const samples = Number(process.env.SAMPLES || 7); -const iterations = Number(process.env.ITERATIONS || 3); -assert(Number.isInteger(samples) && samples > 0 && Number.isInteger(iterations) && iterations > 0); -const profiler = process.env.PROFILE ? new Session() : null; -if (profiler) { - profiler.connect(); - await profiler.post("Profiler.enable"); - await profiler.post("Profiler.start"); -} -const results = []; -for (const c of cases) { - if (process.env.CASE && !c.label.includes(process.env.CASE)) continue; - let canonical; - for (let i = 0; i < 3; i++) canonical = c.check(c.run()); - const times = []; - for (let s = 0; s < samples; s++) { - const start = performance.now(); - const answers = Array.from({ length: iterations }, c.run); - times.push((performance.now() - start) / iterations); - for (const answer of answers) assert.deepEqual(c.check(answer), canonical); - } - const sorted = [...times].sort((a, b) => a - b); - results.push({ label: c.label, packages: c.packages, versions: c.versions, medianMs: sorted[Math.floor(samples / 2)], samplesMs: times, result: canonical }); - console.error(`${c.label}: ${results.at(-1).medianMs.toFixed(3)} ms`); -} -assert(results.length, "CASE matched no workloads"); -if (profiler) { - const { profile } = await profiler.post("Profiler.stop"); - writeFileSync(process.env.PROFILE, JSON.stringify(profile)); - profiler.disconnect(); -} -if (process.env.BASELINE) { - const before = JSON.parse(readFileSync(process.env.BASELINE)); - assert.equal(snapshotInfo?.sha256, before.snapshot?.sha256, "Different snapshots"); - assert.deepEqual(results.map((r) => r.label), before.results.map((r) => r.label), "Different workloads"); - for (const [i, r] of results.entries()) { - assert.deepEqual(r.result, before.results[i].result, `${r.label}: changed resolution or diagnostics`); - } -} -const solverSha256 = createHash("sha256").update(readFileSync(`${output}/Registry.Solver/index.js`)).digest("hex"); -console.log(JSON.stringify({ node: process.version, cpu: cpus()[0].model, output, solverSha256, samples, iterations, snapshot: snapshotInfo, results }, null, 2)); diff --git a/scripts/spago.yaml b/scripts/spago.yaml index a3bdb19f..372a4a8d 100644 --- a/scripts/spago.yaml +++ b/scripts/spago.yaml @@ -15,6 +15,7 @@ package: - foldable-traversable - formatters - json + - minibench - node-fs - node-path - node-process @@ -23,5 +24,6 @@ package: - registry-app - registry-foreign - registry-lib + - registry-test-utils - run - strings diff --git a/scripts/src/BenchSolver.purs b/scripts/src/BenchSolver.purs new file mode 100644 index 00000000..79b22edc --- /dev/null +++ b/scripts/src/BenchSolver.purs @@ -0,0 +1,234 @@ +-- | Local dependency-solver benchmarks; run with --help for options. +module Registry.Scripts.BenchSolver where + +import Registry.App.Prelude + +import ArgParse.Basic as Arg +import Data.Array as Array +import Data.Codec.JSON as CJ +import Data.Codec.JSON.Common as CJ.Common +import Data.Codec.JSON.Record as CJ.Record +import Data.Foldable as Foldable +import Data.Map as Map +import Data.Set as Set +import Data.String as String +import Effect.Aff as Aff +import Effect.Class.Console as Console +import Node.Process as Process +import Performance.Minibench as Bench +import Registry.Internal.Codec as Codec +import Registry.Manifest as Manifest +import Registry.ManifestIndex as ManifestIndex +import Registry.Metadata as Metadata +import Registry.PackageName as PackageName +import Registry.Range as Range +import Registry.Sha256 as Sha256 +import Registry.Solver as Solver +import Registry.Test.Utils as Utils +import Registry.Version as Version + +type Plan = Map PackageName Version +type Solution = { compiler :: Maybe Version, resolutions :: Plan } +type Outcome = Either Solver.SolverErrors Solution + +data Expected = Unsolvable | Solvable (Maybe Plan) + +type Workload = + { label :: String + , index :: Solver.DependencyIndex + , goals :: Map PackageName Range + , expected :: Expected + , metadata :: Maybe (Map PackageName Metadata) + , run :: Unit -> Outcome + } + +type Snapshot = + { manifests :: Array Manifest + , metadata :: Map PackageName Metadata + , compilers :: NonEmptyArray Version + } + +snapshotCodec :: CJ.Codec Snapshot +snapshotCodec = CJ.Record.object + { manifests: CJ.array Manifest.codec + , metadata: Codec.packageMap Metadata.codec + , compilers: CJ.Common.nonEmptyArray Version.codec + } + +type Report = + { node :: String + , snapshot :: Maybe Sha256 + , samples :: Int + , results :: Array { label :: String, timing :: Bench.BenchResult, result :: Either (Array String) Solution } + } + +reportCodec :: CJ.Codec Report +reportCodec = CJ.Record.object + { node: CJ.string + , snapshot: CJ.Record.optional Sha256.codec + , samples: CJ.int + , results: CJ.array $ CJ.Record.object + { label: CJ.string + , timing: CJ.Record.object { mean: CJ.number, stdDev: CJ.number, min: CJ.number, max: CJ.number } + , result: CJ.Common.either (CJ.array CJ.string) $ CJ.Record.object + { compiler: CJ.Record.optional Version.codec + , resolutions: Codec.packageMap Version.codec + } + } + } + +main :: Effect Unit +main = launchAff_ do + args <- Array.drop 2 <$> liftEffect Process.argv + options <- require $ lmap Arg.printArgError $ Arg.parseArgs "bench-solver" "Benchmark local solver workloads (Minibench timings in nanoseconds)." + ( Arg.fromRecord + { snapshot: Arg.optional $ Arg.argument [ "--snapshot" ] "Local registry snapshot JSON" + , baseline: Arg.optional $ Arg.argument [ "--baseline" ] "Compare complete results with this report" + , samples: Arg.default 40 $ Arg.int $ Arg.argument [ "--samples" ] "Timed solves per case (at least two)" + , filter: Arg.default "" $ Arg.argument [ "--case" ] "Workload label substring" + } + ) + args + require $ ensure (options.samples >= 2) "--samples must be at least two" + snapshot <- traverse (require <=< readJsonFile snapshotCodec) options.snapshot + hash <- traverse Sha256.hashFile options.snapshot + real <- require $ maybe (Right []) snapshotWorkloads snapshot + + let cases = Array.filter (String.contains (String.Pattern options.filter) <<< _.label) (syntheticWorkloads <> real) + + require $ ensure (not (Array.null cases)) "--case matched no workloads" + results <- for cases \work -> do + -- Validate and warm up outside Minibench's timed region. + for_ (Array.range 1 3) \_ -> require $ validate work (work.run unit) + + let result = work.run unit + + timing <- liftEffect $ Bench.benchWith' options.samples work.run + require $ ensure (work.run unit == result) (work.label <> ": result changed after timing") + Console.error $ work.label <> ": mean " <> Bench.withUnits timing.mean + pure { label: work.label, timing, result: lmap (map Solver.printSolverError <<< Array.fromFoldable) result } + for_ options.baseline \path -> do + before <- require =<< readJsonFile reportCodec path + require $ ensure (before.snapshot == hash && before.samples == options.samples && before.node == Process.version) "Different snapshot, sample count, or Node version" + require $ ensure (map (\r -> { label: r.label, result: r.result }) before.results == map (\r -> { label: r.label, result: r.result }) results) "Changed workloads, resolutions, or diagnostics" + Console.log $ printJson reportCodec { node: Process.version, snapshot: hash, samples: options.samples, results } + +require :: forall a. Either String a -> Aff a +require = either (Aff.throwError <<< Aff.error) pure + +ensure :: Boolean -> String -> Either String Unit +ensure condition message = if condition then Right unit else Left message + +-- | Inspect only selected versions, independently of solver propagation/search. +validate :: Workload -> Outcome -> Either String Unit +validate work result = case result, work.expected of + Left _, Unsolvable -> Right unit + Left _, _ -> Left $ work.label <> ": unexpectedly unsatisfiable" + Right _, Unsolvable -> Left $ work.label <> ": expected unsatisfiable" + Right solved, Solvable expected -> do + for_ expected \plan -> ensure (plan == solved.resolutions) (work.label <> ": incorrect selection") + reached <- visit Set.empty (Map.toUnfoldable work.goals) + ensure (reached == Map.keys solved.resolutions) "Extraneous packages in resolution" + for_ work.metadata \metadata -> do + ensure (solved.compiler == Just compiler) "Wrong compiler selection" + forWithIndex_ solved.resolutions \name selected -> do + Metadata meta <- note "Missing compiler metadata" $ Map.lookup name metadata + for_ (Map.lookup selected meta.published) \entry -> + ensure (Foldable.minimum entry.compilers <= Just compiler && Just compiler <= Foldable.maximum entry.compilers) "Compiler outside metadata bounds" + where + visit seen pending = case Array.uncons pending of + Nothing -> Right seen + Just { head: Tuple name bounds, tail } -> do + selected <- note "Missing dependency" $ Map.lookup name solved.resolutions + ensure (Range.includes bounds selected) "Dependency outside required range" + selectedDeps <- note "Unknown package version" $ Map.lookup name work.index >>= Map.lookup selected + if Set.member name seen then visit seen tail + else visit (Set.insert name seen) ((Map.toUnfoldable selectedDeps :: Array _) <> tail) + +workload :: String -> Solver.DependencyIndex -> Map PackageName Range -> Expected -> Workload +workload label index goals expected = + { label + , index + , goals + , expected + , metadata: Nothing + , run: \_ -> map (\resolutions -> { compiler: Nothing, resolutions }) $ Solver.solve index goals + } + +compiler :: Version +compiler = Utils.unsafeVersion "0.15.16" + +snapshotWorkloads :: Snapshot -> Either String (Array Workload) +snapshotWorkloads snapshot = do + let + index = Array.foldl (\acc (Manifest m) -> Map.insertWith Map.union m.name (Map.singleton m.version m.dependencies) acc) Map.empty snapshot.manifests + seed = Solver.buildCompilerIndex snapshot.compilers ManifestIndex.empty Map.empty + + withMetadata <- for snapshot.manifests \manifest@(Manifest m) -> + Tuple manifest <$> note ("Missing metadata for " <> PackageName.print m.name) (Map.lookup m.name snapshot.metadata) + + let compilerIndex = Array.foldl (\acc (Tuple manifest meta) -> Solver.updateCompilerIndex acc manifest meta) seed withMetadata + + Array.concat <$> for [ "prelude", "effect", "aff", "web-storage", "halogen", "spec", "tidy", "language-cst-parser" ] \name -> do + latest <- note ("Snapshot missing " <> name) $ Map.lookup (Utils.unsafePackageName name) index >>= Map.findMax + + let + label = name <> "@" <> Version.print latest.key + plain = workload (label <> "/solve") index latest.value (Solvable Nothing) + + pure + [ plain + , plain + { label = label <> "/compiler" + , metadata = Just snapshot.metadata + , run = \_ -> Solver.solveWithCompiler (Range.exact compiler) compilerIndex latest.value + # map (\(Tuple selected resolutions) -> { compiler: Just selected, resolutions }) + } + ] + +syntheticWorkloads :: Array Workload +syntheticWorkloads = do + let + chain = Map.fromFoldable $ map + (\i -> Utils.unsafePackageName ("chain-" <> show i) /\ releases 1 3 (\_ -> if i == 59 then Map.empty else deps [ ("chain-" <> show (i + 1)) /\ range 1 4 ])) + (Array.range 0 59) + + diamondPlan = map (\i -> Utils.unsafePackageName ("branch-" <> show i) /\ version 10) (Array.range 0 39) + # Array.cons (Utils.unsafePackageName "root" /\ version 1) + # Array.cons (Utils.unsafePackageName "shared" /\ version 2) + # Map.fromFoldable + + diamond = map (\i -> Utils.unsafePackageName ("branch-" <> show i) /\ releases 1 10 (\_ -> deps [ "shared" /\ range 1 3 ])) (Array.range 0 39) + # Array.cons (Utils.unsafePackageName "shared" /\ releases 1 2 (const Map.empty)) + # Array.cons (Utils.unsafePackageName "root" /\ releases 1 1 (\_ -> deps $ map (\i -> ("branch-" <> show i) /\ range 1 11) (Array.range 0 39))) + # Map.fromFoldable + + unrelated = map (\i -> Utils.unsafePackageName ("unused-" <> show i) /\ releases 1 1 (\_ -> deps [ "target" /\ range 1 2 ])) (Array.range 0 1999) + # Array.cons (Utils.unsafePackageName "target" /\ releases 1 1 (const Map.empty)) + # Map.fromFoldable + + conflict = Map.fromFoldable + [ Utils.unsafePackageName "a" /\ releases 1 2 (\i -> deps [ "z" /\ range i (i + 1) ]) + , Utils.unsafePackageName "b" /\ releases 1 2 (\i -> deps [ "z" /\ range (3 - i) (4 - i) ]) + , Utils.unsafePackageName "z" /\ releases 1 2 (const Map.empty) + ] + + [ workload "chain-60" chain (deps [ "chain-0" /\ range 1 4 ]) $ Solvable $ Just $ Map.fromFoldable $ map (\i -> Utils.unsafePackageName ("chain-" <> show i) /\ version 3) (Array.range 0 59) + , workload "diamond-40x10" diamond (deps [ "root" /\ range 1 2 ]) $ Solvable $ Just diamondPlan + , workload "unreachable-2000" unrelated (deps [ "target" /\ range 1 2 ]) $ Solvable $ Just $ Map.singleton (Utils.unsafePackageName "target") (version 1) + , workload "narrow-1000" (Map.singleton (Utils.unsafePackageName "releases") (releases 0 999 (const Map.empty))) (deps [ "releases" /\ range 498 500 ]) $ Solvable $ Just $ Map.singleton (Utils.unsafePackageName "releases") (version 499) + , workload "selection-backtrack" conflict (deps [ "a" /\ range 1 3, "b" /\ range 1 3 ]) $ Solvable $ Just $ Map.fromFoldable [ Utils.unsafePackageName "a" /\ version 2, Utils.unsafePackageName "b" /\ version 1, Utils.unsafePackageName "z" /\ version 2 ] + , workload "incompatible-roots" conflict (deps [ "a" /\ range 2 3, "b" /\ range 2 3 ]) Unsolvable + ] + +version :: Int -> Version +version = Utils.unsafeVersion <<< (_ <> ".0.0") <<< show + +range :: Int -> Int -> Range +range lo hi = unsafeFromJust $ Range.mk (version lo) (version hi) + +deps :: Array (Tuple String Range) -> Map PackageName Range +deps = Map.fromFoldable <<< map (lmap Utils.unsafePackageName) + +releases :: Int -> Int -> (Int -> Map PackageName Range) -> Map Version (Map PackageName Range) +releases lo hi dependencies = Map.fromFoldable $ map (\i -> version i /\ dependencies i) (Array.range lo hi) diff --git a/spago.lock b/spago.lock index 8e89fad9..e134a94e 100644 --- a/spago.lock +++ b/spago.lock @@ -279,6 +279,7 @@ "foldable-traversable", "formatters", "json", + "minibench", "node-fs", "node-path", "node-process", @@ -287,6 +288,7 @@ "registry-app", "registry-foreign", "registry-lib", + "registry-test-utils", "run", "strings" ] @@ -2055,6 +2057,19 @@ "prelude" ] }, + "minibench": { + "type": "registry", + "version": "4.0.1", + "integrity": "sha256-zd/YnOqnzz/ers1umv5rgJOyPntF2zFpcLVbPWQIbYU=", + "dependencies": [ + "console", + "effect", + "integers", + "numbers", + "prelude", + "refs" + ] + }, "mmorph": { "type": "registry", "version": "7.0.0", From 9b58949c31edac9b5963873c865265d677efedec Mon Sep 17 00:00:00 2001 From: Thomas Honeyman Date: Mon, 12 Oct 2026 11:14:40 +0000 Subject: [PATCH 5/6] Clarify solver comments and reuse test utilities --- lib/src/Solver.purs | 6 ++---- lib/test/Registry/Solver.purs | 21 ++++++++++----------- 2 files changed, 12 insertions(+), 15 deletions(-) diff --git a/lib/src/Solver.purs b/lib/src/Solver.purs index 8a4a5c17..e8bbf08d 100644 --- a/lib/src/Solver.purs +++ b/lib/src/Solver.purs @@ -153,8 +153,8 @@ solve :: DependencyIndex -> Map PackageName Range -> Either SolverErrors (Map Pa solve index required = do let initializedRequired = initializeRequired required - -- Attach diagnostic provenance only to reachable versions, not the entire - -- registry. Reachability depends on raw ranges, not on that provenance. + -- Record where the dependency requirements come from for every package + -- version reachable in this solve, for use in error reporting. registry = initializeRegistry (gatherReachable index required) solveFull { registry: map (addFrom initializedRequired) <$> registry @@ -652,8 +652,6 @@ dependencyOf roots = coerce \(Sourced v (Pos local global)) -> asDependencyOf :: Intersection -> Intersection -> Intersection asDependencyOf (Intersection i1) (Intersection i2) = do let - -- Only roots propagate from the parent. Combining its local provenance - -- would merge potentially large sets just to discard them immediately. Pos _ lowerRoots = getPos i1.lower Pos _ upperRoots = getPos i1.upper roots = lowerRoots <> upperRoots diff --git a/lib/test/Registry/Solver.purs b/lib/test/Registry/Solver.purs index 0617ab43..124bb3f1 100644 --- a/lib/test/Registry/Solver.purs +++ b/lib/test/Registry/Solver.purs @@ -10,21 +10,20 @@ import Data.Int as Int import Data.List.NonEmpty as NonEmptyList import Data.Map (Map, SemigroupMap(..)) import Data.Map as Map -import Data.Maybe (Maybe(..), fromMaybe') +import Data.Maybe (Maybe(..)) import Data.Newtype (un, wrap) import Data.Semigroup.Foldable (intercalateMap) import Data.Set as Set import Data.Set.NonEmpty as NES import Data.Tuple (Tuple(..)) import Data.Tuple.Nested ((/\)) -import Partial.Unsafe (unsafeCrashWith) import Registry.Manifest (Manifest(..)) import Registry.ManifestIndex as ManifestIndex import Registry.PackageName as PackageName import Registry.Range as Range import Registry.Solver (Intersection(..), LocalSolverPosition(..), SolverError(..), SolverPosition(..), Sourced(..), buildCompilerIndex, initializeRegistry, initializeRequired, lowerBound, printSolverError, solve, solveSeed, solveSteps, solveWithCompiler, updateCompilerIndex, upperBound) import Registry.Test.Assert as Assert -import Registry.Test.Utils (fromRight, unsafeManifest, unsafeMetadata, unsafeNonEmptyArray, unsafeVersion) +import Registry.Test.Utils (fromJust, fromRight, unsafeManifest, unsafeMetadata, unsafeNonEmptyArray, unsafePackageName, unsafeRange, unsafeVersion) import Registry.Types (PackageName, Range, Version) import Registry.Version as Version import Test.Spec as Spec @@ -37,7 +36,7 @@ spec = do shouldSucceedSteps goals result = pure unit >>= \_ -> do let solved = solveSteps (solveSeed { registry: initializeRegistry solverIndex, required: initializeRequired (Map.fromFoldable goals) }) - let toRange intersect = fromMaybe' (\_ -> unsafeCrashWith "Bad intersection") (Range.mk (lowerBound intersect) (upperBound intersect)) + let toRange intersect = fromJust "Bad intersection" (Range.mk (lowerBound intersect) (upperBound intersect)) map toRange (un SemigroupMap solved.required) `Assert.shouldEqual` Map.fromFoldable result shouldFail goals errors = pure unit >>= \_ -> case solve solverIndex (Map.fromFoldable goals) of @@ -301,7 +300,7 @@ spec = do solve index (Map.singleton (package "a") (range 1 3)) `Assert.shouldContain` Map.singleton (package "a") (version 2) - Spec.it "Preserves both bound roots and the child's local source in downstream errors" do + Spec.it "Reports both originally requested packages and the package version directly requiring the missing dependency" do let index = Map.fromFoldable [ package "a" /\ Map.singleton (version 1) (Map.singleton (package "middle") (range 2 5)) @@ -441,7 +440,7 @@ spec = do Map.lookup (package "prelude") resolutions `Assert.shouldEqual` Just (unsafeVersion "6.0.1") Map.lookup (package "effect") resolutions `Assert.shouldEqual` Just (unsafeVersion "4.0.0") - Spec.it "Backtracks package versions for an exact compiler without changing flexible selection" do + Spec.it "Prefers the newer package version unless the compiler requirement forces an older one" do let metadata = Map.fromFoldable [ unsafeMetadata "prelude" [ Tuple "6.0.1" [ "0.15.15", "0.15.16" ] ] @@ -452,14 +451,14 @@ spec = do , unsafeMetadata "my-pkg" [ Tuple "1.0.0" [ "0.15.15" ] ] ] index = buildCompilerIndex compilers manifestIndex metadata - goals = Map.singleton (package "effect") (fromRight "bad range" (Range.parse ">=4.0.0 <6.0.0")) + goals = Map.singleton (package "effect") (unsafeRange ">=4.0.0 <6.0.0") expected compiler effect = Tuple (unsafeVersion compiler) $ Map.fromFoldable [ package "effect" /\ unsafeVersion effect , package "prelude" /\ unsafeVersion "6.0.1" ] solveWithCompiler (Range.exact (unsafeVersion "0.15.16")) index goals `Assert.shouldContain` expected "0.15.16" "4.0.0" - solveWithCompiler (fromRight "bad compiler range" (Range.parse ">=0.15.15 <0.15.17")) index goals + solveWithCompiler (unsafeRange ">=0.15.15 <0.15.17") index goals `Assert.shouldContain` expected "0.15.15" "5.0.0" Spec.it "updateCompilerIndex produces same result as full rebuild" do @@ -606,16 +605,16 @@ qaotic = } package :: String -> PackageName -package = fromRight "bad package name" <<< PackageName.parse +package = unsafePackageName version :: Int -> Version -version = fromRight "bad version" <<< Version.parse <<< (_ <> ".0.0") <<< show +version = unsafeVersion <<< (_ <> ".0.0") <<< show -- For all these tests, we work with major versions only because we do not -- need to exercise the intricacies of the range relations, just the solver, -- which does not care about what versions are, just how they relate range :: Int -> Int -> Range -range lower upper = fromRight "bad range" $ Range.parse $ Array.fold +range lower upper = unsafeRange $ Array.fold [ ">=" , show lower , ".0.0 <" From 0edfbc7951fdb6466c93450ea5d759f6309205ce Mon Sep 17 00:00:00 2001 From: Thomas Honeyman Date: Mon, 12 Oct 2026 15:07:15 +0000 Subject: [PATCH 6/6] Use explicit package names and version ranges in solver tests --- lib/test/Registry/Solver.purs | 315 ++++++++++++++++------------------ 1 file changed, 150 insertions(+), 165 deletions(-) diff --git a/lib/test/Registry/Solver.purs b/lib/test/Registry/Solver.purs index 124bb3f1..5a03d424 100644 --- a/lib/test/Registry/Solver.purs +++ b/lib/test/Registry/Solver.purs @@ -61,110 +61,110 @@ spec = do Spec.describe "Valid dependency ranges" do Spec.it "Solves simple range" do shouldSucceed - [ simple.package /\ range 0 1 ] - [ simple.package /\ version 0, prelude.package /\ version 0 ] + [ simple.package /\ unsafeRange ">=0.0.0 <1.0.0" ] + [ simple.package /\ unsafeVersion "0.0.0", prelude.package /\ unsafeVersion "0.0.0" ] Spec.it "Chooses range with highest SemVer versions from several solution" do shouldSucceed - [ simple.package /\ range 0 2 ] - [ simple.package /\ version 1, prelude.package /\ version 1 ] + [ simple.package /\ unsafeRange ">=0.0.0 <2.0.0" ] + [ simple.package /\ unsafeVersion "1.0.0", prelude.package /\ unsafeVersion "1.0.0" ] Spec.it "Solves for multiple packages" do shouldSucceed - [ prelude.package /\ range 0 2, simple.package /\ range 0 2 ] - [ simple.package /\ version 1, prelude.package /\ version 1 ] + [ prelude.package /\ unsafeRange ">=0.0.0 <2.0.0", simple.package /\ unsafeRange ">=0.0.0 <2.0.0" ] + [ simple.package /\ unsafeVersion "1.0.0", prelude.package /\ unsafeVersion "1.0.0" ] Spec.describe "Valid ranges with small intersection" do Spec.it "Tight bound on 'prelude@0'" do shouldSucceed - [ simple.package /\ range 0 2 -- loose - , prelude.package /\ range 0 1 -- tight + [ simple.package /\ unsafeRange ">=0.0.0 <2.0.0" -- loose + , prelude.package /\ unsafeRange ">=0.0.0 <1.0.0" -- tight ] - [ simple.package /\ version 0 - , prelude.package /\ version 0 + [ simple.package /\ unsafeVersion "0.0.0" + , prelude.package /\ unsafeVersion "0.0.0" ] Spec.it "Tight bound on 'prelude@1'" do shouldSucceed - [ simple.package /\ range 0 2 -- loose - , prelude.package /\ range 1 2 -- tight + [ simple.package /\ unsafeRange ">=0.0.0 <2.0.0" -- loose + , prelude.package /\ unsafeRange ">=1.0.0 <2.0.0" -- tight ] - [ simple.package /\ version 1 - , prelude.package /\ version 1 + [ simple.package /\ unsafeVersion "1.0.0" + , prelude.package /\ unsafeVersion "1.0.0" ] Spec.it "Tight bound on 'simple@0'" do shouldSucceed - [ prelude.package /\ range 0 2 -- loose - , simple.package /\ range 0 1 -- tight + [ prelude.package /\ unsafeRange ">=0.0.0 <2.0.0" -- loose + , simple.package /\ unsafeRange ">=0.0.0 <1.0.0" -- tight ] - [ simple.package /\ version 0 - , prelude.package /\ version 0 + [ simple.package /\ unsafeVersion "0.0.0" + , prelude.package /\ unsafeVersion "0.0.0" ] Spec.it "Tight bound on 'simple@1'" do shouldSucceed - [ prelude.package /\ range 0 2 -- loose - , simple.package /\ range 1 2 -- tight + [ prelude.package /\ unsafeRange ">=0.0.0 <2.0.0" -- loose + , simple.package /\ unsafeRange ">=1.0.0 <2.0.0" -- tight ] - [ simple.package /\ version 1 - , prelude.package /\ version 1 + [ simple.package /\ unsafeVersion "1.0.0" + , prelude.package /\ unsafeVersion "1.0.0" ] Spec.describe "Single-step expands bounds" do Spec.it "Simple range" do shouldSucceedSteps - [ simple.package /\ range 0 1 ] - [ simple.package /\ range 0 1, prelude.package /\ range 0 1 ] + [ simple.package /\ unsafeRange ">=0.0.0 <1.0.0" ] + [ simple.package /\ unsafeRange ">=0.0.0 <1.0.0", prelude.package /\ unsafeRange ">=0.0.0 <1.0.0" ] Spec.it "Multi-version range" do shouldSucceedSteps - [ simple.package /\ range 0 2 ] - [ simple.package /\ range 0 2, prelude.package /\ range 0 2 ] + [ simple.package /\ unsafeRange ">=0.0.0 <2.0.0" ] + [ simple.package /\ unsafeRange ">=0.0.0 <2.0.0", prelude.package /\ unsafeRange ">=0.0.0 <2.0.0" ] Spec.it "Transitive" do shouldSucceedSteps - [ onlySimple.package /\ range 0 1 ] - [ onlySimple.package /\ range 0 1, simple.package /\ range 0 1, prelude.package /\ range 0 1 ] + [ onlySimple.package /\ unsafeRange ">=0.0.0 <1.0.0" ] + [ onlySimple.package /\ unsafeRange ">=0.0.0 <1.0.0", simple.package /\ unsafeRange ">=0.0.0 <1.0.0", prelude.package /\ unsafeRange ">=0.0.0 <1.0.0" ] Spec.describe "Valid dependency ranges containing some invalid versions solve" do Spec.it "Proceeds past broken ranges to find a later valid range" do -- 'broken-fixed' cannot be solved at the broken version 0, but it can be -- solved at the fixed version 1. shouldSucceed - [ brokenFixed.package /\ range 0 2 ] - [ brokenFixed.package /\ version 1, prelude.package /\ version 0 ] + [ brokenFixed.package /\ unsafeRange ">=0.0.0 <2.0.0" ] + [ brokenFixed.package /\ unsafeVersion "1.0.0", prelude.package /\ unsafeVersion "0.0.0" ] Spec.it "Backtracks from broken ranges to find the highest valid range." do -- 'fixed-broken' works at version 0 and 1, but is broken at version 2. shouldSucceed - [ fixedBroken.package /\ range 0 3 ] - [ fixedBroken.package /\ version 1, prelude.package /\ version 1 ] + [ fixedBroken.package /\ unsafeRange ">=0.0.0 <3.0.0" ] + [ fixedBroken.package /\ unsafeVersion "1.0.0", prelude.package /\ unsafeVersion "1.0.0" ] Spec.describe "Does not solve when no versions exist for the specified range" do Spec.it "No versions available for target package" do shouldFail - [ package "does-not-exist" /\ range 0 4 ] + [ unsafePackageName "does-not-exist" /\ unsafeRange ">=0.0.0 <4.0.0" ] [ { error: Conflicts $ Map.fromFoldable - [ package "does-not-exist" `Tuple` intersection 0 (solveRoot "does-not-exist") 4 (solveRoot "does-not-exist") ] + [ unsafePackageName "does-not-exist" `Tuple` intersection 0 (solveRoot "does-not-exist") 4 (solveRoot "does-not-exist") ] , message: "No versions found in the registry for does-not-exist in range\n >=0.0.0 (declared dependency)\n <4.0.0 (declared dependency)" } ] Spec.it "Target package has versions, but none in range" do shouldFail - [ prelude.package /\ range 20 50 ] + [ prelude.package /\ unsafeRange ">=20.0.0 <50.0.0" ] [ { error: Conflicts $ Map.fromFoldable - [ package "prelude" `Tuple` intersection 20 (solveRoot "prelude") 50 (solveRoot "prelude") ] + [ unsafePackageName "prelude" `Tuple` intersection 20 (solveRoot "prelude") 50 (solveRoot "prelude") ] , message: "No versions found in the registry for prelude in range\n >=20.0.0 (declared dependency)\n <50.0.0 (declared dependency)" } ] Spec.it "Direct dependency of target package has no versions in range." do shouldFail - [ brokenFixed.package /\ range 0 1 ] + [ brokenFixed.package /\ unsafeRange ">=0.0.0 <1.0.0" ] [ { error: Conflicts $ Map.fromFoldable - [ package "does-not-exist" `Tuple` intersection 0 (via' "broken-fixed" 0) 4 (via' "broken-fixed" 0) + [ unsafePackageName "does-not-exist" `Tuple` intersection 0 (via' "broken-fixed" 0) 4 (via' "broken-fixed" 0) ] , message: "No versions found in the registry for does-not-exist in range\n >=0.0.0 seen in broken-fixed@0.0.0\n <4.0.0 seen in broken-fixed@0.0.0" } @@ -172,9 +172,9 @@ spec = do Spec.it "Nested dependency of target package has no versions in range." do shouldFail - [ transitiveBroken.package /\ range 0 1 ] + [ transitiveBroken.package /\ unsafeRange ">=0.0.0 <1.0.0" ] [ { error: Conflicts $ Map.fromFoldable - [ package "does-not-exist" `Tuple` intersection 0 (via "fixed-broken" 2 [ "transitive-broken" ]) 5 (via "fixed-broken" 2 [ "transitive-broken" ]) + [ unsafePackageName "does-not-exist" `Tuple` intersection 0 (via "fixed-broken" 2 [ "transitive-broken" ]) 5 (via "fixed-broken" 2 [ "transitive-broken" ]) ] , message: "No versions found in the registry for does-not-exist in range\n >=0.0.0 seen in fixed-broken@2.0.0 from declared dependencies transitive-broken\n <5.0.0 seen in fixed-broken@2.0.0 from declared dependencies transitive-broken" } @@ -182,9 +182,9 @@ spec = do Spec.it "Fails when target package cannot be satisfied" do shouldFail - [ brokenBroken.package /\ range 0 2 ] + [ brokenBroken.package /\ unsafeRange ">=0.0.0 <2.0.0" ] [ { error: Conflicts $ Map.fromFoldable - [ package "does-not-exist" `Tuple` intersection 0 (via' "broken-broken" 0 <> via' "broken-broken" 1) 5 (via' "broken-broken" 0 <> via' "broken-broken" 1) + [ unsafePackageName "does-not-exist" `Tuple` intersection 0 (via' "broken-broken" 0 <> via' "broken-broken" 1) 5 (via' "broken-broken" 0 <> via' "broken-broken" 1) ] , message: "No versions found in the registry for does-not-exist in range\n >=0.0.0 seen in broken-broken@0.0.0, broken-broken@1.0.0\n <5.0.0 seen in broken-broken@0.0.0, broken-broken@1.0.0" } @@ -193,9 +193,9 @@ spec = do Spec.describe "Does not solve when ranges do not intersect" do Spec.it "Simple disjoint ranges" do shouldFail - [ simple.package /\ range 0 1, prelude.package /\ range 1 2 ] - [ { error: WhileSolving (package "simple") $ Map.singleton (version 0) $ Conflicts $ Map.fromFoldable - [ package "prelude" `Tuple` intersection 1 (solveRoot "prelude") 1 (via "simple" 0 []) + [ simple.package /\ unsafeRange ">=0.0.0 <1.0.0", prelude.package /\ unsafeRange ">=1.0.0 <2.0.0" ] + [ { error: WhileSolving (unsafePackageName "simple") $ Map.singleton (unsafeVersion "0.0.0") $ Conflicts $ Map.fromFoldable + [ unsafePackageName "prelude" `Tuple` intersection 1 (solveRoot "prelude") 1 (via "simple" 0 []) ] , message: "While solving simple each version could not be solved:\n- 0.0.0: \n Conflict in version ranges for prelude:\n >=1.0.0 (declared dependency)\n <1.0.0 seen in simple@0.0.0" } @@ -205,11 +205,11 @@ spec = do -- range provided Spec.it "Transitive disjoint ranges" do shouldFail - [ onlySimple.package /\ range 0 4 - , prelude.package /\ range 1 2 + [ onlySimple.package /\ unsafeRange ">=0.0.0 <4.0.0" + , prelude.package /\ unsafeRange ">=1.0.0 <2.0.0" ] - [ { error: WhileSolving (package "simple") $ Map.singleton (version 0) $ Conflicts $ Map.fromFoldable - [ package "prelude" `Tuple` intersection 1 (solveRoot "prelude") 1 (via "simple" 0 []) + [ { error: WhileSolving (unsafePackageName "simple") $ Map.singleton (unsafeVersion "0.0.0") $ Conflicts $ Map.fromFoldable + [ unsafePackageName "prelude" `Tuple` intersection 1 (solveRoot "prelude") 1 (via "simple" 0 []) ] , message: "While solving simple each version could not be solved:\n- 0.0.0: \n Conflict in version ranges for prelude:\n >=1.0.0 (declared dependency)\n <1.0.0 seen in simple@0.0.0" } @@ -217,11 +217,11 @@ spec = do Spec.it "Fails on disjoint ranges" do shouldFail - [ brokenFixed.package /\ range 0 1 - , fixedBroken.package /\ range 2 3 + [ brokenFixed.package /\ unsafeRange ">=0.0.0 <1.0.0" + , fixedBroken.package /\ unsafeRange ">=2.0.0 <3.0.0" ] [ { error: Conflicts $ Map.fromFoldable - [ package "does-not-exist" `Tuple` intersection 0 (via' "broken-fixed" 0 <> via' "fixed-broken" 2) 4 (via' "broken-fixed" 0) + [ unsafePackageName "does-not-exist" `Tuple` intersection 0 (via' "broken-fixed" 0 <> via' "fixed-broken" 2) 4 (via' "broken-fixed" 0) ] , message: "No versions found in the registry for does-not-exist in range\n >=0.0.0 seen in broken-fixed@0.0.0, fixed-broken@2.0.0\n <4.0.0 seen in broken-fixed@0.0.0" } @@ -230,16 +230,16 @@ spec = do Spec.describe "Reports multiple errors" do Spec.it "Reports different errors for different versions" do shouldFail - [ chaotic.package /\ range 1 3 - , prelude.package /\ range 2 4 + [ chaotic.package /\ unsafeRange ">=1.0.0 <3.0.0" + , prelude.package /\ unsafeRange ">=2.0.0 <4.0.0" ] - [ { error: WhileSolving (package "chaotic") $ Map.fromFoldable - [ Tuple (version 1) $ Conflicts $ Map.fromFoldable - [ package "prelude" `Tuple` intersection 2 (solveRoot "prelude") 1 (via "chaotic" 1 []) + [ { error: WhileSolving (unsafePackageName "chaotic") $ Map.fromFoldable + [ Tuple (unsafeVersion "1.0.0") $ Conflicts $ Map.fromFoldable + [ unsafePackageName "prelude" `Tuple` intersection 2 (solveRoot "prelude") 1 (via "chaotic" 1 []) ] - , Tuple (version 2) $ Conflicts $ Map.fromFoldable + , Tuple (unsafeVersion "2.0.0") $ Conflicts $ Map.fromFoldable -- TODO: why no global "chaotic" here? - [ package "prelude" `Tuple` intersection 5 (via "chaotic" 2 []) 4 (solveRoot "prelude") + [ unsafePackageName "prelude" `Tuple` intersection 5 (via "chaotic" 2 []) 4 (solveRoot "prelude") ] ] , message: "While solving chaotic each version could not be solved:\n- 1.0.0: \n Conflict in version ranges for prelude:\n >=2.0.0 (declared dependency)\n <1.0.0 seen in chaotic@1.0.0\n- 2.0.0: \n Conflict in version ranges for prelude:\n >=5.0.0 seen in chaotic@2.0.0\n <4.0.0 (declared dependency)" @@ -248,20 +248,20 @@ spec = do Spec.it "Groups same errors" do shouldFail - [ chaotic.package /\ range 1 4 - , prelude.package /\ range 2 4 + [ chaotic.package /\ unsafeRange ">=1.0.0 <4.0.0" + , prelude.package /\ unsafeRange ">=2.0.0 <4.0.0" ] - [ { error: WhileSolving (package "chaotic") $ Map.fromFoldable - [ Tuple (version 1) $ Conflicts $ Map.fromFoldable - [ package "prelude" `Tuple` intersection 2 (solveRoot "prelude") 1 (via "chaotic" 1 []) + [ { error: WhileSolving (unsafePackageName "chaotic") $ Map.fromFoldable + [ Tuple (unsafeVersion "1.0.0") $ Conflicts $ Map.fromFoldable + [ unsafePackageName "prelude" `Tuple` intersection 2 (solveRoot "prelude") 1 (via "chaotic" 1 []) ] - , Tuple (version 2) $ Conflicts $ Map.fromFoldable + , Tuple (unsafeVersion "2.0.0") $ Conflicts $ Map.fromFoldable -- TODO: why no global "chaotic" here? - [ package "prelude" `Tuple` intersection 5 (via "chaotic" 2 []) 4 (solveRoot "prelude") + [ unsafePackageName "prelude" `Tuple` intersection 5 (via "chaotic" 2 []) 4 (solveRoot "prelude") ] - , Tuple (version 3) $ Conflicts $ Map.fromFoldable + , Tuple (unsafeVersion "3.0.0") $ Conflicts $ Map.fromFoldable -- TODO: why no global "chaotic" here? - [ package "prelude" `Tuple` intersection 5 (via "chaotic" 3 []) 4 (solveRoot "prelude") + [ unsafePackageName "prelude" `Tuple` intersection 5 (via "chaotic" 3 []) 4 (solveRoot "prelude") ] ] , message: "While solving chaotic each version could not be solved:\n- 1.0.0: \n Conflict in version ranges for prelude:\n >=2.0.0 (declared dependency)\n <1.0.0 seen in chaotic@1.0.0\n- 2.0.0: \n Conflict in version ranges for prelude:\n >=5.0.0 seen in chaotic@2.0.0\n <4.0.0 (declared dependency)\n- 3.0.0: \n Conflict in version ranges for prelude:\n >=5.0.0 seen in chaotic@3.0.0\n <4.0.0 (declared dependency)" @@ -271,16 +271,16 @@ spec = do -- since we try packages in alphabetical order Spec.it "Reports different errors for different versions (converse)" do shouldFail - [ qaotic.package /\ range 1 3 - , prelude.package /\ range 2 4 + [ qaotic.package /\ unsafeRange ">=1.0.0 <3.0.0" + , prelude.package /\ unsafeRange ">=2.0.0 <4.0.0" ] - [ { error: WhileSolving (package "qaotic") $ Map.fromFoldable - [ Tuple (version 1) $ Conflicts $ Map.fromFoldable - [ package "prelude" `Tuple` intersection 2 (solveRoot "prelude") 1 (via "qaotic" 1 []) + [ { error: WhileSolving (unsafePackageName "qaotic") $ Map.fromFoldable + [ Tuple (unsafeVersion "1.0.0") $ Conflicts $ Map.fromFoldable + [ unsafePackageName "prelude" `Tuple` intersection 2 (solveRoot "prelude") 1 (via "qaotic" 1 []) ] - , Tuple (version 2) $ Conflicts $ Map.fromFoldable + , Tuple (unsafeVersion "2.0.0") $ Conflicts $ Map.fromFoldable -- TODO: why no global "prelude" here? - [ package "prelude" `Tuple` intersection 5 (via "qaotic" 2 []) 4 (solveRoot "prelude") + [ unsafePackageName "prelude" `Tuple` intersection 5 (via "qaotic" 2 []) 4 (solveRoot "prelude") ] ] , message: "While solving qaotic each version could not be solved:\n- 1.0.0: \n Conflict in version ranges for prelude:\n >=2.0.0 (declared dependency)\n <1.0.0 seen in qaotic@1.0.0\n- 2.0.0: \n Conflict in version ranges for prelude:\n >=5.0.0 seen in qaotic@2.0.0\n <4.0.0 (declared dependency)" @@ -291,42 +291,42 @@ spec = do Spec.it "Excludes broken dependencies belonging only to rejected versions" do let index = Map.fromFoldable - [ package "a" /\ Map.fromFoldable - [ version 1 /\ Map.singleton (package "orphan") (range 1 2) - , version 2 /\ Map.empty + [ unsafePackageName "a" /\ Map.fromFoldable + [ unsafeVersion "1.0.0" /\ Map.singleton (unsafePackageName "orphan") (unsafeRange ">=1.0.0 <2.0.0") + , unsafeVersion "2.0.0" /\ Map.empty ] - , package "orphan" /\ Map.singleton (version 1) (Map.singleton (package "missing") (range 1 2)) + , unsafePackageName "orphan" /\ Map.singleton (unsafeVersion "1.0.0") (Map.singleton (unsafePackageName "missing") (unsafeRange ">=1.0.0 <2.0.0")) ] - solve index (Map.singleton (package "a") (range 1 3)) - `Assert.shouldContain` Map.singleton (package "a") (version 2) + solve index (Map.singleton (unsafePackageName "a") (unsafeRange ">=1.0.0 <3.0.0")) + `Assert.shouldContain` Map.singleton (unsafePackageName "a") (unsafeVersion "2.0.0") Spec.it "Reports both originally requested packages and the package version directly requiring the missing dependency" do let index = Map.fromFoldable - [ package "a" /\ Map.singleton (version 1) (Map.singleton (package "middle") (range 2 5)) - , package "b" /\ Map.singleton (version 1) (Map.singleton (package "middle") (range 1 4)) - , package "middle" /\ Map.singleton (version 2) (Map.singleton (package "missing") (range 1 2)) + [ unsafePackageName "a" /\ Map.singleton (unsafeVersion "1.0.0") (Map.singleton (unsafePackageName "middle") (unsafeRange ">=2.0.0 <5.0.0")) + , unsafePackageName "b" /\ Map.singleton (unsafeVersion "1.0.0") (Map.singleton (unsafePackageName "middle") (unsafeRange ">=1.0.0 <4.0.0")) + , unsafePackageName "middle" /\ Map.singleton (unsafeVersion "2.0.0") (Map.singleton (unsafePackageName "missing") (unsafeRange ">=1.0.0 <2.0.0")) ] - goals = Map.fromFoldable [ package "a" /\ range 1 2, package "b" /\ range 1 2 ] + goals = Map.fromFoldable [ unsafePackageName "a" /\ unsafeRange ">=1.0.0 <2.0.0", unsafePackageName "b" /\ unsafeRange ">=1.0.0 <2.0.0" ] source = via "middle" 2 [ "a", "b" ] solve index goals `Assert.shouldEqual` - Left (pure (Conflicts (Map.singleton (package "missing") (intersection 1 source 2 source)))) + Left (pure (Conflicts (Map.singleton (unsafePackageName "missing") (intersection 1 source 2 source)))) Spec.it "Prefers the alphabetically first package, not the largest total versions" do let index = Map.fromFoldable - [ package "a" /\ Map.fromFoldable - [ version 1 /\ Map.singleton (package "z") (range 1 2) - , version 2 /\ Map.singleton (package "z") (range 2 3) + [ unsafePackageName "a" /\ Map.fromFoldable + [ unsafeVersion "1.0.0" /\ Map.singleton (unsafePackageName "z") (unsafeRange ">=1.0.0 <2.0.0") + , unsafeVersion "2.0.0" /\ Map.singleton (unsafePackageName "z") (unsafeRange ">=2.0.0 <3.0.0") ] - , package "b" /\ Map.fromFoldable - [ version 1 /\ Map.singleton (package "z") (range 2 3) - , version 9 /\ Map.singleton (package "z") (range 1 2) + , unsafePackageName "b" /\ Map.fromFoldable + [ unsafeVersion "1.0.0" /\ Map.singleton (unsafePackageName "z") (unsafeRange ">=2.0.0 <3.0.0") + , unsafeVersion "9.0.0" /\ Map.singleton (unsafePackageName "z") (unsafeRange ">=1.0.0 <2.0.0") ] - , package "z" /\ Map.fromFoldable [ version 1 /\ Map.empty, version 2 /\ Map.empty ] + , unsafePackageName "z" /\ Map.fromFoldable [ unsafeVersion "1.0.0" /\ Map.empty, unsafeVersion "2.0.0" /\ Map.empty ] ] - solve index (Map.fromFoldable [ package "a" /\ range 1 3, package "b" /\ range 1 10 ]) - `Assert.shouldContain` Map.fromFoldable [ package "a" /\ version 2, package "b" /\ version 1, package "z" /\ version 2 ] + solve index (Map.fromFoldable [ unsafePackageName "a" /\ unsafeRange ">=1.0.0 <3.0.0", unsafePackageName "b" /\ unsafeRange ">=1.0.0 <10.0.0" ]) + `Assert.shouldContain` Map.fromFoldable [ unsafePackageName "a" /\ unsafeVersion "2.0.0", unsafePackageName "b" /\ unsafeVersion "1.0.0", unsafePackageName "z" /\ unsafeVersion "2.0.0" ] Spec.it "Matches exhaustive assignments on 4096 small cyclic version graphs" do -- Each of six package versions independently has no dependency, an exact @@ -334,15 +334,15 @@ spec = do -- three-package ring. Enumerating assignments (including absent packages) -- is independent of the solver's propagation and backtracking algorithm. let - names = map package [ "a", "b", "c" ] + names = map unsafePackageName [ "a", "b", "c" ] assignments = do - a <- [ Nothing, Just (version 1), Just (version 2) ] - b <- [ Nothing, Just (version 1), Just (version 2) ] - c <- [ Nothing, Just (version 1), Just (version 2) ] + a <- [ Nothing, Just (unsafeVersion "1.0.0"), Just (unsafeVersion "2.0.0") ] + b <- [ Nothing, Just (unsafeVersion "1.0.0"), Just (unsafeVersion "2.0.0") ] + c <- [ Nothing, Just (unsafeVersion "1.0.0"), Just (unsafeVersion "2.0.0") ] pure $ Map.fromFoldable $ Array.catMaybes $ Array.zipWith (\p -> map (Tuple p)) names [ a, b, c ] - goals = Map.singleton (package "a") (range 1 3) + goals = Map.singleton (unsafePackageName "a") (unsafeRange ">=1.0.0 <3.0.0") satisfies plan deps = all ( \(Tuple p bounds) -> case Map.lookup p plan of @@ -363,14 +363,14 @@ spec = do versionEntry i j = do let digit = (seed / Int.pow 4 (i * 2 + j - 1)) `mod` 4 - next = package (if i == 0 then "b" else if i == 1 then "c" else "a") + next = unsafePackageName (if i == 0 then "b" else if i == 1 then "c" else "a") deps = case digit of 0 -> Map.empty - 1 -> Map.singleton next (range 1 2) - 2 -> Map.singleton next (range 2 3) - _ -> Map.singleton next (range 1 3) + 1 -> Map.singleton next (unsafeRange ">=1.0.0 <2.0.0") + 2 -> Map.singleton next (unsafeRange ">=2.0.0 <3.0.0") + _ -> Map.singleton next (unsafeRange ">=1.0.0 <3.0.0") - version j /\ deps + majorVersion j /\ deps versions i = Map.fromFoldable $ map (versionEntry i) [ 1, 2 ] @@ -437,8 +437,8 @@ spec = do (\i error -> "[Error " <> show (i + 1) <> "] " <> printSolverError error <> "\n") errs Right (Tuple _solvedCompiler resolutions) -> do - Map.lookup (package "prelude") resolutions `Assert.shouldEqual` Just (unsafeVersion "6.0.1") - Map.lookup (package "effect") resolutions `Assert.shouldEqual` Just (unsafeVersion "4.0.0") + Map.lookup (unsafePackageName "prelude") resolutions `Assert.shouldEqual` Just (unsafeVersion "6.0.1") + Map.lookup (unsafePackageName "effect") resolutions `Assert.shouldEqual` Just (unsafeVersion "4.0.0") Spec.it "Prefers the newer package version unless the compiler requirement forces an older one" do let @@ -451,10 +451,10 @@ spec = do , unsafeMetadata "my-pkg" [ Tuple "1.0.0" [ "0.15.15" ] ] ] index = buildCompilerIndex compilers manifestIndex metadata - goals = Map.singleton (package "effect") (unsafeRange ">=4.0.0 <6.0.0") + goals = Map.singleton (unsafePackageName "effect") (unsafeRange ">=4.0.0 <6.0.0") expected compiler effect = Tuple (unsafeVersion compiler) $ Map.fromFoldable - [ package "effect" /\ unsafeVersion effect - , package "prelude" /\ unsafeVersion "6.0.1" + [ unsafePackageName "effect" /\ unsafeVersion effect + , unsafePackageName "prelude" /\ unsafeVersion "6.0.1" ] solveWithCompiler (Range.exact (unsafeVersion "0.15.16")) index goals `Assert.shouldContain` expected "0.15.16" "4.0.0" @@ -479,7 +479,7 @@ spec = do updatedMetadata = Map.fromFoldable [ unsafeMetadata "prelude" [ Tuple "6.0.1" [ "0.15.15" ] ] - , Tuple (package "effect") updatedEffectMeta + , Tuple (unsafePackageName "effect") updatedEffectMeta , unsafeMetadata "my-pkg" [ Tuple "1.0.0" [ "0.15.15" ] ] ] fullNew = buildCompilerIndex compilers manifestIndex updatedMetadata @@ -519,120 +519,105 @@ type TestPackage = -- | Transitively depends on a broken range transitiveBroken :: TestPackage transitiveBroken = - { package: package "transitive-broken" + { package: unsafePackageName "transitive-broken" , versions: Map.fromFoldable - [ version 0 /\ Just (fixedBroken.package /\ range 2 3) - , version 1 /\ Just (brokenFixed.package /\ range 0 1) + [ unsafeVersion "0.0.0" /\ Just (fixedBroken.package /\ unsafeRange ">=2.0.0 <3.0.0") + , unsafeVersion "1.0.0" /\ Just (brokenFixed.package /\ unsafeRange ">=0.0.0 <1.0.0") ] } -- | Broken at v1 and v2 brokenBroken :: TestPackage brokenBroken = - { package: package "broken-broken" + { package: unsafePackageName "broken-broken" , versions: Map.fromFoldable - [ version 0 /\ Just (package "does-not-exist" /\ range 0 5) - , version 1 /\ Just (package "does-not-exist" /\ range 0 5) + [ unsafeVersion "0.0.0" /\ Just (unsafePackageName "does-not-exist" /\ unsafeRange ">=0.0.0 <5.0.0") + , unsafeVersion "1.0.0" /\ Just (unsafePackageName "does-not-exist" /\ unsafeRange ">=0.0.0 <5.0.0") ] } -- | Fixed at v0, v1, broken at v2 fixedBroken :: TestPackage fixedBroken = - { package: package "fixed-broken" + { package: unsafePackageName "fixed-broken" , versions: Map.fromFoldable - [ version 0 /\ Just (prelude.package /\ range 0 1) - , version 1 /\ Just (prelude.package /\ range 1 2) - , version 2 /\ Just (package "does-not-exist" /\ range 0 5) + [ unsafeVersion "0.0.0" /\ Just (prelude.package /\ unsafeRange ">=0.0.0 <1.0.0") + , unsafeVersion "1.0.0" /\ Just (prelude.package /\ unsafeRange ">=1.0.0 <2.0.0") + , unsafeVersion "2.0.0" /\ Just (unsafePackageName "does-not-exist" /\ unsafeRange ">=0.0.0 <5.0.0") ] } -- | Broken at v0, fixed at v1 brokenFixed :: TestPackage brokenFixed = - { package: package "broken-fixed" + { package: unsafePackageName "broken-fixed" , versions: Map.fromFoldable - [ version 0 /\ Just (package "does-not-exist" /\ range 0 4) - , version 1 /\ Just (prelude.package /\ range 0 1) + [ unsafeVersion "0.0.0" /\ Just (unsafePackageName "does-not-exist" /\ unsafeRange ">=0.0.0 <4.0.0") + , unsafeVersion "1.0.0" /\ Just (prelude.package /\ unsafeRange ">=0.0.0 <1.0.0") ] } onlySimple :: TestPackage onlySimple = - { package: package "only-simple" - , versions: Map.singleton (version 0) (Just (simple.package /\ range 0 1)) + { package: unsafePackageName "only-simple" + , versions: Map.singleton (unsafeVersion "0.0.0") (Just (simple.package /\ unsafeRange ">=0.0.0 <1.0.0")) } simple :: TestPackage simple = - { package: package "simple" + { package: unsafePackageName "simple" , versions: Map.fromFoldable - [ version 0 /\ Just (prelude.package /\ range 0 1) - , version 1 /\ Just (prelude.package /\ range 1 2) + [ unsafeVersion "0.0.0" /\ Just (prelude.package /\ unsafeRange ">=0.0.0 <1.0.0") + , unsafeVersion "1.0.0" /\ Just (prelude.package /\ unsafeRange ">=1.0.0 <2.0.0") ] } prelude :: TestPackage prelude = - { package: package "prelude" + { package: unsafePackageName "prelude" , versions: Map.fromFoldable - [ version 0 /\ Nothing - , version 1 /\ Nothing - , version 2 /\ Nothing - , version 3 /\ Nothing - , version 4 /\ Nothing - , version 5 /\ Nothing + [ unsafeVersion "0.0.0" /\ Nothing + , unsafeVersion "1.0.0" /\ Nothing + , unsafeVersion "2.0.0" /\ Nothing + , unsafeVersion "3.0.0" /\ Nothing + , unsafeVersion "4.0.0" /\ Nothing + , unsafeVersion "5.0.0" /\ Nothing ] } chaotic :: TestPackage chaotic = - { package: package "chaotic" + { package: unsafePackageName "chaotic" , versions: Map.fromFoldable - [ version 1 /\ Just (prelude.package /\ range 0 1) - , version 2 /\ Just (prelude.package /\ range 5 6) - , version 3 /\ Just (prelude.package /\ range 5 6) + [ unsafeVersion "1.0.0" /\ Just (prelude.package /\ unsafeRange ">=0.0.0 <1.0.0") + , unsafeVersion "2.0.0" /\ Just (prelude.package /\ unsafeRange ">=5.0.0 <6.0.0") + , unsafeVersion "3.0.0" /\ Just (prelude.package /\ unsafeRange ">=5.0.0 <6.0.0") ] } qaotic :: TestPackage qaotic = - { package: package "qaotic" + { package: unsafePackageName "qaotic" , versions: Map.fromFoldable - [ version 1 /\ Just (prelude.package /\ range 0 1) - , version 2 /\ Just (prelude.package /\ range 5 6) + [ unsafeVersion "1.0.0" /\ Just (prelude.package /\ unsafeRange ">=0.0.0 <1.0.0") + , unsafeVersion "2.0.0" /\ Just (prelude.package /\ unsafeRange ">=5.0.0 <6.0.0") ] } -package :: String -> PackageName -package = unsafePackageName - -version :: Int -> Version -version = unsafeVersion <<< (_ <> ".0.0") <<< show - --- For all these tests, we work with major versions only because we do not --- need to exercise the intricacies of the range relations, just the solver, --- which does not care about what versions are, just how they relate -range :: Int -> Int -> Range -range lower upper = unsafeRange $ Array.fold - [ ">=" - , show lower - , ".0.0 <" - , show upper - , ".0.0" - ] +majorVersion :: Int -> Version +majorVersion = unsafeVersion <<< (_ <> ".0.0") <<< show intersection :: Int -> SolverPosition -> Int -> SolverPosition -> Intersection intersection lower lowerPos upper upperPos = Intersection - { lower: wrap $ Sourced (version lower) lowerPos - , upper: wrap $ Sourced (version upper) upperPos + { lower: wrap $ Sourced (majorVersion lower) lowerPos + , upper: wrap $ Sourced (majorVersion upper) upperPos } solveRoot :: String -> SolverPosition -solveRoot = Pos Root <<< Set.singleton <<< package +solveRoot = Pos Root <<< Set.singleton <<< unsafePackageName via :: String -> Int -> Array String -> SolverPosition -via p v = Pos (Solving (NES.singleton { package: package p, version: version v })) <<< Set.fromFoldable <<< map package +via p v = Pos (Solving (NES.singleton { package: unsafePackageName p, version: majorVersion v })) <<< Set.fromFoldable <<< map unsafePackageName via' :: String -> Int -> SolverPosition -via' p v = Pos (Solving (NES.singleton { package: package p, version: version v })) (Set.singleton (package p)) +via' p v = Pos (Solving (NES.singleton { package: unsafePackageName p, version: majorVersion v })) (Set.singleton (unsafePackageName p))