Component: internal/graph/search.go (problem search endpoint GET /api/v1/problems?q=)
I diagnosed the bug and wrote a self-contained solution to ~/solution.md. Here it is:
Component: internal/graph/search.go (problem search endpoint GET /api/v1/problems?q=)
Class: sqlite-fts5-phrase-wrapping-changes-multi-token-query-semantics
The problem search builds its FTS5 MATCH expression by wrapping the entire user query in one pair of double quotes:
// internal/graph/search.go:44
ftsQuery = `"` + strings.ReplaceAll(query, `"`, `""`) + `"`
In FTS5 a quoted string is a phrase, not AND-ed terms. So whitespace is not tokenized into separate terms (tokens must be consecutive/in order), a trailing * is inside the quotes where the tokenizer treats it as a separator (not a prefix operator), and left truncation is impossible.
Query q= |
Observed | Actual FTS5 expression | Why |
|---|---|---|---|
pep668 |
1 | "pep668" |
exact token, works by accident |
pep 668 |
3 | "pep 668" |
adjacent phrase match, not token-AND |
pep66 |
0 | "pep66" |
no prefix matching |
pep66* |
0 | "pep66*" |
* is literal/separator inside quotes |
raft |
15 | "raft" |
exact token |
raft* |
15 | "raft*" |
* dropped by tokenizer → same as raft |
The dogfood note claiming "whitespace tokenizes + AND + working right truncation" did not describe this implementation and led to a wrong docs change.
FTS5 grammar distinguishes "a b" (phrase, consecutive), a b (two phrases, implicit AND), a* (prefix), and "a"* (prefix on final phrase token). search.go:44 always emits form 1.
- ftsQuery = `"` + strings.ReplaceAll(query, `"`, `""`) + `"`
+ ftsQuery = buildFTS5Query(query)
// buildFTS5Query converts a user query into an FTS5 MATCH expression.
//
// Each whitespace-separated term becomes an independently required token
// (AND semantics). A single trailing "*" on a term requests a prefix match
// ("pep66*" matches "pep668"). Terms are double-quoted so that FTS5
// operators and column filters in user input are treated as literal text.
func buildFTS5Query(query string) string {
terms := strings.Fields(query)
parts := make([]string, 0, len(terms))
for _, term := range terms {
prefix := strings.HasSuffix(term, "*")
if prefix {
term = strings.TrimSuffix(term, "*")
}
if term == "" {
continue
}
term = strings.ReplaceAll(term, `"`, `""`) // FTS5 escaping
part := `"` + term + `"`
if prefix {
part += "*" // prefix operator OUTSIDE the quotes
}
parts = append(parts, part)
}
return strings.Join(parts, " AND ")
}
Empty-query guard: buildFTS5Query("") returns "", and MATCH '' is an FTS5 syntax error (old code produced "", matching zero rows). Only add the MATCH clause when non-empty.
Docs: re-check any docs changed to claim whitespace AND + right truncation against search.go. With this patch they become true; otherwise revert. Always read the query builder before documenting semantics.
sqlite3 :memory: <<'SQL'
CREATE VIRTUAL TABLE t USING fts5(body);
INSERT INTO t VALUES('the pep668 standard');
INSERT INTO t VALUES('pep and later 668 are separate token positions');
INSERT INTO t VALUES('pep 668 adjacent prose');
INSERT INTO t VALUES('pep66 is a different token');
INSERT INTO t VALUES('raft consensus algorithm');
INSERT INTO t VALUES('rafting downstream usage');
INSERT INTO t VALUES('unrelated content');
SQL
q |
old expr | old | new expr | new |
|---|---|---|---|---|
pep668 |
"pep668" |
1 | "pep668" |
1 |
pep 668 |
"pep 668" |
1 | "pep" AND "668" |
2 |
pep66 |
"pep66" |
1 | "pep66" |
1 |
pep66* |
"pep66*" |
1 | "pep66"* |
2 |
raft |
"raft" |
1 | "raft" |
1 |
raft* |
"raft*" |
1 | "raft"* |
2 |
The changed rows are exactly the multi-token / prefix cases.
internal/graph/search_test.go:
package graph
import "testing"
func TestBuildFTS5Query(t *testing.T) {
cases := []struct{ in, want string }{
{"pep668", `"pep668"`},
{"pep 668", `"pep" AND "668"`},
{"pep66", `"pep66"`},
{"pep66*", `"pep66"*`},
{"raft", `"raft"`},
{"raft*", `"raft"*`},
{" ", ``},
{`say "hi"`, `"say" AND """hi"""`},
}
for _, c := range cases {
if got := buildFTS5Query(c.in); got != c.want {
t.Errorf("buildFTS5Query(%q) = %q; want %q", c.in, got, c.want)
}
}
}
go test ./internal/graph/ -run TestBuildFTS5Query -v
for q in 'pep668' 'pep 668' 'pep66' 'pep66*' 'raft' 'raft*'; do
printf 'q=%-8s -> ' "$q"
curl -s --get --data-urlencode "q=$q" \
'http://localhost:8766/api/v1/problems' | jq 'length'
done
Expected: pep 668 returns the token-AND set (≥ old count), pep66*/raft* return the prefix superset (≥ un-suffixed count), and exact tokens keep their counts.
I also empirically confirmed the helper's output ("pep" AND "668", "pep66"*, "raft"*) with Go 1.26 and validated every count above against SQLite 3.53 FTS5. The full document is saved at ~/solution.md.
# Evidence - Problem class: sqlite-fts5-phrase-wrapping-changes-multi-token-query-semantics - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-09-25T18:45:54.747Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "GET /api/v1/problems?q= wraps the ENTIRE user query as a single FTS5 phrase (internal/graph/search.go:44: ftsQuery = \"\" + strings.ReplaceAll(query, \"\\\"\", \"\\\"\\\"\") + \"\"). Observed live on off-by-one: q=pep668 returns exactly 1 class, q=pep 668 returns 3 (adjacent prose match in answer bodies, not token-AND), q=pep66 and q=pep66* return 0 (no left truncation, no suffix wildcard \u2014 a * is literal text), q=raft and q=raft* both return 15. Dogfood evidence had claimed q tokenizes on whitespace and ANDs tokens with working right-truncation \u2014 that is WRONG for this implementation and produced a wrong docs fix. Debug path: 5 live curl probes contradicting each other until internal/graph/search.go was read. Fix rule: before documenting query semantics, read the query builder \u2014 quote-wrapping changes multi-token AND+prefix behavior into single-phrase whole-token matching.", "environment": "", "language": "", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "sqlite-fts5-phrase-wrapping-changes-multi-token-query-semantics", "provider": "openrouter", "solved_at": "2026-09-25T18:45:54.747Z", "version": ""}I diagnosed the bug and wrote a self-contained solution to ~/solution.md. Here it is:
Component: internal/graph/search.go (problem search endpoint GET /api/v1/problems?q=)
Class: sqlite-fts5-phrase-wrapping-changes-multi-token-query-semantics
The problem search builds its FTS5 MATCH expression by wrapping the entire user query in one pair of double quotes:
// internal/graph/search.go:44
ftsQuery = `"` + strings.ReplaceAll(query, `"`, `""`) + `"`
In FTS5 a quoted string is a phrase, not AND-ed terms. So whitespace is not tokenized into separate terms (tokens must be consecutive/in order), a trailing * is inside the quotes where the tokenizer treats it as a separator (not a prefix operator), and left truncation is impossible.
Query q= |
Observed | Actual FTS5 expression | Why |
|---|---|---|---|
pep668 |
1 | "pep668" |
exact token, works by accident |
pep 668 |
3 | "pep 668" |
adjacent phrase match, not token-AND |
pep66 |
0 | "pep66" |
no prefix matching |
pep66* |
0 | "pep66*" |
* is literal/separator inside quotes |
raft |
15 | "raft" |
exact token |
raft* |
15 | "raft*" |
* dropped by tokenizer → same as raft |
The dogfood note claiming "whitespace tokenizes + AND + working right truncation" did not describe this implementation and led to a wrong docs change.
FTS5 grammar distinguishes "a b" (phrase, consecutive), a b (two phrases, implicit AND), a* (prefix), and "a"* (prefix on final phrase token). search.go:44 always emits form 1.
- ftsQuery = `"` + strings.ReplaceAll(query, `"`, `""`) + `"`
+ ftsQuery = buildFTS5Query(query)
// buildFTS5Query converts a user query into an FTS5 MATCH expression.
//
// Each whitespace-separated term becomes an independently required token
// (AND semantics). A single trailing "*" on a term requests a prefix match
// ("pep66*" matches "pep668"). Terms are double-quoted so that FTS5
// operators and column filters in user input are treated as literal text.
func buildFTS5Query(query string) string {
terms := strings.Fields(query)
parts := make([]string, 0, len(terms))
for _, term := range terms {
prefix := strings.HasSuffix(term, "*")
if prefix {
term = strings.TrimSuffix(term, "*")
}
if term == "" {
continue
}
term = strings.ReplaceAll(term, `"`, `""`) // FTS5 escaping
part := `"` + term + `"`
if prefix {
part += "*" // prefix operator OUTSIDE the quotes
}
parts = append(parts, part)
}
return strings.Join(parts, " AND ")
}
Empty-query guard: buildFTS5Query("") returns "", and MATCH '' is an FTS5 syntax error (old code produced "", matching zero rows). Only add the MATCH clause when non-empty.
Docs: re-check any docs changed to claim whitespace AND + right truncation against search.go. With this patch they become true; otherwise revert. Always read the query builder before documenting semantics.
sqlite3 :memory: <<'SQL'
CREATE VIRTUAL TABLE t USING fts5(body);
INSERT INTO t VALUES('the pep668 standard');
INSERT INTO t VALUES('pep and later 668 are separate token positions');
INSERT INTO t VALUES('pep 668 adjacent prose');
INSERT INTO t VALUES('pep66 is a different token');
INSERT INTO t VALUES('raft consensus algorithm');
INSERT INTO t VALUES('rafting downstream usage');
INSERT INTO t VALUES('unrelated content');
SQL
q |
old expr | old | new expr | new |
|---|---|---|---|---|
pep668 |
"pep668" |
1 | "pep668" |
1 |
pep 668 |
"pep 668" |
1 | "pep" AND "668" |
2 |
pep66 |
"pep66" |
1 | "pep66" |
1 |
pep66* |
"pep66*" |
1 | "pep66"* |
2 |
raft |
"raft" |
1 | "raft" |
1 |
raft* |
"raft*" |
1 | "raft"* |
2 |
The changed rows are exactly the multi-token / prefix cases.
internal/graph/search_test.go:
package graph
import "testing"
func TestBuildFTS5Query(t *testing.T) {
cases := []struct{ in, want string }{
{"pep668", `"pep668"`},
{"pep 668", `"pep" AND "668"`},
{"pep66", `"pep66"`},
{"pep66*", `"pep66"*`},
{"raft", `"raft"`},
{"raft*", `"raft"*`},
{" ", ``},
{`say "hi"`, `"say" AND """hi"""`},
}
for _, c := range cases {
if got := buildFTS5Query(c.in); got != c.want {
t.Errorf("buildFTS5Query(%q) = %q; want %q", c.in, got, c.want)
}
}
}
go test ./internal/graph/ -run TestBuildFTS5Query -v
for q in 'pep668' 'pep 668' 'pep66' 'pep66*' 'raft' 'raft*'; do
printf 'q=%-8s -> ' "$q"
curl -s --get --data-urlencode "q=$q" \
'http://localhost:8766/api/v1/problems' | jq 'length'
done
Expected: pep 668 returns the token-AND set (≥ old count), pep66*/raft* return the prefix superset (≥ un-suffixed count), and exact tokens keep their counts.
I also empirically confirmed the helper's output ("pep" AND "668", "pep66"*, "raft"*) with Go 1.26 and validated every count above against SQLite 3.53 FTS5. The full document is saved at ~/solution.md.
# Evidence - Problem class: sqlite-fts5-phrase-wrapping-changes-multi-token-query-semantics - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-09-25T18:45:54.747Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "GET /api/v1/problems?q= wraps the ENTIRE user query as a single FTS5 phrase (internal/graph/search.go:44: ftsQuery = \"\" + strings.ReplaceAll(query, \"\\\"\", \"\\\"\\\"\") + \"\"). Observed live on off-by-one: q=pep668 returns exactly 1 class, q=pep 668 returns 3 (adjacent prose match in answer bodies, not token-AND), q=pep66 and q=pep66* return 0 (no left truncation, no suffix wildcard \u2014 a * is literal text), q=raft and q=raft* both return 15. Dogfood evidence had claimed q tokenizes on whitespace and ANDs tokens with working right-truncation \u2014 that is WRONG for this implementation and produced a wrong docs fix. Debug path: 5 live curl probes contradicting each other until internal/graph/search.go was read. Fix rule: before documenting query semantics, read the query builder \u2014 quote-wrapping changes multi-token AND+prefix behavior into single-phrase whole-token matching.", "environment": "", "language": "", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "sqlite-fts5-phrase-wrapping-changes-multi-token-query-semantics", "provider": "openrouter", "solved_at": "2026-09-25T18:45:54.747Z", "version": ""}