Корзина отвязывается от страницы корзины, у товара появляется уровень спроса #50

Closed
opened 2026-08-02 21:06:59 +03:00 by ddmitry · 1 comment
Owner

Part of #4

Продолжение #40 в той же ветке feat/40-trade-events. Тикет заведён отдельно,
потому что #40 уже отчитан и закрыт по своим критериям: дописывать в него
новую работу значило бы портить след.

Все числа ниже замерены на каноническом зерне seeds.CANONICAL_SEED
(20260601) за 14 дней — том самом мире, на котором стоят тесты.

Зачем

Приёмка #40 нашла в торговых данных два точных равенства, каких в живом
магазине не бывает.

Первое. Множество визитов с событием корзины в точности совпадает с
множеством визитов, открывших страницу /cart. Проверено на всех 14 днях:
706 и 706, 828 и 828, 690 и 690 — ни одного расхождения. Значит, факт
«положил в корзину» идеально предсказывается более поздним просмотром
страницы. В жизни наоборот: положить и корзину не открыть — самый массовый
сюжет магазина.

Второе. Внутри визита, дошедшего до корзины, в неё ложатся все разные
карточки, которые он открывал: 100%. Вне такого визита — 0%. Решение
принимается один раз на визит, а не на товар, поэтому привлекательность
товара измерить нечем: конверсия «просмотр карточки → корзина» по sku даёт
чистый шум, а вопрос «какие товары кладут чаще» ответа не имеет. По всему дню
до корзины доходит 6,8% просмотров карточек — и эти 6,8% размазаны ровно.

Оба равенства аналитик читает как склейку в разметке, а не как поведение
людей. Решения по обеим правкам приняты и записаны в спеку генератора,
раздел 9, блок «Решено при исполнении #40 после приёмки (2026-08-02)» —
тикет их не пересказывает, а исполняет.

Замер до правки (канонический мир, 14 дней)

величина значение
дневная аудитория 6637–7595, средняя 7166
визитов в день 9879
событий в день 45 951 – 52 770, средних 49 509
просмотров карточек в день 26 903
событий корзины в день 1836, в 765 визитах
визиты с событием корзины / со страницей /cart совпадают все 14 дней
просмотров карточек, дошедших до корзины 6,82%
конверсия визита в покупку 2,43%
позиций на заказ 1,87; однопозиционных заказов 50,3%
брошено позиций в покупающих корзинах 12,0%

Что сделать

Отвязать корзину от страницы корзины. Класть может любой визит, открывший
карточку. Страница /cart остаётся шагом воронки и решается как прежде — в
день-функции, торговая половина её решение не трогает. Заказ по-прежнему
рисуется только у визита, дошедшего до подтверждения.

Ввести уровень спроса у товара. Колонка каталога с тремя значениями
(магнит, обычный, залёживается); вероятности трёх уровней — числа мира в
world.py, рядом с остальными. Уровень назначается каждой строке каталога и
живёт в файле: это постоянная часть мира, а не бросок дня.

Что решать исполнителю

Форма вероятности — за исполнителем, и это главная работа тикета. Вилки мира
узкие, а требования тянут в разные стороны:

  • у визита, дошедшего до /cart, хотя бы одна позиция в корзине есть всегда:
    страница корзины при пустой корзине невозможна. Это включение остаётся
    намеренным — ломается не оно, а обратное;
  • при этом таких визитов около 765 в день, и если вероятность у них та же,
    что у прочих, почти всем придётся «спасать» позицию принудительно — тогда
    корзина этих визитов схлопнется до одной позиции, а заказ станет почти
    всегда однопозиционным (сейчас 1,87 позиции на заказ, половина заказов
    однопозиционные);
  • намерение визита в жизни на добавление влияет, и корреляция «открыл
    корзину — что-то положил» законна. Запрещено не влияние, а точное
    совпадение множеств именно этой пары;
  • бюджет событий держится тем, что общая доля просмотров карточек, доходящих
    до корзины, остаётся около нынешних 6,8%: меняется не сколько кладут, а кто
    и что.

Откуда брать намерение — из шага воронки, из метки покупателя, из того и
другого — решает исполнитель и записывает довод в отчёт.

Критерии приёмки

  • Множество визитов с событием корзины больше не совпадает со множеством
    визитов со страницей /cart: визиты, положившие товар и корзину не
    открывавшие, составляют не менее трети всех визитов с событием корзины.
    Сторож меряет долю, а не факт различия одним визитом.
  • У каждого визита, дошедшего до /cart, в корзине хотя бы одна позиция.
    Проверено сторожем на нескольких днях недели, а не наблюдением на одном.
  • Запрет на точное совпадение касается только пары «событие корзины ↔
    страница /cart». Прочие точные соответствия в торговой половине
    намеренны и остаются: подтверждение ↔ покупка, цель ↔ породившее её
    событие. Их сторожа должны остаться зелёными.
  • data/catalog/products.csv несёт уровень спроса у каждой строки; три
    значения; вероятности уровней — именованные числа в world.py.
  • Конверсия «просмотр карточки → корзина» различима по уровням спроса: за
    14 канонических дней доля разных карточек, дошедших до корзины, у
    магнитов не менее чем вдвое выше, чем у залёживающихся, и порядок трёх
    уровней строгий. Знаменатель назвать в тесте явно: просмотры карточек
    товаров этого уровня, повторный просмотр внутри визита считается один
    раз — так же, как считается позиция.
  • Бюджет событий не поехал: доля просмотров карточек, дошедших до
    корзины, остаётся в пределах 5–9% (сейчас 6,82%), средний день — в
    пределах 48–52 тыс. событий (сейчас 49 509).
  • Вилки раздела 5 держатся: дневная аудитория 6–8 тыс., конверсия визита
    в покупку 2,0–3,0% (сейчас 2,43%).
  • Заказ по-прежнему уже корзины: среднее число позиций на заказ 1,6–2,2
    (сейчас 1,87), доля однопозиционных заказов 40–60% (сейчас 50,3%), доля
    брошенных позиций внутри покупающих корзин ненулевая (сейчас 12,0%).
  • Трафиковая половина не сдвинулась: набор просмотров страниц (страница,
    время, WatchID) на каноническом дне совпадает со снимком, снятым до
    правки. Снимок снять первым ходом, до изменений — иначе сверять будет
    не с чем.
  • Детерминизм не ослаб: существующий сторож повторяемости дня зелёный и
    накрывает всё, что тикет добавил.
  • Документация правится тем же изменением: измеренные числа в спеке
    генератора (раздел 9, блок «после приёмки») приведены к новым; модуль
    catalog.py больше не утверждает, что популярность товаров не
    моделируется.
  • make lint, make typecheck, make test зелёные.

Границы

  • Воронку день-функции не трогать: доли CART_PERCENT,
    CHECKOUT_OF_CART_PERCENT, CONFIRMATION_OF_CHECKOUT_PERCENT и их
    «покупательские» близнецы решают страницы, и решают их как прежде. Если
    калибровка требует их сдвинуть — это решение уровня владельца, вернуть
    вопросом, а не двигать молча.
  • План состава мира (#38) не трогать: метка покупателя, пары, приток.
  • Контракт схемы события и сериализатор не трогать: новых колонок события
    тикет не заводит. Уровень спроса — свойство каталога, в событие не попадает.
  • Форма файла каталога решена (спека, раздел 9): колонка добавляется, порядок
    и смысл прежних колонок не меняется. Словарь ClickHouse над тем же файлом
    ещё не построен (#37/#43) — сдвиг сейчас дёшев, но заметить его в спеке
    стоит.
  • Коммит не делать.

Сначала прочитать

  • docs/specs/2026-08-01-generator.md — раздел 5 (вилки мира) и раздел 9,
    блоки «Решено при исполнении #39», «#40» и «#40 после приёмки». Последний —
    источник истины по этому тикету.
  • CONTEXT.md — термины «Уровень спроса», «Торговое событие», «Каталог
    товаров», «Покупатель», «Визит».
  • generator/src/clickstream_generator/commerce.py — вся торговая половина;
    правка живёт в основном в _baskets и _kept.
  • generator/src/clickstream_generator/catalog.py,
    data/catalog/products.csv, generator/src/clickstream_generator/world.py.
  • generator/src/clickstream_generator/day.py — воронка, чтобы понимать, что
    именно не трогаем.

Проверка

cd generator
make lint
make typecheck
make test

Замер мира — своим скриптом на 14 днях канонического зерна. Осторожно с
маской: строка покупки тоже несёт массивы товаров, поэтому «строка с
товарами» — это не «событие корзины»; событие корзины — строка с товарами и
без номера заказа. На этом уже споткнулась постановка: первый замер завысил
события корзины на число покупок. Числа замера — в отчёт.

Part of #4 Продолжение #40 в той же ветке `feat/40-trade-events`. Тикет заведён отдельно, потому что #40 уже отчитан и закрыт по своим критериям: дописывать в него новую работу значило бы портить след. Все числа ниже замерены на каноническом зерне `seeds.CANONICAL_SEED` (20260601) за 14 дней — том самом мире, на котором стоят тесты. ## Зачем Приёмка #40 нашла в торговых данных два точных равенства, каких в живом магазине не бывает. **Первое.** Множество визитов с событием корзины в точности совпадает с множеством визитов, открывших страницу `/cart`. Проверено на всех 14 днях: 706 и 706, 828 и 828, 690 и 690 — ни одного расхождения. Значит, факт «положил в корзину» идеально предсказывается более поздним просмотром страницы. В жизни наоборот: положить и корзину не открыть — самый массовый сюжет магазина. **Второе.** Внутри визита, дошедшего до корзины, в неё ложатся все разные карточки, которые он открывал: 100%. Вне такого визита — 0%. Решение принимается один раз на визит, а не на товар, поэтому привлекательность товара измерить нечем: конверсия «просмотр карточки → корзина» по sku даёт чистый шум, а вопрос «какие товары кладут чаще» ответа не имеет. По всему дню до корзины доходит 6,8% просмотров карточек — и эти 6,8% размазаны ровно. Оба равенства аналитик читает как склейку в разметке, а не как поведение людей. Решения по обеим правкам приняты и записаны в спеку генератора, раздел 9, блок «Решено при исполнении #40 после приёмки (2026-08-02)» — тикет их не пересказывает, а исполняет. ## Замер до правки (канонический мир, 14 дней) | величина | значение | | --- | --- | | дневная аудитория | 6637–7595, средняя 7166 | | визитов в день | 9879 | | событий в день | 45 951 – 52 770, средних 49 509 | | просмотров карточек в день | 26 903 | | событий корзины в день | 1836, в 765 визитах | | визиты с событием корзины / со страницей `/cart` | совпадают все 14 дней | | просмотров карточек, дошедших до корзины | 6,82% | | конверсия визита в покупку | 2,43% | | позиций на заказ | 1,87; однопозиционных заказов 50,3% | | брошено позиций в покупающих корзинах | 12,0% | ## Что сделать **Отвязать корзину от страницы корзины.** Класть может любой визит, открывший карточку. Страница `/cart` остаётся шагом воронки и решается как прежде — в день-функции, торговая половина её решение не трогает. Заказ по-прежнему рисуется только у визита, дошедшего до подтверждения. **Ввести уровень спроса у товара.** Колонка каталога с тремя значениями (магнит, обычный, залёживается); вероятности трёх уровней — числа мира в `world.py`, рядом с остальными. Уровень назначается каждой строке каталога и живёт в файле: это постоянная часть мира, а не бросок дня. ## Что решать исполнителю Форма вероятности — за исполнителем, и это главная работа тикета. Вилки мира узкие, а требования тянут в разные стороны: - у визита, дошедшего до `/cart`, хотя бы одна позиция в корзине есть всегда: страница корзины при пустой корзине невозможна. Это включение остаётся намеренным — ломается не оно, а обратное; - при этом таких визитов около 765 в день, и если вероятность у них та же, что у прочих, почти всем придётся «спасать» позицию принудительно — тогда корзина этих визитов схлопнется до одной позиции, а заказ станет почти всегда однопозиционным (сейчас 1,87 позиции на заказ, половина заказов однопозиционные); - намерение визита в жизни на добавление влияет, и корреляция «открыл корзину — что-то положил» законна. Запрещено не влияние, а точное совпадение множеств именно этой пары; - бюджет событий держится тем, что общая доля просмотров карточек, доходящих до корзины, остаётся около нынешних 6,8%: меняется не сколько кладут, а кто и что. Откуда брать намерение — из шага воронки, из метки покупателя, из того и другого — решает исполнитель и записывает довод в отчёт. ## Критерии приёмки - [x] Множество визитов с событием корзины больше не совпадает со множеством визитов со страницей `/cart`: визиты, положившие товар и корзину не открывавшие, составляют не менее трети всех визитов с событием корзины. Сторож меряет долю, а не факт различия одним визитом. - [x] У каждого визита, дошедшего до `/cart`, в корзине хотя бы одна позиция. Проверено сторожем на нескольких днях недели, а не наблюдением на одном. - [x] Запрет на точное совпадение касается только пары «событие корзины ↔ страница `/cart`». Прочие точные соответствия в торговой половине намеренны и остаются: подтверждение ↔ покупка, цель ↔ породившее её событие. Их сторожа должны остаться зелёными. - [x] `data/catalog/products.csv` несёт уровень спроса у каждой строки; три значения; вероятности уровней — именованные числа в `world.py`. - [x] Конверсия «просмотр карточки → корзина» различима по уровням спроса: за 14 канонических дней доля разных карточек, дошедших до корзины, у магнитов не менее чем вдвое выше, чем у залёживающихся, и порядок трёх уровней строгий. Знаменатель назвать в тесте явно: просмотры карточек товаров этого уровня, повторный просмотр внутри визита считается один раз — так же, как считается позиция. - [x] Бюджет событий не поехал: доля просмотров карточек, дошедших до корзины, остаётся в пределах 5–9% (сейчас 6,82%), средний день — в пределах 48–52 тыс. событий (сейчас 49 509). - [x] Вилки раздела 5 держатся: дневная аудитория 6–8 тыс., конверсия визита в покупку 2,0–3,0% (сейчас 2,43%). - [x] Заказ по-прежнему уже корзины: среднее число позиций на заказ 1,6–2,2 (сейчас 1,87), доля однопозиционных заказов 40–60% (сейчас 50,3%), доля брошенных позиций внутри покупающих корзин ненулевая (сейчас 12,0%). - [x] Трафиковая половина не сдвинулась: набор просмотров страниц (страница, время, `WatchID`) на каноническом дне совпадает со снимком, снятым до правки. Снимок снять первым ходом, до изменений — иначе сверять будет не с чем. - [x] Детерминизм не ослаб: существующий сторож повторяемости дня зелёный и накрывает всё, что тикет добавил. - [x] Документация правится тем же изменением: измеренные числа в спеке генератора (раздел 9, блок «после приёмки») приведены к новым; модуль `catalog.py` больше не утверждает, что популярность товаров не моделируется. - [x] `make lint`, `make typecheck`, `make test` зелёные. ## Границы - Воронку день-функции не трогать: доли `CART_PERCENT`, `CHECKOUT_OF_CART_PERCENT`, `CONFIRMATION_OF_CHECKOUT_PERCENT` и их «покупательские» близнецы решают страницы, и решают их как прежде. Если калибровка требует их сдвинуть — это решение уровня владельца, вернуть вопросом, а не двигать молча. - План состава мира (#38) не трогать: метка покупателя, пары, приток. - Контракт схемы события и сериализатор не трогать: новых колонок события тикет не заводит. Уровень спроса — свойство каталога, в событие не попадает. - Форма файла каталога решена (спека, раздел 9): колонка добавляется, порядок и смысл прежних колонок не меняется. Словарь ClickHouse над тем же файлом ещё не построен (#37/#43) — сдвиг сейчас дёшев, но заметить его в спеке стоит. - Коммит не делать. ## Сначала прочитать - `docs/specs/2026-08-01-generator.md` — раздел 5 (вилки мира) и раздел 9, блоки «Решено при исполнении #39», «#40» и «#40 после приёмки». Последний — источник истины по этому тикету. - `CONTEXT.md` — термины «Уровень спроса», «Торговое событие», «Каталог товаров», «Покупатель», «Визит». - `generator/src/clickstream_generator/commerce.py` — вся торговая половина; правка живёт в основном в `_baskets` и `_kept`. - `generator/src/clickstream_generator/catalog.py`, `data/catalog/products.csv`, `generator/src/clickstream_generator/world.py`. - `generator/src/clickstream_generator/day.py` — воронка, чтобы понимать, что именно не трогаем. ## Проверка ``` cd generator make lint make typecheck make test ``` Замер мира — своим скриптом на 14 днях канонического зерна. Осторожно с маской: строка покупки тоже несёт массивы товаров, поэтому «строка с товарами» — это не «событие корзины»; событие корзины — строка с товарами и без номера заказа. На этом уже споткнулась постановка: первый замер завысил события корзины на число покупок. Числа замера — в отчёт.
ddmitry added the ready-for-agent label 2026-08-02 21:07:05 +03:00
ddmitry self-assigned this 2026-08-02 21:07:06 +03:00
Author
Owner

Сделано, коммит в ветке feat/40-trade-events.

Что получилось. Класть в корзину теперь может любой визит, открывший
карточку: 41,4% кладущих визитов корзину не открывают вовсе — раньше таких
не было ни одного. У визита, дошедшего до /cart, корзина по-прежнему
непуста, ни разу за 14 дней. У товара появился уровень спроса, и конверсия
«просмотр карточки → корзина» стала различима: 13,1% у магнитов, 9,2% у
обычных, 5,8% у залёживающихся — магнит выше залёживающегося в 2,25 раза.
По отдельным товарам разброс от 15,4% до 5,1%.

Числа мира после правки (14 дней канонического зерна): средний день
49 834 события, конверсия карточки в корзину 8,62%, конверсия визита в
покупку 2,43%, аудитория 6637–7595, позиций на заказ 1,74, однопозиционных
заказов 56,4%, брошено 7,25% позиций в покупающих корзинах. Трафиковая
половина побайтово прежняя — sha256 по всем просмотрам за 14 дней совпал со
снимком до правки.

Решение уровня задачи, принятое исполнителем: намерение визита берётся
из шага воронки, а не из метки покупателя — метка уже действует через
воронку, и второе слагаемое посчитало бы её дважды, протянув план состава
сквозь шов между днём и торговой половиной. Двух рядов вероятностей вместо
одного ряда с множителем потребовал потолок: у визита с намерением ряд
обязан быть высоким, иначе корзина схлопывается, а у потолка разница уровней
съедается.

Ревью. Две слепые линии, пересечение — ноль. Линия задачи и вкуса —
7 находок (1 MAJOR), линия кода — 1 MINOR при 120 командах. Триаж: 9 FIXED,
0 отклонено, два круга; обе перепроверки закрыли свои находки.

Крупнейшая находка: нулевая вероятность у залёживающегося товара возвращала
ровно то тождество, ради уничтожения которого тикет заведён, — для целого
уровня спроса и 27% каталога. Закрыта не тем маршрутом, который предложил
ревьюер: предложенный ряд был перемерен и отвергнут (давал магнит ÷
залёживается = 1,84 при требуемых 2), ревьюер это подтвердил своим замером.
Вторая по весу: уровни спроса были разложены идеально ровно, 6/16/8 в каждой
категории, — каталог пересыпан целиком, все числа перемерены.

Сверх тикета, обе линии признали обоснованным: ABANDONED_POSITION_PERCENT
15 → 10 (корзина сузилась, прежняя доля выносила её целиком) и правка
мастер-спеки, раздел 3 — список колонок каталога, по которому будет строиться
словарь ClickHouse (#37/#43).

Проверка: make lint && make typecheck && make test из корня
репозитория — 394 passed (было 383).

Осталось владельцу, не блокирует: конверсия карточки в корзину 8,62% при
вилке 5–9% — запаса 0,4 п.п., и обе границы тикета давят вверх. Следующая
правка торговой половины этот запас потратит; вилку стоит пересмотреть до
того, как #42 заморозит манифест.

Сделано, коммит в ветке `feat/40-trade-events`. **Что получилось.** Класть в корзину теперь может любой визит, открывший карточку: 41,4% кладущих визитов корзину не открывают вовсе — раньше таких не было ни одного. У визита, дошедшего до `/cart`, корзина по-прежнему непуста, ни разу за 14 дней. У товара появился уровень спроса, и конверсия «просмотр карточки → корзина» стала различима: 13,1% у магнитов, 9,2% у обычных, 5,8% у залёживающихся — магнит выше залёживающегося в 2,25 раза. По отдельным товарам разброс от 15,4% до 5,1%. **Числа мира после правки** (14 дней канонического зерна): средний день 49 834 события, конверсия карточки в корзину 8,62%, конверсия визита в покупку 2,43%, аудитория 6637–7595, позиций на заказ 1,74, однопозиционных заказов 56,4%, брошено 7,25% позиций в покупающих корзинах. Трафиковая половина побайтово прежняя — sha256 по всем просмотрам за 14 дней совпал со снимком до правки. **Решение уровня задачи, принятое исполнителем**: намерение визита берётся из шага воронки, а не из метки покупателя — метка уже действует через воронку, и второе слагаемое посчитало бы её дважды, протянув план состава сквозь шов между днём и торговой половиной. Двух рядов вероятностей вместо одного ряда с множителем потребовал потолок: у визита с намерением ряд обязан быть высоким, иначе корзина схлопывается, а у потолка разница уровней съедается. **Ревью.** Две слепые линии, пересечение — ноль. Линия задачи и вкуса — 7 находок (1 MAJOR), линия кода — 1 MINOR при 120 командах. Триаж: 9 FIXED, 0 отклонено, два круга; обе перепроверки закрыли свои находки. Крупнейшая находка: нулевая вероятность у залёживающегося товара возвращала ровно то тождество, ради уничтожения которого тикет заведён, — для целого уровня спроса и 27% каталога. Закрыта не тем маршрутом, который предложил ревьюер: предложенный ряд был перемерен и отвергнут (давал магнит ÷ залёживается = 1,84 при требуемых 2), ревьюер это подтвердил своим замером. Вторая по весу: уровни спроса были разложены идеально ровно, 6/16/8 в каждой категории, — каталог пересыпан целиком, все числа перемерены. **Сверх тикета**, обе линии признали обоснованным: `ABANDONED_POSITION_PERCENT` 15 → 10 (корзина сузилась, прежняя доля выносила её целиком) и правка мастер-спеки, раздел 3 — список колонок каталога, по которому будет строиться словарь ClickHouse (#37/#43). **Проверка**: `make lint && make typecheck && make test` из корня репозитория — 394 passed (было 383). **Осталось владельцу, не блокирует**: конверсия карточки в корзину 8,62% при вилке 5–9% — запаса 0,4 п.п., и обе границы тикета давят вверх. Следующая правка торговой половины этот запас потратит; вилку стоит пересмотреть до того, как #42 заморозит манифест.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: ddmitry/clickstream-data-platform#50