Skip to content

Commit 7073d6e

Browse files
authored
feat(cosinepair): add lazy constructor that skips init() (#459)
Add CosinePair::lazy(m, top_k) that precomputes row_norms but skips the Theta(n^2) init() scan. query_row_top_k and query work unchanged since they recompute distances on the fly. Methods depending on distances/neighbours (closest_pair, ordered_pairs, query_row, distances_from) will panic on a lazy instance. Closes #458
1 parent ea178d2 commit 7073d6e

1 file changed

Lines changed: 69 additions & 0 deletions

File tree

src/algorithm/neighbour/cosinepair.rs

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,35 @@ impl<'a, T: RealNumber + FloatNumber + FloatCore, M: Array2<T>> CosinePair<'a, T
109109
)
110110
}
111111

112+
/// Lazy constructor that skips the Theta(n^2) [`init`] scan.
113+
///
114+
/// [`query_row_top_k`] and [`query`] work unchanged because they
115+
/// recompute distances on the fly from `samples`, `row_norms`, and
116+
/// `parameters`. Methods that depend on the precomputed `distances` /
117+
/// `neighbours` fields (`closest_pair`, `ordered_pairs`, `query_row`,
118+
/// `distances_from`) will panic on a lazy instance.
119+
pub fn lazy(m: &'a M, top_k: usize) -> Result<Self, Failed> {
120+
if m.shape().0 < 2 {
121+
return Err(Failed::because(
122+
FailedError::FindFailed,
123+
"min number of rows should be 2",
124+
));
125+
}
126+
127+
let row_norms = (0..m.shape().0).map(|i| m.get_row(i).norm2()).collect();
128+
129+
Ok(Self {
130+
samples: m,
131+
distances: HashMap::new(),
132+
neighbours: Vec::new(),
133+
row_norms,
134+
parameters: CosinePairParameters {
135+
top_k: Some(top_k),
136+
approximate: false,
137+
},
138+
})
139+
}
140+
112141
/// Constructor with full parameter control
113142
pub fn with_parameters(m: &'a M, parameters: CosinePairParameters) -> Result<Self, Failed> {
114143
if m.shape().0 < 2 {
@@ -1260,6 +1289,46 @@ mod tests {
12601289
}
12611290
}
12621291

1292+
#[test]
1293+
fn lazy_skips_init_and_query_row_top_k_matches_non_lazy() {
1294+
let x = mixed_direction_rows();
1295+
let top_k = 4;
1296+
1297+
let lazy = CosinePair::lazy(&x, top_k).unwrap();
1298+
assert!(
1299+
lazy.distances.is_empty(),
1300+
"lazy instance must have empty distances"
1301+
);
1302+
assert!(
1303+
lazy.neighbours.is_empty(),
1304+
"lazy instance must have empty neighbours"
1305+
);
1306+
1307+
let eager = CosinePair::with_top_k(&x, top_k).unwrap();
1308+
1309+
for row in 0..x.shape().0 {
1310+
let lazy_result = lazy.query_row_top_k(row, 3).unwrap();
1311+
let eager_result = eager.query_row_top_k(row, 3).unwrap();
1312+
assert_eq!(lazy_result.len(), eager_result.len(), "row {}", row);
1313+
for (got, want) in lazy_result.iter().zip(eager_result.iter()) {
1314+
assert_eq!(got.1, want.1, "row {}", row);
1315+
assert!(
1316+
(got.0 - want.0).abs() < 1e-12,
1317+
"row {}: distance mismatch {} vs {}",
1318+
row,
1319+
got.0,
1320+
want.0
1321+
);
1322+
}
1323+
}
1324+
}
1325+
1326+
#[test]
1327+
fn lazy_rejects_single_row() {
1328+
let x = DenseMatrix::<f64>::from_2d_array(&[&[1.0, 2.0]]).unwrap();
1329+
assert!(CosinePair::lazy(&x, 1).is_err());
1330+
}
1331+
12631332
#[test]
12641333
fn query_row_top_k_samples_strided_candidates_when_approximate_is_true() {
12651334
let x = mixed_direction_rows();

0 commit comments

Comments
 (0)