diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 7d1bc8a0f..f4c8c0b81 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -6,6 +6,11 @@ on:
pull_request:
merge_group:
workflow_dispatch:
+ inputs:
+ profile_baseline:
+ description: 'Снять зарегистрированный размерный baseline PROFILE-01'
+ type: boolean
+ default: false
permissions:
contents: read
@@ -21,6 +26,12 @@ env:
COREPACK_DEFAULT_TO_LATEST: "0"
jobs:
+ # Только явный baseline-запрос: обычный кандидат может менять runtime и
+ # не обязан совпадать с зарегистрированным старым источником PROFILE-01.
+ profile-baseline:
+ if: ${{ github.event_name == 'workflow_dispatch' && inputs.profile_baseline }}
+ uses: ./.github/workflows/profile-01.yml
+
verify:
name: typecheck · build · size · package
runs-on: ubuntu-latest
diff --git a/.github/workflows/profile-01.yml b/.github/workflows/profile-01.yml
new file mode 100644
index 000000000..a5b4fe92f
--- /dev/null
+++ b/.github/workflows/profile-01.yml
@@ -0,0 +1,121 @@
+name: PROFILE-01 — проверка размерного протокола
+
+# Размерный вектор вызывается вручную через ci.yml в той же среде, что обычный CI
+# публичного репозитория. Время/GPU/энергия этой машины не являются device proof.
+on:
+ workflow_call:
+
+permissions:
+ contents: read
+
+env:
+ COREPACK_ENABLE_DOWNLOAD_PROMPT: '0'
+ COREPACK_DEFAULT_TO_LATEST: '0'
+ PROFILE_SOURCE_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
+
+jobs:
+ probe:
+ runs-on: ubuntu-latest
+ timeout-minutes: 45
+ steps:
+ - name: Получить точный источник измерителя
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
+ with:
+ ref: ${{ github.event.pull_request.head.sha || github.sha }}
+ persist-credentials: false
+ fetch-depth: 0
+
+ - name: Настроить Node
+ uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v6
+ with:
+ node-version: '24'
+
+ - name: Активировать закреплённый pnpm
+ run: |
+ corepack enable
+ corepack install --global pnpm@11.11.0
+
+ - name: Проверить точную версию протокола
+ shell: bash
+ run: |
+ set -euo pipefail
+ test "$(git rev-parse HEAD)" = "$PROFILE_SOURCE_SHA"
+ node --input-type=module <<'NODE'
+ import { PROFILE_01, preregistrationDigest, verifyPreregistration } from './bench/profile/profile-01-preregistration.mjs';
+ verifyPreregistration(PROFILE_01);
+ if (PROFILE_01.candidateSamplesObservedAtRegistration !== false) {
+ throw new Error('preregistration обязана предшествовать samples');
+ }
+ console.log(`preregistration digest: ${preregistrationDigest(PROFILE_01)}`);
+ NODE
+
+ - name: Установить зависимости
+ run: pnpm install --frozen-lockfile
+
+ - name: Измерить прежний размерный вектор
+ id: probe
+ shell: bash
+ run: |
+ set -euo pipefail
+ mkdir -p "$RUNNER_TEMP/profile-01-raw"
+ node bench/profile/probe-profile-01.mjs \
+ --mode old-vector \
+ --cells all \
+ --out "$RUNNER_TEMP/profile-01-raw"
+
+ - name: Независимо перепроверить raw-артефакт
+ if: ${{ !cancelled() && steps.probe.outcome != 'skipped' }}
+ shell: bash
+ run: |
+ set -euo pipefail
+ for raw in "$RUNNER_TEMP"/profile-01-raw/*.json; do
+ test -f "$raw"
+ node bench/profile/validate-profile-01.mjs --raw "$raw"
+ done
+
+ - name: Проверить отказ внешнего validator на подменах настоящего результата
+ if: ${{ !cancelled() && steps.probe.outcome == 'success' }}
+ shell: bash
+ run: |
+ set -euo pipefail
+ node --input-type=module <<'NODE'
+ import assert from 'node:assert/strict';
+ import { spawnSync } from 'node:child_process';
+ import { mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
+ import { tmpdir } from 'node:os';
+ import { join } from 'node:path';
+ const rawDirectory = join(process.env.RUNNER_TEMP, 'profile-01-raw');
+ const files = readdirSync(rawDirectory).filter(name => name.endsWith('.json'));
+ assert.equal(files.length, 1);
+ const original = JSON.parse(readFileSync(join(rawDirectory, files[0]), 'utf8'));
+ const directory = mkdtempSync(join(tmpdir(), 'profile-replay-mutations-'));
+ try {
+ const cases = [
+ ['число', value => { value.costVector.scenarios[0].gzBytes += 1; }, 'scenarios не воспроизводится'],
+ ['identity', value => { value.head = '0'.repeat(40); }, 'baseProof не покрывает'],
+ ['клетки', value => { value.cellsUnproven = []; }, 'неизмеренные клетки потеряны'],
+ ];
+ for (const [name, mutate, rejection] of cases) {
+ const value = structuredClone(original);
+ mutate(value);
+ const path = join(directory, 'mutated.json');
+ writeFileSync(path, JSON.stringify(value));
+ const run = spawnSync(process.execPath, ['bench/profile/validate-profile-01.mjs', '--raw', path],
+ { encoding: 'utf8', timeout: 180_000, maxBuffer: 8 * 1024 * 1024 });
+ assert.equal(run.status, 1, `${name}: ${run.error ?? run.stderr}`);
+ assert.ok(run.stderr.includes(rejection), `${name}: неверная причина отказа: ${run.stderr}`);
+ console.log(`${name}: ожидаемый предметный отказ подтверждён`);
+ }
+ } finally {
+ rmSync(directory, { recursive: true, force: true });
+ }
+ NODE
+
+ - name: Опубликовать raw-артефакт
+ # Отрицательный результат сохраняется даже после отказа измерителя.
+ if: always()
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
+ with:
+ name: profile-01-raw-${{ env.PROFILE_SOURCE_SHA }}
+ path: ${{ runner.temp }}/profile-01-raw/
+ retention-days: 90
diff --git a/bench/profile/probe-profile-01.mjs b/bench/profile/probe-profile-01.mjs
new file mode 100644
index 000000000..f5336d554
--- /dev/null
+++ b/bench/profile/probe-profile-01.mjs
@@ -0,0 +1,140 @@
+// PROFILE-01 probe: исполняемый измерительный стенд.
+// Измерение размера использует обычную среду CI публичного репозитория.
+// Оно не доказывает задержку, частоту кадров или свойства физического устройства.
+// Недействительная калибровка или расхождение с протоколом запрещают допуск.
+// Использование: node bench/profile/probe-profile-01.mjs --mode old-vector|aa|ab --cells desktop|all --out
+
+import { createHash } from 'node:crypto';
+import { mkdirSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import {
+ PROFILE_01,
+ preregistrationDigest,
+ verifyPreregistration,
+ unmeasuredCells,
+} from './profile-01-preregistration.mjs';
+import { PREREG_OWN_PATHS, makeGit } from './profile-git-proof.mjs';
+import { measureOldVector } from './profile-measurement.mjs';
+
+function fail(message) {
+ throw new Error(`PROFILE-01 probe (fail-closed): ${message}`);
+}
+
+function arg(name) {
+ const index = process.argv.indexOf(name);
+ return index === -1 ? undefined : process.argv[index + 1];
+}
+
+const git = makeGit(fail);
+
+function writeArtifact(outDir, artifact, head, digest) {
+ const rawPath = join(outDir, `profile-01-${artifact.mode}-${head.slice(0, 12)}.json`);
+ const rawBytes = `${JSON.stringify(artifact, null, 2)}\n`;
+ writeFileSync(rawPath, rawBytes);
+ const rawDigest = createHash('sha256').update(rawBytes).digest('hex');
+ // eslint-disable-next-line no-console
+ console.log(JSON.stringify({ rawPath, rawDigest, preregistrationDigest: digest, admission: artifact.admission }));
+}
+
+// Отказ после измерения сохраняет артефакт с причиной и хешем.
+// Отказ до измерения ещё не создаёт данных для сохранения.
+function persistAndFail(outDir, artifact, head, digest, reason) {
+ artifact.finishedAtUtc = new Date().toISOString();
+ artifact.admission = 'NOT-GRANTED';
+ artifact.rejection = reason;
+ writeArtifact(outDir, artifact, head, digest);
+ fail(reason);
+}
+
+async function main() {
+ const repoRoot = fileURLToPath(new URL('../../', import.meta.url));
+ const mode = arg('--mode') ?? fail('требуется --mode old-vector|aa|ab');
+ if (!['old-vector', 'aa', 'ab'].includes(mode)) fail(`неизвестный --mode ${mode}`);
+ const cells = arg('--cells') ?? 'desktop';
+ if (!['desktop', 'all'].includes(cells)) fail(`неизвестный --cells ${cells}`);
+ const outDir = resolve(arg('--out') ?? join(tmpdir(), 'profile-01-raw'));
+ mkdirSync(outDir, { recursive: true });
+
+ // Протокол проверяется до любых измерений.
+ verifyPreregistration(PROFILE_01);
+ const digest = preregistrationDigest(PROFILE_01);
+
+ // 2. Точный base/provenance.
+ const head = git.head(repoRoot);
+ const artifact = {
+ node: 'PROFILE-01',
+ revision: 'r11',
+ mode,
+ cells,
+ preregistrationDigest: digest,
+ candidateSamplesObservedAtRegistration: false,
+ head,
+ sizeGateBlob: null,
+ baseProof: null,
+ seed: 20260929,
+ startedAtUtc: new Date().toISOString(),
+ cellsMeasured: [],
+ cellsUnproven: unmeasuredCells(cells),
+ rawControls: {},
+ calibration: {},
+ costVector: null,
+ admission: 'NOT-GRANTED',
+ rejection: null,
+ };
+
+ if (mode === 'old-vector') {
+ const base = PROFILE_01.productBase.sourceSha;
+ if (!git.ancestor(repoRoot, base)) fail(`old-vector требует HEAD, выросший из PRODUCT_BASE ${base}`);
+ const sizeGateBlob = git.blob(repoRoot, 'HEAD', 'scripts/size-gate.mjs');
+ if (sizeGateBlob !== PROFILE_01.productBase.sizeGateBlob) {
+ fail(`size-gate provenance drifted: ${sizeGateBlob}`);
+ }
+ // Рабочая копия обязана совпадать с коммитом: иначе измеритель
+ // исполнит непроверенный файл, а baseProof этого не покажет.
+ const workingSizeGateBlob = git.workingBlob(repoRoot, 'scripts/size-gate.mjs');
+ if (workingSizeGateBlob !== sizeGateBlob) {
+ fail(`рабочая копия size-gate.mjs отличается от коммита: working ${workingSizeGateBlob}, committed ${sizeGateBlob}`);
+ }
+ const diffPaths = head === base ? [] : git.diffNames(repoRoot, base, head);
+ const foreign = diffPaths.filter((path) => !PREREG_OWN_PATHS.includes(path));
+ if (foreign.length > 0) fail(`измеряемое дерево отличается от PRODUCT_BASE вне prereg-пакета: ${foreign.join(', ')}`);
+ artifact.sizeGateBlob = sizeGateBlob;
+ artifact.baseProof = { productBase: base, head, diffPaths };
+ try {
+ artifact.costVector = await measureOldVector(repoRoot);
+ } catch (error) {
+ persistAndFail(outDir, artifact, head, digest, `незавершённое измерение: ${error.message}`);
+ }
+ if (artifact.costVector.exitCode !== 0) {
+ persistAndFail(outDir, artifact, head, digest, `старый cost vector не зелёный на PRODUCT_BASE (exit ${artifact.costVector.exitCode})`);
+ }
+ for (const name of Object.keys(PROFILE_01.oldCostVectorGzipBytes.scenarios)) {
+ if (!artifact.costVector.scenarios.some((row) => row.name === name)) {
+ persistAndFail(outDir, artifact, head, digest, `size-gate не содержит сценарий ${name}`);
+ }
+ }
+ artifact.cellsMeasured.push('desktop-size-vector');
+ }
+
+ // 4. Калибровка: A/A и deliberate 2×work обязаны быть явными.
+ // Детализация timing-калибровки — в отдельной browser-фазе;
+ // без пройденной калибровки admission не выдаётся (см. ниже).
+ artifact.calibration = { aa: 'PENDING', positive2x: 'PENDING' };
+
+ // Без калибровки aa/ab сохраняют отказ до завершения процесса.
+ if (mode !== 'old-vector') {
+ persistAndFail(outDir, artifact, head, digest, 'aa/ab режимы требуют отдельной зелёной browser-калибровки');
+ }
+ const ready = mode === 'old-vector' && artifact.costVector !== null;
+ artifact.finishedAtUtc = new Date().toISOString();
+ artifact.admission = ready ? 'OLD-VECTOR-ONLY' : 'NOT-GRANTED';
+ if (!ready) {
+ persistAndFail(outDir, artifact, head, digest, 'old-vector не готов: costVector отсутствует');
+ }
+
+ writeArtifact(outDir, artifact, head, digest);
+}
+
+await main();
diff --git a/bench/profile/profile-01-preregistration.mjs b/bench/profile/profile-01-preregistration.mjs
new file mode 100644
index 000000000..142144093
--- /dev/null
+++ b/bench/profile/profile-01-preregistration.mjs
@@ -0,0 +1,216 @@
+// Исполняемый контракт PROFILE-01. Версия закрепляется до измерений кандидата.
+// Неизмеренные клетки сохраняют UNPROVEN и не дают допуска A/B.
+
+import { createHash } from 'node:crypto';
+import { isDeepStrictEqual } from 'node:util';
+
+function deepFreeze(value) {
+ if (value !== null && typeof value === 'object') {
+ for (const child of Object.values(value)) deepFreeze(child);
+ Object.freeze(value);
+ }
+ return value;
+}
+
+function invariant(condition, message) {
+ if (!condition) throw new Error(`PROFILE-01 preregistration: ${message}`);
+}
+
+// Точный main после исправления зависимостей тестов.
+// Runtime, размерный измеритель и потолки прежнего baseline сохранены.
+export const PRODUCT_BASE = Object.freeze({
+ repo: 'Labpics-Team/lab-motion',
+ sourceSha: '7d3ed42e5b054a06e4f6ca7f0c2efc02484e20fd',
+ upstreamMainSha: '7d3ed42e5b054a06e4f6ca7f0c2efc02484e20fd',
+ sizeGateBlob: '4b0f181212b65a881e750e84564778f5828448a3',
+ reason: 'исправление безопасности зависимостей; runtime и размерные потолки сохранены',
+});
+
+// Полный старый cost vector: потолки кода, а не новые оценки.
+// Provenance: scripts/size-gate.mjs, PRODUCT_BASE.sizeGateBlob.
+// Архивные фактические размеры новым измерением не являются:
+// метод повторного снятия — node scripts/size-gate.mjs на exact base.
+export const OLD_COST_VECTOR_GZIP_BYTES = Object.freeze({
+ core: 2220,
+ subpath: 4608,
+ fullCoreConsumer: 2330,
+ nano: 1024,
+ compiledRuntime: 341,
+ compilerSurface: 1024,
+ inView: 1839,
+ inViewConsumer: 1908,
+ compositorCapability: 6600,
+ fullAnimate: 15600,
+ animateCompositorMixed: 17500,
+ bespoke: Object.freeze({
+ './behaviors/reorder': 1518,
+ './utils': 1400,
+ './compiler/vite': 9163,
+ './compiler/runtime': 341,
+ './compositor': 6450,
+ './compositor/stagger': 6450,
+ './tokens': 1650,
+ './projection': 5750,
+ './smart': 7450,
+ './presets': 5600,
+ './animate': 15600,
+ './nano': 1024,
+ './compiler/surface': 1024,
+ './in-view': 1839,
+ './behaviors': 4600,
+ }),
+ scenarios: Object.freeze({
+ 'reorder-controlled': 1522,
+ 'nano spring-to': 1024,
+ 'surface executor': 1024,
+ 'in-view one-liner': 1908,
+ 'only-spring': 920,
+ 'projection-core-only': 720,
+ 'projection-dom-one-liner': 5750,
+ 'only-MotionValue': 1660,
+ 'full-core': 2330,
+ 'compositor-stagger capability': 6600,
+ 'animate + compositor': 17500,
+ 'only-clamp (utils tree-shake)': 340,
+ 'animate-one-liner (фасад)': 15600,
+ 'animate component scope': 15700,
+ 'behaviors-sheet-one-liner': 3700,
+ }),
+ brotliAndTotals: 'initial+reachable total, CSS/data/lazy chunks и Brotli снимаются тем же прогоном size-gate на exact base; знаменатели не смешиваются',
+});
+
+// Классы устройств по r11 §PROFILE-01. Конкретные модели и версии выбираются
+// до измерений кандидата; флагман не заменяет устройство среднего класса.
+export const ROSTER = Object.freeze({
+ classes: Object.freeze([
+ Object.freeze({ id: 'android-mid-60', device: 'физический Android среднего класса', refreshHz: 60, browsers: ['chromium-webview-stable'], status: 'UNPROVEN' }),
+ Object.freeze({ id: 'android-mid-120', device: 'физический Android среднего класса с экраном 120 Гц', refreshHz: 120, browsers: ['chromium-webview-stable'], status: 'UNPROVEN' }),
+ Object.freeze({ id: 'ios-60', device: 'физическое устройство iOS', refreshHz: 60, browsers: ['webkit-stable'], status: 'UNPROVEN' }),
+ Object.freeze({ id: 'ios-120', device: 'физическое устройство iOS с ProMotion', refreshHz: 120, browsers: ['webkit-stable'], status: 'UNPROVEN' }),
+ Object.freeze({ id: 'desktop-chromium', device: 'настольный Linux на собственном runner', refreshHz: null, browsers: ['chromium-stable'], status: 'UNPROVEN' }),
+ Object.freeze({ id: 'desktop-firefox', device: 'настольный Linux на собственном runner', refreshHz: null, browsers: ['firefox-stable'], status: 'UNPROVEN' }),
+ Object.freeze({ id: 'desktop-webkit', device: 'настольный Linux на собственном runner', refreshHz: null, browsers: ['webkit-stable'], status: 'UNPROVEN' }),
+ ]),
+ selectionRule: 'модели и версии ОС — первые доступные репрезентативные устройства собственного парка; выбор фиксируется до измерений кандидата',
+ no120HzRule: 'когда device не умеет 120 Hz, 120 Hz target ему не приписывается, но 60 Hz задача остаётся',
+ cpuThrottleNote: 'CPU-throttle desktop — диагностический контроль, не mobile proof; порог M-04 не выводится из быстрого сервера',
+ fixedConditions: Object.freeze([
+ 'температура, питание и состояние экрана', 'нагрузка', 'фоновые процессы', 'viewport/DPR/content',
+ 'источники времени', 'warm/cold режимы', 'единицы выборки', 'N/iterations/seed', 'все знаменатели',
+ ]),
+});
+
+// Отсутствие оборудования затрагивает отдельные клетки. Клетка без устройства остаётся
+// UNPROVEN и блокирует A/B admission своей клетки; unit/browser подготовка
+// при этом не останавливается, платная закупка без допуска запрещена.
+export const AFFECTED_CELLS_MISSING_HW = Object.freeze([
+ Object.freeze({ cell: 'android-mid-120', blocks: 'A/B admission 120 Hz Android-клетки; 60 Hz задача независима' }),
+ Object.freeze({ cell: 'ios-120', blocks: 'A/B admission 120 Hz iOS-клетки; 60 Hz задача независима' }),
+ Object.freeze({ cell: 'whole-page energy/GPU', blocks: 'выводы M-04/M-05 о полной стоимости устройства; frame CPU-time клетки независимы' }),
+]);
+
+// Замороженные сцены измерения (выбор ДО samples, смена выбора = новая регистрация).
+// M-04: 100 активных скалярных каналов, p99 собственного CPU-time Lab Motion ≤0,5 ms/frame;
+// полный 120 Hz сценарий: верхняя 95% граница доли пропущенных кадров ≤0,1%.
+// M-05: две тяжёлые сцены разных семейств, верхняя 95% граница candidate/best ≤0,50.
+export const SCENES = Object.freeze({
+ m04channels: Object.freeze({
+ id: 'm04-100-scalar-channels',
+ channels: 100,
+ fixture: 'behaviors-sheet heavy scene (createBottomSheet, snapPoints [0, 300, 600])',
+ acceptance: 'p99 собственного CPU-time Lab Motion ≤0,5 ms/frame; OS/browser/render отдельно; deliberate extra work различается стендом',
+ }),
+ m04full120: Object.freeze({
+ id: 'm04-full-120hz',
+ acceptance: 'верхняя 95% граница доли пропущенных кадров ≤0,1% выбранного 120 Hz сценария',
+ }),
+ m05a: Object.freeze({
+ id: 'm05-sheet',
+ family: 'непосредственное управление (sheet)',
+ fixture: 'behaviors-sheet-one-liner consumer (gate 3700 B gzip)',
+ acceptance: 'верхняя 95% граница candidate/best ≤0,50 по доминирующей устранимой стоимости; protected клетки не хуже',
+ }),
+ m05b: Object.freeze({
+ id: 'm05-list',
+ family: 'изменяемая коллекция (фильтруемый/переставляемый список)',
+ fixture: 'reorder-controlled consumer (gate 1522 B gzip)',
+ acceptance: 'верхняя 95% граница candidate/best ≤0,50 по доминирующей устранимой стоимости; protected клетки не хуже',
+ }),
+ rawControlRule: 'для raw control с нулевой стоимостью отношение не вычисляется: требуется совпадение границы и выигрыш другой содержательной метрики',
+});
+
+// Каждый A/B сопровождается полным набором контрольных измерений.
+export const RAW_CONTROLS = Object.freeze({
+ noMotion: 'статический кадр без движения обязан совпадать попиксельно с точностью оракула',
+ reducedMotion: 'при reduced-motion частые действия имеют путь без движения',
+ waapi: 'платформенный контроль bench/compare/waapi-control.entry',
+ oldLab: 'old-Lab profile на том же стенде (PRODUCT_BASE без кандидата)',
+ bestComparator: 'best-comparator: Motion, Anime Animatable/Layout и raw platform; Rive только в совпадающих authoring/handoff задачах',
+ aa: 'A/A: тот же build дважды; обязан различить отсутствие изменения в non-inferiority полосе',
+ positive: 'deliberate 2×work положительный контроль обязан детектироваться',
+});
+
+// Статистика и MDE. Единица независимости — прогон (run); кадры/каналы внутри
+// прогона зависимы и не создают фиктивное число участников.
+export const STATS_MDE = Object.freeze({
+ independenceUnit: 'run (device × build × scene × mode блок)',
+ design: 'парный/блочный дизайн, warm+cold режимы',
+ sampleSize: 'N из заранее выбранных MDE/power и null-pilot оценки дисперсии; N замораживается до A/B',
+ seed: 'фиксированный seed публикуется в артефакте',
+ stoppingRule: 'правило остановки фиксировано заранее; добор samples после просмотра запрещён',
+ coverage: 'family-wise 95% coverage либо заранее утверждённая multiplicity correction',
+ noRepeatToGreen: 'повтор того же опыта до green не допускается; failed A/A = UNPROVEN + baseline RCA, не NO-GO идеи',
+});
+
+// Неудачные измерения сохраняются вместе с успешными.
+export const OBSERVATION_POLICY = Object.freeze({
+ preserve: Object.freeze(['все samples', 'отказы', 'зависания', 'GC/JIT/context switches', 'повреждённые квитанции']),
+ rawAndDigest: 'сырые данные + внешний digest (sha256) в артефакте; команды и метод позволяют независимый replay',
+ retention: 'истечение Actions artifact не удаляет единственную копию proof: компактный witness у research owner, raw по durable artifact policy',
+ forcedGc: 'forced GC допустим только в выделенном retention proof, не в timing',
+ denominators: 'initial, reachable total, cold/warm-compile, startup, interruption, frame, teardown, import/build, peak/retained — свои знаменатели',
+});
+
+// Недействительная калибровка запрещает допуск кандидата.
+export const CALIBRATION = Object.freeze({
+ aaBand: 'A/A обязан показать отсутствие изменения в исходной non-inferiority полосе; старый p95 admission upper ≤1,05 и остальные условия не меняются',
+ positiveDetection: 'deliberate 2×work обязан детектироваться стендом',
+ failClosed: 'invalid calibration → candidate admission не запускается; baseline-only RCA меняет метод лишь с конкретным дефектом/новой предпосылкой',
+ staleProfile: 'профиль, снятый не на зарегистрированном roster/base, к admission не допускается',
+});
+
+export const PROFILE_01 = deepFreeze({
+ node: 'PROFILE-01',
+ revision: 'r11',
+ productBase: PRODUCT_BASE,
+ oldCostVectorGzipBytes: OLD_COST_VECTOR_GZIP_BYTES,
+ roster: ROSTER,
+ affectedCellsMissingHw: AFFECTED_CELLS_MISSING_HW,
+ scenes: SCENES,
+ rawControls: RAW_CONTROLS,
+ statsMde: STATS_MDE,
+ observationPolicy: OBSERVATION_POLICY,
+ calibration: CALIBRATION,
+ candidateSamplesObservedAtRegistration: false,
+});
+
+export function preregistrationDigest(value = PROFILE_01) {
+ return createHash('sha256').update(JSON.stringify(value)).digest('hex');
+}
+
+// Размер пакета не измеряет временные клетки roster. Имя runner и переменная
+// среды не превращают отсутствие измерения в доказательство.
+export function unmeasuredCells(cells) {
+ invariant(cells === 'desktop' || cells === 'all', 'неизвестный набор клеток');
+ return PROFILE_01.roster.classes
+ .filter((cell) => cells === 'all' || cell.id.startsWith('desktop-'))
+ .map((cell) => ({ cell: cell.id, reason: 'временные метрики и калибровка устройства не измерены', blocks: 'допуск A/B для этой клетки' }))
+ .concat([{ cell: 'whole-page energy/GPU', reason: 'энергия и GPU устройства не измерены', blocks: 'выводы M-04/M-05 о полной стоимости устройства' }]);
+}
+
+// Вход сравнивается целиком с одним неизменяемым протоколом. Его версию
+// закрепляют commit, независимое ревью и CI; копия констант рядом не нужна.
+export function verifyPreregistration(value = PROFILE_01) {
+ invariant(isDeepStrictEqual(value, PROFILE_01), 'контракт отличается от зарегистрированного протокола');
+ return true;
+}
diff --git a/bench/profile/profile-git-proof.mjs b/bench/profile/profile-git-proof.mjs
new file mode 100644
index 000000000..c356645ca
--- /dev/null
+++ b/bench/profile/profile-git-proof.mjs
@@ -0,0 +1,64 @@
+// PROFILE-01 git-proof: общие git-доказательства provenance для probe и validator.
+// Один источник PREREG_OWN_PATHS исключает дрейф allowlist между файлами.
+// Недоступность Git запрещает допуск через переданный обработчик отказа.
+
+import { execFileSync } from 'node:child_process';
+import { readCheckoutState } from '../compare/provenance.mjs';
+
+// Измеряемое дерево обязано совпадать с PRODUCT_BASE везде, кроме самих
+// файлов preregistration-пакета. Это позволяет снимать old-vector на
+// PR-ветке, выросшей из PRODUCT_BASE, с доказанной эквивалентностью.
+export const PREREG_OWN_PATHS = Object.freeze([
+ 'bench/profile/profile-01-preregistration.mjs',
+ 'bench/profile/probe-profile-01.mjs',
+ 'bench/profile/validate-profile-01.mjs',
+ 'bench/profile/profile-git-proof.mjs',
+ 'bench/profile/profile-measurement.mjs',
+ 'test/profile-measurement.test.ts',
+ '.github/workflows/profile-01.yml',
+ '.github/workflows/ci.yml',
+ 'test/ci-workflow-contract.test.ts',
+]);
+
+export function makeGit(fail) {
+ const head = (cwd) => {
+ try {
+ const state = readCheckoutState(cwd);
+ if (state.dirty) fail('измерение требует clean checkout');
+ return state.revision;
+ } catch {
+ fail('git недоступен для доказательства provenance');
+ }
+ };
+ const blob = (cwd, rev, path) => {
+ try {
+ return execFileSync('git', ['--no-replace-objects', 'rev-parse', `${rev}:${path}`], { cwd, encoding: 'utf8' }).trim();
+ } catch {
+ fail(`git не смог доказать blob ${path}@${rev}`);
+ }
+ };
+ const workingBlob = (cwd, path) => {
+ try {
+ return execFileSync('git', ['hash-object', '--', path], { cwd, encoding: 'utf8' }).trim();
+ } catch {
+ fail(`git не смог доказать рабочий blob ${path}`);
+ }
+ };
+ const diffNames = (cwd, base, headRef) => {
+ try {
+ const output = execFileSync('git', ['--no-replace-objects', 'diff', '--name-only', `${base}`, `${headRef}`], { cwd, encoding: 'utf8' });
+ return output.split('\n').map((line) => line.trim()).filter(Boolean);
+ } catch {
+ fail(`git не смог доказать эквивалентность дерева ${base}..${headRef}`);
+ }
+ };
+ const ancestor = (cwd, base) => {
+ try {
+ execFileSync('git', ['--no-replace-objects', 'merge-base', '--is-ancestor', base, 'HEAD'], { cwd, stdio: 'ignore' });
+ return true;
+ } catch {
+ return false;
+ }
+ };
+ return { head, blob, workingBlob, diffNames, ancestor };
+}
diff --git a/bench/profile/profile-measurement.mjs b/bench/profile/profile-measurement.mjs
new file mode 100644
index 000000000..d2dcba38f
--- /dev/null
+++ b/bench/profile/profile-measurement.mjs
@@ -0,0 +1,51 @@
+import { isDeepStrictEqual } from 'node:util';
+import { readFileSync } from 'node:fs';
+import { join } from 'node:path';
+import { CANONICAL_GZIP_PACKAGE } from '../../scripts/compression-policy.mjs';
+import {
+ prepareBenchmarkCheckout,
+ assertCheckoutUnchanged,
+ assertInstalledPackageTreesUnchanged,
+} from '../compare/provenance.mjs';
+
+// Сборка и identity принадлежат общему стенду; числа и потолки — size-gate.
+// Адаптер сохраняет структурированный результат тех же измерений, что pnpm size.
+export async function measureOldVector(root) {
+ const provenance = prepareBenchmarkCheckout({
+ root,
+ benchDirectory: root,
+ requiredRootPackages: ['esbuild', CANONICAL_GZIP_PACKAGE],
+ });
+ const gate = await import('../../scripts/size-gate.mjs');
+ const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8'));
+ const entries = gate.measureEntries(gate.deriveEntriesFromExports(pkg), root);
+ const scenarios = [];
+ for (const scenario of gate.IMPORT_COST_SCENARIOS) {
+ scenarios.push(await gate.measureScenario(scenario, join(root, 'dist/index.js')));
+ }
+ const failed = entries.hasWarnings || scenarios.some((row) => row.error || gate.evaluateScenarioBudget(row).exceeded);
+ assertCheckoutUnchanged(root, provenance);
+ assertInstalledPackageTreesUnchanged(root, provenance.environment.rootPackages);
+ return { exitCode: failed ? 1 : 0, entries, scenarios, provenance };
+}
+
+export function validateReplayedVector(recorded, replayed) {
+ if (recorded?.exitCode !== 0 || replayed?.exitCode !== 0) {
+ throw new Error('PROFILE-01: старый cost vector не прошёл независимое измерение');
+ }
+ if (!recorded.entries || !Array.isArray(recorded.scenarios) || recorded.scenarios.length === 0) {
+ throw new Error('PROFILE-01: отсутствуют структурированные измерения');
+ }
+ for (const field of ['entries', 'scenarios']) {
+ if (!isDeepStrictEqual(recorded[field], replayed[field])) {
+ throw new Error(`PROFILE-01: ${field} не воспроизводится независимым измерением`);
+ }
+ }
+ // Время новой сборки отличается; исходники, inputs, dist и инструменты должны совпасть.
+ for (const field of ['revision', 'trackedRevisionSha256', 'inputs', 'distRuntime', 'environment']) {
+ if (recorded.provenance?.[field] === undefined ||
+ !isDeepStrictEqual(recorded.provenance[field], replayed.provenance?.[field])) {
+ throw new Error(`PROFILE-01: provenance.${field} не совпадает при воспроизведении`);
+ }
+ }
+}
diff --git a/bench/profile/validate-profile-01.mjs b/bench/profile/validate-profile-01.mjs
new file mode 100644
index 000000000..51f3c23a8
--- /dev/null
+++ b/bench/profile/validate-profile-01.mjs
@@ -0,0 +1,114 @@
+// PROFILE-01 validate: независимая перепроверка raw-артефакта пробы.
+// Повторяет размерные измерения; не удостоверяет время исторического запуска
+// и не выдаёт допуска производительности или человеческой оценки.
+// Использование: node bench/profile/validate-profile-01.mjs --raw
+
+import { readFileSync } from 'node:fs';
+import { resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { isDeepStrictEqual } from 'node:util';
+import {
+ PROFILE_01,
+ preregistrationDigest,
+ verifyPreregistration,
+ unmeasuredCells,
+} from './profile-01-preregistration.mjs';
+import { PREREG_OWN_PATHS, makeGit } from './profile-git-proof.mjs';
+import { measureOldVector, validateReplayedVector } from './profile-measurement.mjs';
+
+function fail(message) {
+ throw new Error(`PROFILE-01 validate (fail-closed): ${message}`);
+}
+
+function arg(name) {
+ const index = process.argv.indexOf(name);
+ return index === -1 ? undefined : process.argv[index + 1];
+}
+
+async function main() {
+ const rawPath = resolve(arg('--raw') ?? fail('требуется --raw '));
+ const artifact = JSON.parse(readFileSync(rawPath, 'utf8'));
+ if (artifact.node !== 'PROFILE-01' || artifact.revision !== 'r11' ||
+ !['old-vector', 'aa', 'ab'].includes(artifact.mode)) fail('неверный вид артефакта');
+ if (!isDeepStrictEqual(artifact.cellsUnproven, unmeasuredCells(artifact.cells))) {
+ fail('неизмеренные клетки потеряны или подменены');
+ }
+ const expectedMeasured = artifact.admission === 'OLD-VECTOR-ONLY' ? ['desktop-size-vector'] : [];
+ if (!isDeepStrictEqual(artifact.cellsMeasured, expectedMeasured)) fail('неверный набор измеренных клеток');
+
+ // 1. Замороженный контракт не менялся.
+ verifyPreregistration(PROFILE_01);
+ const frozen = preregistrationDigest(PROFILE_01);
+ if (artifact.preregistrationDigest !== frozen) {
+ fail(`preregistration не совпадает: artifact ${artifact.preregistrationDigest}, frozen ${frozen}`);
+ }
+ if (artifact.candidateSamplesObservedAtRegistration !== false) {
+ fail('preregistration обязана предшествовать samples');
+ }
+
+ // Происхождение исходников пересчитывается независимо через Git.
+ // Валидатор не доверяет полям raw JSON: HEAD, ancestry, diff и blob
+ // пересчитываются из checkout, в котором запущен валидатор.
+ // Записанный exitCode не удостоверяет измерение. После проверки Git повторяем
+ // сборку и существующий size-gate; сравниваем данные, а не формат console.log.
+ if (artifact.mode === 'old-vector') {
+ const base = PROFILE_01.productBase.sourceSha;
+ const proof = artifact.baseProof ?? {};
+ if (proof.productBase !== base || proof.head !== artifact.head) fail('baseProof не покрывает artifact head');
+ if (!Array.isArray(proof.diffPaths)) fail('baseProof.diffPaths отсутствует');
+ const repoRoot = fileURLToPath(new URL('../../', import.meta.url));
+ const git = makeGit(fail);
+ const currentHead = git.head(repoRoot);
+ if (currentHead !== artifact.head) {
+ fail(`валидатор запущен не на artifact head: checkout ${currentHead}, artifact ${artifact.head}`);
+ }
+ if (!git.ancestor(repoRoot, base)) fail(`artifact head не вырос из PRODUCT_BASE ${base}`);
+ const recomputed = currentHead === base ? [] : git.diffNames(repoRoot, base, currentHead);
+ const asSet = (paths) => JSON.stringify([...paths].sort());
+ if (asSet(recomputed) !== asSet(proof.diffPaths)) {
+ fail(`baseProof.diffPaths не совпадает с git: artifact [${proof.diffPaths.join(', ')}], git [${recomputed.join(', ')}]`);
+ }
+ const foreign = recomputed.filter((path) => !PREREG_OWN_PATHS.includes(path));
+ if (foreign.length > 0) fail(`дерево отличается от PRODUCT_BASE вне prereg-пакета: ${foreign.join(', ')}`);
+ const committed = git.blob(repoRoot, artifact.head, 'scripts/size-gate.mjs');
+ if (committed !== PROFILE_01.productBase.sizeGateBlob || committed !== artifact.sizeGateBlob) {
+ fail(`size-gate provenance drifted: artifact ${artifact.sizeGateBlob}, git ${committed}`);
+ }
+ if (artifact.admission === 'OLD-VECTOR-ONLY') {
+ validateReplayedVector(artifact.costVector, await measureOldVector(repoRoot));
+ }
+ }
+
+ // 3. A/B без калибровки не существует.
+ if (artifact.mode !== 'old-vector' && artifact.admission !== 'NOT-GRANTED') {
+ fail('некалиброванный A/B не может нести admission');
+ }
+ if (artifact.admission !== 'NOT-GRANTED' && artifact.admission !== 'OLD-VECTOR-ONLY') {
+ fail(`неизвестный admission ${artifact.admission}`);
+ }
+ // Отклонённый артефакт обязан нести причину: NOT-GRANTED без rejection
+ // означает потерянный failure (OBSERVATION_POLICY нарушена).
+ if (artifact.admission === 'NOT-GRANTED') {
+ if (typeof artifact.rejection !== 'string' || artifact.rejection.length === 0) {
+ fail('NOT-GRANTED артефакт обязан содержать непустой rejection');
+ }
+ } else if (artifact.rejection !== null && artifact.rejection !== undefined) {
+ fail('успешный артефакт не должен нести rejection');
+ }
+
+ // 4. Affected cells обязаны быть перечислены, а не молча пропущены.
+ if (!Array.isArray(artifact.cellsUnproven)) fail('cellsUnproven обязан быть списком');
+ if (!Array.isArray(artifact.cellsMeasured)) fail('cellsMeasured обязан быть списком');
+
+ // 5. Времена и seed фиксированы.
+ if (!Number.isFinite(Date.parse(artifact.startedAtUtc ?? ''))) fail('startedAtUtc отсутствует');
+ if (!Number.isFinite(Date.parse(artifact.finishedAtUtc ?? ''))) fail('finishedAtUtc отсутствует');
+ if (Date.parse(artifact.finishedAtUtc) < Date.parse(artifact.startedAtUtc)) fail('время окончания предшествует началу');
+ if (artifact.seed !== 20260929) fail('seed drifted');
+
+ // eslint-disable-next-line no-console
+ console.log(JSON.stringify({ valid: true, mode: artifact.mode, admission: artifact.admission,
+ verification: artifact.admission === 'OLD-VECTOR-ONLY' ? 'independent-size-remeasurement' : 'recorded-refusal-only' }));
+}
+
+await main();
diff --git a/test/ci-workflow-contract.test.ts b/test/ci-workflow-contract.test.ts
index 8a3467ede..58058b6f7 100644
--- a/test/ci-workflow-contract.test.ts
+++ b/test/ci-workflow-contract.test.ts
@@ -283,9 +283,20 @@ function assertNativeGraph(files: Map) {
expect(files.has('ci-gate.yml')).toBe(false);
const ci = workflows.get('ci.yml')!;
const browser = workflows.get('browser.yml')!;
- expect(ci.on).toEqual({ push: { branches: ['main'] }, pull_request: null, merge_group: null, workflow_dispatch: null });
+ expect(ci.on).toEqual({
+ push: { branches: ['main'] }, pull_request: null, merge_group: null,
+ workflow_dispatch: { inputs: { profile_baseline: {
+ description: 'Снять зарегистрированный размерный baseline PROFILE-01',
+ type: 'boolean', default: false,
+ } } },
+ });
expect(browser.on).toEqual({ workflow_call: null });
- expect(Object.keys(ci.jobs).sort()).toEqual(['CI', 'browser-static', 'mutation', 'node-floor', 'tests', 'verify']);
+ expect(Object.keys(ci.jobs).sort()).toEqual(['CI', 'browser-static', 'mutation', 'node-floor', 'profile-baseline', 'tests', 'verify']);
+ expect(ci.jobs['profile-baseline']).toEqual({
+ if: "${{ github.event_name == 'workflow_dispatch' && inputs.profile_baseline }}",
+ uses: './.github/workflows/profile-01.yml',
+ });
+ expect(workflows.get('profile-01.yml')!.on).toEqual({ workflow_call: null });
expect(Object.keys(browser.jobs)).toEqual(['conformance']);
for (const document of [ci, browser]) {
expect(document.permissions).toEqual({ contents: 'read' });
@@ -394,6 +405,11 @@ describe('нативный граф CI', () => {
['пустой успех', 'ci.yml', (w: Workflow) => { w.jobs.CI!.steps![0]!.run = 'true\n'; }],
['игнорирование итога', 'ci.yml', (w: Workflow) => { w.jobs.CI!['continue-on-error'] = true; }],
['добавлен trigger browser', 'browser.yml', (w: Workflow) => { w.on.pull_request = null; }],
+ ['добавлен PR trigger PROFILE', 'profile-01.yml', (w: Workflow) => { w.on.pull_request = null; }],
+ ['PROFILE запущен для обычного кандидата', 'ci.yml', (w: Workflow) => { delete w.jobs['profile-baseline']!.if; }],
+ ['PROFILE без явного запроса', 'ci.yml', (w: Workflow) => {
+ w.jobs['profile-baseline']!.if = "${{ github.event_name == 'workflow_dispatch' }}";
+ }],
['нет workflow_call', 'browser.yml', (w: Workflow) => { w.on = { workflow_dispatch: null }; }],
...['chromium', 'firefox', 'webkit'].map((engine) => [`нет ${engine}`, 'browser.yml', (w: Workflow) => {
const matrix = w.jobs.conformance!.strategy!.matrix;
@@ -494,24 +510,22 @@ describe('нативный граф CI', () => {
}
});
- it('shell итога требует success каждого dependency', () => {
+ it.each(['VERIFY_RESULT', 'TESTS_RESULT', 'MUTATION_RESULT', 'NODE_FLOOR_RESULT', 'BROWSER_RESULT']
+ .flatMap((key) => ['success', 'failure', 'cancelled', 'skipped', 'neutral', 'pending', '', undefined]
+ .map((result) => ({ key, result }))))('shell итога проверяет $key=$result', ({ key, result }) => {
const ci = parse(sources().get('ci.yml')!) as Workflow;
const program = ci.jobs.CI!.steps![0]!.run!;
- for (const key of ['VERIFY_RESULT', 'TESTS_RESULT', 'MUTATION_RESULT', 'NODE_FLOOR_RESULT', 'BROWSER_RESULT']) {
- for (const result of ['success', 'failure', 'cancelled', 'skipped', 'neutral', 'pending', '', undefined]) {
- const env: NodeJS.ProcessEnv = {
- ...process.env, VERIFY_RESULT: 'success', TESTS_RESULT: 'success',
- MUTATION_RESULT: 'success', NODE_FLOOR_RESULT: 'success', BROWSER_RESULT: 'success',
- };
- delete env[key];
- if (result !== undefined) env[key] = result;
- const actual = spawnSync(bash, ['--noprofile', '--norc', '-e', '-o', 'pipefail', '-c', program], {
- env, encoding: 'utf8', timeout: 5000,
- });
- expect(actual.error).toBeUndefined();
- expect(actual.status === 0, `${key}=${result}: ${actual.stderr}`).toBe(result === 'success');
- }
- }
+ const env: NodeJS.ProcessEnv = {
+ ...process.env, VERIFY_RESULT: 'success', TESTS_RESULT: 'success',
+ MUTATION_RESULT: 'success', NODE_FLOOR_RESULT: 'success', BROWSER_RESULT: 'success',
+ };
+ delete env[key];
+ if (result !== undefined) env[key] = result;
+ const actual = spawnSync(bash, ['--noprofile', '--norc', '-e', '-o', 'pipefail', '-c', program], {
+ env, encoding: 'utf8', timeout: 5000,
+ });
+ expect(actual.error).toBeUndefined();
+ expect(actual.status === 0, `${key}=${result}: ${actual.stderr}`).toBe(result === 'success');
});
it('shell Vitest сохраняет код отказа после tee для diagnostics', () => {
@@ -532,4 +546,4 @@ describe('нативный граф CI', () => {
rmSync(cwd, { recursive: true, force: true });
}
});
-});
\ No newline at end of file
+});
diff --git a/test/profile-measurement.test.ts b/test/profile-measurement.test.ts
new file mode 100644
index 000000000..902f4ec08
--- /dev/null
+++ b/test/profile-measurement.test.ts
@@ -0,0 +1,242 @@
+import { describe, expect, it } from 'vitest';
+import { execFileSync, spawnSync } from 'node:child_process';
+import { createHash } from 'node:crypto';
+import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { validateReplayedVector } from '../bench/profile/profile-measurement.mjs';
+import { PROFILE_01, unmeasuredCells, verifyPreregistration } from '../bench/profile/profile-01-preregistration.mjs';
+import { makeGit } from '../bench/profile/profile-git-proof.mjs';
+
+describe('PROFILE: происхождение подтверждает настоящий Git', () => {
+ it('различает clean, tracked/untracked drift, ancestry и недоступное доказательство', () => {
+ const directory = mkdtempSync(join(tmpdir(), 'motion-profile-git-'));
+ const git = (...args: string[]) => execFileSync('git', args, {
+ cwd: directory, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 10_000,
+ }).trim();
+ const proof = makeGit((message: string) => { throw new Error(message); });
+ try {
+ git('init', '--quiet');
+ git('config', 'core.autocrlf', 'false');
+ git('config', 'core.hooksPath', join(directory, 'no-hooks'));
+ const commit = () => {
+ git('add', '.');
+ git('-c', 'user.name=PROFILE test', '-c', 'user.email=profile@example.invalid', '-c', 'commit.gpgsign=false',
+ 'commit', '--quiet', '-m', 'fixture');
+ return git('rev-parse', 'HEAD');
+ };
+ writeFileSync(join(directory, 'source.txt'), 'first\n');
+ const base = commit();
+ expect(proof.head(directory)).toBe(base);
+ const originalBlob = createHash('sha1').update('blob 6\0first\n').digest('hex');
+ expect(proof.blob(directory, base, 'source.txt')).toBe(originalBlob);
+ expect(proof.workingBlob(directory, 'source.txt')).toBe(originalBlob);
+ writeFileSync(join(directory, 'source.txt'), 'second\n');
+ expect(() => proof.head(directory)).toThrow();
+ expect(proof.workingBlob(directory, 'source.txt')).not.toBe(originalBlob);
+ const head = commit();
+ expect(proof.head(directory)).toBe(head);
+ expect(proof.diffNames(directory, base, head)).toEqual(['source.txt']);
+ expect(proof.ancestor(directory, base)).toBe(true);
+ git('checkout', '--quiet', '--detach', base);
+ expect(proof.ancestor(directory, head)).toBe(false);
+ writeFileSync(join(directory, 'untracked.txt'), 'drift\n');
+ expect(() => proof.head(directory)).toThrow();
+ expect(() => proof.blob(directory, base, 'missing.txt')).toThrow();
+ expect(() => proof.workingBlob(directory, 'missing.txt')).toThrow();
+ expect(() => proof.diffNames(directory, 'missing-revision', head)).toThrow();
+ expect(() => proof.head(join(directory, 'missing-directory'))).toThrow();
+ } finally {
+ rmSync(directory, { recursive: true, force: true });
+ }
+ }, 30_000);
+
+ it('old-vector сохраняет отказ измерения, когда в clean clone нет инструментов', () => {
+ const directory = mkdtempSync(join(tmpdir(), 'motion-profile-missing-tools-'));
+ try {
+ const root = fileURLToPath(new URL('../', import.meta.url));
+ const clone = join(directory, 'checkout');
+ const sourceHead = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: root, encoding: 'utf8' }).trim();
+ execFileSync('git', ['clone', '--quiet', '--no-hardlinks', '--no-checkout', root, clone],
+ { encoding: 'utf8', timeout: 30_000 });
+ execFileSync('git', ['checkout', '--quiet', '--detach', sourceHead], { cwd: clone, timeout: 30_000 });
+ const run = spawnSync(process.execPath, [
+ 'bench/profile/probe-profile-01.mjs', '--mode', 'old-vector', '--cells', 'all',
+ '--out', join(directory, 'raw'),
+ ], { cwd: clone, encoding: 'utf8', timeout: 30_000 });
+ expect(run.status, run.stderr).toBe(1);
+ const receipt = JSON.parse(run.stdout.trim());
+ const raw = readFileSync(receipt.rawPath);
+ const artifact = JSON.parse(raw.toString('utf8'));
+ expect(artifact.head).toBe(sourceHead);
+ expect(artifact.admission).toBe('NOT-GRANTED');
+ expect(artifact.rejection).toContain('незавершённое измерение');
+ expect(artifact.rejection).toMatch(/не установлен|не создала обязательный|сборка/);
+ expect(artifact.costVector).toBeNull();
+ expect(artifact.cellsMeasured).toEqual([]);
+ expect(artifact.cellsUnproven).toEqual(unmeasuredCells('all'));
+ expect(receipt.rawDigest).toBe(createHash('sha256').update(raw).digest('hex'));
+ const refusal = spawnSync(process.execPath, ['bench/profile/validate-profile-01.mjs', '--raw', receipt.rawPath],
+ { cwd: clone, encoding: 'utf8', timeout: 30_000 });
+ expect(refusal.status, refusal.stderr).toBe(0);
+ expect(JSON.parse(refusal.stdout.trim()).verification).toBe('recorded-refusal-only');
+ } finally {
+ rmSync(directory, { recursive: true, force: true });
+ }
+ }, 90_000);
+});
+
+describe('PROFILE: замороженный протокол проверяется целиком', () => {
+ it('зарегистрированные потолки совпадают с единственным исполняемым size-gate', async () => {
+ const gate = await import('../scripts/size-gate.mjs');
+ const registered = PROFILE_01.oldCostVectorGzipBytes;
+ expect(registered.bespoke).toEqual(gate.BESPOKE_SUBPATH_GATES);
+ expect(registered.scenarios).toEqual(Object.fromEntries(
+ gate.IMPORT_COST_SCENARIOS.map(({ name, gate: ceiling }) => [name, ceiling]),
+ ));
+ expect(registered.core).toBe(gate.CORE_GATE_BYTES);
+ expect(registered.subpath).toBe(gate.SUBPATH_GATE_BYTES);
+ expect(registered.fullCoreConsumer).toBe(gate.FULL_CORE_CONSUMER_GATE_BYTES);
+ expect(registered.nano).toBe(gate.NANO_GATE_BYTES);
+ expect(registered.compiledRuntime).toBe(gate.BESPOKE_SUBPATH_GATES['./compiler/runtime']);
+ expect(registered.compilerSurface).toBe(gate.BESPOKE_SUBPATH_GATES['./compiler/surface']);
+ expect(registered.inView).toBe(gate.IN_VIEW_GATE_BYTES);
+ expect(registered.inViewConsumer).toBe(gate.IN_VIEW_CONSUMER_GATE_BYTES);
+ expect(registered.compositorCapability).toBe(gate.COMPOSITOR_CAPABILITY_GATE_BYTES);
+ expect(registered.fullAnimate).toBe(gate.FULL_ANIMATE_GATE_BYTES);
+ expect(registered.animateCompositorMixed).toBe(gate.ANIMATE_COMPOSITOR_MIXED_GATE_BYTES);
+ });
+
+ it('принимает независимую JSON-копию полного протокола', () => {
+ expect(verifyPreregistration()).toBe(true);
+ expect(verifyPreregistration(JSON.parse(JSON.stringify(PROFILE_01)))).toBe(true);
+ });
+
+ it.each([
+ ['productBase', 'reason'], ['scenes', 'm05a', 'acceptance'],
+ ['rawControls', 'aa'], ['statsMde', 'sampleSize'], ['calibration', 'failClosed'],
+ ])('отвергает подмену условия %s.%s', (...path) => {
+ const changed = JSON.parse(JSON.stringify(PROFILE_01));
+ const parent = path.slice(0, -1).reduce((object, key) => object[key], changed);
+ parent[path.at(-1)!] = 'условие удалено';
+ expect(() => verifyPreregistration(changed)).toThrow();
+ });
+
+ it('отвергает потерю, подмену и лишние поля на каждой глубине', () => {
+ function visit(value: unknown, path: string[] = []) {
+ if (value === null || typeof value !== 'object') return;
+ for (const [key, child] of Object.entries(value)) {
+ const childPath = [...path, key];
+ for (const operation of ['delete', 'replace']) {
+ const changed = JSON.parse(JSON.stringify(PROFILE_01));
+ const parent = path.reduce((object, segment) => object[segment], changed);
+ if (operation === 'delete') delete parent[key];
+ else parent[key] = typeof child === 'string' ? `${child}!` : 'другой тип';
+ expect(() => verifyPreregistration(changed), `${operation} ${childPath.join('.')}`).toThrow();
+ }
+ visit(child, childPath);
+ }
+ const extra = JSON.parse(JSON.stringify(PROFILE_01));
+ const parent = path.reduce((object, key) => object[key], extra);
+ parent.unregistered = true;
+ expect(() => verifyPreregistration(extra), `extra ${path.join('.')}`).toThrow();
+ }
+ visit(PROFILE_01);
+ for (const invalid of [null, [], '', 1, true]) {
+ expect(() => verifyPreregistration(invalid)).toThrow();
+ }
+ });
+
+ it('не позволяет мутировать вложенные массивы исходного протокола', () => {
+ expect(() => PROFILE_01.roster.classes[0].browsers.push('подмена')).toThrow();
+ expect(PROFILE_01.roster.classes[0].browsers).toEqual(['chromium-webview-stable']);
+ });
+});
+
+function measured() {
+ return {
+ exitCode: 0,
+ entries: { rows: [{ label: '.', gzBytes: 42, gate: 100 }], hasWarnings: false },
+ scenarios: [{ name: 'only-spring', gzBytes: 7, totalGzBytes: 7, gate: 10 }],
+ provenance: {
+ revision: 'a'.repeat(40), trackedRevisionSha256: 'b'.repeat(64),
+ inputs: { 'root/pnpm-lock.yaml': 'c'.repeat(64) },
+ distRuntime: { sha256: 'd'.repeat(64), files: 2 },
+ environment: { node: 'v24.15.0', pnpm: '11.11.0' },
+ builtAt: '2026-09-30T00:00:00Z',
+ },
+ };
+}
+
+describe('PROFILE: записанный успех не заменяет независимый результат', () => {
+ it('принимает воспроизведённые данные с новым временем сборки', () => {
+ const replayed = measured();
+ replayed.provenance.builtAt = '2026-09-30T00:01:00Z';
+ expect(() => validateReplayedVector(measured(), replayed)).not.toThrow();
+ });
+
+ it('отвергает старую подделку из имён сценариев и exit 0', () => {
+ const forged = { exitCode: 0, transcript: 'only-spring size-gate: PASS' };
+ expect(() => validateReplayedVector(forged, measured())).toThrow('структурированные измерения');
+ });
+
+ it('отвергает отказ повторного измерения при записанном успехе', () => {
+ expect(() => validateReplayedVector(measured(), { ...measured(), exitCode: 1 })).toThrow('независимое измерение');
+ });
+
+ it('не повышает сохранённый отказ до успеха', () => {
+ expect(() => validateReplayedVector({ ...measured(), exitCode: 1 }, measured())).toThrow('независимое измерение');
+ });
+
+ it('ловит подмену числа при сохранённом PASS и неизменных названиях', () => {
+ const forged = measured();
+ forged.scenarios[0].gzBytes = 1;
+ expect(() => validateReplayedVector(forged, measured())).toThrow('scenarios');
+ });
+
+ it('ловит удалённый consumer и лишний неподтверждённый entry', () => {
+ const missing = measured();
+ missing.scenarios = [];
+ expect(() => validateReplayedVector(missing, measured())).toThrow();
+ const extra = measured();
+ extra.entries.rows.push({ label: './extra', gzBytes: 1, gate: 100 });
+ expect(() => validateReplayedVector(extra, measured())).toThrow('entries');
+ });
+
+ it.each(['revision', 'trackedRevisionSha256', 'inputs', 'distRuntime', 'environment'])('отвергает другой %s', (field) => {
+ const forged = measured();
+ Reflect.set(forged.provenance, field, 'forged');
+ expect(() => validateReplayedVector(forged, measured())).toThrow(`provenance.${field}`);
+ });
+
+ it('размерный прогон оставляет все device/timing клетки непроверенными', () => {
+ expect(unmeasuredCells('desktop').map((row: { cell: string }) => row.cell)).toEqual([
+ 'desktop-chromium', 'desktop-firefox', 'desktop-webkit', 'whole-page energy/GPU',
+ ]);
+ expect(unmeasuredCells('all').map((row: { cell: string }) => row.cell)).toEqual([
+ 'android-mid-60', 'android-mid-120', 'ios-60', 'ios-120',
+ 'desktop-chromium', 'desktop-firefox', 'desktop-webkit', 'whole-page energy/GPU',
+ ]);
+ expect(() => unmeasuredCells('unknown')).toThrow();
+ });
+
+ it('хеш реального failure-артефакта покрывает отступы, Unicode и конечный LF', () => {
+ const directory = mkdtempSync(join(tmpdir(), 'motion-profile-digest-'));
+ try {
+ const root = fileURLToPath(new URL('../', import.meta.url));
+ const run = spawnSync(process.execPath, [
+ 'bench/profile/probe-profile-01.mjs', '--mode', 'aa', '--out', directory,
+ ], { cwd: root, encoding: 'utf8', timeout: 15_000 });
+ expect(run.status, run.stderr).toBe(1);
+ const receipt = JSON.parse(run.stdout.trim());
+ const raw = readFileSync(receipt.rawPath);
+ expect(JSON.parse(raw.toString('utf8')).rejection).toContain('browser-калибровки');
+ expect(raw.toString('utf8')).toContain('\n "node"');
+ expect(raw.at(-1)).toBe(10);
+ expect(receipt.rawDigest).toBe(createHash('sha256').update(raw).digest('hex'));
+ } finally {
+ rmSync(directory, { recursive: true, force: true });
+ }
+ }, 20_000);
+});