//! The **per-window objective handle** (P1b R2) — context marshalled once, then each solver //! evaluation crosses the FFI boundary as ~10 floats and returns `(energy, gradient)`. //! //! # Why this exists, and what it is *not* //! //! P1b's Q0 decomposition overturned the phase's original premise. The window *traversal* — window //! construction, bboxes, slot read/write, the loop — is **0.1–0.4 %** of refine. Porting it would //! be worth nothing. The cost is elsewhere, and it is structural: //! //! * **Python-side kernel prep, 24.6–49.5 %** — `flatten_loop` per face loop, `bern @ beziers` per //! gradient cubic, colour marshalling. All of it redone on *every* objective evaluation, and //! L-BFGS-B evaluates ~16× per window. //! * **Priors, 31–70 %** — see [`crate::priors`]. //! * **FFI payload, 2.6–9.4 %** — re-marshalling the same geometry every evaluation. //! //! So this module moves **the objective**, not the loop. Everything that is genuinely per-solve — //! the bbox, the face set, the region colours, the target patch, the λ — is fixed for the duration //! of one window solve, so it is marshalled **once** into a [`WindowHandle`]. Only the active //! parameters change per evaluation. //! //! Traversal, budget checks, VarPro, and `scipy.minimize` as the solver driver stay in Python //! permanently. This is not a step toward an in-Rust L-BFGS: the solver question was settled by //! measurement (`scipy.overhead` 0.8–2.4 % at leaf level) and belongs to the browser tier, where //! scipy cannot follow. //! //! # The design choice that keeps the numeric risk small //! //! The handle **reuses the already-gated kernels rather than reimplementing them**: it assembles //! [`crate::window::Face`]/[`crate::window::GradCubic`] and calls [`crate::window::windowed_data`] //! (P0/P1a, gated on 1000 golden windows), then adds priors via [`crate::priors::edge_prior`] //! (P1b R1, gated on real and adversarial loops). //! //! # The vertex-level contract, and why the first version of this module broke it //! //! **Python supplies every polyline vertex on the data path. This crate performs no floating-point //! geometry arithmetic for it — only reordering, reversal and concatenation.** //! //! P0/P1a set the FFI contract at *vertex* level precisely so the crate would never compute //! `bernstein(linspace(…)) @ controls`, a BLAS `dgemm` no scalar ordering reproduces bit-for-bit. //! The first version of this handle flattened in Rust and argued the residual was "a few ULP, six //! orders inside the 1e-9 gate". That is true of the **energy** and false of the **gradient**, and //! the difference is not a matter of degree: //! //! * Coverage is *continuous* in vertex position — a vertex crossing a pixel boundary shifts area //! between two columns and the sum is unchanged. Energy agreed to 1.9e-16. This is what made the //! mistake invisible. //! * `segment_tau_forces` *samples the field at a pixel index*, `(a + t·d) as i64` — a step //! function. One ULP can select a different pixel, i.e. a whole pixel of field, not roundoff. //! //! Real art makes that routine: 25–34 % of control coordinates in the logo fixtures are exactly //! integral, so flattened vertices land exactly on pixel boundaries and the two summation orders //! straddle them. Measured worst-case relative gradient error against Python, at the solver's own //! start point: `logo_1` **2.0e-4**, `logo_lowres` **1.2e-4**, `gradient_linear` 2.2e-6 — against a //! 1e-6 gate, with the P1a kernel at 2.8e-18 on the same windows. //! //! The synthetic fixtures the Tier-1 tests were built from (`render_disk`, `render_composition`) //! produce **zero** such flips, and every non-zero perturbation in that gate moves points *off* the //! lattice. So the tests were not merely unlucky — they could not observe the failure. That is why //! the fix is structural (restore the contract) rather than "add a fixture". //! //! # Frozen-geometry caching //! //! A window's face loops routinely traverse many edges, but only the window's **own** cubics move. //! Frozen edges keep the polyline Python supplied at construction; active edges get a fresh one per //! evaluation. Both come from numpy, so the cache changes work, never values. //! //! Deliberately **not** cached: per-segment coverage contributions of frozen geometry. That would //! be a larger win, but it reorders the signed-area accumulation and would move results off the //! 1e-9 gate for reasons unrelated to correctness. Noted as a possible follow-up, measured first. use crate::model::Pt; use crate::priors::{edge_prior, Cubic, Lambdas}; use crate::window::{windowed_data, Face, GradCubic, RegionColor}; /// One graph edge's cubic chain, plus the polyline Python supplied for it. /// /// The cubics are retained because the **priors** are functions of the control points, not of the /// flattened polyline. Priors are continuous (arc length, angles, handle lengths) with no pixel /// indexing anywhere, so evaluating them from control points in Rust is safe — that is R1, gated at /// 1e-15 on real and adversarial loops. Only the *data* path needs numpy's exact vertices. pub struct EdgeGeom { pub cubics: Vec, /// True when at least one of this edge's control points is a window parameter. pub active: bool, /// Window-shifted polyline from numpy. For a frozen edge this is set once at construction; for /// an active edge it is replaced on every evaluation. polyline: Vec, } /// Where one window parameter slot writes. A slot can name several locations — that is how a /// junction position stays a *single* variable shared by every incident edge-end, which is what /// makes watertightness survive optimization by construction. #[derive(Clone, Copy, Debug)] pub struct SlotLoc { pub edge: usize, pub cubic: usize, pub k: usize, } /// One face loop as an ordered list of darts. pub struct LoopSpec { /// Index into `face_labels`. pub face: usize, /// `(edge index, direction)`; direction < 0 traverses the edge reversed. pub darts: Vec<(usize, i32)>, } /// One cubic whose data gradient is wanted, with the labels its edge separates. #[derive(Clone, Copy, Debug)] pub struct GradSpec { pub edge: usize, pub cubic: usize, pub left: usize, pub right: usize, } /// One edge whose priors enter this window's energy. pub struct PriorSpec { pub edge: usize, pub closed: bool, /// R-20 corner relaxation, indexed by control-polygon vertex. `None` = all ones. pub apt_weight: Option>, } /// Everything fixed for the duration of one window solve. pub struct WindowHandle { pub width: usize, pub height: usize, pub x0: f64, pub y0: f64, pub target: Vec, pub weights: Option>, pub l0: f64, pub background: f64, pub edges: Vec, pub face_labels: Vec, pub loops: Vec, pub colors: Vec, pub slots: Vec>, pub grads: Vec, pub priors: Vec, pub bern: Vec<[f64; 4]>, pub lam: Lambdas, pub include_spt: bool, /// Scratch reused across evaluations so a solve does not churn the allocator ~16× per window. scratch: Scratch, } #[derive(Default)] struct Scratch { /// Per-edge polyline views assembled each evaluation, reused to avoid churning the allocator /// ~16x per window. edge_polylines: Vec>, } /// `np.allclose`'s default predicate: `|a − b| <= atol + rtol·|b|`. /// /// Replicated rather than approximated because `flatten_loop` uses it to decide whether to drop a /// loop's duplicated closing point, and that decision changes the polyline's vertex count. #[inline] fn allclose(a: Pt, b: Pt) -> bool { const RTOL: f64 = 1e-5; const ATOL: f64 = 1e-8; (a[0] - b[0]).abs() <= ATOL + RTOL * b[0].abs() && (a[1] - b[1]).abs() <= ATOL + RTOL * b[1].abs() } /// `raster.flatten_loop`: concatenate the loop's darts, reversing shared edges, dropping the /// joint shared with the previous dart and the duplicated closing point. /// /// Pure data movement over vertices numpy produced — no arithmetic, so nothing here can diverge. fn flatten_loop(edge_polylines: &[Vec], darts: &[(usize, i32)]) -> Vec { let mut out: Vec = Vec::new(); for &(ei, dir) in darts { let p = &edge_polylines[ei]; // Python reverses FIRST and slices the leading point off SECOND; the order matters // because it decides which physical endpoint is dropped. if out.is_empty() { if dir < 0 { out.extend(p.iter().rev().copied()); } else { out.extend_from_slice(p); } } else if dir < 0 { out.extend(p.iter().rev().skip(1).copied()); } else if !p.is_empty() { // `&p[1..]` would PANIC on an empty polyline where Python's `p[1:]` yields an empty // array. A panic here is not merely a different error: pyo3 raises `PanicException`, // which derives from `BaseException` and therefore walks straight through the // orchestrator's `except Exception` never-fail guard, turning a degenerate edge into a // failed request. Matching Python's semantics is both more correct and safer. out.extend_from_slice(&p[1..]); } } if out.len() > 1 && allclose(out[0], out[out.len() - 1]) { out.pop(); } out } impl WindowHandle { /// Size the per-evaluation scratch. Frozen edges already hold the polyline Python supplied at /// construction; there is nothing to compute here. pub fn prime(&mut self) { self.scratch.edge_polylines = vec![Vec::new(); self.edges.len()]; } /// Indices of the active edges, in the order `eval` expects their polylines. pub fn active_order(&self) -> Vec { (0..self.edges.len()).filter(|&i| self.edges[i].active).collect() } /// The window objective: `params` → `(energy, gradient)`. /// /// Mirrors `optimize.window_objective`'s closure exactly — write the slots, evaluate `E_data` /// over the window's pixel support, add the incident edges' priors, then accumulate both /// gradient contributions onto the slots. /// /// `active_polys` (one per active edge, in [`Self::active_order`]) and `grad_pts` (one per /// gradient cubic) are the **numpy-computed, window-shifted vertices**. They are arguments /// rather than something this function derives, because deriving them is what broke the /// vertex-level contract — see the module docs. pub fn eval( &mut self, params: &[f64], active_polys: &[Vec], grad_pts: &[Vec], ) -> (f64, Vec) { // --- 1. write the active parameters into the geometry ------------------------------- // Only the PRIORS read these; the data path uses the vertices Python supplied. Priors are // continuous in the control points with no pixel indexing, so computing them here is safe. for (si, locs) in self.slots.iter().enumerate() { let p = [params[2 * si], params[2 * si + 1]]; for l in locs { self.edges[l.edge].cubics[l.cubic][l.k] = p; } } // --- 2. gather per-edge polylines: supplied for active, retained for frozen ---------- let mut polys = std::mem::take(&mut self.scratch.edge_polylines); let mut next_active = 0; for (i, e) in self.edges.iter().enumerate() { polys[i].clear(); if e.active { polys[i].extend_from_slice(&active_polys[next_active]); next_active += 1; } else { polys[i].extend_from_slice(&e.polyline); } } // --- 3. assemble the faces, preserving the caller's face and loop order -------------- // That order fixes the composite's reduction order, so it is part of the contract, not an // implementation detail. The vertices are already window-shifted by Python. let mut faces: Vec = self .face_labels .iter() .map(|&label| Face { label, loops: Vec::new(), }) .collect(); for lp in &self.loops { let poly = flatten_loop(&polys, &lp.darts); if poly.len() >= 3 { faces[lp.face].loops.push(poly); } } // --- 4. the gradient cubics, straight from numpy ------------------------------------- let grad_cubics: Vec = self .grads .iter() .zip(grad_pts) .map(|(g, pts)| GradCubic { left: g.left, right: g.right, pts: pts.clone(), }) .collect(); // --- 5. E_data + boundary-integral gradient (the P0/P1a kernel, unchanged) ----------- let (e_data, vgrads) = windowed_data( self.width, self.height, self.x0, self.y0, &self.target, &faces, &self.colors, self.background, self.l0, &grad_cubics, self.weights.as_deref(), ); // --- 6. accumulate every gradient contribution per (edge, cubic, control point) ------ // Dense and zero-initialised, so an absent contribution adds nothing — the same semantics // as Python's `dict.get(...) is not None` guards, without the branch. let mut acc: Vec> = self .edges .iter() .map(|e| vec![[[0.0_f64; 2]; 4]; e.cubics.len()]) .collect(); // Data: chain the vertex gradients back to control points (`bernᵀ @ vgrad`). This stays // symmetrical with the kernel's vertex-level contract — the crate receives vertices from // its own flattening, so the product never crosses the FFI boundary. for (gi, g) in self.grads.iter().enumerate() { let vg = &vgrads[gi]; let slot = &mut acc[g.edge][g.cubic]; for (k, sk) in slot.iter_mut().enumerate() { let mut gx = 0.0; let mut gy = 0.0; for (i, row) in self.bern.iter().enumerate() { gx += row[k] * vg[i][0]; gy += row[k] * vg[i][1]; } sk[0] += gx; sk[1] += gy; } } // Priors, per incident edge. Each edge appears at most once here: the prior is a property // of the EDGE, not of each incidence, and listing an island edge twice is exactly the // double-count that shipped undetected from C-3 until 2026-07-20. The Python side dedupes // before marshalling; this loop must not undo that. let mut e_prior = 0.0; for ps in &self.priors { let (pe, pg) = edge_prior( &self.edges[ps.edge].cubics, ps.closed, &self.lam, &self.bern, self.include_spt, ps.apt_weight.as_deref(), ); e_prior += pe; for (ci, gc) in pg.iter().enumerate() { for k in 0..4 { acc[ps.edge][ci][k][0] += gc[k][0]; acc[ps.edge][ci][k][1] += gc[k][1]; } } } // --- 7. project onto the slots ------------------------------------------------------- let mut grad = vec![0.0_f64; self.slots.len() * 2]; for (si, locs) in self.slots.iter().enumerate() { let mut gx = 0.0; let mut gy = 0.0; for l in locs { gx += acc[l.edge][l.cubic][l.k][0]; gy += acc[l.edge][l.cubic][l.k][1]; } grad[2 * si] = gx; grad[2 * si + 1] = gy; } self.scratch.edge_polylines = polys; (e_data + e_prior, grad) } /// Read the current value of every slot — the handle's view of `optimize._read_slots`. /// /// Used by tests to confirm the handle and the graph start from the same point; a silent /// disagreement there would make every downstream comparison meaningless. pub fn read_slots(&self) -> Vec { let mut out = vec![0.0; self.slots.len() * 2]; for (si, locs) in self.slots.iter().enumerate() { let l = locs[0]; let p = self.edges[l.edge].cubics[l.cubic][l.k]; out[2 * si] = p[0]; out[2 * si + 1] = p[1]; } out } } #[cfg(feature = "python")] pub use bindings::register; #[cfg(feature = "python")] mod bindings { use super::*; use numpy::{PyReadonlyArray1, PyReadonlyArray2, PyReadonlyArray3, ToPyArray}; use pyo3::prelude::*; use pyo3::types::PyList; /// The handle, exposed to Python as an opaque object. /// /// Construction marshals; `eval` does not. That asymmetry is the entire point of the class — /// `tests/test_rust_objective_handle.py` measures the per-eval payload and asserts it is small, /// because a design whose justification is "stop re-marshalling" has to prove it stopped. #[pyclass(name = "WindowHandle", unsendable)] pub struct PyWindowHandle { inner: WindowHandle, } #[pymethods] impl PyWindowHandle { #[new] #[pyo3(signature = (width, height, x0, y0, target, weights, l0, background, edge_cubics, edge_active, frozen_polys, face_labels, loop_face, loop_darts, colors01, color_kind, quad_coeffs, quad_transform, slots, grad_spec, prior_spec, prior_apt_weights, bern, lam_spt, lam_apt, lam_hpt, lam_lpt, include_spt))] #[allow(clippy::too_many_arguments)] fn new<'py>( width: usize, height: usize, x0: f64, y0: f64, target: PyReadonlyArray3<'py, f64>, weights: Option>, l0: f64, background: f64, edge_cubics: &Bound<'py, PyList>, edge_active: Vec, // Window-shifted polylines from numpy, one per edge. Active edges' entries are // placeholders (replaced every evaluation); frozen edges keep theirs for the solve. frozen_polys: &Bound<'py, PyList>, face_labels: Vec, loop_face: Vec, loop_darts: &Bound<'py, PyList>, colors01: PyReadonlyArray2<'py, f64>, color_kind: Vec, quad_coeffs: PyReadonlyArray3<'py, f64>, quad_transform: PyReadonlyArray2<'py, f64>, slots: &Bound<'py, PyList>, grad_spec: PyReadonlyArray2<'py, i64>, prior_spec: PyReadonlyArray2<'py, i64>, prior_apt_weights: &Bound<'py, PyList>, bern: PyReadonlyArray2<'py, f64>, lam_spt: f64, lam_apt: f64, lam_hpt: f64, lam_lpt: f64, include_spt: bool, ) -> PyResult { // --- geometry --------------------------------------------------------------- let mut edges: Vec = Vec::with_capacity(edge_cubics.len()); for (i, item) in edge_cubics.iter().enumerate() { let arr: PyReadonlyArray3 = item.extract()?; let v = arr.as_array(); let s = v.shape()[0]; if v.shape()[1] != 4 || v.shape()[2] != 2 { return Err(pyo3::exceptions::PyValueError::new_err( "each edge's cubics must be (s, 4, 2)", )); } let cubics: Vec = (0..s) .map(|ci| { let mut c = [[0.0_f64; 2]; 4]; for (k, p) in c.iter_mut().enumerate() { *p = [v[[ci, k, 0]], v[[ci, k, 1]]]; } c }) .collect(); let poly_arr: PyReadonlyArray2 = frozen_polys.get_item(i)?.extract()?; let pv = poly_arr.as_array(); edges.push(EdgeGeom { cubics, active: *edge_active.get(i).unwrap_or(&true), polyline: (0..pv.shape()[0]).map(|r| [pv[[r, 0]], pv[[r, 1]]]).collect(), }); } // --- loops ------------------------------------------------------------------ let mut loops: Vec = Vec::with_capacity(loop_darts.len()); for (i, item) in loop_darts.iter().enumerate() { let arr: PyReadonlyArray2 = item.extract()?; let v = arr.as_array(); loops.push(LoopSpec { face: loop_face[i], darts: (0..v.shape()[0]) .map(|r| (v[[r, 0]] as usize, v[[r, 1]] as i32)) .collect(), }); } // --- slots ------------------------------------------------------------------ let mut slot_vec: Vec> = Vec::with_capacity(slots.len()); for item in slots.iter() { let arr: PyReadonlyArray2 = item.extract()?; let v = arr.as_array(); slot_vec.push( (0..v.shape()[0]) .map(|r| SlotLoc { edge: v[[r, 0]] as usize, cubic: v[[r, 1]] as usize, k: v[[r, 2]] as usize, }) .collect(), ); } // --- gradient + prior specs ------------------------------------------------- let gs = grad_spec.as_array(); let grads: Vec = (0..gs.shape()[0]) .map(|r| GradSpec { edge: gs[[r, 0]] as usize, cubic: gs[[r, 1]] as usize, left: gs[[r, 2]] as usize, right: gs[[r, 3]] as usize, }) .collect(); let ps = prior_spec.as_array(); let mut priors: Vec = Vec::with_capacity(ps.shape()[0]); for r in 0..ps.shape()[0] { let w: Option> = { let item = prior_apt_weights.get_item(r)?; if item.is_none() { None } else { let a: PyReadonlyArray1 = item.extract()?; Some(a.as_array().iter().copied().collect()) } }; priors.push(PriorSpec { edge: ps[[r, 0]] as usize, closed: ps[[r, 1]] != 0, apt_weight: w, }); } // --- colours (the P1a marshalling, verbatim) -------------------------------- let cv = colors01.as_array(); let qc = quad_coeffs.as_array(); let qt = quad_transform.as_array(); let colors: Vec = cv .rows() .into_iter() .enumerate() .map(|(i, r)| { if color_kind.get(i).copied().unwrap_or(0) == 1 { let mut coeffs = [[0.0_f64; 3]; 6]; for (k, row) in coeffs.iter_mut().enumerate() { for (ch, c) in row.iter_mut().enumerate() { *c = qc[[i, k, ch]]; } } RegionColor::Quad { coeffs, cx: qt[[i, 0]], cy: qt[[i, 1]], s: qt[[i, 2]], } } else { RegionColor::Flat([r[0], r[1], r[2]]) } }) .collect(); let bv = bern.as_array(); let basis: Vec<[f64; 4]> = (0..bv.shape()[0]) .map(|i| [bv[[i, 0]], bv[[i, 1]], bv[[i, 2]], bv[[i, 3]]]) .collect(); // Validate every index BEFORE anything can run. An out-of-range edge index would // otherwise panic deep inside `eval`, and a Rust panic crosses the FFI boundary as // `PanicException` (a `BaseException`) — bypassing the never-fail refine guard that // exists so a request never dies because refinement did. let n_edges = edges.len(); let n_labels = colors.len(); for (si, locs) in slot_vec.iter().enumerate() { for l in locs { if l.edge >= n_edges || l.cubic >= edges[l.edge].cubics.len() || l.k >= 4 { return Err(pyo3::exceptions::PyValueError::new_err(format!( "slot {si} references (edge {}, cubic {}, k {}) which does not exist", l.edge, l.cubic, l.k ))); } } } for g in &grads { if g.edge >= n_edges || g.cubic >= edges[g.edge].cubics.len() || g.left >= n_labels || g.right >= n_labels { return Err(pyo3::exceptions::PyValueError::new_err(format!( "grad_spec row (edge {}, cubic {}, left {}, right {}) is out of range", g.edge, g.cubic, g.left, g.right ))); } } for ps in &priors { if ps.edge >= n_edges { return Err(pyo3::exceptions::PyValueError::new_err(format!( "prior_spec references edge {} which does not exist", ps.edge ))); } } for lp in &loops { if lp.face >= face_labels.len() { return Err(pyo3::exceptions::PyValueError::new_err(format!( "loop references face {} which does not exist", lp.face ))); } for &(ei, _) in &lp.darts { if ei >= n_edges { return Err(pyo3::exceptions::PyValueError::new_err(format!( "loop dart references edge {ei} which does not exist" ))); } } } for &label in &face_labels { if label >= n_labels { return Err(pyo3::exceptions::PyValueError::new_err(format!( "face label {label} has no colour" ))); } } let mut inner = WindowHandle { width, height, x0, y0, target: target.as_array().iter().copied().collect(), weights: weights.map(|w| w.as_array().iter().copied().collect()), l0, background, edges, face_labels, loops, colors, slots: slot_vec, grads, priors, bern: basis, lam: Lambdas { spt: lam_spt, apt: lam_apt, hpt: lam_hpt, lpt: lam_lpt, }, include_spt, scratch: Scratch::default(), }; inner.prime(); Ok(PyWindowHandle { inner }) } /// `params` + the moving vertices → `(energy, gradient)`. /// /// The per-evaluation payload is the parameter vector plus the polylines of the few edges /// a window parameter can move — NOT the window's whole geometry, which is what the P1a /// path re-marshalled every time. Those vertices come from numpy because the data path's /// pixel indexing is a step function of vertex position (module docs). fn eval<'py>( &mut self, py: Python<'py>, params: PyReadonlyArray1<'py, f64>, active_polys: &Bound<'py, PyList>, grad_pts: &Bound<'py, PyList>, ) -> PyResult<(f64, Py>)> { let x: Vec = params.as_array().iter().copied().collect(); if x.len() != self.inner.slots.len() * 2 { return Err(pyo3::exceptions::PyValueError::new_err(format!( "expected {} params for {} slots, got {}", self.inner.slots.len() * 2, self.inner.slots.len(), x.len() ))); } let n_active = self.inner.edges.iter().filter(|e| e.active).count(); if active_polys.len() != n_active || grad_pts.len() != self.inner.grads.len() { return Err(pyo3::exceptions::PyValueError::new_err(format!( "expected {} active polylines and {} gradient polylines, got {} and {}", n_active, self.inner.grads.len(), active_polys.len(), grad_pts.len() ))); } let mut ap: Vec> = Vec::with_capacity(active_polys.len()); for item in active_polys.iter() { let a: PyReadonlyArray2 = item.extract()?; let v = a.as_array(); ap.push((0..v.shape()[0]).map(|r| [v[[r, 0]], v[[r, 1]]]).collect()); } let mut gp: Vec> = Vec::with_capacity(grad_pts.len()); for item in grad_pts.iter() { let a: PyReadonlyArray2 = item.extract()?; let v = a.as_array(); gp.push((0..v.shape()[0]).map(|r| [v[[r, 0]], v[[r, 1]]]).collect()); } let (e, g) = self.inner.eval(&x, &ap, &gp); Ok(( e, numpy::ndarray::Array1::from_vec(g).to_pyarray(py).unbind(), )) } /// The handle's current slot values, for start-point agreement checks. fn read_slots<'py>(&self, py: Python<'py>) -> Py> { numpy::ndarray::Array1::from_vec(self.inner.read_slots()) .to_pyarray(py) .unbind() } /// Number of parameters this handle expects. #[getter] fn n_params(&self) -> usize { self.inner.slots.len() * 2 } /// How many gradient cubics the handle holds — after Python's dedupe by (edge, cubic). #[getter] fn n_grad(&self) -> usize { self.inner.grads.len() } /// Edge indices whose polylines `eval` expects, in order. #[getter] fn active_order(&self) -> Vec { self.inner.active_order() } /// How many edges were marshalled, and how many of those carry an active cubic — the /// caching claim in numbers, so a test can assert the cache is actually doing work /// rather than trivially covering zero edges. #[getter] fn edge_counts(&self) -> (usize, usize) { let active = self.inner.edges.iter().filter(|e| e.active).count(); (self.inner.edges.len(), active) } } pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; Ok(()) } } #[cfg(test)] mod tests { use super::*; fn test_bern(m: usize) -> Vec<[f64; 4]> { (0..m) .map(|i| { let t = i as f64 / (m - 1) as f64; let mt = 1.0 - t; [mt * mt * mt, 3.0 * mt * mt * t, 3.0 * mt * t * t, t * t * t] }) .collect() } /// Reversal happens before the leading point is dropped. Getting that order backwards drops /// the wrong physical endpoint and leaves a one-vertex gap in the loop — which the coverage /// rasterizer then closes with a straight chord, silently. #[test] fn loop_assembly_reverses_before_dropping_the_shared_joint() { let a: Vec = vec![[0.0, 0.0], [1.0, 0.0], [2.0, 0.0]]; let b: Vec = vec![[4.0, 0.0], [3.0, 0.0], [2.0, 0.0]]; // reversed, ends where `a` ends let poly = flatten_loop(&[a, b], &[(0, 1), (1, -1)]); assert_eq!( poly, vec![[0.0, 0.0], [1.0, 0.0], [2.0, 0.0], [3.0, 0.0], [4.0, 0.0]], "the shared endpoint must appear once, and the reversed edge must run 2 -> 4" ); } /// A loop whose first and last points coincide drops the duplicate; one whose ends differ /// keeps both. #[test] fn closing_duplicate_is_dropped_only_when_the_ends_actually_meet() { let closed: Vec = vec![[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 0.0]]; assert_eq!(flatten_loop(&[closed], &[(0, 1)]).len(), 3); let open: Vec = vec![[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.5, 9.0]]; assert_eq!(flatten_loop(&[open], &[(0, 1)]).len(), 4); } /// **The structural guard for the defect that shipped in the first version of this module.** /// /// Rust computed `bern @ controls` with scalar accumulation where numpy uses BLAS. On art with /// pixel-snapped control points (25–34 % of coordinates exactly integral in the logo fixtures) /// the last-bit difference flipped `segment_tau_forces`' pixel index — up to 2.0e-4 relative /// gradient error against a 1e-6 gate, while the energy stayed clean at 1.9e-16 and hid it. /// /// "The crate performs no geometry arithmetic" is not directly assertable, so this asserts the /// observable consequence: `eval` is a pure function of the vertices it is GIVEN. Feed it /// vertices that disagree with its control points and the energy must follow the *vertices* — /// proving nothing was re-derived behind the caller's back. #[test] fn eval_uses_the_supplied_vertices_and_never_recomputes_them() { let bern = test_bern(4); let c: Cubic = [[0.0, 0.0], [1.0, 0.0], [2.0, 0.0], [3.0, 0.0]]; let square = |x: f64| -> Vec { vec![[0.0, 0.0], [x, 0.0], [x, 4.0], [0.0, 4.0]] }; let mut h = WindowHandle { width: 4, height: 4, x0: 0.0, y0: 0.0, target: vec![1.0; 4 * 4 * 3], weights: None, l0: 1.0, background: 1.0, edges: vec![EdgeGeom { cubics: vec![c], active: true, polyline: Vec::new() }], face_labels: vec![0], loops: vec![LoopSpec { face: 0, darts: vec![(0, 1)] }], colors: vec![RegionColor::Flat([0.0, 0.0, 0.0])], slots: vec![vec![SlotLoc { edge: 0, cubic: 0, k: 1 }]], grads: vec![], priors: vec![], bern, lam: Lambdas { spt: 0.0, apt: 0.0, hpt: 0.0, lpt: 0.0 }, include_spt: false, scratch: Scratch::default(), }; h.prime(); // Identical params, different supplied vertices → different energy. If the crate re-derived // the polyline from its control points, these two would be equal. let (narrow, _) = h.eval(&[1.0, 0.0], &[square(1.0)], &[]); let (wide, _) = h.eval(&[1.0, 0.0], &[square(4.0)], &[]); assert!( (narrow - wide).abs() > 1e-9, "energy ignored the supplied vertices ({narrow} vs {wide}) — the crate is flattening \ its own geometry again, which is exactly the defect this guards" ); } /// A frozen edge keeps the polyline Python supplied at construction, and is not expected in /// the per-evaluation `active_polys` list. #[test] fn frozen_edges_keep_their_supplied_polyline_and_are_not_in_the_active_order() { let bern = test_bern(4); let c: Cubic = [[0.0, 0.0], [1.0, 0.0], [2.0, 0.0], [3.0, 0.0]]; let frozen: Vec = vec![[9.0, 9.0], [8.0, 8.0]]; let h = WindowHandle { width: 2, height: 2, x0: 0.0, y0: 0.0, target: vec![0.0; 2 * 2 * 3], weights: None, l0: 1.0, background: 1.0, edges: vec![ EdgeGeom { cubics: vec![c], active: false, polyline: frozen.clone() }, EdgeGeom { cubics: vec![c], active: true, polyline: Vec::new() }, ], face_labels: vec![], loops: vec![], colors: vec![RegionColor::Flat([0.0, 0.0, 0.0])], slots: vec![], grads: vec![], priors: vec![], bern, lam: Lambdas::default(), include_spt: false, scratch: Scratch::default(), }; assert_eq!(h.active_order(), vec![1], "only the active edge needs a fresh polyline"); assert_eq!(h.edges[0].polyline, frozen); } /// A slot naming several locations writes all of them — the mechanism that keeps a junction a /// single shared variable rather than one copy per incident edge. #[test] fn one_slot_writes_every_location_it_names() { let bern = test_bern(4); let c: Cubic = [[0.0, 0.0], [1.0, 0.0], [2.0, 0.0], [3.0, 0.0]]; let mut h = WindowHandle { width: 2, height: 2, x0: 0.0, y0: 0.0, target: vec![0.0; 2 * 2 * 3], weights: None, l0: 1.0, background: 1.0, edges: vec![ EdgeGeom { cubics: vec![c], active: true, polyline: Vec::new() }, EdgeGeom { cubics: vec![c], active: true, polyline: Vec::new() }, ], face_labels: vec![], loops: vec![], colors: vec![RegionColor::Flat([0.0, 0.0, 0.0])], slots: vec![vec![ SlotLoc { edge: 0, cubic: 0, k: 0 }, SlotLoc { edge: 1, cubic: 0, k: 3 }, ]], grads: vec![], priors: vec![], bern, lam: Lambdas::default(), include_spt: false, scratch: Scratch::default(), }; h.prime(); h.eval(&[9.0, -4.0], &[Vec::new(), Vec::new()], &[]); assert_eq!(h.edges[0].cubics[0][0], [9.0, -4.0]); assert_eq!(h.edges[1].cubics[0][3], [9.0, -4.0]); assert_eq!(h.read_slots(), vec![9.0, -4.0]); } }