G2: chunking contract test + the four silent defects it found - #536
Merged
Conversation
🦋 Changeset detectedLatest commit: 12f6271 The changes in this PR will be included in the next version bump. This PR includes changesets to release 2 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
brentrager
added a commit
that referenced
this pull request
Aug 23, 2026
…-tests trap Takes sole ownership of §G2 in the gap doc so #535 and #536 don't conflict on the same section. Folds in #535's correct finding — format extraction (PDF / DOCX / rich HTML → text) still has to land ahead of the chunker before any document-store connector works. Also records why the pre-existing green unit tests were not evidence the chunker was correct: each asserted its rule on the one input shape that never triggers the bug (cap checked only with overlap off, spill only on spaced ASCII, paragraphs only with LF). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
brentrager
added a commit
that referenced
this pull request
Aug 23, 2026
g2chunk's #536 wrote the G2 contract suite and it found four silent defects in the shipped chunker (overlap pushed emitted chunks past max_chars; unspaced CJK/URL text never spilled; CRLF destroyed paragraph structure; chunks straddled markdown sections). My §G2 text called the existing unit tests adequate because they were green and named every rule — which is exactly the reasoning this document exists to stop. Each of those tests asserted its rule on the one input shape that never triggers the bug. §G2 reverts to pristine; #536 owns it. Also corrects my own §G1 bullet. I wrote that an AclKnowledgeStore records and enforces the ACL, which reads as though the fence is opt-in wrapping. Traced it: enforcement is adapter-side in all three backends — Postgres parses DocAcl::ACL_METADATA_KEY at ingest into knowledge_vectors.acl and filters in SQL, DynamoDB into an acl attribute post-filtered at read, and InMemoryStorageAdapter already wraps its own knowledge slice. The live admin indexing path passes state.storage.knowledge() and is fenced by that, not by the caller remembering to wrap. The pipeline writing the metadata is therefore the one link no adapter provides for itself, which is what the contract test guards.
brentrager
added a commit
that referenced
this pull request
Aug 23, 2026
…ipped (#535) * g1: fence the ingest→ACL chain at the pipeline seam, and mark G1/G2/G9 shipped G1's implementation already landed (315841c and follow-ups): the `Connector` seam, `MockConnector`, the file/web/github connectors, the chunker, incremental indexing. `docs/Planning/Feature Gaps.md` was never updated to say so, so §G1, §G2 and §G9 still read as open gaps while G3 and G8 carry ✅ markers. That is how a second agent gets sent to build what exists. The one real hole underneath the stale doc: the guarantee that a connector's ACL survives ingestion was asserted end to end in exactly one place — `github_connector.rs::custom_group_gates_retrieval_end_to_end`. That is a connector test. Rewrite or delete the GitHub connector and the ingest half of G3 loses its only fence, with `ingestion_contract.rs` still green. `ingested_acls_gate_retrieval_for_every_connector` asserts the same chain at the pipeline seam over a `MockConnector`, so it binds every connector present and future: a doc ingested for `group-eng` is readable by a principal carrying that group and returns nothing for `group-fin` or anonymous, while a doc with no ACL stays org-public. Each negative is paired with the entitled-principal positive control on the same query — "nothing leaked" must not be satisfiable by a pipeline that stored nothing, which is a failure mode this repo has shipped. Red before green: with `DocAcl::for_groups(...).attach_to(document)` reverted in pipeline.rs the new test fails with `group-fin must not read the group-eng doc, got 1 hits` — the exact G3 cross-user leak — while the pre-existing contract test stays green, which is what kept the gap invisible. The doc now records what shipped, plus what the ✅ does not cover: `pull` returns `Vec<RawDocument>` rather than the planned `Stream<Document>`, which materializes a whole source per pull and should be re-shaped before Confluence/Jira rather than after (it is a breaking trait change); format extraction (PDF/DOCX) does not exist ahead of the chunker; and rust.yml has no `schedule:`, so G9's gated `external` tier is a convention nothing ever executes. No production behavior changes — one test, one changeset, one doc. * docs: retire the closed items in §5 so the list stops arguing for finished work #534 landed because §G6 claimed "we only helm lint" after the kind deploy-smoke job had shipped. §5's priority list had the same rot: item 1 still named G3 as the highest-severity next increment, and item 4 pointed at G2/G9 by bare letter. Items 1 and 2 are struck (G3 and G1 shipped). Item 4 now names the specific remainders rather than the letters — G2's chunker is done and its format extraction is not; G9's mock and credential-free tier are done and the nightly that would run the gated external tier does not exist. "G2 is done" and "G2's chunker is done, format extraction isn't" send whoever picks this up to very different places. * docs: hand §G2 to #536, and state where ACL enforcement actually lives g2chunk's #536 wrote the G2 contract suite and it found four silent defects in the shipped chunker (overlap pushed emitted chunks past max_chars; unspaced CJK/URL text never spilled; CRLF destroyed paragraph structure; chunks straddled markdown sections). My §G2 text called the existing unit tests adequate because they were green and named every rule — which is exactly the reasoning this document exists to stop. Each of those tests asserted its rule on the one input shape that never triggers the bug. §G2 reverts to pristine; #536 owns it. Also corrects my own §G1 bullet. I wrote that an AclKnowledgeStore records and enforces the ACL, which reads as though the fence is opt-in wrapping. Traced it: enforcement is adapter-side in all three backends — Postgres parses DocAcl::ACL_METADATA_KEY at ingest into knowledge_vectors.acl and filters in SQL, DynamoDB into an acl attribute post-filtered at read, and InMemoryStorageAdapter already wraps its own knowledge slice. The live admin indexing path passes state.storage.knowledge() and is fenced by that, not by the caller remembering to wrap. The pipeline writing the metadata is therefore the one link no adapter provides for itself, which is what the contract test guards.
brentrager
force-pushed
the
g2-chunking-pipeline
branch
from
August 23, 2026 02:03
f815ce5 to
d75a62a
Compare
Gap G2 was recorded open, but the Chunker the connectors feed shipped with G1. What was missing was the contract suite — and writing it found the chunker wrong on four counts, each of which fails silently rather than loudly. tests/chunking.rs (16 tests) pins chunk count/indices/ids, overlap as a shared word run, metadata+title+source+acl propagation, oversized spill with no word lost, UTF-8 integrity at the boundary, and degenerate configs. Fixed against it: - max_chars is now a hard cap on the emitted chunk, overlap included. Overlap was prepended on top of an already-full chunk, so a default 500/64 chunker emitted up to 565 chars — past the limit the embedding API truncates at. - Text without spaces now spills. Word-boundary-only splitting returned a Chinese/Japanese/Thai document, a long URL or a minified blob as ONE unbounded chunk. The fallback cuts on character boundaries. - CRLF documents split on paragraphs. "\r\n\r\n" contains no "\n\n", so every Windows-authored document arrived as one giant paragraph. - A markdown heading is a hard boundary, so no chunk straddles two sections. Characters, never bytes: slicing to a byte offset to hit a character cap cuts an em-dash or emoji in half. The UTF-8 guard is built on unspaced mixed-width text because spaced text never reaches the character-split path — the first version, built from words, passed against a deliberately byte-slicing implementation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…-tests trap Takes sole ownership of §G2 in the gap doc so #535 and #536 don't conflict on the same section. Folds in #535's correct finding — format extraction (PDF / DOCX / rich HTML → text) still has to land ahead of the chunker before any document-store connector works. Also records why the pre-existing green unit tests were not evidence the chunker was correct: each asserted its rule on the one input shape that never triggers the bug (cap checked only with overlap off, spill only on spaced ASCII, paragraphs only with LF). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A dropped ACL in the chunker fails open, not closed: the pipeline turns Chunk.acl into the DocAcl the adapters filter on, so a chunk arriving with acl: None is stored org-public and retrievable by anyone, with nothing logged. The existing assertion covered the packed path only; this covers the word-spill, character-spill and heading-break paths added in the previous commit. Mutation-checked — forcing acl: None in the chunk builder fails it. Reported by g1conn while tracing adapter-side ACL enforcement for #535. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
brentrager
force-pushed
the
g2-chunking-pipeline
branch
from
August 23, 2026 02:16
d75a62a to
792b947
Compare
My §G2 said the other four languages have no ingestion pipeline to port the fixes into. That is true of Go, TypeScript and Python, which never split text, but false of .NET: dotnet/server/src/Chunker.cs is a second chunker carrying all four defects, plus a fifth unique to it — C# string.Length counts UTF-16 code units where Rust counts chars(), so the two implementations chunk the same document to different boundaries for any astral-plane codepoint. Filed as pearl th-ed78a9 rather than fixed here, to keep this PR to the reference implementation. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.
Problem
Gap G2 (document processing / chunking pipeline) was recorded open — "our knowledge store assumes pre-chunked text" — but the
Chunkerthe connectors feed shipped with G1. What was actually missing was the contract suite the gap doc asked for.Writing it found the chunker wrong on four counts. Every one fails silently: no error, no panic, just a chunk the embedding API quietly truncates or a document that never really got chunked.
What shipped
rust/ingestion/tests/chunking.rs— 16 tests pinning chunk count / dense indices / stable ids, overlap as a shared run of words, metadata + title + source + acl propagation onto every chunk, oversized spill with no word lost, UTF-8 integrity at the boundary, and degenerate configs.Fixed against it, in
rust/ingestion/src/chunker.rs:max_charswas not a cap on the emitted chunk"\r\n\r\n"contains no"\n\n", so every Windows-authored file and many HTTP-fetched pages arrived as a single giant paragraph. Content is CRLF-normalized first.Characters, never bytes, throughout: slicing to a byte offset to hit a character cap cuts an em-dash or an emoji in half — a panic in Rust, silent mojibake in a port.
Proof of red
Reverting
chunker.rsin place and leaving the tests fails 6 of 16:The UTF-8 guard needed a second pass, and that is the part worth reviewing. Built from space-separated words it passed against a deliberately byte-slicing implementation — because spaced text never reaches the character-split path at all. It guarded nothing. Rebuilt on unspaced mixed-width text (
"aé数🚀—"), the same mutant panics:Licensing
The Smoo AI monorepo's
rust/knowledge-ingest/src/chunker.rswas read as prior art and nothing was copied. That chunker's entire purpose is byte-parity with a proprietary TypeScript ingest path so its content-addressing lines up across two pipelines — Smoo-specific business logic with no meaning here. Deliberately left behind: the content-addressing scheme (sha256(documentId:chunkIndex)ids and content hashes for an upsert diff), the LangChain-parity separator ladder and its JS-semantics quirks, and every reference to the private ingestion schema. What crossed the boundary is one idea, reimplemented in six lines: a markdown heading is a chunk boundary.Scope
Chunker::new/chunkkeep their signatures. All 41 pre-existing ingestion tests and the 129 server tests still pass.docs/Planning/Feature Gaps.md§G2 marked done +docs/Architecture/Ingestion Pipeline.mdupdated in this PR.Note for the reviewer
I did not make
Chunkera pluggable trait alongsideEmbedder/Reranker. Those seams exist because they have real alternative implementations (gateway vs deterministic vs noop); aChunkertrait here would have exactly one impl and would pushdyn/generics throughingest(),indexing, and eight call sites for no second implementation. The concreteChunkeris already the pipeline's injection point and already defaults to something deterministic and network-free. Say the word if you want the trait anyway and it's a small follow-up.🤖 Generated with Claude Code