diff --git a/CHANGELOG.md b/CHANGELOG.md index 7060801..614a44d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ release's notes are that section. Versions are three-part semantic versions, `MAJOR.MINOR.PATCH`, and every editor plugin carries the product version unchanged. +## [0.0.11] — 2026-10-04 + +### Completion + +- **Completion comes up in files that import modules.** On such a file clangd 23.1 answers every + completion in 0.95–1.05 s even when the BMIs are long built and cached (upstream defect UP-25, + issue #24) — just past the 1 s answer budget, which cancelled the engine's answer at the line and + answered with the fallback instead; after `.`, `->` or `::` the fallback is empty by design, so + the popup sometimes never came at all. The budget now follows the file: where the core engine + keeps answering just past the budget, completion and signature help wait up to 2.5 s for those + answers instead of cancelling them — and where the engine answers rarely or quickly (a broken + module rebuilding, a fan-out save, an ordinary file), the budget, the fallback and the + user-experience scenarios calibrated on them stay exactly as they were. + ## [0.0.10] — 2026-10-03 A workspace's module cache grew without bound when clangd kept crashing: every crash left its diff --git a/docs/50-troubleshooting.md b/docs/50-troubleshooting.md index 3c8bf89..a7f697c 100644 --- a/docs/50-troubleshooting.md +++ b/docs/50-troubleshooting.md @@ -171,7 +171,9 @@ it is always `producer`, the fingerprint is not matching — the report's `proje build files' timestamps are where to look. **Completion shows only words from the file, or hover says modules are being prepared.** A request -has a budget for clangd — completion and signature help 1 s, hover 2 s, go-to-definition 10 s — and +has a budget for clangd — completion and signature help 1 s, and up to 2.5 s for a file whose +completions clangd keeps answering just past that (a module importer on clangd 23.1, upstream defect +UP-25), so those answers are not cancelled at the line — hover 2 s, go-to-definition 10 s — and past it mcppls answers with what it has. Completion is then the words of the file nearest the cursor, an incomplete list, so the editor asks again as you type; hover, while modules are being prepared, is a line saying so. It is clangd being busy with modules, not a failure. From 0.0.8, diff --git a/docs/zh-CN/50-troubleshooting.md b/docs/zh-CN/50-troubleshooting.md index c4b005c..ed14a1a 100644 --- a/docs/zh-CN/50-troubleshooting.md +++ b/docs/zh-CN/50-troubleshooting.md @@ -77,7 +77,7 @@ mcppls report --bundle problem.zip --root path/to/project # 可加 --hide-proj **每次启动都很慢。** 第二次会话应该很快:模型连同构建工具所读一切内容的指纹一起被缓存,与之匹配的会话会立即套用计划,并在后台确认;已经构建好的模块会复用,不会重建(0.0.6 及更早版本在热启动时会把每个模块都重建一遍,issue #30)。`project.firstOrigin` 会说明发生了哪种情况。如果它一直是 `producer`,说明指纹没有匹配上——该看报告里的 `project.producerRun` 和构建文件的时间戳。 -**补全只有文件里的词,或悬停提示说正在准备模块。** 每种请求给 clangd 的都有预算——补全和签名帮助 1 秒,悬停 2 秒,跳转到定义 10 秒——超过之后 mcppls 用手上有的东西作答。补全这时给出的是文件里离光标最近的那些词,是一份不完整的列表,所以你继续输入时编辑器会再问一次;悬停在模块准备期间给出的是一行说明。这是 clangd 正忙着处理模块,不是故障。从 0.0.8 起,clangd 迟到的补全不再丢弃:它最多再算 10 秒,你在同一个词里继续输入时发出的请求都等它,一到就交给它们,所以在 clangd 重建得慢的文件里,列表仍会在你打完这个词之前出现。报告里的 `requests..answeredBy` 按方法统计了各由哪个引擎作答;`engineP50Ms`、`engineP95Ms` 是引擎作答的那些请求在引擎里花的时间,`overheadP50Ms`、`overheadP95Ms` 是 mcppls 在其外加的时间,由此看出一次慢的补全慢在谁;`completion.late` 统计 clangd 迟到的答案和用上它们的请求;`slowestFiles` 列出最慢的十个文件,以及每个文件有多少次补全只拿到了词;`engines[].details.buildTimes` 说明 clangd 构建每个文件花在哪里(preamble、导入的模块、AST 构建次数)。 +**补全只有文件里的词,或悬停提示说正在准备模块。** 每种请求给 clangd 的都有预算——补全和签名帮助 1 秒,若某文件的补全 clangd 总是刚好超过 1 秒才回答(clangd 23.1 的模块导入方,上游缺陷 UP-25),预算最多放宽到 2.5 秒,让这些答案不会正好在预算线上被取消——悬停 2 秒,跳转到定义 10 秒——超过之后 mcppls 用手上有的东西作答。补全这时给出的是文件里离光标最近的那些词,是一份不完整的列表,所以你继续输入时编辑器会再问一次;悬停在模块准备期间给出的是一行说明。这是 clangd 正忙着处理模块,不是故障。从 0.0.8 起,clangd 迟到的补全不再丢弃:它最多再算 10 秒,你在同一个词里继续输入时发出的请求都等它,一到就交给它们,所以在 clangd 重建得慢的文件里,列表仍会在你打完这个词之前出现。报告里的 `requests..answeredBy` 按方法统计了各由哪个引擎作答;`engineP50Ms`、`engineP95Ms` 是引擎作答的那些请求在引擎里花的时间,`overheadP50Ms`、`overheadP95Ms` 是 mcppls 在其外加的时间,由此看出一次慢的补全慢在谁;`completion.late` 统计 clangd 迟到的答案和用上它们的请求;`slowestFiles` 列出最慢的十个文件,以及每个文件有多少次补全只拿到了词;`engines[].details.buildTimes` 说明 clangd 构建每个文件花在哪里(preamble、导入的模块、AST 构建次数)。 **模块单元里出现"未使用的头文件"警告。** 这是 clangd 的 include cleaner,默认开启,mcppls 不关它:在模块接口的全局模块片段、实现单元和导入方里,它和在普通文件里一样,只报没有任何东西用到的头文件(有一个 conformance fixture 在 clangd 升级时守着这一点)。要关掉,在项目的 `.clangd` 或你的 clangd `config.yaml` 里写 `Diagnostics: { UnusedIncludes: None }`;mcppls 启动的 clangd 两处都会读。 diff --git a/editors/claude-code/.claude-plugin/marketplace.json b/editors/claude-code/.claude-plugin/marketplace.json index f65c65f..286091d 100644 --- a/editors/claude-code/.claude-plugin/marketplace.json +++ b/editors/claude-code/.claude-plugin/marketplace.json @@ -11,7 +11,7 @@ "displayName": "C++ Modules Language Server", "source": "./mcppls-lsp", "description": "Registers mcppls as the language server for C and C++ sources, including C++20/23 named modules.", - "version": "0.0.10", + "version": "0.0.11", "author": { "name": "Sunrisepeak", "url": "https://github.com/Sunrisepeak/mcpp-language-server" diff --git a/editors/claude-code/mcppls-lsp/.claude-plugin/plugin.json b/editors/claude-code/mcppls-lsp/.claude-plugin/plugin.json index 89ab0a3..7d5cc25 100644 --- a/editors/claude-code/mcppls-lsp/.claude-plugin/plugin.json +++ b/editors/claude-code/mcppls-lsp/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "mcppls-lsp", "displayName": "C++ Modules Language Server", - "version": "0.0.10", + "version": "0.0.11", "description": "Registers mcppls as the language server for C and C++ sources, including C++20/23 named modules, and its MCP tools (symbols, references, modules, verification, review). Replaces clangd-lsp for a project; do not enable both at once.", "author": { "name": "Sunrisepeak", diff --git a/editors/clion/gradle.properties b/editors/clion/gradle.properties index 2dcfda6..d845609 100644 --- a/editors/clion/gradle.properties +++ b/editors/clion/gradle.properties @@ -2,7 +2,7 @@ # ones within the same major line; sinceBuild/untilBuild in plugin.xml is what actually gates it. platformType = CL platformVersion = 2026.2.3 -pluginVersion = 0.0.10 +pluginVersion = 0.0.11 org.gradle.jvmargs = -Xmx2g # The IDE ships the Kotlin standard library; bundling a second copy in the plugin is what JetBrains # asks plugins not to do. diff --git a/editors/vscode/package-lock.json b/editors/vscode/package-lock.json index 2750eb9..9c7c150 100644 --- a/editors/vscode/package-lock.json +++ b/editors/vscode/package-lock.json @@ -1,12 +1,12 @@ { "name": "mcpp-language-server", - "version": "0.0.10", + "version": "0.0.11", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mcpp-language-server", - "version": "0.0.10", + "version": "0.0.11", "license": "Apache-2.0", "dependencies": { "vscode-languageclient": "^10.1.1" diff --git a/editors/vscode/package.json b/editors/vscode/package.json index 672ba8c..4d831cb 100644 --- a/editors/vscode/package.json +++ b/editors/vscode/package.json @@ -2,7 +2,7 @@ "name": "mcpp-language-server", "displayName": "%displayName%", "description": "%description%", - "version": "0.0.10", + "version": "0.0.11", "publisher": "sunrisepeak", "license": "Apache-2.0", "icon": "icon.png", diff --git a/editors/zed/extension.toml b/editors/zed/extension.toml index 0656777..b7b68b8 100644 --- a/editors/zed/extension.toml +++ b/editors/zed/extension.toml @@ -1,6 +1,6 @@ id = "mcppls" name = "C++ Modules Language Server" -version = "0.0.10" +version = "0.0.11" schema_version = 1 description = "mcppls - C++20/23 named modules that just work: navigation, completion, hover and diagnostics across modules for any compiler" repository = "https://github.com/Sunrisepeak/mcpp-language-server" diff --git a/mcpp.toml b/mcpp.toml index a0d24c5..01ace23 100644 --- a/mcpp.toml +++ b/mcpp.toml @@ -37,7 +37,7 @@ libarchive = "3.8.7" [package] name = "mcpp-language-server" -version = "0.0.10" +version = "0.0.11" description = "Compiler-agnostic C++ modules language server" license = "Apache-2.0" authors = ["Sunrisepeak"] diff --git a/modules/base/src/version.cppm b/modules/base/src/version.cppm index 04291e6..23604e2 100644 --- a/modules/base/src/version.cppm +++ b/modules/base/src/version.cppm @@ -9,7 +9,7 @@ export namespace mcppls::base { // checked against mcpp.toml (the one source) by `mcppls-devtools version --check`, not kept in step by // hand. Three constants that lived here and nothing read were removed rather than left to drift: // the S1 profile version is spec::PROFILE_VERSION, the kit manifest version is spec::KIT_VERSION. -inline constexpr std::string_view VERSION { "0.0.10" }; +inline constexpr std::string_view VERSION { "0.0.11" }; // The clangd the payload ships. Checked against packaging/payload.lock.json by the same command. inline constexpr std::string_view CLANGD_VERSION { "23.1.0" }; // The oldest mcpp that answers `mcpp emit build-database` — the `mcpp.build-database` kind, which diff --git a/src/engine/clangd/workarounds.cpp b/src/engine/clangd/workarounds.cpp index f6ba04f..3355a3e 100644 --- a/src/engine/clangd/workarounds.cpp +++ b/src/engine/clangd/workarounds.cpp @@ -7,7 +7,7 @@ namespace mcppls::engine::clangd { namespace { -constexpr std::array REGISTRY { { +constexpr std::array REGISTRY { { { .id = TRAILING_DOT_MODULE_NAME, .title = "a module name ending in '.' at the end of its line spins clangd forever; clangd is given the line with ';' after the dot", @@ -129,6 +129,17 @@ constexpr std::array REGISTRY { { .canary = "conformance/fixtures/cache-budget: copies a dead generation left are gone after the next start, while the published BMIs stay", .premise = "the cache directory under /.cache/clangd belongs to this server while it holds the workspace lease, so anything the previous clangd left there and no reader holds may be removed (the same premise as RD12, which clears the module locks there)", }, + { + .id = MODULE_IMPORTER_COMPLETION_BUDGET, + .title = "clangd 23.1 answers a module importer's completions in about a second, just past the budget; on such a file the budget waits up to 2.5 s for those answers instead of cancelling each at the line", + .fixedIn = "", + .upstream = "unfiled (UP-25 in issue #24): the module context is re-loaded per request, so a file that imports modules pays it on every completion while a file without imports answers in 60 ms", + .evidence = "LSP replay probe on the qt-demo CDB, 2026-10-04: 950-1050 ms per completion on the importer at every position, 60 ms on a file without imports of the same project; tests/test_completion.cpp (EnginePace); conformance fixtures completion-keywords, ux-xlings, ux-mcpp, ux-heavy-headers", + .added = "0.0.11", + .removeWhen = "clangd keeps a file's module context between requests, or carries the imports in its preamble, so a module importer's completion costs no more than another file's", + .canary = "", + .premise = "the engine's answers for one file arrive reliably and just past the flat budget: two answers in the last sixty seconds, all within two seconds of the ask and all past the flat budget, extend that file's budget to their slowest plus 500 ms (2.5 s at most). Answers far beyond that are not counted -- a busy engine is not a slow-and-steady one -- and an engine that answers rarely (a broken module rebuilding, a fan-out save) or quickly keeps the flat budget, and the fallback with it", + }, } }; // "23.1.0" -> {23, 1, 0}; anything else -> nullopt. diff --git a/src/engine/clangd/workarounds.cppm b/src/engine/clangd/workarounds.cppm index a4de1c9..cd383f4 100644 --- a/src/engine/clangd/workarounds.cppm +++ b/src/engine/clangd/workarounds.cppm @@ -39,6 +39,7 @@ inline constexpr std::string_view BACKGROUND_INDEX_WITHOUT_MODULES { "WA-CLANGD- inline constexpr std::string_view MODULE_SCAN_PER_REQUEST { "WA-CLANGD-009" }; inline constexpr std::string_view CONST_CORRECTNESS_VIEWS { "WA-CLANGD-010" }; inline constexpr std::string_view LEFT_BEHIND_MODULE_COPIES { "WA-CLANGD-011" }; +inline constexpr std::string_view MODULE_IMPORTER_COMPLETION_BUDGET { "WA-CLANGD-012" }; std::span workarounds(); const Workaround* find_workaround(std::string_view id); diff --git a/src/orchestrator/completion.cpp b/src/orchestrator/completion.cpp index 3fc5211..fc25deb 100644 --- a/src/orchestrator/completion.cpp +++ b/src/orchestrator/completion.cpp @@ -227,4 +227,27 @@ Json retarget(const Json& result, base::Position position) { return out; } +void EnginePace::answered(std::chrono::steady_clock::time_point at, std::chrono::milliseconds latency, + std::chrono::milliseconds cap) { + if (latency > cap - MARGIN) return; // beyond any budget this file could earn: the engine is busy, not steady + recent_.push_back(Answer { at, latency }); + if (recent_.size() > KEEP) recent_.pop_front(); +} + +std::chrono::milliseconds EnginePace::budget(std::chrono::steady_clock::time_point now, std::chrono::milliseconds base, + std::chrono::milliseconds cap) const { + const auto window { std::chrono::duration_cast(WINDOW) }; + std::size_t fresh { 0 }; + std::chrono::milliseconds slowest { 0 }; + std::chrono::milliseconds fastest { std::chrono::milliseconds::max() }; + for (const auto& answer : recent_) { + if (now - answer.at > window) continue; + ++fresh; + slowest = std::max(slowest, answer.latency); + fastest = std::min(fastest, answer.latency); + } + if (fresh < 2 || fastest <= base) return base; + return std::min(cap, slowest + MARGIN); +} + } // namespace mcppls::orchestrator::completion diff --git a/src/orchestrator/completion.cppm b/src/orchestrator/completion.cppm index 98d1d2d..81c42ad 100644 --- a/src/orchestrator/completion.cppm +++ b/src/orchestrator/completion.cppm @@ -89,4 +89,34 @@ bool typed_on(const WordKey& earlier, const WordKey& later); // `position` drops its item. The result is a CompletionList, with the engine's `isIncomplete`. Json retarget(const Json& result, base::Position position); +// UP-25 (issue #24): what the core engine has been answering for one file, and the budget those answers earn. clangd +// 23.1 answers a module importer's completions in about a second -- just past the flat budget -- so the flat budget +// alone cancelled every answer the engine was about to give. The budget for such a file waits past the flat one; +// an engine that answers rarely (a broken module rebuilding, a fan-out save) or quickly keeps the flat budget, and +// the fallback must not get slower for it. Pure state: the workspace records each core-engine answer and reads the +// budget before it routes the next completion of that file. +class EnginePace { +public: + // the core engine answered one completion of this file, `latency` after the person asked. Answers far beyond + // `cap` say the engine is busy, not slow-and-steady, and are not counted: counting them would hold every + // fallback of this file past a budget those answers would never meet anyway. + void answered(std::chrono::steady_clock::time_point at, std::chrono::milliseconds latency, + std::chrono::milliseconds cap); + // the budget for the next completion of this file: `base`, unless the answers of the recent window all landed + // past `base` -- at least two of them, so one slow answer is not a pattern -- and then long enough for them + // (`cap` at most). The window outlives a think pause; a longer pause relearns with the answers C-2 already keeps. + std::chrono::milliseconds budget(std::chrono::steady_clock::time_point now, std::chrono::milliseconds base, + std::chrono::milliseconds cap) const; + +private: + struct Answer { + std::chrono::steady_clock::time_point at; + std::chrono::milliseconds latency; + }; + std::deque recent_; + static constexpr std::size_t KEEP { 4 }; + static constexpr std::chrono::seconds WINDOW { 60 }; + static constexpr std::chrono::milliseconds MARGIN { 500 }; +}; + } // namespace mcppls::orchestrator::completion diff --git a/src/orchestrator/workspace.cpp b/src/orchestrator/workspace.cpp index 68dc052..802df6b 100644 --- a/src/orchestrator/workspace.cpp +++ b/src/orchestrator/workspace.cpp @@ -274,6 +274,10 @@ struct Workspace::Impl final : engine::Host { }; std::map> fileRequestStats; static constexpr std::size_t FILE_STATS_LIMIT { 500 }; // files; a session that opens more keeps the first ones + // UP-25 (issue #24): what the core engine has been answering per file, and how long it took -- the completion + // budget of a file whose answers keep landing just past the flat one waits for them instead of cancelling each. + std::map> completionPace_; + static constexpr std::chrono::milliseconds ADAPTIVE_COMPLETION_BUDGET { 2500 }; // F9 (D4): what space-triggered completion costs. Most never pass the gate and cost one look at a line. struct SpaceTriggerStats { std::size_t count { 0 }; @@ -335,8 +339,10 @@ struct Workspace::Impl final : engine::Host { static constexpr std::chrono::seconds LATE_COMPLETION_KEEP { 10 }; struct LateCompletion { std::string uri; + std::string path; completion::WordKey key; Json clientId; // the request the core engine is still working on + Clock::time_point started; // when the person asked (UP-25: the answer's latency feeds the file's pace) Clock::time_point until; std::vector waiting; // jobs of the same word waiting for its answer }; @@ -918,7 +924,7 @@ struct Workspace::Impl final : engine::Host { return; } job.answerers = std::move(selection.answerers); - if (const auto budget = answer_budget(job.method); budget && coreEngine != nullptr + if (const auto budget = request_budget(job); budget && coreEngine != nullptr && std::ranges::find(job.answerers, coreEngine) != job.answerers.end()) { job.budgetAt = job.started + *budget; } @@ -963,6 +969,29 @@ struct Workspace::Impl final : engine::Host { ask_next(jobId); } + // R-7 (plan 2026-09-30), with UP-25's amendment: the flat budget of the method -- except that a file whose + // core-engine completion answers keep landing just past it waits longer, since there the flat budget cancelled + // every answer the engine was about to give, and after `.`, `->` or `::` the fallback is empty by design. + std::optional request_budget(const Job& job) const { + const auto flat = answer_budget(job.method); + if (!flat) return std::nullopt; + if (job.method != lsp::method::TEXT_DOCUMENT_COMPLETION && job.method != lsp::method::TEXT_DOCUMENT_SIGNATURE_HELP) { + return flat; + } + const auto pace = completionPace_.find(job.path); + if (pace == completionPace_.end()) return flat; + return pace->second.budget(Clock::now(), *flat, ADAPTIVE_COMPLETION_BUDGET); + } + + // The person asked at `job.started` and the budget is counted from there, so the pace measures the same span. + void note_core_completion(const std::string& path, Clock::time_point started) { + if (path.empty()) return; + if (completionPace_.size() >= FILE_STATS_LIMIT && !completionPace_.contains(path)) return; + completionPace_[path].answered(Clock::now(), + std::chrono::duration_cast(Clock::now() - started), + ADAPTIVE_COMPLETION_BUDGET); + } + // R-7 (plan 2026-09-30): the core engine has not answered in the request's budget (answer_budget). mcppls answers // with what it has -- a completion the file's words as an incomplete list, with the keywords (F15) merged in by // finish_job; a hover what explain_if_preparing says -- and the core engine's request is cancelled. The job is @@ -982,7 +1011,8 @@ struct Workspace::Impl final : engine::Host { const bool coreAsked { !job.waitsForLate && job.next > 0 && job.answerers[job.next - 1] == coreEngine }; if (coreAsked && position) { if (auto key = completion::word_key(job.text, *position)) { - lateCompletions[jobId] = LateCompletion { uri_of_params(job.params), std::move(*key), clientId, Clock::now() + LATE_COMPLETION_KEEP, {} }; + lateCompletions[jobId] = LateCompletion { uri_of_params(job.params), job.path, std::move(*key), clientId, + job.started, Clock::now() + LATE_COMPLETION_KEEP, {} }; keepCore = true; } } @@ -1019,7 +1049,7 @@ struct Workspace::Impl final : engine::Host { } else if (completion::typed_on(it->second.key, *key)) { it->second.waiting.push_back(jobId); job.waitsForLate = true; - if (const auto budget = answer_budget(job.method)) job.budgetAt = job.started + *budget; + if (const auto budget = request_budget(job)) job.budgetAt = job.started + *budget; return true; } else { coreEngine->cancel(it->second.clientId); @@ -1035,6 +1065,7 @@ struct Workspace::Impl final : engine::Host { if (it == lateCompletions.end()) return; LateCompletion late { std::move(it->second) }; lateCompletions.erase(it); + if (answer.kind == engine::Answer::Kind::result) note_core_completion(late.path, late.started); // An empty answer or an error is nothing to give: whoever waits for it gets the file's words at its budget. if (answer.kind != engine::Answer::Kind::result || completion::is_empty(answer.value)) return; ++lateCompletionsArrived; @@ -1069,7 +1100,12 @@ struct Workspace::Impl final : engine::Host { } switch (answer.kind) { case engine::Answer::Kind::result: - if (coreEngine != nullptr && engineId == coreEngine->id()) current->second.coreAnswered = true; + if (coreEngine != nullptr && engineId == coreEngine->id()) { + current->second.coreAnswered = true; + const bool typing { current->second.method == lsp::method::TEXT_DOCUMENT_COMPLETION + || current->second.method == lsp::method::TEXT_DOCUMENT_SIGNATURE_HELP }; + if (typing) note_core_completion(current->second.path, current->second.started); + } if (!answer.value.is_null()) { current->second.engineRepliedAt = Clock::now(); current->second.answeredBy = engineId; @@ -2518,7 +2554,10 @@ void Workspace::did_close(const Json& message, const Json& params) { if (found == nullptr) return; const Document document { *found }; impl_->documents_.close(uri); - if (!document.path.empty()) impl_->editedAt.erase(base::path_key(document.path)); + if (!document.path.empty()) { + impl_->editedAt.erase(base::path_key(document.path)); + impl_->completionPace_.erase(document.path); // the file's pace says how its engine answered while it was open + } ++impl_->snapshotGeneration; if (!document.path.empty()) { if (auto text = platform::fs::read_file(document.path)) impl_->index.update(document.path, *text); diff --git a/tests/test_completion.cpp b/tests/test_completion.cpp index 6607133..a3d9e93 100644 --- a/tests/test_completion.cpp +++ b/tests/test_completion.cpp @@ -234,6 +234,38 @@ int main() { expect(!routing::answer_budget("textDocument/documentSymbol").has_value()); }; + "the budget follows the file whose engine answers keep landing just past it, and only that one (UP-25)"_test = [] { + using namespace std::chrono_literals; + const auto cap { 2500ms }; + completion::EnginePace pace; + const auto t = std::chrono::steady_clock::now(); + expect(pace.budget(t, 1000ms, cap) == 1000ms) << "nothing answered yet: the flat budget"; + pace.answered(t - 1500ms, 1050ms, cap); + expect(pace.budget(t, 1000ms, cap) == 1000ms) << "one answer is not a pattern"; + pace.answered(t - 1000ms, 1100ms, cap); + expect(pace.budget(t, 1000ms, cap) == 1600ms) << "two recent answers past the budget: wait for them"; + pace.answered(t - 100ms, 300ms, cap); + expect(pace.budget(t, 1000ms, cap) == 1000ms) << "a fast answer among them: the engine is quick here"; + expect(pace.budget(t + 45s, 1000ms, cap) == 1000ms) << "a fast answer in the window keeps the flat budget"; + + completion::EnginePace paused; + paused.answered(t - 40s, 1400ms, cap); + paused.answered(t - 20s, 1500ms, cap); + expect(paused.budget(t, 1000ms, cap) == 2000ms) << "a think pause inside the window does not reset the file"; + expect(paused.budget(t + 61s, 1000ms, cap) == 1000ms) << "after the window the file relearns"; + + completion::EnginePace busy; + busy.answered(t - 2s, 4000ms, cap); + busy.answered(t - 1s, 5000ms, cap); + expect(busy.budget(t, 1000ms, cap) == 1000ms) << "answers no budget within the cap could meet are not counted"; + expect(busy.budget(t, 1000ms, cap) == 1000ms) << "and they do not come back through the ring's tail"; + + completion::EnginePace capped; + capped.answered(t - 2s, 1950ms, cap); + capped.answered(t - 1s, 2000ms, cap); + expect(capped.budget(t, 1000ms, cap) == cap) << "at the cap the budget is the cap, not more"; + }; + "a late answer is recognized by its word and given to a later position in it (C-2)"_test = [] { const std::string text { "int main() {\n auto v = obj.si\n}\n" }; const auto early = completion::word_key(text, Position { 1, 18 }); // after "obj.s"