From fc75c7fe4a1a54de829cfb0f54cd6fa286df616c Mon Sep 17 00:00:00 2001 From: Jacob Fu <141651335+FuJacob@users.noreply.github.com> Date: Mon, 1 Jun 2026 00:39:56 -0700 Subject: [PATCH] Budget the base prompt by section so context can't starve the caret text Adds PromptSectionBudget, a pure character-budget allocator, and rewrites BaseCompletionPromptRenderer to build prioritized sections (persona, style, language, notes, clipboard, screen, and the caret prefix) allocated within a total budget. The caret prefix gets top priority, a guaranteed minimum, and preserve-end truncation, so a large glossary/clipboard/screen capture can never crowd it out. Also fixes a framing bug: custom rules now render as their own 'Writing style: ...' line instead of being jammed into 'in a style', which produced broken prose like 'in a Use British spelling style' for sentence-shaped rules. Sets up the OCR and writing-history context sections. --- Cotabby.xcodeproj/project.pbxproj | 8 ++ .../BaseCompletionPromptRenderer.swift | 135 ++++++++++-------- Cotabby/Support/PromptSectionBudget.swift | 84 +++++++++++ .../BaseCompletionPromptRendererTests.swift | 2 +- CotabbyTests/PromptSectionBudgetTests.swift | 81 +++++++++++ 5 files changed, 247 insertions(+), 63 deletions(-) create mode 100644 Cotabby/Support/PromptSectionBudget.swift create mode 100644 CotabbyTests/PromptSectionBudgetTests.swift diff --git a/Cotabby.xcodeproj/project.pbxproj b/Cotabby.xcodeproj/project.pbxproj index 601cf7b..9ba3f66 100644 --- a/Cotabby.xcodeproj/project.pbxproj +++ b/Cotabby.xcodeproj/project.pbxproj @@ -72,6 +72,7 @@ 3B3E08D1204E85F3776D8853 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = BAADA69C6172DD7F4A642E93 /* Sparkle */; }; 3B5F96F9CC6D4D81B470DB2C /* EmojiSynonymCatalogTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF474064973F4752F79BB041 /* EmojiSynonymCatalogTests.swift */; }; 3C23336EE6F6559857DE92EE /* SuggestionDebugLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 003594B09C83EF2DF35577D5 /* SuggestionDebugLogger.swift */; }; + 3C561CD717064F9250200667 /* PromptSectionBudget.swift in Sources */ = {isa = PBXBuildFile; fileRef = AFCFCCCB69C29A86E726B10A /* PromptSectionBudget.swift */; }; 3CBBC3BFAC0DC8952EE24EF7 /* BundledRuntimeLocator.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA33F5FFAC5B99384E15CE3E /* BundledRuntimeLocator.swift */; }; 3CF1A4E39F24917DF0470A7D /* PromptPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4696A84D17890B154533A08F /* PromptPolicyTests.swift */; }; 4134ADBE464D00BB748BD9AE /* GeneralPaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07480CE96ED0EBD94817C6B1 /* GeneralPaneView.swift */; }; @@ -136,6 +137,7 @@ 7D6BB9AF72F7076A4E5EE96F /* DownloadableModelCatalogView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB5C2AE9A7E55495D26AD074 /* DownloadableModelCatalogView.swift */; }; 7E9413CE7C999C4612348248 /* SuggestionSessionReconcilerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C8F07AC52C7A482F5FE34C5 /* SuggestionSessionReconcilerTests.swift */; }; 7E99F5676A1D1DF7EA7D7702 /* SuggestionCoordinator+Prediction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1ED1EA9282E0AC7592E60889 /* SuggestionCoordinator+Prediction.swift */; }; + 7EB20783E0D36715D1230A5C /* PromptSectionBudgetTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E260C4D08C786CDBD527B329 /* PromptSectionBudgetTests.swift */; }; 7FC103944F4EF39DB965F469 /* InMemoryLogging in Frameworks */ = {isa = PBXBuildFile; productRef = 88921938DC814625ED57D605 /* InMemoryLogging */; }; 814E348C663B697537594F0C /* EmojiRecentsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 671689F289D45A124639C9C6 /* EmojiRecentsTests.swift */; }; 82D4ADEAF05337ABDE4C586C /* RuntimeBootstrapModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60629DFE309C1A4BD8A7FB3B /* RuntimeBootstrapModel.swift */; }; @@ -418,6 +420,7 @@ AD9573F3504CAE6891DF9B7D /* AppUpdateManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppUpdateManager.swift; sourceTree = ""; }; ADBE3E6CC585C1683787C877 /* SuggestionEngineModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionEngineModels.swift; sourceTree = ""; }; AF1E065C7FFB697FCEB2FA5C /* CotabbyTestFixtures.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CotabbyTestFixtures.swift; sourceTree = ""; }; + AFCFCCCB69C29A86E726B10A /* PromptSectionBudget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PromptSectionBudget.swift; sourceTree = ""; }; B2BFD19A159680A495EE02FD /* ScreenshotContextGeneratorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenshotContextGeneratorTests.swift; sourceTree = ""; }; B2F95847D76893C8A5B504B4 /* SuggestionOverlayStabilityGate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionOverlayStabilityGate.swift; sourceTree = ""; }; B424E2AC97C99D335B0D5751 /* SuggestionTextNormalizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionTextNormalizer.swift; sourceTree = ""; }; @@ -472,6 +475,7 @@ E19A5B462891263BDFB56607 /* TrailingDuplicationFilterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrailingDuplicationFilterTests.swift; sourceTree = ""; }; E1D2782B6C7BE3F56BCB22DE /* LlamaVisualContextSummarizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LlamaVisualContextSummarizer.swift; sourceTree = ""; }; E217A66717D78E1E49350EC8 /* DownloadOutcomeClassifierTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadOutcomeClassifierTests.swift; sourceTree = ""; }; + E260C4D08C786CDBD527B329 /* PromptSectionBudgetTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PromptSectionBudgetTests.swift; sourceTree = ""; }; E27B962C66727776D00069DE /* EmojiPopularity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPopularity.swift; sourceTree = ""; }; E3C84377F352140759B448C9 /* CaretGeometrySelector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaretGeometrySelector.swift; sourceTree = ""; }; E43E587E421AF544A8300CE4 /* CustomRulesCatalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomRulesCatalog.swift; sourceTree = ""; }; @@ -763,6 +767,7 @@ 12DD19BCE610808F1E38702D /* PermissionOverlayTrackerTests.swift */, 0D80CC2CCAAFE3F23FB8C37A /* PromptContextSanitizerTests.swift */, 4696A84D17890B154533A08F /* PromptPolicyTests.swift */, + E260C4D08C786CDBD527B329 /* PromptSectionBudgetTests.swift */, B2BFD19A159680A495EE02FD /* ScreenshotContextGeneratorTests.swift */, 2D7360A6D4261989A66658ED /* SentenceBoundaryClassifierTests.swift */, 2BC293F6125E2B14DCF05AD9 /* SettingsAttentionEvaluatorTests.swift */, @@ -913,6 +918,7 @@ FA878B447441BB4F3E327CC8 /* OnboardingTemplateRecommender.swift */, E6423D6CC8CC371D2DA899DE /* PermissionOverlayTracker.swift */, FA4B45B91D4DEAC979C3113E /* PromptContextSanitizer.swift */, + AFCFCCCB69C29A86E726B10A /* PromptSectionBudget.swift */, 6DC693E00430F46E41CB56E6 /* RequestID.swift */, D4B56C250DDEF3E81F9DCBD7 /* SentenceBoundaryClassifier.swift */, 2A02336442BB735EE2E8D064 /* SettingsAttentionEvaluator.swift */, @@ -1163,6 +1169,7 @@ 90DC9508F27F712EB61EEB06 /* PermissionReminderView.swift in Sources */, 39571AB31481959CD5C223AE /* PermissionsPaneView.swift in Sources */, 98E2E14A069384C1088CDB44 /* PromptContextSanitizer.swift in Sources */, + 3C561CD717064F9250200667 /* PromptSectionBudget.swift in Sources */, A5A6CE0EF01CA6A9AFA7A400 /* RequestID.swift in Sources */, 82D4ADEAF05337ABDE4C586C /* RuntimeBootstrapModel.swift in Sources */, 2C6159231472A849F15BD0AE /* ScreenFrameReader.swift in Sources */, @@ -1271,6 +1278,7 @@ 4F38CE1C2602CF4F41323032 /* PermissionOverlayTrackerTests.swift in Sources */, 934885ACC2DEA20B27F10948 /* PromptContextSanitizerTests.swift in Sources */, 3CF1A4E39F24917DF0470A7D /* PromptPolicyTests.swift in Sources */, + 7EB20783E0D36715D1230A5C /* PromptSectionBudgetTests.swift in Sources */, 1B3FFCB9A979F49BF86EAAD4 /* ScreenshotContextGeneratorTests.swift in Sources */, 1D1C6FF0B8F50AC14A1000F4 /* SentenceBoundaryClassifierTests.swift in Sources */, C618C5595DA9C57C806A3E03 /* SettingsAttentionEvaluatorTests.swift in Sources */, diff --git a/Cotabby/Support/BaseCompletionPromptRenderer.swift b/Cotabby/Support/BaseCompletionPromptRenderer.swift index a766556..d7c6cbf 100644 --- a/Cotabby/Support/BaseCompletionPromptRenderer.swift +++ b/Cotabby/Support/BaseCompletionPromptRenderer.swift @@ -5,15 +5,19 @@ import Foundation /// /// Design: a *base* model has no instruction-following channel and will happily continue a bare /// "Task:" line as if it were the document, so an instruction-blob prompt would leak scaffolding into -/// the ghost text. This renderer instead treats the model as a pure text continuer: +/// the ghost text. This renderer treats the model as a pure text continuer: persona, style, language, +/// and supporting context are folded into a short conditioning preface (a base model conditions on +/// description, it does not obey commands), and the caret prefix is the LAST thing in the prompt with +/// trailing whitespace trimmed so generation begins at a clean word boundary. /// -/// - No task preamble and no standalone `Label:` lines. -/// - Custom instructions work by *conditioning*, not obedience: persona, voice, and language are -/// folded into a short framing sentence that makes the desired continuation the most likely one. -/// - Supporting context (notes/screen/clipboard) is included as compact prose ahead of the prefix. -/// - The single invariant that locates the caret is that `prefixText` is the LAST thing in the -/// prompt, with trailing whitespace trimmed so generation begins at a clean word boundary. +/// Sections are character-budgeted via `PromptSectionBudget` so a large glossary, clipboard, or +/// screen capture can never crowd out the caret text: the prefix gets top priority and a guaranteed +/// minimum, and context fills the remaining budget by priority. enum BaseCompletionPromptRenderer { + /// Total character budget for the preface plus caret prefix. The prefix arrives already windowed + /// by `SuggestionRequestFactory`, so this mainly caps how much optional context rides along. + static let defaultContextBudget = 2400 + static func prompt( prefixText: String, applicationName: String, @@ -22,80 +26,87 @@ enum BaseCompletionPromptRenderer { extendedContext: String? = nil, languageInstruction: String? = nil, clipboardContext: String? = nil, - visualContextSummary: String? = nil + visualContextSummary: String? = nil, + contextBudget: Int = defaultContextBudget ) -> String { - var preface: [String] = [] + let trimmedPrefix = Self.trimmingTrailingWhitespace(prefixText) - // Persona/voice/language framing, phrased as a description of the document rather than a - // command, because a base model conditions on description but ignores instructions. Emitted - // only when the user supplied something, so a bare field stays pure continuation (the - // strongest base-model setup). `applicationName` is intentionally not stated as a label here; - // app/window metadata biases a base model toward code/numbers over prose. - if let framing = authorFraming( - userName: userName, - customRules: customRules, - languageInstruction: languageInstruction - ) { - preface.append(framing) + var sections: [PromptSection] = [] + if let persona = Self.personaLine(userName) { + sections.append(Self.contextSection("persona", persona, priority: 60, maxChars: 200)) } - - // Free-form reference notes (glossary/terminology) ahead of the prefix so the user's terms - // become likelier continuations through in-context conditioning. - if let extendedContext, !extendedContext.isEmpty { - preface.append("Notes the writer keeps in mind: \(extendedContext)") + if let style = Self.styleLine(customRules) { + sections.append(Self.contextSection("style", style, priority: 55, maxChars: 300)) + } + if let language = Self.nonEmpty(languageInstruction) { + sections.append(Self.contextSection("language", language, priority: 50, maxChars: 300)) + } + if let notes = Self.nonEmpty(extendedContext) { + sections.append(Self.contextSection("notes", "Notes the writer keeps in mind: \(notes)", priority: 40, maxChars: 600)) } - if let visualContextSummary, !visualContextSummary.isEmpty { - preface.append("Nearby on screen: \(visualContextSummary)") + if let clip = Self.nonEmpty(clipboardContext) { + sections.append(Self.contextSection("clipboard", "On the clipboard: \(clip)", priority: 35, maxChars: 400)) } - if let clipboardContext, !clipboardContext.isEmpty { - preface.append("On the clipboard: \(clipboardContext)") + if let screen = Self.nonEmpty(visualContextSummary) { + sections.append(Self.contextSection("screen", "Nearby on screen: \(screen)", priority: 30, maxChars: 500)) } + // The caret prefix: top priority so it is never starved, kept by its END (the text nearest + // the caret), and rendered last with no label so the model continues from where the user + // stopped. `applicationName` is intentionally not stated; app/window metadata biases a base + // model toward code/numbers over prose. + sections.append( + PromptSection( + name: "prefix", + content: trimmedPrefix, + priority: 100, + minChars: 1, + maxChars: max(1, trimmedPrefix.count), + truncation: .preserveEnd + ) + ) - // Trailing whitespace is trimmed so the model continues from a clean word boundary instead of - // being asked to emit a leading-space token (which base models do poorly). A prefix ending - // mid-word keeps its final partial word, so mid-word continuation still works. Output spacing - // is reconciled downstream by `SuggestionTextNormalizer`. - let trimmedPrefix = Self.trimmingTrailingWhitespace(prefixText) + let kept = PromptSectionBudget.allocate(sections, totalChars: contextBudget) + let prefix = kept.first { $0.name == "prefix" }?.content ?? trimmedPrefix + let preface = kept.filter { $0.name != "prefix" }.map(\.content) guard !preface.isEmpty else { // No context to condition on: hand the model the bare text and let it continue. - return trimmedPrefix + return prefix } - // A blank line separates the conditioning preface from the live text without a label the // model could copy. The prefix remains the final bytes of the prompt. - return preface.joined(separator: "\n") + "\n\n" + trimmedPrefix + return preface.joined(separator: "\n") + "\n\n" + prefix } - /// Builds the conditioning sentence from persona/style/language, or nil when none were supplied. - private static func authorFraming( - userName: String?, - customRules: [String], - languageInstruction: String? - ) -> String? { - let name = (userName ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + private static func contextSection( + _ name: String, + _ content: String, + priority: Int, + maxChars: Int + ) -> PromptSection { + PromptSection(name: name, content: content, priority: priority, minChars: 0, maxChars: maxChars, truncation: .preserveStart) + } + + /// "Written by ." or nil. Conditions the voice via authorship framing. + private static func personaLine(_ userName: String?) -> String? { + guard let name = Self.nonEmpty(userName) else { return nil } + return "Written by \(name)." + } + + /// "Writing style: ." or nil. Rendered as its own line rather than jammed into an + /// "in a style" clause, so multi-word and sentence-shaped rules read correctly and + /// condition cleanly (the old clause produced broken prose like "in a Use British spelling style"). + private static func styleLine(_ customRules: [String]) -> String? { let rules = customRules .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { !$0.isEmpty } - let language = (languageInstruction ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - - guard !name.isEmpty || !rules.isEmpty || !language.isEmpty else { - return nil - } + guard !rules.isEmpty else { return nil } + return "Writing style: \(rules.joined(separator: ", "))." + } - var sentence = "The following is text" - if !name.isEmpty { - sentence += " written by \(name)" - } - if !rules.isEmpty { - sentence += " in a \(rules.joined(separator: ", ")) style" - } - sentence += "." - if !language.isEmpty { - // `languageInstruction` is already a soft directive sentence; append it verbatim. - sentence += " \(language)" - } - return sentence + private static func nonEmpty(_ text: String?) -> String? { + let trimmed = (text ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed } /// Drops trailing spaces, tabs, and newlines so the base-model prompt ends at a word boundary. diff --git a/Cotabby/Support/PromptSectionBudget.swift b/Cotabby/Support/PromptSectionBudget.swift new file mode 100644 index 0000000..0bea6e0 --- /dev/null +++ b/Cotabby/Support/PromptSectionBudget.swift @@ -0,0 +1,84 @@ +import Foundation + +/// File overview: +/// Pure character-budget allocator for the base-model prompt. +/// +/// Why this exists: +/// Once the base prompt carries optional context (a glossary, clipboard, screen OCR, the text after +/// the caret), an unbounded concatenation can crowd out the one thing that actually matters, the +/// caret text the model must continue, or blow the model's context window. This allocator lets each +/// section declare a priority and a min/max character budget; `allocate` fills sections +/// highest-priority-first within a total budget and truncates each to fit, so the caret text (given +/// the top priority and a guaranteed minimum) is never starved by a noisy screen capture. +/// +/// Character-based, not tokenizer-based, on purpose: it keeps this layer pure and deterministic for +/// tests and free of a runtime dependency. It is a safe approximation (roughly 4 chars per token) +/// and can be swapped for a real token count later without changing the section contract. +struct PromptSection: Equatable, Sendable { + /// Which end of the content to keep when it must be shortened. `beforeCursor` keeps its END + /// (the text nearest the caret); `afterCursor` keeps its START (the text nearest the caret). + enum Truncation: Equatable, Sendable { + case preserveStart + case preserveEnd + } + + let name: String + var content: String + /// Higher priority is filled (and kept) first when the budget is tight. + let priority: Int + /// If the remaining budget can't fit at least this many characters, the section is dropped + /// rather than included as a uselessly-tiny fragment. Use 0 to mean "include whatever fits". + let minChars: Int + let maxChars: Int + let truncation: Truncation +} + +enum PromptSectionBudget { + /// Fills sections by priority (descending, ties broken by original order for determinism) within + /// `totalChars`. Each section is capped at `min(maxChars, contentLength, remainingBudget)`, gets + /// dropped if that is below its `minChars`, and gets dropped if it trims to empty. Surviving + /// sections are returned in their ORIGINAL order so the caller keeps control of render order + /// independently of fill priority. + static func allocate(_ sections: [PromptSection], totalChars: Int) -> [PromptSection] { + var remaining = max(0, totalChars) + + let fillOrder = sections.enumerated().sorted { lhs, rhs in + lhs.element.priority == rhs.element.priority + ? lhs.offset < rhs.offset + : lhs.element.priority > rhs.element.priority + } + + var kept: [Int: PromptSection] = [:] + for (index, section) in fillOrder { + guard remaining > 0 else { break } + let cap = min(section.maxChars, section.content.count, remaining) + if cap < section.minChars { + continue + } + let truncated = truncate(section.content, toChars: cap, mode: section.truncation) + .trimmingCharacters(in: .whitespacesAndNewlines) + guard !truncated.isEmpty else { + continue + } + var copy = section + copy.content = truncated + kept[index] = copy + remaining -= truncated.count + } + + return sections.indices.compactMap { kept[$0] } + } + + /// Truncates `text` to at most `chars`, keeping the start or the end per `mode`. Returns the + /// input unchanged when it already fits, and the empty string when `chars <= 0`. + static func truncate(_ text: String, toChars chars: Int, mode: PromptSection.Truncation) -> String { + guard chars > 0 else { return "" } + guard text.count > chars else { return text } + switch mode { + case .preserveStart: + return String(text.prefix(chars)) + case .preserveEnd: + return String(text.suffix(chars)) + } + } +} diff --git a/CotabbyTests/BaseCompletionPromptRendererTests.swift b/CotabbyTests/BaseCompletionPromptRendererTests.swift index bf3ad05..89a6a44 100644 --- a/CotabbyTests/BaseCompletionPromptRendererTests.swift +++ b/CotabbyTests/BaseCompletionPromptRendererTests.swift @@ -50,7 +50,7 @@ final class BaseCompletionPromptRendererTests: XCTestCase { customRules: ["friendly", "professional"], languageInstruction: "Write in English." ) - XCTAssertTrue(prompt.contains("written by Jacob")) + XCTAssertTrue(prompt.contains("Written by Jacob")) XCTAssertTrue(prompt.contains("friendly, professional")) XCTAssertTrue(prompt.contains("Write in English.")) XCTAssertTrue(prompt.hasSuffix("Hi team,")) diff --git a/CotabbyTests/PromptSectionBudgetTests.swift b/CotabbyTests/PromptSectionBudgetTests.swift new file mode 100644 index 0000000..899ac3d --- /dev/null +++ b/CotabbyTests/PromptSectionBudgetTests.swift @@ -0,0 +1,81 @@ +import XCTest +@testable import Cotabby + +/// Pure-function tests for the prompt character-budget allocator: priority fill, total-budget +/// respect, per-section truncation, min-char drop, and render-order preservation. +final class PromptSectionBudgetTests: XCTestCase { + + private func section( + _ name: String, + _ content: String, + priority: Int, + min: Int = 0, + max: Int = 10_000, + _ trunc: PromptSection.Truncation = .preserveStart + ) -> PromptSection { + PromptSection(name: name, content: content, priority: priority, minChars: min, maxChars: max, truncation: trunc) + } + + func test_allocate_keepsAllWhenBudgetAmple() { + let kept = PromptSectionBudget.allocate( + [section("a", "alpha", priority: 10), section("b", "beta", priority: 5)], + totalChars: 1000 + ) + XCTAssertEqual(kept.map(\.name), ["a", "b"]) + } + + func test_allocate_dropsLowerPriorityWhenBudgetTight() { + let kept = PromptSectionBudget.allocate( + [section("low", "xxxxxxxx", priority: 1), section("high", "yyyyyyyy", priority: 9)], + totalChars: 8 + ) + XCTAssertEqual(kept.map(\.name), ["high"]) + } + + func test_allocate_preservesInputOrderNotPriorityOrder() { + let kept = PromptSectionBudget.allocate( + [section("first", "aa", priority: 1), section("second", "bb", priority: 9)], + totalChars: 1000 + ) + XCTAssertEqual(kept.map(\.name), ["first", "second"]) + } + + func test_allocate_respectsTotalBudget() { + let kept = PromptSectionBudget.allocate( + [ + section("a", String(repeating: "a", count: 100), priority: 9), + section("b", String(repeating: "b", count: 100), priority: 8) + ], + totalChars: 120 + ) + XCTAssertLessThanOrEqual(kept.reduce(0) { $0 + $1.content.count }, 120) + } + + func test_allocate_dropsSectionThatCannotMeetMinChars() { + let kept = PromptSectionBudget.allocate( + [section("big", String(repeating: "x", count: 50), priority: 9, min: 30)], + totalChars: 20 + ) + XCTAssertTrue(kept.isEmpty) + } + + func test_allocate_dropsWhitespaceOnlyContent() { + let kept = PromptSectionBudget.allocate( + [section("blank", " ", priority: 9), section("real", "hello", priority: 8)], + totalChars: 1000 + ) + XCTAssertEqual(kept.map(\.name), ["real"]) + } + + func test_truncate_preserveEndKeepsCaretSide() { + XCTAssertEqual(PromptSectionBudget.truncate("abcdefgh", toChars: 3, mode: .preserveEnd), "fgh") + } + + func test_truncate_preserveStartKeepsHead() { + XCTAssertEqual(PromptSectionBudget.truncate("abcdefgh", toChars: 3, mode: .preserveStart), "abc") + } + + func test_truncate_returnsInputWhenItFits() { + XCTAssertEqual(PromptSectionBudget.truncate("abc", toChars: 10, mode: .preserveEnd), "abc") + } +}