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:
@@ -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,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user