~bigbes/sr-ht-spec

ref: f82d90a4317a0f36bb56eb4ba402f0c0c6d09950 sr-ht-spec/search/mixed_test.go -rw-r--r-- 8.6 KiB
f82d90a4 — Eugene Blikh feat(mcpsrv): spec_comment closes the agent half of the review loop (spec-by6.3.4) 25 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
package search

import (
	"context"
	"path/filepath"
	"testing"

	"github.com/blevesearch/bleve/v2"
	"github.com/blevesearch/bleve/v2/analysis/lang/en"
	"github.com/blevesearch/bleve/v2/analysis/lang/ru"
	"github.com/blevesearch/bleve/v2/registry"
	"github.com/stretchr/testify/require"

	"sourcecraft.dev/bigbes/sr-ht-spec/core"
)

// The design leaves "mixed Russian/English search" open, with warren's
// per-index analyzer choice as the starting point. These tests are the evidence
// the decision was made on. They run in three steps:
//
//  1. what one analyzer actually does to the other language's words,
//  2. what that costs a genuinely mixed document under per-document routing,
//  3. that per-line routing removes the cost.

const (
	enSpecBody = "The approved revision is the one a bot reads. Every read resolves a git tree\n" +
		"and reads blobs from it. Proposals live on their own branch and are merged\n" +
		"only after a human approves them.\n"

	ruSpecBody = "Одобренная ревизия — это та, которую читают агенты. Каждое чтение разрешает\n" +
		"дерево гита и читает из него блобы. Предложения живут в отдельной ветке и\n" +
		"объединяются только после проверки человеком.\n"

	// A real shape for this corpus: Russian prose around English requirements
	// quoted verbatim from the upstream document they came from.
	mixedSpecBody = "Этот документ описывает требования к вложениям и двоичным файлам в спецификациях.\n" +
		"The requirement is quoted verbatim from upstream: attachments larger than the\n" +
		"configured cap are rejected by the daemon before the push is accepted.\n" +
		"Ограничение размера вложений обсуждается отдельно и пока не выбрано.\n"
)

func analyze(t *testing.T, analyzer, text string) []string {
	t.Helper()
	a, err := registry.NewCache().AnalyzerNamed(analyzer)
	require.NoError(t, err)
	var out []string
	for _, tok := range a.Analyze([]byte(text)) {
		out = append(out, string(tok.Term))
	}
	return out
}

// TestOneAnalyzerCannotStemBothLanguages records the premise: bleve's per-index
// analyzer choice does not break the other language, it merely stops stemming
// it. Foreign-script terms survive as literals — which is why warren's
// single-analyzer index was usable — but they survive unstemmed, so singular
// and plural stop being the same word.
func TestOneAnalyzerCannotStemBothLanguages(t *testing.T) {
	// Its own language: stemmed, stop words dropped.
	require.Equal(t, []string{"index", "rebuild", "document"},
		analyze(t, en.AnalyzerName, "indexes rebuild all the documents"))
	require.Equal(t, []string{"индекс", "перестраива", "документ"},
		analyze(t, ru.AnalyzerName, "индексы перестраивает все документы"))

	// The other language: passed through whole, stop words and all.
	require.Equal(t, []string{"indexes", "rebuild", "all", "the", "documents"},
		analyze(t, ru.AnalyzerName, "indexes rebuild all the documents"))
	require.Equal(t, []string{"индексы", "перестраивает", "все", "документы"},
		analyze(t, en.AnalyzerName, "индексы перестраивает все документы"))
}

// TestPerDocumentRoutingLosesTheMinorityLanguage is the negative result that
// rules out the obvious reading of the design's open item — "detect the
// dominant language of the document and write the matching field".
//
// The document below is dominantly Russian, so per-document routing files all
// of it, English requirements included, under the Russian analyzer. The Russian
// half then searches correctly and the English half is reachable only by exact
// word form: "attachments" is found, "attachment" is not.
func TestPerDocumentRoutingLosesTheMinorityLanguage(t *testing.T) {
	idx, err := bleve.New(filepath.Join(t.TempDir(), "per-document.bleve"), buildMapping())
	require.NoError(t, err)
	t.Cleanup(func() { require.NoError(t, idx.Close()) })

	// Exactly what per-document routing produces: one field, chosen by the
	// document's dominant language.
	lang := DetectIn(mixedSpecBody, DefaultLang)
	require.Equal(t, LangRU, lang, "the fixture is dominantly Russian")
	require.NoError(t, idx.Index("mixed", map[string]any{
		fieldSpace:  "~bigbes/specs",
		fieldTitle:  "Вложения",
		fieldBodyRU: mixedSpecBody,
	}))

	found := func(term, field string) bool {
		q := bleve.NewMatchQuery(term)
		q.SetField(field)
		res, err := idx.Search(bleve.NewSearchRequest(q))
		require.NoError(t, err)
		return res.Total > 0
	}

	// The dominant half is fine.
	require.True(t, found("вложение", fieldBodyRU), "singular Russian finds the plural in the text")
	// The minority half is not: it is in the index, but only as a literal.
	require.True(t, found("attachments", fieldBodyRU), "the exact English word form is still there")
	require.False(t, found("attachment", fieldBodyRU),
		"per-document routing leaves the English half unstemmed: singular misses the plural")
}

// TestPerLineRoutingFindsBothHalvesOfAMixedDocument is the same document
// through the real path. Each line lands in the field whose analyzer stems it,
// and both halves answer stemmed queries.
func TestPerLineRoutingFindsBothHalvesOfAMixedDocument(t *testing.T) {
	c := newCorpus(t, "~bigbes/specs", "rev1").
		add("specs/storage.md", "---\nid: SPEC-0001\ntitle: Storage model\nstatus: draft\n---\n\n"+enSpecBody).
		add("specs/hranenie.md", "---\nid: SPEC-0002\ntitle: Модель хранения\nstatus: draft\n---\n\n"+ruSpecBody).
		add("specs/attachments.md", "---\nid: SPEC-0003\ntitle: Вложения и двоичные файлы\nstatus: draft\n---\n\n"+mixedSpecBody)
	idx := indexCorpus(t, c)

	// Singular English query, plural in the text: only the stemmer bridges it.
	require.Equal(t, []string{"SPEC-0003"}, hitIDs(t, idx, Query{Text: "attachment", Spaces: core.EverythingFilter()}),
		"the English half of the mixed document is stemmed")
	// Singular Russian query, plural in the text.
	require.Equal(t, []string{"SPEC-0003"}, hitIDs(t, idx, Query{Text: "вложение", Spaces: core.EverythingFilter()}),
		"the Russian half of the mixed document is stemmed")

	// The single-language documents are unaffected.
	require.Equal(t, []string{"SPEC-0001"}, hitIDs(t, idx, Query{Text: "proposal", Spaces: core.EverythingFilter()}))
	require.Equal(t, []string{"SPEC-0002"}, hitIDs(t, idx, Query{Text: "предложение", Spaces: core.EverythingFilter()}))
}

// TestMixedDocumentIsLabelledByItsDominantLanguage checks the label a hit
// carries. The document is routed per line, but it is still one document and
// reports one language — which is what picks the snippet's half.
func TestMixedDocumentIsLabelledByItsDominantLanguage(t *testing.T) {
	c := newCorpus(t, "~bigbes/specs", "rev1").
		add("specs/storage.md", "---\nid: SPEC-0001\ntitle: Storage model\n---\n\n"+enSpecBody).
		add("specs/attachments.md", "---\nid: SPEC-0003\ntitle: Вложения и двоичные файлы\n---\n\n"+mixedSpecBody)
	idx := indexCorpus(t, c)

	res, err := idx.Search(context.Background(), Query{Text: "attachment", Spaces: core.EverythingFilter()})
	require.NoError(t, err)
	require.Len(t, res.Hits, 1)
	require.Equal(t, LangRU, res.Hits[0].Lang)
	require.Contains(t, res.Hits[0].Snippet, "<mark>attachments</mark>",
		"the snippet comes from the half that matched, even though it is not the document's language")

	res, err = idx.Search(context.Background(), Query{Text: "proposal", Spaces: core.EverythingFilter()})
	require.NoError(t, err)
	require.Len(t, res.Hits, 1)
	require.Equal(t, LangEN, res.Hits[0].Lang)
}

// TestCyrillicTitleIsSearchableAndBoosted checks that titles are routed too: a
// Russian title lands in the Russian title field, so a query naming a document
// returns it above documents that merely mention it.
func TestCyrillicTitleIsSearchableAndBoosted(t *testing.T) {
	c := newCorpus(t, "~bigbes/specs", "rev1").
		add("specs/hranenie.md", "---\nid: SPEC-0002\ntitle: Модель хранения данных\n---\n\n"+ruSpecBody).
		add("specs/mention.md", "---\nid: SPEC-0004\ntitle: Прочее\n---\n\n"+
			"Здесь упоминается модель хранения данных, но документ совсем о другом предмете.\n")
	idx := indexCorpus(t, c)

	require.Equal(t, []string{"SPEC-0002", "SPEC-0004"}, hitIDs(t, idx, Query{Text: "модель хранения", Spaces: core.EverythingFilter()}),
		"the document named by the query outranks the one that mentions it")
}