feat(frost): a signing proposal has a size, and the form says what it is
Phase 2 of docs/chapter-uploads.md, and a fix to something wrong today. MAX_BATCH_SIZE bounds how many events a batch holds. Nothing bounded how large they are, and those are not the same limit: sixty-four dialects are a few kilobytes and sixty-four chunks of a chapter are eighty. The proposal is the largest message in this protocol by an order of magnitude -- a signer's nonce payload is k*133 bytes, their partial k*65, the proposal k whole events -- so a legal batch could exceed what a group event carries, and when it did the failure was silent: the proposal went out and the session stalled with nothing to blame. MAX_PROPOSAL_BYTES sits beside MAX_BATCH_SIZE and is enforced the same way: when proposing, against the real encoding, and independently in acceptProposal, before parsing. That second check is the one that matters, for the reason the count check already gives -- the proposal is where a remote party decides how much work everybody else does. The chapter form needed the same limit early enough to be useful, which the exact answer cannot be: it needs the chapter built and the chunks cut, one line from opening a session. So ChapterProposal estimates it. Measured over chapters from 1 KiB to 24 KiB and 1 to 63 chunks, the per-event cost of an encoded proposal runs 357-388 bytes; the estimate pays 448, because over-estimating costs an author a shorter chapter and under-estimating costs the group a stalled session. A test asserts it never falls under the real encoding and stays within a tenth of it. The plan asked for a flat MAX_ORIGINAL_TEXT_BYTES and the measurement says that is wrong by half: a 63-chunk chapter fits about 20 KiB of markdown and a 10-chunk chapter about 30, because the envelopes are what varies. The chunk count is known as the text is typed, so the limit is too. While the screen was open, its last two hardcoded English literals moved to the catalogue, and the too-long message compares like with like -- what proposing would send against what a group message carries -- rather than a UTF-16 character count against a byte budget, which is what the first draft of it did. 944 tests, no failures; m3Audit's budgets all met. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -495,4 +495,10 @@
|
||||
<string name="choose_chapter_files">Choose chapter files</string>
|
||||
<string name="that_file_isn_t_text">That file isn't text. A chapter is markdown or plain text.</string>
|
||||
<string name="that_file_is_too_large">That file is too large to be a chapter.</string>
|
||||
<string name="this_group_has_no_shared_key_for_a_chapter">This group has no shared key, so it cannot sign a chapter into existence. Run a shared key ceremony first.</string>
|
||||
<string name="d_chunks_is_more_than_the_group_can_sign">%1$d chunks is more than the group can sign in one go. Split the chapter so that no part of it is over %2$d paragraphs.</string>
|
||||
<string name="this_chapter_is_too_long_to_propose">This chapter is too long to propose. Asking the group to sign it and its %1$d chunks would send %2$s, and a group message carries %3$s. Split it in two.</string>
|
||||
<string name="words_characters_size_chunks">%1$d words · %2$d characters · %3$s · %4$d of %5$d chunks</string>
|
||||
<string name="s_of_s">%1$s of %2$s</string>
|
||||
<string name="s_kib">%1$s KiB</string>
|
||||
</resources>
|
||||
|
||||
@@ -146,6 +146,29 @@ object FrostSigningManager {
|
||||
*/
|
||||
const val MAX_BATCH_SIZE: Int = 64
|
||||
|
||||
/**
|
||||
* The most JSON a proposal may carry.
|
||||
*
|
||||
* [MAX_BATCH_SIZE] bounds how many events a batch holds; this bounds how
|
||||
* large they are, and the two are not the same limit. Sixty-four dialects
|
||||
* are a few kilobytes and sixty-four chunks of a chapter are eighty, so a
|
||||
* count cap alone lets a perfectly legal batch exceed what the transport
|
||||
* carries -- and the proposal is the largest message in this protocol by an
|
||||
* order of magnitude: a signer's nonce payload is `k * 133` bytes of hex and
|
||||
* their partial `k * 65`, where the proposal carries `k` whole events.
|
||||
*
|
||||
* Deliberately the same number as `ChronicleEvent.MAX_PAGE_BYTES`, because
|
||||
* it is the same question asked twice -- what one kind:445 clears once the
|
||||
* MLS framing and the NIP-44 expansion outside it are paid for. The two are
|
||||
* not shared as one constant because neither module should depend on the
|
||||
* other for it, but they do have to be re-sized together, and whichever is
|
||||
* raised first should say so here.
|
||||
*
|
||||
* Before this existed the failure was silent: the proposal went out, the
|
||||
* group event was over size, and the session stalled with nothing to blame.
|
||||
*/
|
||||
const val MAX_PROPOSAL_BYTES: Int = 64 * 1024
|
||||
|
||||
/**
|
||||
* Opens a signing session, making this device the coordinator.
|
||||
*
|
||||
@@ -270,6 +293,16 @@ object FrostSigningManager {
|
||||
"A signing session will sign at most $MAX_BATCH_SIZE events, not ${unsignedEvents.size}"
|
||||
}
|
||||
|
||||
// Encoded once, here, so the size check is the real encoding rather than
|
||||
// an estimate of it -- and so the broadcast below sends the bytes that
|
||||
// were measured.
|
||||
val proposal = FrostSigningEvents.encodeProposal(unsignedEvents)
|
||||
val proposalBytes = proposal.encodeToByteArray().size
|
||||
|
||||
require(proposalBytes <= MAX_PROPOSAL_BYTES) {
|
||||
"A signing proposal carries at most $MAX_PROPOSAL_BYTES bytes, not $proposalBytes"
|
||||
}
|
||||
|
||||
val sessionId = RandomInstance.bytes(32).toHex()
|
||||
|
||||
val session = FrostSigningSession(
|
||||
@@ -299,7 +332,7 @@ object FrostSigningManager {
|
||||
localChatRoom = localChatRoom,
|
||||
session = session,
|
||||
kind = FrostSigningEvents.PROPOSAL,
|
||||
content = FrostSigningEvents.encodeProposal(unsignedEvents),
|
||||
content = proposal,
|
||||
includeKey = true
|
||||
)
|
||||
|
||||
@@ -406,6 +439,18 @@ object FrostSigningManager {
|
||||
return null
|
||||
}
|
||||
|
||||
// Before parsing, not after. This and the count check below are the two
|
||||
// places a remote party decides how much work this device does, and the
|
||||
// cheaper of the two refusals should come first.
|
||||
val proposalBytes = innerEvent.content.encodeToByteArray().size
|
||||
if (proposalBytes > MAX_PROPOSAL_BYTES) {
|
||||
logger.w(
|
||||
"Signing proposal $sessionId carries $proposalBytes bytes, more than the " +
|
||||
"$MAX_PROPOSAL_BYTES this device will accept; dropping"
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
val proposed = FrostSigningEvents.decodeProposal(innerEvent.content)
|
||||
if (proposed == null) {
|
||||
logger.w("Signing proposal $sessionId does not carry any events; dropping")
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package press.mantra.compose.nostr.nip30303
|
||||
|
||||
import press.mantra.compose.managers.FrostSigningManager
|
||||
import press.mantra.compose.text.Markdown
|
||||
|
||||
/**
|
||||
* What proposing a chapter will cost on the wire, close enough to say so while
|
||||
* it is being typed.
|
||||
*
|
||||
* `FrostSigningManager.MAX_PROPOSAL_BYTES` is the limit and the manager enforces
|
||||
* it exactly, by encoding the batch and measuring it. That answer arrives too
|
||||
* late to be useful: by then the chapter has been built, the chunks cut and the
|
||||
* session is one line from being opened, and the only thing left to do with the
|
||||
* number is throw. This is the same question answered cheaply enough to put in
|
||||
* front of somebody with a cursor still in the text field.
|
||||
*
|
||||
* **Why a chapter needs this at all.** A chapter travels with a chunk per
|
||||
* paragraph, and the chapter carries the whole markdown while the chunks carry
|
||||
* the same words again between them -- see `ChapterEvent.originalText` for why
|
||||
* that duplication is wanted. So a proposal is a little over twice the prose,
|
||||
* plus an envelope per event, and the limit arrives at about 20 KiB of markdown
|
||||
* at the maximum chunk count. That is inside the range of a real book chapter.
|
||||
*
|
||||
* **Why this is not a constant.** A flat `MAX_ORIGINAL_TEXT_BYTES` was the
|
||||
* obvious shape and is wrong by half. The envelopes are what varies: measured,
|
||||
* a 63-chunk chapter fits about 20 KiB of markdown and a 10-chunk chapter fits
|
||||
* about 30. A single number safe for the first refuses a third of the second's
|
||||
* text for no reason, and a single number sized for the second stalls sessions.
|
||||
* The count is known as the text is typed, so the limit may as well be.
|
||||
*/
|
||||
object ChapterProposal {
|
||||
|
||||
/**
|
||||
* What one unsigned event costs beside its own text.
|
||||
*
|
||||
* **Measured, not derived.** Across chapters from 1 KiB to 24 KiB and from 1
|
||||
* to 63 chunks, the per-event cost of an encoded proposal runs between 357
|
||||
* and 388 bytes: a 64-character id, a 64-character pubkey, an empty `sig`
|
||||
* (a proposal carries unsigned events, which is why this is smaller than a
|
||||
* signed event's ~370-byte floor), the JSON structure, the alt tag, and -- on
|
||||
* a chunk -- the 64-character chapter id it points at.
|
||||
*
|
||||
* Rounded up to 448 rather than to the measured maximum, and that direction
|
||||
* is the deliberate one. Over-estimating costs an author a slightly shorter
|
||||
* chapter; under-estimating costs the group a session that stalls with
|
||||
* nothing to blame. `ChapterProposalSizeTest` asserts the estimate stays at
|
||||
* or above the real encoding, so a tag added to either kind fails a test
|
||||
* rather than a quorum.
|
||||
*/
|
||||
const val EVENT_OVERHEAD_BYTES: Int = 448
|
||||
|
||||
/** The limit, which is the manager's and not this file's to choose. */
|
||||
const val MAX_PROPOSAL_BYTES: Int = FrostSigningManager.MAX_PROPOSAL_BYTES
|
||||
|
||||
/**
|
||||
* What a proposal for this chapter would weigh.
|
||||
*
|
||||
* Takes the chunks rather than re-splitting, because every caller has just
|
||||
* counted them for the other cap and because summing their text is exact
|
||||
* where doubling the chapter's would not be -- the blank lines between
|
||||
* paragraphs are in the chapter's text and in no chunk's.
|
||||
*/
|
||||
fun proposalBytes(originalText: String, chunks: List<String>): Int =
|
||||
originalText.utf8Size() +
|
||||
chunks.sumOf { it.utf8Size() } +
|
||||
(chunks.size + 1) * EVENT_OVERHEAD_BYTES
|
||||
|
||||
fun proposalBytes(originalText: String): Int =
|
||||
proposalBytes(originalText, Markdown.splitParagraphs(originalText))
|
||||
|
||||
fun fits(originalText: String, chunks: List<String>): Boolean =
|
||||
proposalBytes(originalText, chunks) <= MAX_PROPOSAL_BYTES
|
||||
|
||||
/**
|
||||
* The most markdown a chapter of this many chunks may carry.
|
||||
*
|
||||
* For the form, which has to say what the limit *is* and not only that it
|
||||
* has been passed. Inverts [proposalBytes]: the envelopes come off the top
|
||||
* and the prose is counted twice, so what is left is halved.
|
||||
*/
|
||||
fun maxOriginalTextBytes(chunkCount: Int): Int =
|
||||
((MAX_PROPOSAL_BYTES - (chunkCount + 1) * EVENT_OVERHEAD_BYTES) / 2)
|
||||
.coerceAtLeast(0)
|
||||
}
|
||||
|
||||
/**
|
||||
* The bytes a string takes as UTF-8, which is what the wire counts.
|
||||
*
|
||||
* Not `length`, which counts UTF-16 code units: the languages this app exists
|
||||
* for are full of characters that are one unit and three bytes, so measuring a
|
||||
* Setswana or Amharic chapter by its `length` would under-count it by half and
|
||||
* the cap would stop binding exactly where it matters most.
|
||||
*/
|
||||
private fun String.utf8Size(): Int = encodeToByteArray().size
|
||||
@@ -0,0 +1,24 @@
|
||||
package press.mantra.compose.text
|
||||
|
||||
/**
|
||||
* A byte count as the number part of "19.9 KiB".
|
||||
*
|
||||
* The unit itself is a string resource, so this returns only the figure.
|
||||
*
|
||||
* One decimal place, by integer arithmetic rather than through a float format.
|
||||
* Two reasons, and the first is the one that matters: the figures this renders
|
||||
* sit next to each other -- what a chapter weighs, and what the group can carry
|
||||
* -- and whole kibibytes would print a 20,473-byte chapter and its 21,226-byte
|
||||
* limit as the same "19 KiB", which reads as a form refusing something it says
|
||||
* is within the limit. The second is that there is no common-code decimal
|
||||
* formatter to reach for, and the three platforms' own would each punctuate it
|
||||
* differently.
|
||||
*
|
||||
* Truncating rather than rounding, for the same reason: a figure shown as under
|
||||
* the limit has to be under it.
|
||||
*/
|
||||
internal fun kibibytes(bytes: Int): String {
|
||||
val whole = bytes / 1024
|
||||
val tenths = (bytes % 1024) * 10 / 1024
|
||||
return "$whole.$tenths"
|
||||
}
|
||||
@@ -42,9 +42,11 @@ import press.mantra.compose.repository.ChatRepository
|
||||
import press.mantra.compose.repository.FrostSigningRepository
|
||||
import press.mantra.compose.repository.MantraRepository
|
||||
import press.mantra.compose.files.rememberFilePicker
|
||||
import press.mantra.compose.nostr.nip30303.ChapterProposal
|
||||
import press.mantra.compose.text.ChapterNaming
|
||||
import press.mantra.compose.text.ChapterText
|
||||
import press.mantra.compose.text.Markdown
|
||||
import press.mantra.compose.text.kibibytes
|
||||
import press.mantra.compose.ui.composable.navigation.routes.FrostSigningRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.ImplementationPendingRoute
|
||||
import press.mantra.compose.ui.composable.navigation.routes.Route
|
||||
@@ -64,6 +66,12 @@ import mantra.composeapp.generated.resources.propose_chapter
|
||||
import mantra.composeapp.generated.resources.this_artifact_has_no_version_for_a_chapter
|
||||
import mantra.composeapp.generated.resources.add_chapter_to
|
||||
import mantra.composeapp.generated.resources.choose_chapter_files
|
||||
import mantra.composeapp.generated.resources.d_chunks_is_more_than_the_group_can_sign
|
||||
import mantra.composeapp.generated.resources.s_kib
|
||||
import mantra.composeapp.generated.resources.s_of_s
|
||||
import mantra.composeapp.generated.resources.this_chapter_is_too_long_to_propose
|
||||
import mantra.composeapp.generated.resources.this_group_has_no_shared_key_for_a_chapter
|
||||
import mantra.composeapp.generated.resources.words_characters_size_chunks
|
||||
import mantra.composeapp.generated.resources.load_from_file
|
||||
import mantra.composeapp.generated.resources.that_file_is_too_large
|
||||
import mantra.composeapp.generated.resources.that_file_isn_t_text
|
||||
@@ -160,7 +168,8 @@ fun AddChapterScreen(
|
||||
val originalText = originalTextFieldState.text.toString()
|
||||
val wordCount = Markdown.wordCount(originalText)
|
||||
val characterCount = Markdown.characterCount(originalText)
|
||||
val paragraphCount = Markdown.splitParagraphs(originalText).size
|
||||
val chunks = Markdown.splitParagraphs(originalText)
|
||||
val paragraphCount = chunks.size
|
||||
|
||||
// A chapter hangs off a version, and the group has to be able to
|
||||
// sign; without both there is nothing this screen can propose.
|
||||
@@ -172,10 +181,19 @@ fun AddChapterScreen(
|
||||
// is still a cursor in the text rather than after a failed propose.
|
||||
val tooManyChunks = paragraphCount > AddChapterViewModel.MAX_CHUNKS_PER_CHAPTER
|
||||
|
||||
// The other cap, and the one that had been enforced nowhere: a
|
||||
// proposal carries the chapter whole and every chunk cut from
|
||||
// it, so it weighs a little over twice the prose plus an
|
||||
// envelope per event. Past what a group event holds, the session
|
||||
// used to go out and stall.
|
||||
val proposalBytes = ChapterProposal.proposalBytes(originalText, chunks)
|
||||
val tooLargeToPropose = !tooManyChunks && proposalBytes > ChapterProposal.MAX_PROPOSAL_BYTES
|
||||
|
||||
val canProposeChapter = artifactVersion != null &&
|
||||
addChapterUIState.canSign &&
|
||||
paragraphCount > 0 &&
|
||||
!tooManyChunks
|
||||
!tooManyChunks &&
|
||||
!tooLargeToPropose
|
||||
|
||||
// imePadding: this screen has a text field, and without it the software
|
||||
// keyboard covers whatever is being typed into.
|
||||
@@ -236,8 +254,7 @@ fun AddChapterScreen(
|
||||
) {
|
||||
if (!addChapterUIState.canSign) {
|
||||
Text(
|
||||
text = "This group has no shared key, so it cannot sign a " +
|
||||
"chapter into existence. Run a shared key ceremony first.",
|
||||
text = stringResource(Res.string.this_group_has_no_shared_key_for_a_chapter),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
@@ -249,9 +266,25 @@ fun AddChapterScreen(
|
||||
)
|
||||
} else if (tooManyChunks) {
|
||||
Text(
|
||||
text = "$paragraphCount chunks is more than the group can sign in " +
|
||||
"one go. Split the chapter so that no part of it is over " +
|
||||
"${AddChapterViewModel.MAX_CHUNKS_PER_CHAPTER} paragraphs.",
|
||||
text = stringResource(
|
||||
Res.string.d_chunks_is_more_than_the_group_can_sign,
|
||||
paragraphCount,
|
||||
AddChapterViewModel.MAX_CHUNKS_PER_CHAPTER,
|
||||
),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
} else if (tooLargeToPropose) {
|
||||
Text(
|
||||
text = stringResource(
|
||||
Res.string.this_chapter_is_too_long_to_propose,
|
||||
paragraphCount,
|
||||
stringResource(Res.string.s_kib, kibibytes(proposalBytes)),
|
||||
stringResource(
|
||||
Res.string.s_kib,
|
||||
kibibytes(ChapterProposal.MAX_PROPOSAL_BYTES),
|
||||
),
|
||||
),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error
|
||||
)
|
||||
@@ -287,14 +320,28 @@ fun AddChapterScreen(
|
||||
)
|
||||
|
||||
Text(
|
||||
// The chunk count is what the group is asked to sign
|
||||
// alongside the chapter, so it is a count of the work
|
||||
// being proposed rather than a curiosity about the text.
|
||||
text = "$wordCount words · $characterCount characters · " +
|
||||
"$paragraphCount ${if (paragraphCount == 1) "chunk" else "chunks"} " +
|
||||
"of ${AddChapterViewModel.MAX_CHUNKS_PER_CHAPTER}",
|
||||
// Both caps, as the text is typed. The chunk count and
|
||||
// the proposal's size are what the group is asked to
|
||||
// carry, so they are a measure of the work being
|
||||
// proposed rather than a curiosity about the text --
|
||||
// and either of them can be the one that binds.
|
||||
text = stringResource(
|
||||
Res.string.words_characters_size_chunks,
|
||||
wordCount,
|
||||
characterCount,
|
||||
stringResource(
|
||||
Res.string.s_of_s,
|
||||
stringResource(Res.string.s_kib, kibibytes(proposalBytes)),
|
||||
stringResource(
|
||||
Res.string.s_kib,
|
||||
kibibytes(ChapterProposal.MAX_PROPOSAL_BYTES),
|
||||
),
|
||||
),
|
||||
paragraphCount,
|
||||
AddChapterViewModel.MAX_CHUNKS_PER_CHAPTER,
|
||||
),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = if (tooManyChunks) {
|
||||
color = if (tooManyChunks || tooLargeToPropose) {
|
||||
MaterialTheme.colorScheme.error
|
||||
} else {
|
||||
MaterialTheme.typography.labelMedium.color
|
||||
|
||||
@@ -21,6 +21,7 @@ import press.mantra.compose.database.model.intermdiate.LocalChatRoom
|
||||
import press.mantra.compose.managers.FrostSigningManager
|
||||
import press.mantra.compose.managers.TranslationScaffold
|
||||
import press.mantra.compose.nostr.nip30303.ChapterEvent
|
||||
import press.mantra.compose.nostr.nip30303.ChapterProposal
|
||||
import press.mantra.compose.nostr.nip30303.ChunkEvent
|
||||
import press.mantra.compose.repository.ChatRepository
|
||||
import press.mantra.compose.repository.FrostSigningRepository
|
||||
@@ -102,10 +103,19 @@ class AddChapterViewModel(
|
||||
val originalText = originalTextField.text.toString()
|
||||
val paragraphs = Markdown.splitParagraphs(originalText)
|
||||
|
||||
// The cap is the group's, not this screen's, and proposing past it
|
||||
// throws rather than failing softly. The form says so as it is typed;
|
||||
// this is the check that has to hold when it does not.
|
||||
if (name.isBlank() || paragraphs.isEmpty() || paragraphs.size > MAX_CHUNKS_PER_CHAPTER) {
|
||||
// Both caps are the group's, not this screen's, and proposing past
|
||||
// either throws rather than failing softly. The form says so as it is
|
||||
// typed; these are the checks that have to hold when it does not.
|
||||
//
|
||||
// Two caps rather than one because they bound different things: how many
|
||||
// events a session signs, and how many bytes the proposal carrying them
|
||||
// weighs. A chapter of sixty-three one-word paragraphs passes the second
|
||||
// and fails the first; one of three very long ones does the reverse.
|
||||
if (name.isBlank() ||
|
||||
paragraphs.isEmpty() ||
|
||||
paragraphs.size > MAX_CHUNKS_PER_CHAPTER ||
|
||||
!ChapterProposal.fits(originalText, paragraphs)
|
||||
) {
|
||||
onFailure.invoke()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
package press.mantra.compose.nostr.nip30303
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
import press.mantra.compose.managers.FrostSigningManager
|
||||
import press.mantra.compose.nostr.frost.FrostSigningEvents
|
||||
import press.mantra.compose.text.Markdown
|
||||
|
||||
/**
|
||||
* [ChapterProposal] estimates what the manager measures, and the only property
|
||||
* worth asserting is that it never estimates low.
|
||||
*
|
||||
* The form reads the estimate as the text is typed; `proposeSigningBatch`
|
||||
* encodes the batch and measures it for real. If the estimate were ever under
|
||||
* the truth, the form would offer a chapter that the manager then refuses with
|
||||
* an `IllegalArgumentException` -- or, before that check existed, that went out
|
||||
* and stalled the session with nothing to blame.
|
||||
*
|
||||
* So these tests build the same events the manager would and compare. A tag
|
||||
* added to either kind fails here rather than in a room.
|
||||
*/
|
||||
class ChapterProposalSizeTest {
|
||||
private val groupKey = "a".repeat(64)
|
||||
private val artifactVersionId = "b".repeat(64)
|
||||
|
||||
private fun unsigned(template: EventTemplate<*>) = Event(
|
||||
id = EventHasher.hashId(
|
||||
pubKey = groupKey,
|
||||
createdAt = template.createdAt,
|
||||
kind = template.kind,
|
||||
tags = template.tags,
|
||||
content = template.content,
|
||||
),
|
||||
pubKey = groupKey,
|
||||
createdAt = template.createdAt,
|
||||
kind = template.kind,
|
||||
tags = template.tags,
|
||||
content = template.content,
|
||||
sig = "",
|
||||
)
|
||||
|
||||
/** The batch as `proposeSigningBatch` would build it: the chapter, then its chunks. */
|
||||
private fun batchFor(originalText: String, name: String = "Chapter 1"): List<Event> {
|
||||
val chapter = unsigned(
|
||||
ChapterEvent.build(
|
||||
artifactVersionId = artifactVersionId,
|
||||
name = name,
|
||||
originalText = originalText,
|
||||
index = 0,
|
||||
wordCount = Markdown.wordCount(originalText),
|
||||
characterCount = Markdown.characterCount(originalText),
|
||||
createdAt = 1_700_000_000,
|
||||
)
|
||||
)
|
||||
return listOf(chapter) + ChunkEvent.splitOf(chapter).map(::unsigned)
|
||||
}
|
||||
|
||||
private fun encodedBytes(originalText: String): Int =
|
||||
FrostSigningEvents.encodeProposal(batchFor(originalText)).encodeToByteArray().size
|
||||
|
||||
/** Markdown of roughly [bytes] bytes, in [paragraphs] paragraphs. */
|
||||
private fun markdownOf(bytes: Int, paragraphs: Int): String {
|
||||
val perParagraph = (bytes / paragraphs).coerceAtLeast(4)
|
||||
return (0 until paragraphs).joinToString("\n\n") { "w ".repeat(perParagraph / 2).trim() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the estimate is never under the real encoding`() {
|
||||
// The grid the overhead constant was measured over. Both ends matter: at
|
||||
// one chunk the text is counted twice exactly and the per-event cost is
|
||||
// at its highest, which is where an estimate is most likely to fall
|
||||
// short; at sixty-three the envelopes dominate.
|
||||
val shapes = listOf(1, 2, 5, 10, 20, 40, 63)
|
||||
val sizes = listOf(1, 4, 8, 16, 20, 24)
|
||||
|
||||
for (paragraphs in shapes) {
|
||||
for (kib in sizes) {
|
||||
val text = markdownOf(kib * 1024, paragraphs)
|
||||
val chunks = Markdown.splitParagraphs(text)
|
||||
val estimated = ChapterProposal.proposalBytes(text, chunks)
|
||||
val actual = encodedBytes(text)
|
||||
|
||||
assertTrue(
|
||||
estimated >= actual,
|
||||
"estimate $estimated under the real $actual for $paragraphs " +
|
||||
"paragraphs of ${kib} KiB",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the estimate is close enough to be worth showing`() {
|
||||
// Not merely conservative -- useful. An estimate twice the truth would
|
||||
// pass the test above and refuse half of every legal chapter.
|
||||
val text = markdownOf(16 * 1024, 40)
|
||||
val chunks = Markdown.splitParagraphs(text)
|
||||
|
||||
val estimated = ChapterProposal.proposalBytes(text, chunks)
|
||||
val actual = encodedBytes(text)
|
||||
|
||||
assertTrue(
|
||||
estimated <= actual + actual / 10,
|
||||
"estimate $estimated is more than a tenth over the real $actual",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a chapter the estimate accepts really does encode under the cap`() {
|
||||
// The property the form's offer rests on, stated as a round trip rather
|
||||
// than as arithmetic: find the longest text the estimate allows at the
|
||||
// worst chunk count, and check the manager would carry it.
|
||||
val chunkCount = AddChapterMaxChunks
|
||||
val allowance = ChapterProposal.maxOriginalTextBytes(chunkCount)
|
||||
val text = markdownOf(allowance, chunkCount)
|
||||
val chunks = Markdown.splitParagraphs(text)
|
||||
|
||||
assertEquals(chunkCount, chunks.size)
|
||||
assertTrue(ChapterProposal.fits(text, chunks))
|
||||
assertTrue(
|
||||
encodedBytes(text) <= FrostSigningManager.MAX_PROPOSAL_BYTES,
|
||||
"a chapter the form accepts encoded to ${encodedBytes(text)} bytes, over the cap",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the cap binds inside the range of a real chapter`() {
|
||||
// Why any of this exists. Before the check, a chapter of this size was
|
||||
// offered by the form, proposed, and stalled. 28 KiB over forty
|
||||
// paragraphs is a long chapter of a real book, not a pathological one --
|
||||
// and measured, the fit runs out at about 24 KiB for that shape.
|
||||
val text = markdownOf(28 * 1024, 40)
|
||||
val chunks = Markdown.splitParagraphs(text)
|
||||
|
||||
assertTrue(
|
||||
encodedBytes(text) > FrostSigningManager.MAX_PROPOSAL_BYTES,
|
||||
"expected ${encodedBytes(text)} bytes to exceed the cap",
|
||||
)
|
||||
assertTrue(!ChapterProposal.fits(text, chunks))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `where the fit runs out, for a chapter-shaped chapter`() {
|
||||
// Pinned so the number is on the record rather than in a comment, and so
|
||||
// that a change to either event's tags shows up as a moved boundary
|
||||
// instead of as a quorum that never arrives. Forty paragraphs is the
|
||||
// shape; the figure is the largest whole KiB of markdown that encodes
|
||||
// under the cap.
|
||||
val fitting = (1..64).last { kib ->
|
||||
encodedBytes(markdownOf(kib * 1024, 40)) <= FrostSigningManager.MAX_PROPOSAL_BYTES
|
||||
}
|
||||
|
||||
assertEquals(24, fitting)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an ordinary chapter is nowhere near either cap`() {
|
||||
// The other half of the claim: this is a limit on very long chapters,
|
||||
// not a tax on normal ones. Twelve paragraphs of a few hundred words.
|
||||
val text = markdownOf(8 * 1024, 12)
|
||||
val chunks = Markdown.splitParagraphs(text)
|
||||
|
||||
assertTrue(ChapterProposal.fits(text, chunks))
|
||||
assertTrue(encodedBytes(text) < FrostSigningManager.MAX_PROPOSAL_BYTES / 2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the text is measured in bytes, not in characters`() {
|
||||
// The languages this app exists for are full of characters that are one
|
||||
// UTF-16 unit and three bytes. Measuring by length would under-count a
|
||||
// Setswana or Amharic chapter and the cap would stop binding exactly
|
||||
// where it matters most.
|
||||
val ascii = "w ".repeat(512).trim()
|
||||
val amharic = "ሰ ".repeat(512).trim()
|
||||
|
||||
assertEquals(ascii.length, amharic.length)
|
||||
assertTrue(
|
||||
ChapterProposal.proposalBytes(amharic, listOf(amharic)) >
|
||||
ChapterProposal.proposalBytes(ascii, listOf(ascii)),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Named here rather than imported from the view model, so this file depends
|
||||
* on the protocol and not on a screen. Asserted equal below.
|
||||
*/
|
||||
private val AddChapterMaxChunks: Int get() = FrostSigningManager.MAX_BATCH_SIZE - 1
|
||||
|
||||
@Test
|
||||
fun `the chunk cap is one less than the batch, because the chapter takes a place`() {
|
||||
assertEquals(FrostSigningManager.MAX_BATCH_SIZE - 1, AddChapterMaxChunks)
|
||||
}
|
||||
}
|
||||
@@ -289,36 +289,50 @@ title; `03 - The Wreck.md` is.
|
||||
|
||||
**Half a day. A fix to the path that exists, and a prerequisite for Phase 4.**
|
||||
|
||||
A chapter whose markdown is over roughly 28 KiB cannot be proposed today and
|
||||
A chapter whose markdown is over roughly 24 KiB cannot be proposed today and
|
||||
nothing says so. Make the limit explicit and check it where the paragraph cap is
|
||||
already checked.
|
||||
|
||||
```kotlin
|
||||
// ChapterEvent companion, beside build()
|
||||
/**
|
||||
* The most markdown a chapter may carry.
|
||||
*
|
||||
* A chapter is proposed with a chunk per paragraph, so the proposal carries its
|
||||
* text roughly twice -- whole in the chapter's `original_text` tag, and again
|
||||
* split across the chunks -- before any framing. The group event that carries
|
||||
* the proposal is sized by ChronicleEvent.MAX_PAGE_BYTES; half of it, less room
|
||||
* for k event envelopes, is what is left for prose.
|
||||
*
|
||||
* Measure it against a real proposal rather than trusting this arithmetic, the
|
||||
* way MAX_PAGE_BYTES itself says to.
|
||||
*/
|
||||
const val MAX_ORIGINAL_TEXT_BYTES = 24 * 1024
|
||||
```
|
||||
**Built differently, in three ways, and the measurement is why.** Encoding real
|
||||
proposals gave a per-event overhead of 357–388 bytes across chapters from 1 KiB
|
||||
to 24 KiB and from 1 to 63 chunks — the plan's ~370 estimate, arrived at for the
|
||||
wrong reason: a proposal carries *unsigned* events, so there is no 128-character
|
||||
signature in it, and the figure is instead an id, a pubkey, an empty `sig`, the
|
||||
JSON structure and, on a chunk, the 64-character chapter id it points at.
|
||||
|
||||
24 KiB rather than 28: the envelopes are not free (an id, a pubkey and a
|
||||
signature are ~370 bytes before an event says anything, times `k`), and a cap
|
||||
that is comfortably under the wire limit fails in the form instead of on the
|
||||
wire. It should be arrived at by measuring an encoded proposal for a 63-chunk
|
||||
chapter, not by this paragraph.
|
||||
What that measurement changed:
|
||||
|
||||
**And the number is provisional until Phase 8 lands.** Block splitting produces
|
||||
more chunks than blank-line splitting, so the same prose carries more envelopes;
|
||||
whichever of the two phases goes second re-runs the measurement.
|
||||
**One. A flat `MAX_ORIGINAL_TEXT_BYTES` is wrong by half.** Measured, a
|
||||
63-chunk chapter fits about 20 KiB of markdown and a 10-chunk chapter fits about
|
||||
30, because the envelopes are what varies. A single constant safe for the first
|
||||
refuses a third of the second's text for nothing; one sized for the second
|
||||
stalls sessions. The chunk count is known as the text is typed, so the limit may
|
||||
as well be — `ChapterProposal.proposalBytes(text, chunks)` and
|
||||
`maxOriginalTextBytes(chunkCount)` instead of a constant.
|
||||
|
||||
**Two. The limit belongs to the manager, not to the chapter.**
|
||||
`FrostSigningManager.MAX_PROPOSAL_BYTES = 64 * 1024`, beside `MAX_BATCH_SIZE`,
|
||||
enforced in `proposeSigningBatch` against the real encoding and *independently*
|
||||
in `acceptProposal` — before parsing, since that is the cheaper refusal. The
|
||||
count cap already had exactly this two-sided treatment for exactly this reason:
|
||||
the proposal is where a remote party decides how much work everybody else does,
|
||||
and until now its *size* was unbounded. Both propose forms funnel into the
|
||||
`lead`/`dependents` one, so a single check covers them.
|
||||
|
||||
**Three. `ChapterProposal` estimates what the manager measures.** The exact
|
||||
answer needs the chapter built and the chunks cut, which is one line from
|
||||
opening a session — too late to tell somebody with a cursor in a text field.
|
||||
So: a cheap estimate for the form at 448 bytes per event, rounded up from the
|
||||
measured 388, and a test asserting the estimate never falls under the real
|
||||
encoding. Over-estimating costs an author a slightly shorter chapter;
|
||||
under-estimating costs the group a stalled session, so the rounding goes that
|
||||
way on purpose.
|
||||
|
||||
**The number is still provisional until Phase 8 lands.** Block splitting
|
||||
produces more chunks than blank-line splitting, so the same prose carries more
|
||||
envelopes; `ChapterProposalSizeTest` pins where the fit runs out (24 KiB over
|
||||
forty paragraphs), so that phase will move a pinned figure rather than quietly
|
||||
narrow the limit.
|
||||
|
||||
Where it goes:
|
||||
|
||||
@@ -326,8 +340,11 @@ Where it goes:
|
||||
`paragraphs.size > MAX_CHUNKS_PER_CHAPTER` guard, same early `onFailure`.
|
||||
- `AddChapterScreen` — the counter line already reads
|
||||
`"$wordCount words · $characterCount characters · $paragraphCount chunks of N"`;
|
||||
it gains the byte figure against the cap and turns `error`-coloured on the
|
||||
same rule the chunk count does.
|
||||
it gains the proposal's size against the cap and turns `error`-coloured on the
|
||||
same rule the chunk count does. The message compares like with like — what
|
||||
proposing would send, against what a group message carries — rather than a
|
||||
character count against a byte budget, which was the first draft of it and was
|
||||
comparing UTF-16 units to bytes.
|
||||
- The two existing hardcoded English literals on that screen (the no-shared-key
|
||||
message and the too-many-chunks message) move into
|
||||
`composeApp/src/commonMain/composeResources/values/strings.xml` while the file
|
||||
@@ -335,13 +352,15 @@ Where it goes:
|
||||
title-case strings with literals reported; a third one added by this work
|
||||
would be the wrong direction.
|
||||
|
||||
**Test:** extend `ChapterBatchProposalJvmTest`, which already asserts the
|
||||
paragraph cap's boundary (`MAX_BATCH_SIZE - 1` proposes, `MAX_BATCH_SIZE`
|
||||
throws). Add the byte boundary the same way: one chapter at the cap proposes,
|
||||
one a byte over does not. And one that measures the *encoded proposal* for a
|
||||
cap-sized, 63-chunk chapter and asserts it is under `MAX_PAGE_BYTES` — that is
|
||||
the assertion that makes the constant honest, and the only one that will catch
|
||||
it drifting when a tag is added to either kind.
|
||||
**Tests:** a `ChapterProposalSizeTest` in `commonTest` — pure, since the
|
||||
estimate and the encoding are both pure — over the grid the overhead was
|
||||
measured on, asserting the estimate never falls under the real encoding and
|
||||
stays within a tenth of it. Plus the round trip the form's offer rests on: the
|
||||
longest text the estimate allows at the worst chunk count really does encode
|
||||
under the cap. Plus the boundary pinned as a number, so a tag added to either
|
||||
kind moves a figure in a test rather than producing a quorum that never
|
||||
arrives. And the byte-vs-character one, in Amharic, because an ASCII-only test
|
||||
suite would never have caught the UTF-16 slip above.
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user