test: add tackle query acceptance gate
This commit is contained in:
@@ -37,3 +37,35 @@ def test_tackle_recommendation_requires_samples_and_independent_players() -> Non
|
|||||||
assert rows[0].status == "insufficient_data"
|
assert rows[0].status == "insufficient_data"
|
||||||
|
|
||||||
engine.dispose()
|
engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def test_tackle_analytics_handles_empty_and_multicomponent_observations() -> None:
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
engine = create_engine("sqlite://")
|
||||||
|
Base.metadata.create_all(engine)
|
||||||
|
with Session(engine) as db:
|
||||||
|
waterbody = Waterbody(slug="empty-check", name_ru="Проверка", unlock_level=1)
|
||||||
|
fish = Fish(slug="perch", name_ru="Окунь", trophy_weight_g=5_000)
|
||||||
|
spot = Spot(waterbody=waterbody, x=1, y=2)
|
||||||
|
report = CatchReport(
|
||||||
|
fish=fish, waterbody=waterbody, spot=spot, weight_g=500,
|
||||||
|
caught_at=now, reported_at=now, player_name="Player",
|
||||||
|
source_type=SourceType.user, source_confidence=80,
|
||||||
|
moderation_status=ModerationStatus.approved,
|
||||||
|
)
|
||||||
|
report.tackle_components.extend([
|
||||||
|
CatchTackleComponent(role="lure", position=0, raw_value="Spinner #1"),
|
||||||
|
CatchTackleComponent(role="rig", position=1, raw_value="Rig #1"),
|
||||||
|
CatchTackleComponent(role="lure", position=2, raw_value="Spinner #1"),
|
||||||
|
CatchTackleComponent(role="lure", position=3, raw_value=" "),
|
||||||
|
])
|
||||||
|
db.add(report)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
rows = tackle_combinations(db, waterbody="empty-check", fish="perch", method=None, hours=72, min_samples=1, min_players=1)
|
||||||
|
assert {(row.role, row.value, row.catches) for row in rows} == {
|
||||||
|
("lure", "Spinner #1", 1), ("rig", "Rig #1", 1),
|
||||||
|
}
|
||||||
|
assert tackle_combinations(db, waterbody="missing", fish=None, method=None, hours=72) == []
|
||||||
|
|
||||||
|
engine.dispose()
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
\set ON_ERROR_STOP on
|
||||||
|
\timing off
|
||||||
|
|
||||||
|
-- Session-local fixture for the G06/G07 public tackle paths.
|
||||||
|
-- No production table or row is modified.
|
||||||
|
SET search_path = pg_temp, public;
|
||||||
|
SET jit = off;
|
||||||
|
SET work_mem = '16MB';
|
||||||
|
|
||||||
|
CREATE TEMP TABLE tackle_item (
|
||||||
|
id uuid PRIMARY KEY, name varchar(200) NOT NULL, category varchar(20) NOT NULL,
|
||||||
|
brand varchar(100), family varchar(100), unlock_level integer
|
||||||
|
);
|
||||||
|
CREATE TEMP TABLE catch_report (
|
||||||
|
id uuid PRIMARY KEY, moderation_status moderationstatus NOT NULL,
|
||||||
|
deleted_at timestamptz, reported_at timestamptz NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TEMP TABLE catch_tackle_component (
|
||||||
|
id uuid PRIMARY KEY, catch_report_id uuid NOT NULL, role varchar(50) NOT NULL,
|
||||||
|
raw_value varchar(200) NOT NULL, position integer NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO tackle_item (id, name, category, brand, family, unlock_level)
|
||||||
|
SELECT md5('tackle-' || g)::uuid, 'Предмет ' || g,
|
||||||
|
CASE WHEN g % 4 = 0 THEN 'bait' ELSE 'lure' END,
|
||||||
|
'Brand-' || (g % 25), 'Family-' || (g % 100), g % 30
|
||||||
|
FROM generate_series(1, 5000) AS g;
|
||||||
|
|
||||||
|
INSERT INTO catch_report (id, moderation_status, deleted_at, reported_at)
|
||||||
|
SELECT md5('tackle-report-' || g)::uuid,
|
||||||
|
CASE WHEN g % 10 = 0 THEN 'pending'::moderationstatus ELSE 'approved'::moderationstatus END,
|
||||||
|
CASE WHEN g % 50 = 0 THEN clock_timestamp() ELSE NULL END,
|
||||||
|
clock_timestamp() - (((g * 97) % 7776000) * interval '1 second')
|
||||||
|
FROM generate_series(1, 100000) AS g;
|
||||||
|
|
||||||
|
INSERT INTO catch_tackle_component (id, catch_report_id, role, raw_value, position)
|
||||||
|
SELECT md5('component-' || g)::uuid,
|
||||||
|
md5('tackle-report-' || g)::uuid,
|
||||||
|
CASE WHEN g % 3 = 0 THEN 'rig' ELSE 'lure' END,
|
||||||
|
'Предмет ' || ((g - 1) % 5000 + 1), 0
|
||||||
|
FROM generate_series(1, 100000) AS g;
|
||||||
|
|
||||||
|
CREATE INDEX tackle_item_catalog_filter_idx
|
||||||
|
ON tackle_item (category, brand, family, unlock_level, name, id);
|
||||||
|
CREATE INDEX catch_report_tackle_feed_idx
|
||||||
|
ON catch_report (moderation_status, deleted_at, reported_at, id);
|
||||||
|
CREATE INDEX catch_tackle_component_report_idx
|
||||||
|
ON catch_tackle_component (catch_report_id, role, raw_value);
|
||||||
|
|
||||||
|
ANALYZE tackle_item;
|
||||||
|
ANALYZE catch_report;
|
||||||
|
ANALYZE catch_tackle_component;
|
||||||
|
|
||||||
|
\echo 'DATASET'
|
||||||
|
SELECT count(*) AS tackle_items, (SELECT count(*) FROM catch_report) AS reports,
|
||||||
|
(SELECT count(*) FROM catch_tackle_component) AS components
|
||||||
|
FROM tackle_item;
|
||||||
|
|
||||||
|
\echo 'PLAN tackle_catalog_filter'
|
||||||
|
EXPLAIN (ANALYZE, BUFFERS, SETTINGS)
|
||||||
|
SELECT id, name, category, brand, family, unlock_level
|
||||||
|
FROM tackle_item
|
||||||
|
WHERE category = 'lure' AND brand = 'Brand-7' AND family = 'Family-7'
|
||||||
|
ORDER BY name, id
|
||||||
|
LIMIT 50;
|
||||||
|
|
||||||
|
\echo 'PLAN tackle_combinations'
|
||||||
|
EXPLAIN (ANALYZE, BUFFERS, SETTINGS)
|
||||||
|
SELECT c.role, c.raw_value, count(DISTINCT r.id)
|
||||||
|
FROM catch_report AS r
|
||||||
|
JOIN catch_tackle_component AS c ON c.catch_report_id = r.id
|
||||||
|
WHERE r.moderation_status = 'approved'
|
||||||
|
AND r.deleted_at IS NULL
|
||||||
|
AND r.reported_at >= now() - interval '72 hours'
|
||||||
|
GROUP BY c.role, c.raw_value
|
||||||
|
ORDER BY count(DISTINCT r.id) DESC, c.role, c.raw_value
|
||||||
|
LIMIT 50;
|
||||||
Executable
+24
@@ -0,0 +1,24 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
repo=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)
|
||||||
|
output=$(mktemp)
|
||||||
|
cleanup() { rm -f "$output"; }
|
||||||
|
trap cleanup EXIT INT TERM
|
||||||
|
|
||||||
|
docker compose exec -T db psql -X -U rf4 -d rf4_spotter \
|
||||||
|
< "$repo/deploy/tackle-query-plan-gate.sql" > "$output"
|
||||||
|
|
||||||
|
for plan in tackle_catalog_filter tackle_combinations; do
|
||||||
|
grep -F "PLAN $plan" "$output" >/dev/null
|
||||||
|
done
|
||||||
|
|
||||||
|
awk '
|
||||||
|
/Execution Time:/ { count += 1; if (($3 + 0) > 250) { print "Tackle query plan exceeded 250 ms: " $0 > "/dev/stderr"; failed = 1 } }
|
||||||
|
END { if (count != 2 || failed) exit 1 }
|
||||||
|
' "$output"
|
||||||
|
|
||||||
|
grep -A40 'PLAN tackle_catalog_filter' "$output" | grep -Eq 'Index Scan|Index Only Scan|Bitmap Index Scan'
|
||||||
|
grep -A60 'PLAN tackle_combinations' "$output" | grep -Eq 'Index Scan|Bitmap Index Scan'
|
||||||
|
grep 'Execution Time:' "$output"
|
||||||
|
echo "Tackle query-plan gate passed: indexed catalog and combination paths under 250 ms"
|
||||||
+1
-1
@@ -74,7 +74,7 @@
|
|||||||
- [ ] **G06 · API и публичный каталог.** Добавлены пагинированный `/api/v1/tackle/items` с фильтрами по категории, бренду, семейству и уровню, detail endpoints для предмета и монтажа, а также ordered `tackle_components` в ответе уловов точки. Ответы показывают только канонические характеристики, provenance, timestamp проверки и `missing_fields`; рейтинг эффективности не добавляется. Остаётся подключить публичные Astro-карточки и ссылки из всех нужных представлений улова.
|
- [ ] **G06 · API и публичный каталог.** Добавлены пагинированный `/api/v1/tackle/items` с фильтрами по категории, бренду, семейству и уровню, detail endpoints для предмета и монтажа, а также ordered `tackle_components` в ответе уловов точки. Ответы показывают только канонические характеристики, provenance, timestamp проверки и `missing_fields`; рейтинг эффективности не добавляется. Остаётся подключить публичные Astro-карточки и ссылки из всех нужных представлений улова.
|
||||||
- [ ] **G07 · Аналитика сочетаний и рекомендации.** Добавлен `/api/v1/analytics/tackle`: approved-наблюдения группируются по роли и исходному компоненту с фильтрами водоёма, рыбы, метода и окна; дубликаты одного улова не увеличивают счётчик, а минимум наблюдений и независимых игроков отделяет факт использования от рекомендации. Пустая или малая выборка получает `insufficient_data`; decay по свежести и отдельные UI-состояния остаются следующим шагом.
|
- [ ] **G07 · Аналитика сочетаний и рекомендации.** Добавлен `/api/v1/analytics/tackle`: approved-наблюдения группируются по роли и исходному компоненту с фильтрами водоёма, рыбы, метода и окна; дубликаты одного улова не увеличивают счётчик, а минимум наблюдений и независимых игроков отделяет факт использования от рекомендации. Пустая или малая выборка получает `insufficient_data`; decay по свежести и отдельные UI-состояния остаются следующим шагом.
|
||||||
- [ ] **G08 · Медиа и качество.** Добавлены отдельные reviewed-роли `tackle_card`, `tackle_detail`, `rig_diagram`, `tackle_screenshot` для `tackle`, а также CLI-параметр `--media-role`; offline audit отклоняет неизвестную роль и несовпадение роли с entity type. Существующие dimensions, MIME, SHA-256, прозрачность, aspect ratio, provenance и атомарное продвижение сохраняются. Остаётся провести реальный contact-sheet review для будущих tackle-кандидатов без автоматической публикации.
|
- [ ] **G08 · Медиа и качество.** Добавлены отдельные reviewed-роли `tackle_card`, `tackle_detail`, `rig_diagram`, `tackle_screenshot` для `tackle`, а также CLI-параметр `--media-role`; offline audit отклоняет неизвестную роль и несовпадение роли с entity type. Существующие dimensions, MIME, SHA-256, прозрачность, aspect ratio, provenance и атомарное продвижение сохраняются. Остаётся провести реальный contact-sheet review для будущих tackle-кандидатов без автоматической публикации.
|
||||||
- [ ] **G09 · Приёмка и эксплуатация.** Добавлены fixture/regression tests для crosswalk, media roles, идемпотентных компонентов и малой выборки рекомендаций; каталог `/tackle` включён в visual-matrix regression. Offline catalog/media audits и сохранение старых данных при сбое проходят. Остаются HTTP/browser acceptance для пустых, неоднозначных и многокомпонентных комплектов и query-plan gate для production-фильтров. Сетевые тесты не выполнять; импорт оставить opt-in, последовательным и под общим cooldown/backoff. Закрывать пакет только после проверяемого счётчика по каждой категории либо явной фиксации `unknown`.
|
- [ ] **G09 · Приёмка и эксплуатация.** Добавлены fixture/regression tests для crosswalk, media roles, идемпотентных компонентов, пустых результатов и многокомпонентных наблюдений; каталог `/tackle` включён в visual-matrix, narrow smoke и accessibility routes. Offline catalog/media audits и сохранение старых данных при сбое проходят. Добавлен TEMP-only query-plan gate для фильтра каталога и группировки сочетаний: production-фильтры должны использовать индексы и укладываться в 250 мс. Остаются HTTP/browser acceptance для пустых, неоднозначных и многокомпонентных комплектов. Сетевые тесты не выполнять; импорт оставить opt-in, последовательным и под общим cooldown/backoff. Закрывать пакет только после проверяемого счётчика по каждой категории либо явной фиксации `unknown`.
|
||||||
|
|
||||||
### Тёмная тема
|
### Тёмная тема
|
||||||
|
|
||||||
|
|||||||
@@ -47,4 +47,12 @@
|
|||||||
|
|
||||||
После наполнения выполнить `EXPLAIN (ANALYZE, BUFFERS)` для activity, moderation queue, records, staging queue и удаления старых submission attempts. Проверять фактическое время, `Rows Removed by Filter`, объём buffers и соответствие выбранного индекса фильтрам. Если таблица превышает 10 000 строк, а план остаётся последовательным и выходит за бюджет, сохранить план в журнал релиза и скорректировать индекс или форму запроса до открытия альфы.
|
После наполнения выполнить `EXPLAIN (ANALYZE, BUFFERS)` для activity, moderation queue, records, staging queue и удаления старых submission attempts. Проверять фактическое время, `Rows Removed by Filter`, объём buffers и соответствие выбранного индекса фильтрам. Если таблица превышает 10 000 строк, а план остаётся последовательным и выходит за бюджет, сохранить план в журнал релиза и скорректировать индекс или форму запроса до открытия альфы.
|
||||||
|
|
||||||
|
Для новых каталогов снастей и аналитики сочетаний используется отдельный TEMP-only gate:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./deploy/test-tackle-query-plans.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Он создаёт 5 000 предметов, 100 000 уловов и 100 000 ordered-компонентов только в текущей psql-сессии, проверяет фильтр каталога и группировку approved-компонентов за 72 часа. Фильтр каталога должен использовать индексный путь, аналитика — селективный индексный путь по временной ленте и уложиться вместе с каталогом в 250 мс; широкий scan компонентов допустим после ограничения уловов. Production-таблицы не изменяются.
|
||||||
|
|
||||||
Bootstrap-тест отдельно проверяет, что Alembic прошёл миграцию `0011` и все восемь составных индексов созданы на чистой PostgreSQL. После запуска на сервере локальный gate не заменяет три полевых замера p95 из [load-testing.md](load-testing.md).
|
Bootstrap-тест отдельно проверяет, что Alembic прошёл миграцию `0011` и все восемь составных индексов созданы на чистой PostgreSQL. После запуска на сервере локальный gate не заменяет три полевых замера p95 из [load-testing.md](load-testing.md).
|
||||||
|
|||||||
Reference in New Issue
Block a user