feat: propose a long artifact's translation in more sessions, not none at all

An artifact of more than MAX_CHAPTERS_PER_TRANSLATION chapters could not be
translated. The form refused, said so in red, and left nothing to be done about
it -- the chapters are already signed, and unlike a chapter's paragraphs there
is nothing the person reading that message can split. It was a cap on how long a
book may be, wearing a cap on a batch as a disguise.

The same answer the chapter side already gives: propose more than once. The
translation and as many chapters as fit go in the first session, and the rest
follow in sessions of their own, naming the translation the first is about to
sign. The admins answer once per session, and the form counts them before the
tap rather than colouring a refusal.

MAX_CHAPTERS_PER_TRANSLATION stays, meaning what it now measures -- how many
chapters ride in the translation's own session, the batch minus the place the
translation itself takes. It is a cap on a session rather than on the work.

The cost is the one every second session in this design carries, and is
documented where it is paid: if the translation fails to reach a quorum while
these succeed, they are valid signatures over rows naming a translation nobody
has, which fail a foreign key on the way in and are logged rather than applied.
The catch-up on the translation is what fills that in afterwards.

**Tests.** TranslationBatchProposalJvmTest now covers the split: an artifact
three chapters past the cap proposes a full first session and a second of three,
every chapter of the work covered exactly once across both, in order, each
naming the translation as the group will author it. The refusal test stays and
keeps its point -- one session is still refused a chapter too many, because a
batch that quietly dropped its last chapters would sign a translation the group
believes covers the whole work while the end of it can never be translated. What
changed is who prevents it: the screen splits rather than checks, and the manager
still refuses independently.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-09-06 12:05:35 +02:00
parent df058dc61c
commit 3fade6c849
3 changed files with 127 additions and 44 deletions

View File

@@ -46,6 +46,7 @@ import press.mantra.compose.database.model.MantraArtifact
import press.mantra.compose.database.model.MantraArtifactVersion
import press.mantra.compose.database.model.MantraDialect
import press.mantra.compose.database.model.intermdiate.LocalChatRoom
import press.mantra.compose.managers.FrostSigningManager
import press.mantra.compose.repository.ChatRepository
import press.mantra.compose.repository.FrostSigningRepository
import press.mantra.compose.repository.MantraRepository
@@ -109,16 +110,18 @@ fun AddTranslationArtifactVersionScreen(
val artifactVersion = addTranslationUIState.artifactVersion
val chapterCount = addTranslationUIState.chapters.size
// The translation and a chapter apiece are signed in one session,
// and a session signs a bounded number of events. Past that the
// artifact cannot be translated in one go at all, which is worth
// saying here rather than after a failed propose.
val tooManyChapters = chapterCount > AddTranslationArtifactVersionViewModel.MAX_CHAPTERS_PER_TRANSLATION
// The translation and a chapter apiece are signed together, and a
// session signs a bounded number of events -- so a long artifact is
// proposed in more than one, and the admins answer once per
// session. That is a number worth seeing before the tap.
val sessionCount = 1 + (
(chapterCount - AddTranslationArtifactVersionViewModel.MAX_CHAPTERS_PER_TRANSLATION)
.coerceAtLeast(0) + FrostSigningManager.MAX_BATCH_SIZE - 1
) / FrostSigningManager.MAX_BATCH_SIZE
val canProposeTranslation = artifactVersion != null &&
selectedDialect != null &&
addTranslationUIState.canSign &&
!tooManyChapters
addTranslationUIState.canSign
// M3 gives a FAB no `enabled`, so borrow the disabled colours every
// other button in the app uses rather than inventing a shade here.
@@ -220,16 +223,6 @@ fun AddTranslationArtifactVersionScreen(
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
} else if (tooManyChapters) {
Text(
text = "$chapterCount chapters is more than the group can sign in " +
"one go, so this artifact cannot be translated here. It signs " +
"at most " +
"${AddTranslationArtifactVersionViewModel.MAX_CHAPTERS_PER_TRANSLATION} " +
"chapters at a time.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
Text("Translate into which dialect?")
@@ -271,16 +264,11 @@ fun AddTranslationArtifactVersionScreen(
"into it as they are added."
} else {
"The group signs the translation and " +
"$chapterCount ${if (chapterCount == 1) "chapter" else "chapters"} " +
"of ${AddTranslationArtifactVersionViewModel.MAX_CHAPTERS_PER_TRANSLATION} " +
"together. Chunks are translated one at a time afterwards."
"$chapterCount ${if (chapterCount == 1) "chapter" else "chapters"}" +
(if (sessionCount == 1) "" else ", in $sessionCount sessions to sign") +
". Chunks are translated one at a time afterwards."
},
style = MaterialTheme.typography.labelMedium,
color = if (tooManyChapters) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.typography.labelMedium.color
}
)
}
}

View File

@@ -104,14 +104,6 @@ class AddTranslationArtifactVersionViewModel(
onSuccess: (sessionId: String) -> Unit,
onFailure: () -> Unit
) {
// The cap is the group's, not this screen's, and proposing past it
// throws rather than failing softly. The form says so before the tap;
// this is the check that has to hold when it does not.
if (chapters.size > MAX_CHAPTERS_PER_TRANSLATION) {
onFailure.invoke()
return
}
// Guard against double submits from repeated FAB taps.
if (isActionPending.value) return
isActionPending.value = true
@@ -141,7 +133,8 @@ class AddTranslationArtifactVersionViewModel(
dependents = { translation ->
TranslationScaffold.chaptersOf(
translationArtifactVersionIds = listOf(translation.id),
chapters = chapters.map(TranslationScaffold.SourceChapter::of),
chapters = chapters.take(MAX_CHAPTERS_PER_TRANSLATION)
.map(TranslationScaffold.SourceChapter::of),
createdAt = translation.createdAt,
)
}
@@ -151,6 +144,15 @@ class AddTranslationArtifactVersionViewModel(
}.getOrNull()
if (session != null) {
// Before navigating, not after: the route this screen sits on
// is popped on success, which clears this view model and takes
// `viewModelScope` with it.
scaffoldRemainingChapters(
localChatRoom = localChatRoom,
chapters = chapters.drop(MAX_CHAPTERS_PER_TRANSLATION),
translationSessionId = session.id,
)
viewModelScope.launch(Dispatchers.Main) {
onSuccess.invoke(session.id)
}
@@ -164,17 +166,70 @@ class AddTranslationArtifactVersionViewModel(
}
}
/**
* Asks the group for the chapters that did not fit the translation's own
* session, in sessions of their own.
*
* The alternative was refusing to translate an artifact of more than
* [MAX_CHAPTERS_PER_TRANSLATION] chapters at all, which is a cap on how
* long a book may be dressed up as a cap on a batch. A group with a longer
* one answers more than once instead.
*
* The translation is named as the group will author it: the first item of
* [translationSessionId] is the unsigned translation, and its id is settled
* at proposal time rather than when the quorum arrives.
*
* These carry the same cost as any second session, and the same one the
* chapter side carries: if the translation itself fails to reach a quorum
* these are valid signatures over rows naming a translation nobody has.
* They fail a foreign key on the way in, are logged, and never become rows
* -- and the catch-up on the translation is what fills the gap afterwards.
*/
private suspend fun scaffoldRemainingChapters(
localChatRoom: LocalChatRoom,
chapters: List<MantraChapter>,
translationSessionId: String,
) {
if (chapters.isEmpty()) return
runCatching {
val translation = frostSigningRepository
.unsignedEvents(frostSigningRepository.getItems(translationSessionId))
.firstOrNull() ?: return@runCatching
chapters.chunked(FrostSigningManager.MAX_BATCH_SIZE).forEach { batch ->
frostSigningRepository.proposeSigningBatch(
localChatRoom = localChatRoom,
userPublicKey = activeUserPublicKey,
events = TranslationScaffold.chaptersOf(
translationArtifactVersionIds = listOf(translation.id),
chapters = batch.map(TranslationScaffold.SourceChapter::of),
createdAt = translation.createdAt,
)
)
}
}.onFailure { error ->
logger.e(
"Proposed the translation but could not ask for the chapters past the first session",
error
)
}
}
companion object {
private const val TAG = "AddTranslationViewModel"
/**
* The most chapters an artifact can be translated with in one session.
* How many chapters ride in the translation's own session.
*
* The translation is signed together with a chapter apiece, and a
* session signs at most [FrostSigningManager.MAX_BATCH_SIZE] events. The
* translation is one of them, so the chapters get the rest. Unlike a
* chapter's paragraphs this is not something whoever is looking at the
* screen can shorten, so it is said plainly rather than as advice.
* translation is one of them, so the chapters get the rest.
*
* A cap on the session, not on the work: an artifact with more chapters
* than this is not refused, it is proposed in more than one session. A
* limit whoever is looking at the screen can do nothing about is not a
* limit worth telling them to work around.
*/
const val MAX_CHAPTERS_PER_TRANSLATION: Int = FrostSigningManager.MAX_BATCH_SIZE - 1

View File

@@ -380,14 +380,54 @@ class TranslationBatchProposalJvmTest {
}
@Test
fun `an artifact of one chapter too many is refused`() = runBlocking {
fun `an artifact longer than one session is split across two`() = runBlocking {
openDevice()
// Refused here rather than truncated: a batch that quietly dropped its
// last chapters would sign a translation the group believes covers the
// whole work while the end of it can never be translated. The screen
// checks first so this is never what a member sees, but the screen is
// not what enforces it.
// What `addTranslation` does with an artifact past the cap: the
// translation and as many chapters as fit, then the rest naming the
// translation the first session is about to sign. Refusing instead
// would be a cap on how long a book may be, dressed up as a cap on a
// batch.
val chapters = sourceChapters(AddTranslationArtifactVersionViewModel.MAX_CHAPTERS_PER_TRANSLATION + 3)
val first = proposeTranslation(
chapters.take(AddTranslationArtifactVersionViewModel.MAX_CHAPTERS_PER_TRANSLATION)
)
val translation = itemEvents(first.id).first()
val second = FrostSigningManager.proposeSigningBatch(
database = db,
localChatRoom = room,
userPublicKey = proposer,
events = TranslationScaffold.chaptersOf(
translationArtifactVersionIds = listOf(translation.id),
chapters = chapters.drop(AddTranslationArtifactVersionViewModel.MAX_CHAPTERS_PER_TRANSLATION)
.map(TranslationScaffold.SourceChapter::of),
createdAt = translation.createdAt,
)
)
assertEquals(FrostSigningManager.MAX_BATCH_SIZE, itemEvents(first.id).size)
assertEquals(3, itemEvents(second.id).size)
// Every chapter of the work is covered exactly once, across both, and
// each names the translation as the group will author it.
val scaffolded = (itemEvents(first.id).drop(1) + itemEvents(second.id)).map {
TranslationChapterEvent(it.id, it.pubKey, it.createdAt, it.tags, it.content, it.sig)
}
assertEquals(chapters.map { it.id }, scaffolded.map { it.chapterId() })
assertEquals(chapters.map { it.index }, scaffolded.map { it.index() })
assertTrue(scaffolded.all { it.translationArtifactVersionId() == translation.id })
}
@Test
fun `one session is still refused a chapter too many`() = runBlocking {
openDevice()
// Refused rather than truncated: a batch that quietly dropped its last
// chapters would sign a translation the group believes covers the whole
// work while the end of it can never be translated. The screen splits
// before it gets here, so this is never what a member sees -- but the
// screen is not what enforces it.
assertFailsWith<IllegalArgumentException> {
proposeTranslation(sourceChapters(FrostSigningManager.MAX_BATCH_SIZE))
}