0039 — Multi-curator split-file label merge-back (append-only owner-pull, adopt-or-surface)
- Status: accepted
- Date: 2026-07-07
- Deciders: bioedca
- PRD anchor: §5.1 (
/labelsprovenance + "themolecule_key+ labeler identity enable multi-curator reconciliation"; "the owner-curator's retrain recomputes every row'sweightfrom the merged label set … no weight reconciliation is needed"), §7.5 ("other members curate into their own split/subset files whose labeled rows … merge back as an append-only owner-pull … joined onmolecule_key. The owner's retrain then recomputes every row'sweight… and human-vs-human disagreement on the same molecule surfaces through a §7.4-style per-trace reconcile prompt"), §7.10 (FR-CONCURRENCY split-file curation), §7.4 (per-trace reconcile prompt) - Milestone: M5
Context and problem statement
Concurrent curation on OneDrive/network shares is served by the split-file path ([ADR-0023]'s /labels log, §7.10): a non-owner lab member curates into their own subset .tether rather than writing the single-writer-locked central project. The third and final concern of the M5 "seeding + drift flag + multi-curator split-merge" PR (after the drift flag [ADR-0037] and the provisional-prior training fold [ADR-0038]) is the owner-pull that folds those split-file human labels back into the condition-owner's canonical project so its ranker retrain sees them. The PRD specifies what must happen (append-only, joined on molecule_key, central weight recompute, disagreement surfaces) but not the mechanics, which force four decisions:
- Which rows merge. A split file's
/labelscan carry both human curation and provisional seed priors. Do the seeds merge too? - How adoption reaches the ranker. The training fold ([ADR-0038]) reads a molecule's human signal from
/molecules.curation_label, not from/labelshuman rows (only provisional priors come from/labels). So appending a contributor's human/labelsrow alone has no training effect — the merge must also updatecuration_labelfor the contributor's labels to matter. - What to do on disagreement. Updating
curation_labelfrom a contributor is exactly the "silent overwrite" §7.4/§7.5 forbid when the owner already holds a different human decision. - Idempotency. "Append-only owner-pull … at the video boundary" invites re-pulling the same split file more than once; a naïve append duplicates the whole contributor history each time.
Getting (3) wrong is the subtle trap: silently majority-voting or averaging away a human-vs-human disagreement discards the signal that annotator disagreement carries and erases one curator's judgement without review.
Decision drivers
- PRD §5.1/§7.5/§7.10 fidelity — append-only, join on
molecule_key, weights recomputed centrally (per-split-file weights advisory), disagreement surfaces via a §7.4 reconcile prompt. - Never silently overwrite a human decision; never silently drop (§7.5) — a foreign decision that conflicts with the owner's is surfaced, not applied; an unattributable row is surfaced, not dropped.
- The merge must actually train — non-conflicting contributor labels have to reach the signal the ranker reads (
/molecules.curation_label), or the feature is inert. - Schema-guard green — additive
/labelsrows + a value-onlycuration_labelrewrite; no group/dataset/dtype/field change ([ADR-0005], [ADR-0023]). - Established practice — annotator disagreement carries signal that single-ground-truth aggregation destroys [Davani2021]; disagreement should be resolved by a deliberate human procedure, not silent voting [Oortwijn2021].
Considered options
- Which rows merge: human-only vs. human + provisional. The two provisional sources (
deeplasi-provisional/cross-condition-seed) are the owner's own machine priors, regenerated by the seeding path ([ADR-0038]); pulling a contributor's copy would duplicate machine priors the owner already holds and conflate "another human curated this" with "a classifier guessed this." Human-only, with the skipped non-human count reported (never silently ignored). - How adoption reaches the ranker: append-to-
/labels-only (inert — training ignores human/labelsrows) vs. also setcuration_labelfor non-conflicting decisions. Only the latter has any training effect, so it is required; the report lists every adopted key so it is transparent, not silent. - Disagreement handling: silent last-writer-wins / majority-vote vs. adopt-or-surface. A contributor decision on an uncurated owner molecule is adopted; one that agrees is a no-op; one that disagrees is returned as a
MergeConflictwith the owner'scuration_labeluntouched — the §7.4 reconcile case. The conflicting row is still appended to the append-only/labelsaudit (with labeler provenance), so the record is complete, but it does not move the training signal until a human resolves it. - Resolution primitive: a bespoke
resolve_conflictAPI vs. reuse the ordinary owner action. A reconcile decision is just the owner settingcuration_label, i.e.tether.project.labels.accept/reject— no new primitive; the (deferred) GUI reconcile dialog calls those. - Idempotency: accept duplication vs. dedup by event provenance. Two
/labelsrows equal on(molecule_key, labeler, timestamp, source_file, source, label_value)are the same event; re-pulling skips them (weightandcondition_idare excluded —weightis advisory/recomputed andcondition_idis rewritten to the owner's authoritative value on append, so neither identifies the event). Append-only still holds: only genuinely new events are appended.
Decision outcome
New tether.project.merge.merge_labels(owner, source, *, recompute_weights=True) -> MergeReport:
- Reads the contributor's
/labels(read-only), partitions human rows (non-human counted intoskipped_non_human), and joins onmolecule_keyagainst the owner's/molecules; a key absent from the owner is collected intounmatched, and a key mapping to more than one ownercondition_id(a same-sha cross-condition coordinate collision, §7.10) is collected intoambiguousand refused — mirroringset_curation_label's multi-condition guard rather than silently tagging one condition and adopting across both — both surfaced, neither appended nor adopted. - Appends the genuinely-new matched human rows to the owner's
/labels/table(theset_curation_labelresize + slice-assign pattern), rewriting each appended row'scondition_idto the owner's authoritative value for that molecule so the central recompute groups it correctly; already-present events (same provenance) count intoskipped_duplicate. - Classifies each matched molecule's latest contributor human decision vs. the owner's
curation_label: owner-UNCURATED→ adopt (writecuration_labelon all matched rows); equal → agreement; different → aMergeConflict(ownercuration_labelleft untouched). - Unless
recompute_weights=False, callsrecompute_label_weights(owner)([ADR-0036]) over the merged set — per-split-file weights are superseded centrally.
MergeReport fully itemizes the merge (appended / adopted / agreements / conflicts / unmatched / skipped_non_human / skipped_duplicate / weights_recomputed), so every source row and every classified molecule is accounted for — nothing is silently dropped.
Consequences
- Good:
schema-guardstays green — additive/labelsrows + a value-onlycuration_labelrewrite over the frozen store; no new group/dataset/dtype/field. No conda-lock change (h5py + numpy, theset_curation_label/recompute_label_weightsstack) and no new §11.2 tunable (the central recompute reuses the existingw₀). - Good: a re-pull of the same split file is idempotent (dedup + idempotent adoption), so the append-only pull can run every video boundary without bloating the log or double-counting.
- Good: human-vs-human disagreement is preserved and surfaced for a deliberate reconcile [Davani2021, Oortwijn2021] and a conflicting contributor decision cannot silently overwrite the owner's — while its audit row is still recorded (the ranker's training signal,
curation_label, is unaffected until the owner resolves it). - Trade-off: adoption uses the contributor's latest human event as their current decision (a contributor un-reject,
label_value = UNCURATED, withdraws the decision — no adopt/conflict); a mid-history flip is not separately surfaced, consistent with "current decision" semantics elsewhere. - Follow-up: the GUI reconcile dialog that walks
MergeReport.conflictsper-trace (accept/reject each) is deferred to the M5 GUI PR (computer-use gate); the headless core surfaces the conflicts it will consume. With this, all three concerns of the split M5 "seeding + drift + multi-curator" PR ([ADR-0037], [ADR-0038], this) have landed; the active-learning non-reordering badge is the last M5 PR before thev0.5.0exit.
More information
- PRD §5.1 (
/labelsprovenance + multi-curator reconciliation), §7.5 (curation & per-condition ML), §7.10 (FR-CONCURRENCY split-file curation), §7.4 (per-trace reconcile prompt); PLAN §9 M5. - Builds on [ADR-0023] (the
/labelscodec + append-only log + human-onlycuration_label), [ADR-0036] (recompute_label_weights), and [ADR-0038] (the training fold that reads human labels fromcuration_label, provisional from/labels). - [Davani2021] Davani, Díaz & Prabhakaran, "Dealing with Disagreements: Looking Beyond the Majority Vote in Subjective Annotations," TACL (2021) — annotator disagreement captures signal that aggregation to a single ground truth ignores.
- [Oortwijn2021] Oortwijn, Ossenkoppele & Betti, "Interrater Disagreement Resolution: A Systematic Procedure to Reach Consensus in Annotation Tasks" (2021) — disagreement in ground-truth construction should be resolved by a systematic human procedure, not silent aggregation.