fix(core): scan fully for a recall term with an unpaired surrogate - #5422
Totoro-qaq wants to merge 2 commits into
Conversation
The candidate source searches the serialized transcript, where JSON.stringify writes a lone surrogate as a \u escape, and SQLite binds a lone-surrogate term as U+FFFD. A Recall term containing one therefore got an empty candidate list, which collectHits treats as authoritative, and a passage the verifier would match was silently dropped. Treat such a term like the other escaped characters and take the full scan. A well-formed surrogate pair is one code point under the u flag and keeps the candidate path. Generated-by: Claude Code
61d98cf to
9be3999
Compare
jackwener
left a comment
There was a problem hiding this comment.
Review result
I found no correctness or scope issues at exact head 9be3999078ba41bb89fcfd3279ec17b0908b7e3f.
The defect is real on the production storage shape. A direct node:sqlite probe using the candidate query returned no match for a lone surrogate, a lone surrogate embedded in a term, or either half of a stored emoji; the complete emoji still matched. The verifier reads parsed transcript text, so accepting that empty candidate set can silently omit a passage that the verifier would otherwise return.
The repair is minimal and sits at the correct boundary: terms containing a Unicode surrogate code point now use the existing bounded full-scan path, while the Unicode-aware regular expression treats a well-formed surrogate pair as one complete code point and leaves ordinary emoji on the candidate path. I also checked low-surrogate terms and the low half of an emoji through runRecall; both bypassed the candidate source and returned the expected passage, while the complete emoji remained narrowed.
The committed regression passed and failed when I removed the new guard. A clean exact-head build then passed the full Core suite (847/847), the storage candidate suites (8/8), the runtime ledger-corpus suites (2/2), Biome, ASF headers, the Windows test inventory, and git diff --check. Current main (672d82731a638e45e3ec87022eabdcf70a5a90be) is the PR's direct parent, and the synthetic merge tree is exactly the PR tree (77a4f736d7b6d616dd6e8e8887d8ea2afca7a83a). The exact-head hosted test and label checks are both complete and successful.
Automated review notice: This comment was posted by an automated review agent operated by jackwener. It is not an independent human review and does not replace one.
Astro-Han
left a comment
There was a problem hiding this comment.
Reviewed at head 9be39990. Comment only — not an approval.
What I verified
The fix is correct and sits at the right boundary.
-
packages/core/src/recall.ts:148addsif (UNPAIRED_SURROGATE_PATTERN.test(term)) return true;tohasJsonEscapedCharacter, which feedsforceFullScanatrecall.ts:452, which makescollectHits(recall.ts:786) skiplistCandidateSessionsentirely. So yes — the whole string is scanned, including everything after a lone surrogate; the narrowed path is not entered at all. -
/\p{Cs}/ubehaves as the comment claims: it matches a lone high surrogate (U+D800) and a lone low one (U+DC00), and does not match a well-formed pair, since under theuflag\uD83D\uDE80is one code point. Emoji terms stay on the candidate path. -
I reproduced the defect against the real
runRecallusing thecandidateDepsdouble fromrecall.test.ts:129, pre-fix vs. with the guard added:term pre-fix post-fix \uD800scannedFully=false,anchors=[],gaps="No transcript match for: …"scannedFully=true,anchors=[m-lone]c\uD800dscannedFully=false,anchors=[]scannedFully=true,anchors=[m-lone]🚀scannedFully=false,anchors=[m-emoji]unchanged ship(control)scannedFully=false,anchors=[m-emoji]unchanged So the silent drop in #5411 is real and the fix resolves it without narrowing anything.
-
The test's third case,
['\uD83D', 'm-emoji'](recall.test.ts:393), is the one that isolates the second failure mode rather than JSON escaping: the stored emoji is literal UTF-8, so there is no\uXXXXescape involved — the term is simply unrepresentable when bound. A directnode:sqliteprobe ofinstr(lower(j), ?)confirms it:\uD800,c\uD800dand\uD83Dall bind as UTF-8EF BF BD(U+FFFD) and miss, while🚀matches. Good coverage of both mechanisms; the comment at:392is accurate that the in-process double is more permissive than SQLite here, which is why that row'sscannedFullyassertion is the one that fails pre-fix. -
Guarding the raw
termsatrecall.ts:452(rather than thefoldedterms actually sent to the store) is sound:foldForMatch(thread-search.ts:564, NFC +toLowerCase) preserves a lone surrogate, so folding can neither introduce nor remove one. Checked directly. -
Placement matches repo convention:
/\p{Cs}/uis the same idiom asLONG_TERM_MEMORY_SURROGATE_CHARS(long-term-memory.ts:73), and routing from core rather than changing the store follows the contract already documented atsqlite-runtime-store.ts:1529("escaped forms excepted, which the caller routes around"). I also confirmed recall is the only consumer of the candidate queries (execution-composition.ts:701), so "storage is unchanged" holds. -
biome checkis clean on both changed files.
This addresses the root cause as filed in #5411 (option 1 of the two the issue suggested), not just the symptom: the predicate that decides "can a serialized-record scan vouch for this term?" is the one place the invariant is enforced, and it was missing this class.
Nits (non-blocking)
recall.ts:141-145—hasJsonEscapedCharacter's own doc comment still justifies only the JSON-escaping half, but the\uD83Dcase fails for an unrelated reason (unrepresentable when bound). The new constant's comment covers both; one extra sentence on the function would help whoever decides next time whether a case belongs here.recall.test.ts:402—assert.deepEqual(anchorIds(result.passages), [anchor])has no message, so a failure inside the loop doesn't say which term caused it. The neighbouring assertion at:397-401does include${JSON.stringify(term)}; adding the same here would keep loop failures self-describing.recall.test.ts:389-394— only high surrogates are exercised. A lone low surrogate is a different code point; I ran\uDC00end to end and it also scans fully, but nothing pins it. It's also the case a future[\uDC00-\uDFFF]-style rewrite of the pattern would most plausibly get wrong, so one more tuple row would be cheap insurance.packages/storage/src/recall-fold.ts:40—isRecallFoldUnstablestill reports a value containing a lone surrogate as fold-stable, the other half of #5411's finding. It's now unreachable from recall because core never forwards such a term, so leaving it is correct; I'd just note in that module's doc comment that "stable" is only sound because callers route surrogate terms away, so the next person touching this scan doesn't re-open the hole.
Astro-Han
left a comment
There was a problem hiding this comment.
Reviewed at head 9be39990. Comment only — not an approval.
What I verified
The fix is correct and sits at the right boundary.
-
packages/core/src/recall.ts:148addsif (UNPAIRED_SURROGATE_PATTERN.test(term)) return true;tohasJsonEscapedCharacter, which feedsforceFullScanatrecall.ts:452, which makescollectHits(recall.ts:786) skiplistCandidateSessionsentirely. So yes — the whole string is scanned, including everything after a lone surrogate; the narrowed path is not entered at all. -
/\p{Cs}/ubehaves as the comment claims: it matches a lone high surrogate (U+D800) and a lone low one (U+DC00), and does not match a well-formed pair, since under theuflag\uD83D\uDE80is one code point. Emoji terms stay on the candidate path. -
I reproduced the defect against the real
runRecallusing thecandidateDepsdouble fromrecall.test.ts:129, pre-fix vs. with the guard added:term pre-fix post-fix \uD800scannedFully=false,anchors=[],gaps="No transcript match for: …"scannedFully=true,anchors=[m-lone]c\uD800dscannedFully=false,anchors=[]scannedFully=true,anchors=[m-lone]🚀scannedFully=false,anchors=[m-emoji]unchanged ship(control)scannedFully=false,anchors=[m-emoji]unchanged So the silent drop in #5411 is real and the fix resolves it without narrowing anything.
-
The test's third case,
['\uD83D', 'm-emoji'](recall.test.ts:393), is the one that isolates the second failure mode rather than JSON escaping: the stored emoji is literal UTF-8, so there is no\uXXXXescape involved — the term is simply unrepresentable when bound. A directnode:sqliteprobe ofinstr(lower(j), ?)confirms it:\uD800,c\uD800dand\uD83Dall bind as UTF-8EF BF BD(U+FFFD) and miss, while🚀matches. Good coverage of both mechanisms; the comment at:392is accurate that the in-process double is more permissive than SQLite here, which is why that row'sscannedFullyassertion is the one that fails pre-fix. -
Guarding the raw
termsatrecall.ts:452(rather than thefoldedterms actually sent to the store) is sound:foldForMatch(thread-search.ts:564, NFC +toLowerCase) preserves a lone surrogate, so folding can neither introduce nor remove one. Checked directly. -
Placement matches repo convention:
/\p{Cs}/uis the same idiom asLONG_TERM_MEMORY_SURROGATE_CHARS(long-term-memory.ts:73), and routing from core rather than changing the store follows the contract already documented atsqlite-runtime-store.ts:1529("escaped forms excepted, which the caller routes around"). I also confirmed recall is the only consumer of the candidate queries (execution-composition.ts:701), so "storage is unchanged" holds. -
biome checkis clean on both changed files.
This addresses the root cause as filed in #5411 (option 1 of the two the issue suggested), not just the symptom: the predicate that decides "can a serialized-record scan vouch for this term?" is the one place the invariant is enforced, and it was missing this class.
Nits (non-blocking)
recall.ts:141-145—hasJsonEscapedCharacter's own doc comment still justifies only the JSON-escaping half, but the\uD83Dcase fails for an unrelated reason (unrepresentable when bound). The new constant's comment covers both; one extra sentence on the function would help whoever decides next time whether a case belongs here.recall.test.ts:402—assert.deepEqual(anchorIds(result.passages), [anchor])has no message, so a failure inside the loop doesn't say which term caused it. The neighbouring assertion at:397-401does include${JSON.stringify(term)}; adding the same here would keep loop failures self-describing.recall.test.ts:389-394— only high surrogates are exercised. A lone low surrogate is a different code point; I ran\uDC00end to end and it also scans fully, but nothing pins it. It's also the case a future[\uDC00-\uDFFF]-style rewrite of the pattern would most plausibly get wrong, so one more tuple row would be cheap insurance.packages/storage/src/recall-fold.ts:40—isRecallFoldUnstablestill reports a value containing a lone surrogate as fold-stable, the other half of #5411's finding. It's now unreachable from recall because core never forwards such a term, so leaving it is correct; I'd just note in that module's doc comment that "stable" is only sound because callers route surrogate terms away, so the next person touching this scan doesn't re-open the hole.
|
Heads-up: the review above was submitted twice by mistake (both are identical — an accidental double-post by the review bot). Apologies for the noise; please treat them as a single review. |
Also say why a lone surrogate forces a full scan even where it is stored literally as half of a pair, name the term in the passage assertion, and note in recall-fold that its stable-record predicate relies on recall scanning escaped and surrogate terms fully. Generated-by: Claude Code
|
Thanks for the careful review. I addressed the four nits in d75d052:
Claude Code-assisted. |
jackwener
left a comment
There was a problem hiding this comment.
Incremental re-review
The code and tests remain correct at exact head d75d0525f7fdc7b24be888db1b0b1c7ea507f872. I found no P0–P2 issues. One minor verification-text correction remains:
- P3 — correct the
u-flag mutation description in the PR body. The Verification section says that dropping theuflag makes/\p{Cs}/umatch both halves of a surrogate pair. In JavaScript,/\p{Cs}/without Unicode mode does not perform a Unicode property escape: it matches the literal textp{Cs}and matches neither lone surrogates nor an emoji. The regression still fails withoutu, but because the guard stops recognizing lone surrogates—not because it recognizes both halves. The new\uDE80row instead correctly kills a high-surrogate-only guard such as/[\uD800-\uDBFF]/u. Please update that bullet so the review record describes the mutation accurately.
The new commit otherwise closes the prior non-blocking gaps without changing production behavior: it pins the low half of a stored emoji, gives each loop failure its term, and documents the Core/Storage invariant on both sides. Replacing the guard with a high-surrogate-only range made the focused regression fail specifically on \uDE80; the restored implementation passes.
Exact-head Core tests pass 847/847, and the hosted test is complete and successful. Current main is 063abf42f4f76eb8f585abaf3c299b24f2fb18f3; its synthetic merge with this head is clean (1aa05e43fc9afcfeaa48741de5d20bd3c46ece13). I installed the current-main dependency set and tested the preceding merge tree: Core 852/852, storage candidate tests 8/8, runtime ledger-corpus tests 2/2, and Biome all passed. Main then advanced by one Desktop-only commit; the Core, Storage, and Runtime subtree object IDs are identical in the latest merge tree, so those results apply unchanged. The exact-head static gates also pass.
Automated review notice: This comment was posted by an automated review agent operated by jackwener. It is not an independent human review and does not replace one.
|
Thanks, you're right: without |
Summary
Recall's candidate source searches the serialized transcript.
JSON.stringifywrites a lone surrogate as a\ud800escape, and SQLite binds a lone-surrogate term as U+FFFD. So a Recall term containing an unpaired surrogate got an empty candidate list.collectHitstreats that list as authoritative, and a passage the verifier would match was silently dropped.hasJsonEscapedCharacternow also returns true for an unpaired surrogate. It uses/\p{Cs}/u, aslong-term-memory.tsdoes, so such a term takes the existing full-scan path. A well-formed pair such as an emoji is one code point under theuflag and keeps the candidate path.Storage is unchanged:
runRecallis the only consumer of the candidate queries, and their doc comments already leave escaped forms to the caller.Fixes #5411
Verification
\uD800,c\uD800dand either half of a stored emoji (\uD83D,\uDE80) scan fully and return the passage;🚀still uses the candidate source. The test failed before the fix./[\uD800-\uDFFF]/without theuflag, which then also matches both halves of the emoji pair; and a high-surrogate-only range/[\uD800-\uDBFF]/u, which misses the low half\uDE80.@maka/coretest:dist 847/847, storagerecall-candidate-sessions8/8, runtimerecall-ledger-corpus2/2.node:sqliteprobe ofinstr(lower(json), ?)confirmed the miss for\uD800,c\uD800dand\uD83D, and the match for🚀.git diff --checkand the Windows test inventory all pass.AI use
Tool(s) and scope: Claude Code helped investigate, implement and test this change.
Checklist
Does this PR entail a change in behavior?
Generated-by: Claude Code