From c74c868080936db712fde8d74ff718fea0ea9706 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 16 May 2026 11:01:03 +0000 Subject: [PATCH 1/4] Initial plan From 5c729dd024ce2c6bc48c07a674c7561260aab524 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 16 May 2026 11:10:38 +0000 Subject: [PATCH 2/4] feat(python): expose resolved_ephemerals on SolveResult Agent-Logs-Url: https://github.com/doubleailes/rer/sessions/ca234794-b68f-4bbf-832b-9be9ee11ca99 Co-authored-by: doubleailes <23233470+doubleailes@users.noreply.github.com> --- crates/rer-python/src/lib.rs | 18 ++++++ crates/rer-resolver/src/rez_solver/phase.rs | 10 +++ crates/rer-resolver/src/rez_solver/solver.rs | 67 ++++++++++++++++++++ tests/test_rich_api.py | 43 +++++++++++++ 4 files changed, 138 insertions(+) diff --git a/crates/rer-python/src/lib.rs b/crates/rer-python/src/lib.rs index 90f762a..ce1d10f 100644 --- a/crates/rer-python/src/lib.rs +++ b/crates/rer-python/src/lib.rs @@ -194,6 +194,14 @@ pub struct SolveResult { /// Number of solve steps the solver performed. #[pyo3(get)] pub num_iterations: u32, + /// Resolved ephemerals as rez-style requirement strings, e.g. + /// `[".feature-1.5", ".mode-debug"]`. Each entry is the intersected + /// range of every ephemeral (`.foo`) requirement that participated in + /// the solve. Empty when no ephemerals were involved, and always empty + /// for `"failed"` / `"error"` results. Mirrors rez's + /// `Solver.resolved_ephemerals`. + #[pyo3(get)] + pub resolved_ephemerals: Vec, } #[pymethods] @@ -218,6 +226,7 @@ impl SolveResult { failure_description: Some(message), solve_time_ms: start.elapsed().as_secs_f64() * 1000.0, num_iterations: 0, + resolved_ephemerals: Vec::new(), } } } @@ -338,6 +347,7 @@ fn solve( failure_description: Some(scope_err.to_string()), solve_time_ms: start.elapsed().as_secs_f64() * 1000.0, num_iterations: 0, + resolved_ephemerals: Vec::new(), }); } Err(_) => { @@ -376,6 +386,12 @@ fn solve( .iter() .map(|rv| (rv.name.clone(), rv.version.clone(), rv.variant_index)) .collect(); + let resolved_ephemerals: Vec = solver + .resolved_ephemerals() + .unwrap_or_default() + .iter() + .map(|r| r.to_string()) + .collect(); SolveResult { status: "solved".to_string(), resolved_packages, @@ -383,6 +399,7 @@ fn solve( failure_description: None, solve_time_ms, num_iterations, + resolved_ephemerals, } } SolverStatus::Failed => SolveResult { @@ -392,6 +409,7 @@ fn solve( failure_description: solver.failure_description(), solve_time_ms, num_iterations, + resolved_ephemerals: Vec::new(), }, other => SolveResult::error(format!("unexpected solver status: {other:?}"), start), }) diff --git a/crates/rer-resolver/src/rez_solver/phase.rs b/crates/rer-resolver/src/rez_solver/phase.rs index a890c87..9c826bd 100644 --- a/crates/rer-resolver/src/rez_solver/phase.rs +++ b/crates/rer-resolver/src/rez_solver/phase.rs @@ -499,6 +499,16 @@ impl ResolvePhase { .filter_map(|s| Rc::make_mut(s).get_solved_variant()) .collect() } + + /// The intersected requirement of each ephemeral (`.foo`) scope in this + /// (solved) phase, in scope order. Mirrors rez's + /// `_ResolvePhase.get_resolved_ephemerals`. + pub fn solved_ephemerals(&self) -> Vec { + self.scopes + .iter() + .filter_map(|s| s.get_solved_ephemeral().cloned()) + .collect() + } } impl std::fmt::Display for ResolvePhase { diff --git a/crates/rer-resolver/src/rez_solver/solver.rs b/crates/rer-resolver/src/rez_solver/solver.rs index 68aa468..c7a435d 100644 --- a/crates/rer-resolver/src/rez_solver/solver.rs +++ b/crates/rer-resolver/src/rez_solver/solver.rs @@ -201,6 +201,16 @@ impl Solver { Some(self.phase_stack.last().unwrap().solved_variants()) } + /// The resolved ephemerals (intersected ranges of every `.foo` requirement + /// that participated in the solve), or `None` if the solve did not + /// succeed. Mirrors rez's `Solver.resolved_ephemerals`. + pub fn resolved_ephemerals(&self) -> Option> { + if self.status() != SolverStatus::Solved { + return None; + } + Some(self.phase_stack.last().unwrap().solved_ephemerals()) + } + /// A human-readable failure description, or `None` if the solve did not /// fail. Mirrors the gist of rez's `Solver.failure_description`. pub fn failure_description(&self) -> Option { @@ -405,6 +415,63 @@ mod tests { assert_eq!(resolved_set(&solver), vec![("foo".into(), "1.0".into())]); } + #[test] + fn test_resolved_ephemerals_intersect_request_only() { + // Two ephemerals on the request intersect to the narrower range. + let solver = solve( + repo(vec![("foo", vec![("1.0", pkg(&[], &[]))])]), + &["foo", ".feature-1+<3", ".feature-2+"], + ); + assert_eq!(solver.status(), SolverStatus::Solved); + let eph: Vec = solver + .resolved_ephemerals() + .expect("solved => Some") + .iter() + .map(|r| r.to_string()) + .collect(); + assert_eq!(eph, vec![".feature-2+<3".to_string()]); + } + + #[test] + fn test_resolved_ephemerals_from_package_requires() { + // The ephemeral is contributed by a resolved package's `requires`. + let solver = solve( + repo(vec![("app", vec![("1.0", pkg(&[".mode-debug"], &[]))])]), + &["app"], + ); + assert_eq!(solver.status(), SolverStatus::Solved); + let eph: Vec = solver + .resolved_ephemerals() + .expect("solved => Some") + .iter() + .map(|r| r.to_string()) + .collect(); + assert_eq!(eph, vec![".mode-debug".to_string()]); + } + + #[test] + fn test_resolved_ephemerals_empty_when_none_in_solve() { + let solver = solve(repo(vec![("foo", vec![("1.0", pkg(&[], &[]))])]), &["foo"]); + assert_eq!(solver.status(), SolverStatus::Solved); + assert_eq!( + solver.resolved_ephemerals().expect("solved => Some"), + Vec::::new() + ); + } + + #[test] + fn test_resolved_ephemerals_none_when_not_solved() { + let solver = solve( + repo(vec![( + "foo", + vec![("1.0", pkg(&[], &[])), ("2.0", pkg(&[], &[]))], + )]), + &["foo-1", "foo-2"], + ); + assert_eq!(solver.status(), SolverStatus::Failed); + assert!(solver.resolved_ephemerals().is_none()); + } + #[test] fn test_variant_selection() { // foo has two variants; the solver should pick one and resolve its deps. diff --git a/tests/test_rich_api.py b/tests/test_rich_api.py index 03f4093..c48e727 100644 --- a/tests/test_rich_api.py +++ b/tests/test_rich_api.py @@ -137,6 +137,49 @@ def test_solveresult_repr_uses_resolved_packages_count(): assert "SolveResult" in r and "1 packages" in r +# --------------------------------------------------------------------------- +# resolved_ephemerals — rez's Solver.resolved_ephemerals +# --------------------------------------------------------------------------- + + +def test_resolved_ephemerals_empty_for_solve_without_ephemerals(): + result = pyrer.solve(["foo"], [_pkg("foo", "1.0.0")]) + assert result.status == "solved" + assert result.resolved_ephemerals == [] + + +def test_resolved_ephemerals_request_only(): + """A `.foo` requirement on the request should surface intersected.""" + result = pyrer.solve([".feature-1"], []) + assert result.status == "solved" + assert result.resolved_ephemerals == [".feature-1"] + + +def test_resolved_ephemerals_intersection_across_request(): + result = pyrer.solve( + ["foo", ".feature-1+<3", ".feature-2+"], + [_pkg("foo", "1.0.0")], + ) + assert result.status == "solved" + assert result.resolved_ephemerals == [".feature-2+<3"] + + +def test_resolved_ephemerals_from_package_requires(): + """An ephemeral contributed by a resolved package's `requires`.""" + result = pyrer.solve( + ["app"], + [_pkg("app", "1.0.0", requires=[".mode-debug"])], + ) + assert result.status == "solved" + assert result.resolved_ephemerals == [".mode-debug"] + + +def test_resolved_ephemerals_empty_on_failure(): + result = pyrer.solve([".foo-1", ".foo-2"], []) + assert result.status == "failed" + assert result.resolved_ephemerals == [] + + # --------------------------------------------------------------------------- # PackageData.from_rez — duck-typed convenience for rez integration # --------------------------------------------------------------------------- From 7cc029aa52c42db9116545360cdfb3f31eefcb6d Mon Sep 17 00:00:00 2001 From: Philippe Llerena Date: Sat, 16 May 2026 16:24:11 +0200 Subject: [PATCH 3/4] feat(resolver): add borrowing-iterator forms for resolved packages/ephemerals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Vec-returning accessors (`Solver::resolved_packages` / `resolved_ephemerals`, `ResolvePhase::solved_variants` / `solved_ephemerals`) force callers that only need to iterate to pay for an intermediate `Vec` plus, for ephemerals, an owning clone of each `Requirement`. Add borrowing-iterator siblings so future consumers can stream without allocating. New API, additive — every existing Vec method keeps its signature: Solver::resolved_packages_iter() -> Option>> Solver::resolved_ephemerals_iter() -> Option> ResolvePhase::iter_solved_variants() -> impl Iterator> ResolvePhase::iter_solved_ephemerals() -> impl Iterator `iter_solved_variants` still has to clone `self.scopes` internally because `get_solved_variant` takes `&mut self` (it triggers a deferred sort on the variant slice); the saving vs the Vec form is the trailing `.collect()`. `iter_solved_ephemerals` is the bigger win — pure borrow, zero allocation, no per-element clone. Refactor the existing Vec methods to delegate to the iter forms so there's one implementation of the filter logic. ## pyrer wired through `crates/rer-python/src/lib.rs` switches its `SolveResult` build to use `resolved_packages_iter` / `resolved_ephemerals_iter`. Two intermediate `Vec`s skipped per solved result; for ephemerals every entry is now read by reference instead of cloned then stringified. ## Tests - `test_iter_resolved_packages_matches_vec_form` and `test_iter_resolved_ephemerals_matches_vec_form` in `solver::tests` — confirm iter and Vec forms agree on the same input and that the iter form returns `None` on a failed solve. - Existing 5 Python tests for `resolved_ephemerals` still pass (the FFI surface is unchanged; just the implementation under it). ## Verification - `cargo test` (Rust): 41/41 unit tests pass (was 39 + 2 new). - `cargo test … --ignored` (188-case differential): 188/188 still match rez 1:1 in 17.68 s. - `pytest tests/` (all Python): 80/80. ## Perf (188-case benchmark, same machine as README reference) | | Total | Mean | Median | |---|---:|---:|---:| | README reference (post-#73) | 11.35 s | 60 ms | 30 ms | | This branch, pre iter-forms (run 1) | 11.19 s | 60 ms | 28 ms | | This branch, pre iter-forms (run 2) | 11.27 s | 60 ms | 33 ms | | This branch, post iter-forms (run 1) | 10.90 s | 58 ms | 28 ms | | This branch, post iter-forms (run 2) | 11.16 s | 59 ms | 30 ms | Within run-to-run noise; if anything a slight improvement from the avoided allocations. No regression. Co-Authored-By: Claude Opus 4.7 --- crates/rer-python/src/lib.rs | 50 ++++++------ crates/rer-resolver/src/rez_solver/phase.rs | 36 +++++++-- crates/rer-resolver/src/rez_solver/solver.rs | 83 ++++++++++++++++++++ 3 files changed, 138 insertions(+), 31 deletions(-) diff --git a/crates/rer-python/src/lib.rs b/crates/rer-python/src/lib.rs index ce1d10f..78a195d 100644 --- a/crates/rer-python/src/lib.rs +++ b/crates/rer-python/src/lib.rs @@ -363,35 +363,39 @@ fn solve( Ok(match solver.status() { SolverStatus::Solved => { - let variants = solver.resolved_packages().unwrap_or_default(); - let resolved_packages: Vec = variants - .iter() - .map(|v| ResolvedVariant { - name: v.name().to_string(), - version: v.version().to_string(), - variant_index: v.index(), - requires: v - .requires() - .requirements() - .iter() - .map(|r| r.to_string()) - .collect(), - uri: match v.index() { - Some(idx) => format!("{}/{}/package.py[{}]", v.name(), v.version(), idx), - None => format!("{}/{}/package.py", v.name(), v.version()), - }, + // Use the borrowing iterator forms — no intermediate Vec, and + // ephemerals are streamed as `&Requirement` rather than cloned. + let resolved_packages: Vec = solver + .resolved_packages_iter() + .map(|it| { + it.map(|v| ResolvedVariant { + name: v.name().to_string(), + version: v.version().to_string(), + variant_index: v.index(), + requires: v + .requires() + .requirements() + .iter() + .map(|r| r.to_string()) + .collect(), + uri: match v.index() { + Some(idx) => { + format!("{}/{}/package.py[{}]", v.name(), v.version(), idx) + } + None => format!("{}/{}/package.py", v.name(), v.version()), + }, + }) + .collect() }) - .collect(); + .unwrap_or_default(); let resolved: Vec<(String, String, Option)> = resolved_packages .iter() .map(|rv| (rv.name.clone(), rv.version.clone(), rv.variant_index)) .collect(); let resolved_ephemerals: Vec = solver - .resolved_ephemerals() - .unwrap_or_default() - .iter() - .map(|r| r.to_string()) - .collect(); + .resolved_ephemerals_iter() + .map(|it| it.map(|r| r.to_string()).collect()) + .unwrap_or_default(); SolveResult { status: "solved".to_string(), resolved_packages, diff --git a/crates/rer-resolver/src/rez_solver/phase.rs b/crates/rer-resolver/src/rez_solver/phase.rs index 9c826bd..23347bc 100644 --- a/crates/rer-resolver/src/rez_solver/phase.rs +++ b/crates/rer-resolver/src/rez_solver/phase.rs @@ -493,21 +493,41 @@ impl ResolvePhase { /// The solved variants of this (solved) phase, in scope order. pub fn solved_variants(&self) -> Vec> { + self.iter_solved_variants().collect() + } + + /// Borrowing iterator form of [`Self::solved_variants`]. Lets callers + /// iterate the resolved variants without allocating an intermediate `Vec`. + /// Internally still clones the scope vec because `get_solved_variant` + /// takes `&mut self` (it triggers a deferred sort inside the variant + /// slice); the saving vs `solved_variants` is the trailing `.collect()`. + /// + /// The yielded `Rc` are refcount bumps — cheap. + pub fn iter_solved_variants(&self) -> impl Iterator> + '_ { let mut scopes = self.scopes.clone(); - scopes - .iter_mut() - .filter_map(|s| Rc::make_mut(s).get_solved_variant()) - .collect() + std::iter::from_fn(move || loop { + let scope = scopes.first_mut()?; + let variant = Rc::make_mut(scope).get_solved_variant(); + scopes.remove(0); + if let Some(v) = variant { + return Some(v); + } + }) } /// The intersected requirement of each ephemeral (`.foo`) scope in this /// (solved) phase, in scope order. Mirrors rez's /// `_ResolvePhase.get_resolved_ephemerals`. pub fn solved_ephemerals(&self) -> Vec { - self.scopes - .iter() - .filter_map(|s| s.get_solved_ephemeral().cloned()) - .collect() + self.iter_solved_ephemerals().cloned().collect() + } + + /// Borrowing iterator form of [`Self::solved_ephemerals`]. Lets callers + /// stream resolved ephemerals (`&Requirement`) without allocating an + /// intermediate `Vec` or cloning every entry — the heavier of the two + /// costs in the existing API. + pub fn iter_solved_ephemerals(&self) -> impl Iterator + '_ { + self.scopes.iter().filter_map(|s| s.get_solved_ephemeral()) } } diff --git a/crates/rer-resolver/src/rez_solver/solver.rs b/crates/rer-resolver/src/rez_solver/solver.rs index c7a435d..c7f4a9b 100644 --- a/crates/rer-resolver/src/rez_solver/solver.rs +++ b/crates/rer-resolver/src/rez_solver/solver.rs @@ -201,6 +201,19 @@ impl Solver { Some(self.phase_stack.last().unwrap().solved_variants()) } + /// Borrowing iterator form of [`Self::resolved_packages`]. `None` if the + /// solve did not succeed; otherwise an iterator that yields each resolved + /// variant (cheap `Rc` refcount bumps) without allocating an intermediate + /// `Vec`. + pub fn resolved_packages_iter( + &self, + ) -> Option> + '_> { + if self.status() != SolverStatus::Solved { + return None; + } + Some(self.phase_stack.last().unwrap().iter_solved_variants()) + } + /// The resolved ephemerals (intersected ranges of every `.foo` requirement /// that participated in the solve), or `None` if the solve did not /// succeed. Mirrors rez's `Solver.resolved_ephemerals`. @@ -211,6 +224,18 @@ impl Solver { Some(self.phase_stack.last().unwrap().solved_ephemerals()) } + /// Borrowing iterator form of [`Self::resolved_ephemerals`]. `None` if the + /// solve did not succeed; otherwise an iterator that yields each resolved + /// ephemeral as `&Requirement`, with no intermediate `Vec` and no clones. + /// The largest saving of the four iterator-form accessors — `Requirement` + /// clones are heavier than `Rc::clone`. + pub fn resolved_ephemerals_iter(&self) -> Option + '_> { + if self.status() != SolverStatus::Solved { + return None; + } + Some(self.phase_stack.last().unwrap().iter_solved_ephemerals()) + } + /// A human-readable failure description, or `None` if the solve did not /// fail. Mirrors the gist of rez's `Solver.failure_description`. pub fn failure_description(&self) -> Option { @@ -415,6 +440,64 @@ mod tests { assert_eq!(resolved_set(&solver), vec![("foo".into(), "1.0".into())]); } + #[test] + fn test_iter_resolved_packages_matches_vec_form() { + let solver = solve( + repo(vec![ + ("app", vec![("1.0", pkg(&["lib-2"], &[]))]), + ("lib", vec![("1.0", pkg(&[], &[])), ("2.0", pkg(&[], &[]))]), + ]), + &["app"], + ); + let owned: Vec<(String, String)> = solver + .resolved_packages() + .unwrap() + .iter() + .map(|v| (v.name().to_string(), v.version().to_string())) + .collect(); + let borrowed: Vec<(String, String)> = solver + .resolved_packages_iter() + .unwrap() + .map(|v| (v.name().to_string(), v.version().to_string())) + .collect(); + assert_eq!(owned, borrowed); + // Iter form on a failed solve returns None too. + let failed = solve( + repo(vec![( + "foo", + vec![("1.0", pkg(&[], &[])), ("2.0", pkg(&[], &[]))], + )]), + &["foo-1", "foo-2"], + ); + assert!(failed.resolved_packages_iter().is_none()); + } + + #[test] + fn test_iter_resolved_ephemerals_matches_vec_form() { + let solver = solve( + repo(vec![("foo", vec![("1.0", pkg(&[], &[]))])]), + &["foo", ".feature-1+<3", ".feature-2+"], + ); + let owned: Vec = solver + .resolved_ephemerals() + .unwrap() + .iter() + .map(|r| r.to_string()) + .collect(); + let borrowed: Vec = solver + .resolved_ephemerals_iter() + .unwrap() + .map(|r| r.to_string()) + .collect(); + assert_eq!(owned, borrowed); + // The borrowing form yields `&Requirement` — confirm callers can + // collect without forcing an owned copy of the requirements. + let by_ref: Vec<&Requirement> = + solver.resolved_ephemerals_iter().unwrap().collect(); + assert_eq!(by_ref.len(), 1); + assert_eq!(by_ref[0].to_string(), ".feature-2+<3"); + } + #[test] fn test_resolved_ephemerals_intersect_request_only() { // Two ephemerals on the request intersect to the narrower range. From 5a1f668d534b8ba7bc6af24a2e9f636c033c8475 Mon Sep 17 00:00:00 2001 From: Philippe Llerena Date: Sat, 16 May 2026 16:27:01 +0200 Subject: [PATCH 4/4] chore(release): bump workspace version to 0.1.0-rc.7 Picks up the resolved_ephemerals feature (issue #84) plus the borrowing-iterator API additions (`resolved_packages_iter` / `resolved_ephemerals_iter` on Solver, `iter_solved_variants` / `iter_solved_ephemerals` on ResolvePhase). The docs version pill, homepage `repo_version`, and the Quick Start `rer-resolver` dep example are bumped along with the workspace version so they stay in sync. Co-Authored-By: Claude Opus 4.7 --- Cargo.toml | 6 +++--- docs/config.toml | 2 +- docs/content/_index.md | 2 +- docs/content/docs/getting-started/quick-start.md | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index e8d6c2e..47f39bb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ members = ["crates/*"] resolver = "2" [workspace.package] -version = "0.1.0-rc.6" +version = "0.1.0-rc.7" authors = [ "Lorenzo Montant ", "Maxim Doucet ", @@ -23,8 +23,8 @@ lazy_static = "1.5.0" rand = "0.8.5" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" -rer-version = { path = "crates/rer-version", version = "0.1.0-rc.6" } -rer-resolver = { path = "crates/rer-resolver", version = "0.1.0-rc.6" } +rer-version = { path = "crates/rer-version", version = "0.1.0-rc.7" } +rer-resolver = { path = "crates/rer-resolver", version = "0.1.0-rc.7" } pyo3 = { version = "0.23.5", features = ["extension-module"] } # `mimalloc` is wired into the bench binary as a `#[global_allocator]`. # Callgrind shows ~33 % of cycles in libc malloc/free; mimalloc has measurably diff --git a/docs/config.toml b/docs/config.toml index e71d356..311af1c 100644 --- a/docs/config.toml +++ b/docs/config.toml @@ -125,7 +125,7 @@ weight = 10 name = "GitHub" pre = '' url = "https://github.com/doubleailes/rer" -post = "v0.1.0-rc.6" +post = "v0.1.0-rc.7" weight = 20 # Footer contents diff --git a/docs/content/_index.md b/docs/content/_index.md index 17c03c8..5469c43 100644 --- a/docs/content/_index.md +++ b/docs/content/_index.md @@ -7,7 +7,7 @@ title = "rer — Rez En Rust" lead = "A faithful Rust port of rez's package solver — callable from Python via PyO3, resolves match rez 1:1." url = "/docs/getting-started/introduction/" url_button = "Get started" -repo_version = "GitHub v0.1.0-rc.6" +repo_version = "GitHub v0.1.0-rc.7" repo_license = "MIT-licensed." repo_url = "https://github.com/doubleailes/rer" diff --git a/docs/content/docs/getting-started/quick-start.md b/docs/content/docs/getting-started/quick-start.md index 9283c2a..6f462b8 100644 --- a/docs/content/docs/getting-started/quick-start.md +++ b/docs/content/docs/getting-started/quick-start.md @@ -74,7 +74,7 @@ Add the resolver crate to your `Cargo.toml`: ```toml [dependencies] -rer-resolver = "0.1.0-rc.6" +rer-resolver = "0.1.0-rc.7" ``` Then call the solver against an in-memory repository: