mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 00:47:00 +01:00
feat(core): perceptual hashes for finding similar photos
A 64-bit difference hash of each thumbnail, stored in the new photos.similar_hash column (schema 34), and the queries the similar-photo finder needs: what is left to hash, progress, and the sets of shots that look alike. Sets made only of byte-identical copies are left to the duplicate finder. A refresh keeps a hash unless the photo's bytes changed. The daemon pass, the control request and the GUI are not built yet; see PLAN.md.
This commit is contained in:
1 parent
c46b2c1082
commit
c98ed0d83b
6 files changed
+406
-4
No files matched your search
@@ -9,7 +9,7 @@ use super::Db;
|
||||
use crate::Result;
|
||||
|
||||
/// Current schema version. Bump on every forward migration added below.
|
||||
pub(super) const SCHEMA_VERSION: i64 = 33;
|
||||
pub(super) const SCHEMA_VERSION: i64 = 34;
|
||||
|
||||
impl Db {
|
||||
pub(super) fn migrate(&self) -> Result<()> {
|
||||
@@ -300,6 +300,22 @@ impl Db {
|
||||
tx.execute_batch(MIGRATION_V33)?;
|
||||
}
|
||||
}
|
||||
if current < 34 {
|
||||
// Same guards as V26-V33.
|
||||
let has_column: bool = tx.query_row(
|
||||
"SELECT COUNT(*) FROM pragma_table_info('photos') WHERE name = 'similar_hash'",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)? > 0;
|
||||
let has_photos: bool = tx.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'photos'",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)? > 0;
|
||||
if has_photos && !has_column {
|
||||
tx.execute_batch(MIGRATION_V34)?;
|
||||
}
|
||||
}
|
||||
tx.execute(
|
||||
"INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
@@ -1006,3 +1022,14 @@ ALTER TABLE photos ADD COLUMN place_id INTEGER;
|
||||
CREATE INDEX IF NOT EXISTS idx_photos_place ON photos(place_id);
|
||||
UPDATE photos SET resolved_at = NULL;
|
||||
";
|
||||
|
||||
/// Schema v34: what each photo looks like, for finding similar ones.
|
||||
///
|
||||
/// `similar_hash` is a 64-bit difference hash of the photo's thumbnail (see
|
||||
/// `crate::similar`): resized and re-encoded copies of one picture land a few
|
||||
/// bits apart, where the server's `content_hash` only matches byte-identical
|
||||
/// files. `NULL` until the daemon has hashed the thumbnail, which it does as
|
||||
/// thumbnails arrive and when the similar-photo finder asks for the rest.
|
||||
const MIGRATION_V34: &str = "
|
||||
ALTER TABLE photos ADD COLUMN similar_hash INTEGER;
|
||||
";
|
||||
@@ -98,6 +98,8 @@ struct Learned {
|
||||
content_hash: Option<String>,
|
||||
main_uid: Option<String>,
|
||||
location: Option<(f64, f64)>,
|
||||
/// What its thumbnail looks like (see [`crate::similar`]).
|
||||
similar_hash: Option<i64>,
|
||||
/// When this photo's node was last resolved, kept so a refresh that skips it
|
||||
/// does not make it look unresolved again.
|
||||
resolved_at: Option<i64>,
|
||||
@@ -293,7 +295,7 @@ impl Db {
|
||||
let learned: HashMap<String, Learned> = {
|
||||
let mut stmt = tx.prepare(
|
||||
"SELECT uid, name, ratio, thumb_state, media_type, favorite, content_hash, \
|
||||
main_uid, resolved_at, latitude, longitude FROM photos",
|
||||
main_uid, resolved_at, latitude, longitude, similar_hash FROM photos",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |r| {
|
||||
Ok((
|
||||
@@ -310,6 +312,7 @@ impl Db {
|
||||
location: r
|
||||
.get::<_, Option<f64>>(9)?
|
||||
.zip(r.get::<_, Option<f64>>(10)?),
|
||||
similar_hash: r.get(11)?,
|
||||
},
|
||||
))
|
||||
})?;
|
||||
@@ -332,6 +335,10 @@ impl Db {
|
||||
carried.name = row.name.clone();
|
||||
carried.media_type = row.media_type.clone();
|
||||
carried.favorite = row.favorite.unwrap_or(false);
|
||||
// New bytes may show another picture: hash it again.
|
||||
if row.content_hash != carried.content_hash {
|
||||
carried.similar_hash = None;
|
||||
}
|
||||
carried.content_hash = row.content_hash.clone();
|
||||
carried.main_uid = row.main_uid.clone();
|
||||
carried.location = row.location;
|
||||
@@ -373,9 +380,9 @@ impl Db {
|
||||
"INSERT INTO photos
|
||||
(uid, capture_time, name, ratio, thumb_state, seq, media_type, kind, favorite,
|
||||
content_hash, main_uid, group_key, resolved_at, latitude, longitude,
|
||||
place_id)
|
||||
place_id, similar_hash)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15,
|
||||
?16)",
|
||||
?16, ?17)",
|
||||
)?;
|
||||
for (seq, ((row, carried, kind), group_key)) in
|
||||
resolved.iter().zip(group_keys.iter()).enumerate()
|
||||
@@ -400,6 +407,7 @@ impl Db {
|
||||
.location
|
||||
.and_then(|(lat, lon)| crate::places::nearest(lat, lon))
|
||||
.map(|city| city.id),
|
||||
carried.similar_hash,
|
||||
])?;
|
||||
}
|
||||
}
|
||||
@@ -771,6 +779,106 @@ impl Db {
|
||||
Ok(sets)
|
||||
}
|
||||
|
||||
/// Shots whose look hasn't been hashed yet, by the uid of the photo the
|
||||
/// grid shows for each, newest first, at most `limit`. Videos, and photos
|
||||
/// with no thumbnail to hash, are left out. A shot counts as hashed once
|
||||
/// any of its files is, since they all show one picture.
|
||||
pub fn photos_unhashed(&self, limit: usize) -> Result<Vec<StoredPhoto>> {
|
||||
let uids: Vec<String> = {
|
||||
let conn = self.read();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT COALESCE(group_key, uid) AS k FROM photos \
|
||||
GROUP BY k \
|
||||
HAVING MAX(similar_hash) IS NULL \
|
||||
AND MAX(kind != ?1 AND thumb_state != ?2) \
|
||||
ORDER BY MAX(capture_time) DESC LIMIT ?3",
|
||||
)?;
|
||||
stmt.query_map(
|
||||
params![
|
||||
crate::control::PhotoKind::Video.as_i64(),
|
||||
THUMB_NONE,
|
||||
limit as i64
|
||||
],
|
||||
|r| r.get(0),
|
||||
)?
|
||||
.collect::<rusqlite::Result<_>>()?
|
||||
};
|
||||
self.photos_by_uid(&uids)
|
||||
}
|
||||
|
||||
/// Record the look of photo `uid`: its difference hash, or
|
||||
/// [`crate::similar::NO_HASH`] when its thumbnail gave none.
|
||||
pub fn photo_set_similar_hash(&self, uid: &str, hash: u64) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute(
|
||||
"UPDATE photos SET similar_hash = ?2 WHERE uid = ?1",
|
||||
params![uid, hash as i64],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// How many shots have been hashed, out of how many could be.
|
||||
pub fn similar_progress(&self) -> Result<(usize, usize)> {
|
||||
let conn = self.read();
|
||||
let (hashed, total): (i64, i64) = conn.query_row(
|
||||
"SELECT COUNT(*) FILTER (WHERE hashed), COUNT(*) FROM \
|
||||
(SELECT MAX(similar_hash) IS NOT NULL AS hashed FROM photos \
|
||||
GROUP BY COALESCE(group_key, uid) \
|
||||
HAVING MAX(similar_hash) IS NOT NULL \
|
||||
OR MAX(kind != ?1 AND thumb_state != ?2))",
|
||||
params![crate::control::PhotoKind::Video.as_i64(), THUMB_NONE],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)?;
|
||||
Ok((hashed.max(0) as usize, total.max(0) as usize))
|
||||
}
|
||||
|
||||
/// Sets of shots that look alike (see [`crate::similar`]), one photo per
|
||||
/// shot in capture order, and the sets newest first. A set whose photos
|
||||
/// are all byte-identical copies is left to [`Self::photo_duplicates`].
|
||||
pub fn photo_similar(&self) -> Result<Vec<Vec<StoredPhoto>>> {
|
||||
let rows: Vec<(String, u64, Option<String>)> = {
|
||||
let conn = self.read();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT COALESCE(group_key, uid) AS k, MAX(similar_hash), \
|
||||
MAX(content_hash) FILTER (WHERE uid = COALESCE(group_key, uid)) \
|
||||
FROM photos GROUP BY k \
|
||||
HAVING MAX(similar_hash) IS NOT NULL AND MAX(similar_hash) != ?1",
|
||||
)?;
|
||||
stmt.query_map(params![crate::similar::NO_HASH as i64], |r| {
|
||||
Ok((r.get(0)?, r.get::<_, i64>(1)? as u64, r.get(2)?))
|
||||
})?
|
||||
.collect::<rusqlite::Result<_>>()?
|
||||
};
|
||||
let hashes: Vec<u64> = rows.iter().map(|(_, hash, _)| *hash).collect();
|
||||
let found: Vec<Vec<usize>> = crate::similar::sets(&hashes)
|
||||
.into_iter()
|
||||
.filter(|set| {
|
||||
let first = &rows[set[0]].2;
|
||||
first.is_none() || set.iter().any(|&i| rows[i].2 != *first)
|
||||
})
|
||||
.collect();
|
||||
let uids: Vec<String> = found.iter().flatten().map(|&i| rows[i].0.clone()).collect();
|
||||
let mut stored: HashMap<String, StoredPhoto> = self
|
||||
.photos_by_uid(&uids)?
|
||||
.into_iter()
|
||||
.map(|photo| (photo.uid.clone(), photo))
|
||||
.collect();
|
||||
let mut sets: Vec<Vec<StoredPhoto>> = found
|
||||
.iter()
|
||||
.map(|set| {
|
||||
let mut photos: Vec<StoredPhoto> = set
|
||||
.iter()
|
||||
.filter_map(|&i| stored.remove(&rows[i].0))
|
||||
.collect();
|
||||
photos.sort_by_key(|p| p.capture_time);
|
||||
photos
|
||||
})
|
||||
.filter(|set| set.len() > 1)
|
||||
.collect();
|
||||
sets.sort_by_key(|set| std::cmp::Reverse(set.iter().map(|p| p.capture_time).max()));
|
||||
Ok(sets)
|
||||
}
|
||||
|
||||
/// Per-tab counts for the Photos page subtitle: `(photos, videos, raw)`.
|
||||
pub fn photos_counts(&self) -> Result<(usize, usize, usize)> {
|
||||
use crate::control::PhotoKind;
|
||||
|
||||
@@ -406,6 +406,76 @@ fn duplicates_list_the_copy_to_keep_first() {
|
||||
assert_eq!(uids, [vec!["filed", "copy"], vec!["jpeg", "stray"]]);
|
||||
}
|
||||
|
||||
/// Shots that look alike form a set, one photo per shot, while a set of
|
||||
/// byte-identical copies is left to the duplicate finder.
|
||||
#[test]
|
||||
fn similar_photos_group_by_look_and_skip_exact_copies() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
db.photos_replace(&[
|
||||
hashed("big", 500, "IMG_1.JPG", "h1"),
|
||||
hashed("small", 400, "IMG_1-small.JPG", "h2"),
|
||||
hashed("copy", 300, "IMG_2.JPG", "h3"),
|
||||
hashed("copy2", 200, "IMG_2(1).JPG", "h3"),
|
||||
hashed("other", 100, "IMG_3.JPG", "h4"),
|
||||
hashed("flat", 50, "IMG_4.JPG", "h5"),
|
||||
])
|
||||
.unwrap();
|
||||
let look = 0x0f0f_3c3c_5a5a_9696_u64;
|
||||
db.photo_set_similar_hash("big", look).unwrap();
|
||||
db.photo_set_similar_hash("small", look ^ 0b101).unwrap();
|
||||
db.photo_set_similar_hash("copy", !look).unwrap();
|
||||
db.photo_set_similar_hash("copy2", !look).unwrap();
|
||||
db.photo_set_similar_hash("other", look ^ 0xffff_ffff)
|
||||
.unwrap();
|
||||
db.photo_set_similar_hash("flat", crate::similar::NO_HASH)
|
||||
.unwrap();
|
||||
|
||||
let sets = db.photo_similar().unwrap();
|
||||
let uids: Vec<Vec<&str>> = sets
|
||||
.iter()
|
||||
.map(|set| set.iter().map(|p| p.uid.as_str()).collect())
|
||||
.collect();
|
||||
assert_eq!(uids, [vec!["small", "big"]]);
|
||||
assert_eq!(db.similar_progress().unwrap(), (6, 6));
|
||||
}
|
||||
|
||||
/// What is left to hash skips videos and hashed shots, and a hash survives a
|
||||
/// refresh unless the photo's bytes changed.
|
||||
#[test]
|
||||
fn similar_hashes_are_kept_until_the_photo_changes() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
let resolved = |uid: &str, hash: &str| TimelineRow {
|
||||
resolved_at: Some(1),
|
||||
..hashed(uid, 100, &format!("{uid}.JPG"), hash)
|
||||
};
|
||||
db.photos_replace(&[
|
||||
resolved("a", "h1"),
|
||||
resolved("b", "h2"),
|
||||
hashed("clip", 50, "clip.mp4", "h3"),
|
||||
])
|
||||
.unwrap();
|
||||
db.photo_set_similar_hash("a", 7).unwrap();
|
||||
db.photo_set_similar_hash("b", 9).unwrap();
|
||||
let left = |db: &Db| -> Vec<String> {
|
||||
db.photos_unhashed(10)
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|p| p.uid)
|
||||
.collect()
|
||||
};
|
||||
assert!(left(&db).is_empty());
|
||||
assert_eq!(db.similar_progress().unwrap(), (2, 2));
|
||||
|
||||
db.photos_replace(&[
|
||||
resolved("a", "h1"),
|
||||
resolved("b", "h2-edited"),
|
||||
hashed("clip", 50, "clip.mp4", "h3"),
|
||||
])
|
||||
.unwrap();
|
||||
assert_eq!(left(&db), ["b"]);
|
||||
assert_eq!(db.similar_progress().unwrap(), (1, 2));
|
||||
}
|
||||
|
||||
/// Trashing the file a group is shown as leaves the rest of the group on the
|
||||
/// timeline instead of pointing at a photo that is gone.
|
||||
#[test]
|
||||
|
||||
@@ -20,6 +20,7 @@ pub mod sdkcache;
|
||||
pub mod search;
|
||||
pub mod service;
|
||||
pub mod shell;
|
||||
pub mod similar;
|
||||
pub mod syncignore;
|
||||
pub mod takeout;
|
||||
pub mod tray;
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
//! Telling photos apart by what they show rather than by their bytes.
|
||||
//!
|
||||
//! The server's content hash only matches byte-identical files. A photo that
|
||||
//! was resized, re-encoded or sent through a messenger is another file, so the
|
||||
//! similar-photo finder hashes each thumbnail instead: a 64-bit difference
|
||||
//! hash ("dHash"), which compares neighbouring brightness in a 9×8 grid. Two
|
||||
//! copies of one picture land a few bits apart, and different pictures about
|
||||
//! half of the bits apart.
|
||||
|
||||
/// Hashes this many bits apart or fewer show the same picture.
|
||||
pub const SIMILAR_BITS: u32 = 6;
|
||||
|
||||
/// Stored for a photo whose thumbnail was looked at and gave no usable hash
|
||||
/// (it couldn't be decoded, or is one flat colour), so it isn't looked at
|
||||
/// again. A real hash is never 0: a picture that would hash to 0 has no
|
||||
/// brightness changes, which [`difference_hash`] turns down as flat.
|
||||
pub const NO_HASH: u64 = 0;
|
||||
|
||||
/// Samples of the grid that differ by less than this in brightness, all of
|
||||
/// them, make a flat picture — a black frame, a blank page — whose hash is
|
||||
/// noise and would match every other flat picture.
|
||||
const FLAT: u8 = 8;
|
||||
|
||||
/// The difference hash of an 8-bit greyscale image `width` px wide, rows
|
||||
/// stored one after another in `luma`. `None` for an empty or flat image.
|
||||
pub fn difference_hash(width: usize, height: usize, luma: &[u8]) -> Option<u64> {
|
||||
if width == 0 || height == 0 || luma.len() < width * height {
|
||||
return None;
|
||||
}
|
||||
// Box-average down to 9×8, so every source pixel counts once and the hash
|
||||
// doesn't depend on which pixels a nearest-neighbour pick happens to hit.
|
||||
let mut grid = [[0u8; 9]; 8];
|
||||
for (gy, row) in grid.iter_mut().enumerate() {
|
||||
let (y0, y1) = span(gy, 8, height);
|
||||
for (gx, cell) in row.iter_mut().enumerate() {
|
||||
let (x0, x1) = span(gx, 9, width);
|
||||
let mut sum = 0u64;
|
||||
for y in y0..y1 {
|
||||
sum += luma[y * width + x0..y * width + x1]
|
||||
.iter()
|
||||
.map(|&v| u64::from(v))
|
||||
.sum::<u64>();
|
||||
}
|
||||
*cell = (sum / ((y1 - y0) * (x1 - x0)) as u64) as u8;
|
||||
}
|
||||
}
|
||||
let (min, max) = grid
|
||||
.iter()
|
||||
.flatten()
|
||||
.fold((u8::MAX, 0), |(min, max), &v| (min.min(v), max.max(v)));
|
||||
if max - min < FLAT {
|
||||
return None;
|
||||
}
|
||||
let mut hash = 0u64;
|
||||
for row in &grid {
|
||||
for pair in row.windows(2) {
|
||||
hash = hash << 1 | u64::from(pair[0] < pair[1]);
|
||||
}
|
||||
}
|
||||
(hash != NO_HASH).then_some(hash)
|
||||
}
|
||||
|
||||
/// The source pixels `from..to` that cell `i` of `cells` covers along an edge
|
||||
/// of `len` px; never empty, even for an edge shorter than the grid.
|
||||
fn span(i: usize, cells: usize, len: usize) -> (usize, usize) {
|
||||
let from = (i * len / cells).min(len - 1);
|
||||
let to = ((i + 1) * len / cells).max(from + 1);
|
||||
(from, to)
|
||||
}
|
||||
|
||||
/// Whether two hashes show the same picture.
|
||||
pub fn alike(a: u64, b: u64) -> bool {
|
||||
(a ^ b).count_ones() <= SIMILAR_BITS
|
||||
}
|
||||
|
||||
/// Group `hashes` into sets of alike ones, by index, each set in index order
|
||||
/// and the sets by their first index. A hash alike to none other is in no
|
||||
/// set. Alike is taken as catching: a burst of shots each a little different
|
||||
/// from the last is one set.
|
||||
pub fn sets(hashes: &[u64]) -> Vec<Vec<usize>> {
|
||||
// Two hashes at most SIMILAR_BITS apart agree on at least one of their
|
||||
// eight bytes, as long as SIMILAR_BITS < 8. So only hashes sharing some
|
||||
// byte are compared, not every pair of the library.
|
||||
const _: () = assert!(SIMILAR_BITS < 8);
|
||||
let mut parent: Vec<usize> = (0..hashes.len()).collect();
|
||||
fn root(parent: &mut [usize], mut i: usize) -> usize {
|
||||
while parent[i] != i {
|
||||
parent[i] = parent[parent[i]];
|
||||
i = parent[i];
|
||||
}
|
||||
i
|
||||
}
|
||||
for byte in 0..8 {
|
||||
let mut buckets: Vec<Vec<usize>> = vec![Vec::new(); 256];
|
||||
for (i, &hash) in hashes.iter().enumerate() {
|
||||
buckets[(hash >> (byte * 8)) as u8 as usize].push(i);
|
||||
}
|
||||
for bucket in &buckets {
|
||||
for (n, &a) in bucket.iter().enumerate() {
|
||||
for &b in &bucket[n + 1..] {
|
||||
if alike(hashes[a], hashes[b]) {
|
||||
let (ra, rb) = (root(&mut parent, a), root(&mut parent, b));
|
||||
if ra != rb {
|
||||
parent[ra.max(rb)] = ra.min(rb);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut by_root: std::collections::BTreeMap<usize, Vec<usize>> = Default::default();
|
||||
for i in 0..hashes.len() {
|
||||
let r = root(&mut parent, i);
|
||||
by_root.entry(r).or_default().push(i);
|
||||
}
|
||||
by_root.into_values().filter(|set| set.len() > 1).collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A 90×80 picture: a bright disc on a gradient.
|
||||
fn picture(width: usize, height: usize) -> Vec<u8> {
|
||||
let mut luma = Vec::with_capacity(width * height);
|
||||
for y in 0..height {
|
||||
for x in 0..width {
|
||||
let (fx, fy) = (x as f64 / width as f64, y as f64 / height as f64);
|
||||
let disc = ((fx - 0.3).powi(2) + (fy - 0.6).powi(2)).sqrt() < 0.2;
|
||||
let v = if disc { 240.0 } else { 40.0 + 120.0 * fx * fy };
|
||||
luma.push(v as u8);
|
||||
}
|
||||
}
|
||||
luma
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_resized_copy_hashes_alike() {
|
||||
let big = difference_hash(900, 800, &picture(900, 800)).unwrap();
|
||||
let small = difference_hash(91, 79, &picture(91, 79)).unwrap();
|
||||
assert!(
|
||||
alike(big, small),
|
||||
"{} bits apart",
|
||||
(big ^ small).count_ones()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_different_picture_hashes_apart() {
|
||||
let one = difference_hash(90, 80, &picture(90, 80)).unwrap();
|
||||
let mirrored: Vec<u8> = picture(90, 80)
|
||||
.chunks(90)
|
||||
.flat_map(|row| row.iter().rev().copied().collect::<Vec<_>>())
|
||||
.collect();
|
||||
let other = difference_hash(90, 80, &mirrored).unwrap();
|
||||
assert!(!alike(one, other));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_flat_picture_has_no_hash() {
|
||||
assert_eq!(difference_hash(64, 64, &[17; 64 * 64]), None);
|
||||
assert_eq!(difference_hash(0, 0, &[]), None);
|
||||
assert_eq!(difference_hash(4, 4, &[0; 3]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_picture_smaller_than_the_grid_still_hashes() {
|
||||
let luma: Vec<u8> = (0..4 * 3).map(|i| (i * 20) as u8).collect();
|
||||
assert!(difference_hash(4, 3, &luma).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alike_hashes_form_sets_and_catch_on() {
|
||||
let a = 0x0123_4567_89ab_cdef_u64;
|
||||
let hashes = [
|
||||
a,
|
||||
!a,
|
||||
a ^ 0b111,
|
||||
a ^ 0b111 ^ (0b1111 << 40),
|
||||
0x5555_0000_ffff_aaaa,
|
||||
];
|
||||
assert_eq!(sets(&hashes), vec![vec![0, 2, 3]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hashes_apart_in_most_bytes_are_still_found() {
|
||||
// One bit apart in each of six bytes: six bits in all, and only the
|
||||
// top two bytes agree.
|
||||
let a = 1u64 << 63;
|
||||
let b = (0..6).fold(a, |h, byte| h | 1 << (byte * 8));
|
||||
assert_eq!(sets(&[a, b]), vec![vec![0, 1]]);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user