Skip to content

fix(fts): AND-join expand_fts_query groups so mixed group+phrase parses - #447

Merged
cdeust merged 1 commit into
mainfrom
claude/fix-fts5-group-and
Aug 24, 2026
Merged

fix(fts): AND-join expand_fts_query groups so mixed group+phrase parses#447
cdeust merged 1 commit into
mainfrom
claude/fix-fts5-group-and

Conversation

@cdeust

@cdeust cdeust commented Aug 24, 2026

Copy link
Copy Markdown
Owner

What

expand_fts_query joined its per-word groups with whitespace. FTS5's grammar accepts implicit-AND between two bare phrases (a b) but rejects it when either operand is a parenthesized group: (a OR b) "c" raises fts5: syntax error.

This fired whenever a multi-token identifier (which becomes an (x OR y OR z) OR-group of camelCase/snake_case sub-tokens) sat next to a single-token word (a bare phrase) in the same query.

How it surfaced

A knowledge-graph entity named cortex_viz/__main__.py hit it exactly. get_causal_chain's entity-mention lookup (get_memories_mentioning_entityexpand_fts_query → FTS5 MATCH) crashed on the SQLite backend with:

fts5: syntax error near ""__main__""

because the query expanded to ("cortex_viz" OR "cortex" OR "viz") "__main__" "py" — a group immediately followed by a bare phrase. This was surfaced by running the harness-comparison benchmark against a real ingested store (cortex-viz), where it degraded the P2 (highest fan-in) probe to a partial result.

Fix

Join the groups with an explicit AND. FTS5 treats a b and a AND b as identical, so match semantics are unchanged, but the explicit form is grammatically valid for every group shape — the crash is gone regardless of how groups and bare phrases interleave.

("cortex_viz" OR "cortex" OR "viz") AND "__main__" AND "py"

Verified against the live benchmark DB: the previously-crashing entity now returns 11 hits instead of raising.

Tests

  • Updated test_expand_query_preserves_and_across_words to expect the explicit AND join.
  • Added a parametrized regression test running expand_fts_query output against a real in-memory FTS5 table across the group-then-phrase (original crash), phrase-then-group, and interleaved cases — asserting no OperationalError (skipped gracefully if sqlite3 was built without FTS5).
  • Added a test proving the join is a conjunction: a row matches only when it contains a sub-token of the group AND the trailing phrase.

Gates: ruff check ✓ · ruff format --check ✓ · craftsmanship gate ✓ · pytest tests_py/shared/test_code_tokenize.py → 19 passed.

🤖 Generated with Claude Code


Generated by Claude Code

expand_fts_query joined its per-word groups with whitespace. FTS5's grammar
accepts implicit-AND between two bare phrases (a b) but rejects it when either
operand is a parenthesized group: (a OR b) "c" raises "fts5: syntax error".

This fired whenever a multi-token identifier (which becomes an OR group) sat
next to a single-token word (a bare phrase) in the same query. A knowledge-graph
entity named cortex_viz/__main__.py hit it exactly -- get_causal_chain's
entity-mention lookup crashed on the SQLite backend with
'fts5: syntax error near ""__main__""'.

Fix: join groups with an explicit AND. FTS5 treats "a b" and "a AND b" as
identical, but the explicit form is grammatically valid for every group shape,
so the crash is gone with no change in match semantics.

Regression tests run expand_fts_query output against a real in-memory FTS5
table (parametrized over the group-then-phrase, phrase-then-group, and
interleaved cases) asserting no OperationalError, plus one test proving the
join is a conjunction (group AND phrase both required).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017fnomaXS71hgxMw2zr7HGR
@cdeust

cdeust commented Aug 24, 2026

Copy link
Copy Markdown
Owner Author

ZETETIC-REVIEW: APPROVE

Independent review (fresh-context reviewer, read-only via gh, head 62ef415):

  • Root cause verified independently against real SQLite FTS5 (not mocked): ("a" OR "b") "c" crashes, ("a" OR "b") AND "c" parses. The uniform AND-join fixes every group-adjacency shape (group→phrase, phrase→group, group→group), not just the reported case — a general fix at the query-construction site (code_tokenize.py::expand_fts_query), no band-aid at the MATCH call.
  • Regression tests execute against a real in-memory FTS5 table; pre-fix code provably raises on the same inputs, post-fix does not.
  • CI: 22/22 non-skipped checks green (SQLite backend, 3.10–3.13, Windows, Craftsmanship Gate, Lint, Type Check, CodeQL, Fuzz). mergeStateStatus CLEAN.
  • Layer check: shared layer, stdlib-only, unchanged imports. SOLID/size caps/§8 source discipline: pass.

Non-blocking notes:

  1. Add a group-then-group parametrized test case (independently verified fixed, but the suite's stated coverage omits that shape).
  2. The PR body's "11 hits on live DB" claim has no linked artifact — cite run output for empirical claims in future PRs.

@cdeust
cdeust merged commit 5083940 into main Aug 24, 2026
25 checks passed
@cdeust
cdeust deleted the claude/fix-fts5-group-and branch August 24, 2026 17:46
cdeust added a commit that referenced this pull request Aug 24, 2026
… drift)

origin/main advanced past this branch's fork point (#447 FTS5 fix, #448
error-classification fix) and #448 fixed mcp_server/tool_error_handler.py
_classify_error's method-size violation (was in the branch-point
baseline; now 21 lines on main, verified by AST), pruning it from
main's .craftsmanship-baseline.json. This branch's copy of the file
still carried the stale entry, which the gate's ratchet rule flags as
"added without a base-ref match" once compared against a freshly
fetched origin/main (the ratchet may only shrink within a PR).

Not something this PR's own changes caused — reproduced by stashing
every change in this branch back to dddc001 and re-running the gate
against fresh origin/main; the same failure appears. Synced this
branch's baseline to match origin/main's exactly for this one entry
(diffed byte-for-byte confirmed no other divergence in either
direction) rather than grandfathering it or regenerating blind.

Co-Authored-By: Claude <noreply@anthropic.com>
cdeust added a commit that referenced this pull request Aug 24, 2026
…s instead of hanging (#449)

* fix(ap): bound interactive AP read calls so a wedged pipeline degrades

The AP MCP client runs with callTimeoutMs=0 — deliberately unbounded, because
a fresh index_codebase of a large tree legitimately exceeds any fixed wall
clock. The only backstop is the 600s wedge-silence window. That is correct for
ingestion but wrong for the interactive read path: search_codebase (behind
unified_search) and the get_symbol / get_context / get_impact / get_processes /
health_check lookups inherited the same 600s, so an AP that connects but then
wedges made unified_search / get_causal_chain hang for up to 10 minutes instead
of degrading to Cortex-only results — the "unreachable => status=partial"
promise in unified_search's docstring was not actually kept.

Fix: APBridge.call gains an optional timeout_s that wraps the client call in
asyncio.wait_for and degrades a timeout to None (recorded reason + stderr note),
exactly like every other AP failure. The interactive read wrappers pass
interactive_call_timeout_s() (30s default, env-overridable via
CORTEX_AP_INTERACTIVE_TIMEOUT_S, 20x below the 600s indexing window). The
indexing/write wrappers (index_codebase, analyze_codebase, resolve_graph,
cluster_graph, detect_changes) and the build-loop query_graph stay unbounded.

A None from a timed-out search_codebase flows through as_list(None) -> [] ->
unified_search returns status=partial with Cortex-only hits.

Tests: a hanging fake client returns None within a 50ms ceiling; the
interactive wrapper forwards a positive timeout while index_codebase forwards
none; the default ceiling is positive and strictly below the 600s wedge window
and honors/validates the env override.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017fnomaXS71hgxMw2zr7HGR

* fix(ap): extract APBridge._degrade to clear the 40-line method-size gate

APBridge.call spanned 42 lines (repo cap: 40, scripts/craftsmanship_rules.py
METHOD_LINE_LIMIT). Both except-branches duplicated the same "record
_unavailable_reason + stderr note + return None" degrade logic; pulled
into one _degrade(reason, note) helper, called from both branches. Fixes
the CI craftsmanship-gate FAILURE on 3debfda (PR #449 review finding 1).

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(ap): surface AP call failure in unified_search status, not just the config flag

unified_search.handler derived status solely from the static is_enabled()
config check. An AP that is enabled but times out or errors on the actual
search_codebase call returned [] indistinguishably from AP genuinely
finding nothing: status="ok", sources=["cortex","ap"], counts.ap=0 either
way. This silently hid the exact failure mode #3debfda's timeout fix was
meant to expose.

WorkflowGraphASTSource gains last_search_degraded_reason, reading the
outcome APBridge._degrade already records on its own call path (no
widened return type, no second round-trip) — None when the call
succeeded or was never attempted (AP disabled / no graph configured),
set when it genuinely failed. unified_search now sets status="partial"
and a degraded: {source, reason} field whenever AP was attempted but
failed, keeping status="ok", degraded=null for a genuine empty AP result.

PR #449 review finding 2.

Co-Authored-By: Claude <noreply@anthropic.com>

* docs(ap): remove false get_causal_chain AP-hang claim from source comments

get_causal_chain (mcp_server/handlers/get_causal_chain.py) imports only
MemoryStore and core.knowledge_graph — no AP dependency, so it cannot
hang on a wedged AP call. mcp_call_timeout.py's interactive-ceiling
comments and the interactive-timeout test's module docstring both
claimed otherwise; corrected to name unified_search only, with an
explicit note that get_causal_chain is unaffected. PR #449 review
finding 3 (PR body corrected separately via gh pr edit).

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(ap): extract search_codebase hit normalization to clear the 300-line/40-line gates

The degraded-tracking addition (bd74351) pushed
workflow_graph_source_ast.py to 316 lines (cap 300) and
search_codebase's body to 45 lines (cap 40) — a NEW craftsmanship
violation not in the base-ref baseline, caught by CI on 30b2d20.

Local `python3 scripts/check_craftsmanship.py` (no args) at the time
compared against a stale local origin/main; re-running after `git fetch
origin main` reproduces CI's FAILURE exactly.

Root fix: search_codebase's row-normalization loop (id/qualified_name/
file_path/score/snippet construction from the raw AP response) is
lifted into workflow_graph_ast_response.normalize_search_hits — the
same module that already owns "normalize an AP response shape" for the
symbol- and edge-loading concerns (issue #275's split), so this is the
same seam, not a new one. search_codebase now just resolves the graph
path, calls the bridge, and delegates. Docstrings on
search_codebase/last_search_degraded_reason trimmed to essentials.

workflow_graph_source_ast.py: 316 -> 288 lines. search_codebase: 45 ->
delegates (no longer a > 40-line method). Behavior-preserving; targeted
tests (ap_bridge/mcp_client/workflow_graph + the two new degraded-status
test files + response_budget_wiring) all still green.

Co-Authored-By: Claude <noreply@anthropic.com>

* fix(ap): reset APBridge._unavailable_reason at the top of every call()

_unavailable_reason was only cleared in connect()'s slow (reconnect)
path. connect()'s fast path (client already connected) skipped the
clear, and call()'s success branch never reset it either. On a reused
instance — the codebase already does this, wiki_verify.py reuses one
WorkflowGraphASTSource across its candidate loop — a stale reason from
an earlier failed call leaked into a later successful one, so
last_search_degraded_reason could report a call as failed when it had
actually just succeeded. Contradicted its own docstring ("None if it
succeeded").

Fix: call() resets self._unavailable_reason = None right after the
tool-allowlist check, before touching connect()/the RPC — each call's
own outcome is now authoritative regardless of prior calls on the same
bridge instance. call() stays at 39 lines (cap 40).

New regression test: two calls on one APBridge instance, first via a
hanging client (fails), second via a client that succeeds, asserting
unavailable_reason is set after the first and None after the second.
Fails on pre-fix code (verified via git stash), passes after.

PR #449 review round 3 (Medium finding, house "no deferred coverage on
new code" rule).

Co-Authored-By: Claude <noreply@anthropic.com>

* chore(craftsmanship): sync baseline ratchet to origin/main (unrelated drift)

origin/main advanced past this branch's fork point (#447 FTS5 fix, #448
error-classification fix) and #448 fixed mcp_server/tool_error_handler.py
_classify_error's method-size violation (was in the branch-point
baseline; now 21 lines on main, verified by AST), pruning it from
main's .craftsmanship-baseline.json. This branch's copy of the file
still carried the stale entry, which the gate's ratchet rule flags as
"added without a base-ref match" once compared against a freshly
fetched origin/main (the ratchet may only shrink within a PR).

Not something this PR's own changes caused — reproduced by stashing
every change in this branch back to dddc001 and re-running the gate
against fresh origin/main; the same failure appears. Synced this
branch's baseline to match origin/main's exactly for this one entry
(diffed byte-for-byte confirmed no other divergence in either
direction) rather than grandfathering it or regenerating blind.

Co-Authored-By: Claude <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
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.

2 participants