Fold a photograph's rival default versions back into one

Picking the newer of two default versions stopped the wrong edit being shown,
but it did not close the split: the losing version stayed in the file, and a
device holding disjoint work — a crop made here, an exposure change made there
— still contributed only one of the two.

Worse, the next write made it larger. `amend` looks its version up by uuid,
neither of the two was ours, so the miss minted a *third* `default = 1` block
and the file grew one rival per device per photograph.

`Sidecar::fuse_default_versions` folds them down. The version with the highest
`(revision, modified)` is the accumulator and every other default is merged
into it as the remote, which is what makes the fold order-independent —
`Version::merge` raises its own revision to `max + 1` as it goes, so merging a
chain in ascending order stops being ascending after the first step and a third
device would be dropped. Contested values resolve to the winner, disjoint keys
survive from both sides because the merge is key-wise, and ratings come across
under `merge_judgement`, so a device that never judged the frame cannot erase
one that did.

The result is a function of the file's bytes alone, so two devices that fuse
independently reach the same document and converge instead of overwriting each
other.

Called wherever a sidecar is parsed:

- `amend`, with the write's own uuid, so the fold lands on the identity this
  device is about to use and the lookup below it hits instead of missing.
- `spawn_sidecar_fetch`, so opening a photograph shows everything done to it
  rather than whichever half won.
- `drain_one`, because `merge_into` reconciles by uuid and would otherwise
  publish the split rather than resolve it.
- `presets::load_local` and `save_local` — a local sidecar's folder may be
  synced by something else entirely, and gets the same split.

A file with one default under the expected uuid comes back byte-identical, so
this costs nothing on the ordinary write and no sidecar is uploaded merely for
having been read.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-05 16:13:53 +02:00
co-authored by Claude Opus 5
parent 98fcf8e98e
commit 601c984894
4 changed files with 432 additions and 37 deletions
+243 -2
View File
@@ -624,8 +624,8 @@ impl Sidecar {
/// This used to answer with the first `is_default` in map order, and map
/// order is *uuid* order. That is only ever right when exactly one version
/// claims to be the default, and two devices editing one photograph
/// routinely produce two that do, because each mints its own uuid for the
/// same photograph. With two, the answer became a per-photograph
/// routinely produce two that do — see [`Self::fuse_default_versions`] for
/// how they come to exist. With two, the answer became a per-photograph
/// coin toss decided by which random uuid happened to sort lower, and the
/// losing device's afternoon of work simply did not appear.
///
@@ -646,6 +646,101 @@ impl Sidecar {
.or_else(|| self.versions.values().next())
}
/// TRACES: FR-NC-8 | FR-NC-9
/// Collapse every version claiming to be the default onto one identity.
///
/// # The failure this repairs
///
/// A version's uuid is the identity a cross-device merge keys on, and for
/// a long time each device minted its own at random for the same
/// photograph. Two devices editing one frame therefore wrote two
/// `[version]` blocks, both marked `default = 1`, and
/// [`Version::merge`] — which is perfectly capable of combining them —
/// was never handed the pair, because it matches on uuid and the uuids
/// never matched. The two edits simply accumulated, and whichever one
/// [`Self::default_version`] happened to pick was the only one anybody
/// saw.
///
/// Minting the uuid deterministically stops new splits. It does nothing
/// for the files already written, which is what this is for.
///
/// # Why folding into the winner rather than merging in sequence
///
/// [`Version::merge`] raises its own `revision` to `max + 1` as it goes,
/// so merging a chain in ascending order stops being ascending after the
/// first step and the third version would lose to the accumulator. Folding
/// *into* the highest `(revision, modified)` avoids the question: every
/// other version is merged in as the remote and loses every contested
/// value, while the disjoint case — a key only it holds — is taken
/// regardless of who wins, which is the whole point of a key-wise merge.
/// Ratings come across under [`merge_judgement`], so a device that never
/// judged the frame cannot erase one that did.
///
/// The result is the same on every device, because it is a function of the
/// file's contents alone. Two devices that fuse independently and upload
/// converge rather than ping-ponging.
///
/// # `canonical`
///
/// The uuid the result should carry — the caller's derived identity for
/// this photograph (`dr_catalog::rating::derived_version_uuid`). Passing
/// `None` folds onto the lexicographically smallest of the defaults, which
/// is still device-independent and is the rule
/// `dr_catalog::keywords::fuse_duplicates` already uses for the same shape
/// of problem.
///
/// A rename happens even when there is nothing to fuse: a lone default
/// still sitting under a randomly minted uuid has to move to the derived
/// one, or the next write finds no match and adds a *third* version.
///
/// Returns the uuid the default now carries, or `None` if there was no
/// default version to fuse.
pub fn fuse_default_versions(&mut self, canonical: Option<&str>) -> Option<String> {
let mut defaults: Vec<String> = self
.versions
.values()
.filter(|v| v.is_default)
.map(|v| v.uuid.clone())
.collect();
defaults.sort();
let winner_uuid = self
.versions
.values()
.filter(|v| v.is_default)
.max_by_key(|v| (v.revision, v.modified))
.map(|v| v.uuid.clone())?;
// A canonical uuid that already names a *different*, non-default
// version is a virtual copy (FR-CAT-12), and renaming onto it would
// delete it. Vanishingly unlikely — a derived uuid is structurally
// distinct from a randomly minted one — but silently destroying a
// named edit is precisely the loss this module exists to prevent, so
// the rename is declined rather than risked.
let target = match canonical {
Some(c) if !defaults.iter().any(|u| u == c) && self.versions.contains_key(c) => {
log::warn!(
"not renaming the default version onto {c}: another version already has it"
);
winner_uuid.clone()
}
Some(c) => c.to_string(),
None => defaults
.first()
.cloned()
.unwrap_or_else(|| winner_uuid.clone()),
};
let mut winner = self.versions.remove(&winner_uuid)?;
for uuid in defaults.iter().filter(|u| **u != winner_uuid) {
if let Some(loser) = self.versions.remove(uuid) {
winner.merge(&loser, None);
}
}
winner.uuid = target.clone();
self.versions.insert(target.clone(), winner);
Some(target)
}
/// Insert or replace a version.
pub fn put(&mut self, version: Version) {
self.versions.insert(version.uuid.clone(), version);
@@ -2457,4 +2552,150 @@ mod split_defaults {
]);
assert_eq!(s.default_version().unwrap().rating, 3);
}
/// Fusing is what makes the two converge rather than merely choosing
/// between them: a parameter only the *losing* version holds survives,
/// because the merge is key-wise.
#[test]
fn fusing_keeps_the_disjoint_work_of_both_devices() {
let mut old = default_version("0545c20a", 1, 100, 4);
old.params
.insert(("exposure".into(), "exposure".into()), 0.75);
let mut new = default_version("679fe872", 2, 200, 0);
new.params
.insert(("saturation".into(), "saturation".into()), 0.5);
let mut s = with(vec![old, new]);
let uuid = s.fuse_default_versions(None).unwrap();
assert_eq!(s.versions.len(), 1, "the split must be closed");
let v = &s.versions[&uuid];
assert_eq!(
v.params.get(&("exposure".into(), "exposure".into())),
Some(&0.75),
"the older device's exposure was dropped"
);
assert_eq!(
v.params.get(&("saturation".into(), "saturation".into())),
Some(&0.5),
"the newer device's saturation was dropped"
);
// A judgement the loser holds and the winner does not is adopted, for
// the reason `merge_judgement` gives: a zero is "never judged".
assert_eq!(v.rating, 4);
}
/// A contested value resolves to the higher revision, not to whichever
/// uuid sorted first.
#[test]
fn fusing_resolves_a_contested_value_by_revision() {
let mut old = default_version("aaaa", 9, 100, 0);
old.params
.insert(("exposure".into(), "exposure".into()), 0.75);
let mut new = default_version("bbbb", 1, 999_999, 0);
new.params
.insert(("exposure".into(), "exposure".into()), -2.0);
let mut s = with(vec![old, new]);
let uuid = s.fuse_default_versions(None).unwrap();
assert_eq!(
s.versions[&uuid]
.params
.get(&("exposure".into(), "exposure".into())),
Some(&0.75),
"the higher revision must win"
);
}
/// Every device must reach the same file from the same input, or two of
/// them fuse and upload forever without converging.
#[test]
fn fusing_is_the_same_on_every_device() {
let versions = vec![
default_version("cccc", 1, 100, 2),
default_version("aaaa", 3, 300, 0),
default_version("bbbb", 2, 200, 0),
];
let mut one = with(versions.clone());
// The other device parsed the same file, so it holds the same set in a
// different insertion order.
let mut two = with(versions.into_iter().rev().collect());
one.fuse_default_versions(None);
two.fuse_default_versions(None);
assert_eq!(one.to_text(), two.to_text());
assert_eq!(one.versions.len(), 1);
}
/// Three or more versions must all fold in. The accumulator's revision
/// climbs as it merges, so folding a chain in ascending order would drop
/// everything after the second — which is why the fold goes into the
/// winner instead.
#[test]
fn a_third_device_is_not_lost_to_the_accumulators_rising_revision() {
let mut a = default_version("aaaa", 1, 100, 0);
a.params.insert(("a".into(), "a".into()), 1.0);
let mut b = default_version("bbbb", 2, 200, 0);
b.params.insert(("b".into(), "b".into()), 2.0);
let mut c = default_version("cccc", 3, 300, 0);
c.params.insert(("c".into(), "c".into()), 3.0);
let mut s = with(vec![a, b, c]);
let uuid = s.fuse_default_versions(None).unwrap();
let v = &s.versions[&uuid];
for key in ["a", "b", "c"] {
assert!(
v.params.contains_key(&(key.to_string(), key.to_string())),
"{key} was dropped by the fold"
);
}
}
/// The rename half. A lone default under a randomly minted uuid has to
/// move onto the derived one, or the next write matches nothing and adds a
/// third version instead of amending this one.
#[test]
fn a_lone_default_is_renamed_onto_the_canonical_uuid() {
let mut s = with(vec![default_version("random-v4", 1, 100, 3)]);
let uuid = s.fuse_default_versions(Some("derived")).unwrap();
assert_eq!(uuid, "derived");
assert_eq!(s.versions.len(), 1);
assert_eq!(s.versions["derived"].rating, 3, "the edit went with it");
}
/// A virtual copy (FR-CAT-12) that happens to hold the canonical uuid must
/// not be overwritten by the rename.
#[test]
fn a_named_version_is_not_destroyed_by_the_rename() {
let mut copy = default_version("derived", 1, 100, 5);
copy.is_default = false;
copy.name = "For print".to_string();
let mut s = with(vec![default_version("random-v4", 1, 100, 3), copy]);
let uuid = s.fuse_default_versions(Some("derived")).unwrap();
assert_eq!(uuid, "random-v4", "the rename must be declined");
assert_eq!(s.versions.len(), 2);
assert_eq!(s.versions["derived"].name, "For print");
}
/// Fusing a file that never needed it must not rewrite it — a sidecar that
/// changes on every read is a sidecar that uploads on every read.
#[test]
fn fusing_a_healthy_sidecar_changes_nothing() {
let mut s = with(vec![default_version("only", 4, 400, 2)]);
let before = s.to_text();
s.fuse_default_versions(None);
assert_eq!(s.to_text(), before);
}
/// Nothing to fuse is not a failure — most photographs have never been
/// edited on any device.
#[test]
fn an_empty_sidecar_has_no_default_to_fuse() {
let mut s = Sidecar::new();
assert_eq!(s.fuse_default_versions(Some("derived")), None);
assert!(s.versions.is_empty());
}
}