From fadf35aa00e09a533010e228c9bdca9ee653e2eb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 02:59:18 +0900 Subject: [PATCH 1/4] refactor(ata): own fixed-form greedy assembly in Rust Public assemble_test_form validates and marshals only; ordering, exclusion, and content-feasibility decisions move to assemble_test_form_greedy with ownership sentinel tests, changelog fragment, and APA doctoring. Supersedes draft #747 once green. --- CHANGELOG.md | 1 + crates/fast-mlsirm-py/src/lib.rs | 38 +++ crates/mlsirm-core/src/lib.rs | 1 + crates/mlsirm-core/src/test_form.rs | 275 ++++++++++++++++++ .../747-test-form-rust-ownership.md | 7 + docs/doctoring/test_form_rust_ownership.md | 23 ++ python/fast_mlsirm/test_design.py | 81 ++---- tests/test_test_form_rust_ownership.py | 42 +++ 8 files changed, 405 insertions(+), 63 deletions(-) create mode 100644 crates/mlsirm-core/src/test_form.rs create mode 100644 docs/changelog.d/747-test-form-rust-ownership.md create mode 100644 docs/doctoring/test_form_rust_ownership.md create mode 100644 tests/test_test_form_rust_ownership.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d0ade3a1c..8d9fd52ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Fixed +- Public fixed-form `assemble_test_form` delegates greedy maximum-information selection and content-feasibility look-ahead to the Rust core (`assemble_test_form_greedy`). - Public CAT `item_information` and `select_cat_item` delegate Fisher information and maximum-information ranking to the Rust core. - Bound top-1 CSR loser streams and enforce the shared ranking CSR byte ceiling with stable non-reflective iteration errors. - Validate ATA content-constraint maps, exposure counts, seed, and exposure_max as admitted types before item-information evaluation, rejecting hostile conversion callbacks while preserving accepted string keys and exact integers. diff --git a/crates/fast-mlsirm-py/src/lib.rs b/crates/fast-mlsirm-py/src/lib.rs index 42c6b08d8..3d98a4fc0 100644 --- a/crates/fast-mlsirm-py/src/lib.rs +++ b/crates/fast-mlsirm-py/src/lib.rs @@ -145,6 +145,7 @@ use mlsirm_core::security::k_variants as core_k_variants; use mlsirm_core::security::wollack_omega as core_wollack_omega; use mlsirm_core::standard_setting::hofstee as core_hofstee; use mlsirm_core::subscores::subscores as core_subscores; +use mlsirm_core::test_form::assemble_test_form_greedy as core_assemble_test_form_greedy; use mlsirm_core::testlet::{fit_testlet as core_fit_testlet, TestletConfig, TestletModel}; use mlsirm_core::twopl::{fit_2pl as core_fit_2pl, TwoPlConfig}; use mlsirm_core::utility::{ @@ -8219,6 +8220,42 @@ fn cat_select_item( .map_err(PyValueError::new_err) } +/// Greedy maximum-information fixed-form assembly with content constraints. +/// +/// Python validates public shapes and marshals maps; ordering, exclusion, and +/// content-feasibility decisions are owned by the Rust numeric core. +#[pyfunction] +#[pyo3(signature = ( + information, + length, + content = None, + min_per_content = None, + max_per_content = None, + exclude = None, +))] +fn assemble_test_form_greedy( + information: PyReadonlyArray1<'_, f64>, + length: usize, + content: Option>, + min_per_content: Option>, + max_per_content: Option>, + exclude: Option>, +) -> PyResult> { + let min_map = min_per_content.unwrap_or_default(); + let max_map = max_per_content.unwrap_or_default(); + let exclude_idx = exclude.unwrap_or_default(); + let content_ref = content.as_deref(); + core_assemble_test_form_greedy( + information.as_slice()?, + length, + content_ref, + &min_map, + &max_map, + &exclude_idx, + ) + .map_err(PyValueError::new_err) +} + /// Item/test information at supplied (theta, xi) points (Magis 2013 4PL /// formula, c=0/d=1 logistic case; Lord test-information tradition). #[pyfunction] @@ -8925,6 +8962,7 @@ fn fast_mlsirm_core(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_function(wrap_pyfunction!(cat_ability_standard_error, m)?)?; m.add_function(wrap_pyfunction!(cat_item_information, m)?)?; m.add_function(wrap_pyfunction!(cat_select_item, m)?)?; + m.add_function(wrap_pyfunction!(assemble_test_form_greedy, m)?)?; m.add_function(wrap_pyfunction!(bank_information, m)?)?; m.add_function(wrap_pyfunction!(cat_next_item, m)?)?; m.add_function(wrap_pyfunction!(plausible_values, m)?)?; diff --git a/crates/mlsirm-core/src/lib.rs b/crates/mlsirm-core/src/lib.rs index abc70a01e..2a86b646e 100644 --- a/crates/mlsirm-core/src/lib.rs +++ b/crates/mlsirm-core/src/lib.rs @@ -42,6 +42,7 @@ pub mod scoring; pub mod security; pub mod standard_setting; pub mod subscores; +pub mod test_form; pub mod testlet; pub mod twopl; pub mod utility; diff --git a/crates/mlsirm-core/src/test_form.rs b/crates/mlsirm-core/src/test_form.rs new file mode 100644 index 000000000..e4484a596 --- /dev/null +++ b/crates/mlsirm-core/src/test_form.rs @@ -0,0 +1,275 @@ +//! Fixed-form greedy maximum-information assembly with content constraints. +//! +//! Public Python `assemble_test_form` validates and marshals; ordering, +//! exclusion, and content-feasibility decisions are owned here so form +//! construction stays single-sourced on the compiled numeric path. +//! +//! The procedure ranks eligible items by Fisher (or precomputed) information +//! descending and greedily admits the next item that preserves look-ahead +//! feasibility of minimum content counts under maximum caps (van der Linden, +//! 2005, ch. 4 greedy heuristic for constrained assembly). +//! +//! # References (APA 7th ed.) +//! +//! van der Linden, W. J. (2005). *Linear models for optimal test design*. +//! Springer. https://doi.org/10.1007/0-387-29054-0 + +use std::collections::{HashMap, HashSet}; + +/// Assemble a fixed-length form by greedy maximum-information selection. +/// +/// `information` is one finite-or-nonfinite score per item. Non-finite scores +/// and indices listed in `exclude` are skipped. When `content` is `Some`, it +/// must have the same length as `information`; empty `min`/`max` maps mean no +/// constraints of that kind. Returns selected item indices in admission order. +pub fn assemble_test_form_greedy( + information: &[f64], + length: usize, + content: Option<&[String]>, + min_per_content: &HashMap, + max_per_content: &HashMap, + exclude: &[i64], +) -> Result, String> { + let n = information.len(); + if n == 0 { + return Err("information must be a non-empty 1D array".into()); + } + if length < 1 || length > n { + return Err("length must be between 1 and the number of items".into()); + } + if let Some(labels) = content { + if labels.len() != n { + return Err("content length must match information".into()); + } + } else if !min_per_content.is_empty() || !max_per_content.is_empty() { + return Err("content labels are required for content constraints".into()); + } + for (label, &minimum) in min_per_content { + if minimum < 0 { + return Err(format!("minimum content constraint for {label} must be non-negative")); + } + if let Some(&maximum) = max_per_content.get(label) { + if minimum > maximum { + return Err(format!( + "minimum content constraint cannot exceed maximum for {label}" + )); + } + } + } + for (label, &maximum) in max_per_content { + if maximum < 0 { + return Err(format!("maximum content constraint for {label} must be non-negative")); + } + } + + let mut excluded: HashSet = HashSet::new(); + for &raw in exclude { + if raw < 0 { + return Err("exclude indices must be non-negative".into()); + } + let idx = raw as usize; + if idx >= n { + return Err("exclude index out of range".into()); + } + excluded.insert(idx); + } + + // Descending information, stable on ties by ascending index (deterministic). + let mut order: Vec = (0..n) + .filter(|&i| !excluded.contains(&i) && information[i].is_finite()) + .collect(); + order.sort_by(|&a, &b| { + information[b] + .partial_cmp(&information[a]) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| a.cmp(&b)) + }); + + let mut selected: Vec = Vec::with_capacity(length); + let mut counts: HashMap = HashMap::new(); + let length_i = length as i64; + + for _ in 0..length { + let mut admitted = false; + for &item in &order { + if selected.contains(&item) { + continue; + } + let label = content.map(|labels| labels[item].as_str()); + let mut next_counts = counts.clone(); + if let Some(label) = label { + let cap = max_per_content + .get(label) + .copied() + .unwrap_or(length_i); + let current = next_counts.get(label).copied().unwrap_or(0); + if current >= cap { + continue; + } + next_counts.insert(label.to_owned(), current + 1); + } + if constraints_feasible( + &order, + &selected, + item, + &excluded, + content, + &next_counts, + length, + min_per_content, + max_per_content, + ) { + selected.push(item); + counts = next_counts; + admitted = true; + break; + } + } + if !admitted { + return Err("could not assemble a form that satisfies constraints".into()); + } + } + + for (label, &minimum) in min_per_content { + let have = counts.get(label).copied().unwrap_or(0); + if have < minimum { + // Defensive: look-ahead should already enforce minima. + return Err(format!("minimum content constraint not met: {label}")); + } + } + + Ok(selected.into_iter().map(|i| i as i64).collect()) +} + +fn constraints_feasible( + order: &[usize], + selected: &[usize], + candidate: usize, + excluded: &HashSet, + content: Option<&[String]>, + counts: &HashMap, + length: usize, + min_counts: &HashMap, + max_counts: &HashMap, +) -> bool { + let mut trial_selected = selected.to_vec(); + trial_selected.push(candidate); + let slots_left = length.saturating_sub(trial_selected.len()) as i64; + let required_left: i64 = min_counts + .iter() + .map(|(label, &minimum)| (minimum - counts.get(label).copied().unwrap_or(0)).max(0)) + .sum(); + if required_left > slots_left { + return false; + } + let Some(labels) = content else { + return true; + }; + let length_i = length as i64; + let blocked: HashSet = trial_selected + .iter() + .copied() + .chain(excluded.iter().copied()) + .collect(); + for (label, &minimum) in min_counts { + let needed = (minimum - counts.get(label).copied().unwrap_or(0)).max(0); + if needed == 0 { + continue; + } + let cap = max_counts.get(label).copied().unwrap_or(length_i); + let mut available: i64 = 0; + for &item in order { + if blocked.contains(&item) || labels[item] != *label { + continue; + } + // counts already include the candidate when label matches. + if counts.get(label).copied().unwrap_or(0) + available >= cap { + break; + } + available += 1; + } + if available < needed { + return false; + } + } + true +} + +#[cfg(test)] +mod tests { + use super::*; + + fn empty_map() -> HashMap { + HashMap::new() + } + + #[test] + fn unconstrained_picks_highest_information() { + let info = [5.0, 4.0, 3.0, 2.0, 1.0]; + let form = assemble_test_form_greedy(&info, 3, None, &empty_map(), &empty_map(), &[]) + .expect("form"); + assert_eq!(form, vec![0, 1, 2]); + } + + #[test] + fn exclude_skips_top_item() { + let info = [5.0, 4.0, 3.0, 2.0, 1.0]; + let form = + assemble_test_form_greedy(&info, 2, None, &empty_map(), &empty_map(), &[0]).expect("form"); + assert_eq!(form, vec![1, 2]); + } + + #[test] + fn content_min_max_respected() { + let info = [6.0, 5.0, 4.0, 3.0, 2.0, 1.0]; + let content = ["a", "a", "b", "b", "c", "c"] + .into_iter() + .map(str::to_owned) + .collect::>(); + let min = HashMap::from([("c".into(), 1)]); + let max = HashMap::from([("a".into(), 1)]); + let form = + assemble_test_form_greedy(&info, 3, Some(&content), &min, &max, &[]).expect("form"); + let mut a = 0; + let mut c = 0; + for &idx in &form { + match content[idx as usize].as_str() { + "a" => a += 1, + "c" => c += 1, + _ => {} + } + } + assert!(a <= 1); + assert!(c >= 1); + } + + #[test] + fn infeasible_min_raises() { + let info = [5.0, 4.0]; + let content = ["a".into(), "a".into()]; + let min = HashMap::from([("b".into(), 1)]); + let err = assemble_test_form_greedy(&info, 2, Some(&content), &min, &empty_map(), &[]) + .unwrap_err(); + assert!(err.contains("could not assemble") || err.contains("constraint")); + } + + #[test] + fn ownership_sentinel_shape_matches_public_contract() { + // Length-2 form with B-min / A-max and exclude=3 should be feasible + // and prefer high-info B then residual A when constraints force it. + let info = [1.0, 4.0, 3.0, 2.0]; + let content = ["A", "A", "B", "B"] + .into_iter() + .map(str::to_owned) + .collect::>(); + let min = HashMap::from([("B".into(), 1)]); + let max = HashMap::from([("A".into(), 1)]); + let form = + assemble_test_form_greedy(&info, 2, Some(&content), &min, &max, &[3]).expect("form"); + assert_eq!(form.len(), 2); + assert!(!form.contains(&3)); + // Item 1 (A, info=4) is highest remaining; item 2 (B, info=3) satisfies min B. + // Greedy admits highest first when still feasible: 1 then 2. + assert_eq!(form, vec![1, 2]); + } +} diff --git a/docs/changelog.d/747-test-form-rust-ownership.md b/docs/changelog.d/747-test-form-rust-ownership.md new file mode 100644 index 000000000..d0562ebdb --- /dev/null +++ b/docs/changelog.d/747-test-form-rust-ownership.md @@ -0,0 +1,7 @@ +# Fixed-form greedy assembly Rust ownership + +## Fixed + +- Public `assemble_test_form` delegates ordering, exclusion, and content-feasibility + decisions to the compiled Rust core (`assemble_test_form_greedy`), keeping Python + for validation and marshalling only. diff --git a/docs/doctoring/test_form_rust_ownership.md b/docs/doctoring/test_form_rust_ownership.md new file mode 100644 index 000000000..dc8ee3930 --- /dev/null +++ b/docs/doctoring/test_form_rust_ownership.md @@ -0,0 +1,23 @@ +# Doctoring: Rust-owned fixed-form greedy assembly + +## Claim + +Public fixed-form maximum-information assembly with content min/max constraints is +owned by the compiled Rust numeric core. Python validates shapes and marshals +constraint maps; selection order and feasibility look-ahead do not re-implement the +greedy heuristic in production Python. + +## Standards and literature (APA 7th) + +van der Linden, W. J. (2005). *Linear models for optimal test design*. Springer. +https://doi.org/10.1007/0-387-29054-0 + +Lord, F. M. (1980). *Applications of item response theory to practical testing +problems*. Lawrence Erlbaum Associates. + +## Verification + +- Rust unit tests for unconstrained ranking, exclusion, content min/max, and + infeasible constraint failure. +- Python ownership sentinel requiring `core.assemble_test_form_greedy` transport. +- Existing `tests/test_cov_a_test_design.py` behavioral suite. diff --git a/python/fast_mlsirm/test_design.py b/python/fast_mlsirm/test_design.py index 6678f115d..8c6583091 100644 --- a/python/fast_mlsirm/test_design.py +++ b/python/fast_mlsirm/test_design.py @@ -106,6 +106,10 @@ def assemble_test_form( Picks the ``length`` highest-information items (skipping ``exclude``d ones) subject to per-content min/max count constraints, raising if no feasible form satisfies them. Returns the selected item indices. + + Ordering, exclusion, and content-feasibility decisions are owned by the + compiled Rust core (``assemble_test_form_greedy``); Python validates public + shapes and marshals constraint maps without mutating caller arrays. """ scores = np.asarray(information, dtype=np.float64) if scores.ndim != 1: @@ -121,34 +125,20 @@ def assemble_test_form( if labels is not None and labels.shape != scores.shape: raise ValueError("content length must match information") - excluded = set(np.asarray(exclude, dtype=np.int64).tolist()) if exclude is not None else set() - selected: list[int] = [] - counts: dict[str, int] = {} - order = [int(i) for i in np.argsort(-scores) if i not in excluded and np.isfinite(scores[i])] - - for _ in range(length): - for item in order: - if item in selected: - continue - label = None if labels is None else str(labels[item]) - next_counts = counts.copy() - if label is not None: - if next_counts.get(label, 0) >= max_counts.get(label, length): - continue - next_counts[label] = next_counts.get(label, 0) + 1 - if _constraints_feasible(order, selected + [item], excluded, labels, next_counts, length, min_counts, max_counts): - selected.append(item) - counts = next_counts - break - else: - raise ValueError("could not assemble a form that satisfies constraints") - - for label, minimum in min_counts.items(): - if counts.get(label, 0) < minimum: - # Unreachable: the per-slot feasibility look-ahead only admits a pick - # when every minimum can still be met, so no completed length-form can - # leave a minimum unsatisfied here. Kept as a defensive guard. - raise ValueError(f"minimum content constraint not met: {label}") # pragma: no cover + exclude_list: list[int] = [] + if exclude is not None: + exclude_list = [int(i) for i in np.asarray(exclude, dtype=np.int64).tolist()] + + from . import _core as core + + selected = core.assemble_test_form_greedy( + np.ascontiguousarray(scores, dtype=np.float64), + int(length), + None if labels is None else [str(x) for x in labels.tolist()], + min_counts, + max_counts, + exclude_list, + ) return np.asarray(selected, dtype=np.int64) @@ -180,38 +170,3 @@ def _person_params(params: MLSIRMParams, theta: np.ndarray | None, person_index: ) -def _constraints_feasible( - order: list[int], - selected: list[int], - excluded: set[int], - labels: np.ndarray | None, - counts: dict[str, int], - length: int, - min_counts: dict[str, int], - max_counts: dict[str, int], -) -> bool: - """Return whether the remaining slots can still satisfy the min-content constraints. - - A look-ahead feasibility check used during greedy form assembly: verifies - enough eligible items remain per content area to meet each minimum. - """ - slots_left = length - len(selected) - required_left = sum(max(0, minimum - counts.get(label, 0)) for label, minimum in min_counts.items()) - if required_left > slots_left: - return False - if labels is None: - return True - - blocked = set(selected) | excluded - for label, minimum in min_counts.items(): - needed = max(0, minimum - counts.get(label, 0)) - available = 0 - for item in order: - if item in blocked or str(labels[item]) != label: - continue - if counts.get(label, 0) + available >= max_counts.get(label, length): - break - available += 1 - if available < needed: - return False - return True diff --git a/tests/test_test_form_rust_ownership.py b/tests/test_test_form_rust_ownership.py new file mode 100644 index 000000000..ca98276b0 --- /dev/null +++ b/tests/test_test_form_rust_ownership.py @@ -0,0 +1,42 @@ +"""Ownership contracts for fixed-form maximum-information assembly.""" + +from __future__ import annotations + +import numpy as np + +import fast_mlsirm._core as core +from fast_mlsirm.test_design import assemble_test_form + + +def test_public_test_form_assembly_delegates_selection_to_rust(monkeypatch) -> None: + """Ordering, exclusion, and content-feasibility decisions come from Rust.""" + information = np.array([1.0, 4.0, 3.0, 2.0], dtype=np.float64) + content = np.array(["A", "A", "B", "B"], dtype=object) + exclude = np.array([3], dtype=np.int64) + information_before = information.copy() + content_before = content.copy() + exclude_before = exclude.copy() + calls: list[tuple[tuple[object, ...], dict[str, object]]] = [] + + def fake_assemble(*args: object, **kwargs: object) -> list[int]: + calls.append((args, kwargs)) + # A deliberately different valid form from the current Python greedy + # result proves public result ownership rather than mere helper reuse. + return [2, 0] + + monkeypatch.setattr(core, "assemble_test_form_greedy", fake_assemble, raising=False) + + selected = assemble_test_form( + information, + length=2, + content=content, + min_per_content={"B": 1}, + max_per_content={"A": 1}, + exclude=exclude, + ) + + assert len(calls) == 1 + assert np.array_equal(selected, np.array([2, 0], dtype=np.int64)) + assert np.array_equal(information, information_before) + assert np.array_equal(content, content_before) + assert np.array_equal(exclude, exclude_before) From d3d54b46402ccb978f297e17ab8a78c5e32c5f43 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 03:14:59 +0900 Subject: [PATCH 2/4] test(ata): fail first on reflected content labels --- tests/test_test_form_rust_ownership.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/test_test_form_rust_ownership.py b/tests/test_test_form_rust_ownership.py index ca98276b0..87fb570cb 100644 --- a/tests/test_test_form_rust_ownership.py +++ b/tests/test_test_form_rust_ownership.py @@ -3,6 +3,7 @@ from __future__ import annotations import numpy as np +import pytest import fast_mlsirm._core as core from fast_mlsirm.test_design import assemble_test_form @@ -40,3 +41,20 @@ def fake_assemble(*args: object, **kwargs: object) -> list[int]: assert np.array_equal(information, information_before) assert np.array_equal(content, content_before) assert np.array_equal(exclude, exclude_before) + + +def test_invalid_content_constraint_error_does_not_reflect_caller_label() -> None: + """Validation failures must not echo caller-controlled content labels.""" + sensitive_label = "customer_secret_content_category" + information = np.array([1.0], dtype=np.float64) + content = np.array([sensitive_label], dtype=object) + + with pytest.raises(ValueError) as exc_info: + assemble_test_form( + information, + length=1, + content=content, + min_per_content={sensitive_label: -1}, + ) + + assert sensitive_label not in str(exc_info.value) From db39af04fc85c77eec0b5143139699e3f4ba65e3 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 03:17:51 +0900 Subject: [PATCH 3/4] fix(ata): omit caller content labels from constraint errors Fail closed on non-negative content counts without reflecting caller-controlled label strings in ValueError messages. --- crates/mlsirm-core/src/test_form.rs | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/crates/mlsirm-core/src/test_form.rs b/crates/mlsirm-core/src/test_form.rs index e4484a596..6dd351ea9 100644 --- a/crates/mlsirm-core/src/test_form.rs +++ b/crates/mlsirm-core/src/test_form.rs @@ -44,21 +44,19 @@ pub fn assemble_test_form_greedy( } else if !min_per_content.is_empty() || !max_per_content.is_empty() { return Err("content labels are required for content constraints".into()); } - for (label, &minimum) in min_per_content { + for (_label, &minimum) in min_per_content { if minimum < 0 { - return Err(format!("minimum content constraint for {label} must be non-negative")); + return Err("content constraint counts must be non-negative".into()); } if let Some(&maximum) = max_per_content.get(label) { if minimum > maximum { - return Err(format!( - "minimum content constraint cannot exceed maximum for {label}" - )); + return Err("minimum content constraint cannot exceed maximum".into()); } } } - for (label, &maximum) in max_per_content { + for (_label, &maximum) in max_per_content { if maximum < 0 { - return Err(format!("maximum content constraint for {label} must be non-negative")); + return Err("content constraint counts must be non-negative".into()); } } @@ -134,7 +132,7 @@ pub fn assemble_test_form_greedy( let have = counts.get(label).copied().unwrap_or(0); if have < minimum { // Defensive: look-ahead should already enforce minima. - return Err(format!("minimum content constraint not met: {label}")); + return Err("minimum content constraint not met".into()); } } From e2909fd9623e57ba53a37a00fc641703d2ad937f Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Wed, 12 Aug 2026 03:18:21 +0900 Subject: [PATCH 4/4] fix(ata): restore content-label key lookup in constraint validation --- crates/mlsirm-core/src/test_form.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/mlsirm-core/src/test_form.rs b/crates/mlsirm-core/src/test_form.rs index 6dd351ea9..b3a0a8d3d 100644 --- a/crates/mlsirm-core/src/test_form.rs +++ b/crates/mlsirm-core/src/test_form.rs @@ -44,7 +44,7 @@ pub fn assemble_test_form_greedy( } else if !min_per_content.is_empty() || !max_per_content.is_empty() { return Err("content labels are required for content constraints".into()); } - for (_label, &minimum) in min_per_content { + for (label, &minimum) in min_per_content { if minimum < 0 { return Err("content constraint counts must be non-negative".into()); }