Repository: opendp/opendp
Pull request #2357: feat: consolidate and prove discrete quantile scorer
Author: login: Shoeboxam
Created: 2025-04-13T18:47:51Z | Merged: 2025-04-29T14:32:01Z

Closes #2356

* drops a factor of two in the make_private_quantile constructor that has no privacy/utility impact
* drops a factor of two in the known-size stability map that doubles utility (we were over-charging before)
* adds a check that disallows nan in Polars. Doesn't affect privacy but prevents a potential unexpected loss in utility
* consolidates the scorer implementations used in the Polars and non-Polars implementations
    * I polished the Polars plugin scorer and relegated non-Polars scorer to a fuzz test
    * Polars plugin scorer has better time complexity on unsorted data: O(n ln(n)) -> O(n ln(c))
    * non-Polars scores are now u64, not usize
* adds and/or updates proofs for functions throughout the stack

Linked issue #2356: Consolidate Polars and standard quantile scorers
The scoring function in polars runs in O(n ln(c)), whereas the traditional scorer runs in O(n ln(n)) when data is not sorted and O(ln(n) c) when sorted. Unfortunately, data is usually not sorted, so we'd rather have the O(n ln(c)) runtime.

Diff:
diff --git a/docs/source/getting-started/statistical-modeling/regression.ipynb b/docs/source/getting-started/statistical-modeling/regression.ipynb
index 0f93f6f97af..d1b954a7a80 100644
--- a/docs/source/getting-started/statistical-modeling/regression.ipynb
+++ b/docs/source/getting-started/statistical-modeling/regression.ipynb
@@ -277,7 +277,7 @@
   },
   {
    "cell_type": "code",
-   "execution_count": 7,
+   "execution_count": null,
    "metadata": {},
    "outputs": [
     {
@@ -295,8 +295,7 @@
     }
    ],
    "source": [
-    "input_space = dp.vector_domain(dp.atom_domain(T=dp.usize), 100), dp.linf_distance(T=dp.usize)\n",
-    "select_index_measurement = dp.m.make_report_noisy_max_gumbel(*input_space, scale=1.0, optimize='min')\n",
+    "select_index_measurement = dp.m.make_report_noisy_max_gumbel(*discrete_scores.output_space, scale=1.0, optimize='min')\n",
     "select_index_measurement"
    ]
   },
@@ -508,7 +507,7 @@
    "name": "python",
    "nbconvert_exporter": "python",
    "pygments_lexer": "ipython3",
-   "version": "3.13.0"
+   "version": "3.13.2"
   },
   "nbsphinx": {
    "orphan": true
diff --git a/docs/source/getting-started/tabular-data/essential-statistics.ipynb b/docs/source/getting-started/tabular-data/essential-statistics.ipynb
index 28e48dc23f3..4b877e02adb 100644
--- a/docs/source/getting-started/tabular-data/essential-statistics.ipynb
+++ b/docs/source/getting-started/tabular-data/essential-statistics.ipynb
@@ -682,7 +682,7 @@
   },
   {
    "cell_type": "code",
-   "execution_count": 17,
+   "execution_count": null,
    "metadata": {},
    "outputs": [
     {
@@ -719,7 +719,7 @@
     "query_median_hours = (\n",
     "    context.query()\n",
     "    .filter(pl.col.HWUSUAL != 99.0)\n",
-    "    .select(pl.col.HWUSUAL.fill_null(35).dp.median(candidates))\n",
+    "    .select(pl.col.HWUSUAL.cast(int).fill_null(35).dp.median(candidates))\n",
     ")\n",
     "query_median_hours.summarize(alpha=0.05)"
    ]
@@ -794,7 +794,7 @@
   },
   {
    "cell_type": "code",
-   "execution_count": 19,
+   "execution_count": null,
    "metadata": {},
    "outputs": [
     {
@@ -832,7 +832,7 @@
     "    context.query()\n",
     "    .filter(pl.col.HWUSUAL != 99.0)\n",
     "    .select(\n",
-    "        pl.col.HWUSUAL.fill_null(35).dp.quantile(a, candidates).alias(f\"{a}-Quantile\")\n",
+    "        pl.col.HWUSUAL.cast(int).fill_null(35).dp.quantile(a, candidates).alias(f\"{a}-Quantile\")\n",
     "        for a in [0.25, 0.5, 0.75]\n",
     "    )\n",
     ")\n",
@@ -924,7 +924,7 @@
    "name": "python",
    "nbconvert_exporter": "python",
    "pygments_lexer": "ipython3",
-   "version": "3.13.0"
+   "version": "3.13.2"
   }
  },
  "nbformat": 4,
diff --git a/python/src/opendp/extras/polars/__init__.py b/python/src/opendp/extras/polars/__init__.py
index 4cb1bcd8447..4445a89137c 100644
--- a/python/src/opendp/extras/polars/__init__.py
+++ b/python/src/opendp/extras/polars/__init__.py
@@ -468,7 +468,7 @@ def quantile(
         ...     margins=[dp.polars.Margin(max_partition_length=100)]
         ... )
         >>> candidates = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90]
-        >>> query = context.query().select(pl.col("age").fill_null(0).dp.quantile(0.25, candidates))
+        >>> query = context.query().select(pl.col("age").cast(int).fill_null(0).dp.quantile(0.25, candidates))
         >>> query.release().collect()
         shape: (1, 1)
         ┌─────┐
@@ -506,7 +506,7 @@ def median(self, candidates: list[float], scale: float | None = None):
         ...     margins=[dp.polars.Margin(max_partition_length=100)]
         ... )
         >>> candidates = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90]
-        >>> query = context.query().select(pl.col("age").fill_null(0).dp.quantile(0.5, candidates))
+        >>> query = context.query().select(pl.col("age").cast(int).fill_null(0).dp.quantile(0.5, candidates))
         >>> query.release().collect()
         shape: (1, 1)
         ┌─────┐
diff --git a/python/test/integration/test_quantile.py b/python/test/integration/test_quantile.py
index 1276fc52306..c4043fcc5a4 100644
--- a/python/test/integration/test_quantile.py
+++ b/python/test/integration/test_quantile.py
@@ -4,25 +4,6 @@
 enable_features("floating-point", "contrib")
 
 
-def test_quantile_score_candidates():
-
-    input_domain = dp.vector_domain(dp.atom_domain(T=int))
-    input_metric = dp.symmetric_distance()
-    candidates = [20, 33, 40, 50, 72, 100]
-    quant_trans = dp.t.make_quantile_score_candidates(
-        input_domain, input_metric, candidates, alpha=0.5
-    )
-
-    assert quant_trans(list(range(100))) == [59, 33, 19, 1, 45, 100]
-
-    expo_meas = dp.m.then_report_noisy_max_gumbel(1.0, "min")
-
-    quantile_meas = quant_trans >> expo_meas
-    idx = quantile_meas(list(range(100)))
-    assert candidates[idx] == 50
-    assert quantile_meas.map(1) == 2.0
-
-
 def test_private_quantile():
     input_space = dp.vector_domain(dp.atom_domain(T=int)), dp.symmetric_distance()
     
@@ -33,4 +14,4 @@ def test_private_quantile():
     )
 
     assert m_median(list(range(100))) == 50
-    assert m_median.map(1) == 0.5
+    assert m_median.map(1) == 1.0
diff --git a/python/test/test_trans.py b/python/test/test_trans.py
index 158c9d547fb..85fb59ed545 100644
--- a/python/test/test_trans.py
+++ b/python/test/test_trans.py
@@ -393,13 +393,3 @@ def test_lipschitz_b_ary_tree():
     print("meas_quantiles(data)", meas_quantiles(data))
 
     assert meas_cdf.map(1) == 4.
-
-
-def test_quantile_score_candidates():
-
-    input_space = dp.vector_domain(dp.atom_domain(T=int)), dp.symmetric_distance()
-    trans = input_space >> dp.t.then_quantile_score_candidates([0, 25, 50, 75, 100], alpha=0.5)
-    scores = trans(list(range(101)))
-    # score works out to 2 * |50 - cand|
-    assert scores == [100, 50, 0, 50, 100]
-    assert trans.map(1) == 1
diff --git a/rust/src/data/ffi/mod.rs b/rust/src/data/ffi/mod.rs
index b6e5963e30d..60fdc23581a 100644
--- a/rust/src/data/ffi/mod.rs
+++ b/rust/src/data/ffi/mod.rs
@@ -707,7 +707,8 @@ pub extern "C" fn opendp_data__object_as_slice(obj: *const AnyObject) -> FfiResu
             if element.descriptor == "String" {
                 vec_string_to_raw(obj)
             } else {
-                dispatch!(vec_to_raw, [(element, @primitives_plus)], (obj))
+                // equivalent to @primitives-plus, but listing the types out excludes this from debug build narrowing of types
+                dispatch!(vec_to_raw, [(element, [u32, u64, i32, i64, usize, f32, f64, bool, String, AnyObject, ExtrinsicObject])], (obj))
             }
         }
         TypeContents::TUPLE(element_ids) => {
diff --git a/rust/src/measurements/make_private_lazyframe/group_by/test.rs b/rust/src/measurements/make_private_lazyframe/group_by/test.rs
index 503e57767f0..7e1541e0891 100644
--- a/rust/src/measurements/make_private_lazyframe/group_by/test.rs
+++ b/rust/src/measurements/make_private_lazyframe/group_by/test.rs
@@ -123,6 +123,8 @@ fn test_explicit_keys() -> Fallible<()> {
         .alias("sum");
     let candidates = (0..N_CANDIDATES).map(|v| v as f64).collect::<Vec<_>>();
     let median_expr = col("B")
+        .fill_nan(0.0)
+        .fill_null(0.0)
         .dp()
         .median(Series::new("".into(), candidates.clone()), None)
         .alias("med");
diff --git a/rust/src/measurements/private_quantile/mod.rs b/rust/src/measurements/private_quantile/mod.rs
index bbe29f5915b..2bb9493e55e 100644
--- a/rust/src/measurements/private_quantile/mod.rs
+++ b/rust/src/measurements/private_quantile/mod.rs
@@ -5,7 +5,7 @@ use crate::{
     domains::{AtomDomain, VectorDomain},
     error::Fallible,
     measures::MaxDivergence,
-    traits::Number,
+    traits::{ExactIntCast, Number},
     transformations::{
         make_quantile_score_candidates, score_candidates_constants, traits::UnboundedMetric,
     },
@@ -56,7 +56,11 @@ where
 
     // scale up the noise proportionally to the increase in sensitivity
     // due to the integerization of scores
-    let denominator = score_candidates_constants(input_domain.size.clone(), alpha)?.1;
+    let denominator = score_candidates_constants(
+        input_domain.size.map(u64::exact_int_cast).transpose()?,
+        alpha,
+    )?
+    .1;
 
     let t_score =
         make_quantile_score_candidates(input_domain, input_metric, candidates.clone(), alpha)?;
@@ -64,7 +68,7 @@ where
     let m_rnm = make_report_noisy_max_gumbel(
         t_score.output_domain.clone(),
         t_score.output_metric.clone(),
-        scale * denominator as f64 * 2.0,
+        scale * denominator as f64,
         Optimize::Min,
     )?;
     let p_index = Function::new(move |idx: &usize| candidates[*idx]);
diff --git a/rust/src/measurements/private_quantile/test.rs b/rust/src/measurements/private_quantile/test.rs
index 626b2c4ac29..21352210810 100644
--- a/rust/src/measurements/private_quantile/test.rs
+++ b/rust/src/measurements/private_quantile/test.rs
@@ -26,8 +26,8 @@ fn test_private_quantile_unsized() -> Fallible<()> {
         1.0,
     )?;
     assert!((50..100).contains(&m_q75.invoke(&(0..100).collect())?));
-    // d_in * alpha / scale = 1 * (3 / 4) / 1 = 0.75
-    assert_eq!(m_q75.map(&1)?, 0.75);
+    // d_in * alpha / scale * 2 = 1 * (3 / 4) / 1 * 2 = 1.5
+    assert_eq!(m_q75.map(&1)?, 1.5);
     Ok(())
 }
 
diff --git a/rust/src/polars/test.rs b/rust/src/polars/test.rs
index e3276702dd7..45d8e2f1fa4 100644
--- a/rust/src/polars/test.rs
+++ b/rust/src/polars/test.rs
@@ -14,9 +14,11 @@ use super::*;
 
 #[test]
 fn test_dp_quantile() -> Fallible<()> {
-    let lf_domain =
-        LazyFrameDomain::new(vec![SeriesDomain::new("A", AtomDomain::<f64>::default())])?
-            .with_margin(Margin::select().with_max_partition_length(100))?;
+    let lf_domain = LazyFrameDomain::new(vec![SeriesDomain::new(
+        "A",
+        AtomDomain::<f64>::new_non_nan(),
+    )])?
+    .with_margin(Margin::select().with_max_partition_length(100))?;
 
     let lf = df!("A" => [50f64; 100])?.lazy();
 
diff --git a/rust/src/transformations/make_stable_expr/expr_discrete_quantile_score/mod.rs b/rust/src/transformations/make_stable_expr/expr_discrete_quantile_score/mod.rs
index 967c905848b..77f9f61b79e 100644
--- a/rust/src/transformations/make_stable_expr/expr_discrete_quantile_score/mod.rs
+++ b/rust/src/transformations/make_stable_expr/expr_discrete_quantile_score/mod.rs
@@ -5,7 +5,7 @@ use crate::polars::{OpenDPPlugin, apply_plugin, literal_value_of, match_plugin};
 use crate::traits::{InfCast, Number};
 use crate::transformations::traits::UnboundedMetric;
 use crate::transformations::{
-    StableExpr, score_candidates_constants, score_candidates_map, validate_candidates,
+    StableExpr, check_candidates, score_candidates_constants, score_candidates_map,
 };
 use crate::{core::Function, error::Fallible};
 
@@ -56,9 +56,21 @@ where
     if active_series.nullable {
         return fallible!(
             MakeTransformation,
-            "Quantile estimation requires non-null inputs"
+            "Quantile estimation requires non-null inputs. Try using `.fill_null(x)` or `.drop_null()` first."
         );
     }
+    let nan = match active_series.dtype() {
+        DataType::Float32 => active_series.atom_domain::<f32>()?.nan(),
+        DataType::Float64 => active_series.atom_domain::<f64>()?.nan(),
+        _ => false,
+    };
+    if nan {
+        return fallible!(
+            MakeTransformation,
+            "Quantile estimation requires non-nan inputs. Try using `.fill_nan(x)` or `.drop_nan()` first."
+        );
+    }
+
     let candidates = candidates.strict_cast(&active_series.dtype())?;
 
     match active_series.dtype() {
@@ -92,8 +104,7 @@ where
         .ok_or_else(|| err!(MakeTransformation, "Must know max_partition_length"))?;
 
     // alpha = alpha_num / alpha_den (numerator and denominator of alpha)
-    let (alpha_num, alpha_den, size_limit) =
-        score_candidates_constants::<u64>(Some(mpl as u64), alpha)?;
+    let (alpha_num, alpha_den, size_limit) = score_candidates_constants(Some(mpl as u64), alpha)?;
 
     let len = candidates.len() as i64;
     let fill_value = typed_lit(0u64).repeat_by(len).reshape(&[-1, len]);
@@ -155,7 +166,7 @@ where
             candidates.null_count()
         );
     }
-    validate_candidates(&series_to_vec::<T>(&candidates.cast(&T::get_dtype())?)?)
+    check_candidates(&series_to_vec::<T>(&candidates.cast(&T::get_dtype())?)?)
 }
 
 fn series_to_vec<'a, T: 'static + PolarsDataType>(
diff --git a/rust/src/transformations/make_stable_expr/expr_discrete_quantile_score/plugin_dq_score.rs b/rust/src/transformations/make_stable_expr/expr_discrete_quantile_score/plugin_dq_score.rs
index db74488a2b6..fe6b86b4b1c 100644
--- a/rust/src/transformations/make_stable_expr/expr_discrete_quantile_score/plugin_dq_score.rs
+++ b/rust/src/transformations/make_stable_expr/expr_discrete_quantile_score/plugin_dq_score.rs
@@ -1,5 +1,3 @@
-use std::iter::zip;
-
 use polars::{
     datatypes::{
         ArrayChunked, ArrowDataType,
@@ -25,7 +23,7 @@ use pyo3_polars::derive::polars_expr;
 #[cfg(feature = "ffi")]
 use serde::{Deserialize, Serialize};
 
-use crate::{polars::OpenDPPlugin, traits::RoundCast};
+use crate::{polars::OpenDPPlugin, traits::RoundCast, transformations::score_candidates};
 
 use super::series_to_vec;
 
@@ -119,24 +117,16 @@ fn discrete_quantile_score_udf(
     };
     let series = column.as_materialized_series();
 
-    let n = series.len() as u64;
-    let DiscreteQuantileScorePlugin {
-        candidates,
-        alpha: (alpha_num, alpha_den),
-        size_limit,
-    } = kwargs;
-
-    // compute histograms of the number of records between each candidate
-    // one histogram has left-open intervals, the other has right-open intervals
-    let (hist_lo, hist_ro) = match series.dtype() {
-        UInt32 => series_histogram::<UInt32Type>(series, candidates),
-        UInt64 => series_histogram::<UInt64Type>(series, candidates),
-        Int8 => series_histogram::<Int8Type>(series, candidates),
-        Int16 => series_histogram::<Int16Type>(series, candidates),
-        Int32 => series_histogram::<Int32Type>(series, candidates),
-        Int64 => series_histogram::<Int64Type>(series, candidates),
-        Float32 => series_histogram::<Float32Type>(series, candidates),
-        Float64 => series_histogram::<Float64Type>(series, candidates),
+    // pack scores into a series, where each row is an array of the scores for each candidate
+    let scores = match series.dtype() {
+        UInt32 => compute_scores_array::<UInt32Type>(series, kwargs),
+        UInt64 => compute_scores_array::<UInt64Type>(series, kwargs),
+        Int8 => compute_scores_array::<Int8Type>(series, kwargs),
+        Int16 => compute_scores_array::<Int16Type>(series, kwargs),
+        Int32 => compute_scores_array::<Int32Type>(series, kwargs),
+        Int64 => compute_scores_array::<Int64Type>(series, kwargs),
+        Float32 => compute_scores_array::<Float32Type>(series, kwargs),
+        Float64 => compute_scores_array::<Float64Type>(series, kwargs),
         UInt8 | UInt16 => polars_bail!(
             InvalidOperation: "u8 and u16 are not supported in the OpenDP Polars plugin. Please use u32 or u64."),
         dtype => polars_bail!(
@@ -144,26 +134,6 @@ fn discrete_quantile_score_udf(
             dtype),
     }?;
 
-    let scores_iter = zip(hist_lo, hist_ro)
-        .scan((0, 0), |(lt, le), (lo, ro)| {
-            // cumsum the left-open histogram to get the total number of records less than the candidate
-            *lt += lo;
-            // cumsum the right-open histogram to get the total number of records lt or equal to the candidate
-            *le += ro;
-
-            let gt = n - *le;
-
-            // the number of records equal to the candidate is the difference between the two cumsums
-            Some(((*lt).min(size_limit), gt.min(size_limit)))
-        })
-        .map(|(lt, gt)| {
-            // |(1 - α) * #(x < c)          -       α * #(x > c)  |    * α_den
-            ((alpha_den - alpha_num) * lt).abs_diff(alpha_num * gt)
-        });
-
-    // pack scores into a series, where each row is an array of the scores for each candidate
-    let scores = UInt64Array::from_values(scores_iter);
-
     let dtype = ArrowDataType::FixedSizeList(
         // should match how Polars initializes ArrowField
         Box::new(ArrowField::new("item".into(), ArrowDataType::UInt64, true)),
@@ -176,40 +146,32 @@ fn discrete_quantile_score_udf(
 }
 
 // PT stands for Polars Type
-fn series_histogram<PT: 'static + PolarsDataType>(
+/// Glue for calling the scorer function from Polars/Arrow dtypes.
+fn compute_scores_array<PT: 'static + PolarsDataType>(
     series: &Series,
-    candidates: Series,
-) -> PolarsResult<(Vec<u64>, Vec<u64>)>
+    kwargs: DiscreteQuantileScorePlugin,
+) -> PolarsResult<UInt64Array>
 where
-    // candidates must be able to be converted into a the physical dtype
+    // candidates must be able to be converted into a physical dtype
     for<'a> PT::Physical<'a>: 'static + RoundCast<f64> + PartialOrd,
     PT::Array: StaticArray,
 {
-    let candidates = series_to_vec::<PT>(&candidates.cast(&PT::get_dtype())?)?;
-
-    // count of the number of records between...
-    //  (-inf, c1), [c1, c2), [c2, c3), ..., [ck, inf)
-    let mut hist_lo = vec![0u64; candidates.len() + 1];
-    //  (-inf, c1], (c1, c2], (c2, c3], ..., (ck, inf)
-    let mut hist_ro = vec![0u64; candidates.len() + 1];
-
-    series
-        .unpack::<PT>()?
-        .downcast_iter()
-        .flat_map(StaticArray::values_iter)
-        .for_each(|v| {
-            let idx_lt = candidates.partition_point(|c| *c < v);
-            hist_ro[idx_lt] += 1;
-
-            let idx_eq = idx_lt + candidates[idx_lt..].partition_point(|c| *c == v);
-            hist_lo[idx_eq] += 1;
-        });
-
-    // don't care about the number of elements greater than all
-    hist_lo.pop();
-    hist_ro.pop();
+    let DiscreteQuantileScorePlugin {
+        candidates,
+        alpha: (alpha_num, alpha_den),
+        size_limit,
+    } = kwargs;
 
-    Ok((hist_lo, hist_ro))
+    Ok(UInt64Array::from_values(score_candidates(
+        series
+            .unpack::<PT>()?
+            .downcast_iter()
+            .flat_map(StaticArray::values_iter),
+        series_to_vec::<PT>(&candidates.cast(&PT::get_dtype())?)?,
+        alpha_num,
+        alpha_den,
+        size_limit,
+    )))
 }
 
 #[cfg(feature = "ffi")]
diff --git a/rust/src/transformations/make_stable_expr/expr_discrete_quantile_score/test.rs b/rust/src/transformations/make_stable_expr/expr_discrete_quantile_score/test.rs
index 9567bd1e355..9df9a0a449a 100644
--- a/rust/src/transformations/make_stable_expr/expr_discrete_quantile_score/test.rs
+++ b/rust/src/transformations/make_stable_expr/expr_discrete_quantile_score/test.rs
@@ -71,6 +71,7 @@ fn test_expr_discrete_quantile_score_int() -> Fallible<()> {
     let candidates = Series::new("".into(), [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
 
     let m_quant: Transformation<_, _, _, Parallel<LInfDistance<f64>>> = col("cycle_(..10i32)")
+        .cast(DataType::Int64)
         .dp()
         .quantile_score(0.5, candidates)
         .make_stable(expr_domain, PartitionDistance(SymmetricDistance))?;
diff --git a/rust/src/transformations/quantile_score_candidates/check_candidates.tex b/rust/src/transformations/quantile_score_candidates/check_candidates.tex
new file mode 100644
index 00000000000..909e6b9c815
--- /dev/null
+++ b/rust/src/transformations/quantile_score_candidates/check_candidates.tex
@@ -0,0 +1,49 @@
+\documentclass{article}
+\input{../../lib.sty}
+
+\title{\texttt{fn check\_candidates}}
+\author{Michael Shoemate}
+\begin{document}
+\maketitle  
+
+
+\contrib
+
+Proves soundness of \rustdoc{transformations/quantile\_score\_candidates/fn}{check\_candidates} 
+in \asOfCommit{mod.rs}{f5bb719}.
+\texttt{check\_candidates} raises an error if the discrete quantile candidate set is invalid.
+
+\section{Hoare Triple}
+\subsection*{Precondition}
+None
+
+\subsection*{Function}
+\label{sec:python-pseudocode}
+\lstinputlisting[language=Python,firstline=2,escapechar=`]{./pseudocode/check_candidates.py}
+
+\subsection*{Postcondition}
+
+\begin{theorem}
+    Candidates must be:
+    \begin{enumerate}
+        \item non-empty
+        \item strictly increasing
+        \item totally ordered
+    \end{enumerate}
+
+    Otherwise the function errors.
+\end{theorem}
+
+\begin{proof}
+    The postconditions can be directly checked:
+    \begin{enumerate}
+        \item \texttt{candidates} is non-empty, by the check on line \ref{empty}
+        \item \texttt{candidates} is strictly increasing, 
+            because there is no window where the left candidate is not less than the right candidate
+        \item \texttt{candidates} is totally ordered, 
+            because no comparisons may fail
+    \end{enumerate}
+    Therefore the postcondition holds.
+\end{proof}
+
+\end{document}
diff --git a/rust/src/transformations/quantile_score_candidates/compute_score.tex b/rust/src/transformations/quantile_score_candidates/compute_score.tex
deleted file mode 100644
index d69e0eb6e1e..00000000000
--- a/rust/src/transformations/quantile_score_candidates/compute_score.tex
+++ /dev/null
@@ -1,64 +0,0 @@
-\documentclass{article}
-\input{../../lib.sty}
-
-% \externaldocument[trans_mod:]{../mod}
-% \ref{trans_mod:thrm:privacy-proof}
-
-\title{\texttt{fn compute\_score}}
-\author{Michael Shoemate}
-\begin{document}
-\maketitle  
-
-
-\contrib
-
-Proves soundness of \rustdoc{transformations/quantile\_score\_candidates/fn}{compute\_score} 
-in \asOfCommit{mod.rs}{f5bb719}.
-\texttt{compute\_score} returns a score for each candidate passed in, 
-where the score is the distance between the candidate and the ideal alpha-quantile.
-
-\subsection*{Vetting History}
-\begin{itemize}
-    \item \vettingPR{456}
-\end{itemize}
-
-\section{Hoare Triple}
-\subsection*{Precondition}
-\begin{itemize}
-    \item \texttt{TIA} (input atom type) is a type with trait \texttt{PartialOrd}.
-    \item \texttt{x} is non-null
-    \item \texttt{candidates} is strictly increasing
-    \item \texttt{alpha\_numer / alpha\_denom} is in $[0, 1]$
-    \item \texttt{size\_limit * alpha\_denom} does not overflow
-\end{itemize}
-
-
-\subsection*{Function}
-\label{sec:python-pseudocode}
-\lstinputlisting[language=Python,firstline=2]{./pseudocode/compute_score.py}
-
-
-\subsection*{Postcondition}
-Each element in the return value corresponds to the score of the candidate at the same index in \texttt{candidates}:
-
-\begin{equation}
-    \textrm{compute\_score}(X, c, \alpha_{num}, \alpha_{den}, l) = |\alpha_{den} \cdot \min(\#(X < c), l), \alpha_{num} \cdot \min(|X| - \#(X = c), l)|
-\end{equation}
-
-
-\section{Proof}
-
-By the preconditions on \texttt{compute\_count}, 
-and by sorting $x$, the preconditions on \texttt{count\_lt\_eq\_recursive} are satisfied.
-
-By the definition of \texttt{count\_lt\_eq\_recursive}, 
-\texttt{num\_lt} contains the number of values in $x$ less than $c$, for each candidate, 
-and similarly for \texttt{num\_eq}.
-
-Then the score is evaluated for each candidate in a loop.
-The scoring function cannot overflow or return null because the risk of overflow is protected 
-by the preconditions.
-The function is also completely non-negative due to the use of $\texttt{abs\_diff}$.
-
-
-\end{document}
diff --git a/rust/src/transformations/quantile_score_candidates/make_quantile_score_candidates.tex b/rust/src/transformations/quantile_score_candidates/make_quantile_score_candidates.tex
index 184fb018cd9..06ab3d38cf1 100644
--- a/rust/src/transformations/quantile_score_candidates/make_quantile_score_candidates.tex
+++ b/rust/src/transformations/quantile_score_candidates/make_quantile_score_candidates.tex
@@ -1,9 +1,6 @@
 \documentclass{article}
 \input{../../lib.sty}
 
-% \externaldocument[trans_mod:]{../mod}
-% \ref{trans_mod:thrm:privacy-proof}
-
 \title{\texttt{fn make\_quantile\_score\_candidates}}
 \author{Michael Shoemate \and Christian Covington \and Ira Globus-Harrus}
 \begin{document}
@@ -18,11 +15,6 @@
 takes a numeric vector database and a vector of numeric quantile candidates,
 and returns a vector of scores, where higher scores correspond to more accurate candidates.
 
-\subsection*{Vetting History}
-\begin{itemize}
-    \item \vettingPR{456}
-\end{itemize}
-
 \section{Intuition}
 The quantile score function scores each $c$ in a set of candidates $C$.
 
@@ -152,7 +144,7 @@ \section{Finite Data Types}
 Therefore, no quantity in the following equation is not representable.
 
 \begin{equation}
-    \textrm{score}(x, c, \alpha_{num}, \alpha_{den}, l) = \mathrm{abs\_diff}(\alpha_{den} \cdot \min(\#(x < c), l), \alpha_{num} \cdot \min(|x| - \#(x = c), l))
+    \textrm{score\_candidates}_i(x, C, \alpha_{num}, \alpha_{den}, l) = \mathrm{abs\_diff}(\alpha_{den} \cdot \min(\#(x < C_i), l), \alpha_{num} \cdot \min(|\#(x > C_i), l))
 \end{equation}
 
 Should we compute counts with a 64-bit integer, we might choose $\alpha_{den}$ to be 10,000.
@@ -162,9 +154,8 @@ \section{Finite Data Types}
 \section{Hoare Triple}
 \subsection*{Precondition}
 \begin{itemize}
+    \item \texttt{MI} is a type with trait \rustdoc{transformations/trait}{UnboundedMetric}.
     \item \texttt{TIA} (input atom type) is a type with trait \rustdoc{traits/trait}{Number}.
-    \item \texttt{A} (alpha type) is a type with trait \rustdoc{traits/trait}{Float}.
-    \item \texttt{MI} is a type with trait \rustdoc{transformations/trait}{ARDatasetMetric}.
 \end{itemize}
 
 \subsection*{Function}
@@ -174,161 +165,33 @@ \subsection*{Function}
 
 \subsection*{Postcondition}
 \validTransformation
-    {(\texttt{input\_domain, input\_metric, candidates, alpha, TIA, A, MI})}
-    {\texttt{make\_quantile\_ score\_candidates}}
-
-\section{Proof}
-\subsection{Appropriate Output Domain}
-\label{sec:approp-output-domain}
-The raw type and domain are equivalent, save for potential nullity in the atomic type. 
-The scalar scorer structurally cannot emit null.
-Therefore the output of the function is a member of the output domain.
-
-\subsection{Stability Guarantee}
-
-The constructor first performs checks to ensure that the preconditions on \texttt{compute\_score} are met.
-It checks that vectors in the input domain do not contain null values,
-that the candidates are strictly increasing,
-that alpha is fractional and in the range $[0, 1]$,
-and computes a \texttt{size\_limit} for which $\texttt{size\_limit} \cdot \texttt{alpha\_den}$ does not overflow a \texttt{usize}.
-Thus by the definition of \texttt{compute\_score}, for each candidate, the response from the function is:
-
-\begin{equation}
-    \textrm{compute\_score}(x, c, \alpha_{num}, \alpha_{den}, l) = |\alpha_{den} \cdot \min(\#(x < c), l), \alpha_{num} \cdot \min(|x| - \#(x = c), l)|
-\end{equation}
-
-The sensitivity of this function differs depending on if the size of the input vector is known.
-
-\subsubsection{Unknown Size Stability}
-\label{unsized-stability}
-First, consider the case where the size is unknown.
+    {(\texttt{input\_domain, input\_metric, candidates, alpha, MI, TIA})}
+    {\texttt{make\_quantile\_\\score\_candidates}}
 
-\begin{lemma}
-    \label{single-change-stab-sym}
-    If $d_{Sym}(x, x') = 1$, then $d_\infty(\function(x), \function(x')) \le \max(\alpha_{num}, \alpha_{den} - \alpha_{num})$.
-\end{lemma}
-
-\begin{proof}
-Assume $d_{Sym}(x, x') = 1$. 
-
-\begin{align*}
-    &d_\infty(\function(x)_i, \function(x')_i) \\
-    &= \max\limits_{i} |\function(x)_i - \function(x')_i| &&\text{by definition of } d_\infty \\
-    &= \max\limits_{i} |\mathrm{abs\_diff}(\alpha_{den} \cdot \min(\#(x < C_i), l), \alpha_{num} \cdot \min(|x| - \#(x = C_i), l)) &&\text{by definition of } \function
-        \\&\hspace{1cm}\mathrm{abs\_diff}(\alpha_{den} \cdot \min(\#(x' < C_i), l), \alpha_{num} \cdot \min(|x'| - \#(x' = C_i), l))| \\
-    &= \alpha_{den} \cdot \max\limits_{i} ||\min(\#(x < C_i), l) - \alpha \cdot \min(|x| - \#(x = C_i), l)|
-        \\&\hspace{1cm}|\min(\#(x' < C_i), l) - \alpha \cdot \min(|x'| - \#(x' = C_i), l)|| \\
-    &\le \alpha_{den} \cdot \max\limits_{i} ||\#(x < C_i) - \alpha \cdot (|x| - \#(x = C_i))|
-        \\&\hspace{1cm}|\#(x' < C_i) - \alpha \cdot (|x| - \#(x' = C_i))|| \\
-\intertext{Consider each of the three cases of adding or removing an element in x.}
-\intertext{Case 1. Assume $x'$ is equal to $x$, but with some $x_j < C_i$ added or removed.}
-    &= \alpha_{den} \cdot \max\limits_{i} ||(1 - \alpha) \cdot \#(x < C_i) - \alpha \cdot \#(x > C_i)| 
-        \\&\hspace{1cm} - |(1 - \alpha) \cdot (\#(x < C_i) \pm 1) - \alpha \cdot \#(x > C_i)|| \\
-    &\leq \alpha_{den} \cdot  \max\limits_{i} ||(1 - \alpha) \cdot \#(x < C_i) - \alpha \cdot \#(x > C_i)|
-        \\&\hspace{1cm} - (|(1 - \alpha) \cdot \#(x < C_i) - \alpha \cdot \#(x > C_i)| + |\pm(1 - \alpha)|)| &&\text{by triangle inequality} \\
-    &= \alpha_{den} \cdot \max\limits_{i} |1 - \alpha| &&\text{scores cancel}\\
-    &= \alpha_{den} - \alpha_{num} &&\text{since } \alpha \leq 1
-\intertext{Case 2. Assume $x'$ is equal to $x$, but with some $x_j > C_i$ added or removed.}
-    &= \alpha_{den} \cdot \max\limits_{i} ||(1 - \alpha) \cdot \#(x < C_i) - \alpha \cdot \#(x > C_i)| 
-        \\&\hspace{1cm} - |(1 - \alpha) \cdot \#(x < C_i) - \alpha \cdot (\#(x > C_i) \pm 1)|| \\
-    &\leq \alpha_{den} \cdot \max\limits_{i} ||(1 - \alpha) \cdot \#(x < C_i) - \alpha \cdot \#(x > C_i)| 
-        \\&\hspace{1cm} - (|(1 - \alpha) \cdot \#(x < C_i) - \alpha \cdot \#(x > C_i)| + |\pm\alpha|)| &&\text{by triangle inequality} \\
-    &= \alpha_{den} \cdot \max\limits_{i} |\alpha| &&\text{scores cancel}\\
-    &= \alpha_{num} &&\text{since } \alpha \geq 0
-\intertext{Case 3. Assume $x'$ is equal to $x$, but with some $x_j = C_i$ added or removed.}
-    &= \alpha_{den} \cdot \max\limits_{i} ||(1 - \alpha) \cdot \#(x < C_i) - \alpha \cdot \#(x > C_i)| 
-        \\&\hspace{1cm} - |(1 - \alpha) \cdot \#(x < C_i) - \alpha \cdot \#(x > C_i)|| \\
-    &= 0 &&\text{no change in score}
-\intertext{Take the union bound over all cases.}
-    &\le \max(\alpha_{num}, \alpha_{den} - \alpha_{num})
-\end{align*}
 
+\begin{proof}[Proof of Appropriate Output Domain]
+    \label{sec:approp-output-domain}
+    The raw type and domain are equivalent, save for potential nullity in the atomic type. 
+    The scalar scorer structurally cannot emit null.
+    Therefore the output of the function is a member of the output domain.
 \end{proof}
 
-Take any two elements $x, x'$ in the \texttt{input\_domain} and any pair $(\din, \dout)$, 
-where \din\ has the associated type for \texttt{input\_metric} and \dout\ has the associated type for \texttt{output\_metric}.
-Assume $x, x'$ are \din-close under \texttt{input\_metric} and that $\texttt{stability\_map}(\din) \leq \dout$. 
+\begin{proof}[Proof of Stability]
+    \label{sec:stability}
+    The constructor first performs checks to ensure that the preconditions on \rustdoc{transformations/quantile\_score\_candidates/fn}{score\_candidates} and \rustdoc{transformations/quantile\_score\_candidates/fn}{score\_candidates\_map} are met.
+    It checks that vectors in the input domain do not contain null values,
+    that the candidates are strictly increasing and totally ordered,
+    that alpha is in the range $[0, 1]$,
+    and computes a \texttt{size\_limit} for which $\texttt{size\_limit} \cdot \texttt{alpha\_den}$ does not overflow a \texttt{u64},
+    and that $\texttt{alpha\_num} \le \texttt{alpha\_den}$.
 
-\begin{align*}
-    \dout &= \max\limits_{x \sim x'} d_{\infty}(s, s') &&\text{where } s = \function(x)\\
-    &= \max\limits_{x \sim x'} \max\limits_{i} |s_i - s'_i| &&\text{by definition of }\rustdoc{metrics/struct}{LInfDistance} \text{, without monotonicity} \\
-    &\leq \sum_j^{\din} \max\limits_{Z_{j} \sim Z_{j+1}} \max\limits_{i} |s_{i,j} - s_{i,j+1}| &&\text{by path property } d_{Sym}(Z_i, Z_{i+1}) = 1, x = Z_0 \text{ and } x' = Z_\din \\
-    &\leq \sum_j^{\din} \max(\alpha_{num}, \alpha_{den} - \alpha_{num}) &&\text{by \ref{single-change-stab-sym}} \\
-    &\leq \din \cdot \max(\alpha_{num}, \alpha_{den} - \alpha_{num}) \\
-\end{align*}
-\label{sec:unknown-size}
-This formula matches the stability map in the case where the dataset size is unknown.
+    Therefore the preconditions of both \rustdoc{transformations/quantile\_score\_candidates/fn}{score\_candidates} and \rustdoc{transformations/quantile\_score\_candidates/fn}{score\_candidates\_map} are met.
 
-
-\subsubsection{Known Size Stability}
-\label{sec:known-size}
-Now consider the case where the dataset size is known.
-
-\begin{lemma}
-    \label{single-change-stab}
-    If $d_{CO}(x, x') \le 1$, then $d_\infty(\function(x), \function(x')) \le 2 \cdot \alpha_{den}$.
-\end{lemma}
-
-\begin{proof}
-Assume $d_{CO}(x, x') \le 1$. 
-
-\begin{align*}
-    &d_\infty(\function(x), \function(x')) \\
-    &= \max\limits_{i} |\function(x)_i - \function(x')_i| &&\text{by definition of } d_\infty \\
-    &= \max\limits_{i} |\mathrm{abs\_diff}(\alpha_{den} \cdot \min(\#(x < C_i), l), \alpha_{num} \cdot \min(|x| - \#(x = C_i), l)) &&\text{by def. of } \function
-        \\&\hspace{1cm} -\mathrm{abs\_diff}(\alpha_{den} \cdot \min(\#(x' < C_i), l), \alpha_{num} \cdot \min(|x'| - \#(x' = C_i), l))| \\
-    &= \alpha_{den} \cdot \max\limits_{i} ||\min(\#(x < C_i), l) - \alpha \cdot \min(|x| - \#(x = C_i), l)|
-        \\&\hspace{1cm}-|\min(\#(x' < C_i), l) - \alpha \cdot \min(|x'| - \#(x' = C_i), l)|| \\
-    &= \alpha_{den} \cdot \max\limits_{i} ||\#(x < C_i) - \alpha \cdot (|x| - \#(x = C_i))|
-        \\&\hspace{1cm}-|\#(x' < C_i) - \alpha \cdot (|x| - \#(x' = C_i))|| \\
-\end{align*}
-
-\begin{align*}
-\intertext{Consider each of the four cases of changing a row in x.}
-\intertext{Case 1. Assume $x'$ is equal to $x$, but with some $x_j < C_i$ replaced with $x'_j > C_i$.}
-    &= 2 \cdot \alpha_{den} \cdot \max\limits_{i} ||(1 - \alpha) \cdot \#(x < C_i) - \alpha \cdot \#(x > C_i)| 
-        \\&\hspace{1cm} - (1 - \alpha) \cdot (\#(x < C_i) - 1) - \alpha \cdot (\#(x > C_i) + 1)|| &&\text{by definition of }\function \\
-    &\leq 2 \cdot \alpha_{den} \cdot \max\limits_{i} ||(1 - \alpha) \cdot \#(x < C_i) - \alpha \cdot \#(x > C_i)|
-        \\&\hspace{1cm} - (|(1 - \alpha) \cdot \#(x < C_i) - \alpha \cdot \#(x > C_i)| + |1|)| &&\text{by triangle inequality} \\
-    &= 2 \cdot \alpha_{den} \cdot \max\limits_{i} |1| &&\text{scores cancel}\\
-    &= 2 \cdot \alpha_{den}
-\intertext{Case 2. Assume $x'$ is equal to $x$, but with some $x_j > C_i$ replaced with $x'_j < C_i$.}
-    &= 2 \cdot \alpha_{den}
-    \intertext{\hspace{1cm}by symmetry, follows from Case 1.}
-\intertext{Case 3. Assume $x'$ is equal to $x$, but with some $x_j \neq C_i$ replaced with $C_i$.}
-    &\leq 2 \cdot \max(\alpha_{num}, \alpha_{den} - \alpha_{num})\\
-    \intertext{\hspace{1cm}equivalent to one removal (see \rustdoc{transformations/fn}{make\_quantile\_score\_candidates})}
-\intertext{Case 4. Assume $x'$ is equal to $x$, but with some $x_j = C_i$ replaced with $x'_j \neq C_i$.}
-    &\leq 2 \cdot \max(\alpha_{num}, \alpha_{den} - \alpha_{num}) \\
-\intertext{\hspace{1cm}equivalent to one addition (see \rustdoc{transformations/fn}{make\_quantile\_score\_candidates})}
-\intertext{Take the union bound over all cases.}
-    d_\infty(s_i, s'_i) &\le \max(2 \cdot \alpha_{den}, 2 \cdot \max(\alpha_{num}, \alpha_{den} - \alpha_{num})) = 2 \cdot \alpha_{den}
-    \intertext{\hspace{1cm}since $\max(\alpha, 1 - \alpha) \leq 1$}
-\end{align*}
+    Further, by the postcondition of \texttt{score\_candidates},
+    the conditions of the postcondition of \texttt{score\_candidates\_map} function are met,
+    meaning that the transformation is stable.
 \end{proof}
 
-Take any two elements $x, x'$ in the \texttt{input\_domain} and any pair $(\din, \dout)$, 
-where \din\ has the associated type for \texttt{input\_metric} and \dout\ has the associated type for \texttt{output\_metric}.
-Assume $x, x'$ are \din-close under \texttt{input\_metric} and that $\texttt{stability\_map}(\din) \leq \dout$. 
-
-\begin{align*}
-    \dout &= \max\limits_{x \sim x'} d_{\infty}(s, s') \\
-    &= \max\limits_{x \sim x'} \max\limits_{i} |s_i - s'_i| &&\text{by definition of }\rustdoc{metrics/struct}{LInfDistance}\text{, without monotonicity} \\
-    &\leq \sum_j^{\din // 2} \max\limits_{Z_{j} \sim Z_{j+1}} \max\limits_{i} |s_{i,j} - s_{i,j+1}| &&\text{by path property } d_{CO}(Z_i, Z_{i+1}) = 1, x = Z_0 \text{ and } Z_\din = x' \\
-    &\leq \sum_j^{\din // 2} 2 \cdot \alpha_{den} &&\text{by \ref{single-change-stab}} \\
-    &\leq 2 \cdot (\din // 2) \cdot \alpha_{den} \\
-\end{align*}
-
-This formula matches the stability map in the case where the dataset size is known.
-
-\subsubsection{Conclusion}
-Take any two elements $x, x'$ in the \texttt{input\_domain} and any pair $(\din, \dout)$, 
-where \din\ has the associated type for \texttt{input\_metric} and \dout\ has the associated type for \texttt{output\_metric}.
-Assume $x, x'$ are \din-close under \texttt{input\_metric} and that $\texttt{stability\_map}(\din) \leq \dout$. 
-
-By \ref{sec:unknown-size} and \ref{sec:known-size} it is shown that \function(x), \function(x') are \dout-close under \texttt{output\_metric} for any choice of input arguments.
-
-
 \bibliographystyle{plain}
 \bibliography{references.bib}
 
diff --git a/rust/src/transformations/quantile_score_candidates/mod.rs b/rust/src/transformations/quantile_score_candidates/mod.rs
index 3a04c6d44f2..e1d2b347172 100644
--- a/rust/src/transformations/quantile_score_candidates/mod.rs
+++ b/rust/src/transformations/quantile_score_candidates/mod.rs
@@ -1,15 +1,14 @@
-use std::cmp::Ordering;
+use std::{cmp::Ordering, iter::zip};
 
 use dashu::{integer::UBig, rational::RBig};
-use num::NumCast;
-use opendp_derive::bootstrap;
+use opendp_derive::{bootstrap, proven};
 
 use crate::{
     core::{Function, MetricSpace, StabilityMap, Transformation},
     domains::{AtomDomain, VectorDomain},
     error::Fallible,
     metrics::{IntDistance, LInfDistance},
-    traits::{ExactIntCast, Integer, Number, RoundCast},
+    traits::{AlertingMul, ExactIntCast, InfDiv, Integer, Number, RoundCast},
 };
 
 use super::traits::UnboundedMetric;
@@ -17,6 +16,9 @@ use super::traits::UnboundedMetric;
 #[cfg(feature = "ffi")]
 mod ffi;
 
+#[cfg(test)]
+mod test;
+
 #[bootstrap(
     features("contrib"),
     generics(MI(suppress), TIA(suppress)),
@@ -24,7 +26,6 @@ mod ffi;
 )]
 /// Makes a Transformation that scores how similar each candidate is to the given `alpha`-quantile on the input dataset.
 ///
-///
 /// # Arguments
 /// * `input_domain` - Uses a smaller sensitivity when the size of vectors in the input domain is known.
 /// * `input_metric` - Either SymmetricDistance or InsertDeleteDistance.
@@ -42,27 +43,39 @@ pub fn make_quantile_score_candidates<MI: UnboundedMetric, TIA: Number>(
 ) -> Fallible<
     Transformation<
         VectorDomain<AtomDomain<TIA>>,
-        VectorDomain<AtomDomain<usize>>,
+        VectorDomain<AtomDomain<u64>>,
         MI,
-        LInfDistance<usize>,
+        LInfDistance<u64>,
     >,
 >
 where
     (VectorDomain<AtomDomain<TIA>>, MI): MetricSpace,
 {
     if input_domain.element_domain.nan() {
-        return fallible!(MakeTransformation, "input must be non-null");
+        return fallible!(
+            MakeTransformation,
+            "input_domain members must have non-nan elements"
+        );
     }
 
-    validate_candidates(&candidates)?;
+    check_candidates(&candidates)?;
 
-    let (alpha_num, alpha_den, size_limit) = score_candidates_constants(input_domain.size, alpha)?;
+    let (alpha_num, alpha_den, size_limit) = score_candidates_constants(
+        input_domain.size.map(u64::exact_int_cast).transpose()?,
+        alpha,
+    )?;
 
-    Transformation::<_, VectorDomain<AtomDomain<usize>>, _, _>::new(
+    Transformation::<_, VectorDomain<AtomDomain<u64>>, _, _>::new(
         input_domain.clone(),
         VectorDomain::default().with_size(candidates.len()),
         Function::new(move |arg: &Vec<TIA>| {
-            compute_score(arg.clone(), &candidates, alpha_num, alpha_den, size_limit)
+            Vec::from_iter(score_candidates(
+                arg.iter().cloned(),
+                candidates.clone(),
+                alpha_num,
+                alpha_den,
+                size_limit,
+            ))
         }),
         input_metric,
         LInfDistance::default(),
@@ -74,32 +87,49 @@ where
     )
 }
 
-pub(crate) fn validate_candidates<T: Number>(candidates: &Vec<T>) -> Fallible<()> {
+/// # Proof Definition
+/// Raises an error if:
+/// * `candidates` is empty
+/// * `candidates` is not strictly increasing
+/// * `candidates` is not totally ordered
+#[proven(proof_path = "transformations/quantile_score_candidates/check_candidates.tex")]
+pub(crate) fn check_candidates<T: Number>(candidates: &Vec<T>) -> Fallible<()> {
     if candidates.is_empty() {
         return fallible!(MakeTransformation, "candidates must be non-empty");
     }
     if candidates.windows(2).any(|w| {
         w[0].partial_cmp(&w[1])
+            // NaN always evaluates to false
             .map(|c| c != Ordering::Less)
             .unwrap_or(true)
     }) {
         return fallible!(
             MakeTransformation,
-            "candidates must be non-null and increasing"
+            "candidates must be non-null and strictly increasing"
         );
     }
     Ok(())
 }
 
-pub(crate) fn score_candidates_constants<T: Integer>(
-    size: Option<T>,
+/// Returns the following constants:
+/// * `alpha_num`
+/// * `alpha_den`
+/// * `size_limit`
+///
+/// The constants are chosen to ensure that `alpha_num / alpha_den` is approximately equal to `alpha`,
+/// in a way that minimizes `alpha_den` to maximize `size_limit`.
+///
+/// # Proof Definition
+/// The constants follow the following properties:
+/// * $alpha_num / alpha_den \in [0, 1]$
+/// * $size_limit \cdot alpha_den < 2^{64}$
+///
+/// An error is raised if these properties cannot be met.
+#[proven(proof_path = "transformations/quantile_score_candidates/score_candidates_constants.tex")]
+pub(crate) fn score_candidates_constants(
+    size: Option<u64>,
     alpha: f64,
-) -> Fallible<(T, T, T)>
-where
-    f64: RoundCast<T>,
-    T: RoundCast<f64> + NumCast,
-    UBig: From<T>,
-{
+) -> Fallible<(u64, u64, u64)> {
     if !(0.0..=1.0).contains(&alpha) {
         return fallible!(MakeTransformation, "alpha must be within [0, 1]");
     }
@@ -109,33 +139,47 @@ where
     let alpha_den_approx = if let Some(size) = size {
         // choose the finest granularity that won't overflow
         // must have that size * denom < MAX, so let denom = MAX // size
-        T::MAX_FINITE.neg_inf_div(&size)?
+        u64::MAX.neg_inf_div(&size)?
     } else {
         // default to an alpha granularity of .00001
-        T::exact_int_cast(10_000)?
+        u64::exact_int_cast(10_000)?
     };
 
     let (alpha_num, alpha_den) = if alpha_den_exact < UBig::from(alpha_den_approx) {
-        T::from(alpha_num_exact.into_parts().1)
-            .zip(T::from(alpha_den_exact))
-            .unwrap()
+        (
+            u64::try_from(alpha_num_exact.into_parts().1)?,
+            u64::try_from(alpha_den_exact)?,
+        )
     } else {
         // numer = alpha * denom
-        let alpha_num_approx = T::round_cast(alpha * f64::round_cast(alpha_den_approx.clone())?)?;
+        let alpha_num_approx = u64::round_cast(alpha * f64::round_cast(alpha_den_approx.clone())?)?;
         (alpha_num_approx, alpha_den_approx)
     };
 
     let size_limit = if let Some(size_limit) = size {
-        // ensures that there is no overflow
-        size_limit.alerting_mul(&alpha_den)?;
         size_limit
     } else {
-        T::MAX_FINITE.neg_inf_div(&alpha_den)?
+        u64::MAX.neg_inf_div(&alpha_den)?
     };
 
+    // both of these are un-fail-able, but they make the proof easy
+    assert!(alpha_num <= alpha_den);
+    size_limit.alerting_mul(&alpha_den)?;
+
     Ok((alpha_num, alpha_den, size_limit))
 }
 
+/// # Proof Definition
+/// Assume `alpha_den >= alpha_num`.
+///
+/// If `known_size` is set,
+/// then returns a function that computes $\texttt{d\_in} // 2 \cdot \texttt{alpha\_den}$
+/// for argument $\texttt{d\_in}$.
+///
+/// Otherwise,
+/// returns a function that computes $\texttt{d\_in} \cdot \max(\texttt{alpha\_num}, \texttt{alpha\_den} - \texttt{alpha\_num})$,
+/// for argument $\texttt{d\_in}$.
+#[proven(proof_path = "transformations/quantile_score_candidates/score_candidates_map.tex")]
 pub(crate) fn score_candidates_map<T: Integer + ExactIntCast<IntDistance>>(
     alpha_num: T,
     alpha_den: T,
@@ -143,7 +187,7 @@ pub(crate) fn score_candidates_map<T: Integer + ExactIntCast<IntDistance>>(
 ) -> impl Fn(&IntDistance) -> Fallible<T> {
     move |d_in| {
         if known_size {
-            T::exact_int_cast(d_in / 2 * 2)? // round down to even
+            T::exact_int_cast(d_in / 2)? // round down to even
                 .alerting_mul(&alpha_den)
         } else {
             let abs_dist_const = alpha_num.max(alpha_den - alpha_num);
@@ -156,15 +200,16 @@ pub(crate) fn score_candidates_map<T: Integer + ExactIntCast<IntDistance>>(
 ///
 /// # Proof Definition
 ///
-/// Under the precondition that `x` is non-null,
+/// Under the precondition that `x` is totally ordered,
+/// that `x` has no greater than $2^{64}$ elements,
 /// that `candidates` is strictly increasing,
-/// that `alpha_numer / alpha_denom` is in [0, 1],
-/// and that `size_limit * alpha_denom` does not overflow,
+/// that `alpha_numer / alpha_denom <= 1`,
+/// and that `size_limit * alpha_denom < 2^{64}`,
 /// computes the score of each candidate in `candidates` on the dataset `x`.
 ///
 /// The score for each `c` in `candidates` is computed as follows:
 /// |alpha_denom * min(#(x < c), size_limit) -
-///  alpha_numer * min(n - #(x = c)), size_limit)|
+///  alpha_numer * min(#(x > c)), size_limit)|
 ///
 /// # Intuition
 /// Lower score is better.
@@ -178,141 +223,48 @@ pub(crate) fn score_candidates_map<T: Integer + ExactIntCast<IntDistance>>(
 /// * `alpha_num` - numerator of alpha fraction
 /// * `alpha_den` - denominator of alpha fraction. alpha fraction is {0: min, 0.5: median, 1: max, ...}
 /// * `size_limit` - maximum size of `x`. If `x` is larger than `size_limit`, scores are truncated
-pub(crate) fn compute_score<TIA: PartialOrd>(
-    mut x: Vec<TIA>,
-    candidates: &Vec<TIA>,
-    alpha_num: usize,
-    alpha_den: usize,
-    size_limit: usize,
-) -> Vec<usize> {
-    // x must be sorted because counts are done via binary search
-    x.sort_by(|a, b| a.partial_cmp(&b).unwrap_or(Ordering::Equal));
-
-    // compute #(`x` < c) and #(`x` = c) for each c in candidates
-    let mut num_lt = vec![0; candidates.len()];
-    let mut num_eq = vec![0; candidates.len()];
-    count_lt_eq_recursive(
-        num_lt.as_mut_slice(),
-        num_eq.as_mut_slice(),
-        candidates.as_slice(),
-        x.as_slice(),
-        0,
-    );
-
-    // now that we have num_lt and num_eq for each candidate, score all candidates
-    num_lt
-        .into_iter()
-        .zip(num_eq.into_iter())
-        // score function cannot overflow.
-        //     lt <= size_limit, so 0 <= alpha_denom * lt <= usize::MAX
-        //     n - eq <= size_limit, so 0 <= size_limit - eq
-        .map(|(lt, eq)| {
-            // |α_den * #(x < c) - α_num * (n - #(x = c))|
-            (alpha_den * lt.min(size_limit)).abs_diff(alpha_num * (x.len() - eq).min(size_limit))
-        })
-        .collect()
-}
-
-/// Compute number of elements less than and equal to each edge
-/// Formula is (#(`x` < `e`), #(`x` == `e`)) for each e in `edges`.
-///
-/// # Arguments
-/// * `count_lt` - location to write the count of elements less than each edge
-/// * `count_eq` - location to write the count of elements equal to each edge
-/// * `edges` - edges to collect counts for. Must be sorted
-/// * `x` - dataset to count against
-/// * `x_start_idx` - value to add to the count. Useful for recursion on subslices
-fn count_lt_eq_recursive<TI: PartialOrd>(
-    count_lt: &mut [usize],
-    count_eq: &mut [usize],
-    edges: &[TI],
-    x: &[TI],
-    x_start_idx: usize,
-) {
-    if edges.is_empty() {
-        return;
-    }
-    if edges.len() == 1 {
-        let (num_lt, num_eq) = count_lt_eq(x, &edges[0]);
-        count_lt[0] = x_start_idx + num_lt;
-        count_eq[0] = num_eq;
-        return;
-    }
-    // use binary search to find #(x < middle edge) = |{i; x[i] < middle edge}|
-    let mid_edge_idx = (edges.len() + 1) / 2;
-    let mid_edge = &edges[mid_edge_idx];
-    let (num_lt, num_eq) = count_lt_eq(x, mid_edge);
-    count_lt[mid_edge_idx] = x_start_idx + num_lt;
-    count_eq[mid_edge_idx] = num_eq;
+#[proven(proof_path = "transformations/quantile_score_candidates/score_candidates.tex")]
+pub(crate) fn score_candidates<TIA: PartialOrd>(
+    x: impl Iterator<Item = TIA>,
+    candidates: Vec<TIA>,
+    alpha_num: u64,
+    alpha_den: u64,
+    size_limit: u64,
+) -> impl Iterator<Item = u64> {
+    // count of the number of records between...
+    //  (-inf, c1), [c1, c2), [c2, c3), ..., [ck, inf)
+    let mut hist_ro = vec![0u64; candidates.len() + 1]; // histogram of right-open intervals
+    //  (-inf, c1], (c1, c2], (c2, c3], ..., (ck, inf)
+    let mut hist_lo = vec![0u64; candidates.len() + 1]; // histogram of left-open intervals
 
-    count_lt_eq_recursive(
-        &mut count_lt[..mid_edge_idx],
-        &mut count_eq[..mid_edge_idx],
-        &edges[..mid_edge_idx],
-        &x[..num_lt + num_eq],
-        x_start_idx,
-    );
+    x.for_each(|x_i| {
+        let idx_lt = candidates.partition_point(|c| *c < x_i);
+        hist_lo[idx_lt] += 1;
 
-    count_lt_eq_recursive(
-        &mut count_lt[mid_edge_idx + 1..],
-        &mut count_eq[mid_edge_idx + 1..],
-        &edges[mid_edge_idx + 1..],
-        &x[num_lt + num_eq..],
-        x_start_idx + num_lt + num_eq,
-    );
-}
+        let idx_eq = idx_lt + candidates[idx_lt..].partition_point(|c| *c == x_i);
+        hist_ro[idx_eq] += 1;
+    });
 
-/// Find the number of elements in `x` < `target` and number of elements in `x` == `target`.
-/// Formula is (#(`x` < `target`), #(`x` == `target`))
-///
-/// # Arguments
-/// * `x` - dataset to count against
-/// * `target` - value to compare against
-fn count_lt_eq<TI: PartialOrd>(x: &[TI], target: &TI) -> (usize, usize) {
-    if x.is_empty() {
-        return (0, 0);
-    }
-    let (mut lower, mut upper) = (0, x.len());
-    let mut eq_upper = upper;
+    let n: u64 = hist_lo.iter().sum();
 
-    while upper - lower > 1 {
-        let middle = lower + (upper - lower) / 2;
+    // don't care about the number of elements greater than all candidates
+    hist_ro.pop();
+    hist_lo.pop();
 
-        if &x[middle] < target {
-            lower = middle;
-        } else {
-            upper = middle;
-            // tighten eq_upper to last middle where x[middle] was still greater than target
-            if &x[middle] > target {
-                eq_upper = middle;
-            }
-        }
-    }
-    let lt = if &x[lower] < target { upper } else { lower };
+    let (mut lt, mut le) = (0u64, 0u64);
 
-    // run another search to find the greatest index equal to target
-    // search starting from the first index equal to target
-    lower = lt;
-    // search for the smallest middle where x[middle] is greater than target
-    upper = eq_upper;
-    while upper - lower > 1 {
-        let middle = lower + (upper - lower) / 2;
+    zip(hist_ro, hist_lo).map(move |(ro, lo)| {
+        // cumsum the right-open histogram to get the total number of records less than the candidate
+        lt += ro;
+        // cumsum the left-open histogram to get the total number of records lt or equal to the candidate
+        le += lo;
 
-        if &x[middle] == target {
-            lower = middle;
-        } else {
-            upper = middle;
-        }
-    }
+        let gt = n - le;
 
-    let eq = if lower == upper || &x[lower] == target {
-        upper
-    } else {
-        lower
-    } - lt;
+        // ensures the score calculation won't overflow
+        let (lt_lim, gt_lim) = (lt.min(size_limit), gt.min(size_limit));
 
-    (lt, eq)
+        // |     (1 - α)         * #(x < c)    -           α * #(x > c)| * α_den
+        ((alpha_den - alpha_num) * lt_lim).abs_diff(alpha_num * gt_lim)
+    })
 }
-
-#[cfg(test)]
-mod test;
diff --git a/rust/src/transformations/quantile_score_candidates/pseudocode/check_candidates.py b/rust/src/transformations/quantile_score_candidates/pseudocode/check_candidates.py
new file mode 100644
index 00000000000..2248c820cf1
--- /dev/null
+++ b/rust/src/transformations/quantile_score_candidates/pseudocode/check_candidates.py
@@ -0,0 +1,13 @@
+# type: ignore
+def validate_candidates(candidates: list[T]):
+    if not candidates: # `\label{empty}`
+        raise ValueError("candidates must not be empty")
+    
+    i1 = iter(candidates)
+    i2 = iter(candidates)
+    next(i1)
+
+    for c1, c2 in zip(i1, i2): # `\label{windows}`
+        cmp = c1.partial_cmp(c2)
+        if cmp is None or cmp != Ordering.Less:
+            raise ValueError("candidates must be non-null and strictly increasing")
diff --git a/rust/src/transformations/quantile_score_candidates/pseudocode/compute_score.py b/rust/src/transformations/quantile_score_candidates/pseudocode/compute_score.py
deleted file mode 100644
index d3a7bef5dc0..00000000000
--- a/rust/src/transformations/quantile_score_candidates/pseudocode/compute_score.py
+++ /dev/null
@@ -1,27 +0,0 @@
-# type: ignore
-def compute_score(
-    x: list[TIA], 
-    candidates: list[TIA], 
-    alpha_num: usize,
-    alpha_den: usize,
-    size_limit: usize
-) -> list[usize]:
-
-    x = list(sorted(x))
-
-    num_lt = [0] * len(candidates)
-    num_eq = [0] * len(candidates)
-
-    count_lt_eq_recursive(
-        num_lt, # mutated in-place
-        num_eq, # mutated in-place
-        edges=candidates,
-        x=x,
-        x_start_idx=0)
-
-    def score(lt, eq):
-        return abs_diff(
-            alpha_den * min(lt, size_limit),
-            alpha_num * min(len(x) - eq, size_limit))
-
-    return [score(lt, eq) for lt, eq in zip(num_lt, num_eq)]
diff --git a/rust/src/transformations/quantile_score_candidates/pseudocode/make_quantile_score_candidates.py b/rust/src/transformations/quantile_score_candidates/pseudocode/make_quantile_score_candidates.py
index 7f96bc19483..bfe0999b220 100644
--- a/rust/src/transformations/quantile_score_candidates/pseudocode/make_quantile_score_candidates.py
+++ b/rust/src/transformations/quantile_score_candidates/pseudocode/make_quantile_score_candidates.py
@@ -3,42 +3,36 @@ def make_quantile_score_candidates(
     input_domain: VectorDomain[AtomDomain[TIA]],
     input_metric: MI,
     candidates: list[TIA],
-    alpha: A
+    alpha: f64,
 ) -> Transformation:
+    if input_domain.element_domain.nan():
+        raise ValueError("input_domain members must have non-nan elements")
 
-    input_domain.element_domain.assert_non_null()
+    check_candidates(candidates)
 
-    for i in range(len(candidates) - 1):
-        assert candidates[i] < candidates[i + 1]
+    size = input_domain.size
+    if size is not None:
+        size = u64.exact_int_cast(input_domain.size)
 
-    alpha_numer, alpha_denom = alpha.into_frac(size=None)
-    if alpha_numer > alpha_denom or alpha_denom == 0:
-        raise ValueError("alpha must be within [0, 1]")
+    alpha_num, alpha_den, size_limit = score_candidates_constants(size, alpha)
 
-    if input_domain.size is not None:
-        # to ensure that the function will not overflow
-        input_domain.size.inf_mul(alpha_denom)
-        size_limit = input_domain.size
-    else:
-        size_limit = (usize.MAX).neg_inf_div(alpha_den)
-
-    def function(arg: list[TIA]) -> list[usize]:
-        return compute_score(arg, candidates, alpha_numer, alpha_denom, size_limit)
-
-    if input_domain.size is not None:
-        def stability_map(d_in: u32) -> usize:
-            return TOA.inf_cast(d_in // 2).inf_mul(2).inf_mul(alpha_denom)
-    else:
-        abs_dist_const: usize = max(alpha_numer, alpha_denom.inf_sub(alpha_numer))
-        stability_map = new_stability_map_from_constant(abs_dist_const, QO=usize)
+    def function(arg: list[TIA]) -> list[u64]:
+        scores = compute_score(arg, candidates, alpha_num, alpha_den, size_limit)
+        return Vec.from_iter(scores)  # like calling list(s) on an iter s
 
     return Transformation(
         input_domain=input_domain,
         output_domain=VectorDomain(
-            element_domain=AtomDomain(T=usize), 
-            size=len(candidates)),
+            element_domain=AtomDomain(T=u64), size=len(candidates)
+        ),
         function=function,
         input_metric=input_metric,
-        output_metric=LInfDistance(Q=usize),
-        stability_map=stability_map,
+        output_metric=LInfDistance.default(T=u64),
+        stability_map=StabilityMap.new_fallible( # `\label{map}`
+            score_candidates_map(
+                alpha_num,
+                alpha_den,
+                input_domain.size.is_some(),
+            )
+        ),
     )
diff --git a/rust/src/transformations/quantile_score_candidates/pseudocode/score_candidates.py b/rust/src/transformations/quantile_score_candidates/pseudocode/score_candidates.py
new file mode 100644
index 00000000000..103e4b287c7
--- /dev/null
+++ b/rust/src/transformations/quantile_score_candidates/pseudocode/score_candidates.py
@@ -0,0 +1,41 @@
+# type: ignore
+def score_candidates(
+    x: Iterator[TIA], 
+    candidates: list[TIA], 
+    alpha_num: u64,
+    alpha_den: u64,
+    size_limit: u64
+) -> Iterator[usize]:
+    # count of the number of records between...
+    #  (-inf, c1), [c1, c2), [c2, c3), ..., [ck, inf)
+    hist_ro = [0] * candidates.len() + 1 # histogram of right-open intervals
+    #  (-inf, c1], (c1, c2], (c2, c3], ..., (ck, inf)
+    hist_lo = [0] * candidates.len() + 1 # histogram of left-open intervals
+
+    for x_i in x:
+        idx_lt = candidates.partition_point(lambda c: c < x_i)
+        hist_lo[idx_lt] += 1  # `\label{hist-lo-increment}`
+
+        idx_eq = idx_lt + candidates[idx_lt:].partition_point(lambda c: c == x_i)
+        hist_ro[idx_eq] += 1  # `\label{hist-ro-increment}`
+
+    n: u64 = hist_lo.iter().sum()  # `\label{n-sum}`
+
+    # don't care about the number of elements greater than all candidates
+    hist_ro.pop()  # `\label{pop-ro}`
+    hist_lo.pop()  # `\label{pop-lo}`
+
+    lt, le = 0, 0
+    for ro, lo in zip(hist_ro, hist_lo):  # `\label{zip-hist}`
+        # cumsum the right-open histogram to get the total number of records less than the candidate
+        lt += ro  # `\label{lt-cumsum}`
+        # cumsum the right-open histogram to get the total number of records lt or equal to the candidate
+        le += lo  # `\label{le-cumsum}`
+
+        gt = n - le # `\label{gt}`
+
+        # the number of records equal to the candidate is the difference between the two cumsums
+        lt_lim, gt_lim = lt.min(size_limit), gt.min(size_limit)  # `\label{lt-gt-lim}`
+
+        # a_den * |    (1 - a)         * #(x < c)    -            a * #(x > c)|
+        yield ((alpha_den - alpha_num) * lt_lim).abs_diff(alpha_num * gt_lim)  # `\label{score}`
diff --git a/rust/src/transformations/quantile_score_candidates/pseudocode/score_candidates_constants.py b/rust/src/transformations/quantile_score_candidates/pseudocode/score_candidates_constants.py
new file mode 100644
index 00000000000..bc4b123a7cc
--- /dev/null
+++ b/rust/src/transformations/quantile_score_candidates/pseudocode/score_candidates_constants.py
@@ -0,0 +1,32 @@
+# type: ignore
+def score_candidates_constants(size: Optional[u64], alpha: f64) -> tuple[u64, u64, u64]:
+    if alpha < 0.0 or 1.0 < alpha:
+        return ValueError("alpha must be within [0, 1]")
+
+    alpha_num_exact, alpha_den_exact = RBig.try_from(alpha).into_parts()
+
+    if size is not None:
+        # choose the finest granularity that won't overflow
+        # must have that size * denom < MAX, so let denom = MAX // size
+        alpha_den_approx = u64.MAX.neg_inf_div(size)
+    else:
+        # default to an alpha granularity of .00001
+        u64.exact_int_cast(10_000)
+
+    if alpha_den_exact < UBig.from_(alpha_den_approx):
+        alpha_num = u64.try_from(alpha_num_exact.into_parts()[1])
+        alpha_den = u64.try_from(alpha_den_exact)
+    else:
+        # numer = alpha * denom
+        alpha_num_approx = u64.round_cast(alpha * f64.round_cast(alpha_den_approx))
+        alpha_num, alpha_den = alpha_num_approx, alpha_den_approx
+
+    if size is not None:
+        size_limit = size
+    else:
+        size_limit = u64.MAX.neg_inf_div(alpha_den)
+
+    assert alpha_num <= alpha_den  # `\label{prop-1}`
+    size_limit.alerting_mul(alpha_den)  # `\label{prop-2}`
+
+    return alpha_num, alpha_den, size_limit  # `\label{return}`
diff --git a/rust/src/transformations/quantile_score_candidates/pseudocode/score_candidates_map.py b/rust/src/transformations/quantile_score_candidates/pseudocode/score_candidates_map.py
new file mode 100644
index 00000000000..0d6852337ba
--- /dev/null
+++ b/rust/src/transformations/quantile_score_candidates/pseudocode/score_candidates_map.py
@@ -0,0 +1,10 @@
+# type: ignore
+def score_candidates_map(alpha_num, alpha_den, known_size) -> Callable[[int], int]:
+    def stability_map(d_in):
+        if known_size:
+            return T.inf_cast(d_in // 2).inf_mul(alpha_den)
+        else:
+            abs_dist_const: u64 = max(alpha_num, alpha_den - alpha_num)  # `\label{sub}`
+            return T.exact_int_cast(d_in).alerting_mul(abs_dist_const)
+
+    return stability_map
diff --git a/rust/src/transformations/quantile_score_candidates/score_candidates.tex b/rust/src/transformations/quantile_score_candidates/score_candidates.tex
new file mode 100644
index 00000000000..c154bf3efff
--- /dev/null
+++ b/rust/src/transformations/quantile_score_candidates/score_candidates.tex
@@ -0,0 +1,96 @@
+\documentclass{article}
+\input{../../lib.sty}
+
+\title{\texttt{fn score\_candidates}}
+\author{Michael Shoemate}
+\begin{document}
+\maketitle  
+
+
+\contrib
+
+Proves soundness of \rustdoc{transformations/quantile\_score\_candidates/fn}{score\_candidates} 
+in \asOfCommit{mod.rs}{f5bb719}.
+\texttt{score\_candidates} returns a score for each candidate passed in, 
+where the score is the distance between the candidate and the ideal alpha-quantile.
+
+\section{Hoare Triple}
+\subsection*{Precondition}
+\subsubsection*{Compiler Verified}
+\begin{itemize}
+    \item Generic \texttt{TI} (input atom type) is a type with trait \texttt{PartialOrd}.
+\end{itemize}
+
+\subsubsection*{Caller Verified}
+\begin{itemize}
+    \item \texttt{x} has at most $2^{64}$ elements.
+    \item \texttt{x} elements are totally ordered (excluding non-nan/non-null).
+    \item \texttt{candidates} is strictly increasing
+    \item $\texttt{alpha\_numer / alpha\_denom} \le 1$
+    \item $\texttt{size\_limit} \cdot \texttt{alpha\_denom < u64.MAX}$
+\end{itemize}
+
+
+\subsection*{Function}
+\label{sec:python-pseudocode}
+\lstinputlisting[language=Python,firstline=2,escapechar=`]{./pseudocode/score_candidates.py}
+
+
+\subsection*{Postcondition}
+
+\begin{theorem}
+    Let $C$ denote \texttt{candidates} and let $l$ denote \texttt{size\_limit}.
+    For each index $i$ in $\{1, \ldots, |C|\}$,
+
+    \begin{equation*}
+        \mathrm{score\_candidates}_i(x, C, \alpha_{num}, \alpha_{den}, l) = |\alpha_{den} \cdot \min(\#(x < C_i), l) - \alpha_{num} \cdot \min(\#(x > C_i), l)|
+    \end{equation*}
+
+    where $\#(x < C_i)$ is the number of elements in $x$ less than $C_i$,
+    $\#(x > C_i)$ is the number of elements in $x$ greater than $C_i$.
+\end{theorem}
+
+\begin{proof}
+    The function breaks down into two parts:
+    \begin{itemize}
+        \item Compute histograms, where the edges are the candidates.
+        \item Uses the histograms to compute scores.
+    \end{itemize}
+
+    The histograms are initialized at zero, with one more bin than candidates,
+    since the bins start at $-\infty$ and end at $+\infty$.
+
+    The bins in \texttt{hist\_ro} are closed on the left, and open on the right.
+    The bins in \texttt{hist\_lo} are open on the left, and closed on the right.
+
+    This is reflected in line \ref{hist-lo-increment}, where \texttt{idx\_lt} is the index of the first bin smaller than \texttt{x\_i}.
+    Similarly, \texttt{idx\_eq} is the index of the first bin greater than or equal to \texttt{x\_i}.
+    It is sufficient to search on equality, because if no candidate is equal to \texttt{x\_i},
+    then the partition point will be zero, so \texttt{idx\_eq} will be equal to \texttt{idx\_lt}, as expected.
+
+    Line \ref{n-sum} computes the total number of elements in the data as each element in \texttt{x} incremented a bin in \texttt{hist\_lo}.
+    No arithmetic thus far can overflow due to the precondition that \texttt{x} has at most $2^64$ elements.
+
+    Notice that there is one more bin than candidates.
+    With \texttt{n}, the last bin in each of the histograms (the number of elements beyond the largest candidate),
+    is not needed to compute the score.
+    Therefore, the last bin is discarded on lines \ref{pop-ro} and \ref{pop-lo}.
+
+    The scores can now be computed in one linear pass over the histograms on line \ref{zip-hist}.
+    The number of elements less than the candidate \texttt{lt} is the cumulative sum of \texttt{hist\_lo},
+    and the number of elements less than or equal to the candidate \texttt{le} is the cumulative sum of \texttt{hist\_ro}.
+    
+    The number of elements greater than the candidate \texttt{gt} is then simply \texttt{n - le} on line \ref{gt}.
+
+    To ensure that the score computation does not overflow, the counts are bounded by \texttt{size\_limit} on line \ref{lt-gt-lim}.
+
+    By the precondition that $\texttt{alpha\_numer / alpha\_denom} \le 1$,
+    then both \texttt{alpha\_den - alpha\_num} and \texttt{alpha\_num} are less than or equal to \texttt{alpha\_den}.
+    Using this, and the precondition that $\texttt{size\_limit} \cdot \texttt{alpha\_denom < u64.MAX}$,
+    then the computation of the score on line \ref{score} is guaranteed to not overflow.
+
+    The computation on line \ref{score} directly satisfies the postcondition.
+\end{proof}
+
+
+\end{document}
diff --git a/rust/src/transformations/quantile_score_candidates/score_candidates_constants.tex b/rust/src/transformations/quantile_score_candidates/score_candidates_constants.tex
new file mode 100644
index 00000000000..2dd4e8f78c3
--- /dev/null
+++ b/rust/src/transformations/quantile_score_candidates/score_candidates_constants.tex
@@ -0,0 +1,52 @@
+\documentclass{article}
+\input{../../lib.sty}
+
+\title{\texttt{fn score\_candidates\_constants}}
+\author{Michael Shoemate}
+\begin{document}
+\maketitle  
+
+
+\contrib
+
+Proves soundness of \rustdoc{transformations/quantile\_score\_candidates/fn}{score\_candidates\_constants} 
+in \asOfCommit{mod.rs}{f5bb719}.
+\texttt{score\_candidates\_constants} returns a fractional decomposition of alpha and an upper bound on dataset size to protect against overflow.
+
+\section{Hoare Triple}
+\subsection*{Precondition}
+None
+
+\subsection*{Function}
+\label{sec:python-pseudocode}
+\lstinputlisting[language=Python,firstline=2,escapechar=`]{./pseudocode/score_candidates_constants.py}
+
+
+\subsection*{Postcondition}
+
+\begin{theorem}
+    When the function does not fail, it returns the following constants:
+    \begin{itemize}
+        \item \texttt{alpha\_num}
+        \item \texttt{alpha\_den}
+        \item \texttt{size\_limit}
+    \end{itemize}
+
+    The constants follow the following properties:
+    \begin{enumerate}
+        \item $alpha_num / alpha_den \in [0, 1]$
+        \item $size_limit \cdot alpha_den < 2^{64}$
+    \end{enumerate}
+
+    An error is raised if these properties cannot be met.
+\end{theorem}
+
+\begin{proof}
+    While the code is complicated, all that is needed is to observe that the function does not return until line \ref{return},
+    and that the conditions are explicitly checked on lines \ref{prop-1} and \ref{prop-2}.
+    
+    Therefore, the postcondition holds.
+\end{proof}
+
+
+\end{document}
diff --git a/rust/src/transformations/quantile_score_candidates/score_candidates_map.tex b/rust/src/transformations/quantile_score_candidates/score_candidates_map.tex
new file mode 100644
index 00000000000..c39e157b9b3
--- /dev/null
+++ b/rust/src/transformations/quantile_score_candidates/score_candidates_map.tex
@@ -0,0 +1,179 @@
+\documentclass{article}
+\input{../../lib.sty}
+
+\title{\texttt{fn score\_candidates\_map}}
+\author{Michael Shoemate}
+\begin{document}
+\maketitle  
+
+
+\contrib
+
+Proves soundness of \rustdoc{transformations/quantile\_score\_candidates/fn}{score\_candidates\_map} 
+in \asOfCommit{mod.rs}{f5bb719}.
+\texttt{score\_candidates\_map} returns a specific function that can be used to prove stability of the quantile scoring transformation.
+
+\section{Hoare Triple}
+\subsection*{Precondition}
+$\texttt{alpha\_den} > \texttt{alpha\_num}$.
+
+\subsection*{Function}
+\label{sec:python-pseudocode}
+\lstinputlisting[language=Python,firstline=2,escapechar=`]{./pseudocode/score_candidates_map.py}
+
+
+\subsection*{Postcondition}
+
+\begin{theorem}
+    Define \function as follows:
+    \begin{equation*}
+        \function(x) = \mathrm{score\_candidates}_i(x, \texttt{candidates}, \texttt{alpha\_num}, \texttt{alpha\_den}, \texttt{size\_limit})
+    \end{equation*}
+
+    The function calls \rustdoc{transformations/quantile\_score\_candidates/fn}{score\_candidates} with fixed choices of
+    \texttt{candidates}, \texttt{alpha\_num}, \texttt{alpha\_den} and \texttt{size\_limit}.
+
+    If the input domain is the set of all vectors with non-null elements of type \texttt{TI},
+    input metric is either \rustdoc{metrics/struct}{SymmetricDistance} or \rustdoc{metrics/struct}{InsertDeleteDistance},
+    and the postcondition of \function is satisfied, then \function is stable.
+
+    This means that, for any two elements $x, x'$ in the \texttt{input\_domain},
+        where $x, x'$ share the same length if \texttt{known\_length} is true,
+    and any pair $(\din, \dout)$, 
+        where \din\ has the associated type for \texttt{input\_metric} and \dout\ has the associated type for \texttt{output\_metric},
+    then when $x, x'$ are \din-close under \texttt{input\_metric} and $\texttt{stability\_map}(\din) \leq \dout$,
+    \function(x), \function(x') are \dout-close under \texttt{output\_metric}.
+\end{theorem}
+
+
+The sensitivity of this function differs depending on whether the size of the input vector is known.
+\label{unsized-stability}
+First, consider the case where the size is unknown.
+
+\begin{lemma}
+    \label{single-change-stab-sym}
+    If $d_{Sym}(x, x') = 1$, then $d_\infty(\function(x), \function(x')) \le \max(\alpha_{num}, \alpha_{den} - \alpha_{num})$.
+\end{lemma}
+
+\begin{proof}
+    Assume $d_{Sym}(x, x') = 1$. 
+
+    \begin{align*}
+        &d_\infty(\function(x)_i, \function(x')_i) \\
+        &= \max\limits_{i} |\function(x)_i - \function(x')_i| &&\text{by definition of } d_\infty \\
+        &= \max\limits_{i} |\mathrm{abs\_diff}(\alpha_{den} \cdot \min(\#(x < C_i), l), \alpha_{num} \cdot \min(|x| - \#(x = C_i), l)) &&\text{by definition of } \function
+            \\&\hspace{1cm}\mathrm{abs\_diff}(\alpha_{den} \cdot \min(\#(x' < C_i), l), \alpha_{num} \cdot \min(|x'| - \#(x' = C_i), l))| \\
+        &= \alpha_{den} \cdot \max\limits_{i} ||\min(\#(x < C_i), l) - \alpha \cdot \min(|x| - \#(x = C_i), l)|
+            \\&\hspace{1cm}|\min(\#(x' < C_i), l) - \alpha \cdot \min(|x'| - \#(x' = C_i), l)|| \\
+        &\le \alpha_{den} \cdot \max\limits_{i} ||\#(x < C_i) - \alpha \cdot (|x| - \#(x = C_i))|
+            \\&\hspace{1cm}|\#(x' < C_i) - \alpha \cdot (|x| - \#(x' = C_i))|| \\
+    \intertext{Consider each of the three cases of adding or removing an element in x.}
+    \intertext{Case 1. Assume $x'$ is equal to $x$, but with some $x_j < C_i$ added or removed.}
+        &= \alpha_{den} \cdot \max\limits_{i} ||(1 - \alpha) \cdot \#(x < C_i) - \alpha \cdot \#(x > C_i)| 
+            \\&\hspace{1cm} - |(1 - \alpha) \cdot (\#(x < C_i) \pm 1) - \alpha \cdot \#(x > C_i)|| \\
+        &\leq \alpha_{den} \cdot  \max\limits_{i} ||(1 - \alpha) \cdot \#(x < C_i) - \alpha \cdot \#(x > C_i)|
+            \\&\hspace{1cm} - (|(1 - \alpha) \cdot \#(x < C_i) - \alpha \cdot \#(x > C_i)| + |\pm(1 - \alpha)|)| &&\text{by triangle inequality} \\
+        &= \alpha_{den} \cdot \max\limits_{i} |1 - \alpha| &&\text{scores cancel}\\
+        &= \alpha_{den} - \alpha_{num} &&\text{since } \alpha \leq 1
+    \intertext{Case 2. Assume $x'$ is equal to $x$, but with some $x_j > C_i$ added or removed.}
+        &= \alpha_{den} \cdot \max\limits_{i} ||(1 - \alpha) \cdot \#(x < C_i) - \alpha \cdot \#(x > C_i)| 
+            \\&\hspace{1cm} - |(1 - \alpha) \cdot \#(x < C_i) - \alpha \cdot (\#(x > C_i) \pm 1)|| \\
+        &\leq \alpha_{den} \cdot \max\limits_{i} ||(1 - \alpha) \cdot \#(x < C_i) - \alpha \cdot \#(x > C_i)| 
+            \\&\hspace{1cm} - (|(1 - \alpha) \cdot \#(x < C_i) - \alpha \cdot \#(x > C_i)| + |\pm\alpha|)| &&\text{by triangle inequality} \\
+        &= \alpha_{den} \cdot \max\limits_{i} |\alpha| &&\text{scores cancel}\\
+        &= \alpha_{num} &&\text{since } \alpha \geq 0
+    \intertext{Case 3. Assume $x'$ is equal to $x$, but with some $x_j = C_i$ added or removed.}
+        &= \alpha_{den} \cdot \max\limits_{i} ||(1 - \alpha) \cdot \#(x < C_i) - \alpha \cdot \#(x > C_i)| 
+            \\&\hspace{1cm} - |(1 - \alpha) \cdot \#(x < C_i) - \alpha \cdot \#(x > C_i)|| \\
+        &= 0 &&\text{no change in score}
+    \intertext{Take the union bound over all cases.}
+        &\le \max(\alpha_{num}, \alpha_{den} - \alpha_{num})
+    \end{align*}
+
+\end{proof}
+
+\label{sec:known-size}
+Now consider the case where the dataset size is known.
+
+\begin{lemma}
+    \label{single-change-stab}
+    If $d_{CO}(x, x') \le 1$, then $d_\infty(\function(x), \function(x')) \le \alpha_{den}$.
+\end{lemma}
+
+\begin{proof}
+    Assume $d_{CO}(x, x') \le 1$. 
+
+    \begin{align*}
+        &d_\infty(\function(x), \function(x')) \\
+        &= \max\limits_{i} |\function(x)_i - \function(x')_i| &&\text{by definition of } d_\infty \\
+        &= \max\limits_{i} |\mathrm{abs\_diff}(\alpha_{den} \cdot \min(\#(x < C_i), l), \alpha_{num} \cdot \min(|x| - \#(x = C_i), l)) &&\text{by def. of } \function
+            \\&\hspace{1cm} -\mathrm{abs\_diff}(\alpha_{den} \cdot \min(\#(x' < C_i), l), \alpha_{num} \cdot \min(|x'| - \#(x' = C_i), l))| \\
+        &= \alpha_{den} \cdot \max\limits_{i} ||\min(\#(x < C_i), l) - \alpha \cdot \min(|x| - \#(x = C_i), l)|
+            \\&\hspace{1.65cm}-|\min(\#(x' < C_i), l) - \alpha \cdot \min(|x'| - \#(x' = C_i), l)|| \\
+        &= \alpha_{den} \cdot \max\limits_{i} ||\#(x < C_i) - \alpha \cdot (|x| - \#(x = C_i))|
+            \\&\hspace{1.65cm}-|\#(x' < C_i) - \alpha \cdot (|x| - \#(x' = C_i))|| \\
+    \end{align*}
+
+    \begin{align*}
+    \intertext{Consider each of the four cases of changing a row in x.}
+    \intertext{Case 1. Assume $x'$ is equal to $x$, but with some $x_j < C_i$ replaced with $x'_j > C_i$.}
+        &= \alpha_{den} \cdot \max\limits_{i} ||(1 - \alpha) \cdot \#(x < C_i) - \alpha \cdot \#(x > C_i)| 
+            \\&\hspace{1.7cm} - |(1 - \alpha) \cdot (\#(x < C_i) - 1) - \alpha \cdot (\#(x > C_i) + 1)|| &&\text{by definition of }\function \\
+        &\leq \alpha_{den} \cdot \max\limits_{i} ||(1 - \alpha) \cdot \#(x < C_i) - \alpha \cdot \#(x > C_i)|
+            \\&\hspace{1.65cm} - (|(1 - \alpha) \cdot \#(x < C_i) - \alpha \cdot \#(x > C_i)| + |1|)| &&\text{by triangle inequality} \\
+        &= \alpha_{den} \cdot \max\limits_{i} |1| &&\text{scores cancel}\\
+        &= \alpha_{den}
+    \intertext{Case 2. Assume $x'$ is equal to $x$, but with some $x_j > C_i$ replaced with $x'_j < C_i$.}
+        &= \alpha_{den}
+        \intertext{\hspace{1cm}by symmetry, follows from Case 1.}
+    \intertext{Case 3. Assume $x'$ is equal to $x$, but with some $x_j \neq C_i$ replaced with $C_i$.}
+        &\leq \max(\alpha_{num}, \alpha_{den} - \alpha_{num})\\
+        \intertext{\hspace{1cm}equivalent to one removal (see \rustdoc{transformations/fn}{make\_quantile\_score\_candidates})}
+    \intertext{Case 4. Assume $x'$ is equal to $x$, but with some $x_j = C_i$ replaced with $x'_j \neq C_i$.}
+        &\leq \max(\alpha_{num}, \alpha_{den} - \alpha_{num}) \\
+    \intertext{\hspace{1cm}equivalent to one addition (see \rustdoc{transformations/fn}{make\_quantile\_score\_candidates})}
+    \intertext{Take the union bound over all cases.}
+        d_\infty(x_i, x'_i) &\le \max(\alpha_{den}, \max(\alpha_{num}, \alpha_{den} - \alpha_{num})) = \alpha_{den}
+        \intertext{\hspace{1cm}since $\max(\alpha, 1 - \alpha) \leq 1$}
+    \end{align*}
+\end{proof}
+
+\begin{proof}[Proof of postcondition]
+    Assume the input domain is the set of all vectors with non-null elements of type \texttt{TI},
+    input metric is either \rustdoc{metrics/struct}{SymmetricDistance} or \rustdoc{metrics/struct}{InsertDeleteDistance},
+    and the postcondition of \function is satisfied.
+
+    First, consider the case where the size is unknown.
+    Take any two members $s, s'$ in the \texttt{input\_domain} and any pair $(\din, \dout)$, 
+    where \din\ has the associated type for \texttt{input\_metric} and \dout\ has the associated type for \texttt{output\_metric}.
+    Assume $s, s'$ are \din-close under \texttt{input\_metric} and that $\texttt{stability\_map}(\din) \leq \dout$. 
+
+    \begin{align*}
+        \dout &= \max\limits_{s \sim s'} d_{\infty}(s, s') &&\text{where } s = \function(x)\\
+        &= \max\limits_{s \sim s'} \max\limits_{i} |s_i - s'_i| &&\text{by definition of }\rustdoc{metrics/struct}{LInfDistance} \text{, without monotonicity} \\
+        &\leq \sum_j^{\din} \max\limits_{Z_{j} \sim Z_{j+1}} \max\limits_{i} |s_{i,j} - s_{i,j+1}| &&\text{by path property } d_{Sym}(Z_i, Z_{i+1}) = 1, x = Z_0 \text{ and } x' = Z_\din \\
+        &\leq \sum_j^{\din} \max(\alpha_{num}, \alpha_{den} - \alpha_{num}) &&\text{by \ref{single-change-stab-sym}} \\
+        &\leq \din \cdot \max(\alpha_{num}, \alpha_{den} - \alpha_{num}) \\
+    \end{align*}
+    \label{sec:unknown-size}
+    This formula matches the stability map in the case where the dataset size is unknown.
+
+    Now, consider the case where the size is known.
+    Take any two elements $s, s'$ in the \texttt{input\_domain} and any pair $(\din, \dout)$, 
+    where \din\ has the associated type for \texttt{input\_metric} and \dout\ has the associated type for \texttt{output\_metric}.
+    Assume $s, s'$ are \din-close under \texttt{input\_metric} and that $\texttt{stability\_map}(\din) \leq \dout$. 
+
+    \begin{align*}
+        \dout &= \max\limits_{s \sim s'} d_{\infty}(s, s') \\
+        &= \max\limits_{s \sim s'} \max\limits_{i} |s_i - s'_i| &&\text{by definition of }\rustdoc{metrics/struct}{LInfDistance}\text{, without monotonicity} \\
+        &\leq \sum_j^{\din // 2} \max\limits_{Z_{j} \sim Z_{j+1}} \max\limits_{i} |s_{i,j} - s_{i,j+1}| &&\text{by path property } d_{CO}(Z_i, Z_{i+1}) = 1, x = Z_0 \text{ and } Z_\din = x' \\
+        &\leq \sum_j^{\din // 2} \alpha_{den} &&\text{by \ref{single-change-stab}} \\
+        &\leq (\din // 2) \cdot \alpha_{den} \\
+    \end{align*}
+
+    This formula matches the stability map in the case where the dataset size is known.
+
+    It is shown that \function(x), \function(x') are \dout-close under \texttt{output\_metric} for any choice of input arguments.
+\end{proof}
+
+\end{document}
diff --git a/rust/src/transformations/quantile_score_candidates/test.rs b/rust/src/transformations/quantile_score_candidates/test.rs
index 62888c566c1..c12e30a1991 100644
--- a/rust/src/transformations/quantile_score_candidates/test.rs
+++ b/rust/src/transformations/quantile_score_candidates/test.rs
@@ -1,96 +1,204 @@
+use num::Float;
+
+use crate::{
+    measurements::{Optimize, make_report_noisy_max_gumbel},
+    metrics::SymmetricDistance,
+    traits::samplers::sample_uniform_uint_below,
+};
+
 use super::*;
 
 #[test]
-fn test_count_lte() {
-    let x = (5..20).collect::<Vec<i32>>();
-    let edges = vec![2, 4, 7, 12, 22];
-    let mut count_lt = vec![0; edges.len()];
-    let mut count_eq = vec![0; edges.len()];
-    count_lt_eq_recursive(
-        count_lt.as_mut_slice(),
-        count_eq.as_mut_slice(),
-        edges.as_slice(),
-        x.as_slice(),
-        0,
+fn test_quantile_score_candidates_median() -> Fallible<()> {
+    let candidates = vec![20, 33, 40, 50, 72, 100];
+    let t_qscore = make_quantile_score_candidates(
+        VectorDomain::new(AtomDomain::<u32>::default()),
+        SymmetricDistance,
+        candidates.clone(),
+        0.5,
+    )?;
+
+    assert_eq!(
+        t_qscore.invoke(&(0..100).collect())?,
+        vec![59, 33, 19, 1, 45, 100]
     );
-    println!("{:?}", count_lt);
-    println!("{:?}", count_eq);
-    assert_eq!(count_lt, vec![0, 0, 2, 7, 15]);
-    assert_eq!(count_eq, vec![0, 0, 1, 1, 0]);
+
+    let m_rnm = make_report_noisy_max_gumbel(
+        t_qscore.output_domain.clone(),
+        t_qscore.output_metric.clone(),
+        1.0,
+        Optimize::Min,
+    )?;
+
+    let m_quantile = (t_qscore >> m_rnm)?;
+    let idx = m_quantile.invoke(&(0..100).collect())?;
+    assert_eq!(candidates[idx], 50);
+    assert_eq!(m_quantile.map(&1)?, 2.0);
+    Ok(())
 }
 
 #[test]
-fn test_count_lte_repetition() {
-    let x = vec![0, 2, 2, 3, 5, 7, 7, 7];
-    let edges = vec![-1, 2, 4, 7, 12, 22];
-    let mut count_lt = vec![0; edges.len()];
-    let mut count_eq = vec![0; edges.len()];
-    count_lt_eq_recursive(
-        count_lt.as_mut_slice(),
-        count_eq.as_mut_slice(),
-        edges.as_slice(),
-        x.as_slice(),
-        0,
-    );
-    println!("{:?}", count_lt);
-    println!("{:?}", count_eq);
-    assert_eq!(count_lt, vec![0, 1, 4, 5, 8, 8]);
-    assert_eq!(count_eq, vec![0, 2, 0, 3, 0, 0]);
+fn test_quantile_score_candidates_offset() -> Fallible<()> {
+    let trans = make_quantile_score_candidates(
+        VectorDomain::new(AtomDomain::<u32>::default()),
+        SymmetricDistance,
+        vec![0, 25, 50, 75, 100],
+        0.5,
+    )?;
+    // score works out to 2 * |50 - cand|
+    assert_eq!(trans.invoke(&(0..101).collect())?, [100, 50, 0, 50, 100]);
+    assert_eq!(trans.map(&1)?, 1);
+    Ok(())
 }
 
-fn test_case(x: Vec<i32>, edges: Vec<i32>, true_lt: Vec<usize>, true_eq: Vec<usize>) {
-    let mut count_lt = vec![0; edges.len()];
-    let mut count_eq = vec![0; edges.len()];
-    count_lt_eq_recursive(
-        count_lt.as_mut_slice(),
-        count_eq.as_mut_slice(),
-        edges.as_slice(),
-        x.as_slice(),
-        0,
-    );
-    println!("LT");
-    println!("{:?}", true_lt);
-    println!("{:?}", count_lt);
+#[test]
+fn test_validate_candidates() -> Fallible<()> {
+    let candidates = vec![1, 2, 3];
+    check_candidates(&candidates)?;
+
+    let candidates = vec![1f32, 2f32, 3f32];
+    check_candidates(&candidates)?;
+
+    let candidates = vec![2, 1, 3];
+    assert!(check_candidates(&candidates).is_err());
+
+    let candidates = vec![2.0, 1.0, 3.0];
+    assert!(check_candidates(&candidates).is_err());
+
+    let candidates = vec![1.0, 2.0, f64::NAN];
+    assert!(check_candidates(&candidates).is_err());
+
+    let candidates = vec![f64::NAN, 1.0, 2.0];
+    assert!(check_candidates(&candidates).is_err());
+
+    Ok(())
+}
+
+#[test]
+fn test_score_candidates_constants() -> Fallible<()> {
+    // pass size, alpha, get alpha decomposition and upper bound for arithmetic
+    let (alpha_num, alpha_den, size_limit) = score_candidates_constants(Some(10), 0.5)?;
+    assert_eq!(alpha_num, 1);
+    assert_eq!(alpha_den, 2);
+    assert_eq!(size_limit, 10);
+
+    let (alpha_num, alpha_den, size_limit) = score_candidates_constants(None, 0.5)?;
+    assert_eq!(alpha_num, 1);
+    assert_eq!(alpha_den, 2);
+    assert_eq!(size_limit, u64::MAX / alpha_den);
+
+    let (alpha_num, alpha_den, size_limit) = score_candidates_constants(None, 0.25)?;
+    assert_eq!(alpha_num, 1);
+    assert_eq!(alpha_den, 4);
+    assert_eq!(size_limit, u64::MAX / alpha_den);
 
-    println!("EQ");
-    println!("{:?}", true_eq);
-    println!("{:?}", count_eq);
+    let (alpha_num, alpha_den, size_limit) = score_candidates_constants(None, 2.0 - 2.0.sqrt())?;
+    // 2 - sqrt(2) = 0.5857864376
+    // take the first 4 digits of the decimal
+    assert_eq!(alpha_num, 5857);
+    assert_eq!(alpha_den, 10_000);
+    assert_eq!(size_limit, u64::MAX / alpha_den);
 
-    assert_eq!(true_lt, count_lt);
-    assert_eq!(true_eq, count_eq);
+    let (alpha_num, alpha_den, size_limit) = score_candidates_constants(None, 0.0)?;
+    assert_eq!(alpha_num, 0);
+    assert_eq!(alpha_den, 1);
+    assert_eq!(size_limit, u64::MAX);
+
+    let (alpha_num, alpha_den, size_limit) =
+        score_candidates_constants(Some(u64::MAX / 4), 0.34434532242)?;
+    // since the size bound is so large, the alpha decomposition is more lossy
+    assert_eq!(alpha_num, 1);
+    assert_eq!(alpha_den, 4);
+    assert_eq!(size_limit, u64::MAX / 4);
+
+    Ok(())
 }
 
 #[test]
-fn test_count_lte_edge_cases() {
-    // check constant x
-    test_case(vec![0; 10], vec![-1], vec![0], vec![0]);
-    test_case(vec![0; 10], vec![0], vec![0], vec![10]);
-    test_case(vec![0; 10], vec![1], vec![10], vec![0]);
-
-    // below first split
-    test_case(vec![1, 2, 3, 3, 3, 3], vec![2], vec![1], vec![1]);
-    test_case(vec![1, 2, 3, 3, 3, 3, 3], vec![2], vec![1], vec![1]);
-    // above first split
-    test_case(vec![1, 1, 1, 1, 2, 3], vec![2], vec![4], vec![1]);
-    test_case(vec![1, 1, 1, 1, 2, 3, 3], vec![2], vec![4], vec![1]);
+fn test_score_candidates_function() {
+    let c = vec![-1, 2, 4, 7, 12, 22];
+    let x = vec![0, 2, 2, 3, 5, 7, 7, 7];
+    // RO:       (-inf, -1], (-1, 2], (2, 4], (4, 7], (7, 12], (12, 22], (22, inf)
+    // hist_ro = 0           3,       1,      4,      0,       0,        0
+    // LO:       (-inf, -1), [-1, 2), [2, 4), [4, 7), [7, 12), [12, 22), [22, inf)
+    // hist_lo = 0           1,       3,      1,      3,       0,        0
+    let scores: Vec<_> = score_candidates(x.into_iter(), c, 1, 2, 7).collect();
+    // lt = 0, le = 0, gt = 8 - 0 = 8
+    // |1 * min(0, 7) - 1 * min(8, 7)| = |0 - 7| = 7
+
+    // lt = 1, le = 3, gt = 8 - 3 = 5
+    // |1 * min(1, 7) - 1 * min(5, 7)| = |1 - 5| = 4
+
+    // lt = 4, le = 4, gt = 8 - 4 = 4
+    // |1 * min(4, 7) - 1 * min(4, 7)| = |4 - 4| = 0
+
+    // lt = 5, le = 8, gt = 8 - 8 = 0
+    // |1 * min(5, 7) - 1 * min(0, 7)| = |5 - 0| = 5
+
+    // lt = 8, le = 8, gt = 8 - 8 = 0
+    // |1 * min(8, 7) - 1 * min(0, 7)| = |7 - 0| = 7
+
+    // lt = 8, le = 8, gt = 8 - 8 = 0
+    // |1 * min(8, 7) - 1 * min(0, 7)| = |7 - 0| = 7
+    assert_eq!(scores, vec![7, 4, 0, 5, 7, 7]);
 }
 
 #[test]
-fn test_scorer() -> Fallible<()> {
-    let edges = vec![-1, 2, 4, 7, 12, 22];
+fn test_scorer_edge_cases() {
+    let c = vec![];
+    let x = vec![1, 2, 3];
+    assert!(Vec::from_iter(score_candidates(x.into_iter(), c, 1, 2, 10)).is_empty());
 
-    let x = vec![0, 2, 2, 3, 5, 7, 7, 7];
-    let scores = compute_score(x, &edges, 1, 2, 8);
-    println!("{:?}", scores);
+    let c = vec![1, 2, 3];
+    let x = vec![];
+    let scores = Vec::from_iter(score_candidates(x.into_iter(), c, 1, 2, 10));
+    assert_eq!(scores, vec![0; 3]);
+}
+
+#[test]
+fn test_score_candidates_stability() -> Fallible<()> {
+    // test case one and two in change one model where one record is moved beyond candidate
+    let alpha_num = 1;
+    let alpha_den = 2;
+    let s1 = score_candidates([0, 4].into_iter(), vec![1], alpha_num, alpha_den, 1000)
+        .next()
+        .unwrap();
 
-    let x = vec![0, 2, 2, 3, 4, 7, 7, 7];
-    let scores = compute_score(x, &edges, 1, 2, 8);
-    println!("{:?}", scores);
+    let s2 = score_candidates([4, 4].into_iter(), vec![1], alpha_num, alpha_den, 1000)
+        .next()
+        .unwrap();
+    // score changes by alpha_den
+    assert_eq!(s1, s2 - alpha_den);
+
+    // test case three and four in change one model where one record is replaced with candidate
+    let s3 = score_candidates([1, 4].into_iter(), vec![1], alpha_num, alpha_den, 1000)
+        .next()
+        .unwrap();
+    // score changes by alpha_den
+    assert_eq!(s1, s3 - alpha_num.max(alpha_den - alpha_num));
+    Ok(())
+}
+
+#[test]
+fn test_score_candidates_map() -> Fallible<()> {
+    // d_in * max(alpha_num, alpha_den - alpha_num) = 1 * max(1, 2 - 1) = 1
+    assert_eq!(score_candidates_map(1, 2, false)(&1)?, 1);
+    assert_eq!(score_candidates_map(1, 2, false)(&2)?, 2);
+    // d_in * max(alpha_num, alpha_den - alpha_num) = 1 * max(2, 3 - 2) = 2
+    assert_eq!(score_candidates_map(2, 3, false)(&1)?, 2);
+
+    // d_in // 2 * alpha_den = 1 // 2 * 2 = 0
+    assert_eq!(score_candidates_map(1, 2, true)(&1)?, 0);
+    // d_in // 2 * alpha_den = 2 // 2 * 2 = 2
+    assert_eq!(score_candidates_map(1, 2, true)(&2)?, 2);
+    assert_eq!(score_candidates_map(1, 2, true)(&3)?, 2);
+    // d_in // 2 * alpha_den = 1 // 2 * 3 = 3
+    assert_eq!(score_candidates_map(2, 3, true)(&2)?, 3);
     Ok(())
 }
 
 #[cfg(feature = "derive")]
-mod test_trans {
+mod integration_tests {
     use crate::{
         measurements::{Optimize, make_report_noisy_max_gumbel},
         metrics::SymmetricDistance,
@@ -118,7 +226,7 @@ mod test_trans {
         assert_eq!(trans.map(&1)?, 3);
 
         // sensitivity is d_in * α_den
-        assert_eq!(trans_sized.map(&2)?, 8);
+        assert_eq!(trans_sized.map(&2)?, 4);
 
         Ok(())
     }
@@ -165,3 +273,190 @@ mod test_trans {
         Ok(())
     }
 }
+
+mod alternative_impl {
+    use super::*;
+    use std::cmp::Ordering;
+
+    #[test]
+    fn test_alternative_impl_is_equivalent() -> Fallible<()> {
+        // There are two independent implementations of score_candidates that should be equivalent.
+        // If this test fails, then at least one of the implementations is bugged.
+        let data_max = 100_000u32;
+        let size_lim = 10_000u64;
+
+        let mut candidates = (0..1_000)
+            .map(|_| sample_uniform_uint_below(data_max))
+            .collect::<Fallible<Vec<u32>>>()?;
+        candidates.sort_unstable();
+        candidates.dedup();
+
+        let x: Vec<u32> = (0..size_lim)
+            .map(|_| sample_uniform_uint_below(data_max))
+            .collect::<Fallible<Vec<u32>>>()?;
+
+        let alpha_den = sample_uniform_uint_below(100_000)?;
+        let alpha_num = sample_uniform_uint_below(alpha_den)?;
+
+        let scores1: Vec<_> = score_candidates(
+            x.clone().into_iter(),
+            candidates.clone(),
+            alpha_num,
+            alpha_den,
+            size_lim,
+        )
+        .collect();
+
+        let scores2: Vec<_> =
+            score_candidates2(x.into_iter(), candidates, alpha_num, alpha_den, size_lim).collect();
+
+        assert_eq!(scores1, scores2);
+        Ok(())
+    }
+
+    /// Shares the same signature as score_candidates, but uses a different algorithm.
+    /// Runtime is O(n * ln(n)) for the sort phase, and O(ln(n) * c) for the score phase,
+    /// where n is the size of the input vector and c is the number of candidates.
+    /// Therefore, if n is already sorted, then overall runtime is O(ln(n) * c), otherwise O(n * ln(n)).
+    ///
+    /// score_candidates is O(ln(c) * n), which when data is not sorted, is faster for large n.
+    pub(crate) fn score_candidates2<TIA: 'static + PartialOrd>(
+        x: impl Iterator<Item = TIA>,
+        candidates: Vec<TIA>,
+        alpha_num: u64,
+        alpha_den: u64,
+        size_limit: u64,
+    ) -> impl Iterator<Item = u64> {
+        let alpha_num = alpha_num as usize;
+        let alpha_den = alpha_den as usize;
+        let size_limit = size_limit as usize;
+        let mut x = x.collect::<Vec<_>>();
+        // x must be sorted because counts are done via binary search
+        x.sort_by(|a, b| a.partial_cmp(&b).unwrap_or(Ordering::Equal));
+
+        // compute #(`x` < c) and #(`x` = c) for each c in candidates
+        let mut num_lt = vec![0; candidates.len()];
+        let mut num_eq = vec![0; candidates.len()];
+        count_lt_eq_recursive(
+            num_lt.as_mut_slice(),
+            num_eq.as_mut_slice(),
+            candidates.as_slice(),
+            x.as_slice(),
+            0,
+        );
+
+        // now that we have num_lt and num_eq for each candidate, score all candidates
+        num_lt
+            .into_iter()
+            .zip(num_eq.into_iter())
+            // score function cannot overflow.
+            //     lt <= size_limit, so 0 <= alpha_denom * lt <= usize::MAX
+            //     n - eq <= size_limit, so 0 <= size_limit - eq
+            .map(move |(lt, eq)| {
+                // |α_den * #(x < c) - α_num * (n - #(x = c))|
+                (alpha_den * lt.min(size_limit))
+                    .abs_diff(alpha_num * (x.len() - eq).min(size_limit)) as u64
+            })
+    }
+
+    /// Compute number of elements less than and equal to each edge
+    /// Formula is (#(`x` < `e`), #(`x` == `e`)) for each e in `edges`.
+    ///
+    /// # Arguments
+    /// * `count_lt` - location to write the count of elements less than each edge
+    /// * `count_eq` - location to write the count of elements equal to each edge
+    /// * `edges` - edges to collect counts for. Must be sorted
+    /// * `x` - dataset to count against
+    /// * `x_start_idx` - value to add to the count. Useful for recursion on subslices
+    fn count_lt_eq_recursive<TI: PartialOrd>(
+        count_lt: &mut [usize],
+        count_eq: &mut [usize],
+        edges: &[TI],
+        x: &[TI],
+        x_start_idx: usize,
+    ) {
+        if edges.is_empty() {
+            return;
+        }
+        if edges.len() == 1 {
+            let (num_lt, num_eq) = count_lt_eq(x, &edges[0]);
+            count_lt[0] = x_start_idx + num_lt;
+            count_eq[0] = num_eq;
+            return;
+        }
+        // use binary search to find #(x < middle edge) = |{i; x[i] < middle edge}|
+        let mid_edge_idx = (edges.len() + 1) / 2;
+        let mid_edge = &edges[mid_edge_idx];
+        let (num_lt, num_eq) = count_lt_eq(x, mid_edge);
+        count_lt[mid_edge_idx] = x_start_idx + num_lt;
+        count_eq[mid_edge_idx] = num_eq;
+
+        count_lt_eq_recursive(
+            &mut count_lt[..mid_edge_idx],
+            &mut count_eq[..mid_edge_idx],
+            &edges[..mid_edge_idx],
+            &x[..num_lt + num_eq],
+            x_start_idx,
+        );
+
+        count_lt_eq_recursive(
+            &mut count_lt[mid_edge_idx + 1..],
+            &mut count_eq[mid_edge_idx + 1..],
+            &edges[mid_edge_idx + 1..],
+            &x[num_lt + num_eq..],
+            x_start_idx + num_lt + num_eq,
+        );
+    }
+
+    /// Find the number of elements in `x` < `target` and number of elements in `x` == `target`.
+    /// Formula is (#(`x` < `target`), #(`x` == `target`))
+    ///
+    /// # Arguments
+    /// * `x` - dataset to count against
+    /// * `target` - value to compare against
+    fn count_lt_eq<TI: PartialOrd>(x: &[TI], target: &TI) -> (usize, usize) {
+        if x.is_empty() {
+            return (0, 0);
+        }
+        let (mut lower, mut upper) = (0, x.len());
+        let mut eq_upper = upper;
+
+        while upper - lower > 1 {
+            let middle = lower + (upper - lower) / 2;
+
+            if &x[middle] < target {
+                lower = middle;
+            } else {
+                upper = middle;
+                // tighten eq_upper to last middle where x[middle] was still greater than target
+                if &x[middle] > target {
+                    eq_upper = middle;
+                }
+            }
+        }
+        let lt = if &x[lower] < target { upper } else { lower };
+
+        // run another search to find the greatest index equal to target
+        // search starting from the first index equal to target
+        lower = lt;
+        // search for the smallest middle where x[middle] is greater than target
+        upper = eq_upper;
+        while upper - lower > 1 {
+            let middle = lower + (upper - lower) / 2;
+
+            if &x[middle] == target {
+                lower = middle;
+            } else {
+                upper = middle;
+            }
+        }
+
+        let eq = if lower == upper || &x[lower] == target {
+            upper
+        } else {
+            lower
+        } - lt;
+
+        (lt, eq)
+    }
+}


Reviews:
user: login: Shoeboxam
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-15T21:03:56Z
user: login: Shoeboxam
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-15T21:05:41Z
user: login: Shoeboxam
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-16T12:54:44Z
user: login: mccalluc
body: Just a partial review, so I'll request re-review myself: Should look at it with fresh eyes tomorrow.

Thanks for the github comments. I guess most of theme are about the difference between this PR and the previous state, but consider if comments in the source code would be useful for any of these?
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-16T20:42:00Z
user: login: mccalluc
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T15:27:37Z
user: login: mccalluc
body: I think the verdict on unexecuted notebook cells was that it's fine in a PR (particularly when it would update a lot of images, like in this case), but we might try to clean it up before release?

Assuming that's true, the only change here is the LaTex, and that seems good.

Approved.
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T14:26:25Z

Review comments:
diff_hunk: @@ -4,25 +4,6 @@
 enable_features("floating-point", "contrib")
 
 
-def test_quantile_score_candidates():
path: python/test/integration/test_quantile.py
user: login: Shoeboxam
body: I moved this test to Rust. Now that the dtype is u64 and not usize, the function can't be directly called from our tests in Python.
created_at: 2025-04-15T21:03:56Z
author_association: MEMBER
diff_hunk: @@ -56,15 +56,19 @@ where
 
     // scale up the noise proportionally to the increase in sensitivity
     // due to the integerization of scores
-    let denominator = score_candidates_constants(input_domain.size.clone(), alpha)?.1;
+    let denominator = score_candidates_constants(
+        input_domain.size.map(u64::exact_int_cast).transpose()?,
+        alpha,
+    )?
+    .1;
 
     let t_score =
         make_quantile_score_candidates(input_domain, input_metric, candidates.clone(), alpha)?;
 
     let m_rnm = make_report_noisy_max_gumbel(
         t_score.output_domain.clone(),
         t_score.output_metric.clone(),
-        scale * denominator as f64 * 2.0,
path: rust/src/measurements/private_quantile/mod.rs
user: login: Shoeboxam
body: I dropped this factor because I ultimately decided that it makes things more confusing. It was there to hide the factor of two lost by the non monotonicity of the scoring function.

This way the median costs 1 epsilon when scale is 1 at sensitivity 1. With it dropped, the scale parameter is also the effective noise scale added to data, and remains consistent with Polars.
created_at: 2025-04-15T21:05:41Z
author_association: MEMBER
diff_hunk: @@ -719,7 +719,7 @@
     "query_median_hours = (\n",
     "    context.query()\n",
     "    .filter(pl.col.HWUSUAL != 99.0)\n",
-    "    .select(pl.col.HWUSUAL.fill_null(35).dp.median(candidates))\n",
+    "    .select(pl.col.HWUSUAL.cast(int).fill_null(35).dp.median(candidates))\n",
path: docs/source/getting-started/tabular-data/essential-statistics.ipynb
user: login: Shoeboxam
body: I changed my mind on the enforcement of nans because even though nans were not privacy impacting, they were potentially utility impacting, because nans always contribute to the smallest bin. You'd really rather they be ignored or imputed, which sets us back to policing the domain descriptor.
created_at: 2025-04-16T05:17:21Z
author_association: MEMBER
diff_hunk: @@ -33,4 +14,4 @@ def test_private_quantile():
     )
 
     assert m_median(list(range(100))) == 50
-    assert m_median.map(1) == 0.5
+    assert m_median.map(1) == 1.0
path: python/test/integration/test_quantile.py
user: login: Shoeboxam
body: You'll see later I removed a factor of two in the recently added private quantile constructor. The math washes out, there's essentially half as much noise and twice as much budget for the same choice of scale.
created_at: 2025-04-16T05:20:21Z
author_association: MEMBER
diff_hunk: @@ -109,41 +137,54 @@ where
     let alpha_den_approx = if let Some(size) = size {
         // choose the finest granularity that won't overflow
         // must have that size * denom < MAX, so let denom = MAX // size
-        T::MAX_FINITE.neg_inf_div(&size)?
+        u64::MAX.neg_inf_div(&size)?
     } else {
         // default to an alpha granularity of .00001
-        T::exact_int_cast(10_000)?
+        u64::exact_int_cast(10_000)?
     };
 
     let (alpha_num, alpha_den) = if alpha_den_exact < UBig::from(alpha_den_approx) {
-        T::from(alpha_num_exact.into_parts().1)
-            .zip(T::from(alpha_den_exact))
-            .unwrap()
+        (
+            u64::try_from(alpha_num_exact.into_parts().1)?,
+            u64::try_from(alpha_den_exact)?,
+        )
     } else {
         // numer = alpha * denom
-        let alpha_num_approx = T::round_cast(alpha * f64::round_cast(alpha_den_approx.clone())?)?;
+        let alpha_num_approx = u64::round_cast(alpha * f64::round_cast(alpha_den_approx.clone())?)?;
         (alpha_num_approx, alpha_den_approx)
     };
 
     let size_limit = if let Some(size_limit) = size {
-        // ensures that there is no overflow
-        size_limit.alerting_mul(&alpha_den)?;
         size_limit
     } else {
-        T::MAX_FINITE.neg_inf_div(&alpha_den)?
+        u64::MAX.neg_inf_div(&alpha_den)?
     };
 
+    // both of these are un-fail-able, but they make the proof easy
+    assert!(alpha_num <= alpha_den);
+    size_limit.alerting_mul(&alpha_den)?;
+
     Ok((alpha_num, alpha_den, size_limit))
 }
 
+/// # Proof Definition
+/// Assume `alpha_den >= alpha_num`.
+///
+/// If `known_size` is set,
+/// then returns a function that computes $\texttt{d\_in} // 2 \cdot 2 \cdot \texttt{alpha\_den}$
+/// for argument $\texttt{d\_in}$.
+///
+/// Otherwise,
+/// returns a function that computes $\texttt{d\_in} \cdot \max(\texttt{alpha\_num}, \texttt{alpha\_den} - \texttt{alpha\_num})$,
+/// for argument $\texttt{d\_in}$.
 pub(crate) fn score_candidates_map<T: Integer + ExactIntCast<IntDistance>>(
     alpha_num: T,
     alpha_den: T,
     known_size: bool,
 ) -> impl Fn(&IntDistance) -> Fallible<T> {
     move |d_in| {
         if known_size {
-            T::exact_int_cast(d_in / 2 * 2)? // round down to even
+            T::exact_int_cast(d_in / 2)? // round down to even
path: rust/src/transformations/quantile_score_candidates/mod.rs
user: login: Shoeboxam
body: Dropped this two. This dropped two comes from improving the proof.
created_at: 2025-04-16T12:25:06Z
author_association: MEMBER
diff_hunk: @@ -178,141 +220,47 @@ pub(crate) fn score_candidates_map<T: Integer + ExactIntCast<IntDistance>>(
 /// * `alpha_num` - numerator of alpha fraction
 /// * `alpha_den` - denominator of alpha fraction. alpha fraction is {0: min, 0.5: median, 1: max, ...}
 /// * `size_limit` - maximum size of `x`. If `x` is larger than `size_limit`, scores are truncated
-pub(crate) fn compute_score<TIA: PartialOrd>(
-    mut x: Vec<TIA>,
-    candidates: &Vec<TIA>,
-    alpha_num: usize,
-    alpha_den: usize,
-    size_limit: usize,
-) -> Vec<usize> {
-    // x must be sorted because counts are done via binary search
-    x.sort_by(|a, b| a.partial_cmp(&b).unwrap_or(Ordering::Equal));
-
-    // compute #(`x` < c) and #(`x` = c) for each c in candidates
-    let mut num_lt = vec![0; candidates.len()];
-    let mut num_eq = vec![0; candidates.len()];
-    count_lt_eq_recursive(
-        num_lt.as_mut_slice(),
-        num_eq.as_mut_slice(),
-        candidates.as_slice(),
-        x.as_slice(),
-        0,
-    );
-
-    // now that we have num_lt and num_eq for each candidate, score all candidates
-    num_lt
-        .into_iter()
-        .zip(num_eq.into_iter())
-        // score function cannot overflow.
-        //     lt <= size_limit, so 0 <= alpha_denom * lt <= usize::MAX
-        //     n - eq <= size_limit, so 0 <= size_limit - eq
-        .map(|(lt, eq)| {
-            // |α_den * #(x < c) - α_num * (n - #(x = c))|
-            (alpha_den * lt.min(size_limit)).abs_diff(alpha_num * (x.len() - eq).min(size_limit))
-        })
-        .collect()
-}
-
-/// Compute number of elements less than and equal to each edge
-/// Formula is (#(`x` < `e`), #(`x` == `e`)) for each e in `edges`.
-///
-/// # Arguments
-/// * `count_lt` - location to write the count of elements less than each edge
-/// * `count_eq` - location to write the count of elements equal to each edge
-/// * `edges` - edges to collect counts for. Must be sorted
-/// * `x` - dataset to count against
-/// * `x_start_idx` - value to add to the count. Useful for recursion on subslices
-fn count_lt_eq_recursive<TI: PartialOrd>(
-    count_lt: &mut [usize],
-    count_eq: &mut [usize],
-    edges: &[TI],
-    x: &[TI],
-    x_start_idx: usize,
-) {
-    if edges.is_empty() {
-        return;
-    }
-    if edges.len() == 1 {
-        let (num_lt, num_eq) = count_lt_eq(x, &edges[0]);
-        count_lt[0] = x_start_idx + num_lt;
-        count_eq[0] = num_eq;
-        return;
-    }
-    // use binary search to find #(x < middle edge) = |{i; x[i] < middle edge}|
-    let mid_edge_idx = (edges.len() + 1) / 2;
-    let mid_edge = &edges[mid_edge_idx];
-    let (num_lt, num_eq) = count_lt_eq(x, mid_edge);
-    count_lt[mid_edge_idx] = x_start_idx + num_lt;
-    count_eq[mid_edge_idx] = num_eq;
+pub(crate) fn score_candidates<TIA: PartialOrd>(
path: rust/src/transformations/quantile_score_candidates/mod.rs
user: login: Shoeboxam
body: This logic comes from the Polars implementation of this scorer. We had two different equivalent impls, now we have just one.
created_at: 2025-04-16T12:28:13Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,179 @@
+\documentclass{article}
+\input{../../lib.sty}
+
+\title{\texttt{fn score\_candidates\_map}}
+\author{Michael Shoemate}
+\begin{document}
+\maketitle  
+
+
+\contrib
+
+Proves soundness of \rustdoc{transformations/quantile\_score\_candidates/fn}{score\_candidates\_map} 
+in \asOfCommit{mod.rs}{f5bb719}.
+\texttt{score\_candidates\_map} returns a specific function that can be used to prove stability of the quantile scoring transformation.
+
+\section{Hoare Triple}
+\subsection*{Precondition}
+$\texttt{alpha\_den} > \texttt{alpha\_num}$.
+
+\subsection*{Function}
+\label{sec:python-pseudocode}
+\lstinputlisting[language=Python,firstline=2,escapechar=`]{./pseudocode/score_candidates_map.py}
+
+
+\subsection*{Postcondition}
+
+\begin{theorem}
+    Define \function as follows:
+    \begin{equation*}
+        \function(x) = \mathrm{score\_candidates}_i(x, \texttt{candidates}, \texttt{alpha\_num}, \texttt{alpha\_den}, \texttt{size\_limit})
+    \end{equation*}
+
+    The function calls \rustdoc{transformations/quantile\_score\_candidates/fn}{score\_candidates} with fixed choices of
+    \texttt{candidates}, \texttt{alpha\_num}, \texttt{alpha\_den} and \texttt{size\_limit}.
+
+    If the input domain is the set of all vectors with non-null elements of type \texttt{TI},
+    input metric is either \rustdoc{metrics/struct}{SymmetricDistance} or \rustdoc{metrics/struct}{InsertDeleteDistance},
+    and the postcondition of \function is satisfied, then \function is stable.
+
+    This means that, for any two elements $x, x'$ in the \texttt{input\_domain},
+        where $x, x'$ share the same length if \texttt{known\_length} is true,
+    and any pair $(\din, \dout)$, 
+        where \din\ has the associated type for \texttt{input\_metric} and \dout\ has the associated type for \texttt{output\_metric},
+    then when $x, x'$ are \din-close under \texttt{input\_metric} and $\texttt{stability\_map}(\din) \leq \dout$,
+    \function(x), \function(x') are \dout-close under \texttt{output\_metric}.
+\end{theorem}
+
+
+The sensitivity of this function differs depending on whether the size of the input vector is known.
+\label{unsized-stability}
+First, consider the case where the size is unknown.
+
+\begin{lemma}
+    \label{single-change-stab-sym}
+    If $d_{Sym}(x, x') = 1$, then $d_\infty(\function(x), \function(x')) \le \max(\alpha_{num}, \alpha_{den} - \alpha_{num})$.
+\end{lemma}
+
+\begin{proof}
+    Assume $d_{Sym}(x, x') = 1$. 
+
+    \begin{align*}
+        &d_\infty(\function(x)_i, \function(x')_i) \\
+        &= \max\limits_{i} |\function(x)_i - \function(x')_i| &&\text{by definition of } d_\infty \\
+        &= \max\limits_{i} |\mathrm{abs\_diff}(\alpha_{den} \cdot \min(\#(x < C_i), l), \alpha_{num} \cdot \min(|x| - \#(x = C_i), l)) &&\text{by definition of } \function
+            \\&\hspace{1cm}\mathrm{abs\_diff}(\alpha_{den} \cdot \min(\#(x' < C_i), l), \alpha_{num} \cdot \min(|x'| - \#(x' = C_i), l))| \\
+        &= \alpha_{den} \cdot \max\limits_{i} ||\min(\#(x < C_i), l) - \alpha \cdot \min(|x| - \#(x = C_i), l)|
+            \\&\hspace{1cm}|\min(\#(x' < C_i), l) - \alpha \cdot \min(|x'| - \#(x' = C_i), l)|| \\
+        &\le \alpha_{den} \cdot \max\limits_{i} ||\#(x < C_i) - \alpha \cdot (|x| - \#(x = C_i))|
+            \\&\hspace{1cm}|\#(x' < C_i) - \alpha \cdot (|x| - \#(x' = C_i))|| \\
+    \intertext{Consider each of the three cases of adding or removing an element in x.}
+    \intertext{Case 1. Assume $x'$ is equal to $x$, but with some $x_j < C_i$ added or removed.}
+        &= \alpha_{den} \cdot \max\limits_{i} ||(1 - \alpha) \cdot \#(x < C_i) - \alpha \cdot \#(x > C_i)| 
+            \\&\hspace{1cm} - |(1 - \alpha) \cdot (\#(x < C_i) \pm 1) - \alpha \cdot \#(x > C_i)|| \\
+        &\leq \alpha_{den} \cdot  \max\limits_{i} ||(1 - \alpha) \cdot \#(x < C_i) - \alpha \cdot \#(x > C_i)|
+            \\&\hspace{1cm} - (|(1 - \alpha) \cdot \#(x < C_i) - \alpha \cdot \#(x > C_i)| + |\pm(1 - \alpha)|)| &&\text{by triangle inequality} \\
+        &= \alpha_{den} \cdot \max\limits_{i} |1 - \alpha| &&\text{scores cancel}\\
+        &= \alpha_{den} - \alpha_{num} &&\text{since } \alpha \leq 1
+    \intertext{Case 2. Assume $x'$ is equal to $x$, but with some $x_j > C_i$ added or removed.}
+        &= \alpha_{den} \cdot \max\limits_{i} ||(1 - \alpha) \cdot \#(x < C_i) - \alpha \cdot \#(x > C_i)| 
+            \\&\hspace{1cm} - |(1 - \alpha) \cdot \#(x < C_i) - \alpha \cdot (\#(x > C_i) \pm 1)|| \\
+        &\leq \alpha_{den} \cdot \max\limits_{i} ||(1 - \alpha) \cdot \#(x < C_i) - \alpha \cdot \#(x > C_i)| 
+            \\&\hspace{1cm} - (|(1 - \alpha) \cdot \#(x < C_i) - \alpha \cdot \#(x > C_i)| + |\pm\alpha|)| &&\text{by triangle inequality} \\
+        &= \alpha_{den} \cdot \max\limits_{i} |\alpha| &&\text{scores cancel}\\
+        &= \alpha_{num} &&\text{since } \alpha \geq 0
+    \intertext{Case 3. Assume $x'$ is equal to $x$, but with some $x_j = C_i$ added or removed.}
+        &= \alpha_{den} \cdot \max\limits_{i} ||(1 - \alpha) \cdot \#(x < C_i) - \alpha \cdot \#(x > C_i)| 
+            \\&\hspace{1cm} - |(1 - \alpha) \cdot \#(x < C_i) - \alpha \cdot \#(x > C_i)|| \\
+        &= 0 &&\text{no change in score}
+    \intertext{Take the union bound over all cases.}
+        &\le \max(\alpha_{num}, \alpha_{den} - \alpha_{num})
+    \end{align*}
+
+\end{proof}
+
+\label{sec:known-size}
+Now consider the case where the dataset size is known.
+
+\begin{lemma}
+    \label{single-change-stab}
+    If $d_{CO}(x, x') \le 1$, then $d_\infty(\function(x), \function(x')) \le 2 \cdot \alpha_{den}$.
+\end{lemma}
+
+\begin{proof}
+    Assume $d_{CO}(x, x') \le 1$. 
+
+    \begin{align*}
+        &d_\infty(\function(x), \function(x')) \\
+        &= \max\limits_{i} |\function(x)_i - \function(x')_i| &&\text{by definition of } d_\infty \\
+        &= \max\limits_{i} |\mathrm{abs\_diff}(\alpha_{den} \cdot \min(\#(x < C_i), l), \alpha_{num} \cdot \min(|x| - \#(x = C_i), l)) &&\text{by def. of } \function
+            \\&\hspace{1cm} -\mathrm{abs\_diff}(\alpha_{den} \cdot \min(\#(x' < C_i), l), \alpha_{num} \cdot \min(|x'| - \#(x' = C_i), l))| \\
+        &= \alpha_{den} \cdot \max\limits_{i} ||\min(\#(x < C_i), l) - \alpha \cdot \min(|x| - \#(x = C_i), l)|
+            \\&\hspace{1cm}-|\min(\#(x' < C_i), l) - \alpha \cdot \min(|x'| - \#(x' = C_i), l)|| \\
+        &= \alpha_{den} \cdot \max\limits_{i} ||\#(x < C_i) - \alpha \cdot (|x| - \#(x = C_i))|
+            \\&\hspace{1cm}-|\#(x' < C_i) - \alpha \cdot (|x| - \#(x' = C_i))|| \\
+    \end{align*}
+
+    \begin{align*}
+    \intertext{Consider each of the four cases of changing a row in x.}
+    \intertext{Case 1. Assume $x'$ is equal to $x$, but with some $x_j < C_i$ replaced with $x'_j > C_i$.}
path: rust/src/transformations/quantile_score_candidates/score_candidates_map.tex
user: login: Shoeboxam
body: This is where an extra two came from in the prior proof. In the prior proof, it considered the addition and removal separately. I also wrote some tests that exercise these cases.
created_at: 2025-04-16T12:44:56Z
author_association: MEMBER
diff_hunk: @@ -1,96 +1,193 @@
+use num::Float;
+
+use crate::{
+    measurements::{Optimize, make_report_noisy_max_gumbel},
+    metrics::SymmetricDistance,
+    traits::samplers::sample_uniform_uint_below,
+};
+
 use super::*;
 
 #[test]
-fn test_count_lte() {
-    let x = (5..20).collect::<Vec<i32>>();
-    let edges = vec![2, 4, 7, 12, 22];
-    let mut count_lt = vec![0; edges.len()];
-    let mut count_eq = vec![0; edges.len()];
-    count_lt_eq_recursive(
-        count_lt.as_mut_slice(),
-        count_eq.as_mut_slice(),
-        edges.as_slice(),
-        x.as_slice(),
-        0,
+fn test_quantile_score_candidates_median() -> Fallible<()> {
+    let candidates = vec![20, 33, 40, 50, 72, 100];
+    let t_qscore = make_quantile_score_candidates(
+        VectorDomain::new(AtomDomain::<u32>::default()),
+        SymmetricDistance,
+        candidates.clone(),
+        0.5,
+    )?;
+
+    assert_eq!(
+        t_qscore.invoke(&(0..100).collect())?,
+        vec![59, 33, 19, 1, 45, 100]
     );
-    println!("{:?}", count_lt);
-    println!("{:?}", count_eq);
-    assert_eq!(count_lt, vec![0, 0, 2, 7, 15]);
-    assert_eq!(count_eq, vec![0, 0, 1, 1, 0]);
+
+    let m_rnm = make_report_noisy_max_gumbel(
+        t_qscore.output_domain.clone(),
+        t_qscore.output_metric.clone(),
+        1.0,
+        Optimize::Min,
+    )?;
+
+    let m_quantile = (t_qscore >> m_rnm)?;
+    let idx = m_quantile.invoke(&(0..100).collect())?;
+    assert_eq!(candidates[idx], 50);
+    assert_eq!(m_quantile.map(&1)?, 2.0);
+    Ok(())
 }
 
 #[test]
-fn test_count_lte_repetition() {
-    let x = vec![0, 2, 2, 3, 5, 7, 7, 7];
-    let edges = vec![-1, 2, 4, 7, 12, 22];
-    let mut count_lt = vec![0; edges.len()];
-    let mut count_eq = vec![0; edges.len()];
-    count_lt_eq_recursive(
-        count_lt.as_mut_slice(),
-        count_eq.as_mut_slice(),
-        edges.as_slice(),
-        x.as_slice(),
-        0,
-    );
-    println!("{:?}", count_lt);
-    println!("{:?}", count_eq);
-    assert_eq!(count_lt, vec![0, 1, 4, 5, 8, 8]);
-    assert_eq!(count_eq, vec![0, 2, 0, 3, 0, 0]);
+fn test_quantile_score_candidates_offset() -> Fallible<()> {
+    let trans = make_quantile_score_candidates(
+        VectorDomain::new(AtomDomain::<u32>::default()),
+        SymmetricDistance,
+        vec![0, 25, 50, 75, 100],
+        0.5,
+    )?;
+    // score works out to 2 * |50 - cand|
+    assert_eq!(trans.invoke(&(0..101).collect())?, [100, 50, 0, 50, 100]);
+    assert_eq!(trans.map(&1)?, 1);
+    Ok(())
 }
 
-fn test_case(x: Vec<i32>, edges: Vec<i32>, true_lt: Vec<usize>, true_eq: Vec<usize>) {
-    let mut count_lt = vec![0; edges.len()];
-    let mut count_eq = vec![0; edges.len()];
-    count_lt_eq_recursive(
-        count_lt.as_mut_slice(),
-        count_eq.as_mut_slice(),
-        edges.as_slice(),
-        x.as_slice(),
-        0,
-    );
-    println!("LT");
-    println!("{:?}", true_lt);
-    println!("{:?}", count_lt);
+#[test]
+fn test_validate_candidates() -> Fallible<()> {
+    let candidates = vec![1, 2, 3];
+    check_candidates(&candidates)?;
+
+    let candidates = vec![1f32, 2f32, 3f32];
+    check_candidates(&candidates)?;
+
+    let candidates = vec![2, 1, 3];
+    assert!(check_candidates(&candidates).is_err());
+
+    let candidates = vec![2.0, 1.0, 3.0];
+    assert!(check_candidates(&candidates).is_err());
 
-    println!("EQ");
-    println!("{:?}", true_eq);
-    println!("{:?}", count_eq);
+    let candidates = vec![1.0, 2.0, f64::NAN];
+    assert!(check_candidates(&candidates).is_err());
 
-    assert_eq!(true_lt, count_lt);
-    assert_eq!(true_eq, count_eq);
+    let candidates = vec![f64::NAN, 1.0, 2.0];
+    assert!(check_candidates(&candidates).is_err());
+
+    Ok(())
 }
 
 #[test]
-fn test_count_lte_edge_cases() {
-    // check constant x
-    test_case(vec![0; 10], vec![-1], vec![0], vec![0]);
-    test_case(vec![0; 10], vec![0], vec![0], vec![10]);
-    test_case(vec![0; 10], vec![1], vec![10], vec![0]);
-
-    // below first split
-    test_case(vec![1, 2, 3, 3, 3, 3], vec![2], vec![1], vec![1]);
-    test_case(vec![1, 2, 3, 3, 3, 3, 3], vec![2], vec![1], vec![1]);
-    // above first split
-    test_case(vec![1, 1, 1, 1, 2, 3], vec![2], vec![4], vec![1]);
-    test_case(vec![1, 1, 1, 1, 2, 3, 3], vec![2], vec![4], vec![1]);
+fn test_score_candidates_constants() -> Fallible<()> {
+    // pass size, alpha, get alpha decomposition and upper bound for arithmetic
+    let (alpha_num, alpha_den, size_limit) = score_candidates_constants(Some(10), 0.5)?;
+    assert_eq!(alpha_num, 1);
+    assert_eq!(alpha_den, 2);
+    assert_eq!(size_limit, 10);
+
+    let (alpha_num, alpha_den, size_limit) = score_candidates_constants(None, 0.5)?;
+    assert_eq!(alpha_num, 1);
+    assert_eq!(alpha_den, 2);
+    assert_eq!(size_limit, u64::MAX / alpha_den);
+
+    let (alpha_num, alpha_den, size_limit) = score_candidates_constants(None, 0.25)?;
+    assert_eq!(alpha_num, 1);
+    assert_eq!(alpha_den, 4);
+    assert_eq!(size_limit, u64::MAX / alpha_den);
+
+    let (alpha_num, alpha_den, size_limit) = score_candidates_constants(None, 2.0 - 2.0.sqrt())?;
+    // 2 - sqrt(2) = 0.5857864376
+    // take the first 4 digits of the decimal
+    assert_eq!(alpha_num, 5857);
+    assert_eq!(alpha_den, 10_000);
+    assert_eq!(size_limit, u64::MAX / alpha_den);
+
+    let (alpha_num, alpha_den, size_limit) = score_candidates_constants(None, 0.0)?;
+    assert_eq!(alpha_num, 0);
+    assert_eq!(alpha_den, 1);
+    assert_eq!(size_limit, u64::MAX);
+
+    let (alpha_num, alpha_den, size_limit) =
+        score_candidates_constants(Some(u64::MAX / 4), 0.34434532242)?;
+    // since the size bound is so large, the alpha decomposition is more lossy
+    assert_eq!(alpha_num, 1);
+    assert_eq!(alpha_den, 4);
+    assert_eq!(size_limit, u64::MAX / 4);
+
+    Ok(())
 }
 
 #[test]
-fn test_scorer() -> Fallible<()> {
-    let edges = vec![-1, 2, 4, 7, 12, 22];
-
+fn test_score_candidates_function() {
+    let c = vec![-1, 2, 4, 7, 12, 22];
     let x = vec![0, 2, 2, 3, 5, 7, 7, 7];
-    let scores = compute_score(x, &edges, 1, 2, 8);
-    println!("{:?}", scores);
+    // RO:       (-inf, -1], (-1, 2], (2, 4], (4, 7], (7, 12], (12, 22], (22, inf)
+    // hist_ro = 0           3,       1,      4,      0,       0,        0
+    // LO:       (-inf, -1), [-1, 2), [2, 4), [4, 7), [7, 12), [12, 22), [22, inf)
+    // hist_lo = 0           1,       3,      1,      3,       0,        0
+    let scores: Vec<_> = score_candidates(x.into_iter(), c, 1, 2, 7).collect();
+    // lt = 0, le = 0, gt = 8 - 0 = 8
+    // |1 * min(0, 7) - 1 * min(8, 7)| = |0 - 7| = 7
+
+    // lt = 1, le = 3, gt = 8 - 3 = 5
+    // |1 * min(1, 7) - 1 * min(5, 7)| = |1 - 5| = 4
+
+    // lt = 4, le = 4, gt = 8 - 4 = 4
+    // |1 * min(4, 7) - 1 * min(4, 7)| = |4 - 4| = 0
+
+    // lt = 5, le = 8, gt = 8 - 8 = 0
+    // |1 * min(5, 7) - 1 * min(0, 7)| = |5 - 0| = 5
+
+    // lt = 8, le = 8, gt = 8 - 8 = 0
+    // |1 * min(8, 7) - 1 * min(0, 7)| = |7 - 0| = 7
+
+    // lt = 8, le = 8, gt = 8 - 8 = 0
+    // |1 * min(8, 7) - 1 * min(0, 7)| = |7 - 0| = 7
+    assert_eq!(scores, vec![7, 4, 0, 5, 7, 7]);
+}
+
+#[test]
+fn test_scorer_edge_cases() {
+    let c = vec![];
+    let x = vec![1, 2, 3];
+    assert!(Vec::from_iter(score_candidates(x.into_iter(), c, 1, 2, 10)).is_empty());
+
+    let c = vec![1, 2, 3];
+    let x = vec![];
+    let scores = Vec::from_iter(score_candidates(x.into_iter(), c, 1, 2, 10));
+    assert_eq!(scores, vec![0; 3]);
+}
+
+#[test]
+fn test_score_candidates_stability() -> Fallible<()> {
+    let s1 = score_candidates([0, 4].into_iter(), vec![1], 1, 2, 1000)
path: rust/src/transformations/quantile_score_candidates/test.rs
user: login: Shoeboxam
body: These test the cases in the proof that have been tightened.
created_at: 2025-04-16T12:50:41Z
author_association: MEMBER
diff_hunk: @@ -56,9 +56,21 @@ where
     if active_series.nullable {
         return fallible!(
             MakeTransformation,
-            "Quantile estimation requires non-null inputs"
+            "Quantile estimation requires non-null inputs. Try using `.fill_null(x)` or `.drop_null()` first."
         );
     }
+    let nan = match active_series.dtype() {
path: rust/src/transformations/make_stable_expr/expr_discrete_quantile_score/mod.rs
user: login: Shoeboxam
body: This is where the "not necessary for privacy, but good for utility" check is added.
created_at: 2025-04-16T12:53:28Z
author_association: MEMBER
diff_hunk: @@ -277,7 +277,7 @@
   },
   {
    "cell_type": "code",
-   "execution_count": 7,
+   "execution_count": null,
path: docs/source/getting-started/statistical-modeling/regression.ipynb
user: login: mccalluc
body: It looks like this notebook wasn't reevaluated after changes. (Could imagine a test that every notebook has every cell evaluated in order.) I think CI will tell us if there are errors, but it wouldn't notices changes in output short of that.
created_at: 2025-04-16T20:33:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,48 @@
+\documentclass{article}
+\input{../../lib.sty}
+
+\title{\texttt{fn check\_candidates}}
+\author{Michael Shoemate}
+\begin{document}
+\maketitle  
+
+
+\contrib
+
+Proves soundness of \rustdoc{transformations/quantile\_score\_candidates/fn}{check\_candidates} 
+in \asOfCommit{mod.rs}{f5bb719}.
+\texttt{check\_candidates} raises an error if the discrete quantile candidate set is invalid.
+
+\section{Hoare Triple}
+\subsection*{Precondition}
+None
+
+\subsection*{Function}
+\label{sec:python-pseudocode}
+\lstinputlisting[language=Python,firstline=2,escapechar=`]{./pseudocode/check_candidates.py}
+
+\subsection*{Postcondition}
+
+\begin{theorem}
+    Candidates must be:
+    \begin{enumerate}
+        \item non-empty
+        \item strictly increasing
+        \item totally ordered
+    \end{enumerate}
+
+    Otherwise the function errors.
+\end{theorem}
+
+\begin{proof}
+    \begin{enumerate}
+        \item \texttt{candidates} is non-empty, by the check on line \ref{empty}
path: rust/src/transformations/quantile_score_candidates/check_candidates.tex
user: login: mccalluc
body: This renders as 
<img width="816" alt="Screenshot 2025-04-17 at 11 13 37 AM" src="https://github.com/user-attachments/assets/7aa9dd50-869b-4c1f-88de-a5d8d2ed1f29" />

Maybe it expects some text between the `\begin{proof}` and `\begin{enumerate}`?
created_at: 2025-04-17T15:14:55Z
author_association: CONTRIBUTOR

PR comments:
user: login: Shoeboxam
created_at: 2025-04-13T18:55:39Z
body: * **#2358** <a href="https://app.graphite.dev/github/pr/opendp/opendp/2358?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>
* **#1734** <a href="https://app.graphite.dev/github/pr/opendp/opendp/1734?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>
* **#2357** <a href="https://app.graphite.dev/github/pr/opendp/opendp/2357?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a> 👈 <a href="https://app.graphite.dev/github/pr/opendp/opendp/2357?utm_source=stack-comment-view-in-graphite" target="_blank">(View in Graphite)</a>
* `main`


<h2></h2>

This stack of pull requests is managed by <a href="https://graphite.dev?utm-source=stack-comment"><b>Graphite</b></a>. Learn more about <a href="https://stacking.dev/?utm_source=stack-comment">stacking</a>.
<!-- Current dependencies on/for this PR: -->
author_association: MEMBER
user: login: github-actions[bot]
created_at: 2025-04-15T05:19:49Z
body: ## 🔖 Rendered Proofs
* [`rust/src/transformations/quantile_score_candidates/check_candidates.pdf`](https://github.com/opendp/artifacts/blob/main/PR/2357/rust/src/transformations/quantile_score_candidates/check_candidates.pdf?raw=true)
* [`rust/src/transformations/quantile_score_candidates/make_quantile_score_candidates.pdf`](https://github.com/opendp/artifacts/blob/main/PR/2357/rust/src/transformations/quantile_score_candidates/make_quantile_score_candidates.pdf?raw=true)
* [`rust/src/transformations/quantile_score_candidates/score_candidates.pdf`](https://github.com/opendp/artifacts/blob/main/PR/2357/rust/src/transformations/quantile_score_candidates/score_candidates.pdf?raw=true)
* [`rust/src/transformations/quantile_score_candidates/score_candidates_constants.pdf`](https://github.com/opendp/artifacts/blob/main/PR/2357/rust/src/transformations/quantile_score_candidates/score_candidates_constants.pdf?raw=true)
* [`rust/src/transformations/quantile_score_candidates/score_candidates_map.pdf`](https://github.com/opendp/artifacts/blob/main/PR/2357/rust/src/transformations/quantile_score_candidates/score_candidates_map.pdf?raw=true)

synchronized with 37d58bde85db13d9d82824c61901695419af96fb
author_association: NONE