1use core::cmp::Ordering;
4
5use crate::{
6 AlgorithmControl, AlgorithmInterrupt, AlgorithmReceipt, FiniteCost, GraphError, NeverInterrupt,
7 control::WorkMeter,
8 cost::{add, compare, validate},
9};
10
11mod verify;
12
13pub use verify::verify_alignment;
14
15#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
17pub enum AlignmentWindow {
18 #[default]
20 Unbounded,
21 Radius(usize),
23}
24
25impl AlignmentWindow {
26 fn contains(self, left: usize, right: usize) -> bool {
27 match self {
28 Self::Unbounded => true,
29 Self::Radius(radius) => left.abs_diff(right) <= radius,
30 }
31 }
32}
33
34#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
36pub enum AlignmentBoundary {
37 #[default]
39 Global,
40 Subsequence,
43}
44
45#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
47pub enum AlignmentMemory {
48 #[default]
50 Full,
51 RollingScoreOnly,
53}
54
55#[derive(Clone, Debug, PartialEq, Eq)]
57pub struct GapPolicy<C> {
58 pub delete: C,
60 pub insert: C,
62}
63
64impl<C> GapPolicy<C> {
65 pub fn new(delete: C, insert: C) -> Self {
67 Self { delete, insert }
68 }
69}
70
71#[derive(Clone, Debug, PartialEq, Eq)]
73pub struct DtwPolicy<C> {
74 pub window: AlignmentWindow,
76 pub gaps: GapPolicy<C>,
78 pub boundary: AlignmentBoundary,
80 pub memory: AlignmentMemory,
82}
83
84impl<C> DtwPolicy<C> {
85 pub fn new(gaps: GapPolicy<C>) -> Self {
87 Self {
88 window: AlignmentWindow::Unbounded,
89 gaps,
90 boundary: AlignmentBoundary::Global,
91 memory: AlignmentMemory::Full,
92 }
93 }
94
95 pub fn with_window(mut self, window: AlignmentWindow) -> Self {
97 self.window = window;
98 self
99 }
100
101 pub fn with_boundary(mut self, boundary: AlignmentBoundary) -> Self {
103 self.boundary = boundary;
104 self
105 }
106
107 pub fn with_memory(mut self, memory: AlignmentMemory) -> Self {
109 self.memory = memory;
110 self
111 }
112}
113
114#[derive(Clone, Copy, Debug, PartialEq, Eq)]
116pub enum AlignmentMove {
117 Match,
119 Delete,
121 Insert,
123}
124
125#[derive(Clone, Debug, PartialEq, Eq)]
127pub struct AlignmentCell<C> {
128 pub total_cost: C,
130 pub predecessor: Option<AlignmentMove>,
132 pub step_cost: Option<C>,
134}
135
136#[derive(Clone, Debug, PartialEq, Eq)]
138pub enum AlignmentCertificate<C> {
139 Full {
141 cells: Vec<Vec<Option<AlignmentCell<C>>>>,
143 },
144 Rolling {
146 final_row: Vec<Option<C>>,
148 endpoint: usize,
150 },
151}
152
153#[derive(Clone, Debug, PartialEq, Eq)]
155pub enum AlignmentStep<C> {
156 Match {
158 left: usize,
160 right: usize,
162 cost: C,
164 },
165 Delete {
167 left: usize,
169 cost: C,
171 },
172 Insert {
174 right: usize,
176 cost: C,
178 },
179}
180
181#[derive(Clone, Debug, PartialEq, Eq)]
183pub struct Alignment<C> {
184 pub score: C,
186 pub steps: Option<Vec<AlignmentStep<C>>>,
188 pub certificate: AlignmentCertificate<C>,
190 pub receipt: AlgorithmReceipt,
192}
193
194pub fn dynamic_time_warp<T, C: FiniteCost>(
196 left: &[T],
197 right: &[T],
198 local_cost: impl Fn(&T, &T) -> C,
199 policy: DtwPolicy<C>,
200) -> Result<Alignment<C>, GraphError> {
201 dynamic_time_warp_with_control(
202 left,
203 right,
204 local_cost,
205 policy,
206 &AlgorithmControl::default(),
207 &NeverInterrupt,
208 )
209}
210
211pub fn dynamic_time_warp_with_control<T, C: FiniteCost>(
213 left: &[T],
214 right: &[T],
215 local_cost: impl Fn(&T, &T) -> C,
216 policy: DtwPolicy<C>,
217 control: &AlgorithmControl,
218 interrupt: &dyn AlgorithmInterrupt,
219) -> Result<Alignment<C>, GraphError> {
220 validate_policy(&policy)?;
221 let memory = peak_memory(left.len(), right.len(), policy.memory)?;
222 let mut meter = WorkMeter::new(control, interrupt, memory)?;
223 let computed = match policy.memory {
224 AlignmentMemory::Full => {
225 let (cells, stats) = full_table(left, right, &local_cost, &policy, Some(&mut meter))?;
226 let endpoint = select_endpoint(&cells, policy.boundary)?;
227 let score = cells[left.len()][endpoint]
228 .as_ref()
229 .expect("selected endpoint is reachable")
230 .total_cost
231 .clone();
232 let steps = reconstruct(&cells, left.len(), endpoint, policy.boundary)?;
233 Computed {
234 score,
235 steps: Some(steps),
236 certificate: AlignmentCertificate::Full { cells },
237 stats,
238 }
239 }
240 AlignmentMemory::RollingScoreOnly => {
241 let (final_row, stats) =
242 rolling_row(left, right, &local_cost, &policy, Some(&mut meter))?;
243 let endpoint = select_rolling_endpoint(&final_row, policy.boundary, right.len())?;
244 let score = final_row[endpoint]
245 .as_ref()
246 .expect("selected endpoint is reachable")
247 .clone();
248 Computed {
249 score,
250 steps: None,
251 certificate: AlignmentCertificate::Rolling {
252 final_row,
253 endpoint,
254 },
255 stats,
256 }
257 }
258 };
259 let receipt = meter.finish();
260 debug_assert_eq!(receipt.cells, computed.stats.cells);
261 debug_assert_eq!(receipt.edges, computed.stats.edges);
262 Ok(Alignment {
263 score: computed.score,
264 steps: computed.steps,
265 certificate: computed.certificate,
266 receipt,
267 })
268}
269
270struct Computed<C> {
271 score: C,
272 steps: Option<Vec<AlignmentStep<C>>>,
273 certificate: AlignmentCertificate<C>,
274 stats: Stats,
275}
276
277#[derive(Clone, Copy, Debug, Default)]
278struct Stats {
279 cells: u64,
280 edges: u64,
281}
282
283type Table<C> = Vec<Vec<Option<AlignmentCell<C>>>>;
284
285fn full_table<T, C: FiniteCost>(
286 left: &[T],
287 right: &[T],
288 local_cost: &impl Fn(&T, &T) -> C,
289 policy: &DtwPolicy<C>,
290 mut meter: Option<&mut WorkMeter<'_>>,
291) -> Result<(Table<C>, Stats), GraphError> {
292 let rows = left
293 .len()
294 .checked_add(1)
295 .ok_or_else(|| GraphError::WeightOverflow("alignment rows".to_owned()))?;
296 let columns = right
297 .len()
298 .checked_add(1)
299 .ok_or_else(|| GraphError::WeightOverflow("alignment columns".to_owned()))?;
300 let mut cells = vec![vec![None; columns]; rows];
301 let mut stats = Stats::default();
302 for i in 0..rows {
303 for j in 0..columns {
304 if !policy.window.contains(i, j) {
305 continue;
306 }
307 charge_cell(&mut meter, &mut stats)?;
308 cells[i][j] = compute_cell(
309 i,
310 j,
311 left,
312 right,
313 local_cost,
314 policy,
315 |row, column| cells[row][column].clone(),
316 &mut meter,
317 &mut stats,
318 )?;
319 }
320 }
321 Ok((cells, stats))
322}
323
324#[allow(clippy::too_many_arguments)]
325fn compute_cell<C: FiniteCost, T>(
326 i: usize,
327 j: usize,
328 left: &[T],
329 right: &[T],
330 local_cost: &impl Fn(&T, &T) -> C,
331 policy: &DtwPolicy<C>,
332 lookup: impl Fn(usize, usize) -> Option<AlignmentCell<C>>,
333 meter: &mut Option<&mut WorkMeter<'_>>,
334 stats: &mut Stats,
335) -> Result<Option<AlignmentCell<C>>, GraphError> {
336 if i == 0 && (j == 0 || policy.boundary == AlignmentBoundary::Subsequence) {
337 return Ok(Some(AlignmentCell {
338 total_cost: C::zero(),
339 predecessor: None,
340 step_cost: None,
341 }));
342 }
343 let mut best: Option<(C, AlignmentMove, C)> = None;
344 if i > 0 && j > 0 {
345 charge_edge(meter, stats)?;
346 if let Some(previous) = lookup(i - 1, j - 1) {
347 let cost = local_cost(&left[i - 1], &right[j - 1]);
348 validate_non_negative(&cost, "alignment local cost")?;
349 let total = add(&previous.total_cost, &cost, "alignment match")?;
350 choose(&mut best, total, AlignmentMove::Match, cost)?;
351 }
352 }
353 if i > 0 {
354 charge_edge(meter, stats)?;
355 if let Some(previous) = lookup(i - 1, j) {
356 let total = add(
357 &previous.total_cost,
358 &policy.gaps.delete,
359 "alignment deletion",
360 )?;
361 choose(
362 &mut best,
363 total,
364 AlignmentMove::Delete,
365 policy.gaps.delete.clone(),
366 )?;
367 }
368 }
369 if j > 0 {
370 charge_edge(meter, stats)?;
371 if let Some(previous) = lookup(i, j - 1) {
372 let total = add(
373 &previous.total_cost,
374 &policy.gaps.insert,
375 "alignment insertion",
376 )?;
377 choose(
378 &mut best,
379 total,
380 AlignmentMove::Insert,
381 policy.gaps.insert.clone(),
382 )?;
383 }
384 }
385 Ok(
386 best.map(|(total_cost, predecessor, step_cost)| AlignmentCell {
387 total_cost,
388 predecessor: Some(predecessor),
389 step_cost: Some(step_cost),
390 }),
391 )
392}
393
394fn rolling_row<T, C: FiniteCost>(
395 left: &[T],
396 right: &[T],
397 local_cost: &impl Fn(&T, &T) -> C,
398 policy: &DtwPolicy<C>,
399 mut meter: Option<&mut WorkMeter<'_>>,
400) -> Result<(Vec<Option<C>>, Stats), GraphError> {
401 let columns = right
402 .len()
403 .checked_add(1)
404 .ok_or_else(|| GraphError::WeightOverflow("alignment columns".to_owned()))?;
405 let mut previous = vec![None; columns];
406 let mut stats = Stats::default();
407 for i in 0..=left.len() {
408 let mut current = vec![None; columns];
409 for j in 0..columns {
410 if !policy.window.contains(i, j) {
411 continue;
412 }
413 charge_cell(&mut meter, &mut stats)?;
414 if i == 0 && (j == 0 || policy.boundary == AlignmentBoundary::Subsequence) {
415 current[j] = Some(C::zero());
416 continue;
417 }
418 let mut best: Option<C> = None;
419 if i > 0 && j > 0 {
420 charge_edge(&mut meter, &mut stats)?;
421 if let Some(prior) = &previous[j - 1] {
422 let cost = local_cost(&left[i - 1], &right[j - 1]);
423 validate_non_negative(&cost, "alignment local cost")?;
424 choose_score(&mut best, add(prior, &cost, "alignment match")?)?;
425 }
426 }
427 if i > 0 {
428 charge_edge(&mut meter, &mut stats)?;
429 if let Some(prior) = &previous[j] {
430 choose_score(
431 &mut best,
432 add(prior, &policy.gaps.delete, "alignment deletion")?,
433 )?;
434 }
435 }
436 if j > 0 {
437 charge_edge(&mut meter, &mut stats)?;
438 if let Some(prior) = ¤t[j - 1] {
439 choose_score(
440 &mut best,
441 add(prior, &policy.gaps.insert, "alignment insertion")?,
442 )?;
443 }
444 }
445 current[j] = best;
446 }
447 previous = current;
448 }
449 Ok((previous, stats))
450}
451
452fn choose<C: FiniteCost>(
453 best: &mut Option<(C, AlignmentMove, C)>,
454 total: C,
455 movement: AlignmentMove,
456 step_cost: C,
457) -> Result<(), GraphError> {
458 let replace = match best {
459 Some((current, _, _)) => {
460 compare(&total, current, "alignment candidate ordering")? == Ordering::Less
461 }
462 None => true,
463 };
464 if replace {
465 *best = Some((total, movement, step_cost));
466 }
467 Ok(())
468}
469
470fn choose_score<C: FiniteCost>(best: &mut Option<C>, total: C) -> Result<(), GraphError> {
471 let replace = match best {
472 Some(current) => {
473 compare(&total, current, "alignment candidate ordering")? == Ordering::Less
474 }
475 None => true,
476 };
477 if replace {
478 *best = Some(total);
479 }
480 Ok(())
481}
482
483fn select_endpoint<C: FiniteCost>(
484 cells: &Table<C>,
485 boundary: AlignmentBoundary,
486) -> Result<usize, GraphError> {
487 let final_row = cells.last().expect("alignment table has a prefix row");
488 select_cell_endpoint(final_row, boundary)
489}
490
491fn select_cell_endpoint<C: FiniteCost>(
492 row: &[Option<AlignmentCell<C>>],
493 boundary: AlignmentBoundary,
494) -> Result<usize, GraphError> {
495 match boundary {
496 AlignmentBoundary::Global => row
497 .len()
498 .checked_sub(1)
499 .filter(|endpoint| row[*endpoint].is_some())
500 .ok_or(GraphError::Disconnected),
501 AlignmentBoundary::Subsequence => {
502 let mut best: Option<(usize, &C)> = None;
503 for (index, cell) in row.iter().enumerate() {
504 let Some(cell) = cell else {
505 continue;
506 };
507 let replace = match best {
508 Some((_, cost)) => {
509 compare(&cell.total_cost, cost, "alignment endpoint ordering")?
510 == Ordering::Less
511 }
512 None => true,
513 };
514 if replace {
515 best = Some((index, &cell.total_cost));
516 }
517 }
518 best.map(|(index, _)| index).ok_or(GraphError::Disconnected)
519 }
520 }
521}
522
523fn select_rolling_endpoint<C: FiniteCost>(
524 row: &[Option<C>],
525 boundary: AlignmentBoundary,
526 right_len: usize,
527) -> Result<usize, GraphError> {
528 match boundary {
529 AlignmentBoundary::Global => row
530 .get(right_len)
531 .and_then(Option::as_ref)
532 .map(|_| right_len)
533 .ok_or(GraphError::Disconnected),
534 AlignmentBoundary::Subsequence => {
535 let mut best: Option<(usize, &C)> = None;
536 for (index, cost) in row.iter().enumerate() {
537 let Some(cost) = cost else {
538 continue;
539 };
540 let replace = match best {
541 Some((_, current)) => {
542 compare(cost, current, "alignment endpoint ordering")? == Ordering::Less
543 }
544 None => true,
545 };
546 if replace {
547 best = Some((index, cost));
548 }
549 }
550 best.map(|(index, _)| index).ok_or(GraphError::Disconnected)
551 }
552 }
553}
554
555fn reconstruct<C: FiniteCost>(
556 cells: &Table<C>,
557 mut i: usize,
558 mut j: usize,
559 boundary: AlignmentBoundary,
560) -> Result<Vec<AlignmentStep<C>>, GraphError> {
561 let mut reversed = Vec::new();
562 loop {
563 let cell = cells[i][j].as_ref().ok_or_else(|| {
564 GraphError::CertificateInvalid("alignment path visits an unreachable cell".to_owned())
565 })?;
566 let Some(movement) = cell.predecessor else {
567 if i == 0 && (j == 0 || boundary == AlignmentBoundary::Subsequence) {
568 break;
569 }
570 return Err(GraphError::CertificateInvalid(
571 "alignment path ends at an illegal free boundary".to_owned(),
572 ));
573 };
574 let cost = cell.step_cost.clone().ok_or_else(|| {
575 GraphError::CertificateInvalid("alignment step has no cost".to_owned())
576 })?;
577 match movement {
578 AlignmentMove::Match => {
579 i -= 1;
580 j -= 1;
581 reversed.push(AlignmentStep::Match {
582 left: i,
583 right: j,
584 cost,
585 });
586 }
587 AlignmentMove::Delete => {
588 i -= 1;
589 reversed.push(AlignmentStep::Delete { left: i, cost });
590 }
591 AlignmentMove::Insert => {
592 j -= 1;
593 reversed.push(AlignmentStep::Insert { right: j, cost });
594 }
595 }
596 }
597 reversed.reverse();
598 Ok(reversed)
599}
600
601fn validate_policy<C: FiniteCost>(policy: &DtwPolicy<C>) -> Result<(), GraphError> {
602 validate_non_negative(&policy.gaps.delete, "alignment deletion gap")?;
603 validate_non_negative(&policy.gaps.insert, "alignment insertion gap")
604}
605
606fn validate_non_negative<C: FiniteCost>(cost: &C, context: &str) -> Result<(), GraphError> {
607 validate(cost, context)?;
608 if compare(cost, &C::zero(), context)? == Ordering::Less {
609 return Err(GraphError::Unsupported(format!(
610 "{context} must be non-negative"
611 )));
612 }
613 Ok(())
614}
615
616fn peak_memory(
617 left_len: usize,
618 right_len: usize,
619 memory: AlignmentMemory,
620) -> Result<usize, GraphError> {
621 let columns = right_len
622 .checked_add(1)
623 .ok_or_else(|| GraphError::WeightOverflow("alignment columns".to_owned()))?;
624 match memory {
625 AlignmentMemory::Full => left_len
626 .checked_add(1)
627 .and_then(|rows| rows.checked_mul(columns))
628 .ok_or_else(|| GraphError::WeightOverflow("alignment table cells".to_owned())),
629 AlignmentMemory::RollingScoreOnly => {
630 let rows: usize = if left_len == 0 { 1 } else { 2 };
631 rows.checked_mul(columns)
632 .ok_or_else(|| GraphError::WeightOverflow("alignment rolling rows".to_owned()))
633 }
634 }
635}
636
637fn charge_cell(
638 meter: &mut Option<&mut WorkMeter<'_>>,
639 stats: &mut Stats,
640) -> Result<(), GraphError> {
641 if let Some(meter) = meter.as_deref_mut() {
642 meter.cell()?;
643 }
644 stats.cells = stats
645 .cells
646 .checked_add(1)
647 .ok_or_else(|| GraphError::WeightOverflow("alignment cell count".to_owned()))?;
648 Ok(())
649}
650
651fn charge_edge(
652 meter: &mut Option<&mut WorkMeter<'_>>,
653 stats: &mut Stats,
654) -> Result<(), GraphError> {
655 if let Some(meter) = meter.as_deref_mut() {
656 meter.edge()?;
657 }
658 stats.edges = stats
659 .edges
660 .checked_add(1)
661 .ok_or_else(|| GraphError::WeightOverflow("alignment edge count".to_owned()))?;
662 Ok(())
663}
664
665#[cfg(test)]
666mod tests;