Skip to content

G2: chunking contract test + the four silent defects it found - #536

Merged
brentrager merged 4 commits into
mainfrom
g2-chunking-pipeline
Aug 23, 2026
Merged

G2: chunking contract test + the four silent defects it found#536
brentrager merged 4 commits into
mainfrom
g2-chunking-pipeline

Conversation

@brentrager

Copy link
Copy Markdown
Contributor

Problem

Gap G2 (document processing / chunking pipeline) was recorded open — "our knowledge store assumes pre-chunked text" — but the Chunker the 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:

Defect Why it was silent
max_chars was not a cap on the emitted chunk Overlap was prepended on top of a chunk that already filled the cap, so a default 500/64 chunker emitted up to 565 chars. Over the embedding model's limit the API truncates rather than rejects — the tail is dropped from the index with nothing logged. Overlap now comes out of the packing budget.
Text without spaces never spilled Splitting on word boundaries only meant a Chinese/Japanese/Thai document — or a long URL, or a minified blob — has exactly one "word", so a 50k-char document came back as one unbounded chunk. Fallback now cuts on character boundaries.
CRLF documents lost all paragraph structure "\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.
A chunk could straddle two markdown sections Which attributes section A's text to section B's heading at retrieval time. A heading is now a hard boundary.

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.rs in place and leaving the tests fails 6 of 16:

a_markdown_heading_starts_a_new_chunk          the two sections must not share a chunk
no_chunk_exceeds_the_cap_even_with_overlap_on  cap 80/overlap 20: chunk is 83 chars
a_single_word_longer_than_the_cap_is_split     chunk is 250 chars
crlf_documents_split_on_paragraphs_like_lf     left: ["alpha paragraph beta", …]
a_degenerate_config_still_terminates           cap 1/overlap 999: chunk is 5 chars
text_with_no_whitespace_still_spills           unspaced text must still spill, got 1 chunk(s)
test result: FAILED. 9 passed; 6 failed

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:

panicked at src/chunker.rs:261: end byte index 7 is not a char boundary;
it is inside '🚀' (bytes 6..10 of string)

Licensing

The Smoo AI monorepo's rust/knowledge-ingest/src/chunker.rs was 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

  • Rust only. Chunking runs server-side in the ingestion crate; the other four languages have no ingestion pipeline to port this into.
  • No public API changeChunker::new / chunk keep 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.md updated in this PR.

Note for the reviewer

I did not make Chunker a pluggable trait alongside Embedder/Reranker. Those seams exist because they have real alternative implementations (gateway vs deterministic vs noop); a Chunker trait here would have exactly one impl and would push dyn/generics through ingest(), indexing, and eight call sites for no second implementation. The concrete Chunker is 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

@changeset-bot

changeset-bot Bot commented Aug 23, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 12f6271

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
@smooai/smooth-operator Patch
@smooai/smooth-operator-web-chat-example Patch

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
brentrager force-pushed the g2-chunking-pipeline branch from f815ce5 to d75a62a Compare August 23, 2026 02:03
brentrager and others added 3 commits August 22, 2026 22:14
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
brentrager force-pushed the g2-chunking-pipeline branch from d75a62a to 792b947 Compare August 23, 2026 02:16
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>
@brentrager
brentrager merged commit f62ee95 into main Aug 23, 2026
3 checks passed
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