From cd4284ae5232f38cfe0991823980830eede766a8 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Tue, 29 Sep 2026 03:30:35 +0300 Subject: [PATCH 01/12] bench(profile): preregister PROFILE-01 measurement protocol --- .github/workflows/profile-01.yml | 97 +++++++++ bench/profile/probe-profile-01.mjs | 177 +++++++++++++++ bench/profile/profile-01-preregistration.mjs | 214 +++++++++++++++++++ bench/profile/validate-profile-01.mjs | 79 +++++++ 4 files changed, 567 insertions(+) create mode 100644 .github/workflows/profile-01.yml create mode 100644 bench/profile/probe-profile-01.mjs create mode 100644 bench/profile/profile-01-preregistration.mjs create mode 100644 bench/profile/validate-profile-01.mjs diff --git a/.github/workflows/profile-01.yml b/.github/workflows/profile-01.yml new file mode 100644 index 000000000..d75fd92d0 --- /dev/null +++ b/.github/workflows/profile-01.yml @@ -0,0 +1,97 @@ +name: PROFILE-01 — preregistered probe + +# Только ручной запуск на штатных self-hosted runners (de-04 первым по +# CI-контракту). Платные GitHub-hosted runners запрещены. Тяжёлые тесты и +# бенчи никогда не запускаются в песочнице. +on: + workflow_dispatch: + inputs: + mode: + description: 'Режим пробы: old-vector (старый cost vector на PRODUCT_BASE)' + required: true + default: 'old-vector' + type: choice + options: + - old-vector + cells: + description: 'Клетки roster: desktop (без железа) или all (требует device lab)' + required: true + default: 'desktop' + type: choice + options: + - desktop + - all + +permissions: + contents: read + +env: + COREPACK_ENABLE_DOWNLOAD_PROMPT: '0' + COREPACK_DEFAULT_TO_LATEST: '0' + +jobs: + probe: + # Generic self-hosted label: парка de-04 обслуживает первым по контракту. + runs-on: self-hosted + timeout-minutes: 45 + steps: + - name: Получить exact harness + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + with: + persist-credentials: false + fetch-depth: 0 + + - name: Настроить Node + uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v6 + with: + node-version: '24' + + - name: Активировать pinned pnpm + run: | + corepack enable + corepack install --global pnpm@11.11.0 + + - name: Доказать preregistration-before-samples + shell: bash + run: | + set -euo pipefail + test "$(git rev-parse HEAD)" = "$GITHUB_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: Собрать exact base + run: pnpm build + + - name: Снять старый cost vector (fail-closed) + shell: bash + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/profile-01-raw" + node bench/profile/probe-profile-01.mjs \ + --mode '${{ inputs.mode }}' \ + --cells '${{ inputs.cells }}' \ + --out "$RUNNER_TEMP/profile-01-raw" + + - name: Независимо перепроверить raw-артефакт + shell: bash + run: | + set -euo pipefail + for raw in "$RUNNER_TEMP"/profile-01-raw/*.json; do + node bench/profile/validate-profile-01.mjs --raw "$raw" + done + + - name: Опубликовать raw-артефакт + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: profile-01-raw-${{ github.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..a5eb9c357 --- /dev/null +++ b/bench/profile/probe-profile-01.mjs @@ -0,0 +1,177 @@ +// PROFILE-01 probe: исполняемый измерительный стенд. +// Запуск ТОЛЬКО на штатных self-hosted runners (de-04 первым по CI-контракту), +// никогда в песочнице и никогда на платных GitHub-hosted runners. +// Fail-closed: невалидная калибровка или несоответствие frozen-контракта +// останавливают admission, а не дают «зелёный» результат. +// Использование: node bench/profile/probe-profile-01.mjs --mode old-vector|aa|ab --cells desktop|all --out + +import { createHash } from 'node:crypto'; +import { execFileSync } from 'node:child_process'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { + PROFILE_01, + preregistrationDigest, + verifyPreregistration, +} from './profile-01-preregistration.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]; +} + +function gitHead(cwd) { + try { + return execFileSync('git', ['rev-parse', 'HEAD'], { cwd, encoding: 'utf8' }).trim(); + } catch { + fail('git недоступен для доказательства provenance'); + } +} + +function gitBlob(cwd, rev, path) { + try { + return execFileSync('git', ['rev-parse', `${rev}:${path}`], { cwd, encoding: 'utf8' }).trim(); + } catch { + fail(`git не смог доказать blob ${path}@${rev}`); + } +} + +function gitDiffNames(cwd, base, head) { + try { + const output = execFileSync('git', ['diff', '--name-only', `${base}`, `${head}`], { cwd, encoding: 'utf8' }); + return output.split('\n').map((line) => line.trim()).filter(Boolean); + } catch { + fail(`git не смог доказать эквивалентность дерева ${base}..${head}`); + } +} + +function isAncestor(cwd, base) { + try { + execFileSync('git', ['merge-base', '--is-ancestor', base, 'HEAD'], { cwd, stdio: 'ignore' }); + return true; + } catch { + return false; + } +} + +// Измеряемое дерево обязано совпадать с PRODUCT_BASE везде, кроме самих +// файлов preregistration-пакета. Это позволяет снимать old-vector на +// PR-ветке, выросшей из PRODUCT_BASE, с доказанной эквивалентностью. +const PREREG_OWN_PATHS = Object.freeze([ + 'bench/profile/profile-01-preregistration.mjs', + 'bench/profile/probe-profile-01.mjs', + 'bench/profile/validate-profile-01.mjs', + '.github/workflows/profile-01.yml', +]); + +function runSizeGate(repoRoot) { + // scripts/size-gate.mjs не имеет --json: сырьём является точный stdout + // плюс exit code. Требует собранный dist (pnpm build) — только self-hosted. + try { + const transcript = execFileSync('node', ['scripts/size-gate.mjs'], { + cwd: repoRoot, + encoding: 'utf8', + timeout: 20 * 60 * 1000, + maxBuffer: 64 * 1024 * 1024, + }); + return { exitCode: 0, transcript }; + } catch (error) { + return { exitCode: error?.status ?? 1, transcript: String(error?.stdout ?? error?.message ?? error) }; + } +} + +function main() { + const repoRoot = resolve(join(new URL('.', import.meta.url).pathname, '..', '..')); + 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'; + const outDir = resolve(arg('--out') ?? join(tmpdir(), 'profile-01-raw')); + mkdirSync(outDir, { recursive: true }); + + // 1. Frozen-контракт обязан проходить самопроверку до любых samples. + verifyPreregistration(PROFILE_01); + const digest = preregistrationDigest(PROFILE_01); + + // 2. Точный base/provenance. + const head = gitHead(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: [], + rawControls: {}, + calibration: {}, + costVector: null, + admission: 'NOT-GRANTED', + }; + + if (mode === 'old-vector') { + const base = PROFILE_01.productBase.mainSha; + if (!isAncestor(repoRoot, base)) fail(`old-vector требует HEAD, выросший из PRODUCT_BASE ${base}`); + const sizeGateBlob = gitBlob(repoRoot, 'HEAD', 'scripts/size-gate.mjs'); + if (sizeGateBlob !== '4b0f181212b65a881e750e84564778f5828448a3') { + fail(`size-gate provenance drifted: ${sizeGateBlob}`); + } + const diffPaths = head === base ? [] : gitDiffNames(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 }; + artifact.costVector = runSizeGate(repoRoot); + if (artifact.costVector.exitCode !== 0) { + fail(`старый cost vector не зелёный на PRODUCT_BASE (exit ${artifact.costVector.exitCode})`); + } + for (const [name, gate] of Object.entries(PROFILE_01.oldCostVectorGzipBytes.scenarios)) { + if (!artifact.costVector.transcript.includes(`${name} `) && !artifact.costVector.transcript.includes(name)) { + fail(`транскрипт size-gate не содержит сценарий ${name}`); + } + } + artifact.cellsMeasured.push('desktop-size-vector'); + } + + // 3. Клетки устройств: измеряется только прикреплённое железо. + // Физические Android/iOS без device-lab конфига — affected cells, не success. + const deviceLab = process.env.PROFILE_01_DEVICE_LAB ?? ''; + if (cells === 'all' && deviceLab === '') { + for (const cell of PROFILE_01.affectedCellsMissingHw) { + artifact.cellsUnproven.push({ cell: cell.cell, reason: 'no attached device lab; affected cell, admission blocked', blocks: cell.blocks }); + } + artifact.cellsUnproven.push({ cell: 'android-mid-60', reason: 'no attached device lab', blocks: 'A/B admission Android-клеток' }); + artifact.cellsUnproven.push({ cell: 'ios-60', reason: 'no attached device lab', blocks: 'A/B admission iOS-клеток' }); + } + + // 4. Калибровка: A/A и deliberate 2×work обязаны быть явными. + // Детализация timing-калибровки — в browser-фазе на self-hosted runner; + // без пройденной калибровки admission не выдаётся (см. ниже). + artifact.calibration = { aa: 'PENDING', positive2x: 'PENDING' }; + + // 5. Fail-closed итог: без зелёной калибровки admission запрещён. + const ready = mode === 'old-vector' && artifact.costVector !== null; + artifact.finishedAtUtc = new Date().toISOString(); + artifact.admission = ready ? 'OLD-VECTOR-ONLY' : 'NOT-GRANTED'; + if (mode !== 'old-vector') { + fail('aa/ab режимы требуют зелёной browser-калибровки на self-hosted runner; локальный запуск запрещён'); + } + + const rawPath = join(outDir, `profile-01-${mode}-${head.slice(0, 12)}.json`); + writeFileSync(rawPath, `${JSON.stringify(artifact, null, 2)}\n`); + const rawDigest = createHash('sha256').update(JSON.stringify(artifact)).digest('hex'); + // eslint-disable-next-line no-console + console.log(JSON.stringify({ rawPath, rawDigest, preregistrationDigest: digest, admission: artifact.admission })); +} + +main(); diff --git a/bench/profile/profile-01-preregistration.mjs b/bench/profile/profile-01-preregistration.mjs new file mode 100644 index 000000000..3facce3af --- /dev/null +++ b/bench/profile/profile-01-preregistration.mjs @@ -0,0 +1,214 @@ +// PROFILE-01: замороженная preregistration измерительного протокола. +// Действует ДО любых candidate samples. Любое измерение, проведённое без +// этой регистрации, не является доказательством и не допускается к admission. +// Файл bench-only: production source, exports, бюджеты и гейты не меняются. +// Неизвестное железо — отдельная affected cell (UNPROVEN), а не удачный профиль +// после результата. Повтор опыта до green запрещён. + +import { createHash } from 'node:crypto'; + +function invariant(condition, message) { + if (!condition) throw new Error(`PROFILE-01 preregistration: ${message}`); +} + +// Точный продуктовый base, на котором зафиксирован старый cost vector. +export const PRODUCT_BASE = Object.freeze({ + repo: 'Labpics-Team/lab-motion', + mainSha: '0fb23264a93a18a8242fd15a2375e9f75845dbdf', + mergeOf: 'PR #435 (squash), head c0bbba720d058042f08f0f37f7b14401db67da56', + mergeBase: 'f60acc91d63549758cbd75fa8c99ab06c79989bb', + mergedAtUtc: '2026-09-28T21:58:33Z', +}); + +// Полный старый cost vector: потолки кода, а не новые оценки. +// Provenance: scripts/size-gate.mjs, blob 4b0f181212b65a881e750e84564778f5828448a3 +// на PRODUCT_BASE. Архивные фактические размеры новым измерением не являются: +// метод повторного снятия — 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; знаменатели не смешиваются', +}); + +// Roster классов устройств/браузеров/сцен по r11 §PROFILE-01. +// Конкретные модели/OS/browser builds выбираются из доступного +// репрезентативного inventory ДО candidate results и замораживаются +// в первом sample-артефакте. Здесь — классы и правило отбора, а не +// выдуманные модели. Флагман не заменяет mid-range. +export const ROSTER = Object.freeze({ + classes: Object.freeze([ + Object.freeze({ id: 'android-mid-60', device: 'physical mid-range Android', refreshHz: 60, browsers: ['chromium-webview-stable'], status: 'UNPROVEN' }), + Object.freeze({ id: 'android-mid-120', device: 'physical mid-range Android with 120 Hz display', refreshHz: 120, browsers: ['chromium-webview-stable'], status: 'UNPROVEN' }), + Object.freeze({ id: 'ios-60', device: 'physical iOS', refreshHz: 60, browsers: ['webkit-stable'], status: 'UNPROVEN' }), + Object.freeze({ id: 'ios-120', device: 'physical iOS with ProMotion', refreshHz: 120, browsers: ['webkit-stable'], status: 'UNPROVEN' }), + Object.freeze({ id: 'desktop-chromium', device: 'desktop Linux self-hosted runner', refreshHz: null, browsers: ['chromium-stable'], status: 'UNPROVEN' }), + Object.freeze({ id: 'desktop-firefox', device: 'desktop Linux self-hosted runner', refreshHz: null, browsers: ['firefox-stable'], status: 'UNPROVEN' }), + Object.freeze({ id: 'desktop-webkit', device: 'desktop Linux self-hosted runner', refreshHz: null, browsers: ['webkit-stable'], status: 'UNPROVEN' }), + ]), + selectionRule: 'конкретные модели/OS builds — первые доступные репрезентативные из inventory self-hosted парка на момент первого sample; выбор фиксируется в артефакте и не меняется после candidate results', + no120HzRule: 'когда device не умеет 120 Hz, 120 Hz target ему не приписывается, но 60 Hz задача остаётся', + cpuThrottleNote: 'CPU-throttle desktop — диагностический контроль, не mobile proof; порог M-04 не выводится из быстрого сервера', + fixedConditions: Object.freeze([ + 'thermal/power/display state', 'workloads', 'backgrounds', 'viewport/DPR/content', + 'clocks', 'warm/cold режимы', 'sampling units', 'N/iterations/seed', 'все знаменатели', + ]), +}); + +// Missing hardware — отдельные affected cells. Ячейка без устройства остаётся +// 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 full-device cost claims; 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 с нулевой стоимостью отношение не вычисляется: требуется совпадение границы и выигрыш другой содержательной метрики', +}); + +// Raw controls: каждый A/B обязан сопровождаться полным набором. +export const RAW_CONTROLS = Object.freeze({ + noMotion: 'no-motion still control (статический кадр обязан совпадать попиксельно с точностью oracle)', + reducedMotion: 'reduced-motion control (частые действия имеют no-motion path)', + waapi: 'raw platform control (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 идеи', +}); + +// Observation policy: сохраняется всё, failures не удаляются. +export const OBSERVATION_POLICY = Object.freeze({ + preserve: Object.freeze(['все samples', 'failures', 'stalls', 'GC/JIT/context switches', 'malformed receipts']), + 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 — свои знаменатели', +}); + +// Fail-closed калибровка: невалидная калибровка = candidate admission не запускается. +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 = Object.freeze({ + 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, число +// потолков/сценариев, fail-closed флаги. Вызывается пробой до любых samples. +export function verifyPreregistration(value = PROFILE_01) { + invariant(value && typeof value === 'object', 'контракт обязан быть объектом'); + invariant(value.candidateSamplesObservedAtRegistration === false, 'preregistration обязана предшествовать samples'); + invariant(value.productBase?.mainSha === '0fb23264a93a18a8242fd15a2375e9f75845dbdf', 'product base drifted'); + const gates = value.oldCostVectorGzipBytes; + invariant(gates?.nano === 1024 && gates?.compiledRuntime === 341 && gates?.compilerSurface === 1024, 'старые 1024/341/1024 ceilings drifted'); + invariant(gates?.fullAnimate === 15600 && gates?.animateCompositorMixed === 17500, 'full/mixed consumer ceilings drifted'); + invariant(Object.keys(gates?.bespoke ?? {}).length === 15, 'bespoke subpath gates drifted'); + invariant(Object.keys(gates?.scenarios ?? {}).length === 15, 'consumer scenario gates drifted'); + invariant((value.roster?.classes ?? []).length === 7, 'roster обязан покрывать 7 классов'); + invariant((value.affectedCellsMissingHw ?? []).length === 3, 'missing-HW affected cells drifted'); + invariant(typeof value.scenes?.m05a?.id === 'string' && typeof value.scenes?.m05b?.id === 'string', 'M-05 сцены обязаны быть заморожены'); + invariant(value.calibration?.failClosed !== undefined, 'fail-closed калибровка обязана быть явной'); + return true; +} diff --git a/bench/profile/validate-profile-01.mjs b/bench/profile/validate-profile-01.mjs new file mode 100644 index 000000000..90b2fbc06 --- /dev/null +++ b/bench/profile/validate-profile-01.mjs @@ -0,0 +1,79 @@ +// PROFILE-01 validate: независимая перепроверка raw-артефакта пробы. +// Ловит согласованные по виду, но неверные elapsed/divisor/missing sample, +// подмену preregistration после samples и дрейф provenance. +// Использование: node bench/profile/validate-profile-01.mjs --raw + +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { + PROFILE_01, + preregistrationDigest, + verifyPreregistration, +} from './profile-01-preregistration.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]; +} + +function main() { + const rawPath = resolve(arg('--raw') ?? fail('требуется --raw ')); + const artifact = JSON.parse(readFileSync(rawPath, 'utf8')); + + // 1. Замороженный контракт не менялся. + verifyPreregistration(PROFILE_01); + const frozen = preregistrationDigest(PROFILE_01); + if (artifact.preregistrationDigest !== frozen) { + fail(`preregistration подменена после samples: artifact ${artifact.preregistrationDigest}, frozen ${frozen}`); + } + if (artifact.candidateSamplesObservedAtRegistration !== false) { + fail('preregistration обязана предшествовать samples'); + } + + // 2. Provenance exact base: HEAD обязан быть PRODUCT_BASE либо его + // потомком с диффом только внутри prereg-пакета. + const OWN_PATHS = [ + 'bench/profile/profile-01-preregistration.mjs', + 'bench/profile/probe-profile-01.mjs', + 'bench/profile/validate-profile-01.mjs', + '.github/workflows/profile-01.yml', + ]; + if (artifact.mode === 'old-vector') { + const base = PROFILE_01.productBase.mainSha; + 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 foreign = proof.diffPaths.filter((path) => !OWN_PATHS.includes(path)); + if (foreign.length > 0) fail(`дерево отличается от PRODUCT_BASE вне prereg-пакета: ${foreign.join(', ')}`); + if (artifact.sizeGateBlob !== '4b0f181212b65a881e750e84564778f5828448a3') { + fail(`size-gate provenance drifted: ${artifact.sizeGateBlob}`); + } + if (!artifact.costVector || artifact.costVector.exitCode !== 0) fail('old-vector без зелёного costVector'); + } + + // 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}`); + } + + // 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 (artifact.seed !== 20260929) fail('seed drifted'); + + // eslint-disable-next-line no-console + console.log(JSON.stringify({ valid: true, mode: artifact.mode, admission: artifact.admission })); +} + +main(); From 52205a5b9ca34d36ec6dc54db79d0fbbf5354432 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Tue, 29 Sep 2026 03:54:15 +0300 Subject: [PATCH 02/12] bench(profile): close review findings on prereg packet --- .github/workflows/profile-01.yml | 4 + bench/profile/probe-profile-01.mjs | 99 +++++++++----------- bench/profile/profile-01-preregistration.mjs | 60 +++++++++++- bench/profile/profile-git-proof.mjs | 58 ++++++++++++ bench/profile/validate-profile-01.mjs | 53 ++++++++--- 5 files changed, 201 insertions(+), 73 deletions(-) create mode 100644 bench/profile/profile-git-proof.mjs diff --git a/.github/workflows/profile-01.yml b/.github/workflows/profile-01.yml index d75fd92d0..ca05db54f 100644 --- a/.github/workflows/profile-01.yml +++ b/.github/workflows/profile-01.yml @@ -90,6 +90,10 @@ jobs: done - name: Опубликовать raw-артефакт + # if: always() — failure-артефакты (NOT-GRANTED с rejection) обязаны + # загружаться даже после failed probe ([1]): OBSERVATION_POLICY + # требует сохранять failures, иначе отрицательный результат теряется. + if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: name: profile-01-raw-${{ github.sha }} diff --git a/bench/profile/probe-profile-01.mjs b/bench/profile/probe-profile-01.mjs index a5eb9c357..97af1d445 100644 --- a/bench/profile/probe-profile-01.mjs +++ b/bench/profile/probe-profile-01.mjs @@ -15,6 +15,7 @@ import { preregistrationDigest, verifyPreregistration, } from './profile-01-preregistration.mjs'; +import { PREREG_OWN_PATHS, makeGit } from './profile-git-proof.mjs'; function fail(message) { throw new Error(`PROFILE-01 probe (fail-closed): ${message}`); @@ -25,49 +26,7 @@ function arg(name) { return index === -1 ? undefined : process.argv[index + 1]; } -function gitHead(cwd) { - try { - return execFileSync('git', ['rev-parse', 'HEAD'], { cwd, encoding: 'utf8' }).trim(); - } catch { - fail('git недоступен для доказательства provenance'); - } -} - -function gitBlob(cwd, rev, path) { - try { - return execFileSync('git', ['rev-parse', `${rev}:${path}`], { cwd, encoding: 'utf8' }).trim(); - } catch { - fail(`git не смог доказать blob ${path}@${rev}`); - } -} - -function gitDiffNames(cwd, base, head) { - try { - const output = execFileSync('git', ['diff', '--name-only', `${base}`, `${head}`], { cwd, encoding: 'utf8' }); - return output.split('\n').map((line) => line.trim()).filter(Boolean); - } catch { - fail(`git не смог доказать эквивалентность дерева ${base}..${head}`); - } -} - -function isAncestor(cwd, base) { - try { - execFileSync('git', ['merge-base', '--is-ancestor', base, 'HEAD'], { cwd, stdio: 'ignore' }); - return true; - } catch { - return false; - } -} - -// Измеряемое дерево обязано совпадать с PRODUCT_BASE везде, кроме самих -// файлов preregistration-пакета. Это позволяет снимать old-vector на -// PR-ветке, выросшей из PRODUCT_BASE, с доказанной эквивалентностью. -const PREREG_OWN_PATHS = Object.freeze([ - 'bench/profile/profile-01-preregistration.mjs', - 'bench/profile/probe-profile-01.mjs', - 'bench/profile/validate-profile-01.mjs', - '.github/workflows/profile-01.yml', -]); +const git = makeGit(fail); function runSizeGate(repoRoot) { // scripts/size-gate.mjs не имеет --json: сырьём является точный stdout @@ -85,6 +44,26 @@ function runSizeGate(repoRoot) { } } +function writeArtifact(outDir, artifact, head, digest) { + const rawPath = join(outDir, `profile-01-${artifact.mode}-${head.slice(0, 12)}.json`); + writeFileSync(rawPath, `${JSON.stringify(artifact, null, 2)}\n`); + const rawDigest = createHash('sha256').update(JSON.stringify(artifact)).digest('hex'); + // eslint-disable-next-line no-console + console.log(JSON.stringify({ rawPath, rawDigest, preregistrationDigest: digest, admission: artifact.admission })); +} + +// Post-measurement отказ обязан сначала Persist артефакт с причиной +// (OBSERVATION_POLICY: failures сохраняются с digest), затем fail. +// Pre-measurement отказы (ancestry/blob/diff до runSizeGate) остаются +// fail-fast без артефакта: измерения ещё не было. +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); +} + function main() { const repoRoot = resolve(join(new URL('.', import.meta.url).pathname, '..', '..')); const mode = arg('--mode') ?? fail('требуется --mode old-vector|aa|ab'); @@ -98,7 +77,7 @@ function main() { const digest = preregistrationDigest(PROFILE_01); // 2. Точный base/provenance. - const head = gitHead(repoRoot); + const head = git.head(repoRoot); const artifact = { node: 'PROFILE-01', revision: 'r11', @@ -117,27 +96,34 @@ function main() { calibration: {}, costVector: null, admission: 'NOT-GRANTED', + rejection: null, }; if (mode === 'old-vector') { const base = PROFILE_01.productBase.mainSha; - if (!isAncestor(repoRoot, base)) fail(`old-vector требует HEAD, выросший из PRODUCT_BASE ${base}`); - const sizeGateBlob = gitBlob(repoRoot, 'HEAD', 'scripts/size-gate.mjs'); + if (!git.ancestor(repoRoot, base)) fail(`old-vector требует HEAD, выросший из PRODUCT_BASE ${base}`); + const sizeGateBlob = git.blob(repoRoot, 'HEAD', 'scripts/size-gate.mjs'); if (sizeGateBlob !== '4b0f181212b65a881e750e84564778f5828448a3') { fail(`size-gate provenance drifted: ${sizeGateBlob}`); } - const diffPaths = head === base ? [] : gitDiffNames(repoRoot, base, head); + // [0] Рабочая копия обязана совпадать с коммитом: иначе runSizeGate + // исполнит непроверенный файл, а 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 }; artifact.costVector = runSizeGate(repoRoot); if (artifact.costVector.exitCode !== 0) { - fail(`старый cost vector не зелёный на PRODUCT_BASE (exit ${artifact.costVector.exitCode})`); + persistAndFail(outDir, artifact, head, digest, `старый cost vector не зелёный на PRODUCT_BASE (exit ${artifact.costVector.exitCode})`); } for (const [name, gate] of Object.entries(PROFILE_01.oldCostVectorGzipBytes.scenarios)) { if (!artifact.costVector.transcript.includes(`${name} `) && !artifact.costVector.transcript.includes(name)) { - fail(`транскрипт size-gate не содержит сценарий ${name}`); + persistAndFail(outDir, artifact, head, digest, `транскрипт size-gate не содержит сценарий ${name}`); } } artifact.cellsMeasured.push('desktop-size-vector'); @@ -160,18 +146,19 @@ function main() { artifact.calibration = { aa: 'PENDING', positive2x: 'PENDING' }; // 5. Fail-closed итог: без зелёной калибровки admission запрещён. + // Отклонённые aa/ab обязаны Persist failure-артефакт до fail ([2]): + // OBSERVATION_POLICY требует сохранять failures с digest. + if (mode !== 'old-vector') { + persistAndFail(outDir, artifact, head, digest, 'aa/ab режимы требуют зелёной browser-калибровки на self-hosted runner; локальный запуск запрещён'); + } const ready = mode === 'old-vector' && artifact.costVector !== null; artifact.finishedAtUtc = new Date().toISOString(); artifact.admission = ready ? 'OLD-VECTOR-ONLY' : 'NOT-GRANTED'; - if (mode !== 'old-vector') { - fail('aa/ab режимы требуют зелёной browser-калибровки на self-hosted runner; локальный запуск запрещён'); + if (!ready) { + persistAndFail(outDir, artifact, head, digest, 'old-vector не готов: costVector отсутствует'); } - const rawPath = join(outDir, `profile-01-${mode}-${head.slice(0, 12)}.json`); - writeFileSync(rawPath, `${JSON.stringify(artifact, null, 2)}\n`); - const rawDigest = createHash('sha256').update(JSON.stringify(artifact)).digest('hex'); - // eslint-disable-next-line no-console - console.log(JSON.stringify({ rawPath, rawDigest, preregistrationDigest: digest, admission: artifact.admission })); + writeArtifact(outDir, artifact, head, digest); } main(); diff --git a/bench/profile/profile-01-preregistration.mjs b/bench/profile/profile-01-preregistration.mjs index 3facce3af..6273bf50c 100644 --- a/bench/profile/profile-01-preregistration.mjs +++ b/bench/profile/profile-01-preregistration.mjs @@ -195,20 +195,70 @@ export function preregistrationDigest(value = PROFILE_01) { return createHash('sha256').update(JSON.stringify(value)).digest('hex'); } -// Самопроверка замороженного контракта: структура, классы roster, число -// потолков/сценариев, fail-closed флаги. Вызывается пробой до любых samples. +// Самопроверка замороженного контракта: структура, ТОЧНЫЕ ЗНАЧЕНИЯ +// потолков/сценариев/roster/scenes, fail-closed флаги. Вызывается пробой +// до любых samples. Проверяются именно значения, а не только число ключей: +// частичная проверка (только counts) пропускает тихую подмену значений. +// Якорь заморозки — НЕ этот файл сам по себе, а связка: git ancestry +// (probe доказывает HEAD из PRODUCT_BASE) + exact-head review + CI на +// exact head. Отдельный .sha256-sidecar в том же PR отвергнут сознательно: +// файл в том же trust-domain не добавляет независимости — подмена обновила +// бы оба файла разом. Независимость даёт git-история и ревью, а не второй +// файл рядом. export function verifyPreregistration(value = PROFILE_01) { invariant(value && typeof value === 'object', 'контракт обязан быть объектом'); + invariant(value.node === 'PROFILE-01' && value.revision === 'r11', 'node/revision drifted'); invariant(value.candidateSamplesObservedAtRegistration === false, 'preregistration обязана предшествовать samples'); + invariant(value.productBase?.repo === 'Labpics-Team/lab-motion', 'product repo drifted'); invariant(value.productBase?.mainSha === '0fb23264a93a18a8242fd15a2375e9f75845dbdf', 'product base drifted'); + invariant(value.productBase?.mergedAtUtc === '2026-09-28T21:58:33Z', 'product base время drifted'); const gates = value.oldCostVectorGzipBytes; + invariant(gates?.core === 2220 && gates?.subpath === 4608 && gates?.fullCoreConsumer === 2330, 'core/subpath/fullCore ceilings drifted'); invariant(gates?.nano === 1024 && gates?.compiledRuntime === 341 && gates?.compilerSurface === 1024, 'старые 1024/341/1024 ceilings drifted'); + invariant(gates?.inView === 1839 && gates?.inViewConsumer === 1908, 'in-view ceilings drifted'); + invariant(gates?.compositorCapability === 6600, 'compositor capability ceiling drifted'); invariant(gates?.fullAnimate === 15600 && gates?.animateCompositorMixed === 17500, 'full/mixed consumer ceilings drifted'); + const bespokeExpected = { + './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, + }; + for (const [key, expected] of Object.entries(bespokeExpected)) { + invariant(gates?.bespoke?.[key] === expected, `bespoke gate ${key} drifted`); + } invariant(Object.keys(gates?.bespoke ?? {}).length === 15, 'bespoke subpath gates drifted'); + const scenariosExpected = { + '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, + }; + for (const [key, expected] of Object.entries(scenariosExpected)) { + invariant(gates?.scenarios?.[key] === expected, `scenario gate ${key} drifted`); + } invariant(Object.keys(gates?.scenarios ?? {}).length === 15, 'consumer scenario gates drifted'); - invariant((value.roster?.classes ?? []).length === 7, 'roster обязан покрывать 7 классов'); + const rosterIds = (value.roster?.classes ?? []).map((entry) => entry?.id); + invariant(JSON.stringify(rosterIds) === JSON.stringify(['android-mid-60', 'android-mid-120', 'ios-60', 'ios-120', 'desktop-chromium', 'desktop-firefox', 'desktop-webkit']), 'roster классы drifted'); invariant((value.affectedCellsMissingHw ?? []).length === 3, 'missing-HW affected cells drifted'); - invariant(typeof value.scenes?.m05a?.id === 'string' && typeof value.scenes?.m05b?.id === 'string', 'M-05 сцены обязаны быть заморожены'); - invariant(value.calibration?.failClosed !== undefined, 'fail-closed калибровка обязана быть явной'); + invariant(value.scenes?.m04channels?.id === 'm04-100-scalar-channels' && value.scenes?.m04channels?.channels === 100, 'M-04 channels сцена drifted'); + invariant(value.scenes?.m04full120?.id === 'm04-full-120hz', 'M-04 120Hz сцена drifted'); + invariant(value.scenes?.m05a?.id === 'm05-sheet' && typeof value.scenes?.m05a?.family === 'string', 'M-05 sheet сцена drifted'); + invariant(value.scenes?.m05b?.id === 'm05-list' && typeof value.scenes?.m05b?.family === 'string', 'M-05 list сцена drifted'); + for (const key of ['noMotion', 'reducedMotion', 'waapi', 'oldLab', 'bestComparator', 'aa', 'positive']) { + invariant(typeof value.rawControls?.[key] === 'string', `raw control ${key} отсутствует`); + } + for (const key of ['independenceUnit', 'design', 'sampleSize', 'seed', 'stoppingRule', 'coverage', 'noRepeatToGreen']) { + invariant(typeof value.statsMde?.[key] === 'string', `stats/MDE ${key} отсутствует`); + } + for (const key of ['preserve', 'rawAndDigest', 'retention', 'forcedGc', 'denominators']) { + invariant(value.observationPolicy?.[key] !== undefined, `observation policy ${key} отсутствует`); + } + for (const key of ['aaBand', 'positiveDetection', 'failClosed', 'staleProfile']) { + invariant(typeof value.calibration?.[key] === 'string', `calibration ${key} отсутствует`); + } return true; } diff --git a/bench/profile/profile-git-proof.mjs b/bench/profile/profile-git-proof.mjs new file mode 100644 index 000000000..a303556ae --- /dev/null +++ b/bench/profile/profile-git-proof.mjs @@ -0,0 +1,58 @@ +// PROFILE-01 git-proof: общие git-доказательства provenance для probe и validator. +// Один источник PREREG_OWN_PATHS исключает дрейф allowlist между файлами. +// Fail-closed: любая недоступность git превращается в отказ admission через +// переданный fail-колбэк вызывающей стороны, а не в молчаливый пропуск. + +import { execFileSync } from 'node:child_process'; + +// Измеряемое дерево обязано совпадать с 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', + '.github/workflows/profile-01.yml', +]); + +export function makeGit(fail) { + const head = (cwd) => { + try { + return execFileSync('git', ['rev-parse', 'HEAD'], { cwd, encoding: 'utf8' }).trim(); + } catch { + fail('git недоступен для доказательства provenance'); + } + }; + const blob = (cwd, rev, path) => { + try { + return execFileSync('git', ['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', ['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', ['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/validate-profile-01.mjs b/bench/profile/validate-profile-01.mjs index 90b2fbc06..e76ad5934 100644 --- a/bench/profile/validate-profile-01.mjs +++ b/bench/profile/validate-profile-01.mjs @@ -4,12 +4,13 @@ // Использование: node bench/profile/validate-profile-01.mjs --raw import { readFileSync } from 'node:fs'; -import { resolve } from 'node:path'; +import { join, resolve } from 'node:path'; import { PROFILE_01, preregistrationDigest, verifyPreregistration, } from './profile-01-preregistration.mjs'; +import { PREREG_OWN_PATHS, makeGit } from './profile-git-proof.mjs'; function fail(message) { throw new Error(`PROFILE-01 validate (fail-closed): ${message}`); @@ -34,25 +35,44 @@ function main() { fail('preregistration обязана предшествовать samples'); } - // 2. Provenance exact base: HEAD обязан быть PRODUCT_BASE либо его - // потомком с диффом только внутри prereg-пакета. - const OWN_PATHS = [ - 'bench/profile/profile-01-preregistration.mjs', - 'bench/profile/probe-profile-01.mjs', - 'bench/profile/validate-profile-01.mjs', - '.github/workflows/profile-01.yml', - ]; + // 2. Provenance exact base: независимое git-перевычисление ([4]). + // Валидатор не доверяет полям raw JSON: HEAD, ancestry, diff и blob + // пересчитываются из checkout, в котором запущен валидатор. + // Transcript size-gate — только диагностическое поле: проверяются + // exitCode, непустота и присутствие имён сценариев, побайтового + // сравнения с новым запуском нет (измерения зависят от build/runtime + // окружения; независимый re-run — отдельная dispatch-проба). + const SIZE_GATE_FROZEN = '4b0f181212b65a881e750e84564778f5828448a3'; if (artifact.mode === 'old-vector') { const base = PROFILE_01.productBase.mainSha; 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 foreign = proof.diffPaths.filter((path) => !OWN_PATHS.includes(path)); + const repoRoot = resolve(join(new URL('.', import.meta.url).pathname, '..', '..')); + 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(', ')}`); - if (artifact.sizeGateBlob !== '4b0f181212b65a881e750e84564778f5828448a3') { - fail(`size-gate provenance drifted: ${artifact.sizeGateBlob}`); + const committed = git.blob(repoRoot, artifact.head, 'scripts/size-gate.mjs'); + if (committed !== SIZE_GATE_FROZEN || committed !== artifact.sizeGateBlob) { + fail(`size-gate provenance drifted: artifact ${artifact.sizeGateBlob}, git ${committed}`); } if (!artifact.costVector || artifact.costVector.exitCode !== 0) fail('old-vector без зелёного costVector'); + if (typeof artifact.costVector.transcript !== 'string' || artifact.costVector.transcript.length === 0) { + fail('costVector.transcript обязан быть непустой диагностикой'); + } + for (const name of Object.keys(PROFILE_01.oldCostVectorGzipBytes.scenarios)) { + if (!artifact.costVector.transcript.includes(name)) fail(`транскрипт не содержит сценарий ${name}`); + } } // 3. A/B без калибровки не существует. @@ -62,6 +82,15 @@ function main() { 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 обязан быть списком'); From 1d8d7b626c8882709f591772d9b44eeac449f32b Mon Sep 17 00:00:00 2001 From: Claude Code Date: Wed, 30 Sep 2026 11:23:03 +0300 Subject: [PATCH 03/12] fix(profile): bind baseline to current source before measurement --- bench/profile/probe-profile-01.mjs | 3 ++- bench/profile/profile-01-preregistration.mjs | 12 ++++++------ bench/profile/validate-profile-01.mjs | 3 ++- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/bench/profile/probe-profile-01.mjs b/bench/profile/probe-profile-01.mjs index 97af1d445..96d841abb 100644 --- a/bench/profile/probe-profile-01.mjs +++ b/bench/profile/probe-profile-01.mjs @@ -10,6 +10,7 @@ import { execFileSync } from 'node:child_process'; 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, @@ -65,7 +66,7 @@ function persistAndFail(outDir, artifact, head, digest, reason) { } function main() { - const repoRoot = resolve(join(new URL('.', import.meta.url).pathname, '..', '..')); + 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'; diff --git a/bench/profile/profile-01-preregistration.mjs b/bench/profile/profile-01-preregistration.mjs index 6273bf50c..97d07abd5 100644 --- a/bench/profile/profile-01-preregistration.mjs +++ b/bench/profile/profile-01-preregistration.mjs @@ -14,10 +14,10 @@ function invariant(condition, message) { // Точный продуктовый base, на котором зафиксирован старый cost vector. export const PRODUCT_BASE = Object.freeze({ repo: 'Labpics-Team/lab-motion', - mainSha: '0fb23264a93a18a8242fd15a2375e9f75845dbdf', - mergeOf: 'PR #435 (squash), head c0bbba720d058042f08f0f37f7b14401db67da56', - mergeBase: 'f60acc91d63549758cbd75fa8c99ab06c79989bb', - mergedAtUtc: '2026-09-28T21:58:33Z', + mainSha: '0912acd875a67bed8f165e345626fd00082e258e', + mergeOf: 'PR #390; current main before PROFILE measurements', + registeredFrom: '0fb23264a93a18a8242fd15a2375e9f75845dbdf', + mergedAtUtc: '2026-09-29T17:24:51Z', }); // Полный старый cost vector: потолки кода, а не новые оценки. @@ -210,8 +210,8 @@ export function verifyPreregistration(value = PROFILE_01) { invariant(value.node === 'PROFILE-01' && value.revision === 'r11', 'node/revision drifted'); invariant(value.candidateSamplesObservedAtRegistration === false, 'preregistration обязана предшествовать samples'); invariant(value.productBase?.repo === 'Labpics-Team/lab-motion', 'product repo drifted'); - invariant(value.productBase?.mainSha === '0fb23264a93a18a8242fd15a2375e9f75845dbdf', 'product base drifted'); - invariant(value.productBase?.mergedAtUtc === '2026-09-28T21:58:33Z', 'product base время drifted'); + invariant(value.productBase?.mainSha === '0912acd875a67bed8f165e345626fd00082e258e', 'product base drifted'); + invariant(value.productBase?.mergedAtUtc === '2026-09-29T17:24:51Z', 'product base время drifted'); const gates = value.oldCostVectorGzipBytes; invariant(gates?.core === 2220 && gates?.subpath === 4608 && gates?.fullCoreConsumer === 2330, 'core/subpath/fullCore ceilings drifted'); invariant(gates?.nano === 1024 && gates?.compiledRuntime === 341 && gates?.compilerSurface === 1024, 'старые 1024/341/1024 ceilings drifted'); diff --git a/bench/profile/validate-profile-01.mjs b/bench/profile/validate-profile-01.mjs index e76ad5934..61298b82c 100644 --- a/bench/profile/validate-profile-01.mjs +++ b/bench/profile/validate-profile-01.mjs @@ -5,6 +5,7 @@ import { readFileSync } from 'node:fs'; import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { PROFILE_01, preregistrationDigest, @@ -48,7 +49,7 @@ function main() { 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 = resolve(join(new URL('.', import.meta.url).pathname, '..', '..')); + const repoRoot = fileURLToPath(new URL('../../', import.meta.url)); const git = makeGit(fail); const currentHead = git.head(repoRoot); if (currentHead !== artifact.head) { From 667cfad1d0de2c4db1cf95074d1c43161308bb35 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Wed, 30 Sep 2026 11:49:25 +0300 Subject: [PATCH 04/12] chore(deps): patch mutation-tool transitive advisories --- pnpm-lock.yaml | 20 ++++++++++---------- pnpm-workspace.yaml | 4 ++-- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0ffe51657..5eb28a0c3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,10 +5,10 @@ settings: excludeLinksFromLockfile: false overrides: - brace-expansion@<=5.0.7: ^5.0.8 + brace-expansion@<5.0.12: ^5.0.12 browserslist: ^4.28.7 esbuild: ^0.28.1 - fast-uri: ^3.1.7 + fast-uri: ^3.1.8 postcss@<=8.5.17: ^8.5.18 qs: ^6.16.0 nanoid@<3.3.18: ^3.3.18 @@ -1247,8 +1247,8 @@ packages: bidi-js@1.0.3: resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} - brace-expansion@5.0.9: - resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==} + brace-expansion@5.0.12: + resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==} engines: {node: 20 || >=22} browserslist@4.28.8: @@ -1440,8 +1440,8 @@ packages: fast-string-width@3.0.2: resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} - fast-uri@3.1.7: - resolution: {integrity: sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==} + fast-uri@3.1.8: + resolution: {integrity: sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==} fast-wrap-ansi@0.2.2: resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} @@ -3207,7 +3207,7 @@ snapshots: ajv@8.18.0: dependencies: fast-deep-equal: 3.1.3 - fast-uri: 3.1.7 + fast-uri: 3.1.8 json-schema-traverse: 1.0.0 require-from-string: 2.0.2 @@ -3229,7 +3229,7 @@ snapshots: dependencies: require-from-string: 2.0.2 - brace-expansion@5.0.9: + brace-expansion@5.0.12: dependencies: balanced-match: 4.0.4 @@ -3417,7 +3417,7 @@ snapshots: dependencies: fast-string-truncated-width: 3.0.3 - fast-uri@3.1.7: {} + fast-uri@3.1.8: {} fast-wrap-ansi@0.2.2: dependencies: @@ -3645,7 +3645,7 @@ snapshots: minimatch@10.2.5: dependencies: - brace-expansion: 5.0.9 + brace-expansion: 5.0.12 mlly@1.8.2: dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index d6e6fb5a0..67a575793 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,10 +2,10 @@ allowBuilds: esbuild: true minimumReleaseAge: 1440 overrides: - brace-expansion@<=5.0.7: ^5.0.8 + brace-expansion@<5.0.12: ^5.0.12 browserslist: ^4.28.7 esbuild: ^0.28.1 - fast-uri: ^3.1.7 + fast-uri: ^3.1.8 postcss@<=8.5.17: ^8.5.18 qs: ^6.16.0 nanoid@<3.3.18: ^3.3.18 From 5fb003fb4abf3a780f55fa91fd292fb6e57c53cb Mon Sep 17 00:00:00 2001 From: Claude Code Date: Wed, 30 Sep 2026 11:49:59 +0300 Subject: [PATCH 05/12] fix(profile): independently replay the baseline cost vector --- .github/workflows/profile-01.yml | 18 +++-- bench/profile/probe-profile-01.mjs | 59 ++++++---------- bench/profile/profile-01-preregistration.mjs | 10 +++ bench/profile/profile-git-proof.mjs | 13 ++-- bench/profile/profile-measurement.mjs | 50 ++++++++++++++ bench/profile/validate-profile-01.mjs | 38 ++++++----- test/profile-measurement.test.ts | 71 ++++++++++++++++++++ 7 files changed, 189 insertions(+), 70 deletions(-) create mode 100644 bench/profile/profile-measurement.mjs create mode 100644 test/profile-measurement.test.ts diff --git a/.github/workflows/profile-01.yml b/.github/workflows/profile-01.yml index ca05db54f..2e966301f 100644 --- a/.github/workflows/profile-01.yml +++ b/.github/workflows/profile-01.yml @@ -1,8 +1,7 @@ name: PROFILE-01 — preregistered probe -# Только ручной запуск на штатных self-hosted runners (de-04 первым по -# CI-контракту). Платные GitHub-hosted runners запрещены. Тяжёлые тесты и -# бенчи никогда не запускаются в песочнице. +# Размерный вектор запускается вручную в той же среде, что обычный CI +# публичного репозитория. Время/GPU/энергия этой машины не являются device proof. on: workflow_dispatch: inputs: @@ -14,7 +13,7 @@ on: options: - old-vector cells: - description: 'Клетки roster: desktop (без железа) или all (требует device lab)' + description: 'Непроверенные timing/device-клетки: desktop или полный roster' required: true default: 'desktop' type: choice @@ -31,8 +30,7 @@ env: jobs: probe: - # Generic self-hosted label: парка de-04 обслуживает первым по контракту. - runs-on: self-hosted + runs-on: ubuntu-latest timeout-minutes: 45 steps: - name: Получить exact harness @@ -51,7 +49,7 @@ jobs: corepack enable corepack install --global pnpm@11.11.0 - - name: Доказать preregistration-before-samples + - name: Проверить exact registration snapshot shell: bash run: | set -euo pipefail @@ -68,10 +66,8 @@ jobs: - name: Установить зависимости run: pnpm install --frozen-lockfile - - name: Собрать exact base - run: pnpm build - - name: Снять старый cost vector (fail-closed) + id: probe shell: bash run: | set -euo pipefail @@ -82,10 +78,12 @@ jobs: --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 diff --git a/bench/profile/probe-profile-01.mjs b/bench/profile/probe-profile-01.mjs index 96d841abb..559a9665b 100644 --- a/bench/profile/probe-profile-01.mjs +++ b/bench/profile/probe-profile-01.mjs @@ -1,12 +1,11 @@ // PROFILE-01 probe: исполняемый измерительный стенд. -// Запуск ТОЛЬКО на штатных self-hosted runners (de-04 первым по CI-контракту), -// никогда в песочнице и никогда на платных GitHub-hosted runners. +// Измерение размера использует обычную среду CI публичного репозитория. +// Оно не доказывает задержку, частоту кадров или свойства физического устройства. // Fail-closed: невалидная калибровка или несоответствие frozen-контракта // останавливают admission, а не дают «зелёный» результат. // Использование: node bench/profile/probe-profile-01.mjs --mode old-vector|aa|ab --cells desktop|all --out import { createHash } from 'node:crypto'; -import { execFileSync } from 'node:child_process'; import { mkdirSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; @@ -15,8 +14,10 @@ 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}`); @@ -29,22 +30,6 @@ function arg(name) { const git = makeGit(fail); -function runSizeGate(repoRoot) { - // scripts/size-gate.mjs не имеет --json: сырьём является точный stdout - // плюс exit code. Требует собранный dist (pnpm build) — только self-hosted. - try { - const transcript = execFileSync('node', ['scripts/size-gate.mjs'], { - cwd: repoRoot, - encoding: 'utf8', - timeout: 20 * 60 * 1000, - maxBuffer: 64 * 1024 * 1024, - }); - return { exitCode: 0, transcript }; - } catch (error) { - return { exitCode: error?.status ?? 1, transcript: String(error?.stdout ?? error?.message ?? error) }; - } -} - function writeArtifact(outDir, artifact, head, digest) { const rawPath = join(outDir, `profile-01-${artifact.mode}-${head.slice(0, 12)}.json`); writeFileSync(rawPath, `${JSON.stringify(artifact, null, 2)}\n`); @@ -65,11 +50,12 @@ function persistAndFail(outDir, artifact, head, digest, reason) { fail(reason); } -function main() { +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 }); @@ -92,7 +78,7 @@ function main() { seed: 20260929, startedAtUtc: new Date().toISOString(), cellsMeasured: [], - cellsUnproven: [], + cellsUnproven: unmeasuredCells(cells), rawControls: {}, calibration: {}, costVector: null, @@ -107,7 +93,7 @@ function main() { if (sizeGateBlob !== '4b0f181212b65a881e750e84564778f5828448a3') { fail(`size-gate provenance drifted: ${sizeGateBlob}`); } - // [0] Рабочая копия обязана совпадать с коммитом: иначе runSizeGate + // Рабочая копия обязана совпадать с коммитом: иначе измеритель // исполнит непроверенный файл, а baseProof этого не покажет. const workingSizeGateBlob = git.workingBlob(repoRoot, 'scripts/size-gate.mjs'); if (workingSizeGateBlob !== sizeGateBlob) { @@ -118,31 +104,24 @@ function main() { if (foreign.length > 0) fail(`измеряемое дерево отличается от PRODUCT_BASE вне prereg-пакета: ${foreign.join(', ')}`); artifact.sizeGateBlob = sizeGateBlob; artifact.baseProof = { productBase: base, head, diffPaths }; - artifact.costVector = runSizeGate(repoRoot); + 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, gate] of Object.entries(PROFILE_01.oldCostVectorGzipBytes.scenarios)) { - if (!artifact.costVector.transcript.includes(`${name} `) && !artifact.costVector.transcript.includes(name)) { - persistAndFail(outDir, artifact, head, digest, `транскрипт size-gate не содержит сценарий ${name}`); + 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'); } - // 3. Клетки устройств: измеряется только прикреплённое железо. - // Физические Android/iOS без device-lab конфига — affected cells, не success. - const deviceLab = process.env.PROFILE_01_DEVICE_LAB ?? ''; - if (cells === 'all' && deviceLab === '') { - for (const cell of PROFILE_01.affectedCellsMissingHw) { - artifact.cellsUnproven.push({ cell: cell.cell, reason: 'no attached device lab; affected cell, admission blocked', blocks: cell.blocks }); - } - artifact.cellsUnproven.push({ cell: 'android-mid-60', reason: 'no attached device lab', blocks: 'A/B admission Android-клеток' }); - artifact.cellsUnproven.push({ cell: 'ios-60', reason: 'no attached device lab', blocks: 'A/B admission iOS-клеток' }); - } - // 4. Калибровка: A/A и deliberate 2×work обязаны быть явными. - // Детализация timing-калибровки — в browser-фазе на self-hosted runner; + // Детализация timing-калибровки — в отдельной browser-фазе; // без пройденной калибровки admission не выдаётся (см. ниже). artifact.calibration = { aa: 'PENDING', positive2x: 'PENDING' }; @@ -150,7 +129,7 @@ function main() { // Отклонённые aa/ab обязаны Persist failure-артефакт до fail ([2]): // OBSERVATION_POLICY требует сохранять failures с digest. if (mode !== 'old-vector') { - persistAndFail(outDir, artifact, head, digest, 'aa/ab режимы требуют зелёной browser-калибровки на self-hosted runner; локальный запуск запрещён'); + persistAndFail(outDir, artifact, head, digest, 'aa/ab режимы требуют отдельной зелёной browser-калибровки'); } const ready = mode === 'old-vector' && artifact.costVector !== null; artifact.finishedAtUtc = new Date().toISOString(); @@ -162,4 +141,4 @@ function main() { writeArtifact(outDir, artifact, head, digest); } -main(); +await main(); diff --git a/bench/profile/profile-01-preregistration.mjs b/bench/profile/profile-01-preregistration.mjs index 97d07abd5..41ba6e225 100644 --- a/bench/profile/profile-01-preregistration.mjs +++ b/bench/profile/profile-01-preregistration.mjs @@ -195,6 +195,16 @@ 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: 'timing and device calibration not measured', blocks: 'A/B admission for this cell' })) + .concat([{ cell: 'whole-page energy/GPU', reason: 'device energy/GPU not measured', blocks: 'M-04/M-05 full-device cost claims' }]); +} + // Самопроверка замороженного контракта: структура, ТОЧНЫЕ ЗНАЧЕНИЯ // потолков/сценариев/roster/scenes, fail-closed флаги. Вызывается пробой // до любых samples. Проверяются именно значения, а не только число ключей: diff --git a/bench/profile/profile-git-proof.mjs b/bench/profile/profile-git-proof.mjs index a303556ae..fb3a26ab6 100644 --- a/bench/profile/profile-git-proof.mjs +++ b/bench/profile/profile-git-proof.mjs @@ -4,6 +4,7 @@ // переданный fail-колбэк вызывающей стороны, а не в молчаливый пропуск. import { execFileSync } from 'node:child_process'; +import { readCheckoutState } from '../compare/provenance.mjs'; // Измеряемое дерево обязано совпадать с PRODUCT_BASE везде, кроме самих // файлов preregistration-пакета. Это позволяет снимать old-vector на @@ -13,20 +14,24 @@ export const PREREG_OWN_PATHS = Object.freeze([ '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', ]); export function makeGit(fail) { const head = (cwd) => { try { - return execFileSync('git', ['rev-parse', 'HEAD'], { cwd, encoding: 'utf8' }).trim(); + 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', ['rev-parse', `${rev}:${path}`], { cwd, encoding: 'utf8' }).trim(); + return execFileSync('git', ['--no-replace-objects', 'rev-parse', `${rev}:${path}`], { cwd, encoding: 'utf8' }).trim(); } catch { fail(`git не смог доказать blob ${path}@${rev}`); } @@ -40,7 +45,7 @@ export function makeGit(fail) { }; const diffNames = (cwd, base, headRef) => { try { - const output = execFileSync('git', ['diff', '--name-only', `${base}`, `${headRef}`], { cwd, encoding: 'utf8' }); + 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}`); @@ -48,7 +53,7 @@ export function makeGit(fail) { }; const ancestor = (cwd, base) => { try { - execFileSync('git', ['merge-base', '--is-ancestor', base, 'HEAD'], { cwd, stdio: 'ignore' }); + execFileSync('git', ['--no-replace-objects', 'merge-base', '--is-ancestor', base, 'HEAD'], { cwd, stdio: 'ignore' }); return true; } catch { return false; diff --git a/bench/profile/profile-measurement.mjs b/bench/profile/profile-measurement.mjs new file mode 100644 index 000000000..2b379c5c3 --- /dev/null +++ b/bench/profile/profile-measurement.mjs @@ -0,0 +1,50 @@ +import { isDeepStrictEqual } from 'node:util'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +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', 'tsup', 'terser', 'typescript'], + }); + 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 index 61298b82c..d7e089eca 100644 --- a/bench/profile/validate-profile-01.mjs +++ b/bench/profile/validate-profile-01.mjs @@ -1,17 +1,20 @@ // PROFILE-01 validate: независимая перепроверка raw-артефакта пробы. -// Ловит согласованные по виду, но неверные elapsed/divisor/missing sample, -// подмену preregistration после samples и дрейф provenance. +// Повторяет размерные измерения; не удостоверяет время исторического запуска +// и не выдаёт допуска производительности или человеческой оценки. // Использование: node bench/profile/validate-profile-01.mjs --raw import { readFileSync } from 'node:fs'; -import { join, resolve } from 'node:path'; +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}`); @@ -22,9 +25,16 @@ function arg(name) { return index === -1 ? undefined : process.argv[index + 1]; } -function main() { +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); @@ -39,10 +49,8 @@ function main() { // 2. Provenance exact base: независимое git-перевычисление ([4]). // Валидатор не доверяет полям raw JSON: HEAD, ancestry, diff и blob // пересчитываются из checkout, в котором запущен валидатор. - // Transcript size-gate — только диагностическое поле: проверяются - // exitCode, непустота и присутствие имён сценариев, побайтового - // сравнения с новым запуском нет (измерения зависят от build/runtime - // окружения; независимый re-run — отдельная dispatch-проба). + // Записанный exitCode не удостоверяет измерение. После проверки Git повторяем + // сборку и существующий size-gate; сравниваем данные, а не формат console.log. const SIZE_GATE_FROZEN = '4b0f181212b65a881e750e84564778f5828448a3'; if (artifact.mode === 'old-vector') { const base = PROFILE_01.productBase.mainSha; @@ -67,12 +75,8 @@ function main() { if (committed !== SIZE_GATE_FROZEN || committed !== artifact.sizeGateBlob) { fail(`size-gate provenance drifted: artifact ${artifact.sizeGateBlob}, git ${committed}`); } - if (!artifact.costVector || artifact.costVector.exitCode !== 0) fail('old-vector без зелёного costVector'); - if (typeof artifact.costVector.transcript !== 'string' || artifact.costVector.transcript.length === 0) { - fail('costVector.transcript обязан быть непустой диагностикой'); - } - for (const name of Object.keys(PROFILE_01.oldCostVectorGzipBytes.scenarios)) { - if (!artifact.costVector.transcript.includes(name)) fail(`транскрипт не содержит сценарий ${name}`); + if (artifact.admission === 'OLD-VECTOR-ONLY') { + validateReplayedVector(artifact.costVector, await measureOldVector(repoRoot)); } } @@ -100,10 +104,12 @@ function main() { // 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 })); + console.log(JSON.stringify({ valid: true, mode: artifact.mode, admission: artifact.admission, + verification: artifact.admission === 'OLD-VECTOR-ONLY' ? 'independent-size-remeasurement' : 'recorded-refusal-only' })); } -main(); +await main(); diff --git a/test/profile-measurement.test.ts b/test/profile-measurement.test.ts new file mode 100644 index 000000000..b72b4680b --- /dev/null +++ b/test/profile-measurement.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; +import { validateReplayedVector } from '../bench/profile/profile-measurement.mjs'; +import { unmeasuredCells } from '../bench/profile/profile-01-preregistration.mjs'; + +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(); + }); +}); From bb9497004033cc88d25a638d6264354fe731521b Mon Sep 17 00:00:00 2001 From: Claude Code Date: Wed, 30 Sep 2026 11:56:20 +0300 Subject: [PATCH 06/12] fix(profile): capture the canonical measurement toolchain --- bench/profile/profile-measurement.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bench/profile/profile-measurement.mjs b/bench/profile/profile-measurement.mjs index 2b379c5c3..d2dcba38f 100644 --- a/bench/profile/profile-measurement.mjs +++ b/bench/profile/profile-measurement.mjs @@ -1,6 +1,7 @@ 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, @@ -13,7 +14,7 @@ export async function measureOldVector(root) { const provenance = prepareBenchmarkCheckout({ root, benchDirectory: root, - requiredRootPackages: ['esbuild', 'tsup', 'terser', 'typescript'], + requiredRootPackages: ['esbuild', CANONICAL_GZIP_PACKAGE], }); const gate = await import('../../scripts/size-gate.mjs'); const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')); From ca11052201683114650a9382c965d54966ea83c1 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Wed, 30 Sep 2026 12:14:57 +0300 Subject: [PATCH 07/12] fix(profile): register the security-patched baseline explicitly --- bench/profile/probe-profile-01.mjs | 2 +- bench/profile/profile-01-preregistration.mjs | 14 +++++++------- bench/profile/validate-profile-01.mjs | 4 ++-- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/bench/profile/probe-profile-01.mjs b/bench/profile/probe-profile-01.mjs index 559a9665b..f0e61ac02 100644 --- a/bench/profile/probe-profile-01.mjs +++ b/bench/profile/probe-profile-01.mjs @@ -87,7 +87,7 @@ async function main() { }; if (mode === 'old-vector') { - const base = PROFILE_01.productBase.mainSha; + 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 !== '4b0f181212b65a881e750e84564778f5828448a3') { diff --git a/bench/profile/profile-01-preregistration.mjs b/bench/profile/profile-01-preregistration.mjs index 41ba6e225..779039a45 100644 --- a/bench/profile/profile-01-preregistration.mjs +++ b/bench/profile/profile-01-preregistration.mjs @@ -11,13 +11,13 @@ function invariant(condition, message) { if (!condition) throw new Error(`PROFILE-01 preregistration: ${message}`); } -// Точный продуктовый base, на котором зафиксирован старый cost vector. +// Точный baseline: прежний main с отдельным исправлением зависимостей тестов. +// Runtime, размерный измеритель и потолки сохранены; branch SHA не назван main. export const PRODUCT_BASE = Object.freeze({ repo: 'Labpics-Team/lab-motion', - mainSha: '0912acd875a67bed8f165e345626fd00082e258e', - mergeOf: 'PR #390; current main before PROFILE measurements', - registeredFrom: '0fb23264a93a18a8242fd15a2375e9f75845dbdf', - mergedAtUtc: '2026-09-29T17:24:51Z', + sourceSha: '667cfad1d0de2c4db1cf95074d1c43161308bb35', + upstreamMainSha: '0912acd875a67bed8f165e345626fd00082e258e', + reason: 'dependency security correction; runtime and cost ceilings unchanged', }); // Полный старый cost vector: потолки кода, а не новые оценки. @@ -220,8 +220,8 @@ export function verifyPreregistration(value = PROFILE_01) { invariant(value.node === 'PROFILE-01' && value.revision === 'r11', 'node/revision drifted'); invariant(value.candidateSamplesObservedAtRegistration === false, 'preregistration обязана предшествовать samples'); invariant(value.productBase?.repo === 'Labpics-Team/lab-motion', 'product repo drifted'); - invariant(value.productBase?.mainSha === '0912acd875a67bed8f165e345626fd00082e258e', 'product base drifted'); - invariant(value.productBase?.mergedAtUtc === '2026-09-29T17:24:51Z', 'product base время drifted'); + invariant(value.productBase?.sourceSha === '667cfad1d0de2c4db1cf95074d1c43161308bb35', 'product base drifted'); + invariant(value.productBase?.upstreamMainSha === '0912acd875a67bed8f165e345626fd00082e258e', 'upstream main drifted'); const gates = value.oldCostVectorGzipBytes; invariant(gates?.core === 2220 && gates?.subpath === 4608 && gates?.fullCoreConsumer === 2330, 'core/subpath/fullCore ceilings drifted'); invariant(gates?.nano === 1024 && gates?.compiledRuntime === 341 && gates?.compilerSurface === 1024, 'старые 1024/341/1024 ceilings drifted'); diff --git a/bench/profile/validate-profile-01.mjs b/bench/profile/validate-profile-01.mjs index d7e089eca..57dc2039d 100644 --- a/bench/profile/validate-profile-01.mjs +++ b/bench/profile/validate-profile-01.mjs @@ -40,7 +40,7 @@ async function main() { verifyPreregistration(PROFILE_01); const frozen = preregistrationDigest(PROFILE_01); if (artifact.preregistrationDigest !== frozen) { - fail(`preregistration подменена после samples: artifact ${artifact.preregistrationDigest}, frozen ${frozen}`); + fail(`preregistration не совпадает: artifact ${artifact.preregistrationDigest}, frozen ${frozen}`); } if (artifact.candidateSamplesObservedAtRegistration !== false) { fail('preregistration обязана предшествовать samples'); @@ -53,7 +53,7 @@ async function main() { // сборку и существующий size-gate; сравниваем данные, а не формат console.log. const SIZE_GATE_FROZEN = '4b0f181212b65a881e750e84564778f5828448a3'; if (artifact.mode === 'old-vector') { - const base = PROFILE_01.productBase.mainSha; + 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 отсутствует'); From 1dd2e1425b5e2c8a076dff3c4c19965c4851d7f9 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Wed, 30 Sep 2026 12:27:58 +0300 Subject: [PATCH 08/12] fix(profile): bind raw digest to the written artifact bytes --- bench/profile/probe-profile-01.mjs | 5 +++-- test/profile-measurement.test.ts | 25 +++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/bench/profile/probe-profile-01.mjs b/bench/profile/probe-profile-01.mjs index f0e61ac02..3710b0e6e 100644 --- a/bench/profile/probe-profile-01.mjs +++ b/bench/profile/probe-profile-01.mjs @@ -32,8 +32,9 @@ const git = makeGit(fail); function writeArtifact(outDir, artifact, head, digest) { const rawPath = join(outDir, `profile-01-${artifact.mode}-${head.slice(0, 12)}.json`); - writeFileSync(rawPath, `${JSON.stringify(artifact, null, 2)}\n`); - const rawDigest = createHash('sha256').update(JSON.stringify(artifact)).digest('hex'); + 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 })); } diff --git a/test/profile-measurement.test.ts b/test/profile-measurement.test.ts index b72b4680b..a18c4b4a5 100644 --- a/test/profile-measurement.test.ts +++ b/test/profile-measurement.test.ts @@ -1,4 +1,10 @@ import { describe, expect, it } from 'vitest'; +import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { mkdtempSync, readFileSync, rmSync } 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 { unmeasuredCells } from '../bench/profile/profile-01-preregistration.mjs'; @@ -68,4 +74,23 @@ describe('PROFILE: записанный успех не заменяет нез ]); 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); }); From f56da5e17dedd47f1fff247f9de365436a2bf007 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Wed, 30 Sep 2026 13:09:06 +0300 Subject: [PATCH 09/12] =?UTF-8?q?fix(profile):=20=D0=BF=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D1=8F=D1=82=D1=8C=20=D0=B2=D0=B5=D1=81=D1=8C=20?= =?UTF-8?q?=D0=B7=D0=B0=D1=80=D0=B5=D0=B3=D0=B8=D1=81=D1=82=D1=80=D0=B8?= =?UTF-8?q?=D1=80=D0=BE=D0=B2=D0=B0=D0=BD=D0=BD=D1=8B=D0=B9=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D1=82=D0=BE=D0=BA=D0=BE=D0=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/profile-01.yml | 31 +++-- bench/profile/probe-profile-01.mjs | 15 +- bench/profile/profile-01-preregistration.mjs | 137 ++++++------------- bench/profile/profile-git-proof.mjs | 3 +- bench/profile/validate-profile-01.mjs | 2 +- test/profile-measurement.test.ts | 49 ++++++- 6 files changed, 112 insertions(+), 125 deletions(-) diff --git a/.github/workflows/profile-01.yml b/.github/workflows/profile-01.yml index 2e966301f..d8f883708 100644 --- a/.github/workflows/profile-01.yml +++ b/.github/workflows/profile-01.yml @@ -1,8 +1,13 @@ -name: PROFILE-01 — preregistered probe +name: PROFILE-01 — проверка размерного протокола -# Размерный вектор запускается вручную в той же среде, что обычный CI +# Размерный вектор запускается в PR и вручную в той же среде, что обычный CI # публичного репозитория. Время/GPU/энергия этой машины не являются device proof. on: + pull_request: + paths: + - 'bench/profile/**' + - 'test/profile-measurement.test.ts' + - '.github/workflows/profile-01.yml' workflow_dispatch: inputs: mode: @@ -27,15 +32,17 @@ permissions: 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: Получить exact harness + - name: Получить точный источник измерителя uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} persist-credentials: false fetch-depth: 0 @@ -44,16 +51,16 @@ jobs: with: node-version: '24' - - name: Активировать pinned pnpm + - name: Активировать закреплённый pnpm run: | corepack enable corepack install --global pnpm@11.11.0 - - name: Проверить exact registration snapshot + - name: Проверить точную версию протокола shell: bash run: | set -euo pipefail - test "$(git rev-parse HEAD)" = "$GITHUB_SHA" + 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); @@ -66,15 +73,15 @@ jobs: - name: Установить зависимости run: pnpm install --frozen-lockfile - - name: Снять старый cost vector (fail-closed) + - name: Измерить прежний размерный вектор id: probe shell: bash run: | set -euo pipefail mkdir -p "$RUNNER_TEMP/profile-01-raw" node bench/profile/probe-profile-01.mjs \ - --mode '${{ inputs.mode }}' \ - --cells '${{ inputs.cells }}' \ + --mode '${{ inputs.mode || 'old-vector' }}' \ + --cells '${{ inputs.cells || 'desktop' }}' \ --out "$RUNNER_TEMP/profile-01-raw" - name: Независимо перепроверить raw-артефакт @@ -88,12 +95,10 @@ jobs: done - name: Опубликовать raw-артефакт - # if: always() — failure-артефакты (NOT-GRANTED с rejection) обязаны - # загружаться даже после failed probe ([1]): OBSERVATION_POLICY - # требует сохранять failures, иначе отрицательный результат теряется. + # Отрицательный результат сохраняется даже после отказа измерителя. if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 with: - name: profile-01-raw-${{ github.sha }} + 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 index 3710b0e6e..5fd0c3d0e 100644 --- a/bench/profile/probe-profile-01.mjs +++ b/bench/profile/probe-profile-01.mjs @@ -1,8 +1,7 @@ // PROFILE-01 probe: исполняемый измерительный стенд. // Измерение размера использует обычную среду CI публичного репозитория. // Оно не доказывает задержку, частоту кадров или свойства физического устройства. -// Fail-closed: невалидная калибровка или несоответствие frozen-контракта -// останавливают admission, а не дают «зелёный» результат. +// Недействительная калибровка или расхождение с протоколом запрещают допуск. // Использование: node bench/profile/probe-profile-01.mjs --mode old-vector|aa|ab --cells desktop|all --out import { createHash } from 'node:crypto'; @@ -39,10 +38,8 @@ function writeArtifact(outDir, artifact, head, digest) { console.log(JSON.stringify({ rawPath, rawDigest, preregistrationDigest: digest, admission: artifact.admission })); } -// Post-measurement отказ обязан сначала Persist артефакт с причиной -// (OBSERVATION_POLICY: failures сохраняются с digest), затем fail. -// Pre-measurement отказы (ancestry/blob/diff до runSizeGate) остаются -// fail-fast без артефакта: измерения ещё не было. +// Отказ после измерения сохраняет артефакт с причиной и хешем. +// Отказ до измерения ещё не создаёт данных для сохранения. function persistAndFail(outDir, artifact, head, digest, reason) { artifact.finishedAtUtc = new Date().toISOString(); artifact.admission = 'NOT-GRANTED'; @@ -60,7 +57,7 @@ async function main() { const outDir = resolve(arg('--out') ?? join(tmpdir(), 'profile-01-raw')); mkdirSync(outDir, { recursive: true }); - // 1. Frozen-контракт обязан проходить самопроверку до любых samples. + // Протокол проверяется до любых измерений. verifyPreregistration(PROFILE_01); const digest = preregistrationDigest(PROFILE_01); @@ -126,9 +123,7 @@ async function main() { // без пройденной калибровки admission не выдаётся (см. ниже). artifact.calibration = { aa: 'PENDING', positive2x: 'PENDING' }; - // 5. Fail-closed итог: без зелёной калибровки admission запрещён. - // Отклонённые aa/ab обязаны Persist failure-артефакт до fail ([2]): - // OBSERVATION_POLICY требует сохранять failures с digest. + // Без калибровки aa/ab сохраняют отказ до завершения процесса. if (mode !== 'old-vector') { persistAndFail(outDir, artifact, head, digest, 'aa/ab режимы требуют отдельной зелёной browser-калибровки'); } diff --git a/bench/profile/profile-01-preregistration.mjs b/bench/profile/profile-01-preregistration.mjs index 779039a45..1cb421546 100644 --- a/bench/profile/profile-01-preregistration.mjs +++ b/bench/profile/profile-01-preregistration.mjs @@ -1,11 +1,16 @@ -// PROFILE-01: замороженная preregistration измерительного протокола. -// Действует ДО любых candidate samples. Любое измерение, проведённое без -// этой регистрации, не является доказательством и не допускается к admission. -// Файл bench-only: production source, exports, бюджеты и гейты не меняются. -// Неизвестное железо — отдельная affected cell (UNPROVEN), а не удачный профиль -// после результата. Повтор опыта до green запрещён. +// Исполняемый контракт 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}`); @@ -17,7 +22,7 @@ export const PRODUCT_BASE = Object.freeze({ repo: 'Labpics-Team/lab-motion', sourceSha: '667cfad1d0de2c4db1cf95074d1c43161308bb35', upstreamMainSha: '0912acd875a67bed8f165e345626fd00082e258e', - reason: 'dependency security correction; runtime and cost ceilings unchanged', + reason: 'исправление безопасности зависимостей; runtime и размерные потолки сохранены', }); // Полный старый cost vector: потолки кода, а не новые оценки. @@ -73,37 +78,34 @@ export const OLD_COST_VECTOR_GZIP_BYTES = Object.freeze({ brotliAndTotals: 'initial+reachable total, CSS/data/lazy chunks и Brotli снимаются тем же прогоном size-gate на exact base; знаменатели не смешиваются', }); -// Roster классов устройств/браузеров/сцен по r11 §PROFILE-01. -// Конкретные модели/OS/browser builds выбираются из доступного -// репрезентативного inventory ДО candidate results и замораживаются -// в первом sample-артефакте. Здесь — классы и правило отбора, а не -// выдуманные модели. Флагман не заменяет mid-range. +// Классы устройств по r11 §PROFILE-01. Конкретные модели и версии выбираются +// до измерений кандидата; флагман не заменяет устройство среднего класса. export const ROSTER = Object.freeze({ classes: Object.freeze([ - Object.freeze({ id: 'android-mid-60', device: 'physical mid-range Android', refreshHz: 60, browsers: ['chromium-webview-stable'], status: 'UNPROVEN' }), - Object.freeze({ id: 'android-mid-120', device: 'physical mid-range Android with 120 Hz display', refreshHz: 120, browsers: ['chromium-webview-stable'], status: 'UNPROVEN' }), - Object.freeze({ id: 'ios-60', device: 'physical iOS', refreshHz: 60, browsers: ['webkit-stable'], status: 'UNPROVEN' }), - Object.freeze({ id: 'ios-120', device: 'physical iOS with ProMotion', refreshHz: 120, browsers: ['webkit-stable'], status: 'UNPROVEN' }), - Object.freeze({ id: 'desktop-chromium', device: 'desktop Linux self-hosted runner', refreshHz: null, browsers: ['chromium-stable'], status: 'UNPROVEN' }), - Object.freeze({ id: 'desktop-firefox', device: 'desktop Linux self-hosted runner', refreshHz: null, browsers: ['firefox-stable'], status: 'UNPROVEN' }), - Object.freeze({ id: 'desktop-webkit', device: 'desktop Linux self-hosted runner', refreshHz: null, browsers: ['webkit-stable'], status: 'UNPROVEN' }), + 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: 'конкретные модели/OS builds — первые доступные репрезентативные из inventory self-hosted парка на момент первого sample; выбор фиксируется в артефакте и не меняется после candidate results', + selectionRule: 'модели и версии ОС — первые доступные репрезентативные устройства собственного парка; выбор фиксируется до измерений кандидата', no120HzRule: 'когда device не умеет 120 Hz, 120 Hz target ему не приписывается, но 60 Hz задача остаётся', cpuThrottleNote: 'CPU-throttle desktop — диагностический контроль, не mobile proof; порог M-04 не выводится из быстрого сервера', fixedConditions: Object.freeze([ - 'thermal/power/display state', 'workloads', 'backgrounds', 'viewport/DPR/content', - 'clocks', 'warm/cold режимы', 'sampling units', 'N/iterations/seed', 'все знаменатели', + 'температура, питание и состояние экрана', 'нагрузка', 'фоновые процессы', 'viewport/DPR/content', + 'источники времени', 'warm/cold режимы', 'единицы выборки', 'N/iterations/seed', 'все знаменатели', ]), }); -// Missing hardware — отдельные affected cells. Ячейка без устройства остаётся +// Отсутствие оборудования затрагивает отдельные клетки. Клетка без устройства остаётся // 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 full-device cost claims; frame CPU-time клетки независимы' }), + Object.freeze({ cell: 'whole-page energy/GPU', blocks: 'выводы M-04/M-05 о полной стоимости устройства; frame CPU-time клетки независимы' }), ]); // Замороженные сцены измерения (выбор ДО samples, смена выбора = новая регистрация). @@ -136,11 +138,11 @@ export const SCENES = Object.freeze({ rawControlRule: 'для raw control с нулевой стоимостью отношение не вычисляется: требуется совпадение границы и выигрыш другой содержательной метрики', }); -// Raw controls: каждый A/B обязан сопровождаться полным набором. +// Каждый A/B сопровождается полным набором контрольных измерений. export const RAW_CONTROLS = Object.freeze({ - noMotion: 'no-motion still control (статический кадр обязан совпадать попиксельно с точностью oracle)', - reducedMotion: 'reduced-motion control (частые действия имеют no-motion path)', - waapi: 'raw platform control (bench/compare waapi-control.entry)', + 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 полосе', @@ -159,16 +161,16 @@ export const STATS_MDE = Object.freeze({ noRepeatToGreen: 'повтор того же опыта до green не допускается; failed A/A = UNPROVEN + baseline RCA, не NO-GO идеи', }); -// Observation policy: сохраняется всё, failures не удаляются. +// Неудачные измерения сохраняются вместе с успешными. export const OBSERVATION_POLICY = Object.freeze({ - preserve: Object.freeze(['все samples', 'failures', 'stalls', 'GC/JIT/context switches', 'malformed receipts']), + 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 — свои знаменатели', }); -// Fail-closed калибровка: невалидная калибровка = candidate admission не запускается. +// Недействительная калибровка запрещает допуск кандидата. export const CALIBRATION = Object.freeze({ aaBand: 'A/A обязан показать отсутствие изменения в исходной non-inferiority полосе; старый p95 admission upper ≤1,05 и остальные условия не меняются', positiveDetection: 'deliberate 2×work обязан детектироваться стендом', @@ -176,7 +178,7 @@ export const CALIBRATION = Object.freeze({ staleProfile: 'профиль, снятый не на зарегистрированном roster/base, к admission не допускается', }); -export const PROFILE_01 = Object.freeze({ +export const PROFILE_01 = deepFreeze({ node: 'PROFILE-01', revision: 'r11', productBase: PRODUCT_BASE, @@ -201,74 +203,13 @@ 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: 'timing and device calibration not measured', blocks: 'A/B admission for this cell' })) - .concat([{ cell: 'whole-page energy/GPU', reason: 'device energy/GPU not measured', blocks: 'M-04/M-05 full-device cost claims' }]); + .map((cell) => ({ cell: cell.id, reason: 'временные метрики и калибровка устройства не измерены', blocks: 'допуск A/B для этой клетки' })) + .concat([{ cell: 'whole-page energy/GPU', reason: 'энергия и GPU устройства не измерены', blocks: 'выводы M-04/M-05 о полной стоимости устройства' }]); } -// Самопроверка замороженного контракта: структура, ТОЧНЫЕ ЗНАЧЕНИЯ -// потолков/сценариев/roster/scenes, fail-closed флаги. Вызывается пробой -// до любых samples. Проверяются именно значения, а не только число ключей: -// частичная проверка (только counts) пропускает тихую подмену значений. -// Якорь заморозки — НЕ этот файл сам по себе, а связка: git ancestry -// (probe доказывает HEAD из PRODUCT_BASE) + exact-head review + CI на -// exact head. Отдельный .sha256-sidecar в том же PR отвергнут сознательно: -// файл в том же trust-domain не добавляет независимости — подмена обновила -// бы оба файла разом. Независимость даёт git-история и ревью, а не второй -// файл рядом. +// Вход сравнивается целиком с одним неизменяемым протоколом. Его версию +// закрепляют commit, независимое ревью и CI; копия констант рядом не нужна. export function verifyPreregistration(value = PROFILE_01) { - invariant(value && typeof value === 'object', 'контракт обязан быть объектом'); - invariant(value.node === 'PROFILE-01' && value.revision === 'r11', 'node/revision drifted'); - invariant(value.candidateSamplesObservedAtRegistration === false, 'preregistration обязана предшествовать samples'); - invariant(value.productBase?.repo === 'Labpics-Team/lab-motion', 'product repo drifted'); - invariant(value.productBase?.sourceSha === '667cfad1d0de2c4db1cf95074d1c43161308bb35', 'product base drifted'); - invariant(value.productBase?.upstreamMainSha === '0912acd875a67bed8f165e345626fd00082e258e', 'upstream main drifted'); - const gates = value.oldCostVectorGzipBytes; - invariant(gates?.core === 2220 && gates?.subpath === 4608 && gates?.fullCoreConsumer === 2330, 'core/subpath/fullCore ceilings drifted'); - invariant(gates?.nano === 1024 && gates?.compiledRuntime === 341 && gates?.compilerSurface === 1024, 'старые 1024/341/1024 ceilings drifted'); - invariant(gates?.inView === 1839 && gates?.inViewConsumer === 1908, 'in-view ceilings drifted'); - invariant(gates?.compositorCapability === 6600, 'compositor capability ceiling drifted'); - invariant(gates?.fullAnimate === 15600 && gates?.animateCompositorMixed === 17500, 'full/mixed consumer ceilings drifted'); - const bespokeExpected = { - './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, - }; - for (const [key, expected] of Object.entries(bespokeExpected)) { - invariant(gates?.bespoke?.[key] === expected, `bespoke gate ${key} drifted`); - } - invariant(Object.keys(gates?.bespoke ?? {}).length === 15, 'bespoke subpath gates drifted'); - const scenariosExpected = { - '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, - }; - for (const [key, expected] of Object.entries(scenariosExpected)) { - invariant(gates?.scenarios?.[key] === expected, `scenario gate ${key} drifted`); - } - invariant(Object.keys(gates?.scenarios ?? {}).length === 15, 'consumer scenario gates drifted'); - const rosterIds = (value.roster?.classes ?? []).map((entry) => entry?.id); - invariant(JSON.stringify(rosterIds) === JSON.stringify(['android-mid-60', 'android-mid-120', 'ios-60', 'ios-120', 'desktop-chromium', 'desktop-firefox', 'desktop-webkit']), 'roster классы drifted'); - invariant((value.affectedCellsMissingHw ?? []).length === 3, 'missing-HW affected cells drifted'); - invariant(value.scenes?.m04channels?.id === 'm04-100-scalar-channels' && value.scenes?.m04channels?.channels === 100, 'M-04 channels сцена drifted'); - invariant(value.scenes?.m04full120?.id === 'm04-full-120hz', 'M-04 120Hz сцена drifted'); - invariant(value.scenes?.m05a?.id === 'm05-sheet' && typeof value.scenes?.m05a?.family === 'string', 'M-05 sheet сцена drifted'); - invariant(value.scenes?.m05b?.id === 'm05-list' && typeof value.scenes?.m05b?.family === 'string', 'M-05 list сцена drifted'); - for (const key of ['noMotion', 'reducedMotion', 'waapi', 'oldLab', 'bestComparator', 'aa', 'positive']) { - invariant(typeof value.rawControls?.[key] === 'string', `raw control ${key} отсутствует`); - } - for (const key of ['independenceUnit', 'design', 'sampleSize', 'seed', 'stoppingRule', 'coverage', 'noRepeatToGreen']) { - invariant(typeof value.statsMde?.[key] === 'string', `stats/MDE ${key} отсутствует`); - } - for (const key of ['preserve', 'rawAndDigest', 'retention', 'forcedGc', 'denominators']) { - invariant(value.observationPolicy?.[key] !== undefined, `observation policy ${key} отсутствует`); - } - for (const key of ['aaBand', 'positiveDetection', 'failClosed', 'staleProfile']) { - invariant(typeof value.calibration?.[key] === 'string', `calibration ${key} отсутствует`); - } + invariant(isDeepStrictEqual(value, PROFILE_01), 'контракт отличается от зарегистрированного протокола'); return true; } diff --git a/bench/profile/profile-git-proof.mjs b/bench/profile/profile-git-proof.mjs index fb3a26ab6..7d7ac5686 100644 --- a/bench/profile/profile-git-proof.mjs +++ b/bench/profile/profile-git-proof.mjs @@ -1,7 +1,6 @@ // PROFILE-01 git-proof: общие git-доказательства provenance для probe и validator. // Один источник PREREG_OWN_PATHS исключает дрейф allowlist между файлами. -// Fail-closed: любая недоступность git превращается в отказ admission через -// переданный fail-колбэк вызывающей стороны, а не в молчаливый пропуск. +// Недоступность Git запрещает допуск через переданный обработчик отказа. import { execFileSync } from 'node:child_process'; import { readCheckoutState } from '../compare/provenance.mjs'; diff --git a/bench/profile/validate-profile-01.mjs b/bench/profile/validate-profile-01.mjs index 57dc2039d..05eecefc9 100644 --- a/bench/profile/validate-profile-01.mjs +++ b/bench/profile/validate-profile-01.mjs @@ -46,7 +46,7 @@ async function main() { fail('preregistration обязана предшествовать samples'); } - // 2. Provenance exact base: независимое git-перевычисление ([4]). + // Происхождение исходников пересчитывается независимо через Git. // Валидатор не доверяет полям raw JSON: HEAD, ancestry, diff и blob // пересчитываются из checkout, в котором запущен валидатор. // Записанный exitCode не удостоверяет измерение. После проверки Git повторяем diff --git a/test/profile-measurement.test.ts b/test/profile-measurement.test.ts index a18c4b4a5..f011b298a 100644 --- a/test/profile-measurement.test.ts +++ b/test/profile-measurement.test.ts @@ -6,7 +6,54 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { validateReplayedVector } from '../bench/profile/profile-measurement.mjs'; -import { unmeasuredCells } from '../bench/profile/profile-01-preregistration.mjs'; +import { PROFILE_01, unmeasuredCells, verifyPreregistration } from '../bench/profile/profile-01-preregistration.mjs'; + +describe('PROFILE: замороженный протокол проверяется целиком', () => { + 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 { From 0bc5c5d95533aa8d1a9c401d9dfbdb8eabfe63db Mon Sep 17 00:00:00 2001 From: Claude Code Date: Wed, 30 Sep 2026 13:29:53 +0300 Subject: [PATCH 10/12] =?UTF-8?q?fix(ci):=20=D0=B7=D0=B0=D0=BF=D1=83=D1=81?= =?UTF-8?q?=D0=BA=D0=B0=D1=82=D1=8C=20PROFILE=20=D1=87=D0=B5=D1=80=D0=B5?= =?UTF-8?q?=D0=B7=20=D0=BE=D1=81=D0=BD=D0=BE=D0=B2=D0=BD=D0=BE=D0=B9=20wor?= =?UTF-8?q?kflow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 11 +++++++++++ .github/workflows/profile-01.yml | 29 ++++------------------------- bench/profile/profile-git-proof.mjs | 2 ++ test/ci-workflow-contract.test.ts | 22 +++++++++++++++++++--- 4 files changed, 36 insertions(+), 28 deletions(-) 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 index d8f883708..93894600e 100644 --- a/.github/workflows/profile-01.yml +++ b/.github/workflows/profile-01.yml @@ -1,30 +1,9 @@ name: PROFILE-01 — проверка размерного протокола -# Размерный вектор запускается в PR и вручную в той же среде, что обычный CI +# Размерный вектор вызывается вручную через ci.yml в той же среде, что обычный CI # публичного репозитория. Время/GPU/энергия этой машины не являются device proof. on: - pull_request: - paths: - - 'bench/profile/**' - - 'test/profile-measurement.test.ts' - - '.github/workflows/profile-01.yml' - workflow_dispatch: - inputs: - mode: - description: 'Режим пробы: old-vector (старый cost vector на PRODUCT_BASE)' - required: true - default: 'old-vector' - type: choice - options: - - old-vector - cells: - description: 'Непроверенные timing/device-клетки: desktop или полный roster' - required: true - default: 'desktop' - type: choice - options: - - desktop - - all + workflow_call: permissions: contents: read @@ -80,8 +59,8 @@ jobs: set -euo pipefail mkdir -p "$RUNNER_TEMP/profile-01-raw" node bench/profile/probe-profile-01.mjs \ - --mode '${{ inputs.mode || 'old-vector' }}' \ - --cells '${{ inputs.cells || 'desktop' }}' \ + --mode old-vector \ + --cells all \ --out "$RUNNER_TEMP/profile-01-raw" - name: Независимо перепроверить raw-артефакт diff --git a/bench/profile/profile-git-proof.mjs b/bench/profile/profile-git-proof.mjs index 7d7ac5686..c356645ca 100644 --- a/bench/profile/profile-git-proof.mjs +++ b/bench/profile/profile-git-proof.mjs @@ -16,6 +16,8 @@ export const PREREG_OWN_PATHS = Object.freeze([ '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) { diff --git a/test/ci-workflow-contract.test.ts b/test/ci-workflow-contract.test.ts index 8a3467ede..a085deb8a 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; @@ -532,4 +548,4 @@ describe('нативный граф CI', () => { rmSync(cwd, { recursive: true, force: true }); } }); -}); \ No newline at end of file +}); From a000aca1ed201002dba990a3dd210ca192dc0ea7 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Wed, 30 Sep 2026 13:43:33 +0300 Subject: [PATCH 11/12] =?UTF-8?q?test(ci):=20=D0=B8=D0=B7=D0=BE=D0=BB?= =?UTF-8?q?=D0=B8=D1=80=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20=D1=81=D1=80=D0=BE?= =?UTF-8?q?=D0=BA=D0=B8=20=D0=BE=D1=82=D0=B4=D0=B5=D0=BB=D1=8C=D0=BD=D1=8B?= =?UTF-8?q?=D1=85=20shell=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BE?= =?UTF-8?q?=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/ci-workflow-contract.test.ts | 30 ++++++++++++++---------------- 1 file changed, 14 insertions(+), 16 deletions(-) diff --git a/test/ci-workflow-contract.test.ts b/test/ci-workflow-contract.test.ts index a085deb8a..58058b6f7 100644 --- a/test/ci-workflow-contract.test.ts +++ b/test/ci-workflow-contract.test.ts @@ -510,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', () => { From 22b5f430e5333fa2114397b3198aca51134e4088 Mon Sep 17 00:00:00 2001 From: Claude Code Date: Wed, 30 Sep 2026 14:47:05 +0300 Subject: [PATCH 12/12] =?UTF-8?q?test(profile):=20=D0=BF=D1=80=D0=BE=D0=B2?= =?UTF-8?q?=D0=B5=D1=80=D0=B8=D1=82=D1=8C=20Git=20=D0=B8=20=D0=BE=D1=82?= =?UTF-8?q?=D0=BA=D0=B0=D0=B7=D1=8B=20=D0=B2=D0=BD=D0=B5=D1=88=D0=BD=D0=B5?= =?UTF-8?q?=D0=B3=D0=BE=20=D0=B8=D0=B7=D0=BC=D0=B5=D1=80=D0=B8=D1=82=D0=B5?= =?UTF-8?q?=D0=BB=D1=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/profile-01.yml | 38 +++++++ bench/profile/probe-profile-01.mjs | 2 +- bench/profile/profile-01-preregistration.mjs | 5 +- bench/profile/validate-profile-01.mjs | 3 +- test/profile-measurement.test.ts | 103 ++++++++++++++++++- 5 files changed, 144 insertions(+), 7 deletions(-) diff --git a/.github/workflows/profile-01.yml b/.github/workflows/profile-01.yml index 93894600e..a5b4fe92f 100644 --- a/.github/workflows/profile-01.yml +++ b/.github/workflows/profile-01.yml @@ -73,6 +73,44 @@ jobs: 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() diff --git a/bench/profile/probe-profile-01.mjs b/bench/profile/probe-profile-01.mjs index 5fd0c3d0e..f5336d554 100644 --- a/bench/profile/probe-profile-01.mjs +++ b/bench/profile/probe-profile-01.mjs @@ -88,7 +88,7 @@ async function main() { 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 !== '4b0f181212b65a881e750e84564778f5828448a3') { + if (sizeGateBlob !== PROFILE_01.productBase.sizeGateBlob) { fail(`size-gate provenance drifted: ${sizeGateBlob}`); } // Рабочая копия обязана совпадать с коммитом: иначе измеритель diff --git a/bench/profile/profile-01-preregistration.mjs b/bench/profile/profile-01-preregistration.mjs index 1cb421546..d3b106a93 100644 --- a/bench/profile/profile-01-preregistration.mjs +++ b/bench/profile/profile-01-preregistration.mjs @@ -22,12 +22,13 @@ export const PRODUCT_BASE = Object.freeze({ repo: 'Labpics-Team/lab-motion', sourceSha: '667cfad1d0de2c4db1cf95074d1c43161308bb35', upstreamMainSha: '0912acd875a67bed8f165e345626fd00082e258e', + sizeGateBlob: '4b0f181212b65a881e750e84564778f5828448a3', reason: 'исправление безопасности зависимостей; runtime и размерные потолки сохранены', }); // Полный старый cost vector: потолки кода, а не новые оценки. -// Provenance: scripts/size-gate.mjs, blob 4b0f181212b65a881e750e84564778f5828448a3 -// на PRODUCT_BASE. Архивные фактические размеры новым измерением не являются: +// 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, diff --git a/bench/profile/validate-profile-01.mjs b/bench/profile/validate-profile-01.mjs index 05eecefc9..51f3c23a8 100644 --- a/bench/profile/validate-profile-01.mjs +++ b/bench/profile/validate-profile-01.mjs @@ -51,7 +51,6 @@ async function main() { // пересчитываются из checkout, в котором запущен валидатор. // Записанный exitCode не удостоверяет измерение. После проверки Git повторяем // сборку и существующий size-gate; сравниваем данные, а не формат console.log. - const SIZE_GATE_FROZEN = '4b0f181212b65a881e750e84564778f5828448a3'; if (artifact.mode === 'old-vector') { const base = PROFILE_01.productBase.sourceSha; const proof = artifact.baseProof ?? {}; @@ -72,7 +71,7 @@ async function main() { 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 !== SIZE_GATE_FROZEN || committed !== artifact.sizeGateBlob) { + 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') { diff --git a/test/profile-measurement.test.ts b/test/profile-measurement.test.ts index f011b298a..902f4ec08 100644 --- a/test/profile-measurement.test.ts +++ b/test/profile-measurement.test.ts @@ -1,14 +1,113 @@ import { describe, expect, it } from 'vitest'; -import { spawnSync } from 'node:child_process'; +import { execFileSync, spawnSync } from 'node:child_process'; import { createHash } from 'node:crypto'; -import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +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);