LearnNewsExamplesServices
Frontmatter
id17425
titleEvery schema-conforming parser's chunks embed behind the literal word "undefined"
stateClosed
labels
bugaiagent-os
assigneesneo-opus-vega
createdAtAug 20, 2026, 6:39 PM
updatedAtAug 20, 2026, 8:48 PM
githubUrlhttps://github.com/neomjs/neo/issues/17425
authorneo-opus-vega
commentsCount0
parentIssue17411
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[]
closedAtAug 20, 2026, 8:48 PM

Every schema-conforming parser's chunks embed behind the literal word "undefined"

Closed Backlog/active-chunk-18 bugaiagent-os
neo-opus-vega
neo-opus-vega commented on Aug 20, 2026, 6:39 PM

Context

Found while measuring a stalled external tenant ingestion lane on 2026-08-20. Not inferred from reading — measured against the production method:

node -e "VectorService.buildEmbeddingInputText(chunkShapedExactlyAsParsedChunkV1Requires)"

v1  (schema-conforming): "undefined: Foo in Foo"
legacy (type present)  : "class: Foo in Foo"

The provider input string for every chunk whose parser satisfies the current published contract begins with the literal token undefined. That string is what gets embedded, so the token is in the vector.

Live latest-open sweep: checked latest 20 open issues at 2026-08-20T16:37Z; A2A claim sweep over the last window; no equivalent found. Nearest neighbour is #17392 (parser-identity orphans), whose ACs are entirely about reconciling stale rows — a different subject.

The Problem

buildEmbeddingInputText reads chunk.type. The canonical chunk contract does not have a type.

ai/services/knowledge-base/parser/parsed-chunk-v1.schema.json requires ['schemaVersion', 'tenantId', 'repoSlug', 'rootKind', 'sourcePath', 'content', 'hashInputs', 'parserId', 'parserVersion', 'kind', 'name'] and declares no type property at all. So a parser written against the published schema — which is every external and tenant parser — emits no type, and its header renders undefined.

Blast radius is bounded and worth stating precisely. All three in-repo parsers (SourceParser, TestParser, DocumentationParser) emit type alongside kind, so neo's own corpus is unaffected. The defect is confined to schema-conforming parsers — the external and tenant path, which is exactly the path a client-facing deployment runs.

The error path already knows. recordOversizedEmbeddingSkip reads chunk.kind || chunk.type when building its telemetry (IngestionService.mjs:1599), one function away from a production path that reads only chunk.type. The oversized-skip report names the kind correctly while the embedding it is reporting on does not.

The Architectural Reality

Three sites read the wrong field, and they are two copies of one expression plus a byte count derived from it:

site expression consequence
ai/services/knowledge-base/VectorService.mjs:594 `${chunk.type}: ${chunk.name} in …` the embedded text
ai/services/knowledge-base/IngestionService.mjs:1478 same string, second copy the guardrail's view of the same text
ai/services/knowledge-base/VectorService.mjs:695 Buffer.byteLength(\${chunk.type}: …`)` prefixBytes for the admission band

Three other sites in the same two files already read both fields — IngestionService.mjs:1599, :2278, and the kind entry in hashInputs. The pattern exists; these three did not adopt it.

The third site's practical error is small and should not be oversold: "undefined" is 9 bytes against real kinds of 4–14, so prefixBytes is off by a few bytes either way. It is worth fixing in the same edit because it is the same expression, not because it is an admission-band bug.

⚠️ The fix must be type || kind, NOT kind || type — and that inverts the sibling precedent

type and kind are not synonyms. In SourceParser (:176-222), type is defaultType — the corpus bucket, 'src' / 'app' / 'example' — while kind is the chunk shape, 'module-context' / 'class-properties' / 'class-config' / 'method'. A neo chunk carries both, with different values.

So kind || type would change the header for every existing neo chunk from "src: …" to "method: …". And that change would not be repaired by re-ingestion, because the embedding text is not part of the chunk hash: computeChunkHash folds only the hashInputs field list (['kind','name','content','sourcePath','parserId','parserVersion'], IngestionService.mjs:990), and the derived text is not in it. Existing rows would keep vectors built from the old string, new rows would carry the new one, and no reconciliation signal distinguishes them — the silently-mixed-corpus class #17392 exists to catch.

type || kind fills the gap for chunks that have no type and leaves every chunk that has one byte-identical. Zero re-embed, zero mixed corpus.

Whether the header should carry the chunk shape rather than the corpus bucket is a real question and a separate, corpus-invalidating one. Out of scope here.

Contract Ledger Matrix

AMENDED 2026-08-20 on @neo-gpt's Required Action 1 against PR #17426. The prior table specified two independent builders plus a planner deriving from "the same expression" — three call sites agreeing by discipline. The shipped shape is one pure authority they all read, which is what the review asked for: the earlier form left IngestionService calling the imported VectorService singleton, a second authority sitting beside the configurable this.vectorService seam.

Target Surface Source of Authority Proposed Behavior Fallback Docs Evidence
helpers/embeddingInputFormat.mjsbuildEmbeddingInputHeader / buildEmbeddingInputText (new, pure, Neo-free) parsed-chunk-v1.schema.json (requires kind, declares no type) the definition of the provider input string: chunk.type || chunk.kind header, then body. Type-first, so a schema-conforming chunk is named by its kind while a chunk carrying both stays byte-identical neither field present ⇒ unchanged from today for that malformed chunk; never a throw parser/identity-tuple.md measured "undefined: Foo in Foo" on a v1-shaped chunk
VectorService.buildEmbeddingInputHeader / buildEmbeddingInputText the helper above thin call sites, retained so callers and specs keep one entry point while the contract has one home the two service copies had drifted from three sibling sites that already read both fields
VectorService byte-budget planner (splitOversizedEmbeddingChunk) the helper's header output prefixBytes measures that string; it does not restate its format a planner carrying its own copy budgets against a string that may not be the one sent
IngestionService.buildEmbeddingInputText the helper above, not this.vectorService the guardrail measures by construction the same string the provider receives the seam is documented as the downstream embedding/upsert service; a pure format is not a per-deployment choice

Decision Record impact

none. The chunk contract is governed by parsed-chunk-v1.schema.json and parser/identity-tuple.md, not by an ADR. This ticket conforms the readers to the published schema; it changes no decision.

Acceptance Criteria

  • RED-PROOF: against dev, an assertion that a chunk shaped exactly as parsed-chunk-v1 requires (kind present, no type key) produces an embedding input text naming its kind must FAIL, with the failure showing the literal undefined. Asserted through the production method, not a re-implementation of its template.
  • NO-DRIFT CONTROL, and this is the arm that matters most: a chunk carrying both fields with different values (type: 'src', kind: 'method' — the real SourceParser shape) produces a header naming type, byte-identical to today. Without this arm, kind || type passes the red-proof above and silently re-writes the whole neo corpus's embedding text with no reconciliation signal.
  • Both copies of the builder produce the same string for the same chunk, asserted by comparing them directly rather than by asserting each against a literal — the two are documented as one shape and drifted anyway.
  • The byte-budget planner measures the shared header rather than restating its format, so a further copy cannot appear. Asserted through the splitter's own output, not the header builder's — two chunks differing only in header length must receive budgets differing by exactly that, and a restated format collapses the difference to zero. (The first version of this arm asserted the builder's byte delta and stayed green against a restored planner copy — the wrong subject for a claim about the planner.)
  • Neither field present ⇒ no crash and no new behaviour. A malformed chunk must not start throwing where it previously produced a string; asserted explicitly, because the schema requires kind and a guard that assumes compliance is the reason this ticket exists.
  • Mutation-verified per site: reverting any one of the three sites to chunk.type turns exactly one arm red.
  • Corpus non-participation is asserted through the HASH FUNCTION, not through source syntax. createChunkHash is observed with stage-matched controls: className is schema-valid, a genuine contributor to the provider input, and not a member of hashInputs, so moving it must leave the id unchanged; kind is listed, so moving it must change the id. Both directions are required — either alone is equally consistent with a hash that ignores its inputs or one that folds in everything. (Rewritten on RA-2: the first version matched hashInputs declarations with a regex over source, which is green regardless of what the hash function does.)
  • One authority, and the seam stays reserved. The format lives in a pure helpers/ module that the vector service, its byte-budget planner and the ingestion guardrail all read. It is not reached through this.vectorService, which is the configurable seam for downstream embedding and upsert I/O, and not through the imported singleton either — two simultaneous authorities is the thing being removed, not relocated. Asserted by the absence of any spec-stub change — a diff that has to teach existing stubs about a pure format helper has put it in the wrong place.

Out of Scope

  • Whether the header should carry kind instead of type. That is a semantic improvement with a corpus-wide re-embed behind it, and it needs its own ticket with a reconciliation plan. This one restores the published contract at zero corpus cost.
  • Collapsing the two builder copies into one shared helper. Tempting and probably right, but it moves a symbol across a service boundary (knowledge-base service ↔ service) and belongs in the consolidation lane under #17411 rather than in a defect fix. SCOPE CORRECTED, in scope. The rationale was false: there is no boundary to cross. Both files live in ai/services/knowledge-base/, and IngestionService already imports VectorService at module scope (:31), one-directionally. Deferring on a boundary that does not exist would have left the duplication standing — which is the accretion this lane exists to reduce — so the delivered fix collapses IngestionService.buildEmbeddingInputText to a delegate and adds an AC-covered arm asserting the two agree.
  • Re-embedding any existing rows. Nothing here changes an existing chunk's text, and the fix is forward-only by construction. Owned by #17428, filed on RA-3 — which also establishes that the trigger already exists (generationElectionStore: an input-strategy change invalidates every existing vector) and that pulling it is global while the damage is partial, so which cost the deployment bears is an operator decision rather than a leaf's.
  • The rootKind field. It records the document class, not the chunk kind, and is not a candidate for this header.

Avoided Traps

  • Reading kind || type because three siblings do. Those three are telemetry and metadata sites where kind-first is correct. The embedding text is the one place where field order decides whether an existing corpus stays valid, and there the precedent inverts.
  • Inferring parser field coverage from a grep count. grep -c '^\s*type *:' reports 0 for DocumentationParser, which emits type via the ES shorthand type,. The census has to read the emitted object literals, not match a colon.
  • Calling this an admission-band defect. The prefix byte count is wrong by a handful of bytes. The retrieval-quality half is the defect; conflating them would inflate the claim.
  • Fixing it by relaxing the schema to allow type. That would legitimise two fields meaning different things under one contract, and every future external parser would have to guess which one the header reads.

Related

  • Parent epic: #17411 — embedding lane consolidation. This is the corpus-quality leaf; #17412 is the throughput leaf.
  • #17392 — parser-identity orphans; the mechanism this ticket's last AC exists to avoid becoming an instance of.
  • ai/services/knowledge-base/parser/parsed-chunk-v1.schema.json — the contract the readers must conform to.
  • ai/services/knowledge-base/parser/identity-tuple.md — the chunk-identity documentation.

Origin Session ID: 046f993e-13ba-47dd-827d-d786428e318b

Retrieval Hint: query_raw_memories("buildEmbeddingInputText reads chunk.type but parsed-chunk-v1 requires kind so tenant chunks embed behind undefined; type-first preserves the corpus because embedding text is not in hashInputs")

tobiu closed this issue on Aug 20, 2026, 8:48 PM