Skip to content

fix: the four defects a ~500-article corpus surfaces - #56

Open
lucasmaan wants to merge 25 commits into
mainfrom
fix/scale-defects-at-500-articles
Open

lucasmaan wants to merge 25 commits into
mainfrom
fix/scale-defects-at-500-articles

Conversation

@lucasmaan

Copy link
Copy Markdown
Contributor

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 of
wall 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 listing
both classify and retrieval read; wiki/ holds 682 files and 7 of them parse no frontmatter title, so they
never 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_line over the 675
indexed articles), an 80K budget is spent at about 229 articles. Past that the client refuses
the prompt before sending, _select_relevant catches the refusal, and the answer is ungrounded. A
500-article KB is therefore silently un-queryable.

retrieval/retrieve.py:69 (_fit_catalog) now ranks the catalog by lexical overlap with the question and
keeps 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 own
token, every other script is a word run matched through \w. Restricted to [a-zA-Z0-9], a Chinese title
tokenises 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:

  • Nothing capped a chat message, so a pasted document arrived as the query, drove the catalog budget
    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.
  • The scan stopped at the first line too long to fit, discarding every shorter article ranked below it:
    for one 930-char line against a tight budget, that kept none of 21. It now skips and carries on.
  • \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 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-164 retries it on the existing backoff, floored by the server's
Retry-After when the header carries a usable delay and capped at _RETRY_AFTER_CAP_S = 120.0 (:51) so
one absurd value cannot park a worker for the rest of a run. The HTTP-date form of Retry-After is
RFC-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 compile does 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_workers goes
4 -> 12 and ai.daemon.concurrency 8 -> 16
(internal/config/config.go:103,138, etc/kaas.toml:27,52). 12 rather than the CLI's 16 because 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.
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 = 32 loaded 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 both
figures (internal/config/config.go:249-253). 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, 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_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; etc/kaas.toml:19-26 now says so. 192 is labelled a ceiling for uniformly long documents
rather 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_words stripped everything outside [a-zA-Z0-9\s], so a Chinese title tokenised to the
empty set and _relevance_score (core/classify.py:122) 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: 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 what
the 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 articles
at 38K (was 83%) and 93% at 78K (was 88%).

Migrating dedup_create_new to the shared tokeniser turned a missed-duplicate failure into a false-merge
one, 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_phase rather than by argument:

  • Date-differing siblings and CJK character subsets: 发言复盘 2026-01 against 发言复盘 2026-03 scored
    0.83, 上海 against 海上运输 1.0, 数据安全 against 安全数据库 1.0.
  • A length-ratio guard binding twice as hard on Chinese as on English, because bigrams roughly double a CJK
    title's token count, so 向量检索/向量检索基础 stopped deduplicating while the English
    Vector Search/Vector Search Basics pair still did, reopening the collision the dedup exists for.
  • A one-character negation reads as an addition rather than a substitution, since 不支持向量检索 carries
    every token of 支持向量检索: that pair scored 0.91, 启用灰度发布 against 停用灰度发布 0.80. Both were
    0.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 Gateway scored 0.80 on both sides of the
    branch. The polarity gate below puts all three at 0.000.
  • The arithmetic that broke all of it: for two n-token titles differing in ONE token, Dice is exactly
    1 - 1/n, so _NEAR_IDENTICAL = 0.85 was a bar on title length, not on rewordings, and it was
    cleared 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 the
    arm and its constant are deleted.
  • Set containment cannot see a transposition: 腾讯云到阿里云迁移方案 and 阿里云到腾讯云迁移方案 carry
    exactly the same tokens, so every set-based score read 1.000 and the pair merged, as did
    Migration From Redis To Kafka Decisions against 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-369
implements it as a subsequence test over _text.bigram_sequence (_text.py:45, bigram tokens in reading
order 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 so q1, v2, h1 count),
a polarity gate (_POLARITY_MARKERS, :263), and Dice at 0.7 (:250). Measured with the shipped
functions: 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 675
titles, 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: attempts is spent by ClaimNext at claim time
(internal/store/sqlite/sqlite.go:334) and no path ever gave it back; MarkFailed(retry=true) and
RecoverExpired both leave it. Worker.Process funnelled circuit.ErrOpen into the same Nack path as a
real 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_workers to 12
turned 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 of ClaimNext:
    owner-scoped, back to pending and queued, attempts = MAX(attempts - 1, 0), lease cleared, no error
    recorded. queue.Release wraps it (internal/queue/queue.go:108) and Worker.failEngine
    (internal/worker/worker.go:187) routes circuit.ErrOpen to it at either the extract or the pipeline
    stage. A real engine error still Nacks and still spends the attempt, so a poison document cannot retry
    forever.
  • The dispatcher scales its batch to what the breaker admits (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 RecoverExpired keeps
it), 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 open rows that used to be the only sign of an outage; the dispatcher now logs
each breaker transition once, on change rather than per tick (dispatcher.go:73-75), since a long probe
holds 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_CONTINUA with all three compiled patterns was verified equal to the values captured before the
rewrite. Thirteen new cases in test_text.py pin both ends of all four blocks and five word characters just
outside 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

  • A refused task flaps in the task list. An operator sees it move pending <-> running with attempts
    oscillating and updated_at moving, and if the engine is permanently down tasks requeue indefinitely
    instead of failing. That is the intended semantics (the work really is still pending), and the transition
    log is where the outage shows.
  • ReleaseTask does not clear error. If an earlier delivery failed for real and a later one was merely
    refused, that message is the last thing actually known about the document. Expect this in the UI:
    attempts can fall while an older error stays on display, which could not happen before, because every
    requeue went through MarkFailed.
  • Two gaps in the transition log come from State()'s semantics rather than the loop. A probe that fails and
    re-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.
  • drain is capped per tick (dispatcher.go:106): it no longer reuses slots freed mid-drain, so a
    backlog of fast-failing tasks drains at maxConc per poll interval (12/s at defaults) instead of all at
    once. Irrelevant for LLM-bound work; about 42s for 500 unreadable files.
  • A probe in flight still costs one claim/release round-trip per tick, 2 SQLite writes. Removing it needs new
    breaker state ("is a trial slot free"), more surface than the churn is worth.
  • The dedup rule has three limits, each in the docstring and each pinned by a test: an addition that narrows
    the subject still merges (Gateway Migration Plan Deprecation); a respelling that splits a token counts as
    a substitution and is refused, which costs one real duplicate (Global Architecture Bi-Weekly against
    Biweekly, likewise May 6 / May6); and the relation is not transitive, so a generic title acts as a
    hub (the corpus's 15 hub triples are all the one 发言复盘 archive).
  • The dedup backstop is now three gates, two tokenisers and one threshold, and its failure mode is a
    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.
  • Two attempt-burning paths remain, and both are judged correct: ErrOpen on an already-cancelled context
    (the lease is gone and RecoverExpired owns the row, and this is tested), and a transient SetStage error
    (pre-existing, and that document did do work).
  • Two mutation survivors are left in place on purpose. Dropping status = ? from ReleaseTask's WHERE
    clause is unobservable, because every exit from running clears lease_owner, so the owner predicate
    already excludes those rows: defence-in-depth identical to SetStage's and Heartbeat's. And restoring an
    unbounded drain for the closed arm survives, because catching the per-tick cap needs slots to free during
    a 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.py gives 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.py holds 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 branch
failure. Its skip guard checks LLM_API_KEY or OPENAI_API_KEY, the second of which is set in this
environment, so the test runs, but LLM_BASE_URL is unset and it has no endpoint to reach. The count is
lower than the 1770 measured at fdbb1ad for the same reason: 1770 counted the 20 distill tests that do
pass, and 13 text cases have been added since. Coverage, measured on that run: core/classify.py, _text.py
and retrieval/retrieve.py all 100%.

Go, re-measured at the branch head after the second merge: go test -count=1 ./... passes across all 15 packages,
go vet ./... and go build ./... clean, gofmt clean on every file this branch touched. 178 tests across
the three packages the worker work touched,
with internal/worker green under -race and under -shuffle=on. Coverage: internal/worker 98.7%,
internal/queue 100%, internal/store/sqlite 93.0%, and ReleaseTask, failEngine, release and drain
each 100%. Five files elsewhere (internal/api/{api_test,chat,session}.go, internal/bridge/daemon.go,
internal/worker/worker_test.go) were already unformatted on origin/main and were left alone.

Mutation testing, always with the repository untouched (Python: a /tmp copy plus PYTHONPATH; 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_TOKEN widened and narrowed; both halves of
the polarity list deleted.
The retry set includes _TIMEOUT_BACKOFF_BASE 10 -> 7, which killed 9 tests once the assertions stopped
reading 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
<-started in TestDispatcherRespectsConcurrencyCap, 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" stage
literals left the whole suite green, and TestDispatcherPausesWhenBreakerOpen stays green when a task is
claimed and handed straight back, so case StateOpen: continue had no test at all.

Base

origin/main is merged into this branch twice, at 48b4db3 and 36c2f8c, rather than rebased onto it. The
branch forked at eddefb9. The first merge brought in 0d76328, which edits Worker.Process's
PipelineRequest (Model: w.cfg.Model), the same function the worker work changed, plus 253e25d and
05f51ea, both CodeQL workflow changes. Merging keeps main's fix alongside this branch's, and keeps the
reviewed commits and the SHAs the session log references intact.

0d76328 landed with no test, so one commit here adds one (process_test.go, inside the existing
TestProcessPassesConfigToEngine, which already records both bridge requests) and corrects Config.Model's
comment, which listed only ExtractRequest.Model. Two mutants confirm the assertion bites: dropping the
field, and forwarding SummarizeModel instead.

The second merge, 36c2f8c, brought in #55, where this branch's two pure-translation commits a791320 and
e4295f6 landed, plus #54 and #50. #55 landed as a squash, so main's copies of those files share no
ancestry 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/main now. etc/kaas.toml and py/README.md carry both translation and this branch's config
content, so they were merged by hand, keeping main's comment corrections on top of this branch's
extract_workers = 12, concurrency = 16 and the KB_WORKERS / KAAS_DAEMON_MAX_WORKERS rows.

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...HEAD is
25 files, +2472 / -154; the only prose in it is py/README.md's two environment-variable rows, which
document this branch's own concurrency change.

🤖 Generated with Claude Code

lucasmaan and others added 25 commits August 22, 2026 21:11
…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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant