feat: let a translation ask for the chapters it is missing

A translation is scaffolded from both ends now -- the chapters that existed when
it was proposed, and each chapter signed afterwards putting itself in. Neither
end closes the gap on its own, and no snapshot taken at proposal time can.

Two ways they miss each other. A chapter and a translation proposed at the same
moment each read what exists when they are proposed, so neither sees the other
and nothing retries. And a scaffolding session that fails to reach a quorum
leaves nothing behind to try again with -- the chapter's id is spent, since a
re-proposed chapter is a different one.

So the translation's own screen says what it is missing and offers to ask for
it. Above the chapter list rather than below, because a chapter that is not in a
translation is invisible from a list of the ones that are: the whole failure is
that nothing looks wrong.

**Matched on the chapter named, not counted.** `chaptersMissingFrom` compares
which source chapter each translation chapter stands for. A count would read a
translation that is missing its second chapter but picked up its third as one
missing its last, and would then scaffold the wrong chapter -- leaving the real
gap open and a duplicate beside it. It also means running a catch-up on a
translation that is already complete proposes nothing at all, rather than a
second copy of every chapter under fresh ids.

**More sessions rather than a cap.** The missing chapters are chunked at
MAX_BATCH_SIZE, one session each. A translation far enough behind to need more
than a batch holds is not one to refuse; it is one the group answers for more
than once. They share a timestamp, so a catch-up reads as the one act it is.

The screen lands on the first session -- the rest are beside it in the room's
list -- and the card stays until a quorum arrives, which is honest: the chapters
are still missing until then.

**Tests.** Two more in TranslationScaffoldTest, both about the matching rather
than the counting: a gap in the middle, and a complete translation being missing
nothing. Checked against a broken implementation -- taking the missing chapters
as the tail after a count passes on a translation that fell behind at the end,
which is the easy case, and is caught by the gap.

Not covered: `catchUpMissingChapters` itself, which is plumbing over the
templates those tests pin and the batch API the jvm tests pin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-09-06 12:03:54 +02:00
parent 66119f34df
commit df058dc61c
6 changed files with 279 additions and 21 deletions

View File

@@ -4,6 +4,7 @@ import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
import press.mantra.compose.database.model.MantraChapter
import press.mantra.compose.database.model.MantraTranslationChapter
import press.mantra.compose.nostr.nip30303.TranslationChapterEvent
import press.mantra.compose.nostr.nip30303.tags.ChapterIdTag
import press.mantra.compose.nostr.nip30303.tags.IndexTag
@@ -12,10 +13,11 @@ import press.mantra.compose.nostr.nip30303.tags.TranslationArtifactVersionIdTag
/**
* The join between a translation and the chapters it is of, as a pure function.
*
* Both ends arrive on their own schedule, so this is built from two
* directions -- a translation being proposed, and a chapter being proposed.
* What it must not do is disagree with itself about what a translation covers
* depending on which direction it was built from.
* Both ends arrive on their own schedule, so this is built from three
* directions -- a translation being proposed, a chapter being proposed, and a
* catch-up filling in what the two missed. What it must not do is disagree with
* itself about what a translation covers depending on which direction it was
* built from.
*/
class TranslationScaffoldTest {
@@ -35,6 +37,17 @@ class TranslationScaffoldTest {
chatRoomId = "r".repeat(64),
)
private fun translationChapterFor(chapter: MantraChapter, translationId: String) =
MantraTranslationChapter(
id = "t${chapter.id}".take(64),
publicKey = "p".repeat(64),
chapterId = chapter.id,
translationArtifactVersionId = translationId,
index = chapter.index,
signature = "",
chatRoomId = "r".repeat(64),
)
private fun parse(templates: List<com.vitorpamplona.quartz.nip01Core.signers.EventTemplate<TranslationChapterEvent>>) =
templates.map { template ->
Triple(
@@ -90,10 +103,10 @@ class TranslationScaffoldTest {
}
@Test
fun `a chapter keeps its own position, not its position in the list`() {
fun `a catch-up keeps each chapter's own position, not the position in the gap`() {
val chapters = (0..5).map(::sourceChapter)
// A caller may hold a subset, and the subset is not contiguous.
// A catch-up proposes a subset, and the subset is not contiguous.
// Counting the list would renumber chapters 3 and 5 as 1 and 2, which
// is a translation that reads in a different order than the work.
val templates = TranslationScaffold.chaptersOf(
@@ -136,4 +149,35 @@ class TranslationScaffoldTest {
).isEmpty()
)
}
@Test
fun `what is missing is matched on the chapter named, not on how many there are`() {
val chapters = (0..3).map(::sourceChapter)
// Chapter 1 never landed but chapter 2 did, which a count would read as
// "the last one is missing" and scaffold the wrong chapter.
val missing = TranslationScaffold.chaptersMissingFrom(
translationChapters = listOf(chapters[0], chapters[2]).map {
translationChapterFor(it, translationA)
},
sourceChapters = chapters,
)
assertEquals(listOf(chapters[1].id, chapters[3].id), missing.map { it.id })
}
@Test
fun `a translation that is already complete is missing nothing`() {
val chapters = (0..2).map(::sourceChapter)
// So that running a catch-up twice proposes nothing the second time,
// rather than a second copy of every chapter under fresh ids.
assertEquals(
emptyList(),
TranslationScaffold.chaptersMissingFrom(
translationChapters = chapters.map { translationChapterFor(it, translationA) },
sourceChapters = chapters,
)
)
}
}