diff --git a/editorial/production/README.md b/editorial/production/README.md index 2104f5f..57ee7d8 100644 --- a/editorial/production/README.md +++ b/editorial/production/README.md @@ -1,6 +1,6 @@ # Производство редакционных партий -На 31 июля 2026 года строгий аудит проходит 265 из 358 созданных материалов. Остальные 93 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить. +На 31 июля 2026 года строгий аудит проходит 268 из 358 созданных материалов. Остальные 90 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить. ## Одна партия diff --git a/editorial/reviews/2025-07-draft.md b/editorial/reviews/2025-07-draft.md new file mode 100644 index 0000000..ba8bc2a --- /dev/null +++ b/editorial/reviews/2025-07-draft.md @@ -0,0 +1,80 @@ +# P89 — июль 2025: метрики продукта для инженера + +## Область draft-пакета + +Пакет заменяет только три overlay-статьи: + +- editorial-2025-07-practice-product-metrics — «Метрики продукта для инженера: построить цепочку, а не поднять conversion». +- editorial-2025-07-mechanism-product-metrics — «Почему рост метрики не является решением: denominator, cohort и guardrail». +- editorial-2025-07-field-product-metrics — «Полевой журнал продуктовой метрики: evidence до решения». + +Единственный исполняемый артефакт — web/scripts/upgrade-2025-07.mjs. Все events, cohorts, subjects, requestId, периоды, ratios, решения и результаты — fixed synthetic in-memory JS literals. Скрипт не читает и не пишет files, Git, network, CI, clock, telemetry, production или user data и не выполняет side effect. Пакет не подключает overlay к registry, не меняет README, очередь, articles.json, app-страницы, docs, staging, commit или push. + +## Исследование и историческая граница + +Проверка источников выполнена 31.07.2026 обычными HTTPS-запросами без авторизации и TLS bypass. Историческая граница — 31 июля 2025: OpenTelemetry закреплён immutable commit, работы Microsoft Research опубликованы в 2017 и 2019 годах. + +| Источник | Закрепление | Узко подтверждаемое утверждение | Граница | +| --- | --- | --- | --- | +| [OpenTelemetry events](https://raw.githubusercontent.com/open-telemetry/semantic-conventions/c01aa89d9a13042e56536c60975139c50e764796/docs/general/events.md) | tag v1.31.0, commit c01aa89d9a13042e56536c60975139c50e764796, 11.03.2025 | event имеет уникальное имя; convention документирует его структуру и attributes; dynamic values не включаются в event name. | Не определяет продуктовую causal effect, denominator, guardrail или ship criterion. | +| [Safe Velocity with Controlled Rollouts](https://www.microsoft.com/en-us/research/uploads/prod/2020/06/Safe-Velocity-ICSE-SEI.pdf) | ICSE-SEIP 2019, author version | local feature, success и guardrail metrics имеют разные роли; guardrail не должен деградировать при rollout. | Описывает controlled rollouts Microsoft, не доказывает эффект произвольной local metric в другом продукте. | +| [A Dirty Dozen Metric Interpretation Pitfalls](https://www.microsoft.com/en-us/research/uploads/prod/2020/08/2017-08-KDDMetricInterpretationPitfalls.pdf) | KDD 2017, author version | другой состав наблюдений и telemetry loss могут исказить metric delta; numerator и denominator полезно разбирать отдельно. | Не заменяет дизайн конкретного эксперимента, power analysis, privacy policy или pipeline validation. | + +Причинная цепочка, названия полей, synthetic input и правило HOLD — собственная инженерная конструкция статей. Они не приписаны источникам как framework guarantee. + +## Проход 1 — факты, техника и источники + +- createFixedProductMetricInput выдаёт только один из четырёх frozen fixed cases. inspectFixedProductMetric считает unique subjects для opened, confirmed и render_failed; не принимает произвольный input как доказательство. +- Успешный case требует attribution, declared denominator и единственный cohort-period. prepareFixedProductDecision после PASS создаёт только needs-human-owner-decision; production effect остаётся not-attempted. +- Negative paths проверены отдельно: нет attribution → missing-attribution-rule; другой период → mixed-cohort-or-period; all-events вместо unique opened subjects → wrong-denominator. +- Из источников в тексте взяты только документированная форма события, разные роли local/success/guardrail и риск неверного состава наблюдений. Нет утверждения, что рост confirm сам доказывает причинность или что конкретный threshold применим вне synthetic example. + +**Вердикт прохода 1:** пройдено. Код и текст разделяют зафиксированный учебный contract, human decision и реальные продуктовые выводы. + +## Проход 2 — голос, плотность, ситуация и цена + +| Статья | Первые два абзаца | Основной текст | Артефакт | Проверяемый финал | +| --- | --- | ---: | --- | --- | +| practice | Изолированный рост conversion и цена false release/повторного расследования | 6 648 | causal funnel, event contract, fixture | test на confirm без requestId возвращает HOLD | +| mechanism | Разный состав numerator/denominator и цена false positive | 6 410 | matrix, ratio contract, negative cases | событие из другого period останавливает расчёт | +| field | Число отделено от context и цена неповторимого decision | 6 291 | decision record, evidence loop, stop example | другой инженер восстанавливает contract по карточке | + +Три текста соответствуют голосу М8: автор не продаёт «рост метрик», а сравнивает риск, owner и воспроизводимый путь. Термины раскрыты в момент первого использования: cohort, attribution, denominator, guardrail, local metric и decision record. Формула «симптом → причина → проверка → действие» сохраняется в каждом разделе. Синтетические числа не выдаются за telemetry, реальные деньги, людей или инциденты. + +**Вердикт прохода 2:** пройдено. Усиленный audit:draft подтвердил проблему и цену ошибки в первых двух абзацах, диапазон 5–15 тыс. знаков, таблицу, figure, code example, ordered sequence и источники. + +## Проход 3 — SVG, fixture и выпуск + +Последовательность фактически выполненных проверок: + +- node --check web/scripts/upgrade-2025-07.mjs — PASS. +- node web/scripts/upgrade-2025-07.mjs --verify-fixture — PASS, **11/11** assertions. +- cd web && npm run audit:draft -- scripts/upgrade-2025-07.mjs — PASS: **6 648 / 6 410 / 6 291** body chars после независимой редакторской правки. +- xmllint --noout для трёх SVG — PASS; safety scan чист: нет script, foreignObject, javascript:, data:image и event-handler attributes. +- Sharp renders на 375 px просмотрены вручную. Funnel сохраняет отдельную красную ветку guardrail; matrix читается в четыре строки; evidence loop показывает HOLD ветку и не обрезает подписи. + +| Asset | Смысл | Результат 375 px | +| --- | --- | --- | +| product-metrics-2025-causal-funnel.svg | change → opened → confirmed → human review и guardrail | PASS: стрелки, подписи шагов и красный guardrail видимы. | +| product-metrics-2025-metric-guardrail-matrix.svg | четыре измерительных условия и HOLD | PASS: контраст и строки читаемы, нижняя оговорка не обрезана. | +| product-metrics-2025-decision-evidence-loop.svg | evidence loop с веткой repair contract | PASS: пять шагов и HOLD branch различимы на узком экране. | + +Full build и audit:articles намеренно не запускались: overlay не подключён к registry. Их должен выполнить независимый интегратор после подключения. Commit и публикация не выполнялись. + +**Вердикт прохода 3:** draft-пакет готов к независимой приёмке, но не опубликован. + +## Выпуск после независимой приёмки + +### 1. Факты и техника + +Редактор проверил источники заново. Immutable OpenTelemetry commit `c01aa89d9a13042e56536c60975139c50e764796` от 11 марта 2025 подтверждает уникальное имя события, документированную структуру и низкую кардинальность имени. PDF Safe Velocity (ICSE-SEIP 2019) подтверждает разные роли data-quality, guardrail, success и local metrics; PDF Dirty Dozen (KDD 2017) подтверждает, что sample/telemetry mismatch может сделать delta недостоверной и требует разбирать numerator и denominator. Эти факты не используются как доказательство causal effect или ship decision для synthetic набора. + +Заново выполнены `node --check`, fixture 11/11 и усиленный draft audit. Все три code examples выполнены вручную: valid case заканчивается `needs-human-owner-decision`, wrong denominator и mixed period — `stop-before-product-decision` с точной причиной. Скрипт сохраняет границу: фиксированные literals в памяти, без запроса dashboard, telemetry или production action. + +### 2. Редактура и голос + +Полное чтение выявило чрезмерный общий вводный блок у mechanism и field. Он удалён и заменён на два специальных раздела: проверку ratio до интерпретации и чтение карточки решения. Поэтому статьи не пересказывают practice, а раскрывают собственный вопрос: доказательство измерения и воспроизводимое decision record. После правки основной текст составляет 6 648, 6 410 и 6 291 знак; в первых двух абзацах каждой названы проблема и цена ошибки. Термины раскрыты в контексте, а причинность оставлена проверяемой гипотезой, не обещанием фреймворка. + +### 3. Визуал и выпуск + +Три SVG прошли XML и safety scan. Независимый Sharp render на 375 px подтвердил, что causal funnel, metric matrix и evidence loop читаются без обрезания; у каждой figure есть содержательный alt и подпись. После подключения overlay целевой audit подтвердил: practice — одна figure, две таблицы и один example; mechanism и field — по одной figure, таблице и примеру. Registry содержит 259 уникальных ревизий, `git diff --check` чист, production build успешно сгенерировал 374 страницы. diff --git a/web/data/editorial-revisions.mjs b/web/data/editorial-revisions.mjs index 36f68f1..77853b3 100644 --- a/web/data/editorial-revisions.mjs +++ b/web/data/editorial-revisions.mjs @@ -84,6 +84,7 @@ import { revisions as february2025Revisions } from '../scripts/upgrade-2025-02.m import { revisions as march2025Revisions } from '../scripts/upgrade-2025-03.mjs'; import { revisions as april2025Revisions } from '../scripts/upgrade-2025-04.mjs'; import { revisions as may2025Revisions } from '../scripts/upgrade-2025-05.mjs'; +import { revisions as july2025Revisions } from '../scripts/upgrade-2025-07.mjs'; // This layer replaces archived source entries without losing their stable slug and date. export const editorialRevisions = [ @@ -173,4 +174,5 @@ export const editorialRevisions = [ ...march2025Revisions, ...april2025Revisions, ...may2025Revisions, + ...july2025Revisions, ]; diff --git a/web/public/assets/editorial/2025/product-metrics-2025-causal-funnel.svg b/web/public/assets/editorial/2025/product-metrics-2025-causal-funnel.svg new file mode 100644 index 0000000..eeb6fc3 --- /dev/null +++ b/web/public/assets/editorial/2025/product-metrics-2025-causal-funnel.svg @@ -0,0 +1,7 @@ + + Причинная воронка product metricТехническое изменение ведет к открытию и подтверждению checkout; failure отображен как независимый guardrail. + Одна цепочка до решения + изменениеформа без ожиданияopenedunique subjectconfirmedsame requestreviewhuman + + guardrailrender_failed / openedСначала contract и одинаковый period; затем metric; затем решение. + diff --git a/web/public/assets/editorial/2025/product-metrics-2025-decision-evidence-loop.svg b/web/public/assets/editorial/2025/product-metrics-2025-decision-evidence-loop.svg new file mode 100644 index 0000000..103f919 --- /dev/null +++ b/web/public/assets/editorial/2025/product-metrics-2025-decision-evidence-loop.svg @@ -0,0 +1 @@ +Цикл evidence для решенияПять шагов от гипотезы к human decision, с отдельной красной веткой HOLD для нарушенного contract.Evidence loop: остановка тоже результатhypothesischange → user stepevent contractname + fieldsmetric checkperiod + denominatorguardraildegradation?humandecisionHOLD: repair contractmissing attribution / mixed periodDecision record хранит evidence и limitation; функция не делает выпуск автоматически. diff --git a/web/public/assets/editorial/2025/product-metrics-2025-metric-guardrail-matrix.svg b/web/public/assets/editorial/2025/product-metrics-2025-metric-guardrail-matrix.svg new file mode 100644 index 0000000..e5df3fc --- /dev/null +++ b/web/public/assets/editorial/2025/product-metrics-2025-metric-guardrail-matrix.svg @@ -0,0 +1 @@ +Матрица metric и guardrailТаблица отделяет допустимый сигнал от трёх условий, которые ведут к остановке решения.Metric без contract не допускает решениеПроверкаСигналРезультатattributionsubject + requestIdиначе HOLDdenominatorunique opened subjectиначе HOLDcohort и periodодно окно для вариантовиначе HOLDguardrailrender_failed / openedhuman reviewLocal movement — не ship; сначала проверка измерительного контракта. diff --git a/web/scripts/upgrade-2025-07.mjs b/web/scripts/upgrade-2025-07.mjs new file mode 100644 index 0000000..6f38100 --- /dev/null +++ b/web/scripts/upgrade-2025-07.mjs @@ -0,0 +1,180 @@ +function escapeHtml(value) { + return String(value).replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", '''); +} + +const p = (text) => '

' + text + '

'; +const h2 = (text) => '

' + text + '

'; +const code = (text) => '
' + escapeHtml(text) + '
'; +const ol = (items) => '
    ' + items.map((item) => '
  1. ' + item + '
  2. ').join('') + '
'; +const figure = (src, alt, caption) => '
' + alt + '
' + caption + '
'; +const table = (caption, headers, rows) => '
' + headers.map((item) => '').join('') + '' + rows.map((row) => '' + row.map((item) => '').join('') + '').join('') + '
' + caption + '
' + item + '
' + item + '
'; + +function plainText(content) { return content.replace(/<[^>]+>/g, ' ').replace(/&(?:quot|amp|lt|gt|#039);/g, ' ').replace(/\s+/g, ' ').trim(); } +function bodyText(content) { return plainText(content.replace(/

Проверяемые источники<\/h2>[\s\S]*?(?=

|$)/, '')); } +function deepFreeze(value) { if (value && typeof value === 'object' && !Object.isFrozen(value)) { Object.values(value).forEach(deepFreeze); Object.freeze(value); } return value; } +function revision(meta, parts) { + const contentHtml = parts.join('\n') + '\n' + h2('Проверяемые источники') + sourceList(); + const proseLength = bodyText(contentHtml).length; + if (proseLength < 5000 || proseLength > 15000) throw new Error(meta.slug + ': body length ' + proseLength); + return deepFreeze({ ...meta, contentHtml, proseLength }); +} + +const sources = deepFreeze([ + { + title: 'OpenTelemetry Semantic Conventions v1.31.0, events (immutable commit c01aa89, 11 March 2025)', + url: 'https://raw.githubusercontent.com/open-telemetry/semantic-conventions/c01aa89d9a13042e56536c60975139c50e764796/docs/general/events.md', + version: 'tag v1.31.0; immutable commit c01aa89d9a13042e56536c60975139c50e764796, 2025-03-11', + claim: 'Событие имеет уникальное имя; структура события и применимые attributes должны быть документированы, а dynamic values не должны становиться частью имени.', + boundary: 'Это соглашение о семантике telemetry. Оно не определяет продуктовую метрику, causal effect, выбор denominator или критерий ship.', + }, + { + title: 'Microsoft Research: Safe Velocity with Controlled Rollouts (ICSE-SEIP 2019)', + url: 'https://www.microsoft.com/en-us/research/uploads/prod/2020/06/Safe-Velocity-ICSE-SEI.pdf', + version: 'ICSE-SEIP 2019 author version, published 2019; HTTPS checked 2026-07-31', + claim: 'Работа различает local feature, success и guardrail metrics; guardrail нужен, чтобы выпуск не ухудшал важные показатели, а local metric помогает объяснить движение более общей метрики.', + boundary: 'Это описание controlled rollouts Microsoft. Оно не доказывает, что любое изменение local metric вызвало пользовательский результат в другом продукте.', + }, + { + title: 'Microsoft Research: A Dirty Dozen Metric Interpretation Pitfalls (KDD 2017)', + url: 'https://www.microsoft.com/en-us/research/uploads/prod/2020/08/2017-08-KDDMetricInterpretationPitfalls.pdf', + version: 'KDD 2017 author version, published 2017; HTTPS checked 2026-07-31', + claim: 'Работа показывает, что разные наборы наблюдений в treatment и control делают metric delta недостоверной; рекомендует разбирать numerator и denominator и отслеживать telemetry loss.', + boundary: 'Пример относится к controlled experiments. Он не заменяет дизайн эксперимента, расчёт мощности, privacy policy или проверку конкретного pipeline.', + }, +]); +function sourceList() { return ''; } + +const SYNTHETIC_SCOPE = 'p89-product-metrics-2025-07-fixed-in-memory-v1'; +const SYNTHETIC_BOUNDARY = 'Все события, варианты, результаты и решения ниже — fixed synthetic JS literals. Модуль не читает файлы, сеть, clock, telemetry, Git, CI, production или user data и не выполняет side effect.'; +const FIXED_EVENTS = deepFreeze([ + { event: 'product.checkout_opened', subject: 'u-a', cohort: 'control', period: '2025-07-14', surface: 'web', requestId: 'r-1' }, + { event: 'product.checkout_confirmed', subject: 'u-a', cohort: 'control', period: '2025-07-14', surface: 'web', requestId: 'r-1' }, + { event: 'product.checkout_opened', subject: 'u-b', cohort: 'treatment', period: '2025-07-14', surface: 'web', requestId: 'r-2' }, + { event: 'product.checkout_confirmed', subject: 'u-b', cohort: 'treatment', period: '2025-07-14', surface: 'web', requestId: 'r-2' }, + { event: 'product.checkout_opened', subject: 'u-c', cohort: 'treatment', period: '2025-07-14', surface: 'web', requestId: 'r-3' }, + { event: 'product.checkout_opened', subject: 'u-d', cohort: 'control', period: '2025-07-14', surface: 'web', requestId: 'r-4' }, + { event: 'product.render_failed', subject: 'u-d', cohort: 'control', period: '2025-07-14', surface: 'web', requestId: 'r-4', errorType: 'synthetic-timeout' }, +]); +const FIXED_CASES = deepFreeze({ + 'fixed-valid-v1': { cohortPeriod: '2025-07-14', attribution: 'same-subject-and-request', denominator: 'unique-subjects-with-checkout_opened', guardrail: 'render_failed-per-opened', events: FIXED_EVENTS }, + 'fixed-missing-attribution-v1': { cohortPeriod: '2025-07-14', attribution: '', denominator: 'unique-subjects-with-checkout_opened', guardrail: 'render_failed-per-opened', events: FIXED_EVENTS }, + 'fixed-mixed-period-v1': { cohortPeriod: '2025-07-14', attribution: 'same-subject-and-request', denominator: 'unique-subjects-with-checkout_opened', guardrail: 'render_failed-per-opened', events: [...FIXED_EVENTS, { event: 'product.checkout_confirmed', subject: 'u-e', cohort: 'treatment', period: '2025-07-15', surface: 'web', requestId: 'r-5' }] }, + 'fixed-wrong-denominator-v1': { cohortPeriod: '2025-07-14', attribution: 'same-subject-and-request', denominator: 'all-events', guardrail: 'render_failed-per-opened', events: FIXED_EVENTS }, +}); + +function uniqueSubjects(events, cohort, event) { return new Set(events.filter((item) => item.cohort === cohort && item.event === event).map((item) => item.subject)).size; } +function evaluateFixedProductMetric(input) { + if (!input || !Object.values(FIXED_CASES).includes(input)) return deepFreeze({ accepted: false, reason: 'reject-unrecognised-fixed-input', productionEffect: 'not-attempted' }); + const reasons = []; + if (!input.attribution) reasons.push('missing-attribution-rule'); + if (input.denominator !== 'unique-subjects-with-checkout_opened') reasons.push('wrong-denominator'); + if (input.events.some((item) => item.period !== input.cohortPeriod)) reasons.push('mixed-cohort-or-period'); + const byCohort = ['control', 'treatment'].map((cohort) => { + const opened = uniqueSubjects(input.events, cohort, 'product.checkout_opened'); + const confirmed = uniqueSubjects(input.events, cohort, 'product.checkout_confirmed'); + const failed = uniqueSubjects(input.events, cohort, 'product.render_failed'); + return deepFreeze({ cohort, opened, confirmed, conversion: opened ? confirmed / opened : null, failed, guardrailRate: opened ? failed / opened : null }); + }); + const decision = reasons.length ? 'hold-for-measurement-repair' : 'eligible-for-human-decision'; + return deepFreeze({ scope: SYNTHETIC_SCOPE, accepted: reasons.length === 0, reasons, definition: deepFreeze({ attribution: input.attribution || 'missing', denominator: input.denominator, guardrail: input.guardrail, cohortPeriod: input.cohortPeriod }), byCohort, decision, productionEffect: 'not-attempted' }); +} +export function createFixedProductMetricInput(caseId) { return FIXED_CASES[caseId] || null; } +export function inspectFixedProductMetric(input) { return evaluateFixedProductMetric(input); } +export function prepareFixedProductDecision(report) { return deepFreeze({ kind: 'fixed-synthetic-decision-record-v1', accepted: report?.accepted === true, status: report?.accepted ? 'needs-human-owner-decision' : 'stop-before-product-decision', evidence: report?.definition || null, reasons: report?.reasons || ['missing-report'], productionEffect: 'not-attempted' }); } +export function runProductMetricFixture() { + const valid = evaluateFixedProductMetric(FIXED_CASES['fixed-valid-v1']); + const noAttribution = evaluateFixedProductMetric(FIXED_CASES['fixed-missing-attribution-v1']); + const mixed = evaluateFixedProductMetric(FIXED_CASES['fixed-mixed-period-v1']); + const denominator = evaluateFixedProductMetric(FIXED_CASES['fixed-wrong-denominator-v1']); + return deepFreeze({ assertions: deepFreeze({ validAccepted: valid.accepted, validDecisionIsHuman: prepareFixedProductDecision(valid).status === 'needs-human-owner-decision', validConversion: valid.byCohort[1].conversion === 0.5, validGuardrail: valid.byCohort[0].guardrailRate === 0.5, attributionStops: noAttribution.reasons.includes('missing-attribution-rule'), mixedStops: mixed.reasons.includes('mixed-cohort-or-period'), denominatorStops: denominator.reasons.includes('wrong-denominator'), noProduction: valid.productionEffect === 'not-attempted', frozenEvents: Object.isFrozen(FIXED_EVENTS), unknownStops: !evaluateFixedProductMetric({}).accepted, fixedInputFrozen: Object.isFrozen(FIXED_CASES['fixed-valid-v1']) }) }); +} + +const common = [ + h2('Граница модели и терминов'), + p('В статье «событие» — записанный факт с именем, субъектом, cohort, периодом и контекстом. Cohort — группа, которой показан один вариант. Attribution — правило, по которому действие относят к этому варианту. Guardrail — отдельная метрика, которая не даёт купить рост локального показателя деградацией рядом. Это не словарь для дашборда: каждое поле должно отвечать на вопрос, почему изменение разрешили или остановили.'), + p('OpenTelemetry в закреплённой версии требует уникальное имя события и документированную структуру. Для продуктовых событий это полезная дисциплина: product.checkout_confirmed не содержит динамический id, а subject, requestId, cohort и period лежат отдельно. Но соглашение об имени не доказывает связь с решением пользователя. Эту связь сначала формулирует владелец продукта и проверяет инженерный контракт.'), + h2('Один fixed synthetic пример'), + code("import { createFixedProductMetricInput, inspectFixedProductMetric, prepareFixedProductDecision } from './upgrade-2025-07.mjs';\n\nconst report = inspectFixedProductMetric(\n createFixedProductMetricInput('fixed-valid-v1'),\n);\nconst decision = prepareFixedProductDecision(report);\n\nconsole.log({ accepted: report.accepted, cohorts: report.byCohort, status: decision.status });\n// Fixed literals only: no HTTP, file, telemetry, clock or production action."), + p('В наборе четыре открытия, два подтверждения и одно synthetic render failure. Модель считает unique subjects, а не число строк: один повторный event не должен незаметно увеличить denominator. У treatment один из двух открывших подтвердил действие; у control подтверждает один из двух. Это не результат реального продукта и не статистический вывод: небольшой fixed набор нужен только для проверки того, что contract сохраняет cohort, period, denominator и guardrail рядом.'), + table('Контракт события и роль поля', ['Поле', 'Пример', 'Проверка', 'Нельзя заключать'], [['event', 'product.checkout_opened', 'имя описывает тип факта', 'что пользователь доволен'], ['subject', 'u-b', 'deduplicate denominator', 'что это реальная личность'], ['cohort', 'treatment', 'сравнить один вариант с control', 'что варианты случайно распределены'], ['period', '2025-07-14', 'не смешать окна', 'что эффект сохранится завтра'], ['requestId', 'r-2', 'связать шаги одной попытки', 'что причина уже доказана'], ['errorType', 'synthetic-timeout', 'посчитать guardrail', 'что это production incident']]), + h2('Общий маршрут до решения'), + ol(['Назвать решение. Не «смотрим conversion», а «оставляем вариант только если подтверждение не растёт ценой failure».', 'Записать причинную цепочку. Техническое изменение → видимый шаг → действие пользователя → outcome и отдельный guardrail.', 'Зафиксировать cohort, период и denominator. Эти поля пишут до подсчёта, иначе результат подстраивают под удобный ответ.', 'Проверить negative path. Нехватка attribution, иной period или иной denominator должны остановить decision, а не превратиться в null на графике.', 'Сохранить decision record. Указать owner, evidence, ограничения и следующий запуск; human owner, а не функция, принимает product decision.']), + p(SYNTHETIC_BOUNDARY), +]; + +const practice = revision({ slug: 'editorial-2025-07-practice-product-metrics', title: 'Метрики продукта для инженера: построить цепочку, а не поднять conversion', categories: ['Метрики', 'Инженерия'], cover: '/assets/editorial/2025/product-metrics-2025-causal-funnel.svg', excerpt: 'Практический маршрут: связать изменение, событие, действие пользователя, guardrail и решение так, чтобы ошибка измерения останавливалась раньше релиза.', readingMinutes: 12 }, [ + p('Инженер ускоряет экран и видит рост conversion на графике. Через несколько дней оказывается, что поменялся не выбор пользователя, а способ считать открытие: часть повторных попыток исчезла из denominator. Цена ошибки — выпуск решения по красивому числу, повторное расследование и потеря времени у продукта, аналитики и разработки.'), + p('Причина в разорванной цепочке. Latency, click и confirm часто живут в разных отчётах, а failure остаётся техническим alert. Проверка начинается не с нового dashboard: нужно назвать одно решение, один вход в воронку, одно правило attribution и один guardrail. Действие — записать их в contract до того, как смотреть delta.'), + h2('От технического изменения к пользовательскому решению'), + p('Для примера возьмём synthetic изменение: экран подтверждения получает предзагруженную форму. Гипотеза не звучит как «latency важен». Она звучит уже: «если форма открывается без дополнительного ожидания, больше пользователей, которые открыли checkout, доходят до confirm; при этом render failures не должны стать чаще». Здесь loading step — механизм, opened и confirmed — наблюдаемые точки, а failure — цена, которую нельзя спрятать за conversion.'), + figure('/assets/editorial/2025/product-metrics-2025-causal-funnel.svg', 'Причинная воронка fixed synthetic примера: техническое изменение ведёт к открытию checkout, подтверждению и решению пользователя; сбоку отдельно проходит guardrail render failure. Каждое ребро подписано событием и границей интерпретации.', 'Воронка не утверждает причинность сама по себе. Она заставляет до измерения указать, какой шаг наблюдаем, где attribution и какое ухудшение останавливает выпуск.'), + table('Цепочка и проверка', ['Звено', 'Что фиксируем', 'Симптом ошибки', 'Действие'], [['Изменение', 'variant и owner', 'невозможно сказать, что сравнивали', 'остановить карточку'], ['Открытие', 'checkout_opened, subject, period', 'denominator плавает', 'считать unique subjects'], ['Подтверждение', 'checkout_confirmed + requestId', 'шаг нельзя отнести к попытке', 'записать attribution rule'], ['Guardrail', 'render_failed / opened', 'conversion растёт вместе с отказами', 'hold и расследовать']]), + ...common, + h2('Что считать, а что не выдавать за доказательство'), + p('Microsoft Research различает local feature metrics, более общие success metrics и guardrail. Это хороший ориентир для раскладки, но не готовая формула для любого продукта. В нашем примере confirm/opened — local metric: он показывает, добрался ли пользователь до целевого шага. Он не равен долгосрочному outcome и не доказывает, что предзагрузка стала причиной. Поэтому decision record хранит формулировку гипотезы и отдельно пишет: «нужна проверка design и достаточного наблюдения».') , + p('Если attribution отсутствует, функция возвращает missing-attribution-rule. Это важнее нулевого графика: нельзя понять, относится ли confirm к показанному варианту, другому устройству или повторной попытке. Следующий шаг не «взять больше строк», а зафиксировать ключ связи и проверить, что он есть у обоих событий. Только после этого имеет смысл обсуждать latency или текст кнопки.'), + h2('Ограничение и следующий шаг'), + p('Эта практика не заменяет randomized experiment, расчёт мощности, privacy review или реальный ownership. Fixed literals не показывают статистическую значимость и не содержат денег, команд или production telemetry. Следующий шаг: выберите одну воронку, выпишите denominator в виде множества субъектов и добавьте test, где confirmation пришёл без requestId. Ожидаемый результат — hold с понятной причиной, а не искусственный рост conversion.'), + h2('Историческая граница июля 2025'), + p('Использованы OpenTelemetry v1.31.0 от 11 марта 2025 и работы Microsoft Research 2017 и 2019 годов. Они существовали до 31 июля 2025. Из них взяты только дисциплина события, различение metric roles и риск разного набора наблюдений. Конкретная causal chain, поля и stop decisions — инженерная конструкция этой статьи, не нормативное следствие источников.'), +]); + +const mechanism = revision({ slug: 'editorial-2025-07-mechanism-product-metrics', title: 'Почему рост метрики не является решением: denominator, cohort и guardrail', categories: ['Метрики', 'Качество'], cover: '/assets/editorial/2025/product-metrics-2025-metric-guardrail-matrix.svg', excerpt: 'Механика проверки: отделить movement локальной метрики от допустимого решения и остановить расчёт при смешанных периодах, неверном denominator или отсутствии attribution.', readingMinutes: 13 }, [ + p('На отчёте treatment выглядит лучше control, но numerator и denominator собраны из разных наборов. Иногда это случается после невинной оптимизации: страница перестаёт перезагружаться, клиентский event теряется или cohort получает другое окно. Цена ошибки — false positive: команда объясняет пользователю эффект, которого не было, и выпускает изменение с неизвестным ущербом.'), + p('Причина не в самой дроби. Любая ratio скрывает состав числителя и знаменателя. Проверка — разложить metric на набор субъектов, события и период, затем убедиться, что treatment и control отвечают на один и тот же вопрос. Действие — сделать эти условия частью result; если хотя бы одно не выполнено, решение получает stop status.'), + h2('Metric — это контракт, а не имя на графике'), + p('В fixed model conversion задан как unique subjects с checkout_confirmed, делённое на unique subjects с checkout_opened, внутри одного cohort и одного дня. Такой contract намеренно мал. Он не говорит, сколько ждать пользователя, как обрабатывать два устройства и что считать «успешным заказом». Но он не даёт случайно заменить denominator на all-events и выдать плотность telemetry за поведение людей.'), + figure('/assets/editorial/2025/product-metrics-2025-metric-guardrail-matrix.svg', 'Матрица fixed synthetic расчёта: local metric, success interpretation и guardrail разделены; строки missing attribution, wrong denominator и mixed period ведут к HOLD, а не к ship.', 'Матрица показывает границу: удачный local signal разрешает только human review, а любое нарушение измерительного контракта останавливает product decision.'), + table('Negative paths, которые нельзя сгладить', ['Сбой', 'Почему delta нельзя читать', 'Машинный result', 'Ремонт'], [['Нет attribution', 'confirm не связан с вариантом', 'missing-attribution-rule', 'добавить ключ связи и test'], ['Смешан period', 'группы не наблюдались в одном окне', 'mixed-cohort-or-period', 'зафиксировать окно до запроса'], ['Неверный denominator', 'ratio отвечает на другой вопрос', 'wrong-denominator', 'считать unique opened subjects'], ['Guardrail деградирует', 'локальный выигрыш куплен отказами', 'eligible, но human hold', 'разобрать error path']]), + h2('Проверить ratio до интерпретации'), + p('Numerator — число подтверждений, denominator — число открывших, но оба значения имеют смысл только вместе с правилом inclusion. Cohort здесь означает вариант сравнения, attribution связывает подтверждение с попыткой, а guardrail описывает недопустимое ухудшение рядом. Эти термины не доказывают причинность: они лишь делают видимыми условия, без которых ratio отвечает на другой вопрос.'), + code("import { createFixedProductMetricInput, inspectFixedProductMetric, prepareFixedProductDecision } from './upgrade-2025-07.mjs';\n\nconst report = inspectFixedProductMetric(\n createFixedProductMetricInput('fixed-wrong-denominator-v1'),\n);\nconst decision = prepareFixedProductDecision(report);\n\nconsole.log({ accepted: report.accepted, reasons: report.reasons, status: decision.status });\n// Expected: wrong-denominator and stop-before-product-decision.\n// The input is fixed synthetic data in memory; no metric is sent or published."), + p('Этот пример не вычисляет «правильную conversion» задним числом. Он показывает, что имя метрики не переносится на другой denominator. Report с all-events получает stop до human decision, хотя набор событий внешне похож на valid case. Для реального расчёта отдельно нужны randomization, задержка доставки, политика идентификаторов и проверка pipeline.'), + h2('Порядок проверки сравнения'), + ol(['Назвать decision. Сформулировать, какое действие будет разрешено или остановлено, а не просто открыть dashboard.', 'Зафиксировать population. До запроса записать cohort, period, inclusion и attribution.', 'Разложить дробь. Показать numerator и denominator отдельно и проверить, что они относятся к одной задаче.', 'Положить рядом guardrail. Сравнить его по тому же окну и назвать владельца порога.', 'Вернуть HOLD при разрыве. Repair contract предшествует product discussion.']), + p(SYNTHETIC_BOUNDARY), + h2('Почему guardrail живёт рядом, а не в конце отчёта'), + p('Guardrail не обязан быть «самой важной» метрикой. Его работа проще: заранее назвать ухудшение, которое делает локальный выигрыш недостаточным. В synthetic наборе один render_failed у двух opened в control не создаёт сравнения для реального продукта. Он показывает контракт: failure имеет тот же cohort и тот же denominator base, поэтому его нельзя потерять при смене панели. В реальном проекте порог и owner определяются до запуска, а не после удачного результата.'), + p('Работа Safe Velocity описывает guardrail как показатель, который не должен ухудшаться при rollout, и local metric как средство объяснить изменения. Нельзя перевернуть эту фразу в гарантию: если local metric вырос, значит outcome улучшился. Между ними остаются выборка, вариант, сезонность, качество событий и другие механизмы. Поэтому автор оставляет человеческое решение последним шагом и требует доказательства, достаточного для конкретного риска.'), + h2('Разбор numerator и denominator'), + p('Работа Microsoft о metric pitfalls особенно полезна именно здесь: она показывает пример, где другой состав наблюдений делает delta ненадёжной, и предлагает декомпозицию numerator и denominator. Наше практическое следствие скромнее: вместе с числом хранить список правил, из которого оно получено. Если после изменения request path исчезла часть opened, alert должен говорить о denominator, а не радоваться conversion.'), + p('Проверка в модуле добавляет один confirm из следующего дня. Результат mixed-cohort-or-period останавливает расчёт до сравнения. Это не статистический тест и не защита от всех ошибок: код не проверяет рандомизацию, задержку доставки или личность subject. Зато он делает распространённую подмену видимой и не позволяет default-фильтру quietly собрать удобную выборку.'), + h2('Ограничение и следующий шаг'), + p('Не делайте из guardrail универсальный список. У одной операции критичен отказ, у другой — отмена, доступность или безопасность; метрика получает смысл только рядом с пользовательским решением и владельцем риска. Следующий шаг: возьмите одну ratio из своего dashboard, напишите её numerator и denominator словами, затем подайте в test события из другого периода. Ожидаемый результат — расчёт остановлен с причиной, которую может исправить конкретный owner.'), + h2('Историческая граница июля 2025'), + p('Microsoft Research PDF 2017 года использован для риска sample/metric mismatch, а работа 2019 года — для ролей local, success и guardrail. OpenTelemetry v1.31.0 закреплён commit от марта 2025. Они не доказывают причинность fixed example и не задают пороги. Структура decision record и её stop reasons — ограниченный проектный выбор автора.'), +]); + +const field = revision({ slug: 'editorial-2025-07-field-product-metrics', title: 'Полевой журнал продуктовой метрики: evidence до решения', categories: ['Метрики', 'Процессы'], cover: '/assets/editorial/2025/product-metrics-2025-decision-evidence-loop.svg', excerpt: 'Полевой формат decision record: собрать цепочку, metric definition, guardrail, negative path и ограничение так, чтобы владелец мог принять или остановить решение.', readingMinutes: 13 }, [ + p('Самая дорогая ошибка в продуктовой метрике часто происходит не в SQL и не в графике. В review остаётся фраза «conversion вырос», но не остаётся периода, cohort, definition и guardrail. Через неделю никто не может повторить расчёт или объяснить, почему выпуск состоялся. Цена ошибки — повторная работа, спор об owner и решение, которое нельзя защитить evidence.'), + p('Причина — результат отделён от решения. Dashboard хранит число, а контекст живёт в сообщениях и памяти участников. Проверка — собрать короткий decision record до discussion: изменение, causal hypothesis, event contract, denominator, cohort/period, guardrail, evidence и limitation. Действие — отправить на human decision только карточку без red flags; остальные остановить и вернуть владельцу измерения.'), + h2('Карточка решения должна помещаться в review'), + table('Fixed synthetic decision record', ['Поле', 'Значение', 'Вопрос reviewer'], [['Decision', 'leave / hold после human review', 'какой риск принимаем?'], ['Hypothesis', 'форма без ожидания помогает пройти confirm', 'какое звено наблюдаем?'], ['Attribution', 'same subject and request', 'как confirm связан с вариантом?'], ['Window', '2025-07-14', 'одинаков ли период cohort?'], ['Local metric', 'confirmed unique subjects / opened unique subjects', 'каков denominator?'], ['Guardrail', 'render_failed / opened', 'что останавливает выпуск?'], ['Limitation', 'fixed synthetic only', 'чего evidence не доказывает?']]), + figure('/assets/editorial/2025/product-metrics-2025-decision-evidence-loop.svg', 'Цикл evidence для product decision: определить change и hypothesis, зафиксировать event contract, проверить cohort-period-denominator и guardrail, затем создать human decision record либо HOLD с причиной ремонта.', 'Decision loop специально заканчивается человеком и ограничением. Рисунок не превращает один metric delta в автоматический ship.'), + h2('Карточка хранит условия решения, а не красивый итог'), + p('Cohort — это группа с одним вариантом, attribution — правило связи попытки и подтверждения, denominator — множество открывших, guardrail — отдельное ограничение на ущерб. В field record они нужны не для терминологической полноты, а чтобы reviewer увидел, какое поле надо вернуть владельцу измерения. Если отсутствует хотя бы одно, фраза «conversion вырос» не становится решением.'), + p('Здесь нет настоящего пользователя, записи сессии или telemetry: subject и requestId — fixed synthetic labels. Они показывают место для связи, но не советуют хранить реальный идентификатор. Модуль создаёт только decision hand-off или HOLD; он не запрашивает dashboard и не выпускает вариант.'), + h2('Как выглядит полезный negative path'), + p('В этой теме остановка — результат, а не исключение. Если checkout_confirmed пришёл без requestId, отчёт не должен гадать, к какому открытию его отнести. Если в treatment попал следующий день, отчёт не должен выровнять его средним. Если denominator стал all-events, расчёт не должен сохранять прежнее название conversion. Каждая ветка возвращает короткую причину и владельца ремонта: instrumentation owner, query owner или experiment owner.'), + code("import { createFixedProductMetricInput, inspectFixedProductMetric, prepareFixedProductDecision } from './upgrade-2025-07.mjs';\n\nconst report = inspectFixedProductMetric(\n createFixedProductMetricInput('fixed-mixed-period-v1'),\n);\nconsole.log(prepareFixedProductDecision(report));\n// Expected: stop-before-product-decision with mixed-cohort-or-period.\n// The input is fixed in-memory synthetic data; nothing is published or sent."), + p('Такой путь полезен на review: вместо «данных мало» он говорит, какое именно условие нарушено. Вызов не исправляет cohort и не меняет production. Он только не разрешает перейти к решению. Это важно для ответственности: владелец интерфейса не обязан чинить SQL, а владелец metric contract не обязан объяснять UX-гипотезу. Карточка связывает их по явному полю, а не по догадке.'), + h2('Порядок полевой проверки'), + ol(['Сначала прочитать решение. Если нельзя закончить фразой «оставить или остановить вариант при таких условиях», метрика ещё не выбрана.', 'Затем проверить событие. Имя, subject, cohort, period и ключ attribution должны быть записаны до query.', 'Разложить ratio. Отдельно вывести unique numerator и unique denominator; не подменять субъект строкой telemetry.', 'Посмотреть guardrail тем же срезом. Если у него другой период или population, назвать ограничение, не сравнивать молча.', 'Прогнать один отрицательный вход. Missing attribution, mixed period или wrong denominator обязан привести к HOLD.', 'Зафиксировать evidence. Ссылка на definition, запрос, owner и следующий check делают результат воспроизводимым.']), + h2('Что делать, когда local metric двигается'), + p('Сначала не объявлять успех. Сверить, что метрика использует тот же event schema и что набор субъектов не менялся. Затем показать guardrail и diagnostic split: отдельно cohorts, period и numerator/denominator. Только после этого reviewer решает, нужно ли расширять наблюдение, повторять запуск или менять реализацию. Этот порядок короче бесконечного dashboard tour, потому что каждый шаг отвечает на отдельный риск.'), + p('Статья о metric pitfalls предупреждает, что другой состав выборок может исказить направление delta, а потеря telemetry может быть одним из источников bias. Это подтверждает необходимость проверки, но не создаёт готовый pipeline. В конкретном проекте нужно отдельно проверить privacy, retention, идентификаторы и задержку событий. Synthetic requestId здесь лишь показывает место связи; он не является советом хранить реальный идентификатор пользователя.'), + h2('Ограничение и следующий проверяемый шаг'), + p('Полевой журнал не обещает, что команда научится доказывать все product effects. Он делает неизвестное явным: какую связь мы наблюдаем, какое ухудшение не принимаем и чего не хватает для решения. В этом пакете нет реальных пользователей, денег, инцидентов, SQL, сети или production query. Поэтому его fixture проверяет только deterministic stop branches и отсутствие side effect.'), + p('Следующий шаг: добавьте к одному существующему review ровно четыре строки — decision, denominator, guardrail и limitation. Затем попросите другого инженера восстановить query только по карточке. Если он не может понять cohort или period, не улучшайте визуализацию: сначала исправьте event contract. Ожидаемый результат — спор о выпуске превращается в список проверяемых условий.'), + h2('Историческая граница июля 2025'), + p('Источники ограничены материалами, существовавшими до 31 июля 2025: Microsoft Research 2017/2019 и OpenTelemetry v1.31.0. Их утверждения отделены от нашего процесса: они поддерживают ценность раздельных metric roles, документированного события и проверки состава выборки. Формат журнала, synthetic literals и решение HOLD — практическая схема автора, а не обещание framework.'), +]); + +export const revisions = [practice, mechanism, field].map(({ proseLength, ...item }) => item); +if (process.argv.includes('--verify-fixture')) { + const assertions = runProductMetricFixture().assertions; + const failed = Object.entries(assertions).filter(([, value]) => value !== true).map(([key]) => key); + if (failed.length) { process.stderr.write('FAIL fixture: ' + failed.join(', ') + '\n'); process.exitCode = 1; } + else process.stdout.write('PASS fixture: ' + Object.keys(assertions).length + '/' + Object.keys(assertions).length + ' assertions\n'); +} +if (process.argv.includes('--print-revisions')) process.stdout.write(JSON.stringify(revisions) + '\n');