From 14aa9cc235a5fd96cc4a8059627b4cbf0cabed6b Mon Sep 17 00:00:00 2001 From: Wenyao Gao Date: Thu, 27 Aug 2026 16:35:09 -0700 Subject: [PATCH 1/5] =?UTF-8?q?feat(power):=20ingest=20and=20expose=20powe?= =?UTF-8?q?r=20audit=20provenance=20|=20=E5=8A=9F=E7=8E=87=EF=BC=9A?= =?UTF-8?q?=E6=91=84=E5=8F=96=E5=B9=B6=E5=85=AC=E5=BC=80=E5=8A=9F=E8=80=97?= =?UTF-8?q?=E5=AE=A1=E8=AE=A1=E6=BA=AF=E6=BA=90=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turn the reserved power_invalid_reasons / power_audit contract fields live end-to-end: - migration 014 adds dedicated jsonb columns on benchmark_results and recreates latest_benchmarks with the migration-012 definition verbatim so br.* picks up the new columns - mapBenchmarkRow narrows both fields defensively (snake_case reason codes, fixed 8-key audit shape, empty -> undefined so the columns store SQL NULL) and both keys join NON_METRIC_KEYS so Number(['5']) === 5 can never mint a bogus numeric metric - bulkIngestBenchmarkRows persists both as jsonb lanes, NULL when absent, refreshed on conflict like workers - all four read paths select the columns via to_jsonb(...) -> 'col' (the PR #405/#407 deploy-order lesson: bare references fail at plan time until the next ingest applies the migration; the jsonb lookup degrades to NULL) - rowToAggDataEntry passes reasons through, and both chart tooltips render a bilingual "Measured power withheld" line with re-sanitized codes - unofficial-run overlay rows carry both fields; the calculator view strips them; OpenAPI wording moves from reserved to live The persistence input is built by spreading (ingest-ci-run.ts {...row, configId}; run-overrides.ts applyBenchmarkPointBackfill {...point}), so the new BenchmarkParams fields flow through with no changes there. 中文:将预留的 power_invalid_reasons / power_audit 契约字段全链路转正:迁移 014 为 benchmark_results 增加两个专用 jsonb 列并按迁移 012 的定义原样重建 latest_benchmarks;mapBenchmarkRow 对两个字段做防御性收窄(snake_case 原因码、 固定 8 键审计对象、空值映射为 undefined 以存储 SQL NULL);批量摄取以 jsonb 通道 持久化并在冲突时刷新;四条读取路径均用 to_jsonb(...) -> 'col' 容错读取(PR 携带同样字段;计算器视图剥离;OpenAPI 文案由预留改为正式。 --- docs/data-pipeline.md | 11 ++ .../app/src/app/api/unofficial-run/route.ts | 4 + .../app/src/components/inference/types.ts | 5 + .../inference/utils/tooltipUtils.ts | 24 +++ .../src/lib/api-documentation.power.test.ts | 5 +- packages/app/src/lib/api-documentation.ts | 8 +- packages/app/src/lib/api-route-catalog.ts | 4 +- packages/app/src/lib/benchmark-api-view.ts | 11 +- packages/app/src/lib/benchmark-transform.ts | 7 + .../db/migrations/014_power_provenance.sql | 162 ++++++++++++++++++ packages/db/src/etl/benchmark-ingest.ts | 26 ++- packages/db/src/etl/benchmark-mapper.ts | 134 +++++++++++++++ packages/db/src/queries/benchmarks.ts | 26 ++- 13 files changed, 413 insertions(+), 14 deletions(-) create mode 100644 packages/db/migrations/014_power_provenance.sql diff --git a/docs/data-pipeline.md b/docs/data-pipeline.md index ef8e420bb..cc6c2a79c 100644 --- a/docs/data-pipeline.md +++ b/docs/data-pipeline.md @@ -442,3 +442,14 @@ All normalizer logic lives in `packages/db/src/etl/normalizers.ts`. The function - **v2 (2025-12-19+)**: Separate `prefill_tp` / `decode_tp` / `prefill_ep` / `decode_ep` / `prefill_dp_attention` / `decode_dp_attention` / `prefill_num_workers` / `decode_num_workers` / `num_prefill_gpu` / `num_decode_gpu` fields are present. These map directly; `num_prefill_gpu` / `num_decode_gpu` fall back to `tp * ep` if absent. Detection is a single `'prefill_tp' in row` check — no version field is required in the artifact. + +### Power Audit Provenance (`power_invalid_reasons`, `power_audit`) + +Producers (`aggregate_power.py`) annotate every aggregate result row with two optional provenance fields alongside the `power_valid` verdict: + +- **`power_invalid_reasons`** — array of snake_case reason-code strings explaining a withheld verdict (emitted when `power_valid == 0`), e.g. `sampling_gap_exceeded`, `expected_gpu_count_mismatch`. +- **`power_audit`** — compact measurement-window audit object with a fixed 8-key shape: `window_start_unix`, `window_end_unix`, `expected_gpu_count`, `observed_gpu_count`, `sample_count`, `max_sample_gap_s`, `producer_sha`, `exporter_image_sha256`. Present on valid and invalid rows alike. + +`mapBenchmarkRow()` narrows them defensively (`extractPowerInvalidReasons` / `extractPowerAudit`): reason codes must match `/^[a-z][a-z0-9_]*$/` (≤ 64 chars, deduplicated, capped at 32), audit numerics must be finite (counts: non-negative safe integers), shas collapse to `null` unless a non-empty string ≤ 128 chars, and unknown audit keys are dropped. An empty result maps to `undefined`, so the dedicated `benchmark_results.power_invalid_reasons` / `power_audit` JSONB columns (migration 014, mirroring the `workers` precedent from migration 006) store SQL NULL — never `[]` or `{}`. Legacy artifacts without the fields flow through every layer as NULL/undefined. + +Reads are **permanently tolerant**: `queries/benchmarks.ts` selects the columns as `to_jsonb(br) -> 'power_invalid_reasons'` (and `lb` on the matview branch) rather than bare column references. A bare reference fails at query _plan_ time until the next ingest workflow applies the migration (migrations run in the ingest workflows, not at Vercel deploy — PR #405 shipped bare reads ahead of migration 006 and served 500s until #407 identified this jsonb-lookup primitive). The key lookup degrades to NULL while the column is missing and is byte-identical once it exists, making deploy order irrelevant. diff --git a/packages/app/src/app/api/unofficial-run/route.ts b/packages/app/src/app/api/unofficial-run/route.ts index b827b6d75..2781eb092 100644 --- a/packages/app/src/app/api/unofficial-run/route.ts +++ b/packages/app/src/app/api/unofficial-run/route.ts @@ -73,6 +73,10 @@ export function normalizeArtifactRows( // Surface the same per-worker payload the DB path emits so unofficial // overlays carry the multinode measured-power breakdown too. workers: params.workers, + // Same parity for the power audit provenance: overlay rows explain a + // withheld verdict exactly like persisted rows do. + power_invalid_reasons: params.powerInvalidReasons, + power_audit: params.powerAudit, date, run_url: runUrl, }); diff --git a/packages/app/src/components/inference/types.ts b/packages/app/src/components/inference/types.ts index da0cb54ad..a22aaab21 100644 --- a/packages/app/src/components/inference/types.ts +++ b/packages/app/src/components/inference/types.ts @@ -115,6 +115,11 @@ export interface AggDataEntry { // Measured GPU telemetry (emitted by runner's aggregate_power.py). // Optional because historical runs predate the fields. power_valid?: number; + /** + * Producer reason codes explaining a withheld power verdict + * (PLAN-06 contract; present only when power_valid == 0 upstream). + */ + power_invalid_reasons?: string[]; power_metric_schema_version?: number; /** * Certification tier for the measured power telemetry, derived by diff --git a/packages/app/src/components/inference/utils/tooltipUtils.ts b/packages/app/src/components/inference/utils/tooltipUtils.ts index cd7524a83..b16ab6437 100644 --- a/packages/app/src/components/inference/utils/tooltipUtils.ts +++ b/packages/app/src/components/inference/utils/tooltipUtils.ts @@ -147,6 +147,7 @@ const TOOLTIP_STRINGS = { powerData: 'Power Measurement', powerCertified: 'Validated (current PowerX method)', powerLegacy: 'Historical (not validated under the current method)', + powerWithheld: 'Measured power withheld', }, zh: { dismiss: '点击其他区域关闭', @@ -164,6 +165,7 @@ const TOOLTIP_STRINGS = { powerData: '功耗测量', powerCertified: '已验证(采用当前 PowerX 方法)', powerLegacy: '历史测量(尚未按当前方法验证)', + powerWithheld: '实测功耗未采信', }, } as const; @@ -519,6 +521,26 @@ const generateParallelismHTML = (d: InferenceData, locale: Locale = 'en'): strin ${tooltipLine(t.dpAttention, d.dp_attention ? t.yes : t.no)}`; }; +/** Producer reason codes are snake_case; anything else never reaches the DOM. */ +const POWER_REASON_CODE_RE = /^[a-z][a-z0-9_]*$/u; + +/** + * One muted line explaining a withheld measured-power verdict. Empty unless + * the point carries producer reason codes. Codes are re-validated against the + * snake_case shape before interpolation (defense in depth — tooltip content + * is raw HTML) and humanized by replacing underscores with spaces. + */ +const powerWithheldHTML = (d: InferenceData, locale: Locale): string => { + if (!Array.isArray(d.power_invalid_reasons) || d.power_invalid_reasons.length === 0) return ''; + const codes = d.power_invalid_reasons + .filter( + (code) => typeof code === 'string' && code.length <= 64 && POWER_REASON_CODE_RE.test(code), + ) + .map((code) => code.replaceAll('_', ' ')); + if (codes.length === 0) return ''; + return tooltipLine(TOOLTIP_STRINGS[locale].powerWithheld, codes.join(', ')); +}; + /** * Generates HTML content for official data point tooltips. * @@ -572,6 +594,7 @@ export const generateTooltipContent = (config: TooltipConfig): string => { ${tooltipLine(t.concurrency, `${d.conc}`)} ${tooltipLine(t.precision, `${d.precision.toUpperCase()}`)} ${generateCacheMetadataHTML(d, locale)} + ${powerWithheldHTML(d, locale)} ${generateAgenticHTML(d, locale)} ${generateWorkerPowerHTML(d, isPinned, locale)} ${runLinkHTML(runUrl, locale)} @@ -614,6 +637,7 @@ export const generateOverlayTooltipContent = (config: OverlayTooltipConfig): str ${tooltipLine(t.concurrency, `${d.conc}`)} ${tooltipLine(t.precision, `${d.precision.toUpperCase()}`)} ${generateCacheMetadataHTML(d, locale)} + ${powerWithheldHTML(d, locale)} ${generateAgenticHTML(d, locale)} ${generateWorkerPowerHTML(d, isPinned, locale)} diff --git a/packages/app/src/lib/api-documentation.power.test.ts b/packages/app/src/lib/api-documentation.power.test.ts index d5d80bdd0..5d26d5452 100644 --- a/packages/app/src/lib/api-documentation.power.test.ts +++ b/packages/app/src/lib/api-documentation.power.test.ts @@ -23,12 +23,15 @@ describe('measured-power API documentation', () => { } }); - it('reserves optional power_invalid_reasons and power_audit row fields', () => { + it('documents optional power_invalid_reasons and power_audit row fields', () => { const reasons = benchmarkRowSchema?.properties?.power_invalid_reasons; expect(reasons?.type).toBe('array'); expect(reasons?.items).toEqual({ type: 'string' }); + // PLAN-07 turned the fields live; the reserved wording must not linger. + expect(reasons?.description).not.toMatch(/reserved|forthcoming/iu); const audit = benchmarkRowSchema?.properties?.power_audit; + expect(audit?.description).not.toMatch(/reserved|forthcoming/iu); expect(Object.keys(audit?.properties ?? {}).toSorted()).toEqual( [ 'window_start_unix', diff --git a/packages/app/src/lib/api-documentation.ts b/packages/app/src/lib/api-documentation.ts index 21386dc70..b3b4ca6ad 100644 --- a/packages/app/src/lib/api-documentation.ts +++ b/packages/app/src/lib/api-documentation.ts @@ -248,7 +248,7 @@ const powerAuditSchema: ApiSchema = { }, additionalProperties: true, description: - 'Optional power measurement-window audit. Individual fields may be absent; legacy rows omit the object.', + 'Compact power measurement-window audit emitted alongside the power_valid verdict: window bounds, expected vs. observed GPU counts, sample statistics, and producer identity (producer_sha / exporter_image_sha256 are null for single-node telemetry without an srt-slurm producer). Present on valid and invalid rows from provenance-aware producers; absent on legacy rows.', }; const workerPowerSchema = objectSchemaWithOptional( { @@ -297,7 +297,7 @@ const benchmarkRowSchema = objectSchemaWithOptional( power_invalid_reasons: { ...arraySchema(stringSchema), description: - 'Optional snake_case validation reason codes when metrics.power_valid == 0. Absent on legacy rows.', + 'Producer snake_case reason codes explaining a withheld measurement, present when metrics.power_valid == 0. Absent on legacy rows and validated rows.', }, power_audit: powerAuditSchema, date: { type: 'string', format: 'date' }, @@ -2678,8 +2678,8 @@ const overview = { id: 'measured-power', title: text('Measured power', '实测功率'), description: text( - 'Benchmark rows may carry measured power, energy, and GPU-telemetry metric keys (avg_power_w, joules_per_*, avg_temp_c, peak_temp_c, avg_util_pct, avg_mem_used_mb). power_valid is tri-state: 1 means the measurement window was validated; 0 means validation failed and measured values are withheld end-to-end (the producer strips them and ingest scrubs them — treat any that remain as unreliable); absent marks a legacy row predating validation. power_metric_schema_version == 2 defines every unprefixed joules_per_* field as whole-deployment energy — unversioned disaggregated joules are ambiguous because those fields previously carried role-local values. workers[] carries the per-worker power/telemetry breakdown on multinode and disaggregated runs. power_invalid_reasons and power_audit provide optional producer validation details. For measured-power requests, use powerValid=strictV2 to require power_valid == 1 and power_metric_schema_version == 2. It is the only supported power filter. Omit powerValid for general benchmark requests so results remain available even when they lack valid power measurements.', - '基准测试数据行可能包含实测功率、能耗和 GPU 遥测指标(avg_power_w、joules_per_*、avg_temp_c、peak_temp_c、avg_util_pct、avg_mem_used_mb)。power_valid 有三种状态:1 表示测量窗口已通过验证;0 表示验证失败,生产端会移除实测值,摄取端也会再次清除,若仍有残留,应视为不可靠;缺失表示该数据行早于验证机制。power_metric_schema_version == 2 规定所有无前缀的 joules_per_* 字段均按整个部署统计能耗。未标注版本的分离式部署数据中,这些字段曾记录单个角色的能耗,因此其统计口径不明确。多节点和分离式运行中,各 worker 的功率和遥测明细位于 workers[]。power_invalid_reasons 与 power_audit 可包含生产端的验证详情。查询实测功率时,使用 powerValid=strictV2,仅保留 power_valid == 1 且 power_metric_schema_version == 2 的行。这是唯一支持的功率筛选值。常规基准测试请求应省略 powerValid,以保留缺少有效功率测量的结果。', + 'Benchmark rows may carry measured power, energy, and GPU-telemetry metric keys (avg_power_w, joules_per_*, avg_temp_c, peak_temp_c, avg_util_pct, avg_mem_used_mb). power_valid is tri-state: 1 means the measurement window was validated; 0 means validation failed and measured values are withheld end-to-end (the producer strips them and ingest scrubs them — treat any that remain as unreliable); absent marks a legacy row predating validation. power_metric_schema_version == 2 defines every unprefixed joules_per_* field as whole-deployment energy — unversioned disaggregated joules are ambiguous because those fields previously carried role-local values. workers[] carries the per-worker power/telemetry breakdown on multinode and disaggregated runs. power_invalid_reasons lists the producer reason codes (snake_case) on withheld rows (power_valid == 0), and power_audit carries the compact measurement-window audit (window bounds, GPU counts, sample statistics, producer identity) on valid and invalid rows alike; both are absent on rows ingested before the provenance contract. For measured-power requests, use powerValid=strictV2 to require power_valid == 1 and power_metric_schema_version == 2. It is the only supported power filter. Omit powerValid for general benchmark requests so results remain available even when they lack valid power measurements.', + '基准测试数据行可能包含实测功率、能耗和 GPU 遥测指标(avg_power_w、joules_per_*、avg_temp_c、peak_temp_c、avg_util_pct、avg_mem_used_mb)。power_valid 有三种状态:1 表示测量窗口已通过验证;0 表示验证失败,生产端会移除实测值,摄取端也会再次清除,若仍有残留,应视为不可靠;缺失表示该数据行早于验证机制。power_metric_schema_version == 2 规定所有无前缀的 joules_per_* 字段均按整个部署统计能耗。未标注版本的分离式部署数据中,这些字段曾记录单个角色的能耗,因此其统计口径不明确。多节点和分离式运行中,各 worker 的功率和遥测明细位于 workers[]。power_invalid_reasons 在实测值被扣留的行(power_valid == 0)上列出生产端的 snake_case 原因码;power_audit 在有效与无效行上都携带精简的测量窗口审计信息(窗口边界、GPU 数量、采样统计、生产者标识);早于溯源契约摄取的行均不含这两个字段。查询实测功率时,使用 powerValid=strictV2,仅保留 power_valid == 1 且 power_metric_schema_version == 2 的行。这是唯一支持的功率筛选值。常规基准测试请求应省略 powerValid,以保留缺少有效功率测量的结果。', ), shape: 'BenchmarkRows', example: { diff --git a/packages/app/src/lib/api-route-catalog.ts b/packages/app/src/lib/api-route-catalog.ts index e01c8e5a7..451bab2b8 100644 --- a/packages/app/src/lib/api-route-catalog.ts +++ b/packages/app/src/lib/api-route-catalog.ts @@ -76,7 +76,7 @@ export const apiRouteCatalog = [ en: 'UI-only overlay for unofficial workflow artifacts; upstream artifact availability and shape are not stable.', zh: '仅供界面叠加非官方工作流制品;上游制品的可用性和结构并不稳定。', }, - sourceSha256: '4a3f3da8399c741c26f0f502d44b1870a8ccdc05775edfd6ea3dee4e020df25c', + sourceSha256: '252c57b34dfbf94335d085e34aee62e5b88467b27edcf98fdbe53166adec8cfe', }, { source: 'src/app/api/v1/agentic-aggregates/route.ts', @@ -731,7 +731,7 @@ export const apiContractSourceDigests = [ }, { source: '../db/src/queries/benchmarks.ts', - sourceSha256: '486e34d55275170c7e0544af24c151199752eb5628191d38606b1ecec289dfdf', + sourceSha256: '6f38e94cbeafed5383e4519bcf691e67cab0ea1fbf75e71d2dfb2de5bd3698dd', reviewArea: { en: 'Benchmark row fields and latest, exact-run, history, and TCO query semantics.', zh: '基准行字段以及最新、精确运行、历史和 TCO 查询语义。', diff --git a/packages/app/src/lib/benchmark-api-view.ts b/packages/app/src/lib/benchmark-api-view.ts index 372db5752..7f20aed83 100644 --- a/packages/app/src/lib/benchmark-api-view.ts +++ b/packages/app/src/lib/benchmark-api-view.ts @@ -31,6 +31,8 @@ interface BenchmarkViewRow { osl: number | null; metrics: Record; workers?: unknown; + power_invalid_reasons?: unknown; + power_audit?: unknown; } /** @@ -45,7 +47,14 @@ export function toCalculatorBenchmarkRows( return rows .filter((row) => rowToSequence(row) === sequence) .map((row) => { - const { workers: _workers, ...rest } = row; + // The calculator view excludes measured-power data by design, so the + // audit provenance that explains it goes too. + const { + workers: _workers, + power_invalid_reasons: _powerInvalidReasons, + power_audit: _powerAudit, + ...rest + } = row; return { ...rest, metrics: Object.fromEntries( diff --git a/packages/app/src/lib/benchmark-transform.ts b/packages/app/src/lib/benchmark-transform.ts index 48ba55c1d..9587f5cf9 100644 --- a/packages/app/src/lib/benchmark-transform.ts +++ b/packages/app/src/lib/benchmark-transform.ts @@ -217,6 +217,13 @@ export function rowToAggDataEntry(row: BenchmarkRow): AggDataEntry { // rows predating the field so downstream chart code can distinguish // "no measurement" from "0 W" via createChartDataPoint's typeof guard. power_valid: m.power_valid, + // Narrow at the trust boundary: API rows may carry null (SQL NULL) or + // omit the field entirely on legacy data; only a non-empty array of + // producer reason codes survives. + power_invalid_reasons: + Array.isArray(row.power_invalid_reasons) && row.power_invalid_reasons.length > 0 + ? row.power_invalid_reasons + : undefined, power_metric_schema_version: m.power_metric_schema_version, power_tier: resolvePowerTier({ powerValid: m.power_valid, diff --git a/packages/db/migrations/014_power_provenance.sql b/packages/db/migrations/014_power_provenance.sql new file mode 100644 index 000000000..76fe37fe3 --- /dev/null +++ b/packages/db/migrations/014_power_provenance.sql @@ -0,0 +1,162 @@ +-- ============================================================ +-- BENCHMARK_RESULTS — power audit provenance columns +-- ============================================================ +-- +-- Producers (aggregate_power.py) annotate every aggregate result row with two +-- optional provenance fields alongside the power_valid verdict: +-- +-- power_invalid_reasons — array of snake_case reason-code strings explaining +-- a withheld verdict (present when power_valid == 0); +-- power_audit — compact measurement-window audit object +-- {window_start_unix, window_end_unix, expected_gpu_count, +-- observed_gpu_count, sample_count, max_sample_gap_s, producer_sha, +-- exporter_image_sha256}, present on valid and invalid rows alike. +-- +-- These live in dedicated JSONB columns rather than in `metrics` (mirror of +-- the migration-006 `workers` rationale): metrics is a flat +-- Record — every consumer assumes scalar values, so an array +-- or object under one key would break parseNum and surface as "missing" +-- everywhere. Dedicated columns also let SELECTs skip the fields when a +-- query doesn't need them. NULL on legacy rows and rows whose producers +-- predate the provenance contract. + +alter table benchmark_results + add column power_invalid_reasons jsonb; + +alter table benchmark_results + add column power_audit jsonb; + +-- Re-create the latest_benchmarks materialized view so the new columns ride +-- on the view as well (`br.*` in a matview freezes the column list at +-- creation time). The definition below is copied VERBATIM from migration +-- 012_benchmark_recipe_fingerprint.sql — the current canonical definition — +-- solely so `br.*` picks up the new columns. Do not edit the body here; +-- line-selection changes belong in a migration of their own. + +drop materialized view if exists latest_benchmarks; + +create materialized view latest_benchmarks as +with recursive run_lines as ( + select + c.model, + c.hardware, + c.framework, + c.precision, + c.disagg, + case when br.benchmark_type = 'agentic_traces' then '' else c.spec_method end as line_spec_method, + br.benchmark_type, + br.isl, + br.osl, + br.offload_mode, + br.workflow_run_id, + br.date, + wr.run_started_at, + wr.append_only, + min(br.image) as image, + count(distinct br.image) as image_count, + bool_and(br.image is not null) as images_complete + from benchmark_results br + join configs c on c.id = br.config_id + join latest_workflow_runs wr on wr.id = br.workflow_run_id + where br.error is null + group by + c.model, c.hardware, c.framework, c.precision, c.disagg, + case when br.benchmark_type = 'agentic_traces' then '' else c.spec_method end, + br.benchmark_type, br.isl, br.osl, br.offload_mode, + br.workflow_run_id, br.date, wr.run_started_at, wr.append_only +), ranked_runs as ( + select + run_lines.*, + row_number() over ( + partition by + model, hardware, framework, precision, disagg, line_spec_method, + benchmark_type, isl, osl, offload_mode + order by date desc, run_started_at desc nulls last, workflow_run_id desc + ) as run_rank + from run_lines +), curve_runs as ( + select + ranked_runs.*, + ranked_runs.image as root_image, + ranked_runs.date as snapshot_date, + ranked_runs.workflow_run_id as snapshot_workflow_run_id + from ranked_runs + where run_rank = 1 + + union all + + select + older.*, + current.root_image, + current.snapshot_date, + current.snapshot_workflow_run_id + from curve_runs current + join ranked_runs older + on older.model = current.model + and older.hardware = current.hardware + and older.framework = current.framework + and older.precision = current.precision + and older.disagg = current.disagg + and older.line_spec_method = current.line_spec_method + and older.benchmark_type = current.benchmark_type + and older.isl is not distinct from current.isl + and older.osl is not distinct from current.osl + and older.offload_mode = current.offload_mode + and older.run_rank = current.run_rank + 1 + where current.append_only + and current.image_count = 1 + and current.images_complete + and older.image_count = 1 + and older.images_complete + and older.image = current.root_image +) +select distinct on ( + br.config_id, + br.benchmark_type, + br.isl, + br.osl, + br.offload_mode, + br.recipe_fingerprint, + br.conc +) + br.*, + cr.snapshot_date, + cr.snapshot_workflow_run_id +from curve_runs cr +join benchmark_results br + on br.workflow_run_id = cr.workflow_run_id + and br.benchmark_type = cr.benchmark_type + and br.isl is not distinct from cr.isl + and br.osl is not distinct from cr.osl + and br.offload_mode = cr.offload_mode +join configs point_c + on point_c.id = br.config_id + and point_c.model = cr.model + and point_c.hardware = cr.hardware + and point_c.framework = cr.framework + and point_c.precision = cr.precision + and point_c.disagg = cr.disagg + and case when br.benchmark_type = 'agentic_traces' then '' else point_c.spec_method end = cr.line_spec_method +where br.error is null +order by + br.config_id, + br.benchmark_type, + br.isl, + br.osl, + br.offload_mode, + br.recipe_fingerprint, + br.conc, + cr.run_rank; + +create unique index latest_benchmarks_pk + on latest_benchmarks ( + config_id, + conc, + isl, + osl, + benchmark_type, + offload_mode, + recipe_fingerprint + ) + nulls not distinct; +create index latest_benchmarks_model_idx on latest_benchmarks (config_id); diff --git a/packages/db/src/etl/benchmark-ingest.ts b/packages/db/src/etl/benchmark-ingest.ts index 617c51efe..72ba4098e 100644 --- a/packages/db/src/etl/benchmark-ingest.ts +++ b/packages/db/src/etl/benchmark-ingest.ts @@ -7,7 +7,7 @@ import path from 'node:path'; import type postgres from 'postgres'; import { cleanLogText, type ServerLogFile, type ServerLogFilePath } from './server-log-artifacts'; -import type { BenchmarkType, WorkerPower } from './benchmark-mapper'; +import type { BenchmarkType, PowerAudit, WorkerPower } from './benchmark-mapper'; import { kvCachePoolTokensFromServerLog } from './server-log-metrics'; type Sql = ReturnType; @@ -23,6 +23,8 @@ export interface BenchmarkPersistenceInput { recipeFingerprint: string | null; metrics: Record; workers?: WorkerPower[]; + powerInvalidReasons?: string[]; + powerAudit?: PowerAudit; } type BenchmarkPointIdentity = Pick< @@ -88,11 +90,21 @@ export async function bulkIngestBenchmarkRows( const workersJsons = deduped.map((r) => r.workers === undefined ? null : JSON.stringify(r.workers), ); + // Same encoding for the power audit provenance columns: JSON null lanes + // keep the jsonb[] unnest inputs homogeneous and store SQL NULL for rows + // whose artifacts predate the provenance contract. + const powerInvalidReasonsJsons = deduped.map((r) => + r.powerInvalidReasons === undefined ? null : JSON.stringify(r.powerInvalidReasons), + ); + const powerAuditJsons = deduped.map((r) => + r.powerAudit === undefined ? null : JSON.stringify(r.powerAudit), + ); const result = await sql<{ inserted: boolean; id: number }[]>` insert into benchmark_results ( workflow_run_id, config_id, benchmark_type, offload_mode, date, - isl, osl, conc, image, recipe_fingerprint, metrics, workers + isl, osl, conc, image, recipe_fingerprint, metrics, workers, + power_invalid_reasons, power_audit ) select ${workflowRunId}, @@ -106,7 +118,9 @@ export async function bulkIngestBenchmarkRows( unnest(${sql.array(images)}), unnest(${sql.array(recipeFingerprints)}), unnest(${sql.array(metricsJsons)}::jsonb[]), - unnest(${sql.array(workersJsons)}::jsonb[]) + unnest(${sql.array(workersJsons)}::jsonb[]), + unnest(${sql.array(powerInvalidReasonsJsons)}::jsonb[]), + unnest(${sql.array(powerAuditJsons)}::jsonb[]) on conflict ( workflow_run_id, config_id, benchmark_type, isl, osl, conc, offload_mode, recipe_fingerprint @@ -120,7 +134,11 @@ export async function bulkIngestBenchmarkRows( jsonb_build_object('kv_cache_pool_tokens', benchmark_results.metrics->'kv_cache_pool_tokens') ), image = excluded.image, - workers = excluded.workers + workers = excluded.workers, + -- Like workers, the fresh artifact is authoritative for provenance: a + -- re-ingest from an artifact without the fields deliberately nulls them. + power_invalid_reasons = excluded.power_invalid_reasons, + power_audit = excluded.power_audit returning (xmax = 0) as inserted, id `; diff --git a/packages/db/src/etl/benchmark-mapper.ts b/packages/db/src/etl/benchmark-mapper.ts index 0e6958f3b..81a3be497 100644 --- a/packages/db/src/etl/benchmark-mapper.ts +++ b/packages/db/src/etl/benchmark-mapper.ts @@ -86,6 +86,12 @@ const NON_METRIC_KEYS = new Set([ // sibling of the metrics JSONB by mapBenchmarkRow so the metrics column // stays Record for the index signature on BenchmarkRow. 'workers', + // Power audit provenance (array / object, never scalars). Extracted into + // dedicated BenchmarkParams fields by mapBenchmarkRow. Listed here because + // Number(['5']) === 5: without the guard a malformed single-element reasons + // array would be auto-captured as a bogus numeric metric. + 'power_invalid_reasons', + 'power_audit', ]); /** @@ -126,6 +132,25 @@ export interface WorkerPower { avg_mem_used_mb?: number; } +/** + * Compact measurement-window audit emitted by aggregate_power.py alongside + * the power_valid verdict (present on valid and invalid rows alike). All + * fields optional: {@link extractPowerAudit} omits malformed numerics rather + * than dropping the whole object, and single-node telemetry has no srt-slurm + * producer so both shas are null there. Stored on benchmark_results in the + * dedicated `power_audit` JSONB column (migration 014). + */ +export interface PowerAudit { + window_start_unix?: number; + window_end_unix?: number; + expected_gpu_count?: number; + observed_gpu_count?: number; + sample_count?: number; + max_sample_gap_s?: number; + producer_sha?: string | null; + exporter_image_sha256?: string | null; +} + export interface BenchmarkParams { config: ConfigParams; benchmarkType: BenchmarkType; @@ -148,6 +173,19 @@ export interface BenchmarkParams { * predating the multinode patch. */ workers?: WorkerPower[]; + /** + * Producer reason codes explaining a withheld power verdict + * (aggregate_power.py emits them when power_valid == 0). Stored in the + * dedicated `power_invalid_reasons` JSONB column (migration 014). + * Undefined for legacy artifacts and rows with a valid verdict. + */ + powerInvalidReasons?: string[]; + /** + * Compact power measurement-window audit from the same producer contract. + * Stored in the dedicated `power_audit` JSONB column (migration 014). + * Undefined for legacy artifacts predating the provenance contract. + */ + powerAudit?: PowerAudit; } /** @@ -357,6 +395,12 @@ export function mapBenchmarkRow( // narrowing — anything other than a non-empty array of objects is dropped, // and a withheld power verdict drops the payload entirely. const workers = powerWithheld ? undefined : extractWorkers(row.workers); + // Audit provenance is extracted unconditionally on power_valid: the + // contract says reasons appear when power_valid == 0, but the app stores + // whatever validly arrives — tolerance in both directions, and no data + // loss if a future producer annotates valid rows too. + const powerInvalidReasons = extractPowerInvalidReasons(row.power_invalid_reasons); + const powerAudit = extractPowerAudit(row.power_audit); return { config: { @@ -378,6 +422,8 @@ export function mapBenchmarkRow( recipeFingerprint, metrics, workers, + powerInvalidReasons, + powerAudit, }; } @@ -554,3 +600,91 @@ export function extractWorkers(raw: unknown): WorkerPower[] | undefined { } return out.length > 0 ? out : undefined; } + +/** Producer reason codes are lowercase snake_case identifiers. */ +const POWER_REASON_CODE_RE = /^[a-z][a-z0-9_]*$/u; +const MAX_POWER_REASON_CODES = 32; +const MAX_POWER_REASON_LENGTH = 64; +const MAX_POWER_AUDIT_SHA_LENGTH = 128; + +/** + * Narrow a raw `power_invalid_reasons` value from the artifact JSON to + * `string[]` or undefined. Entries must be snake_case strings (length ≤ 64 + * after trim) to be kept; anything else is dropped silently (same policy as + * `extractWorkers`). Deduplicates preserving first-seen order and caps the + * result at 32 codes. Returns undefined for any non-array input or an empty + * result so the eventual JSONB column stores null rather than `[]`. + */ +export function extractPowerInvalidReasons(raw: unknown): string[] | undefined { + if (!Array.isArray(raw)) return undefined; + const out: string[] = []; + const seen = new Set(); + for (const entry of raw) { + if (typeof entry !== 'string') continue; + const code = entry.trim(); + if (code.length === 0 || code.length > MAX_POWER_REASON_LENGTH) continue; + if (!POWER_REASON_CODE_RE.test(code) || seen.has(code)) continue; + seen.add(code); + out.push(code); + if (out.length >= MAX_POWER_REASON_CODES) break; + } + return out.length > 0 ? out : undefined; +} + +/** parseNum plus a finiteness guard: parseNum passes Infinity through. */ +function auditFiniteNum(v: unknown): number | undefined { + const n = parseNum(v); + return n !== undefined && Number.isFinite(n) ? n : undefined; +} + +/** GPU/sample counts must be non-negative safe integers. */ +function auditCount(v: unknown): number | undefined { + const n = parseInt2(v); + return n !== undefined && Number.isSafeInteger(n) && n >= 0 ? n : undefined; +} + +/** Trimmed non-empty string of bounded length, anything else → null. */ +function auditSha(v: unknown): string | null { + if (typeof v !== 'string') return null; + const s = v.trim(); + return s.length > 0 && s.length <= MAX_POWER_AUDIT_SHA_LENGTH ? s : null; +} + +/** + * Narrow a raw `power_audit` value from the artifact JSON to a fixed 8-key + * {@link PowerAudit} or undefined. Field-by-field: window/gap fields must be + * finite numbers, counts must be non-negative safe integers; malformed + * numerics are omitted, since a partial audit beats none. The shas keep a + * trimmed non-empty string of length ≤ 128 and collapse anything else + * (including explicit null) to null, matching the contract's string|null. + * Unknown keys are dropped so the stored object stays bounded. Returns + * undefined for non-object input and for an empty husk (no numeric field + * survived and both shas are null) so the eventual JSONB column stores null + * rather than `{}`. + */ +export function extractPowerAudit(raw: unknown): PowerAudit | undefined { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined; + const e = raw as Record; + const audit: PowerAudit = {}; + + const window_start_unix = auditFiniteNum(e.window_start_unix); + if (window_start_unix !== undefined) audit.window_start_unix = window_start_unix; + const window_end_unix = auditFiniteNum(e.window_end_unix); + if (window_end_unix !== undefined) audit.window_end_unix = window_end_unix; + const max_sample_gap_s = auditFiniteNum(e.max_sample_gap_s); + if (max_sample_gap_s !== undefined) audit.max_sample_gap_s = max_sample_gap_s; + const expected_gpu_count = auditCount(e.expected_gpu_count); + if (expected_gpu_count !== undefined) audit.expected_gpu_count = expected_gpu_count; + const observed_gpu_count = auditCount(e.observed_gpu_count); + if (observed_gpu_count !== undefined) audit.observed_gpu_count = observed_gpu_count; + const sample_count = auditCount(e.sample_count); + if (sample_count !== undefined) audit.sample_count = sample_count; + const hasNumericField = Object.keys(audit).length > 0; + + audit.producer_sha = auditSha(e.producer_sha); + audit.exporter_image_sha256 = auditSha(e.exporter_image_sha256); + if (!hasNumericField && audit.producer_sha === null && audit.exporter_image_sha256 === null) { + return undefined; + } + return audit; +} diff --git a/packages/db/src/queries/benchmarks.ts b/packages/db/src/queries/benchmarks.ts index 25aead1f6..8c915560b 100644 --- a/packages/db/src/queries/benchmarks.ts +++ b/packages/db/src/queries/benchmarks.ts @@ -1,6 +1,6 @@ import type { DbClient } from '../connection.js'; -import type { WorkerPower } from '../etl/benchmark-mapper.js'; -export type { WorkerPower } from '../etl/benchmark-mapper.js'; +import type { PowerAudit, WorkerPower } from '../etl/benchmark-mapper.js'; +export type { PowerAudit, WorkerPower } from '../etl/benchmark-mapper.js'; /** * One entry in `BenchmarkRow.workers` — mirrors the runner's aggregate_power.py @@ -47,6 +47,18 @@ export interface BenchmarkRow { * aggregate_power.py's multinode patch — surfaced as undefined here. */ workers?: BenchmarkWorkerRow[]; + /** + * Producer reason codes explaining a withheld power verdict. Stored in the + * dedicated `power_invalid_reasons` JSONB column (migration 014). + * Null/undefined on legacy rows and rows from valid runs. + */ + power_invalid_reasons?: string[] | null; + /** + * Compact power measurement-window audit from the producer contract. + * Stored in the dedicated `power_audit` JSONB column (migration 014). + * Null/undefined on legacy rows predating the provenance contract. + */ + power_audit?: PowerAudit | null; date: string; /** Producer identity and timestamp; preserved for per-point provenance. */ workflow_run_id?: number; @@ -256,6 +268,12 @@ function executeRecursiveBenchmarkQuery( br.recipe_fingerprint, ${plan.metricsExpression}, br.workers, + -- Deploy-order tolerance (the #405/#407 lesson): a bare br.power_* column + -- reference fails at query PLAN time until the next ingest run applies + -- migration 014. The jsonb key lookup degrades to NULL while the column + -- is missing and is byte-identical once it exists. + to_jsonb(br) -> 'power_invalid_reasons' AS power_invalid_reasons, + to_jsonb(br) -> 'power_audit' AS power_audit, br.date::text, br.workflow_run_id, wr.run_started_at::text, @@ -451,6 +469,10 @@ export async function getLatestBenchmarks( lb.recipe_fingerprint, lb.metrics, lb.workers, + -- Same deploy-order tolerance as the recursive branch: NULL until + -- migration 014 recreates latest_benchmarks, identical afterwards. + to_jsonb(lb) -> 'power_invalid_reasons' AS power_invalid_reasons, + to_jsonb(lb) -> 'power_audit' AS power_audit, lb.date::text, lb.workflow_run_id, wr.run_started_at::text, From 8dcdda3c942f21ea4b51bc35dff4f632f45aa1be Mon Sep 17 00:00:00 2001 From: Wenyao Gao Date: Thu, 27 Aug 2026 16:45:01 -0700 Subject: [PATCH 2/5] =?UTF-8?q?test(power):=20cover=20power=20audit=20prov?= =?UTF-8?q?enance=20across=20mapper,=20ingest,=20queries,=20and=20UI=20|?= =?UTF-8?q?=20=E6=B5=8B=E8=AF=95=EF=BC=9A=E8=A6=86=E7=9B=96=E5=8A=9F?= =?UTF-8?q?=E8=80=97=E5=AE=A1=E8=AE=A1=E6=BA=AF=E6=BA=90=E5=9C=A8=E6=98=A0?= =?UTF-8?q?=E5=B0=84=E3=80=81=E6=91=84=E5=8F=96=E3=80=81=E6=9F=A5=E8=AF=A2?= =?UTF-8?q?=E4=B8=8E=E7=95=8C=E9=9D=A2=E5=90=84=E5=B1=82=E7=9A=84=E8=A1=8C?= =?UTF-8?q?=E4=B8=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - extractPowerInvalidReasons: snake_case validation, dedupe, 32-code cap, 64-char boundary, empty/non-array/all-invalid -> undefined (never []) - extractPowerAudit: 8-field round-trip, Infinity/NaN/junk numerics omitted, negative and non-safe-integer counts rejected, sha trimming and null collapse, unknown keys dropped, empty husk -> undefined - mapBenchmarkRow lands the fields on BenchmarkParams for v1/v2/agentic rows, stores audits from valid rows too, and never mints a numeric metric from a malformed ['5'] reasons array (Number(['5']) === 5) - bulkIngestBenchmarkRows recording-mock: both columns in the INSERT list, two extra ::jsonb[] lanes, null lanes for absent fields, excluded.* refresh on conflict - all four read paths pin the tolerant to_jsonb(...) -> 'col' form and a negative regex guards against bare br./lb. references (the #407 lesson) - rowToAggDataEntry narrows null/[]/absent to undefined; tooltips render the bilingual withheld line only for sanitized codes (en + zh, overlay parity); calculator view strips both fields alongside workers 中文:为溯源字段新增全链路测试——提取函数的收窄规则(原因码校验、去重、上限、 空值处理;审计对象 8 字段往返、异常数值剔除、sha 归一化)、映射到 BenchmarkParams、批量摄取的 jsonb 通道与冲突刷新、四条读取路径的 to_jsonb 容错形式(并用反向正则钉死 #407 教训)、前端窄化与双语提示框行为、计算器 视图剥离。 --- .../src/app/api/unofficial-run/route.test.ts | 35 +++ .../inference/utils/tooltip-utils.test.ts | 68 +++++ .../app/src/lib/benchmark-api-view.test.ts | 9 + .../app/src/lib/benchmark-transform.test.ts | 19 ++ packages/db/src/etl/benchmark-ingest.test.ts | 76 ++++++ packages/db/src/etl/benchmark-mapper.test.ts | 246 +++++++++++++++++- packages/db/src/queries/benchmarks.test.ts | 42 +++ 7 files changed, 494 insertions(+), 1 deletion(-) diff --git a/packages/app/src/app/api/unofficial-run/route.test.ts b/packages/app/src/app/api/unofficial-run/route.test.ts index d4a8e5f4d..88b8ef856 100644 --- a/packages/app/src/app/api/unofficial-run/route.test.ts +++ b/packages/app/src/app/api/unofficial-run/route.test.ts @@ -193,6 +193,41 @@ describe('normalizeArtifactRows', () => { }, ); + it('carries power audit provenance on overlay rows', () => { + const audit = { + window_start_unix: 1756174800, + window_end_unix: 1756175400, + expected_gpu_count: 8, + observed_gpu_count: 8, + sample_count: 4800, + max_sample_gap_s: 1.013, + producer_sha: null, + exporter_image_sha256: null, + }; + const [row] = normalizeArtifactRows( + [ + rawRow({ + power_valid: 0, + power_invalid_reasons: ['sampling_gap_exceeded'], + power_audit: audit, + }), + ], + '2026-03-01', + ); + + expect(row.power_invalid_reasons).toEqual(['sampling_gap_exceeded']); + expect(row.power_audit).toEqual(audit); + // The provenance explains the withheld verdict; it never lands in metrics. + expect(row.metrics).not.toHaveProperty('power_invalid_reasons'); + expect(row.metrics).not.toHaveProperty('power_audit'); + }); + + it('leaves provenance keys undefined for rows without the contract fields', () => { + const [row] = normalizeArtifactRows([rawRow()], '2026-03-01'); + expect(row.power_invalid_reasons).toBeUndefined(); + expect(row.power_audit).toBeUndefined(); + }); + it('preserves recipe identity for unofficial overlays', () => { const rows = normalizeArtifactRows( [ diff --git a/packages/app/src/components/inference/utils/tooltip-utils.test.ts b/packages/app/src/components/inference/utils/tooltip-utils.test.ts index 013f77e02..b5ac1a45d 100644 --- a/packages/app/src/components/inference/utils/tooltip-utils.test.ts +++ b/packages/app/src/components/inference/utils/tooltip-utils.test.ts @@ -798,6 +798,74 @@ describe('generateGPUGraphTooltipContent', () => { }); }); +describe('measured-power withheld tooltip line', () => { + const reasons = ['sampling_gap_exceeded', 'expected_gpu_count_mismatch']; + + it('renders the withheld line with humanized codes (en)', () => { + const html = generateTooltipContent( + tooltipConfig({ data: pt({ power_valid: 0, power_invalid_reasons: reasons }) }), + ); + expect(html).toContain('Measured power withheld'); + expect(html).toContain('sampling gap exceeded'); + expect(html).toContain('expected gpu count mismatch'); + }); + + it('renders the withheld line in Chinese on /zh surfaces', () => { + const html = generateTooltipContent( + tooltipConfig({ + data: pt({ power_valid: 0, power_invalid_reasons: reasons }), + locale: 'zh', + }), + ); + expect(html).toContain('实测功耗未采信'); + expect(html).toContain('sampling gap exceeded'); + }); + + it('filters malformed codes before HTML interpolation (defense in depth)', () => { + const html = generateTooltipContent( + tooltipConfig({ + data: pt({ + power_valid: 0, + power_invalid_reasons: ['', 'sampling_gap_exceeded', 'UPPER'], + }), + }), + ); + expect(html).not.toContain(''); + expect(html).not.toContain('UPPER'); + expect(html).toContain('sampling gap exceeded'); + }); + + it('omits the line entirely when every code is malformed', () => { + const html = generateTooltipContent( + tooltipConfig({ data: pt({ power_valid: 0, power_invalid_reasons: [''] }) }), + ); + expect(html).not.toContain('Measured power withheld'); + }); + + it.each([ + ['absent reasons', pt({ power_valid: 0 })], + ['empty reasons', pt({ power_valid: 0, power_invalid_reasons: [] })], + ['valid row', pt({ power_valid: 1 })], + ])('omits the line for %s', (_name, data) => { + const html = generateTooltipContent(tooltipConfig({ data })); + expect(html).not.toContain('Measured power withheld'); + }); + + it('gives unofficial overlay tooltips the same line', () => { + const html = generateOverlayTooltipContent({ + ...tooltipConfig({ data: pt({ power_valid: 0, power_invalid_reasons: reasons }) }), + overlayData: { + label: 'feature-branch', + hardwareConfig: mockHardwareConfig, + data: [], + runUrl: 'https://example.com', + } as any, + } as OverlayTooltipConfig); + expect(html).toContain('Measured power withheld'); + expect(html).toContain('sampling gap exceeded'); + }); +}); + describe('worker power drilldown', () => { const workers = [ { role: 'frontend', worker_idx: 0, hosts: ['fe0'], num_gpus: 0, avg_power_w: 120 }, diff --git a/packages/app/src/lib/benchmark-api-view.test.ts b/packages/app/src/lib/benchmark-api-view.test.ts index a2772c3b8..c74397c99 100644 --- a/packages/app/src/lib/benchmark-api-view.test.ts +++ b/packages/app/src/lib/benchmark-api-view.test.ts @@ -14,6 +14,8 @@ const rows = [ unused_debug_metric: 99, }, workers: [{ rank: 0, avg_power_w: 700 }], + power_invalid_reasons: ['sampling_gap_exceeded'], + power_audit: { sample_count: 4800, producer_sha: null, exporter_image_sha256: null }, }, { benchmark_type: 'single_turn', @@ -46,6 +48,13 @@ describe('toCalculatorBenchmarkRows', () => { ]); }); + it('strips workers and the power audit provenance from the payload-trimmed view', () => { + const [row] = toCalculatorBenchmarkRows(rows, '1k/1k'); + expect(row).not.toHaveProperty('workers'); + expect(row).not.toHaveProperty('power_invalid_reasons'); + expect(row).not.toHaveProperty('power_audit'); + }); + it('keeps all three cache tiers — the trim cannot know which one a row will use', () => { // `measuredCacheHitRate` picks between external and CPU per row, so the allowlist // has to pass all three through or the choice is made for it by the trim. diff --git a/packages/app/src/lib/benchmark-transform.test.ts b/packages/app/src/lib/benchmark-transform.test.ts index ffd358c35..1723c86db 100644 --- a/packages/app/src/lib/benchmark-transform.test.ts +++ b/packages/app/src/lib/benchmark-transform.test.ts @@ -280,6 +280,25 @@ describe('rowToAggDataEntry', () => { expect(entry.joules_per_output_token).toBe(8.4); }); + it('passes through producer power_invalid_reasons on withheld rows', () => { + const entry = rowToAggDataEntry( + makeRow({ + metrics: { power_valid: 0 }, + power_invalid_reasons: ['thermal_throttle', 'sample_gap'], + }), + ); + expect(entry.power_invalid_reasons).toEqual(['thermal_throttle', 'sample_gap']); + }); + + it.each([ + ['legacy row without the field', {}], + ['API null (SQL NULL column)', { power_invalid_reasons: null }], + ['empty array', { power_invalid_reasons: [] }], + ])('leaves power_invalid_reasons undefined for %s', (_name, overrides) => { + const entry = rowToAggDataEntry(makeRow({ metrics: {}, ...overrides })); + expect(entry.power_invalid_reasons).toBeUndefined(); + }); + it('passes through versioned whole-deployment joules per successful query', () => { const entry = rowToAggDataEntry( makeRow({ diff --git a/packages/db/src/etl/benchmark-ingest.test.ts b/packages/db/src/etl/benchmark-ingest.test.ts index d1a1db6a3..4c886688d 100644 --- a/packages/db/src/etl/benchmark-ingest.test.ts +++ b/packages/db/src/etl/benchmark-ingest.test.ts @@ -7,8 +7,10 @@ import { describe, expect, it, vi } from 'vitest'; import type { Sql } from './db-utils'; import { benchmarkPointIngestKey, + bulkIngestBenchmarkRows, insertServerLogFilePaths, insertServerLogFiles, + type BenchmarkPersistenceInput, } from './benchmark-ingest'; const point = (recipeFingerprint: string | null) => ({ @@ -55,6 +57,80 @@ function fakeTransactionSql(linkedId: number | null) { return { sql: tag as Sql, calls }; } +function captureInsertSql() { + const calls: { text: string; values: unknown[] }[] = []; + const tag = vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => { + calls.push({ text: strings.join('?').replaceAll(/\s+/gu, ' ').trim(), values }); + return Promise.resolve([]); + }) as any; + tag.array = (value: unknown) => value; + return { sql: tag as Sql, calls }; +} + +describe('bulkIngestBenchmarkRows — power audit provenance lanes', () => { + const provenancedRow: BenchmarkPersistenceInput = { + configId: 7, + benchmarkType: 'single_turn', + isl: 1024, + osl: 1024, + conc: 64, + offloadMode: 'off', + image: 'img', + recipeFingerprint: null, + metrics: { power_valid: 0 }, + powerInvalidReasons: ['sampling_gap_exceeded'], + powerAudit: { + window_start_unix: 1756174800, + window_end_unix: 1756175400, + expected_gpu_count: 8, + observed_gpu_count: 8, + sample_count: 4800, + max_sample_gap_s: 1.013, + producer_sha: null, + exporter_image_sha256: null, + }, + }; + const legacyRow: BenchmarkPersistenceInput = { + configId: 8, + benchmarkType: 'single_turn', + isl: 1024, + osl: 1024, + conc: 128, + offloadMode: 'off', + image: 'img', + recipeFingerprint: null, + metrics: { tput_per_gpu: 100 }, + }; + + it('names both columns, adds two jsonb lanes, and refreshes both on conflict', async () => { + const { sql, calls } = captureInsertSql(); + await bulkIngestBenchmarkRows(sql, [provenancedRow, legacyRow], 42, '2026-08-27'); + + const { text } = calls[0]; + expect(text).toContain('metrics, workers, power_invalid_reasons, power_audit )'); + // metrics + workers + power_invalid_reasons + power_audit + expect(text.match(/::jsonb\[\]/gu)).toHaveLength(4); + expect(text).toContain('power_invalid_reasons = excluded.power_invalid_reasons'); + expect(text).toContain('power_audit = excluded.power_audit'); + }); + + it('serializes present fields and contributes null lanes for absent ones', async () => { + const { sql, calls } = captureInsertSql(); + await bulkIngestBenchmarkRows(sql, [provenancedRow, legacyRow], 42, '2026-08-27'); + + // Template value order mirrors the INSERT column list; the provenance + // lanes ride directly after metrics and workers. + const { values } = calls[0]; + expect(values[10]).toEqual([ + JSON.stringify(provenancedRow.metrics), + JSON.stringify(legacyRow.metrics), + ]); + expect(values[11]).toEqual([null, null]); + expect(values[12]).toEqual([JSON.stringify(provenancedRow.powerInvalidReasons), null]); + expect(values[13]).toEqual([JSON.stringify(provenancedRow.powerAudit), null]); + }); +}); + describe('insertServerLogFiles', () => { const files = [ { fileName: 'results/benchmark.log', logText: 'benchmark' }, diff --git a/packages/db/src/etl/benchmark-mapper.test.ts b/packages/db/src/etl/benchmark-mapper.test.ts index 0bc4f3b1a..dc672a397 100644 --- a/packages/db/src/etl/benchmark-mapper.test.ts +++ b/packages/db/src/etl/benchmark-mapper.test.ts @@ -1,6 +1,8 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { MEASURED_POWER_METRIC_KEYS } from '@semianalysisai/inferencex-constants'; import { + extractPowerAudit, + extractPowerInvalidReasons, extractWorkers, mapBenchmarkRow, normalizePowerContractMetrics, @@ -435,6 +437,13 @@ describe('mapBenchmarkRow', () => { } expect(result!.metrics).not.toHaveProperty('power_invalid_reasons'); expect(result!.metrics).not.toHaveProperty('power_audit'); + expect(result!.powerInvalidReasons).toEqual(['window_too_short']); + expect(result!.powerAudit).toEqual({ + window_start_unix: 1, + window_end_unix: 2, + producer_sha: null, + exporter_image_sha256: null, + }); }); }); @@ -974,6 +983,241 @@ describe('extractWorkers', () => { }); }); +describe('extractPowerInvalidReasons', () => { + it('keeps valid snake_case codes in first-seen order', () => { + expect( + extractPowerInvalidReasons(['sampling_gap_exceeded', 'expected_gpu_count_mismatch']), + ).toEqual(['sampling_gap_exceeded', 'expected_gpu_count_mismatch']); + }); + + it('deduplicates preserving first-seen order', () => { + expect( + extractPowerInvalidReasons([ + 'telemetry_file_missing', + 'no_usable_power_samples', + 'telemetry_file_missing', + ]), + ).toEqual(['telemetry_file_missing', 'no_usable_power_samples']); + }); + + it.each([ + ['non-string entry', [42]], + ['empty string', ['']], + ['hyphenated code', ['Bad-Reason']], + ['uppercase code', ['UPPER']], + ['leading digit', ['9lives']], + ['65-char code', ['a'.repeat(65)]], + ])('silently drops %s', (_name, raw) => { + expect(extractPowerInvalidReasons(raw)).toBeUndefined(); + }); + + it('drops malformed entries while keeping valid siblings', () => { + expect(extractPowerInvalidReasons([42, 'sampling_gap_exceeded', '', null])).toEqual([ + 'sampling_gap_exceeded', + ]); + }); + + it('keeps a 64-char code (boundary)', () => { + const code = 'a'.repeat(64); + expect(extractPowerInvalidReasons([code])).toEqual([code]); + }); + + it('caps the result at 32 codes', () => { + const raw = Array.from({ length: 40 }, (_v, i) => `reason_${i}`); + const result = extractPowerInvalidReasons(raw); + expect(result).toHaveLength(32); + expect(result![0]).toBe('reason_0'); + expect(result![31]).toBe('reason_31'); + }); + + it.each([ + ['empty array', []], + ['non-array object', { reason: 'x' }], + ['string', 'sampling_gap_exceeded'], + ['null', null], + ['undefined', undefined], + ])('returns undefined (never []) for %s', (_name, raw) => { + expect(extractPowerInvalidReasons(raw)).toBeUndefined(); + }); +}); + +describe('extractPowerAudit', () => { + /** Full valid audit as aggregate_power.py emits it on a multinode run. */ + const fullAudit = { + window_start_unix: 1756174800.25, + window_end_unix: 1756175400.75, + expected_gpu_count: 16, + observed_gpu_count: 16, + sample_count: 9600, + max_sample_gap_s: 1.013, + producer_sha: '887a6cb7c2ec174e5e2b977468a12ab34cd56ef7', + exporter_image_sha256: + 'sha256:0b7f1a2c3d4e5f60718293a4b5c6d7e8f9012a3b4c5d6e7f8091a2b3c4d5e6f7', + }; + + it('round-trips a full valid object across all 8 fields', () => { + expect(extractPowerAudit(fullAudit)).toEqual(fullAudit); + }); + + it('omits Infinity / NaN / junk-string numerics (partial audit beats none)', () => { + expect( + extractPowerAudit({ + ...fullAudit, + window_start_unix: Number.POSITIVE_INFINITY, + window_end_unix: Number.NaN, + max_sample_gap_s: 'garbage', + }), + ).toEqual({ + expected_gpu_count: 16, + observed_gpu_count: 16, + sample_count: 9600, + producer_sha: fullAudit.producer_sha, + exporter_image_sha256: fullAudit.exporter_image_sha256, + }); + }); + + it('rejects negative and non-safe-integer counts', () => { + expect( + extractPowerAudit({ + ...fullAudit, + expected_gpu_count: -1, + observed_gpu_count: Number.MAX_SAFE_INTEGER + 1, + sample_count: Number.POSITIVE_INFINITY, + }), + ).toEqual({ + window_start_unix: fullAudit.window_start_unix, + window_end_unix: fullAudit.window_end_unix, + max_sample_gap_s: fullAudit.max_sample_gap_s, + producer_sha: fullAudit.producer_sha, + exporter_image_sha256: fullAudit.exporter_image_sha256, + }); + }); + + it('keeps shas trimmed and collapses null / number / oversized shas to null', () => { + expect( + extractPowerAudit({ + sample_count: 1, + producer_sha: ' abc123 ', + exporter_image_sha256: null, + }), + ).toEqual({ sample_count: 1, producer_sha: 'abc123', exporter_image_sha256: null }); + expect( + extractPowerAudit({ + sample_count: 1, + producer_sha: 42, + exporter_image_sha256: 'x'.repeat(129), + }), + ).toEqual({ sample_count: 1, producer_sha: null, exporter_image_sha256: null }); + }); + + it('nulls the explicit-null numeric fields a producer emits without a benchmark window', () => { + // aggregate_power.py sets window_* / max_sample_gap_s to null when the + // benchmark result was unreadable; those are omitted, not kept as null. + expect( + extractPowerAudit({ + window_start_unix: null, + window_end_unix: null, + expected_gpu_count: 8, + observed_gpu_count: 0, + sample_count: 0, + max_sample_gap_s: null, + producer_sha: null, + exporter_image_sha256: null, + }), + ).toEqual({ + expected_gpu_count: 8, + observed_gpu_count: 0, + sample_count: 0, + producer_sha: null, + exporter_image_sha256: null, + }); + }); + + it('drops unknown keys (fixed 8-key shape bounds the stored object)', () => { + expect(extractPowerAudit({ sample_count: 3, integration_method: 'trapezoid' })).toEqual({ + sample_count: 3, + producer_sha: null, + exporter_image_sha256: null, + }); + }); + + it.each([ + ['string', 'audit'], + ['array', [1, 2]], + ['null', null], + ['undefined', undefined], + ['number', 7], + ])('returns undefined for non-object input: %s', (_name, raw) => { + expect(extractPowerAudit(raw)).toBeUndefined(); + }); + + it('returns undefined for an empty husk (no numerics, both shas null)', () => { + expect(extractPowerAudit({})).toBeUndefined(); + expect(extractPowerAudit({ producer_sha: null, exporter_image_sha256: 42 })).toBeUndefined(); + expect(extractPowerAudit({ window_start_unix: 'junk' })).toBeUndefined(); + }); +}); + +describe('mapBenchmarkRow — power audit provenance', () => { + const reasons = ['sampling_gap_exceeded', 'expected_gpu_count_mismatch']; + const audit = { + window_start_unix: 1756174800, + window_end_unix: 1756175400, + expected_gpu_count: 8, + observed_gpu_count: 8, + sample_count: 4800, + max_sample_gap_s: 1.013, + producer_sha: null, + exporter_image_sha256: null, + }; + + it.each([ + ['v1', makeV1Row], + ['v2', makeV2Row], + ['agentic', makeAgenticRow], + ])('lands the contract fields on BenchmarkParams for %s rows', (_name, makeRow) => { + const tracker = createSkipTracker(); + const result = mapBenchmarkRow( + makeRow({ power_valid: 0, power_invalid_reasons: reasons, power_audit: audit }), + tracker, + ); + expect(result!.powerInvalidReasons).toEqual(reasons); + expect(result!.powerAudit).toEqual(audit); + }); + + it('stores provenance from valid rows too (tolerance in both directions)', () => { + const tracker = createSkipTracker(); + const result = mapBenchmarkRow(makeV2Row({ power_valid: 1, power_audit: audit }), tracker); + expect(result!.metrics.power_valid).toBe(1); + expect(result!.powerAudit).toEqual(audit); + expect(result!.powerInvalidReasons).toBeUndefined(); + }); + + it('leaves both fields undefined on legacy rows', () => { + const tracker = createSkipTracker(); + const result = mapBenchmarkRow(makeV2Row(), tracker); + expect(result!.powerInvalidReasons).toBeUndefined(); + expect(result!.powerAudit).toBeUndefined(); + }); + + it("never captures a malformed ['5'] reasons array as a numeric metric", () => { + // Number(['5']) === 5 — without the NON_METRIC_KEYS guard the generic + // capture loop would mint a bogus power_invalid_reasons numeric metric. + const tracker = createSkipTracker(); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const result = mapBenchmarkRow(makeV2Row({ power_invalid_reasons: ['5'] }), tracker); + expect(result!.metrics).not.toHaveProperty('power_invalid_reasons'); + expect(result!.powerInvalidReasons).toBeUndefined(); + expect(warn.mock.calls.map((call) => String(call[0]))).not.toContainEqual( + expect.stringContaining('power_invalid_reasons'), + ); + } finally { + warn.mockRestore(); + } + }); +}); + describe('mapBenchmarkRow — agentic interactivity normalization', () => { it('derives *_intvty from 1/*_itl, discarding the artifact value', () => { const tracker = createSkipTracker(); diff --git a/packages/db/src/queries/benchmarks.test.ts b/packages/db/src/queries/benchmarks.test.ts index bdebe5b82..90f6c5cf2 100644 --- a/packages/db/src/queries/benchmarks.test.ts +++ b/packages/db/src/queries/benchmarks.test.ts @@ -113,3 +113,45 @@ describe('append-only benchmark snapshots', () => { expect(values).toEqual([['dsv4']]); }); }); + +describe('power audit provenance reads (tolerant to a not-yet-applied migration 014)', () => { + const TOLERANT_BR = [ + "to_jsonb(br) -> 'power_invalid_reasons' AS power_invalid_reasons", + "to_jsonb(br) -> 'power_audit' AS power_audit", + ]; + + it('selects both columns via to_jsonb on the exact-run path', async () => { + const captured = captureSql(); + await getBenchmarksForRun(captured.sql, 'dsv4', 123456); + const { text } = captured.query(); + for (const piece of TOLERANT_BR) expect(text).toContain(piece); + expect(text).not.toMatch(/\b(?:br|lb)\.power_(?:invalid_reasons|audit)\b/u); + }); + + it('selects both columns via to_jsonb on the dated latest path', async () => { + const captured = captureSql(); + await getLatestBenchmarks(captured.sql, 'dsv4', '2026-08-01'); + const { text } = captured.query(); + for (const piece of TOLERANT_BR) expect(text).toContain(piece); + expect(text).not.toMatch(/\b(?:br|lb)\.power_(?:invalid_reasons|audit)\b/u); + }); + + it('selects both columns via to_jsonb on the history path', async () => { + const captured = captureSql(); + await getAllBenchmarksForHistory(captured.sql, 'dsv4', 8192, 1024); + const { text } = captured.query(); + for (const piece of TOLERANT_BR) expect(text).toContain(piece); + expect(text).not.toMatch(/\b(?:br|lb)\.power_(?:invalid_reasons|audit)\b/u); + }); + + it('selects both columns via to_jsonb on the no-date matview path', async () => { + const captured = captureSql(); + await getLatestBenchmarks(captured.sql, 'dsv4'); + const { text } = captured.query(); + expect(text).toContain("to_jsonb(lb) -> 'power_invalid_reasons' AS power_invalid_reasons"); + expect(text).toContain("to_jsonb(lb) -> 'power_audit' AS power_audit"); + // The #407 lesson, pinned: a bare column reference would fail to PLAN + // until the next ingest run applies migration 014. + expect(text).not.toMatch(/\b(?:br|lb)\.power_(?:invalid_reasons|audit)\b/u); + }); +}); From 77c512469165fecf074f3146b96b501996e47c07 Mon Sep 17 00:00:00 2001 From: Wenyao Gao Date: Thu, 27 Aug 2026 17:04:12 -0700 Subject: [PATCH 3/5] =?UTF-8?q?fix(etl):=20persist=20power=20provenance=20?= =?UTF-8?q?from=20the=20supplemental=20ingest=20lane=20|=20ETL=EF=BC=9A?= =?UTF-8?q?=E8=A1=A5=E5=85=85=E6=95=B0=E6=8D=AE=E6=91=84=E5=8F=96=E9=80=9A?= =?UTF-8?q?=E9=81=93=E5=90=8C=E6=A0=B7=E6=8C=81=E4=B9=85=E5=8C=96=E5=8A=9F?= =?UTF-8?q?=E8=80=97=E6=BA=AF=E6=BA=90=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review found ingest-supplemental.ts participates in the power publication contract (normalize + scrub, PLAN-03) but silently dropped power_invalid_reasons / power_audit: the persistence input carried no provenance fields, so a supplemental entry with them would persist NULL columns. Extract both via the shared narrowers — entry-level fields sibling to metrics (mirroring artifact rows), with a metrics-nested fallback since power_valid rides in metrics in this format — and delete the keys from metrics so the persisted jsonb stays a flat numeric record. Pin the call sequence next to the PLAN-03 supplemental tests. --- packages/db/src/etl/benchmark-mapper.test.ts | 28 ++++++++++++++++++++ packages/db/src/ingest-supplemental.ts | 24 ++++++++++++++++- 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/packages/db/src/etl/benchmark-mapper.test.ts b/packages/db/src/etl/benchmark-mapper.test.ts index dc672a397..57bd56664 100644 --- a/packages/db/src/etl/benchmark-mapper.test.ts +++ b/packages/db/src/etl/benchmark-mapper.test.ts @@ -897,6 +897,34 @@ describe('scrubWithheldPowerMetrics (direct — supplemental ingest path)', () = expect(metrics).not.toHaveProperty(key); } }); + + it('recovers provenance companions nested under metrics and leaves the record flat', () => { + // ingest-supplemental.ts also accepts the provenance companions nested in + // `metrics` (where power_valid rides in that format), extracting them via + // the shared narrowers and deleting the keys so the persisted metrics + // jsonb stays a flat numeric record. Pin that sequence. + const metrics = supplementalMetrics({ + power_valid: 0, + power_invalid_reasons: ['sampling_gap_exceeded', 'sampling_gap_exceeded', ''], + power_audit: { sample_count: 12, producer_sha: 'abc123', unknown_key: true }, + }); + normalizePowerContractMetrics(metrics, metrics); + scrubWithheldPowerMetrics(metrics); + const reasons = extractPowerInvalidReasons(metrics.power_invalid_reasons); + const audit = extractPowerAudit(metrics.power_audit); + delete metrics.power_invalid_reasons; + delete metrics.power_audit; + + expect(reasons).toEqual(['sampling_gap_exceeded']); + expect(audit).toEqual({ + sample_count: 12, + producer_sha: 'abc123', + exporter_image_sha256: null, + }); + expect(metrics).not.toHaveProperty('power_invalid_reasons'); + expect(metrics).not.toHaveProperty('power_audit'); + expect(metrics.tput_per_gpu).toBe(567.8); + }); }); describe('extractWorkers', () => { diff --git a/packages/db/src/ingest-supplemental.ts b/packages/db/src/ingest-supplemental.ts index 18a2a8b55..9017345cb 100644 --- a/packages/db/src/ingest-supplemental.ts +++ b/packages/db/src/ingest-supplemental.ts @@ -27,7 +27,12 @@ import { bulkUpsertAvailability, type BenchmarkPersistenceInput, } from './etl/benchmark-ingest'; -import { normalizePowerContractMetrics, scrubWithheldPowerMetrics } from './etl/benchmark-mapper'; +import { + extractPowerAudit, + extractPowerInvalidReasons, + normalizePowerContractMetrics, + scrubWithheldPowerMetrics, +} from './etl/benchmark-mapper'; import { ingestEvalRow } from './etl/eval-ingest'; const sql = createAdminSql({ @@ -180,6 +185,10 @@ interface SupplementalBmk { is_multinode?: boolean; prefill_num_workers?: number; decode_num_workers?: number; + /** Producer power-audit provenance (PLAN-06 contract), sibling to `metrics` + * like on artifact rows; narrowed by the shared extractors. */ + power_invalid_reasons?: unknown; + power_audit?: unknown; } async function ingestSupplementalBmk( @@ -271,6 +280,17 @@ async function ingestSupplementalBmk( // then strip withheld measurements when power_valid=0. normalizePowerContractMetrics(entry.metrics, entry.metrics); scrubWithheldPowerMetrics(entry.metrics); + // The provenance companions ride the entry itself (sibling to the flat + // numeric `metrics` record), but accept a nesting under `metrics` too — + // power_valid lives there in this format. Delete the keys from `metrics` + // either way so the structured companions never enter the persisted + // metrics jsonb (the NON_METRIC_KEYS guarantee on the mapper path). + const powerInvalidReasons = extractPowerInvalidReasons( + entry.power_invalid_reasons ?? entry.metrics.power_invalid_reasons, + ); + const powerAudit = extractPowerAudit(entry.power_audit ?? entry.metrics.power_audit); + delete entry.metrics.power_invalid_reasons; + delete entry.metrics.power_audit; rows.push({ configId, @@ -282,6 +302,8 @@ async function ingestSupplementalBmk( image: entry.image, recipeFingerprint: null, metrics: entry.metrics, + powerInvalidReasons, + powerAudit, }); } From 8a5f9e1b40fa62a50cdc245849025104deef2e29 Mon Sep 17 00:00:00 2001 From: Wenyao Gao Date: Mon, 31 Aug 2026 13:52:40 -0700 Subject: [PATCH 4/5] chore(comments): clarify power provenance rationale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 中文:清理功耗溯源实现中的内部计划标签、重复测试说明和装饰性注释,同时保留迁移顺序、部署容错、数据收窄、安全与持久化约束。 --- docs/data-pipeline.md | 2 +- .../src/app/api/unofficial-run/route.test.ts | 1 - .../app/src/app/api/unofficial-run/route.ts | 2 - .../app/src/components/inference/types.ts | 4 -- .../inference/utils/tooltipUtils.ts | 8 +-- .../src/lib/api-documentation.power.test.ts | 2 - packages/app/src/lib/api-route-catalog.ts | 4 +- packages/app/src/lib/benchmark-api-view.ts | 3 +- .../db/migrations/014_power_provenance.sql | 32 ++--------- packages/db/src/etl/benchmark-ingest.test.ts | 3 - packages/db/src/etl/benchmark-ingest.ts | 8 +-- packages/db/src/etl/benchmark-mapper.test.ts | 9 --- packages/db/src/etl/benchmark-mapper.ts | 55 ++++--------------- packages/db/src/ingest-supplemental.ts | 9 +-- packages/db/src/queries/benchmarks.test.ts | 2 - packages/db/src/queries/benchmarks.ts | 23 +++----- 16 files changed, 32 insertions(+), 135 deletions(-) diff --git a/docs/data-pipeline.md b/docs/data-pipeline.md index cc6c2a79c..f5c996318 100644 --- a/docs/data-pipeline.md +++ b/docs/data-pipeline.md @@ -452,4 +452,4 @@ Producers (`aggregate_power.py`) annotate every aggregate result row with two op `mapBenchmarkRow()` narrows them defensively (`extractPowerInvalidReasons` / `extractPowerAudit`): reason codes must match `/^[a-z][a-z0-9_]*$/` (≤ 64 chars, deduplicated, capped at 32), audit numerics must be finite (counts: non-negative safe integers), shas collapse to `null` unless a non-empty string ≤ 128 chars, and unknown audit keys are dropped. An empty result maps to `undefined`, so the dedicated `benchmark_results.power_invalid_reasons` / `power_audit` JSONB columns (migration 014, mirroring the `workers` precedent from migration 006) store SQL NULL — never `[]` or `{}`. Legacy artifacts without the fields flow through every layer as NULL/undefined. -Reads are **permanently tolerant**: `queries/benchmarks.ts` selects the columns as `to_jsonb(br) -> 'power_invalid_reasons'` (and `lb` on the matview branch) rather than bare column references. A bare reference fails at query _plan_ time until the next ingest workflow applies the migration (migrations run in the ingest workflows, not at Vercel deploy — PR #405 shipped bare reads ahead of migration 006 and served 500s until #407 identified this jsonb-lookup primitive). The key lookup degrades to NULL while the column is missing and is byte-identical once it exists, making deploy order irrelevant. +Reads are **permanently tolerant**: `queries/benchmarks.ts` selects the columns as `to_jsonb(br) -> 'power_invalid_reasons'` (and `lb` on the matview branch) rather than bare column references. A bare reference fails during query planning until the next ingest workflow applies the migration, because migrations run in the ingest workflows rather than at Vercel deploy. The key lookup degrades to NULL while the column is missing and is byte-identical once it exists, making deploy order irrelevant. diff --git a/packages/app/src/app/api/unofficial-run/route.test.ts b/packages/app/src/app/api/unofficial-run/route.test.ts index 88b8ef856..72f124925 100644 --- a/packages/app/src/app/api/unofficial-run/route.test.ts +++ b/packages/app/src/app/api/unofficial-run/route.test.ts @@ -217,7 +217,6 @@ describe('normalizeArtifactRows', () => { expect(row.power_invalid_reasons).toEqual(['sampling_gap_exceeded']); expect(row.power_audit).toEqual(audit); - // The provenance explains the withheld verdict; it never lands in metrics. expect(row.metrics).not.toHaveProperty('power_invalid_reasons'); expect(row.metrics).not.toHaveProperty('power_audit'); }); diff --git a/packages/app/src/app/api/unofficial-run/route.ts b/packages/app/src/app/api/unofficial-run/route.ts index 2781eb092..48a517a3f 100644 --- a/packages/app/src/app/api/unofficial-run/route.ts +++ b/packages/app/src/app/api/unofficial-run/route.ts @@ -73,8 +73,6 @@ export function normalizeArtifactRows( // Surface the same per-worker payload the DB path emits so unofficial // overlays carry the multinode measured-power breakdown too. workers: params.workers, - // Same parity for the power audit provenance: overlay rows explain a - // withheld verdict exactly like persisted rows do. power_invalid_reasons: params.powerInvalidReasons, power_audit: params.powerAudit, date, diff --git a/packages/app/src/components/inference/types.ts b/packages/app/src/components/inference/types.ts index a22aaab21..433fb321e 100644 --- a/packages/app/src/components/inference/types.ts +++ b/packages/app/src/components/inference/types.ts @@ -115,10 +115,6 @@ export interface AggDataEntry { // Measured GPU telemetry (emitted by runner's aggregate_power.py). // Optional because historical runs predate the fields. power_valid?: number; - /** - * Producer reason codes explaining a withheld power verdict - * (PLAN-06 contract; present only when power_valid == 0 upstream). - */ power_invalid_reasons?: string[]; power_metric_schema_version?: number; /** diff --git a/packages/app/src/components/inference/utils/tooltipUtils.ts b/packages/app/src/components/inference/utils/tooltipUtils.ts index b16ab6437..4d7b95211 100644 --- a/packages/app/src/components/inference/utils/tooltipUtils.ts +++ b/packages/app/src/components/inference/utils/tooltipUtils.ts @@ -521,15 +521,9 @@ const generateParallelismHTML = (d: InferenceData, locale: Locale = 'en'): strin ${tooltipLine(t.dpAttention, d.dp_attention ? t.yes : t.no)}`; }; -/** Producer reason codes are snake_case; anything else never reaches the DOM. */ const POWER_REASON_CODE_RE = /^[a-z][a-z0-9_]*$/u; -/** - * One muted line explaining a withheld measured-power verdict. Empty unless - * the point carries producer reason codes. Codes are re-validated against the - * snake_case shape before interpolation (defense in depth — tooltip content - * is raw HTML) and humanized by replacing underscores with spaces. - */ +/** Revalidate producer codes before interpolating them into raw tooltip HTML. */ const powerWithheldHTML = (d: InferenceData, locale: Locale): string => { if (!Array.isArray(d.power_invalid_reasons) || d.power_invalid_reasons.length === 0) return ''; const codes = d.power_invalid_reasons diff --git a/packages/app/src/lib/api-documentation.power.test.ts b/packages/app/src/lib/api-documentation.power.test.ts index 5d26d5452..ab0aceb3c 100644 --- a/packages/app/src/lib/api-documentation.power.test.ts +++ b/packages/app/src/lib/api-documentation.power.test.ts @@ -27,7 +27,6 @@ describe('measured-power API documentation', () => { const reasons = benchmarkRowSchema?.properties?.power_invalid_reasons; expect(reasons?.type).toBe('array'); expect(reasons?.items).toEqual({ type: 'string' }); - // PLAN-07 turned the fields live; the reserved wording must not linger. expect(reasons?.description).not.toMatch(/reserved|forthcoming/iu); const audit = benchmarkRowSchema?.properties?.power_audit; @@ -44,7 +43,6 @@ describe('measured-power API documentation', () => { 'exporter_image_sha256', ].toSorted(), ); - // Producers may emit partial audits, so individual audit fields remain optional. expect(audit?.required).toBeUndefined(); expect(benchmarkRowSchema?.required).not.toContain('power_invalid_reasons'); diff --git a/packages/app/src/lib/api-route-catalog.ts b/packages/app/src/lib/api-route-catalog.ts index 451bab2b8..a00759cf7 100644 --- a/packages/app/src/lib/api-route-catalog.ts +++ b/packages/app/src/lib/api-route-catalog.ts @@ -76,7 +76,7 @@ export const apiRouteCatalog = [ en: 'UI-only overlay for unofficial workflow artifacts; upstream artifact availability and shape are not stable.', zh: '仅供界面叠加非官方工作流制品;上游制品的可用性和结构并不稳定。', }, - sourceSha256: '252c57b34dfbf94335d085e34aee62e5b88467b27edcf98fdbe53166adec8cfe', + sourceSha256: '6973369b5ee4ded847754b7d38ec7b9db7c30f895c9b0e54c7aaf9b2bf7eb45d', }, { source: 'src/app/api/v1/agentic-aggregates/route.ts', @@ -731,7 +731,7 @@ export const apiContractSourceDigests = [ }, { source: '../db/src/queries/benchmarks.ts', - sourceSha256: '6f38e94cbeafed5383e4519bcf691e67cab0ea1fbf75e71d2dfb2de5bd3698dd', + sourceSha256: '0793d7901630d74301493a34436c8ad868cc4c938d46ba40256b19fc76b17332', reviewArea: { en: 'Benchmark row fields and latest, exact-run, history, and TCO query semantics.', zh: '基准行字段以及最新、精确运行、历史和 TCO 查询语义。', diff --git a/packages/app/src/lib/benchmark-api-view.ts b/packages/app/src/lib/benchmark-api-view.ts index 7f20aed83..3f552267f 100644 --- a/packages/app/src/lib/benchmark-api-view.ts +++ b/packages/app/src/lib/benchmark-api-view.ts @@ -47,8 +47,7 @@ export function toCalculatorBenchmarkRows( return rows .filter((row) => rowToSequence(row) === sequence) .map((row) => { - // The calculator view excludes measured-power data by design, so the - // audit provenance that explains it goes too. + // Omit provenance with the measured-power fields it explains. const { workers: _workers, power_invalid_reasons: _powerInvalidReasons, diff --git a/packages/db/migrations/014_power_provenance.sql b/packages/db/migrations/014_power_provenance.sql index 76fe37fe3..82916a4e2 100644 --- a/packages/db/migrations/014_power_provenance.sql +++ b/packages/db/migrations/014_power_provenance.sql @@ -1,24 +1,5 @@ --- ============================================================ --- BENCHMARK_RESULTS — power audit provenance columns --- ============================================================ --- --- Producers (aggregate_power.py) annotate every aggregate result row with two --- optional provenance fields alongside the power_valid verdict: --- --- power_invalid_reasons — array of snake_case reason-code strings explaining --- a withheld verdict (present when power_valid == 0); --- power_audit — compact measurement-window audit object --- {window_start_unix, window_end_unix, expected_gpu_count, --- observed_gpu_count, sample_count, max_sample_gap_s, producer_sha, --- exporter_image_sha256}, present on valid and invalid rows alike. --- --- These live in dedicated JSONB columns rather than in `metrics` (mirror of --- the migration-006 `workers` rationale): metrics is a flat --- Record — every consumer assumes scalar values, so an array --- or object under one key would break parseNum and surface as "missing" --- everywhere. Dedicated columns also let SELECTs skip the fields when a --- query doesn't need them. NULL on legacy rows and rows whose producers --- predate the provenance contract. +-- Power audit provenance lives in dedicated JSONB columns so `metrics` stays +-- a flat Record. NULL identifies rows from older producers. alter table benchmark_results add column power_invalid_reasons jsonb; @@ -26,12 +7,9 @@ alter table benchmark_results alter table benchmark_results add column power_audit jsonb; --- Re-create the latest_benchmarks materialized view so the new columns ride --- on the view as well (`br.*` in a matview freezes the column list at --- creation time). The definition below is copied VERBATIM from migration --- 012_benchmark_recipe_fingerprint.sql — the current canonical definition — --- solely so `br.*` picks up the new columns. Do not edit the body here; --- line-selection changes belong in a migration of their own. +-- Re-create latest_benchmarks because `br.*` freezes the column list when the +-- materialized view is created. The body remains identical to the canonical +-- definition in migration 012; line-selection changes require a new migration. drop materialized view if exists latest_benchmarks; diff --git a/packages/db/src/etl/benchmark-ingest.test.ts b/packages/db/src/etl/benchmark-ingest.test.ts index 4c886688d..e97792ba6 100644 --- a/packages/db/src/etl/benchmark-ingest.test.ts +++ b/packages/db/src/etl/benchmark-ingest.test.ts @@ -108,7 +108,6 @@ describe('bulkIngestBenchmarkRows — power audit provenance lanes', () => { const { text } = calls[0]; expect(text).toContain('metrics, workers, power_invalid_reasons, power_audit )'); - // metrics + workers + power_invalid_reasons + power_audit expect(text.match(/::jsonb\[\]/gu)).toHaveLength(4); expect(text).toContain('power_invalid_reasons = excluded.power_invalid_reasons'); expect(text).toContain('power_audit = excluded.power_audit'); @@ -118,8 +117,6 @@ describe('bulkIngestBenchmarkRows — power audit provenance lanes', () => { const { sql, calls } = captureInsertSql(); await bulkIngestBenchmarkRows(sql, [provenancedRow, legacyRow], 42, '2026-08-27'); - // Template value order mirrors the INSERT column list; the provenance - // lanes ride directly after metrics and workers. const { values } = calls[0]; expect(values[10]).toEqual([ JSON.stringify(provenancedRow.metrics), diff --git a/packages/db/src/etl/benchmark-ingest.ts b/packages/db/src/etl/benchmark-ingest.ts index 72ba4098e..122965e08 100644 --- a/packages/db/src/etl/benchmark-ingest.ts +++ b/packages/db/src/etl/benchmark-ingest.ts @@ -84,15 +84,11 @@ export async function bulkIngestBenchmarkRows( const images = deduped.map((r) => r.image); const recipeFingerprints = deduped.map((r) => r.recipeFingerprint); const metricsJsons = deduped.map((r) => JSON.stringify(r.metrics)); - // workers is optional — encode missing values as JSON null so the JSONB - // unnest input has a homogeneous type (jsonb[]) and stores SQL NULL in the - // column for rows that didn't emit a per-worker breakdown. + // Optional JSONB lanes use JSON null so each jsonb[] input remains + // homogeneous and missing payloads persist as SQL NULL. const workersJsons = deduped.map((r) => r.workers === undefined ? null : JSON.stringify(r.workers), ); - // Same encoding for the power audit provenance columns: JSON null lanes - // keep the jsonb[] unnest inputs homogeneous and store SQL NULL for rows - // whose artifacts predate the provenance contract. const powerInvalidReasonsJsons = deduped.map((r) => r.powerInvalidReasons === undefined ? null : JSON.stringify(r.powerInvalidReasons), ); diff --git a/packages/db/src/etl/benchmark-mapper.test.ts b/packages/db/src/etl/benchmark-mapper.test.ts index 57bd56664..d22c73f6e 100644 --- a/packages/db/src/etl/benchmark-mapper.test.ts +++ b/packages/db/src/etl/benchmark-mapper.test.ts @@ -899,10 +899,6 @@ describe('scrubWithheldPowerMetrics (direct — supplemental ingest path)', () = }); it('recovers provenance companions nested under metrics and leaves the record flat', () => { - // ingest-supplemental.ts also accepts the provenance companions nested in - // `metrics` (where power_valid rides in that format), extracting them via - // the shared narrowers and deleting the keys so the persisted metrics - // jsonb stays a flat numeric record. Pin that sequence. const metrics = supplementalMetrics({ power_valid: 0, power_invalid_reasons: ['sampling_gap_exceeded', 'sampling_gap_exceeded', ''], @@ -1070,7 +1066,6 @@ describe('extractPowerInvalidReasons', () => { }); describe('extractPowerAudit', () => { - /** Full valid audit as aggregate_power.py emits it on a multinode run. */ const fullAudit = { window_start_unix: 1756174800.25, window_end_unix: 1756175400.75, @@ -1139,8 +1134,6 @@ describe('extractPowerAudit', () => { }); it('nulls the explicit-null numeric fields a producer emits without a benchmark window', () => { - // aggregate_power.py sets window_* / max_sample_gap_s to null when the - // benchmark result was unreadable; those are omitted, not kept as null. expect( extractPowerAudit({ window_start_unix: null, @@ -1229,8 +1222,6 @@ describe('mapBenchmarkRow — power audit provenance', () => { }); it("never captures a malformed ['5'] reasons array as a numeric metric", () => { - // Number(['5']) === 5 — without the NON_METRIC_KEYS guard the generic - // capture loop would mint a bogus power_invalid_reasons numeric metric. const tracker = createSkipTracker(); const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); try { diff --git a/packages/db/src/etl/benchmark-mapper.ts b/packages/db/src/etl/benchmark-mapper.ts index 81a3be497..bf45c175f 100644 --- a/packages/db/src/etl/benchmark-mapper.ts +++ b/packages/db/src/etl/benchmark-mapper.ts @@ -86,10 +86,8 @@ const NON_METRIC_KEYS = new Set([ // sibling of the metrics JSONB by mapBenchmarkRow so the metrics column // stays Record for the index signature on BenchmarkRow. 'workers', - // Power audit provenance (array / object, never scalars). Extracted into - // dedicated BenchmarkParams fields by mapBenchmarkRow. Listed here because - // Number(['5']) === 5: without the guard a malformed single-element reasons - // array would be auto-captured as a bogus numeric metric. + // Keep structured provenance outside flat numeric metrics. Explicitly + // excluding the keys also blocks Number(['5']) coercion. 'power_invalid_reasons', 'power_audit', ]); @@ -133,12 +131,8 @@ export interface WorkerPower { } /** - * Compact measurement-window audit emitted by aggregate_power.py alongside - * the power_valid verdict (present on valid and invalid rows alike). All - * fields optional: {@link extractPowerAudit} omits malformed numerics rather - * than dropping the whole object, and single-node telemetry has no srt-slurm - * producer so both shas are null there. Stored on benchmark_results in the - * dedicated `power_audit` JSONB column (migration 014). + * Narrowed measurement-window audit. Fields are optional because malformed + * numerics are omitted; producer identity is null when unavailable. */ export interface PowerAudit { window_start_unix?: number; @@ -173,18 +167,7 @@ export interface BenchmarkParams { * predating the multinode patch. */ workers?: WorkerPower[]; - /** - * Producer reason codes explaining a withheld power verdict - * (aggregate_power.py emits them when power_valid == 0). Stored in the - * dedicated `power_invalid_reasons` JSONB column (migration 014). - * Undefined for legacy artifacts and rows with a valid verdict. - */ powerInvalidReasons?: string[]; - /** - * Compact power measurement-window audit from the same producer contract. - * Stored in the dedicated `power_audit` JSONB column (migration 014). - * Undefined for legacy artifacts predating the provenance contract. - */ powerAudit?: PowerAudit; } @@ -395,10 +378,7 @@ export function mapBenchmarkRow( // narrowing — anything other than a non-empty array of objects is dropped, // and a withheld power verdict drops the payload entirely. const workers = powerWithheld ? undefined : extractWorkers(row.workers); - // Audit provenance is extracted unconditionally on power_valid: the - // contract says reasons appear when power_valid == 0, but the app stores - // whatever validly arrives — tolerance in both directions, and no data - // loss if a future producer annotates valid rows too. + // Audit metadata is independent of the verdict so valid-row provenance is retained. const powerInvalidReasons = extractPowerInvalidReasons(row.power_invalid_reasons); const powerAudit = extractPowerAudit(row.power_audit); @@ -601,19 +581,14 @@ export function extractWorkers(raw: unknown): WorkerPower[] | undefined { return out.length > 0 ? out : undefined; } -/** Producer reason codes are lowercase snake_case identifiers. */ const POWER_REASON_CODE_RE = /^[a-z][a-z0-9_]*$/u; const MAX_POWER_REASON_CODES = 32; const MAX_POWER_REASON_LENGTH = 64; const MAX_POWER_AUDIT_SHA_LENGTH = 128; /** - * Narrow a raw `power_invalid_reasons` value from the artifact JSON to - * `string[]` or undefined. Entries must be snake_case strings (length ≤ 64 - * after trim) to be kept; anything else is dropped silently (same policy as - * `extractWorkers`). Deduplicates preserving first-seen order and caps the - * result at 32 codes. Returns undefined for any non-array input or an empty - * result so the eventual JSONB column stores null rather than `[]`. + * Keep at most 32 unique snake_case reason codes, each at most 64 characters. + * Empty results become undefined so persistence stores SQL NULL, not `[]`. */ export function extractPowerInvalidReasons(raw: unknown): string[] | undefined { if (!Array.isArray(raw)) return undefined; @@ -637,13 +612,11 @@ function auditFiniteNum(v: unknown): number | undefined { return n !== undefined && Number.isFinite(n) ? n : undefined; } -/** GPU/sample counts must be non-negative safe integers. */ function auditCount(v: unknown): number | undefined { const n = parseInt2(v); return n !== undefined && Number.isSafeInteger(n) && n >= 0 ? n : undefined; } -/** Trimmed non-empty string of bounded length, anything else → null. */ function auditSha(v: unknown): string | null { if (typeof v !== 'string') return null; const s = v.trim(); @@ -651,16 +624,10 @@ function auditSha(v: unknown): string | null { } /** - * Narrow a raw `power_audit` value from the artifact JSON to a fixed 8-key - * {@link PowerAudit} or undefined. Field-by-field: window/gap fields must be - * finite numbers, counts must be non-negative safe integers; malformed - * numerics are omitted, since a partial audit beats none. The shas keep a - * trimmed non-empty string of length ≤ 128 and collapse anything else - * (including explicit null) to null, matching the contract's string|null. - * Unknown keys are dropped so the stored object stays bounded. Returns - * undefined for non-object input and for an empty husk (no numeric field - * survived and both shas are null) so the eventual JSONB column stores null - * rather than `{}`. + * Narrow to the fixed {@link PowerAudit} shape: finite window/gap values, + * non-negative safe-integer counts, and bounded producer identifiers. Unknown + * keys are dropped; an empty result becomes undefined so persistence stores + * SQL NULL rather than `{}`. */ export function extractPowerAudit(raw: unknown): PowerAudit | undefined { if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined; diff --git a/packages/db/src/ingest-supplemental.ts b/packages/db/src/ingest-supplemental.ts index 9017345cb..b9ca0c684 100644 --- a/packages/db/src/ingest-supplemental.ts +++ b/packages/db/src/ingest-supplemental.ts @@ -185,8 +185,6 @@ interface SupplementalBmk { is_multinode?: boolean; prefill_num_workers?: number; decode_num_workers?: number; - /** Producer power-audit provenance (PLAN-06 contract), sibling to `metrics` - * like on artifact rows; narrowed by the shared extractors. */ power_invalid_reasons?: unknown; power_audit?: unknown; } @@ -280,11 +278,8 @@ async function ingestSupplementalBmk( // then strip withheld measurements when power_valid=0. normalizePowerContractMetrics(entry.metrics, entry.metrics); scrubWithheldPowerMetrics(entry.metrics); - // The provenance companions ride the entry itself (sibling to the flat - // numeric `metrics` record), but accept a nesting under `metrics` too — - // power_valid lives there in this format. Delete the keys from `metrics` - // either way so the structured companions never enter the persisted - // metrics jsonb (the NON_METRIC_KEYS guarantee on the mapper path). + // Accept the supplemental format's metrics nesting, then remove the + // structured fields so persisted metrics remain numeric-only. const powerInvalidReasons = extractPowerInvalidReasons( entry.power_invalid_reasons ?? entry.metrics.power_invalid_reasons, ); diff --git a/packages/db/src/queries/benchmarks.test.ts b/packages/db/src/queries/benchmarks.test.ts index 90f6c5cf2..0a4d1ead3 100644 --- a/packages/db/src/queries/benchmarks.test.ts +++ b/packages/db/src/queries/benchmarks.test.ts @@ -150,8 +150,6 @@ describe('power audit provenance reads (tolerant to a not-yet-applied migration const { text } = captured.query(); expect(text).toContain("to_jsonb(lb) -> 'power_invalid_reasons' AS power_invalid_reasons"); expect(text).toContain("to_jsonb(lb) -> 'power_audit' AS power_audit"); - // The #407 lesson, pinned: a bare column reference would fail to PLAN - // until the next ingest run applies migration 014. expect(text).not.toMatch(/\b(?:br|lb)\.power_(?:invalid_reasons|audit)\b/u); }); }); diff --git a/packages/db/src/queries/benchmarks.ts b/packages/db/src/queries/benchmarks.ts index 8c915560b..20c39a614 100644 --- a/packages/db/src/queries/benchmarks.ts +++ b/packages/db/src/queries/benchmarks.ts @@ -47,17 +47,9 @@ export interface BenchmarkRow { * aggregate_power.py's multinode patch — surfaced as undefined here. */ workers?: BenchmarkWorkerRow[]; - /** - * Producer reason codes explaining a withheld power verdict. Stored in the - * dedicated `power_invalid_reasons` JSONB column (migration 014). - * Null/undefined on legacy rows and rows from valid runs. - */ + /** Producer reason codes for withheld power; null/undefined on other rows. */ power_invalid_reasons?: string[] | null; - /** - * Compact power measurement-window audit from the producer contract. - * Stored in the dedicated `power_audit` JSONB column (migration 014). - * Null/undefined on legacy rows predating the provenance contract. - */ + /** Narrowed measurement-window audit; null/undefined on legacy rows. */ power_audit?: PowerAudit | null; date: string; /** Producer identity and timestamp; preserved for per-point provenance. */ @@ -268,10 +260,9 @@ function executeRecursiveBenchmarkQuery( br.recipe_fingerprint, ${plan.metricsExpression}, br.workers, - -- Deploy-order tolerance (the #405/#407 lesson): a bare br.power_* column - -- reference fails at query PLAN time until the next ingest run applies - -- migration 014. The jsonb key lookup degrades to NULL while the column - -- is missing and is byte-identical once it exists. + -- A bare br.power_* reference fails during query planning until the next + -- ingest applies migration 014. The jsonb lookup returns NULL before the + -- migration and the stored value afterward, making deploy order safe. to_jsonb(br) -> 'power_invalid_reasons' AS power_invalid_reasons, to_jsonb(br) -> 'power_audit' AS power_audit, br.date::text, @@ -469,8 +460,8 @@ export async function getLatestBenchmarks( lb.recipe_fingerprint, lb.metrics, lb.workers, - -- Same deploy-order tolerance as the recursive branch: NULL until - -- migration 014 recreates latest_benchmarks, identical afterwards. + -- latest_benchmarks lacks these fields until migration 014 recreates it; + -- the jsonb lookup keeps reads safe during that deploy window. to_jsonb(lb) -> 'power_invalid_reasons' AS power_invalid_reasons, to_jsonb(lb) -> 'power_audit' AS power_audit, lb.date::text, From ae58fc88dfd9a98ab7d5fe375c6e38d530eefb5f Mon Sep 17 00:00:00 2001 From: Wenyao Gao Date: Mon, 31 Aug 2026 14:07:12 -0700 Subject: [PATCH 5/5] fix: show withheld power in GPU tooltips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 中文:在 GPU 对比图提示中显示实测功耗未采信原因 --- .../src/components/inference/utils/tooltip-utils.test.ts | 8 ++++++++ .../app/src/components/inference/utils/tooltipUtils.ts | 1 + 2 files changed, 9 insertions(+) diff --git a/packages/app/src/components/inference/utils/tooltip-utils.test.ts b/packages/app/src/components/inference/utils/tooltip-utils.test.ts index b5ac1a45d..57018979c 100644 --- a/packages/app/src/components/inference/utils/tooltip-utils.test.ts +++ b/packages/app/src/components/inference/utils/tooltip-utils.test.ts @@ -864,6 +864,14 @@ describe('measured-power withheld tooltip line', () => { expect(html).toContain('Measured power withheld'); expect(html).toContain('sampling gap exceeded'); }); + + it('gives GPU comparison tooltips the same line', () => { + const html = generateGPUGraphTooltipContent( + tooltipConfig({ data: pt({ power_valid: 0, power_invalid_reasons: reasons }) }), + ); + expect(html).toContain('Measured power withheld'); + expect(html).toContain('sampling gap exceeded'); + }); }); describe('worker power drilldown', () => { diff --git a/packages/app/src/components/inference/utils/tooltipUtils.ts b/packages/app/src/components/inference/utils/tooltipUtils.ts index 4d7b95211..bae5550f1 100644 --- a/packages/app/src/components/inference/utils/tooltipUtils.ts +++ b/packages/app/src/components/inference/utils/tooltipUtils.ts @@ -691,6 +691,7 @@ export const generateGPUGraphTooltipContent = (config: TooltipConfig): string => ${tooltipLine(t.concurrency, `${d.conc}`)} ${tooltipLine(t.precision, `${d.precision.toUpperCase()}`)} ${generateCacheMetadataHTML(d, locale)} + ${powerWithheldHTML(d, locale)} ${generateAgenticHTML(d, locale)} ${generateWorkerPowerHTML(d, isPinned, locale)} ${runLinkHTML(runUrl, locale)}