From d62a26f5b1b31a2f532b01982f0f4607d205c375 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Fri, 2 Oct 2026 01:09:40 +0300 Subject: [PATCH] =?UTF-8?q?core:=20fts5-=D1=82=D0=B5=D1=81=D1=82=20=D0=BF?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D1=8F=D0=B5=D1=82=20=D1=80=D0=B5?= =?UTF-8?q?=D0=B3=D0=B8=D1=81=D1=82=D1=80=D0=BE=D0=BD=D0=B5=D0=B7=D0=B0?= =?UTF-8?q?=D0=B2=D0=B8=D1=81=D0=B8=D0=BC=D0=BE=D1=81=D1=82=D1=8C=20=D0=BA?= =?UTF-8?q?=D0=B8=D1=80=D0=B8=D0=BB=D0=BB=D0=B8=D1=86=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/orders/01-fix-fts5.md | 26 ++++++ docs/orders/02-chunker.md | 47 +++++++++++ e2e/mutation_check.py | 82 +++++++++++++++++++ e2e/mutations.tsv | 5 ++ .../test/kotlin/memo/core/CoreSmokeTest.kt | 42 +++++++++- 5 files changed, 201 insertions(+), 1 deletion(-) create mode 100644 docs/orders/01-fix-fts5.md create mode 100644 docs/orders/02-chunker.md create mode 100644 e2e/mutation_check.py create mode 100644 e2e/mutations.tsv diff --git a/docs/orders/01-fix-fts5.md b/docs/orders/01-fix-fts5.md new file mode 100644 index 0000000..58ebc71 --- /dev/null +++ b/docs/orders/01-fix-fts5.md @@ -0,0 +1,26 @@ +Проект: /root/WORK/memo (Kotlin/JVM). Заказ крошечный: усилить ОДИН тест. + +Проблема (доказана мутационной проверкой): тест `fts5FindsCyrillic` в +memo-core/src/test/kotlin/memo/core/CoreSmokeTest.kt ПРОХОДИТ даже если токенизатор в тесте +заменить с `unicode61` на `ascii` — то есть тест не проверяет то, ради чего он написан +(регистронезависимый поиск по кириллице). Проверено на живом SQLite: с `ascii` строчный запрос +«траефик» НЕ находит строку «Траефик» (0 совпадений), с `unicode61` — находит. + +Что сделать — ровно это, в файле memo-core/src/test/kotlin/memo/core/CoreSmokeTest.kt: + +1. В тесте `fts5FindsCyrillic` (тело метода, сигнатуру и имя не менять): + - таблица создаётся с `tokenize='unicode61'` (оставить как есть); + - ВСТАВЛЯЕМОЕ значение сделать с заглавной буквой: `"Траефик внутри"` вместо `"внутренний домен траефик"`; + - после существующей проверки добавить ещё две проверки в том же тесте: + а) запрос СТРОЧНЫМИ `"траефик"` находит ровно 1 строку и её rowid == 1; + б) запрос ЗАГЛАВНЫМИ `"Траефик"` тоже находит ровно 1 строку и её rowid == 1. + - Проверки делать отдельным запросом каждым, не одним. +2. Ничего больше в файле не менять. Формулировки ассертов писать по-русски, как в файле. +3. Прогнать `./gradlew :memo-core:test --rerun-tasks` и добиться зелёного результата. +4. Закоммитить: `git add -A && git commit -m "core: fts5-тест проверяет регистронезависимость кириллицы"`. + +СТРОГИЕ ЗАПРЕТЫ: +- Не выводить план текстом; сразу правь файл. +- Не трогать боевой код (memo-core/src/main/**), только тестовый файл. +- Не менять другие тесты, не переименовывать их. +- Не добавлять зависимости. diff --git a/docs/orders/02-chunker.md b/docs/orders/02-chunker.md new file mode 100644 index 0000000..aa3eb79 --- /dev/null +++ b/docs/orders/02-chunker.md @@ -0,0 +1,47 @@ +Проект: /root/WORK/memo (Kotlin/JVM). Заказ по чанкеру markdown. + +Создать РОВНО ОДИН новый файл: memo-core/src/main/kotlin/memo/core/Chunker.kt +и РОВНО ОДИН новый тестовый файл: memo-core/src/test/kotlin/memo/core/ChunkerTest.kt + +## Chunker.kt + +package memo.core + +data class Chunk( + val heading: String, // текст заголовка без решёток, обрезанный по краям + val line: Int, // 1-based номер строки, ГДЕ СТОИТ ЗАГОЛОВОК; 1, если заголовка нет + val text: String, // текст раздела без строки-заголовка + val ord: Int, // порядковый номер чанка в файле, с 0 +) + +fun chunkMarkdown(text: String): List + +Правила: +- Разделитель — строка, начинающаяся с одного, двух или трёх символов '#' и следующего за ними пробела + (заголовки '#'..'###'). Более длинные последовательности решёток заголовками НЕ считаются. +- Заголовок попадает в field heading, его строка — в field line. В text строки-заголовки не входят + (text обрезается по краям; пустые строки в начале и конце удаляются). +- Текст ДО первого заголовка даёт чанк с heading = "" и line = 1 (только если он непустой после обрезки). +- Пустые чанки (text пустой после trim) НЕ возвращаются. +- Боковые ограничения по размеру: если text длиннее 4000 символов, он режется на несколько чанков + по 4000 символов с перекрытием 600 символов (хвост предыдущего повторяется в начале следующего); + все части одного раздела имеют ОДИН И ТОТ ЖЕ heading и line, ord растёт. +- Никаких зависимостей; чистый Kotlin. + +## ChunkerTest.kt — ровно 5 тестов, имена ровно такие + +1. `splitsByHeadingLevels` — вход с '# A', '## B', '### C' → 3 чанка, heading = ["A","B","C"], line = [1,3,5], ord = [0,1,2]. +2. `textBeforeFirstHeadingBecomesChunkWithEmptyHeading` — ввод «преамбула\n# Заголовок\nтело» → 2 чанка, первый: heading "»", line 1; второй heading "Заголовок". +3. `fourHashesIsNotAHeading` — строка '#### не заголовок' остаётся частью текста предыдущего раздела. +4. `emptySectionsAreDropped` — ввод с двумя заголовками подряд без текста между ними → 1 чанк. +5. `longSectionIsSplitWithOverlap` — раздел из 6000 символов 'а' → 2 чанка, длина первого 4000, + длина второго 2600, и последние 600 символов первого равны первым 600 символам второго (перекрытие). + +После: `./gradlew :memo-core:test --rerun-tasks` — все тесты (4 старых + 5 новых) зелёные. +Коммит: `git add -A && git commit -m "core: чанкер markdown по заголовкам с перекрытием"`. + +СТРОГИЕ ЗАПРЕТЫ: +- Не выводить план текстом; сразу создавай файлы. +- Не трогать существующие файлы (Embedder.kt, Db.kt, CoreSmokeTest.kt, build.gradle.kts) и любые другие. +- Не добавлять зависимости. +- Не создавать другие файлы. diff --git a/e2e/mutation_check.py b/e2e/mutation_check.py new file mode 100644 index 0000000..1bfeab0 --- /dev/null +++ b/e2e/mutation_check.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""Мутационная проверка: ломаем боевой код и смотрим, падает ли нужный тест. + +Формат e2e/mutations.tsv: fileoldnewmust_fail_test +Прогон: python3 e2e/mutation_check.py --gradle ./gradlew +Выход 0 — все мутации убиты (тесты ловят поломку). 1 — есть выжившие (дыра в покрытии). +""" + +import argparse +import glob +import shutil +import subprocess +import sys +import xml.etree.ElementTree as ET + + +def run_tests(gradle: str) -> dict[str, str]: + """Возвращает {test_name: status}, где status = PASS/FAIL.""" + subprocess.run([gradle, "--quiet", "test", "--rerun-tasks"], + capture_output=True, text=True, timeout=1800) + status: dict[str, str] = {} + for f in glob.glob("**/build/test-results/test/*.xml", recursive=True): + try: + root = ET.parse(f).getroot() + except ET.ParseError: + continue + for tc in root.iter("testcase"): + failed = any(c.tag in ("failure", "error") for c in tc) + status[tc.get("name")] = "FAIL" if failed else "PASS" + return status + + +def main() -> int: + ap = argparse.ArgumentParser() + ap.add_argument("--gradle", default="./gradlew") + ap.add_argument("--mutations", default="e2e/mutations.tsv") + a = ap.parse_args() + + rows = [] + for line in open(a.mutations, encoding="utf-8"): + line = line.rstrip("\n") + if not line or line.startswith("#"): + continue + f, old, new, must_fail = line.split("\t") + rows.append((f, old.replace("\\n", "\n"), new.replace("\\n", "\n"), must_fail)) + + print("=== базовая линия (без мутаций) ===") + base = run_tests(a.gradle) + print(f"тестов найдено: {len(base)}; упавших: {[k for k, v in base.items() if v == 'FAIL']}") + + survivors = [] + for f, old, new, must_fail in rows: + backup = f + ".bak" + shutil.copy2(f, backup) + try: + src = open(f, encoding="utf-8").read() + assert old in src, f"МУТАЦИЯ НЕ ПРИМЕНИЛАСЬ (нет фрагмента) в {f}: {old[:60]!r}" + open(f, "w", encoding="utf-8").write(src.replace(old, new, 1)) + st = run_tests(a.gradle) + got = st.get(must_fail, "НЕ НАЙДЕН") + killed = got == "FAIL" + print(f"{'УБИТА ' if killed else 'ВЫЖИЛА'} | {must_fail} -> {got} | {f}: {old[:50]} -> {new[:30]}") + if not killed: + survivors.append((must_fail, f, old, new)) + finally: + shutil.move(backup, f) + + print("\n=== восстановление ===") + st = run_tests(a.gradle) + print(f"после отката упавших: {[k for k, v in st.items() if v == 'FAIL']}") + + if survivors: + print("\nВЫЖИВШИЕ МУТАЦИИ (дыры в покрытии):") + for t, f, old, new in survivors: + print(f" - {t}: {f} `{old[:60]}` -> `{new[:40]}`") + return 1 + print("\nВСЕ МУТАЦИИ УБИТЫ") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/e2e/mutations.tsv b/e2e/mutations.tsv new file mode 100644 index 0000000..c7f4cf4 --- /dev/null +++ b/e2e/mutations.tsv @@ -0,0 +1,5 @@ +# fileoldnewmust_fail_test +memo-core/src/test/kotlin/memo/core/CoreSmokeTest.kt CREATE VIRTUAL TABLE v USING vec0(embedding float[4]) CREATE VIRTUAL TABLE v USING vec0(embedding float[8]) vec0KnnRoundTrip +memo-core/src/test/kotlin/memo/core/CoreSmokeTest.kt floatArrayOf(0.1f, 0.9f, 0.0f, 0.0f) floatArrayOf(0.0f, 0.0f, 0.0f, 1.0f) vec0KnnRoundTrip +memo-core/src/test/kotlin/memo/core/CoreSmokeTest.kt tokenize='unicode61' tokenize='ascii' fts5FindsCyrillic +memo-core/src/test/kotlin/memo/core/CoreSmokeTest.kt "SELECT a FROM t" "SELECT a+1 FROM t" ksqliteSmoke diff --git a/memo-core/src/test/kotlin/memo/core/CoreSmokeTest.kt b/memo-core/src/test/kotlin/memo/core/CoreSmokeTest.kt index 0af8061..8145b65 100644 --- a/memo-core/src/test/kotlin/memo/core/CoreSmokeTest.kt +++ b/memo-core/src/test/kotlin/memo/core/CoreSmokeTest.kt @@ -93,7 +93,7 @@ class CoreSmokeTest { val insert = conn.prepare("INSERT INTO docs(rowid, text) VALUES (?, ?)") try { insert.bindLong(1, 1L) - insert.bindText(2, "внутренний домен траефик") + insert.bindText(2, "Траефик внутри") insert.executeUpdate() } finally { insert.close() @@ -118,6 +118,46 @@ class CoreSmokeTest { } finally { query.close() } + + val queryLower = conn.prepare("SELECT rowid FROM docs WHERE docs MATCH ?") + try { + queryLower.bindText(1, "траефик") + val rs = queryLower.executeQuery() + try { + var hits = 0 + var lastRowid = -1L + while (rs.next()) { + hits++ + lastRowid = rs.getLong(0)!! + } + assertEquals(1, hits, "ожидалось ровно одно совпадение FTS5") + assertEquals(1L, lastRowid, "rowid должен быть 1") + } finally { + rs.close() + } + } finally { + queryLower.close() + } + + val queryUpper = conn.prepare("SELECT rowid FROM docs WHERE docs MATCH ?") + try { + queryUpper.bindText(1, "Траефик") + val rs = queryUpper.executeQuery() + try { + var hits = 0 + var lastRowid = -1L + while (rs.next()) { + hits++ + lastRowid = rs.getLong(0)!! + } + assertEquals(1, hits, "ожидалось ровно одно совпадение FTS5") + assertEquals(1L, lastRowid, "rowid должен быть 1") + } finally { + rs.close() + } + } finally { + queryUpper.close() + } } finally { conn.close() file.delete()