revise July 2025 product metrics articles
Build and deploy / deploy (push) Successful in 16s

This commit is contained in:
2026-07-31 17:56:35 +03:00
parent fa6d1c3eac
commit 48bf467171
7 changed files with 272 additions and 1 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# Производство редакционных партий # Производство редакционных партий
На 31 июля 2026 года строгий аудит проходит 265 из 358 созданных материалов. Остальные 93 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить. На 31 июля 2026 года строгий аудит проходит 268 из 358 созданных материалов. Остальные 90 не считаются «почти готовыми»: их нужно заменить, а не косметически удлинить.
## Одна партия ## Одна партия
+80
View File
@@ -0,0 +1,80 @@
# P89 — июль 2025: метрики продукта для инженера
## Область draft-пакета
Пакет заменяет только три overlay-статьи:
- <code>editorial-2025-07-practice-product-metrics</code> — «Метрики продукта для инженера: построить цепочку, а не поднять conversion».
- <code>editorial-2025-07-mechanism-product-metrics</code> — «Почему рост метрики не является решением: denominator, cohort и guardrail».
- <code>editorial-2025-07-field-product-metrics</code> — «Полевой журнал продуктовой метрики: evidence до решения».
Единственный исполняемый артефакт — <code>web/scripts/upgrade-2025-07.mjs</code>. Все 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, очередь, <code>articles.json</code>, 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 <code>v1.31.0</code>, commit <code>c01aa89d9a13042e56536c60975139c50e764796</code>, 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 — факты, техника и источники
- <code>createFixedProductMetricInput</code> выдаёт только один из четырёх frozen fixed cases. <code>inspectFixedProductMetric</code> считает unique subjects для opened, confirmed и render_failed; не принимает произвольный input как доказательство.
- Успешный case требует attribution, declared denominator и единственный cohort-period. <code>prepareFixedProductDecision</code> после PASS создаёт только <code>needs-human-owner-decision</code>; production effect остаётся <code>not-attempted</code>.
- Negative paths проверены отдельно: нет attribution → <code>missing-attribution-rule</code>; другой период → <code>mixed-cohort-or-period</code>; <code>all-events</code> вместо unique opened subjects → <code>wrong-denominator</code>.
- Из источников в тексте взяты только документированная форма события, разные роли 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:** пройдено. Усиленный <code>audit:draft</code> подтвердил проблему и цену ошибки в первых двух абзацах, диапазон 5–15 тыс. знаков, таблицу, figure, code example, ordered sequence и источники.
## Проход 3 — SVG, fixture и выпуск
Последовательность фактически выполненных проверок:
- <code>node --check web/scripts/upgrade-2025-07.mjs</code> — PASS.
- <code>node web/scripts/upgrade-2025-07.mjs --verify-fixture</code> — PASS, **11/11** assertions.
- <code>cd web &amp;&amp; npm run audit:draft -- scripts/upgrade-2025-07.mjs</code> — PASS: **6 648 / 6 410 / 6 291** body chars после независимой редакторской правки.
- <code>xmllint --noout</code> для трёх SVG — PASS; safety scan чист: нет <code>script</code>, <code>foreignObject</code>, <code>javascript:</code>, <code>data:image</code> и event-handler attributes.
- Sharp renders на 375 px просмотрены вручную. Funnel сохраняет отдельную красную ветку guardrail; matrix читается в четыре строки; evidence loop показывает HOLD ветку и не обрезает подписи.
| Asset | Смысл | Результат 375 px |
| --- | --- | --- |
| <code>product-metrics-2025-causal-funnel.svg</code> | change → opened → confirmed → human review и guardrail | PASS: стрелки, подписи шагов и красный guardrail видимы. |
| <code>product-metrics-2025-metric-guardrail-matrix.svg</code> | четыре измерительных условия и HOLD | PASS: контраст и строки читаемы, нижняя оговорка не обрезана. |
| <code>product-metrics-2025-decision-evidence-loop.svg</code> | evidence loop с веткой repair contract | PASS: пять шагов и HOLD branch различимы на узком экране. |
Full build и <code>audit:articles</code> намеренно не запускались: 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 страницы.
+2
View File
@@ -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 march2025Revisions } from '../scripts/upgrade-2025-03.mjs';
import { revisions as april2025Revisions } from '../scripts/upgrade-2025-04.mjs'; import { revisions as april2025Revisions } from '../scripts/upgrade-2025-04.mjs';
import { revisions as may2025Revisions } from '../scripts/upgrade-2025-05.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. // This layer replaces archived source entries without losing their stable slug and date.
export const editorialRevisions = [ export const editorialRevisions = [
@@ -173,4 +174,5 @@ export const editorialRevisions = [
...march2025Revisions, ...march2025Revisions,
...april2025Revisions, ...april2025Revisions,
...may2025Revisions, ...may2025Revisions,
...july2025Revisions,
]; ];
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="540" viewBox="0 0 960 540" role="img" aria-labelledby="title desc">
<title id="title">Причинная воронка product metric</title><desc id="desc">Техническое изменение ведет к открытию и подтверждению checkout; failure отображен как независимый guardrail.</desc>
<rect width="960" height="540" fill="#101827"/><text x="60" y="70" fill="#f8fafc" font-family="Arial" font-size="30" font-weight="700">Одна цепочка до решения</text>
<g font-family="Arial" font-size="20" text-anchor="middle"><rect x="55" y="190" width="190" height="100" rx="16" fill="#1d4ed8"/><text x="150" y="230" fill="white">изменение</text><text x="150" y="260" fill="#dbeafe">форма без ожидания</text><rect x="300" y="190" width="190" height="100" rx="16" fill="#0369a1"/><text x="395" y="230" fill="white">opened</text><text x="395" y="260" fill="#cffafe">unique subject</text><rect x="545" y="190" width="190" height="100" rx="16" fill="#0f766e"/><text x="640" y="230" fill="white">confirmed</text><text x="640" y="260" fill="#ccfbf1">same request</text><rect x="790" y="190" width="130" height="100" rx="16" fill="#047857"/><text x="855" y="230" fill="white">review</text><text x="855" y="260" fill="#d1fae5">human</text></g>
<g stroke="#94a3b8" stroke-width="5" fill="none"><path d="M245 240h45"/><path d="M490 240h45"/><path d="M735 240h45"/><path d="M395 292v100h245"/></g><g fill="#94a3b8"><path d="M282 230l14 10-14 10z"/><path d="M527 230l14 10-14 10z"/><path d="M772 230l14 10-14 10z"/></g>
<rect x="640" y="370" width="235" height="95" rx="16" fill="#b91c1c"/><text x="757" y="410" fill="white" font-family="Arial" font-size="22" text-anchor="middle">guardrail</text><text x="757" y="440" fill="#fee2e2" font-family="Arial" font-size="18" text-anchor="middle">render_failed / opened</text><text x="60" y="500" fill="#cbd5e1" font-family="Arial" font-size="18">Сначала contract и одинаковый period; затем metric; затем решение.</text>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="560" viewBox="0 0 960 560" role="img" aria-labelledby="title desc"><title id="title">Цикл evidence для решения</title><desc id="desc">Пять шагов от гипотезы к human decision, с отдельной красной веткой HOLD для нарушенного contract.</desc><rect width="960" height="560" fill="#101827"/><text x="55" y="65" fill="#f8fafc" font-family="Arial" font-size="30" font-weight="700">Evidence loop: остановка тоже результат</text><g font-family="Arial" text-anchor="middle" font-size="18"><g fill="#075985"><rect x="65" y="200" width="145" height="82" rx="14"/><text x="137" y="234" fill="white">hypothesis</text><text x="137" y="260" fill="#cffafe">change → user step</text><rect x="245" y="200" width="145" height="82" rx="14"/><text x="317" y="234" fill="white">event contract</text><text x="317" y="260" fill="#cffafe">name + fields</text><rect x="425" y="200" width="145" height="82" rx="14"/><text x="497" y="234" fill="white">metric check</text><text x="497" y="260" fill="#cffafe">period + denominator</text><rect x="605" y="200" width="145" height="82" rx="14"/><text x="677" y="234" fill="white">guardrail</text><text x="677" y="260" fill="#cffafe">degradation?</text><rect x="785" y="200" width="120" height="82" rx="14" fill="#047857"/><text x="845" y="234" fill="white">human</text><text x="845" y="260" fill="#d1fae5">decision</text></g><g stroke="#94a3b8" stroke-width="5"><path d="M210 241h28"/><path d="M390 241h28"/><path d="M570 241h28"/><path d="M750 241h28"/></g><path d="M497 285v90h100" stroke="#f87171" stroke-width="5" fill="none"/><rect x="600" y="340" width="250" height="80" rx="14" fill="#991b1b"/><text x="725" y="373" fill="white">HOLD: repair contract</text><text x="725" y="400" fill="#fee2e2">missing attribution / mixed period</text></g><text x="55" y="500" fill="#cbd5e1" font-family="Arial" font-size="18">Decision record хранит evidence и limitation; функция не делает выпуск автоматически.</text></svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="960" height="580" viewBox="0 0 960 580" role="img" aria-labelledby="title desc"><title id="title">Матрица metric и guardrail</title><desc id="desc">Таблица отделяет допустимый сигнал от трёх условий, которые ведут к остановке решения.</desc><rect width="960" height="580" fill="#101827"/><text x="55" y="65" fill="#f8fafc" font-family="Arial" font-size="30" font-weight="700">Metric без contract не допускает решение</text><g font-family="Arial" font-size="19"><rect x="55" y="105" width="850" height="58" fill="#334155"/><text x="85" y="142" fill="white">Проверка</text><text x="410" y="142" fill="white">Сигнал</text><text x="695" y="142" fill="white">Результат</text><g fill="#e2e8f0"><rect x="55" y="163" width="850" height="72" fill="#172554"/><text x="85" y="207">attribution</text><text x="410" y="207">subject + requestId</text><text x="695" y="207">иначе HOLD</text><rect x="55" y="235" width="850" height="72" fill="#0f172a"/><text x="85" y="279">denominator</text><text x="410" y="279">unique opened subject</text><text x="695" y="279">иначе HOLD</text><rect x="55" y="307" width="850" height="72" fill="#172554"/><text x="85" y="351">cohort и period</text><text x="410" y="351">одно окно для вариантов</text><text x="695" y="351">иначе HOLD</text><rect x="55" y="379" width="850" height="72" fill="#0f172a"/><text x="85" y="423">guardrail</text><text x="410" y="423">render_failed / opened</text><text x="695" y="423">human review</text></g></g><rect x="55" y="488" width="850" height="46" rx="12" fill="#7f1d1d"/><text x="480" y="519" fill="#fee2e2" font-family="Arial" font-size="19" text-anchor="middle">Local movement — не ship; сначала проверка измерительного контракта.</text></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

+180
View File
@@ -0,0 +1,180 @@
function escapeHtml(value) {
return String(value).replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&#039;');
}
const p = (text) => '<p>' + text + '</p>';
const h2 = (text) => '<h2>' + text + '</h2>';
const code = (text) => '<pre><code>' + escapeHtml(text) + '</code></pre>';
const ol = (items) => '<ol>' + items.map((item) => '<li>' + item + '</li>').join('') + '</ol>';
const figure = (src, alt, caption) => '<figure><img src="' + src + '" alt="' + alt + '" loading="lazy" /><figcaption>' + caption + '</figcaption></figure>';
const table = (caption, headers, rows) => '<div class="table-scroll"><table><caption>' + caption + '</caption><thead><tr>' + headers.map((item) => '<th scope="col">' + item + '</th>').join('') + '</tr></thead><tbody>' + rows.map((row) => '<tr>' + row.map((item) => '<td>' + item + '</td>').join('') + '</tr>').join('') + '</tbody></table></div>';
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>Проверяемые источники<\/h2>[\s\S]*?(?=<h2>|$)/, '')); }
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 '<ul>' + sources.map((source) => '<li><a href="' + source.url + '" target="_blank" rel="noopener noreferrer">' + escapeHtml(source.title) + '</a> — версия: ' + escapeHtml(source.version) + '. ' + escapeHtml(source.claim) + ' Граница: ' + escapeHtml(source.boundary) + '</li>').join('') + '</ul>'; }
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 в закреплённой версии требует уникальное имя события и документированную структуру. Для продуктовых событий это полезная дисциплина: <code>product.checkout_confirmed</code> не содержит динамический id, а <code>subject</code>, <code>requestId</code>, <code>cohort</code> и <code>period</code> лежат отдельно. Но соглашение об имени не доказывает связь с решением пользователя. Эту связь сначала формулирует владелец продукта и проверяет инженерный контракт.'),
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(['<strong>Назвать решение.</strong> Не «смотрим conversion», а «оставляем вариант только если подтверждение не растёт ценой failure».', '<strong>Записать причинную цепочку.</strong> Техническое изменение → видимый шаг → действие пользователя → outcome и отдельный guardrail.', '<strong>Зафиксировать cohort, период и denominator.</strong> Эти поля пишут до подсчёта, иначе результат подстраивают под удобный ответ.', '<strong>Проверить negative path.</strong> Нехватка attribution, иной period или иной denominator должны остановить decision, а не превратиться в null на графике.', '<strong>Сохранить decision record.</strong> Указать 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 отсутствует, функция возвращает <code>missing-attribution-rule</code>. Это важнее нулевого графика: нельзя понять, относится ли 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 с <code>checkout_confirmed</code>, делённое на unique subjects с <code>checkout_opened</code>, внутри одного cohort и одного дня. Такой contract намеренно мал. Он не говорит, сколько ждать пользователя, как обрабатывать два устройства и что считать «успешным заказом». Но он не даёт случайно заменить denominator на <code>all-events</code> и выдать плотность 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 с <code>all-events</code> получает stop до human decision, хотя набор событий внешне похож на valid case. Для реального расчёта отдельно нужны randomization, задержка доставки, политика идентификаторов и проверка pipeline.'),
h2('Порядок проверки сравнения'),
ol(['<strong>Назвать decision.</strong> Сформулировать, какое действие будет разрешено или остановлено, а не просто открыть dashboard.', '<strong>Зафиксировать population.</strong> До запроса записать cohort, period, inclusion и attribution.', '<strong>Разложить дробь.</strong> Показать numerator и denominator отдельно и проверить, что они относятся к одной задаче.', '<strong>Положить рядом guardrail.</strong> Сравнить его по тому же окну и назвать владельца порога.', '<strong>Вернуть HOLD при разрыве.</strong> 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 из следующего дня. Результат <code>mixed-cohort-or-period</code> останавливает расчёт до сравнения. Это не статистический тест и не защита от всех ошибок: код не проверяет рандомизацию, задержку доставки или личность 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('В этой теме остановка — результат, а не исключение. Если <code>checkout_confirmed</code> пришёл без requestId, отчёт не должен гадать, к какому открытию его отнести. Если в treatment попал следующий день, отчёт не должен выровнять его средним. Если denominator стал <code>all-events</code>, расчёт не должен сохранять прежнее название 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(['<strong>Сначала прочитать решение.</strong> Если нельзя закончить фразой «оставить или остановить вариант при таких условиях», метрика ещё не выбрана.', '<strong>Затем проверить событие.</strong> Имя, subject, cohort, period и ключ attribution должны быть записаны до query.', '<strong>Разложить ratio.</strong> Отдельно вывести unique numerator и unique denominator; не подменять субъект строкой telemetry.', '<strong>Посмотреть guardrail тем же срезом.</strong> Если у него другой период или population, назвать ограничение, не сравнивать молча.', '<strong>Прогнать один отрицательный вход.</strong> Missing attribution, mixed period или wrong denominator обязан привести к HOLD.', '<strong>Зафиксировать evidence.</strong> Ссылка на 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');