vectorhd / rust /vectorhd-core /src /handle.rs
mcyakar's picture
deploy: VectorHD 0.2.10 (source ff79722)
5e84645
Raw
History Blame Contribute Delete
39.2 kB
//! 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<Cubic>,
/// 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<Pt>,
}
/// 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<Vec<f64>>,
}
/// 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<f64>,
pub weights: Option<Vec<f64>>,
pub l0: f64,
pub background: f64,
pub edges: Vec<EdgeGeom>,
pub face_labels: Vec<usize>,
pub loops: Vec<LoopSpec>,
pub colors: Vec<RegionColor>,
pub slots: Vec<Vec<SlotLoc>>,
pub grads: Vec<GradSpec>,
pub priors: Vec<PriorSpec>,
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<Vec<Pt>>,
}
/// `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<Pt>], darts: &[(usize, i32)]) -> Vec<Pt> {
let mut out: Vec<Pt> = 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<usize> {
(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<Pt>],
grad_pts: &[Vec<Pt>],
) -> (f64, Vec<f64>) {
// --- 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<Face> = 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<GradCubic> = 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<Vec<[Pt; 4]>> = 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<f64> {
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<PyReadonlyArray2<'py, f64>>,
l0: f64,
background: f64,
edge_cubics: &Bound<'py, PyList>,
edge_active: Vec<bool>,
// 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<usize>,
loop_face: Vec<usize>,
loop_darts: &Bound<'py, PyList>,
colors01: PyReadonlyArray2<'py, f64>,
color_kind: Vec<u8>,
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<Self> {
// --- geometry ---------------------------------------------------------------
let mut edges: Vec<EdgeGeom> = Vec::with_capacity(edge_cubics.len());
for (i, item) in edge_cubics.iter().enumerate() {
let arr: PyReadonlyArray3<f64> = 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<Cubic> = (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<f64> = 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<LoopSpec> = Vec::with_capacity(loop_darts.len());
for (i, item) in loop_darts.iter().enumerate() {
let arr: PyReadonlyArray2<i64> = 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<SlotLoc>> = Vec::with_capacity(slots.len());
for item in slots.iter() {
let arr: PyReadonlyArray2<i64> = 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<GradSpec> = (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<PriorSpec> = Vec::with_capacity(ps.shape()[0]);
for r in 0..ps.shape()[0] {
let w: Option<Vec<f64>> = {
let item = prior_apt_weights.get_item(r)?;
if item.is_none() {
None
} else {
let a: PyReadonlyArray1<f64> = 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<RegionColor> = 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<numpy::PyArray1<f64>>)> {
let x: Vec<f64> = 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<Pt>> = Vec::with_capacity(active_polys.len());
for item in active_polys.iter() {
let a: PyReadonlyArray2<f64> = 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<Pt>> = Vec::with_capacity(grad_pts.len());
for item in grad_pts.iter() {
let a: PyReadonlyArray2<f64> = 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::PyArray1<f64>> {
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<usize> {
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::<PyWindowHandle>()?;
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<Pt> = vec![[0.0, 0.0], [1.0, 0.0], [2.0, 0.0]];
let b: Vec<Pt> = 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<Pt> = 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<Pt> = 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<Pt> { 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<Pt> = 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]);
}
}