Conversation
…g all context Page selection sent every catalog line in a single prompt. At the ~340 chars a catalog line runs to, the 80K budget is spent around 230 articles; past that the client refuses the prompt before sending, _select_relevant catches the refusal, and chat answers from the model's own knowledge with no KB grounding at all -- reported on stderr and nowhere else. A 500-article KB is silently un-queryable. Rank the catalog by lexical overlap with the question and keep the lines that fit, so an oversized KB costs recall rather than dropping grounding whole. Ranking needs a tokeniser that survives CJK, hence the new _text module: each ideograph becomes its own token and every other script is matched as a word run, which \w resolves against Unicode. Restricted to [a-zA-Z0-9], a Chinese title tokenises to the empty set, every article scores 0.0, and the cut keeps whatever happened to come first. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
429 sat with the malformed-request statuses in the non-retryable branch, so a rate limit ended the call on the first response and took the document's phase with it. It is the one 4xx that clears on its own, and it fires more the harder a run is driven -- which makes it a blocker for raising worker counts at all. Retry it on the existing backoff, floored by the server's Retry-After when the header carries a usable delay, and capped so one absurd value cannot park a worker for the rest of the run. The label stays distinct from the gateway family: both are retryable, but only this one says the concurrency is the cause. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… fit Three defects code review found in the catalog fit, each reproducing the silent loss of grounding the fit exists to prevent: - Nothing caps a chat message, so a pasted document arrives as the query. Rendered whole it drove the catalog budget negative, emptied the listing, and overran the prompt limit on its own -- straight back to answering with no KB context. Selection now reads a bounded prefix; the answering call still gets it all. - Stopping the scan at the first line too long to fit discarded every shorter article ranked below it, which for one 930-char line and a tight budget meant keeping none of 21. Skip that line and carry on down the ranking instead. - \w carries the underscore, so cb_cooldown_sec stayed one token and a question about "the cooldown" scored 0 against the article defining it. Reference-table keys are the reason the catalog line carries a keys column at all, so those articles were the first dropped. Excluded it from the word class. The tokeniser's character ranges go back to \u escapes: a class written as literal ideographs cannot be reviewed by reading it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…he CLI A bulk ingest ran 4 documents at a time. One queue task carries one document, so the per-phase fan-out inside Python collapses to a single group and this figure is the whole of a run's document-level parallelism -- while `kb-ai compile` does the same work 16-wide. Measured against the one full run on record (108 documents, 2450s at 12 workers), 500 documents at 4 works out to about 7.3 hours, which is what a 500-document run actually took. Raise it to 12 rather than the CLI's 16: 12 is the highest this pipeline has been measured at against a live gateway with zero extract errors, and a default is the wrong place to guess. Raise the daemon pool with it, since every dispatched document holds a daemon slot for its whole pipeline and a pool of 8 would just become the new limit -- with a margin so a bulk ingest cannot lock out the chat and derive calls that share the daemon. The new test guards the pairing, which is the part that is easy to break later: raising one of the two alone buys nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…he real concurrency ceiling Two problems, both left by the extract_workers 4 -> 12 raise. The daemon >= worker invariant was guarded by a test over the shipped defaults only, so a deployment writing extract_workers = 32 loaded cleanly and got the daemon's 16-slot semaphore as the silent real cap — the raise reading as having bought nothing. validate() now refuses the unpaired raise and names both figures. Refused rather than clamped: clamping would ignore the number the operator wrote down. The comment justifying 12 was also wrong in a way future tuning would have relied on. It claimed the per-phase fan-out inside Python "collapses to one group" because a queue task carries one document. It does not: a document over 16,000 characters splits, and each phase fans out to min(chunks, KB_WORKERS) calls of its own (core/extract.py:306,648,759), so the worst case is the product — 12 x 16 = 192 in-flight calls, which is the figure a gateway's rate limit is actually met with. The default itself stands; the measured run survived this same shape. KB_WORKERS has no config field, but the daemon inherits the backend's environment (bridge/daemon.go:98 passes os.Environ() through), so exporting it is how the second factor gets bounded — now stated in etc/kaas.toml and py/README.md instead of being discoverable only by reading extract.py. Also recorded: KAAS_DAEMON_MAX_WORKERS still falls back to 8 for a standalone `kb-ai daemon` while the Go backend passes 16, and compile.py's document default stays 16 rather than matching the queue's 12 — every published measurement in docs/articles/ was taken at 16, so moving it would make those numbers describe a run nobody made. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every wait assertion in this file read the expected value out of the module under test — completion_mod._TIMEOUT_BACKOFF_BASE and _RETRY_AFTER_CAP_S — so a mutation of either constant moved both sides of the assertion and the suite stayed green. Measured: base 10 -> 7 and cap 120 -> 600 each left 44/44 passing. The literals 10 and 120.0 now live in the test file as the independent second source; the same two mutations kill 8 tests and 1 test respectively. Two gaps closed while here. The Retry-After floor was only ever exercised where it could not change the outcome: with the deadline guard reached through the timeout and gateway paths, the wait can never exceed the backoff, so nothing pinned that the server's wait is what makes a rate limit trip it. The new test sets a deadline the 10s backoff clears comfortably and a Retry-After that does not — removing the max() floor turns it red. And the HTTP-date form of Retry-After is RFC-legal and deliberately ignored (trusting it means trusting our clock against the server's), which now has a test saying so rather than a docstring alone. 46 tests in the file, full suite 1669 passed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… the sources The review round on 59637e5 found that two of the three comments that commit exists to fix were still false, in the same shape as the original. Both verified against the files before rewriting. "Preferred over matching the CLI's unmeasured 16" was wrong: 16 is the better-measured figure. docs/articles/kaas-distill-a-codebase.md:351 publishes a live-gateway run at KB_WORKERS unset — 359 documents, 994 calls, 0 errors — against the 108 documents cited for 12. The real reason to stop at 12 is the daemon, not the evidence: a queue-route ingest shares the daemon's 16-slot semaphore with chat, derive and retrieval, so 16 would saturate it for the length of the run, while `kb-ai compile` owns its process and needs no such margin. That is now what both comments say, and it is also the honest answer to why compile.py keeps 16 — the earlier "every published measurement was taken at 16" was false too (kaas-four-layers.zh-CN.md:147 sources its cost figures from the workers=12 run, and cost is concurrency-independent anyway). "The product it survived is 12 x 12 at the least" repeated the very error being fixed — treating the fan-out as always maxed when it is min(chunks, KB_WORKERS). data/distill-2026-06.log records the chunk counts: 302 chunks over 108 documents, mean 2.8, max 9, so its twelve largest together could only have put 80 calls in flight and the typical peak was near 34. 192 is a ceiling for a corpus of uniformly long documents and is now labelled as one, in all three places. Test fix from the same round: the accepted half of the new validate() rule used concurrency=40 against extract_workers=32, which a rule tightened from < to <= also passes — so the boundary both comments bless ("at or above") was pinned by nothing. Verified: the mutant now fails the equal pair and passes nothing else. Two more from the round, both real. The HTTP-date Retry-After test wrote down a date two months out, so after 2026-10-21 a date-honouring implementation would have yielded a negative delta floored to 0 and left the test green while pinning nothing; it is computed from now + 1h and a date-honouring mutant still kills it and only it. And that test's sibling docstring credited the 120s cap it does not pin — 200s overruns a 150s deadline capped or not — so it now points at the test that does. Recorded, not changed: DaemonConf.Concurrency stated the invariant a fifth time and had already drifted; it is now a pointer at the one statement. bridge/daemon.go clamps concurrency < 1 to 1, so `concurrency = 0` never reached Python's 8 fallback — it exported 1, and refusing it is strictly an improvement. Go 16 packages ok, vet clean, gofmt clean. py 1669 passed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The repository's language policy makes English the only working language, on the ground that KaaS accepts contributions worldwide and a Chinese artifact is a wall for most of the people we want reviewing this code. These two files were the leftovers a contributor meets first: the sample deployment config, and the AI engine's own README. Translation only. Every configuration value in etc/kaas.toml is byte identical (verified: the non-comment diff is empty), and py/README.md keeps every technical identifier it had — the set of backticked tokens is unchanged apart from `distill` gaining the backticks its sibling already had, with the same 12 headings and 35 table rows. Not translated, and each for a reason rather than by oversight: the `zh` maps in web/src/i18n/strings.ts are locale data the policy exempts, and Chinese test fixtures (the CJK tokeniser's inputs, frontmatter titles, wiki filenames in UI tests) are the data under test — translating them would delete what those tests check. A survey found CJK in 38 further tracked files; triaging which of those are real violations is a separate pass, not this commit's. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Batch 1 of the English-only sweep: the eleven files where the Chinese is comment, script output, CLI help or issue-template text, so translating it cannot change what the program does. The three issue templates were bilingual and are now English-only, which is the maintainer's ruling rather than an inference from the policy. Files: cmd/kaas/main.go (CLI help and two comments), internal/api/wiki.go, internal/mcp/types.go, Dockerfile, CONTRIBUTING.md, scripts/release.sh, scripts/smoke-test.sh, .github/workflows/claude-interactive.yml (comments only), and the three .github/ISSUE_TEMPLATE files. Proven not to change behaviour rather than asserted: every changed line in the Go files, the Dockerfile and CONTRIBUTING.md is a comment or a help string; every changed line in the shell scripts is a comment or a message inside echo/die/info with all variables and command substitutions intact ($0, $TAG, $CURRENT_BRANCH, the git rev-parse call); claude-interactive.yml parses to a structure identical to HEAD's, which is only possible if nothing but comments moved; and for the issue templates both the field id lists and the whole non-display structure (types, required flags, labels arrays, render directives, ordering) compare equal to HEAD. `kaas help` was run and its column alignment survives. go build, go vet, go test ./... and bash -n on both scripts are clean, and no test or workflow referenced any of the translated strings. Deliberately NOT in this commit, because each would change behaviour and wants its own decision: - core/extract.py:485-501 composes Chinese labels into the text sent to the model. extract_prompt_version() hashes only the four prompt templates and the two group tables, so editing these would alter what the model sees while the freshness gate stayed silent, leaving a KB with both label styles and no re-extraction; test_core_extract.py:295-345 asserts the labels as they are. - prompts/defaults/suggest.md is an entire prompt. - workflows/claude-issue-triage.yml carries the prompt that triages live issues. - .github/labels.yml needs a GitHub-side sync to take effect. Left alone on purpose: the zh locale maps, and the Chinese fixtures in the CJK tokeniser, frontmatter, path and UI tests, which are the data under test. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…g greedily Two defects made the classifier create duplicate articles once a KB outgrew the prompt budget, and they compounded: the ranking that decides which existing articles the classifier sees was dead, and the fit that cuts the ranked list threw away most of what the budget could hold. The ranking's `_title_words` stripped everything outside [a-zA-Z0-9\s], so a Chinese title tokenised to the empty set and `_relevance_score` returned 0.0 for every article -- the sort was stable and therefore a no-op, leaving the cut to keep whichever articles came first. The same regexp welded a hyphenated topic into one token, so the damage was never limited to Chinese: measured over 162 real extractions from data/kb-knowledge, 84% of topics are kebab-case slugs, the mean best score across the 675-article catalog was 0.195 against 0.691 now, and 15% of extractions scored zero against every article. Both call sites move to kb_ai._text, which retrieval already uses -- one tokeniser, no drift. `_fit_articles_to_budget` halved the list until it fit, so a budget with room for 30 of 32 entries kept 16. Measured on the same 675-article catalog: at a 38K budget it kept 84 entries where the budget holds 143, and at 78K it kept 168 where 292 fit. The greedy walk keeps every entry that fits and skips (rather than stops at) one whose block does not, matching retrieve._fit_catalog. To charge each entry its exact cost the array is assembled from per-entry blocks, and a test pins the result byte-identical to json.dumps(..., indent=2). Combined, with the article's own summary standing in for an extraction's topics, the merge target survives the cut for 89% of the 675 articles at a 38K budget (was 83%) and 93% at 78K (was 88%). Consequence worth recording: dedup now also splits hyphens in English titles, so of 227,475 title pairs in that KB 86 reach the 0.7 merge threshold where 46 did before. The extra pairs are hyphenation and case variants of the same phrase -- "AI Native Development Workflow" against "AI-Native Full-Lifecycle Development Workflow" scores 1.0 -- which is the near-duplicate the dedup exists to catch. Tests: 1673 passed (+4). 8 of 8 mutations killed, including reverting either call site to the ASCII regexp, `continue` -> `break` in the walk, dropping the array brackets from the cost, and sorting the catalog ascending.
…iser The review round on 895424d found the tokeniser migration had turned a missed-duplicate failure into a false-merge one, which is worse: a false merge writes a document's knowledge into an article that never claimed the subject and nothing later undoes it, while a missed duplicate leaves an article a later compile can still merge. Reproduced before fixing, all five on the corpus the branch was diagnosed against: `发言复盘 2026-01` against `发言复盘 2026-03` scored 0.83, `Weekly Report 2026-01/03` 0.75 (English, so this was never a CJK problem), `上海` against `海上运输` 1.0, `数据安全` against `安全数据库` 1.0, and two different teams' OKR articles 0.75. `dedup_create_new` now scores through `_duplicate_score`, which keeps the ranking's shape -- shared tokens over the smaller set, so "Vector Search Basics" beside "Vector Search" is still caught, which is the collision pipeline/_phase_dedup.py exists for -- and adds three guards, each paying for a consequence of the tokeniser change: - Numbers gate the comparison: two titles alike apart from their numbers are consecutive instances of a series. Splitting a date into its own tokens is what pushed that class over the threshold. - CJK runs are compared as character bigrams (kb_ai._text.bigram_tokens), because single characters make 数据安全 a perfect match for 安全数据库. Ranking keeps single characters -- there a loose match only reorders a list. - The two titles must be within 1.5x in token count, because containment alone is weak evidence. Measured against the PRE-FIX rule as the baseline for what counts as a regression, over the 675 titles of data/kb-knowledge: the new rule merges 16 pairs where the pre-fix one merged 46. 9 are merged by both, 7 only by the new one (inspected: all same-subject -- capitalisation variants and this corpus's near-duplicate AI-Native workflow cluster), and 37 the pre-fix rule merged are now refused, which is where the false merges lived. Also from the review, both verified before fixing: - `_entry_block` indented through textwrap.indent, which breaks lines on everything str.splitlines() accepts, so a title carrying U+0085, U+2028 or U+2029 (ensure_ascii=False emits all three raw) came back with two spaces injected. Swept: those three codepoints and no others. - Two mutation gaps closed with tests rather than argument: charging one character per entry separator instead of two survived the whole suite (80 small entries against a budget for 30 keeps 31 and overruns), and `>= 0.7` -> `> 0.7` survived because 0.7 is reachable and nothing tested it. The test whose docstring claimed to validate CJK threshold safety scored 0.167, a factor of four below the threshold; it is now the three adversarial pairs above. - The dead `if not new_words` guard is gone: `_duplicate_score` returns 0.0 for an untitled create, which never beats the 0.0 seed. Tests: 1689 passed, 1 failed, 1 xfailed. The failure is tests/test_distill.py::test_distill_end_to_end_produces_article, pre-existing and environmental (a live call to a gateway that does not serve gpt-4o-mini) -- 895424d's message reported the deselected count as clean and should not have. 18 of 18 mutations killed, including reverting the dedup to the pre-fix score, dropping either guard, moving the length ratio to 2.0, and emitting single characters from bigram_tokens. `core/classify.py` 99% (one pre-existing line), `_text.py` 100%.
…atio The re-review found three false-merge or duplicate-article paths still open in ab82316, and the worst of them was mine: bigrams roughly double a CJK title's token count, so the 1.5 length ratio bound about twice as hard on Chinese as on English. Proven through run_dedup_phase itself, the cross-group collision the phase exists for: `Vector Search`/`Vector Search Basics` deduped, while `向量检索`/`向量检索基础` and `成本管理`/`成本管理系统` did not -- Bug 2 reopened on the path Task 3 is about. Also still open: the two-teams-OKR class the ratio comment claimed to fix (0.71, unchanged from 895424d), and `2026 Q1 Planning Review` against `2026 Q2 Planning Review` (0.75), because `_numbers` matched bare digit runs and `q1` is not one. The rule is now one guard and one constant fewer, per the reviewer's proposal: - Dice (2|a n b| / (|a| + |b|)) replaces smaller-set normalisation plus the length ratio. It charges both titles' sizes, so a qualifier still merges (0.80 for "Vector Search Basics", 0.75 for 向量检索基础) while containment alone does not (0.40 for "Pricing" inside "Pricing Model Review Notes", 0.67 for two teams sharing `2026 H1 ... Team OKR Decisions`, 0.57 for 数据安全/安全数据库). - A number is any token carrying a digit, so `q1`, `v2` and `h1` count. - The gate fires only when both titles carry numbers AND they disagree. Demanding equality was refusing 15 real duplicate pairs in data/kb-knowledge, each one article titled once with a trailing date and once without. Re-measured over the 675 titles with the shipped functions: 25 pairs merge, 16 of them also merged by the pre-branch ASCII rule and 9 new. All 25 were read: 19 are one article titled twice, 2 are this corpus's near-duplicate AI-Native workflow cluster, 4 are a rolling 发言复盘 article beside its dated instalments. The pre-branch rule merged 46 and 895424d's merged 86. On 16 hand-labelled pairs this rule disagrees with the label 0 times against the previous rule's 7. Recorded rather than fixed, and pre-existing rather than introduced here: a period spelled without digits is invisible to the gate, so `Phase I`/`Phase II`, 一月/三月 and `Part One`/`Part Two` still score 0.75 and merge, exactly as they did before this branch. Closing it needs an ordinal vocabulary, not another guard. It is stated in _duplicate_score's docstring. Also from the re-review: the test whose docstring claimed to validate CJK threshold safety was asserting a normalisation production does not use (it reported 0.5 of margin where the real margin was 0.033), and now asserts the token sets themselves; U+30FB inside _SCRIPTIO_CONTINUA is documented in bigram_tokens rather than silently narrowed, because narrowing the range would move retrieval's ranking too; the doubled .lower() is gone; and the empty-token guard, whose removal raised ZeroDivisionError with no test noticing, now has one. Tests: 1697 passed, 1 failed (the pre-existing environmental test_distill case), 1 xfailed. 23 of 23 mutations killed, including every arm of the new rule -- Dice to either one-sided normalisation, the factor of two, the digit-token definition, both readings of the gate, the threshold at 0.4 and 0.75, and the bigram range narrowed to Han. `core/classify.py` 99% (one pre-existing line), `_text.py` 100%.
The third review round found the class the two previous rounds had missed, and it is the one that matters most for a KB whose titles are Chinese: turning CJK dedup on made a one-character difference enough to merge opposite claims. `支持向量检索` against `不支持向量检索` scored 0.91, `启用灰度发布` against `停用灰度发布` 0.80, `生产环境数据库迁移方案` against `测试环境数据库迁移方案` 0.80. All three scored 0.000 before this branch, because non-ASCII tokenised to nothing. The reviewer proved the merge through run_dedup_phase, not from the diff. Checked before fixing, and it reframes the rule: the same shape is INHERITED on the English path. `Rate Limiting Enabled/Disabled On Gateway` scored 0.80 before this branch and 0.80 after; two teams' `2026 H1 ... Team OKR Decisions` 0.83 both; `Phase I`/`Phase II Rollout Plan` 0.75 both; `Checkout`/`Search Outage Postmortem And Action Items` 0.83 both. So this was never a CJK bug -- it is what shared-tokens-over-the-smaller-set has always done, and the branch made it reachable in a second language. One structural rule closes all of it, replacing the "score is high enough" reading with a shape: a duplicate title is the same title with words ADDED, or a near-identical rewording of it. So two titles that each carry a token the other lacks are a different article unless they are near-identical (0.85, which admits `Bi-Weekly` against `Biweekly` at 0.88 -- a real corpus pair -- and refuses every sibling-instance shape measured, the highest being 0.83). Addition still merges, which is the collision pipeline/_phase_dedup.py exists for: "Vector Search Basics" beside "Vector Search", 向量检索基础 beside 向量检索. Two supporting changes, both measured rather than guessed: - Negation can be an addition rather than a substitution (`不支持X` contains every token of `支持X`), so an unpaired polarity marker refuses the merge. The list is 17 entries and deliberately small: a missing entry leaves the pre-branch behaviour instead of creating a new failure, and a spurious match (无 inside 无线) costs a duplicate rather than a misfiled document. - bigram_tokens reads `&` as the word it stands for. Nine of the corpus's duplicate pairs are one article written once with `&` and once with `And`; without this they differ on both sides and the new rule would refuse them. tokens() is left alone so retrieval's ranking does not move. Measured with the shipped functions, over 16 hand-labelled bad pairs (every false merge the three rounds named) and 10 hand-labelled duplicates (including 4 real corpus rewordings and the pipeline's collision shape in both languages): 0 false merges and 0 missed duplicates, against 12 and 0 for the rule in be3d7b1. Over the 675 titles of data/kb-knowledge: 23 pairs merge, 16 of them also merged by the pre-branch rule (which merged 46 in total) and 7 new. All 23 were read: 16 are one article titled twice, 1 is this corpus's AI-Native workflow cluster, and 6 are a rolling 发言复盘 article beside its dated instalments -- the price of matching a dated title to its undated twin, which the docstring now states and a test pins. Also from the round: the 25-pair breakdown in be3d7b1's comment was 17/2/6 rather than the 19/2/4 it claimed (the 发言复盘 group is six pairs, not four) -- both the count and the class are corrected here; the docstring no longer claims to have closed a boilerplate class it had closed for exactly one asymmetric pair; and four mutations the previous round had not tried are now pinned by tests -- the numbers gate read one-sidedly (`发言复盘 2026` against `发言复盘 2026-01`), `_numbers` reading the raw string instead of tokens, digits as `[0-9]` instead of `\d` (fullwidth `2026`), and the threshold at 0.68. Tests: 1717 passed, 1 failed (the pre-existing environmental test_distill case), 1 xfailed. 36 of 36 mutations killed, including every arm of the new rule: the substitution guard removed, made one-directional, or reduced to near-identity alone; 0.85 moved to 0.8 and to 0.95; the polarity gate removed and its Chinese markers deleted; the ampersand rule removed. `core/classify.py` 99% (one pre-existing line), `_text.py` 100%.
…t hid them
The fourth review round found the arithmetic I should have seen: for two titles of
n tokens differing in ONE token, Dice is exactly 1 - 1/n. So `_NEAR_IDENTICAL =
0.85` was not a bar on rewordings, it was a bar on title length -- every pair of
7 tokens or more cleared it, and 581 of this corpus's 675 titles are that long.
Measured through dedup_create_new: 内部用户数据访问审计方案 against
外部用户数据访问审计方案 0.909, 高优先级/低优先级需求排期方案 0.889,
`允许跨境数据传输的合规评估结论` against `拒绝跨境数据传输的合规评估结论` 0.857 --
an approve/refuse inversion walking straight past the polarity gate, because 允许
and 拒绝 are not markers. The previous round's own documented refusals flipped on
one added word: `生产环境`/`测试环境数据库迁移方案` is 0.0, but the same pair with
`实施方案评审` appended is 0.857.
No constant fixes this, which the reviewer proved rather than argued: on the corpus
that arm decides exactly one pair, `Global Architecture Bi-Weekly` against
`Biweekly` at 0.882 -- BELOW the 0.909 false merge. So the arm and its constant are
deleted, and the rule is now one line shorter than the round that introduced it:
containment or nothing. A duplicate title is the same title with words added.
Measured with the shipped functions: 0 false merges over 22 hand-labelled bad pairs
(every one the four rounds named, now parametrised in the test file so a later
round can recompute them), and 1 missed duplicate -- the Bi-Weekly pair, whose loss
is the deliberate price and is pinned by a test rather than described in prose. On
the 675 titles: 22 pairs merge against the pre-branch rule's 46, 15 of them one
article titled twice, 1 the AI-Native workflow cluster, 6 the rolling 发言复盘
archive beside its instalments.
Also from the round, all verified before acting:
- The polarity list loses `rollback`, `revert`, `non` and the deprecate family. They
are not negations in this corpus, they are domain nouns: `rollback` appears in 22
of 675 titles ("Abnormal Trade Rollback Methodology"), `non` in 4
("Non-Middleware Integration Scope"). Every remaining marker is now exercised by
a parametrised test, so nothing sits in the list unjustified.
- The number gate is INERT on this corpus -- 22 pairs with it, without it, and with
numbers read as digit runs. It is kept for the series shape the docstring names
(`发言复盘 2026` against `发言复盘 2026-01`, which containment alone merges at
0.889) and the comment now says so instead of implying it earns its place here.
- Round 4 attributed the `May 6`/`May6` refusal to the number gate; measured, it is
containment that refuses it, and it would be refused with the gate deleted. It is
recorded with `Bi-Weekly`/`Biweekly` as the same class: a re-spelling that splits
a token is a substitution.
- Three limitations are now stated in the docstring AND pinned by tests, because the
last two rounds each shipped a docstring claim that measurement did not support:
an addition that narrows the subject still merges (`Gateway Migration Plan
Deprecation`, 全球数据安全规范); a token-splitting re-spelling is refused; and the
relation is not transitive, so a generic title acts as a hub (`Big Data Team OKR
Decisions` and `DBA Team OKR Decisions` score 0.0 against each other yet both
merge into `Team OKR Decisions` -- 15 such triples exist in the corpus).
- Two mutations round 4 found surviving are closed: containment tested in only one
direction (nothing covered the classifier proposing the SHORTER title), and ASCII
polarity markers matched as substrings (the old test had `Notification` on both
sides, so it passed either way).
Tests: 1741 passed, 1 failed (the pre-existing environmental test_distill case),
1 xfailed. 37 of 37 mutations killed, including containment removed, made
one-directional in either direction, or relaxed to any shared token; both halves of
the polarity list deleted; and the ampersand rule removed. `core/classify.py` 99%
(one pre-existing line), `_text.py` 100%.
…icate The fifth round found Task 3 mergeable and named one change worth making first, having measured it: set containment cannot see a transposition. 腾讯云到阿里云迁移方案 and 阿里云到腾讯云迁移方案 carry exactly the same tokens, so every set-based score reads 1.000 and the pair merged -- as did 主库切换到备库演练 against 备库切换到主库演练, and `Migration From Redis To Kafka Decisions` against the same title with Redis and Kafka swapped (that last one merged before this branch too; the Chinese ones could not, because CJK scored 0.0). Containment is now a subsequence test over kb_ai._text.bigram_sequence, which is bigram_tokens in reading order with repeats kept. All three transpositions score 0.000, and the corpus loses nothing: the 23 merges are the same 23. Three more findings from the round, each verified before acting: - The number gate is NOT inert on this corpus, as be3d7b1's comment claimed. Deleting it adds a 23rd merge and that pair is genuine -- `Customer Service Bot TOP5 Scenario Closed-Loop Integration Decisions (P2P, …)` against the same title written flat. `_numbers` was reading `p2p` and `top5` as numbers, which contradicts the gate's own premise that a number in a title is a period, a version or a date. _NUMBER_TOKEN now matches bare digits or a one-to-two-letter prefix over digits (`q1`, `v2`, `h1`), so that pair merges and the `2026 Q1` against `2026 Q2` protection stands. Corpus merges: 22 -> 23. - "A missing polarity marker leaves the pre-branch behaviour" was false for Chinese, and this is the direction that matters: before this branch every CJK pair scored 0.0, so an omission creates a merge that could not happen before. Measured, 拒绝跨境数据传输方案 landed in 跨境数据传输方案 at 0.88 with no entry for 拒绝. Added 拒绝, 反对, 废止, 撤销, reject, rejected -- none of them appears in any of the corpus's 675 titles, so none can misfire here -- and the comment now states the asymmetry instead of the reassurance. - Two mutations survived the previous round's set: the polarity gate read one-directionally (every polarity test put the marker on the NEW side, so nothing would have noticed `支持向量检索` merging into an existing `不支持向量检索`), and the `not m.isascii()` guard could be deleted because the fixture was capitalised and _polarity's substring arm reads the raw title. Both are pinned now, the first by running every marker from the existing side as well. Corrected in the comments rather than left to drift: the docstring's transitivity example is labelled as constructed (all 15 hub triples in the corpus belong to the one 发言复盘 archive); one of the 23 merges is an addition-changes-subject case rather than one article twice, and says so; the count of pre-branch merges this rule refuses is 30, of which 5 are genuine duplicates in the classes already documented. _text.py's docstring had also kept its examples as \uXXXX escapes since the round that was asked to fix them -- they are literal Chinese now, verified by grep. Tests: 1770 passed, 1 failed (the pre-existing environmental test_distill case), 1 xfailed. 39 of 39 mutations killed, including subsequence relaxed back to set containment, tested one way only, or walking a fresh iterator per token (which is plain set containment in disguise); _NUMBER_TOKEN widened and narrowed; the polarity gate made one-directional; both halves of the marker list deleted. `core/classify.py` 99% (one pre-existing line), `_text.py` 100%. go build + go vet clean.
…' retries A breaker rejection never reaches the engine, so the document it was refused for did nothing wrong. Process funnelled ErrOpen into the same Nack path as a real engine failure, and ClaimNext charges an attempt on every delivery, so each refusal cost the document a retry for nothing. Only one task per half-open round passes the breaker's single trial, which meant a healthy document failed permanently once max_attempts ran out. Raising worker.extract_workers to 12 turned 3 wasted attempts per round into 11. The fix sits at both layers that were wrong: - store gains ReleaseTask, the inverse of ClaimNext: owner-scoped, back to pending and queued, the claim's attempt handed back, no error recorded. Process calls it through queue.Release whenever a brk.Do error is circuit.ErrOpen, at the extract stage or the pipeline stage. - the dispatcher now scales its batch to what the breaker will admit: nothing while it is cooling down, one task while half-open, the full semaphore when closed. Without this the release path would churn a whole batch of claims and releases on every poll tick for as long as the probe call runs. A real engine failure still Nacks and still spends the attempt, so a poison document cannot retry forever. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… the log Review of the previous commit found two gaps and one overstated comment. Folding the two "extract: %v" / "pipeline: %v" literals into failEngine's stage parameter made them transposable, and nothing failed when they were swapped: the task's error column is the operator's only clue about where a document died. TestProcessEngineErrorStillSpendsTheAttempt now runs both stages as a table and asserts the recorded prefix. Handing a refused task back writes no error on it, so the pre-fix "extract: circuit: breaker open" rows were the only sign of an outage and the fix removed them. The dispatcher now logs each breaker transition once — on change, not per tick, since a long probe holds half-open for many ticks. Also corrects the ReleaseTask comment and its test's: a row reaches status=running only through ClaimNext because queue.Submit is the sole CreateTask caller and always inserts pending, not because ClaimNext is the only writer of that column. store.Task.Attempts was documented as "claimed and failed", which was never true — the increment is at claim time, and that is now load-bearing for ReleaseTask. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… batch rule Review round 2 found two mutation holes, both the shape round 1 had already flagged: an assertion narrower than the behaviour it was standing in for. The transition log was only exercised from a breaker seeded half-open, so the one line an operator actually needs — closed -> open — was untested. Gating the log on `state == StateHalfOpen`, or on `lastState == StateClosed`, passed the whole suite. TestDispatcherLogsEveryBreakerTransition now walks the real cycle against an injected clock (trip, cool down, failed probe, cool down, successful probe) and pins all five lines in order. `case circuit.StateOpen: continue` had no test either: draining a task before the continue passed, because TestDispatcherPausesWhenBreakerOpen only asserts the task stays pending and the engine is untouched, and both stay true when a task is claimed and handed straight back. TestDispatcherClaimsNothingWhileCooling Down asserts zero claims instead. Also documents three consequences the review asked to have written down rather than coded around: ReleaseTask deliberately leaves an older real error on the row, so attempts can fall while that message stays on display; the half-open tick moves the oldest pending row's updated_at once per poll interval; and State()'s semantics mean a probe that re-opens between two ticks logs nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review round 3 found the third assertion narrower than the behaviour it stood in for — this time behind a comment rather than behind code. Round 2 documented that ReleaseTask deliberately leaves an older real failure message on the row. Nothing tested it: adding `error = ''` to the UPDATE passed the whole suite, because the two assertions that looked like coverage only ever ran against a freshly submitted task whose error was already empty, so they prove the release adds no error and cannot tell preserved from cleared. TestReleaseTaskKeepsAnEarlierFailureMessage builds the one state the decision is about: a real failure, then a refusal, leaving attempts down and the message up. The transition test's phase gating also proved nothing about the log. stubClaimer pushes its tick token from RecoverExpired, which runs before the state is read and logged, and the tokens are never drained — so waiting on tick counts let the clock advance before the line it was supposed to wait for, and the five-line order held by timing rather than by a barrier. Each phase now waits for the line it must produce, which needs a mutex-protected log sink because the writes come from Run's goroutine. Verified against the reviewer's diagnostic: a 3ms stall injected before the state read, which used to make it fail deterministically, now leaves it green over 30 runs and 10 under -race. scriptedEngine loses the call counter the old gating used. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… clock Review round 4 ran 30 mutations and killed every production-behaviour mutant, so its verdict was ready to merge. It left four survivors, three of which are worth a line each. Breaker-recovery liveness rested on the wrong test. TestDispatcherHandsOutOneProbe WhileHalfOpen asserted only the upper bound, so replacing the half-open arm with `continue` or with drain(..., 0) — a breaker that can never recover — passed the test named after the probe and was caught only by the one named after the log. It now asserts the lower bound too. An exact count is not available: the probe blocks, so later ticks legitimately claim and hand back one each. TestProcessReleaseFailureIsSwallowed claimed in its own comment that a failed Release is logged and never looked at the log; dropping the log.Printf survived. Round 1 rejected a silent hand-back for exactly this reason, one layer up. queue.Release passing 0 instead of the clock also survived. updated_at is a sortable column in the task list, so a zero would file the release under the epoch. The fourth survivor is left alone deliberately: dropping `status = ?` from ReleaseTask's WHERE is unobservable, because every exit from running clears lease_owner, so the owner predicate already excludes those rows. It is defence-in-depth identical to SetStage's and Heartbeat's. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Picks up main's PipelineRequest Model fix (0d76328), which touches the same Worker.Process this branch changed, plus the two CodeQL workflow changes. Merged rather than rebased to keep the 20 reviewed commits and the SHAs that the session log references.
main's 0d76328 added Model to the PipelineRequest with no test, and it is a bug worth a guard: without it the Python engine falls back to its own literal default, so an endpoint serving anything else answers every classify call with HTTP 400, no document reaches wiki/, and the task retries until its attempts run out. Extract was already covered. TestProcessPassesConfigToEngine records both requests, so the assertion is one line there. Two mutants confirm it bites: dropping the field, and forwarding SummarizeModel instead. The fixture model was named "extract-model" back when only extract carried it; it is now "cfg-model", which is what it always meant and stays distinguishable from "sum-model". Config.Model's comment listed only ExtractRequest.Model. KBDir on the line above already documents both hops; Model now does too.
_SCRIPTIO_CONTINUA drives every lexical score in the retrieval and dedup paths, and until now nothing asserted where it starts and stops. That range can move without anyone editing it: two of its endpoints, U+3040 and U+FAFF, are unassigned codepoints that render as an empty box, and NFKC normalisation rewrites two others -- U+30FF into two characters and U+F900 into U+8C48 -- into a character class that still compiles, running unbroken from U+4E00 to U+FAFF. Nothing raises; Yi, every Hangul syllable and the private use area just quietly become break-less script. Thirteen cases: each of the eight endpoints has to tokenise as its own character between two Latin letters, and five word characters outside the blocks (U+3005, U+3105, U+A000, U+AC00, U+FB00) have to keep joining their neighbours into one token. U+AC00 is in that list because the compatibility block's lower bound is the one a rewrite drags down, and Hangul is the largest stretch of word characters it swallows on the way. The expectations are escapes, not characters -- as characters, one NFKC pass over the tree would rewrite them and the range in the same direction and the mismatch would stay green. There is deliberately no probe between U+4DBF and U+4E00: that gap holds only hexagram symbols, which \w does not match, so a bound widened there is not observable through the tokeniser. Verified by mutation against a copy outside the repository: NFKC-normalising the range kills 3, dropping the compatibility block kills 2, dragging its lower bound to U+8C48 kills 2, and moving it to U+AC00, moving the unified upper bound either way, or moving the kana lower bound each kill 1. Reverting every mutation brings all 29 back green, so it is these assertions doing the killing rather than incidental coverage.
The four ranges were written as the characters they denote, which makes the
constant both unreadable and fragile. Unreadable because U+3040 and U+FAFF
are unassigned codepoints: a reviewer sees two empty boxes and cannot tell
what the range covers, while the neighbouring docstring already describes
the same range numerically ("U+30FB is inside the range"), so the code and
its comment spoke different alphabets. Fragile because NFKC normalisation of
this file rewrites U+30FF into two characters and U+F900 into U+8C48, which
leaves a character class that still compiles and now runs unbroken from
U+4E00 to U+FAFF -- so Yi, every Hangul syllable and the private use area
become break-less script, one character per token, with nothing raised and
nothing visible in a diff.
Non-raw escapes rather than a raw string, so Python resolves them at parse
time and the compiled patterns stay byte-identical to before rather than
relying on re's own handling of \u inside a character class. Verified: all
of _SCRIPTIO_CONTINUA, _TOKEN_RE, _RUN_RE and _CHUNK_RE compare equal to
the values captured before the change.
The comment now names each block and its bounds. tests/test_text.py pins
both ends of all four, so the normalisation this guards against turns red.
Brings in #55, the translation PR that was split out of this branch, plus #54 and #50. Seven files conflicted, all of them files #55 touched: the translations landed on main as a squash, so main's copies share no ancestry with this branch's e4295f6 and a791320 and git had to treat them as two independent edits. Resolved by diffing the two sides rather than picking a side per file. Five were pure translation and main's copy was strictly better -- it carries the review fixes made after this branch forked (Deployment method, "(if enabled)", the restored "describe your use case first", the wrapped allowed_non_write_users comment, Color, "invalid tag format", and the release summary's alignment) and had nothing this branch would lose. Taken whole; all seven non-scale files are now byte-identical to origin/main. etc/kaas.toml and py/README.md carried both, so they were merged by hand: main's comment corrections (the compose imperative, "cheaper than `model`", Initialize) on top of this branch's extract_workers = 12, concurrency = 16 and the KB_WORKERS / KAAS_DAEMON_MAX_WORKERS rows, which are this branch's subject and not translation. Verified: go build, go vet and go test -count=1 ./... all pass, gofmt clean on every Go file that differs from main; 1763 Python tests pass, 1 xfailed; _SCRIPTIO_CONTINUA and its three compiled patterns still compare equal to the values captured before the escape rewrite; no conflict markers and no CJK left under .github, etc or scripts.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Running the knowledge-base pipeline over a ~500-article corpus surfaced four defects a small KB never
reaches: chat silently lost all KB grounding once the catalog outgrew the prompt budget, the classifier's
article ranking scored 0.0 on a Chinese title and 0 on a kebab-case topic no article title welds the same
way, a bulk ingest through the queue ran 4 documents at a time where the equivalent CLI run does 16, and a
rate limit or a half-open circuit breaker burned a healthy document's retry budget until it failed
permanently. This branch fixes all four across 18 commits organised as four tasks, each carried through code
review and mutation testing; three further commits ride along, one adding a test main was missing and two on
the tokeniser module this work introduces. Figures below are measured against
data/distill-2026-06.log(108 documents, Sonnet 4.6,
workers=12; 527 calls, 2450.88s of wall clock, so ~4.9 calls and ~22.7s ofwall clock per document, or ~272s of serialized per-document work) and the real KB in
data/kb-knowledge.Every corpus figure below is over the 675 entries in that KB's
index/master-index.md, which is the listingboth classify and retrieval read;
wiki/holds 682 files and 7 of them parse no frontmatter title, so theynever reach the index and no figure here rests on 682.
Retrieval: the catalog no longer overruns the prompt budget
An operator with a large KB saw chat answer from the model's own knowledge with no citations, the only clue
a line on stderr. Page selection sent every catalog line in one prompt; at the 348.6 chars a catalog line
averages on the reference KB (median 342.0, measured with the shipped
render_catalog_lineover the 675indexed articles), an 80K budget is spent at about 229 articles. Past that the client refuses
the prompt before sending,
_select_relevantcatches the refusal, and the answer is ungrounded. A500-article KB is therefore silently un-queryable.
retrieval/retrieve.py:69(_fit_catalog) now ranks the catalog by lexical overlap with the question andkeeps the lines that fit, so an oversized KB costs recall instead of dropping grounding whole. Ranking
needs a tokeniser that survives CJK, hence the new
py/src/kb_ai/_text.py: each ideograph is its owntoken, every other script is a word run matched through
\w. Restricted to[a-zA-Z0-9], a Chinese titletokenises to the empty set, every article scores 0.0, and the cut keeps whatever came first.
Three further defects review found in the fit, each reproducing the same silent loss of grounding:
negative, emptied the listing and overran the prompt limit on its own. Selection now reads a bounded
prefix (
_MAX_QUERY_CHARS,retrieve.py:41, applied at:112); the answering call still gets it all.for one 930-char line against a tight budget, that kept none of 21. It now skips and carries on.
\wcarries the underscore, socb_cooldown_secstayed one token and a question about "the cooldown"scored 0 against the article defining it. Reference-table keys are why the catalog line carries a keys
column at all, so those articles were dropped first.
_is now out of the word class (_text.py:35).Rate limits: 429 is retryable, and it is what gates concurrency
429 sat with the malformed-request statuses in the non-retryable branch, so a rate limit ended the call on
the first response and took the document's phase with it. It is the one 4xx that clears on its own, and it
fires harder the more concurrency a run is driven with, which made it a blocker for raising worker counts
at all.
llm/_completion.py:132-164retries it on the existing backoff, floored by the server'sRetry-Afterwhen the header carries a usable delay and capped at_RETRY_AFTER_CAP_S = 120.0(:51) soone absurd value cannot park a worker for the rest of a run. The HTTP-date form of
Retry-AfterisRFC-legal and deliberately ignored (honouring it means trusting our clock against the server's), and a
test now says so rather than only a docstring. The label stays distinct from the gateway family: both are
retryable, but only this one says concurrency is the cause.
Queue ingest: 4 documents at a time against the CLI's 16
A bulk ingest through the queue route ran 4 documents at a time. One queue task carries one document, so
that was the whole of a run's document-level parallelism, while
kb-ai compiledoes the same work 16-wide.The 500-document run took 8 hours, observed rather than instrumented. Against the baseline's ~272s of
serialized work per document, 500 documents at 4 lanes predicts about 9.5 hours, 18% pessimistic against
that 8, so the observed time is a throughput ceiling rather than a regression. The same arithmetic gives
about 3.2 hours at 12 lanes, which is an extrapolation and not a measurement.
worker.extract_workersgoes4 -> 12 and
ai.daemon.concurrency8 -> 16(
internal/config/config.go:103,138,etc/kaas.toml:27,52). 12 rather than the CLI's 16 because aqueue-route ingest shares the daemon's 16-slot semaphore with chat, derive and retrieval, so 16 would
saturate it for the length of the run, while
kb-ai compileowns its process and needs no such margin.The daemon pool rises with it because every dispatched document holds a daemon slot for its whole
pipeline, so a pool of 8 would just become the new limit.
Config: refuse a worker pool the daemon cannot serve
The daemon >= worker invariant was guarded by a test over the shipped defaults only, so a deployment
writing
extract_workers = 32loaded cleanly and got the daemon's 16-slot semaphore as the silent real cap,so the raise read as having bought nothing.
validate()now refuses the unpaired raise and names bothfigures (
internal/config/config.go:249-253). Refused rather than clamped: clamping would ignore thenumber the operator wrote down.
The comment justifying 12 was also wrong in a way future tuning would have relied on. It claimed the
per-phase fan-out inside Python collapses to one group because a queue task carries one document. It does
not: a document over 16,000 characters splits and each phase fans out to
min(chunks, KB_WORKERS)calls ofits own, so the worst case is the product: 12 x 16 = 192 in-flight calls, the figure a gateway's rate
limit is actually met with.
KB_WORKERShas no config field, but the daemon inherits the backend'senvironment (
bridge/daemon.go:98passesos.Environ()through), so exporting it is how the second factorgets bounded;
etc/kaas.toml:19-26now says so. 192 is labelled a ceiling for uniformly long documentsrather than an observed figure; the baseline log's documents average 2.8 chunks and peak at 9, so its
typical fan-out was near 34.
Classify: the ranking was dead, the fit wasted the budget, and the dedup was wrong
Two defects made the classifier create duplicate articles once a KB outgrew the prompt budget, and they
compounded.
_title_wordsstripped everything outside[a-zA-Z0-9\s], so a Chinese title tokenised to theempty set and
_relevance_score(core/classify.py:122) returned 0.0 for every article. The sort wasstable and therefore a no-op, leaving the cut to keep whichever articles came first. The same regexp welded
a hyphenated topic into one token, so the damage was never limited to Chinese: across all 982 extractions in
the reference KB, 13,721 of 16,289 topics are kebab-case slugs (84%), the mean best score across the
675-article catalog was 0.195 against
0.691 now, and 15% of extractions scored zero against every article, now 0%. Separately,
_fit_articles_to_budget(core/classify.py:149) halved the list until it fit, discarding 41-43% of whatthe budget holds: 84 entries kept where 143 fit at a 38K budget, 168 where 292 fit at 78K. It now walks
greedily, keeping every entry that fits and skipping (rather than stopping at) one whose block does not,
matching
retrieve._fit_catalog. Combined, the merge target survives the cut for 89% of the 675 articlesat 38K (was 83%) and 93% at 78K (was 88%).
Migrating
dedup_create_newto the shared tokeniser turned a missed-duplicate failure into a false-mergeone, which is the expensive direction: a false merge writes a document's knowledge into an article that
never claimed the subject and nothing later undoes it, while a missed duplicate leaves an article a later
compile can still merge. Five review rounds each found a class the previous fix had not closed, every one
killed by a measurement through the shipped
run_dedup_phaserather than by argument:发言复盘 2026-01against发言复盘 2026-03scored0.83,
上海against海上运输1.0,数据安全against安全数据库1.0.title's token count, so
向量检索/向量检索基础stopped deduplicating while the EnglishVector Search/Vector Search Basicspair still did, reopening the collision the dedup exists for.不支持向量检索carriesevery token of
支持向量检索: that pair scored 0.91,启用灰度发布against停用灰度发布0.80. Both were0.000 before the branch only because non-ASCII tokenised to nothing, and the same shape was already
reachable in English, where
Rate Limiting Enabled/Disabled On Gatewayscored 0.80 on both sides of thebranch. The polarity gate below puts all three at 0.000.
1 - 1/n, so_NEAR_IDENTICAL = 0.85was a bar on title length, not on rewordings, and it wascleared by every pair of 7 tokens or more (581 of the 675 titles).
允许跨境数据传输的合规评估结论against拒绝跨境数据传输的合规评估结论scored 0.857, an approve/refuse inversion. No constant fixes that, so thearm and its constant are deleted.
腾讯云到阿里云迁移方案and阿里云到腾讯云迁移方案carryexactly the same tokens, so every set-based score read 1.000 and the pair merged, as did
Migration From Redis To Kafka Decisionsagainst the same title with Redis and Kafka swapped.The rule that survived: a duplicate title is the same title with words added in place: nothing
substituted, nothing reordered, no disagreeing number, no unpaired negation.
core/classify.py:248-369implements it as a subsequence test over
_text.bigram_sequence(_text.py:45, bigram tokens in readingorder with repeats kept, CJK runs as character bigrams,
&read as the word it stands for), a numbers gate(
_NUMBER_TOKEN,:273, bare digits or a one-to-two-letter prefix over digits soq1,v2,h1count),a polarity gate (
_POLARITY_MARKERS,:263), and Dice at 0.7 (:250). Measured with the shippedfunctions: 0 false merges over 22 hand-labelled bad pairs (every one the five rounds named, all parametrised
in
py/tests/test_core_classify_scoring.py) and 1 miss over 10 hand-labelled duplicates. Over the 675titles, 23 pairs merge against the pre-branch rule's 46; of the 30 it refuses, 5 are genuine duplicates in
classes the docstring names.
Worker: a half-open circuit breaker no longer spends healthy documents' retries
A document that hit the breaker failed permanently although nothing was wrong with it. The diagnosis is one
layer below where it looks:
attemptsis spent byClaimNextat claim time(
internal/store/sqlite/sqlite.go:334) and no path ever gave it back;MarkFailed(retry=true)andRecoverExpiredboth leave it.Worker.Processfunnelledcircuit.ErrOpeninto the same Nack path as areal engine failure, so each refusal cost the document a retry for a call the engine never saw. Only one
task per half-open round passes the breaker's single trial, so raising
worker.extract_workersto 12turned 3 wasted attempts per round into 11. Both layers that were wrong are fixed:
store.ReleaseTask(internal/store/store.go:130,sqlite.go:427) is the inverse ofClaimNext:owner-scoped, back to pending and queued,
attempts = MAX(attempts - 1, 0), lease cleared, no errorrecorded.
queue.Releasewraps it (internal/queue/queue.go:108) andWorker.failEngine(
internal/worker/worker.go:187) routescircuit.ErrOpento it at either the extract or the pipelinestage. A real engine error still Nacks and still spends the attempt, so a poison document cannot retry
forever.
internal/worker/dispatcher.go:90-95):nothing while cooling down, one task while half-open, the full semaphore when closed. Without it the
release path would churn a whole batch of claims and releases every poll tick for as long as the probe
call runs.
Two alternatives were priced and rejected: moving the increment from claim to Nack loses crash-loop
protection (a worker that dies mid-task must still spend an attempt, which is why
RecoverExpiredkeepsit), and making the breaker block rather than reject holds the lease while waiting and risks lease expiry,
which burns the attempt anyway. A released task records no error, so the fix removed the
extract: circuit: breaker openrows that used to be the only sign of an outage; the dispatcher now logseach breaker transition once, on change rather than per tick (
dispatcher.go:73-75), since a long probeholds half-open for many ticks.
Also carried: the break-less script range spelled as escapes
Two commits unrelated to the scale defects, on the tokeniser module this branch introduces.
_SCRIPTIO_CONTINUA(_text.py:34) was written as the eight literal characters bounding its four blocks.Two of them, U+3040 and U+FAFF, are unassigned codepoints that render as an empty box, so the range was
unreadable next to a docstring that describes it numerically. It was also fragile: an NFKC pass over the file
rewrites U+30FF into two characters and U+F900 into U+8C48, leaving a character class that still compiles and
now runs unbroken from U+4E00 to U+FAFF. Nothing raises and nothing shows in a diff, but Yi, every Hangul
syllable and the private use area silently become one token per character, which moves every lexical score
in the retrieval and dedup paths. The constant is now non-raw escapes, resolved at parse time, and
_SCRIPTIO_CONTINUAwith all three compiled patterns was verified equal to the values captured before therewrite. Thirteen new cases in
test_text.pypin both ends of all four blocks and five word characters justoutside them; seven mutants on the range were killed, each reverted afterwards to confirm the new
assertions are what killed them.
Deliberately accepted costs and behaviour changes
pending <-> runningwithattemptsoscillating and
updated_atmoving, and if the engine is permanently down tasks requeue indefinitelyinstead of failing. That is the intended semantics (the work really is still pending), and the transition
log is where the outage shows.
ReleaseTaskdoes not clearerror. If an earlier delivery failed for real and a later one was merelyrefused, that message is the last thing actually known about the document. Expect this in the UI:
attemptscan fall while an older error stays on display, which could not happen before, because everyrequeue went through
MarkFailed.State()'s semantics rather than the loop. A probe that fails andre-opens between two poll ticks logs nothing. And once the cooldown elapses with an empty queue,
State()answers half-open with no probe running, so the last line reads half-open for the rest of a total outage.
drainis capped per tick (dispatcher.go:106): it no longer reuses slots freed mid-drain, so abacklog of fast-failing tasks drains at
maxConcper poll interval (12/s at defaults) instead of all atonce. Irrelevant for LLM-bound work; about 42s for 500 unreadable files.
breaker state ("is a trial slot free"), more surface than the churn is worth.
the subject still merges (
Gateway Migration Plan Deprecation); a respelling that splits a token counts asa substitution and is refused, which costs one real duplicate (
Global Architecture Bi-WeeklyagainstBiweekly, likewiseMay 6/May6); and the relation is not transitive, so a generic title acts as ahub (the corpus's 15 hub triples are all the one
发言复盘archive).duplicate article. Review judged it mergeable as is. The simpler alternative nobody has priced is dropping
the lexical backstop and leaving deduplication to the classifier's own output. The classify prompt itself
is untouched by all of this.
ErrOpenon an already-cancelled context(the lease is gone and
RecoverExpiredowns the row, and this is tested), and a transientSetStageerror(pre-existing, and that document did do work).
status = ?fromReleaseTask's WHEREclause is unobservable, because every exit from
runningclearslease_owner, so the owner predicatealready excludes those rows: defence-in-depth identical to
SetStage's andHeartbeat's. And restoring anunbounded
drainfor the closed arm survives, because catching the per-tick cap needs slots to free duringa drain and whether they do is a scheduler race, so no non-flaky test holds it. The cost of that one is
throughput, and the loop header states the bound.
Testing
Python, re-measured at the branch head:
uv run pytest -q --ignore=tests/test_distill.pygives 1763 passed /1 xfailed, against 1619 on the same exclusion at the fork point
eddefb9. The whole suite there gives 1639,which is not the same measurement: it counts the 20 distill tests that pass. That is not the whole suite, and
the caveat matters:
tests/test_distill.pyholds 21 tests and is excluded because one of them,test_distill_end_to_end_produces_article, makes a live gateway call and fails here. It is not a branchfailure. Its skip guard checks
LLM_API_KEYorOPENAI_API_KEY, the second of which is set in thisenvironment, so the test runs, but
LLM_BASE_URLis unset and it has no endpoint to reach. The count islower than the 1770 measured at
fdbb1adfor the same reason: 1770 counted the 20 distill tests that dopass, and 13 text cases have been added since. Coverage, measured on that run:
core/classify.py,_text.pyand
retrieval/retrieve.pyall 100%.Go, re-measured at the branch head after the second merge:
go test -count=1 ./...passes across all 15 packages,go vet ./...andgo build ./...clean,gofmtclean on every file this branch touched. 178 tests acrossthe three packages the worker work touched,
with
internal/workergreen under-raceand under-shuffle=on. Coverage:internal/worker98.7%,internal/queue100%,internal/store/sqlite93.0%, andReleaseTask,failEngine,releaseanddraineach 100%. Five files elsewhere (
internal/api/{api_test,chat,session}.go,internal/bridge/daemon.go,internal/worker/worker_test.go) were already unformatted onorigin/mainand were left alone.Mutation testing, always with the repository untouched (Python: a
/tmpcopy plusPYTHONPATH; Go:throwaway git worktrees): 11 mutants in the config/retry follow-up, 39 of 39 in the classify work, 34 in the
worker work, 7 on the tokeniser range. Every mutant that changes production behaviour was killed, with the
two deliberate exceptions listed above, and the reverse control was green each time. The classify set
includes the subsequence test relaxed back to set containment, tested one way only, or walking a fresh
iterator per token (plain set containment in disguise);
_NUMBER_TOKENwidened and narrowed; both halves ofthe polarity list deleted.
The retry set includes
_TIMEOUT_BACKOFF_BASE10 -> 7, which killed 9 tests once the assertions stoppedreading the constant out of the module under test, and 0 of 44 before. Two worker mutants survived until the
tests were strengthened, both for the same reason: a bound asserted where an exact count was available. Two
further worker experiments were invalid (one hit a compile error, one hung on the pre-existing unguarded
<-startedinTestDispatcherRespectsConcurrencyCap, which hangs rather than fails under mutation).Every review round found the same pattern, never in the production code: an assertion narrower than the
behaviour it stood in for, fixed by widening the test. Swapping the
"extract: %v"/"pipeline: %v"stageliterals left the whole suite green, and
TestDispatcherPausesWhenBreakerOpenstays green when a task isclaimed and handed straight back, so
case StateOpen: continuehad no test at all.Base
origin/mainis merged into this branch twice, at48b4db3and36c2f8c, rather than rebased onto it. Thebranch forked at
eddefb9. The first merge brought in0d76328, which editsWorker.Process'sPipelineRequest(Model: w.cfg.Model), the same function the worker work changed, plus253e25dand05f51ea, both CodeQL workflow changes. Merging keeps main's fix alongside this branch's, and keeps thereviewed commits and the SHAs the session log references intact.
0d76328landed with no test, so one commit here adds one (process_test.go, inside the existingTestProcessPassesConfigToEngine, which already records both bridge requests) and correctsConfig.Model'scomment, which listed only
ExtractRequest.Model. Two mutants confirm the assertion bites: dropping thefield, and forwarding
SummarizeModelinstead.The second merge,
36c2f8c, brought in #55, where this branch's two pure-translation commitsa791320ande4295f6landed, plus #54 and #50. #55 landed as a squash, so main's copies of those files share noancestry with the branch's and all seven conflicted. Five were pure translation and were taken whole from
main, whose copies carry review fixes made after this branch forked; they are byte-identical to
origin/mainnow.etc/kaas.tomlandpy/README.mdcarry both translation and this branch's configcontent, so they were merged by hand, keeping main's comment corrections on top of this branch's
extract_workers = 12,concurrency = 16and theKB_WORKERS/KAAS_DAEMON_MAX_WORKERSrows.The two translation commits still show in the commit list because they are ancestors, but their content is
main's now, so this pull request contains no translation file.
git diff --name-only origin/main...HEADis25 files, +2472 / -154; the only prose in it is
py/README.md's two environment-variable rows, whichdocument this branch's own concurrency change.
🤖 Generated with Claude Code