kornia-slam 0.1.0

Real-time visual-inertial SLAM in Rust, built on kornia-rs.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
//! Map growth: proposing new landmarks and observations from geometry.
//!
//! These functions read only. Each returns a request that the caller publishes
//! through the map's canonical mutation API, so a rejected batch leaves nothing
//! partially inserted and the geometry here never has to know how storage works.
//!
//! The map does not need to know whether a position came from stereo depth or
//! from pair triangulation — both paths build the same request.

use crate::frame::Frame;
use crate::mapping::map::{
    LandmarkSeed, LandmarkTarget, Map, MapInsertion, ORB_SCALE_FACTOR, ObservationKey,
    ObservationLink,
};
use crate::stereo::unproject_stereo;
use kornia_3d::camera::PinholeCamera;
use kornia_3d::pose::{Pose3d, TriangulationConfig, triangulate_matched_points};
use kornia_algebra::{Mat3F64, Vec2F64, Vec3F64};
use kornia_imgproc::features::{OrbMatchConfig, hamming_distance, match_orb_descriptors};
use std::collections::HashSet;

/// Minimum epipolar-consistent matches before a pair is worth triangulating.
const MIN_GROWTH_MATCHES: usize = 15;
/// 1-DOF chi-square gate at 95% for point-to-epipolar-line distance
/// (ORB-SLAM3's `CheckDistEpipolarLine`), scaled per octave.
const EPIPOLAR_CHI2: f64 = 3.84;

/// Seeds for `frame`'s unassociated close stereo keypoints (`z < mthdepth`).
///
/// Far points are left to multi-view triangulation. `claimed` lists the feature
/// slots already taken — tracked matches for a keyframe about to be published,
/// or the stored associations of one already in the map — so a seed can never
/// contend for a feature an earlier pass took.
pub fn stereo_seeds(
    frame: &Frame,
    camera: &PinholeCamera,
    mthdepth: f64,
    claimed: &[Option<usize>],
) -> Vec<LandmarkSeed> {
    let cam_points = unproject_stereo(frame, camera);
    if cam_points.is_empty() {
        return Vec::new();
    }
    let pose_inv = frame.pose_world_to_cam.inverse();

    let mut seeds = Vec::new();
    for (desc_idx, p_cam) in &cam_points {
        if p_cam.z > mthdepth {
            continue;
        }
        if claimed.get(*desc_idx).copied().flatten().is_some() {
            continue;
        }
        seeds.push(LandmarkSeed {
            position: pose_inv.transform_point(p_cam),
            color: frame
                .keypoint_colors
                .get(*desc_idx)
                .copied()
                .unwrap_or([128; 3]),
            reference: ObservationKey {
                keyframe_idx: frame.idx,
                feature_idx: *desc_idx,
            },
        });
    }
    seeds
}

/// Triangulates unassociated features shared by two stored keyframes.
///
/// Both keyframes must already be in the map. Each new landmark is referenced
/// to its `curr_kf_idx` feature and carries an explicit second observation on
/// `prev_kf_idx` — without that second observer the point would bias its own
/// scale and normal geometry and the cull would be over-aggressive.
pub fn pair_growth_request(
    map: &Map,
    prev_kf_idx: usize,
    curr_kf_idx: usize,
    match_config: OrbMatchConfig,
    triangulation_config: &TriangulationConfig,
    camera: &PinholeCamera,
) -> Option<MapInsertion> {
    let prev_kf = map.get_keyframe(prev_kf_idx)?;
    let curr_kf = map.get_keyframe(curr_kf_idx)?;

    // Only features without a landmark in either keyframe. Matching the full
    // arrays and filtering afterwards discards almost everything once the
    // keyframes are mature.
    let prev_unassoc: Vec<usize> = (0..prev_kf.frame.features.descriptors.len())
        .filter(|&i| prev_kf.map_point(i).is_none())
        .collect();
    let curr_unassoc: Vec<usize> = (0..curr_kf.frame.features.descriptors.len())
        .filter(|&i| curr_kf.map_point(i).is_none())
        .collect();
    if prev_unassoc.is_empty() || curr_unassoc.is_empty() {
        return None;
    }

    // Both poses are known, so the fundamental matrix is fully determined:
    // F = K^-T [t]x R K^-1 with (R, t) the prev->curr relative pose. Filtering
    // against it replaces the two-view estimator's F-matrix RANSAC (mirrors
    // ORB-SLAM3's SearchForTriangulation).
    let rel = Pose3d::between(
        &prev_kf.frame.pose_world_to_cam,
        &curr_kf.frame.pose_world_to_cam,
    );
    if rel.translation.length() <= 1e-8 {
        // No baseline: epipolar geometry degenerates and triangulation would
        // reject everything anyway.
        return None;
    }
    let t = rel.translation;
    let t_skew = Mat3F64::from_cols(
        Vec3F64::new(0.0, t.z, -t.y),
        Vec3F64::new(-t.z, 0.0, t.x),
        Vec3F64::new(t.y, -t.x, 0.0),
    );
    let k_inv = Mat3F64::from_cols(
        Vec3F64::new(1.0 / camera.fx, 0.0, 0.0),
        Vec3F64::new(0.0, 1.0 / camera.fy, 0.0),
        Vec3F64::new(-camera.cx / camera.fx, -camera.cy / camera.fy, 1.0),
    );
    let f_mat = k_inv.transpose() * (t_skew * rel.rotation) * k_inv;

    // Epipole of the prev camera in the curr image. Near it every keypoint is
    // close to every epipolar line, so the chi-square gate is uninformative
    // there: wrong matches survive and triangulate to depth-garbage points that
    // still reproject well in both views. Mirrors ORB-SLAM3's epipole-proximity
    // rejection; RANSAC consensus used to absorb these.
    let prev_center_world = prev_kf.frame.pose_world_to_cam.inverse().translation;
    let epipole_cam = curr_kf
        .frame
        .pose_world_to_cam
        .transform_point(&prev_center_world);
    let epipole_px = (epipole_cam.z.abs() > 1e-9).then(|| {
        Vec2F64::new(
            camera.fx * epipole_cam.x / epipole_cam.z + camera.cx,
            camera.fy * epipole_cam.y / epipole_cam.z + camera.cy,
        )
    });

    // Brute-force descriptor matching over the unassociated subsets; the global
    // second-best ratio test and orientation consistency live in the matcher and
    // are essential for match quality.
    let sub_matches = match_orb_descriptors(
        &prev_unassoc
            .iter()
            .map(|&i| prev_kf.frame.features.orientations[i])
            .collect::<Vec<_>>(),
        &prev_unassoc
            .iter()
            .map(|&i| prev_kf.frame.features.descriptors[i])
            .collect::<Vec<_>>(),
        &curr_unassoc
            .iter()
            .map(|&i| curr_kf.frame.features.orientations[i])
            .collect::<Vec<_>>(),
        &curr_unassoc
            .iter()
            .map(|&i| curr_kf.frame.features.descriptors[i])
            .collect::<Vec<_>>(),
        match_config,
    );

    let mut pair_indices: Vec<(usize, usize)> = Vec::new();
    let mut matched_prev: Vec<Vec2F64> = Vec::new();
    let mut matched_curr: Vec<Vec2F64> = Vec::new();
    for (prev_sub, curr_sub) in sub_matches {
        let (Some(&prev_idx), Some(&curr_idx)) =
            (prev_unassoc.get(prev_sub), curr_unassoc.get(curr_sub))
        else {
            continue;
        };
        let (Some(pu), Some(qu)) = (
            prev_kf.frame.undistorted_xy(prev_idx, camera),
            curr_kf.frame.undistorted_xy(curr_idx, camera),
        ) else {
            continue;
        };
        let p = Vec2F64::new(pu[0] as f64, pu[1] as f64);
        let q = Vec2F64::new(qu[0] as f64, qu[1] as f64);
        let octave = curr_kf
            .frame
            .features
            .octaves
            .get(curr_idx)
            .copied()
            .unwrap_or(0);

        // Reject curr keypoints near the epipole; radius grows with octave
        // (ORB-SLAM3 uses 100 * scaleFactor^octave px^2).
        if let Some(e) = epipole_px {
            let dx = q.x - e.x;
            let dy = q.y - e.y;
            if dx * dx + dy * dy < 100.0 * ORB_SCALE_FACTOR.powi(octave as i32) {
                continue;
            }
        }

        let l = f_mat * Vec3F64::new(p.x, p.y, 1.0);
        let line_norm_sq = l.x * l.x + l.y * l.y;
        if line_norm_sq <= 1e-12 {
            continue;
        }
        let d = l.x * q.x + l.y * q.y + l.z;
        let sigma_sq = ORB_SCALE_FACTOR.powi(2 * octave as i32);
        if d * d > EPIPOLAR_CHI2 * sigma_sq * line_norm_sq {
            continue;
        }

        pair_indices.push((prev_idx, curr_idx));
        matched_prev.push(p);
        matched_curr.push(q);
    }
    if pair_indices.len() < MIN_GROWTH_MATCHES {
        return None;
    }

    let triangulated = triangulate_matched_points(
        &matched_prev,
        &matched_curr,
        &prev_kf.frame.pose_world_to_cam,
        &curr_kf.frame.pose_world_to_cam,
        camera,
        triangulation_config,
    )
    .ok()?;

    let mut request = MapInsertion::default();
    for tp in &triangulated {
        let Some(&(prev_idx, curr_idx)) = pair_indices.get(tp.pair_index) else {
            continue;
        };
        // A landmark may only claim a free feature in each keyframe, and a
        // triangulated pair could name one twice; resolve here rather than
        // letting the batch fail as a whole.
        if curr_kf.map_point(curr_idx).is_some() || prev_kf.map_point(prev_idx).is_some() {
            continue;
        }
        if request
            .landmarks
            .iter()
            .any(|s| s.reference.feature_idx == curr_idx)
            || request
                .observations
                .iter()
                .any(|o| o.observation.feature_idx == prev_idx)
        {
            continue;
        }
        let new_index = request.landmarks.len();
        request.landmarks.push(LandmarkSeed {
            position: tp.position,
            color: curr_kf
                .frame
                .keypoint_colors
                .get(curr_idx)
                .copied()
                .unwrap_or([128; 3]),
            reference: ObservationKey {
                keyframe_idx: curr_kf_idx,
                feature_idx: curr_idx,
            },
        });
        request.observations.push(ObservationLink {
            observation: ObservationKey {
                keyframe_idx: prev_kf_idx,
                feature_idx: prev_idx,
            },
            landmark: LandmarkTarget::New(new_index),
        });
    }
    (!request.landmarks.is_empty()).then_some(request)
}

/// Search radius, in pixels, for a landmark's projection into a neighbour.
const FUSE_SEARCH_RADIUS_PX: f32 = 7.0;
/// Descriptor distance above which a projected landmark is not the keypoint.
const FUSE_MAX_HAMMING: u32 = 50;

/// Links the current keyframe's landmarks into neighbours that do not yet
/// observe them — the forward half of ORB-SLAM3's `SearchInNeighbors`.
///
/// Read-only: proposals are resolved deterministically here (a keypoint wanted
/// by two landmarks goes to the smaller Hamming distance) and the caller
/// publishes them. Duplicate merging and the second-hop covisible expansion are
/// still not implemented.
pub fn neighbor_fusion_links(
    map: &Map,
    curr_kf_idx: usize,
    neighbor_kf_indices: &[usize],
    camera: &PinholeCamera,
) -> Vec<ObservationLink> {
    let Some(curr_kf) = map.get_keyframe(curr_kf_idx) else {
        return Vec::new();
    };
    let curr_mp_indices: Vec<usize> = curr_kf
        .map_point_by_desc_idx
        .iter()
        .filter_map(|&mp| mp)
        .collect();
    if curr_mp_indices.is_empty() {
        return Vec::new();
    }

    let r2 = FUSE_SEARCH_RADIUS_PX * FUSE_SEARCH_RADIUS_PX;
    let mut links = Vec::new();

    for &nb_kf_idx in neighbor_kf_indices {
        if nb_kf_idx == curr_kf_idx {
            continue;
        }
        let Some(nb_kf) = map.get_keyframe(nb_kf_idx) else {
            continue;
        };

        // (keypoint in neighbour, landmark, hamming)
        let mut proposals: Vec<(usize, usize, u32)> = Vec::new();
        for &mp_idx in &curr_mp_indices {
            let mp = match map.map_points().get(mp_idx) {
                Some(mp) if !mp.culled => mp,
                _ => continue,
            };
            if mp.is_observed_by(nb_kf_idx) {
                continue;
            }
            let p_cam = nb_kf.frame.pose_world_to_cam.transform_point(&mp.position);
            if p_cam.z <= 0.0 {
                continue;
            }
            let Ok(pixel) = camera.project_to_image(&p_cam, 0.0, nb_kf.frame.image_size) else {
                continue;
            };
            let (u, v) = (pixel.x as f32, pixel.y as f32);

            let mut best_dist = u32::MAX;
            let mut best_kp = usize::MAX;
            for kp_idx in 0..nb_kf.frame.features.keypoints_xy.len() {
                if nb_kf.map_point(kp_idx).is_some() {
                    continue;
                }
                let Some(kp) = nb_kf.frame.undistorted_xy(kp_idx, camera) else {
                    continue;
                };
                let (dx, dy) = (kp[0] - u, kp[1] - v);
                if dx * dx + dy * dy > r2 {
                    continue;
                }
                let dist =
                    hamming_distance(&mp.descriptor, &nb_kf.frame.features.descriptors[kp_idx]);
                if dist < best_dist {
                    best_dist = dist;
                    best_kp = kp_idx;
                }
            }
            if best_dist <= FUSE_MAX_HAMMING && best_kp != usize::MAX {
                proposals.push((best_kp, mp_idx, best_dist));
            }
        }

        // Closest descriptor wins a contested keypoint; one claim per keypoint
        // and one per landmark, since a landmark may hold only one feature in
        // any keyframe.
        proposals.sort_by_key(|&(_, _, dist)| dist);
        let mut taken_kp: HashSet<usize> = HashSet::new();
        let mut taken_mp: HashSet<usize> = HashSet::new();
        for (kp_idx, mp_idx, _) in proposals {
            if !taken_kp.insert(kp_idx) || !taken_mp.insert(mp_idx) {
                continue;
            }
            links.push(ObservationLink {
                observation: ObservationKey {
                    keyframe_idx: nb_kf_idx,
                    feature_idx: kp_idx,
                },
                landmark: LandmarkTarget::Existing(mp_idx),
            });
        }
    }
    links
}

/// Which of `candidates`, given as `(reference feature, current feature)`, can
/// be published together.
///
/// A feature may hold at most one landmark in each view, so a candidate naming
/// a feature an earlier one took is dropped. Returns the accepted indices in
/// input order — first claim wins, matching the order the triangulator emits.
///
/// A rejected candidate must not reserve anything: reserving its *other*
/// feature would knock out a later candidate that was perfectly publishable.
pub fn accepted_pair_claims(candidates: &[(usize, usize)]) -> Vec<usize> {
    let mut claimed_reference: HashSet<usize> = HashSet::new();
    let mut claimed_current: HashSet<usize> = HashSet::new();
    let mut accepted = Vec::new();
    for (index, &(reference_feature, current_feature)) in candidates.iter().enumerate() {
        if claimed_reference.contains(&reference_feature)
            || claimed_current.contains(&current_feature)
        {
            continue;
        }
        claimed_reference.insert(reference_feature);
        claimed_current.insert(current_feature);
        accepted.push(index);
    }
    accepted
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::mapping::map::Keyframe;
    use kornia_image::ImageSize;
    use kornia_imgproc::features::OrbFeatures;

    fn camera() -> PinholeCamera {
        PinholeCamera {
            fx: 400.0,
            fy: 400.0,
            cx: 320.0,
            cy: 240.0,
            k1: 0.0,
            k2: 0.0,
            p1: 0.0,
            p2: 0.0,
        }
    }

    fn test_frame(idx: usize, descriptors: Vec<[u8; 32]>) -> Frame {
        let n = descriptors.len();
        Frame {
            idx,
            features: OrbFeatures {
                keypoints_xy: (0..n).map(|i| [i as f32, i as f32]).collect(),
                orientations: vec![0.0; n],
                descriptors,
                octaves: vec![0; n],
            },
            pose_world_to_cam: Pose3d::IDENTITY,
            image_size: ImageSize {
                width: 640,
                height: 480,
            },
            keypoint_colors: vec![[0; 3]; n],
            u_right: Vec::new(),
            depth: Vec::new(),
            keypoints_undist: Vec::new(),
        }
    }

    #[test]
    fn stereo_seeds_filter_far_and_already_claimed_features() {
        let mut frame = test_frame(10, vec![[0; 32]; 4]);
        frame.features.keypoints_xy = vec![[320.0, 240.0]; 4];
        frame.depth = vec![2.0, 3.0, 20.0, -1.0];
        frame.pose_world_to_cam.translation = Vec3F64::new(-1.0, 0.0, 0.0);

        // Feature 0 is already taken by a tracked landmark; 2 is beyond
        // mthdepth; 3 has invalid depth.
        let claimed = vec![Some(7), None, None, None];
        let seeds = stereo_seeds(&frame, &camera(), 5.0, &claimed);

        assert_eq!(seeds.len(), 1);
        assert_eq!(seeds[0].reference.feature_idx, 1);
        assert_eq!(seeds[0].reference.keyframe_idx, 10);
    }

    #[test]
    fn proposal_generation_leaves_the_map_untouched() {
        let mut map = Map::new();
        map.insert_keyframe(Keyframe::from_frame(test_frame(10, vec![[0; 32]; 4])))
            .unwrap();
        let before_points = map.num_map_points();
        let before_kfs = map.keyframes().len();

        let _ = pair_growth_request(
            &map,
            10,
            10,
            OrbMatchConfig::default(),
            &TriangulationConfig::default(),
            &camera(),
        );
        let _ = neighbor_fusion_links(&map, 10, &[10], &camera());

        assert_eq!(map.num_map_points(), before_points);
        assert_eq!(map.keyframes().len(), before_kfs);
    }

    /// A pair request names both observers, so publishing it gives each new
    /// landmark two observations; repeating it proposes nothing, because every
    /// feature is now claimed.
    #[test]
    fn pair_growth_yields_two_observers_and_does_not_duplicate() {
        let camera = camera();
        let descriptors: Vec<[u8; 32]> = (0..20).map(|i| [i as u8; 32]).collect();
        let mut previous = test_frame(10, descriptors.clone());
        let mut current = test_frame(20, descriptors);
        current.pose_world_to_cam.translation = Vec3F64::new(-1.0, 0.0, 0.0);
        let points: Vec<Vec3F64> = (0..20)
            .map(|i| Vec3F64::new((i % 5) as f64 * 0.3 - 0.6, (i / 5) as f64 * 0.3 - 0.45, 5.0))
            .collect();
        for frame in [&mut previous, &mut current] {
            frame.features.keypoints_xy = points
                .iter()
                .map(|p| {
                    let p = frame.pose_world_to_cam.transform_point(p);
                    [
                        (camera.fx * p.x / p.z + camera.cx) as f32,
                        (camera.fy * p.y / p.z + camera.cy) as f32,
                    ]
                })
                .collect();
        }

        let mut map = Map::new();
        map.insert_keyframe(Keyframe::from_frame(previous)).unwrap();
        map.insert_keyframe(Keyframe::from_frame(current)).unwrap();

        let request = pair_growth_request(
            &map,
            10,
            20,
            OrbMatchConfig::default(),
            &TriangulationConfig::default(),
            &camera,
        )
        .expect("a well-conditioned pair should propose landmarks");
        assert_eq!(request.landmarks.len(), points.len());
        let result = map.apply_insertion(request).expect("valid batch");

        for (i, expected) in points.iter().enumerate() {
            let mp = map.get_keyframe(20).unwrap().map_point(i).unwrap();
            assert_eq!(map.get_keyframe(10).unwrap().map_point(i), Some(mp));
            let point = &map.map_points()[mp];
            assert!((point.position - *expected).length() < 1e-4);
            assert_eq!(point.observations().len(), 2);
            assert!(point.is_observed_by(10));
            assert!(point.is_observed_by(20));
        }
        assert_eq!(result.landmark_ids.len(), points.len());

        assert!(
            pair_growth_request(
                &map,
                10,
                20,
                OrbMatchConfig::default(),
                &TriangulationConfig::default(),
                &camera,
            )
            .is_none(),
            "every feature is claimed, so nothing is left to propose"
        );
    }

    /// Two landmarks projecting onto one neighbour keypoint: the closer
    /// descriptor wins and the keypoint is claimed once.
    #[test]
    fn fusion_gives_a_contested_keypoint_to_the_best_descriptor_once() {
        let mut map = Map::new();
        map.insert_keyframe(Keyframe::from_frame(test_frame(10, vec![[1; 32], [0; 32]])))
            .unwrap();
        let mut neighbor = test_frame(20, vec![[0; 32]]);
        neighbor.features.keypoints_xy = vec![[320.0, 240.0]];
        map.insert_keyframe(Keyframe::from_frame(neighbor)).unwrap();

        let worse = map
            .insert_landmark(LandmarkSeed {
                position: Vec3F64::new(0.0, 0.0, 5.0),
                color: [0; 3],
                reference: ObservationKey {
                    keyframe_idx: 10,
                    feature_idx: 0,
                },
            })
            .unwrap();
        let best = map
            .insert_landmark(LandmarkSeed {
                position: Vec3F64::new(0.0, 0.0, 5.0),
                color: [0; 3],
                reference: ObservationKey {
                    keyframe_idx: 10,
                    feature_idx: 1,
                },
            })
            .unwrap();

        let links = neighbor_fusion_links(&map, 10, &[20], &camera());
        assert_eq!(links.len(), 1, "the keypoint is claimed once");
        assert_eq!(links[0].landmark, LandmarkTarget::Existing(best));
        assert_eq!(links[0].observation.feature_idx, 0);
        assert_ne!(links[0].landmark, LandmarkTarget::Existing(worse));
    }

    /// A rejected candidate must reserve nothing. `(0, 1)` is refused because
    /// reference 0 is taken; if it still reserved current 1, the publishable
    /// `(1, 1)` would be lost with it — enough dropped support to fail the
    /// bootstrap acceptance threshold.
    #[test]
    fn a_rejected_candidate_does_not_reserve_its_other_feature() {
        assert_eq!(
            accepted_pair_claims(&[(0, 0), (0, 1), (1, 1)]),
            vec![0, 2],
            "the third candidate is publishable and must survive"
        );
    }

    #[test]
    fn first_claim_wins_in_either_view() {
        // Repeated current feature.
        assert_eq!(accepted_pair_claims(&[(0, 0), (1, 0)]), vec![0]);
        // Repeated reference feature.
        assert_eq!(accepted_pair_claims(&[(0, 0), (0, 1)]), vec![0]);
        // Disjoint in both views.
        assert_eq!(
            accepted_pair_claims(&[(0, 0), (1, 1), (2, 2)]),
            vec![0, 1, 2]
        );
        assert!(accepted_pair_claims(&[]).is_empty());
    }
}