@viccydev/pi-fpa 0.8.1 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -80,7 +80,7 @@ Extension 内置的关键防护:
80
80
 
81
81
  ## Artifact 与看板 Extension
82
82
 
83
- `fpa-artifacts` 提供 `fpa_artifact_commit`,对 `approved_cycle_forecast` 和 `execution_receipt` 做严格字段校验、对账、稳定指纹和原子落盘。只有工具返回成功后的 JSON 才是冻结产物,Markdown 不是正式数据源。
83
+ `fpa-artifacts` 提供 `fpa_forecast_finalize`,把紧凑 Forecast plan 一次性合成、校验、按目标周期推导生命周期角色并冻结;模型无需在 Graph handoff 与 ledger 枚举之间转译 `forecast_role`。通用的 `fpa_artifact_commit` 仍负责 `approved_cycle_forecast` 和 `execution_receipt` 的严格字段校验、对账、稳定指纹和原子落盘。只有工具返回成功后的 JSON 才是冻结产物,Markdown 不是正式数据源。
84
84
 
85
85
  `fpa-dashboard` 提供分模块发布工具和兼容的闭环刷新工具:
86
86
 
@@ -159,12 +159,12 @@ pi list
159
159
  团队分发建议使用固定 Git tag:
160
160
 
161
161
  ```bash
162
- pi install git:github.com/linyqh/pi-fpa@v0.8.1
162
+ pi install git:github.com/linyqh/pi-fpa@v0.9.1
163
163
  ```
164
164
 
165
165
  ## 发布到 npm
166
166
 
167
- 发布动作由 GitHub Release 触发。Release 标签必须严格使用 `v<package.json version>`,例如版本 `0.8.1` 对应 `v0.8.1`。工作流会检出该标签,执行 `npm ci`、`npm test` 和包内容预检,全部通过后发布公开包 `@viccydev/pi-fpa`。普通 Release 发布到 `latest`,Prerelease 发布到 `next`。
167
+ 发布动作由 GitHub Release 触发。Release 标签必须严格使用 `v<package.json version>`,例如版本 `0.9.1` 对应 `v0.9.1`。工作流会检出该标签,执行 `npm ci`、`npm test` 和包内容预检,全部通过后发布公开包 `@viccydev/pi-fpa`。普通 Release 发布到 `latest`,Prerelease 发布到 `next`。
168
168
 
169
169
  发布认证使用 npm Trusted Publishing / OIDC,不使用长期 npm Token。npm 包后台的 Trusted Publisher 配置为:
170
170
 
@@ -6,10 +6,16 @@ import {
6
6
  type AllocationAction,
7
7
  type ApprovedCycleForecastInput,
8
8
  type ForecastAllocation,
9
+ type ForecastConclusion,
9
10
  type ForecastSlice,
10
11
  type Period,
11
12
  type ScenarioMetric,
12
13
  } from "./contracts.ts";
14
+ import {
15
+ assessForecastQuality,
16
+ type ForecastDimensionSnapshot,
17
+ type ForecastQualityAssessment,
18
+ } from "./forecast-quality.ts";
13
19
 
14
20
  // ============================================================================
15
21
  // Composing an approved forecast from a compact plan.
@@ -28,19 +34,14 @@ import {
28
34
  // derived. So the plan carries just those, and this module computes the rest —
29
35
  // which makes the identities true by construction rather than true if checked.
30
36
  //
31
- // Deliberately returns the artifact instead of writing it. A node that writes
32
- // is a mutating node, and the engine bars mutating nodes from automatic retry
33
- // and from failure routes that lead back to themselves. Keeping composition
34
- // read-only is what lets a bad plan be repaired and recomposed automatically,
35
- // while the irreversible freeze stays in its own node.
37
+ // Deliberately returns the artifact instead of writing it, so isolated callers
38
+ // can inspect diagnostics without mutation. The forecast-freeze Graph uses the
39
+ // finalizer as its one mutation seam; malformed plans fail once with the full
40
+ // actionable limitation report instead of entering a repair loop.
36
41
  // ============================================================================
37
42
 
38
43
  /** Distinct dimension values a plan's slice keys must be drawn from. */
39
- export interface UaDimensionValues {
40
- app_code: Set<string>;
41
- platform: Set<string>;
42
- media_source: Set<string>;
43
- }
44
+ export interface UaDimensionValues extends ForecastDimensionSnapshot {}
44
45
 
45
46
  export interface PlanSliceRoas {
46
47
  downside: number | null;
@@ -172,7 +173,10 @@ function sumScenario(values: Array<number | null>): number | null {
172
173
  export interface ComposeDiagnostics {
173
174
  slice_count: number;
174
175
  stopped_slice_count: number;
176
+ limited_slice_count: number;
175
177
  total_approved_spend: number;
178
+ calculable_approved_spend: number;
179
+ calculable_spend_pct: number;
176
180
  validated_dimensions: boolean;
177
181
  portfolio_placeholder_app_id: string | null;
178
182
  }
@@ -192,21 +196,78 @@ export interface ComposeResult {
192
196
  */
193
197
  export async function loadUaDimensionValues(signal?: AbortSignal): Promise<UaDimensionValues> {
194
198
  const result = await runStructuredQuery(
195
- { dataset: "ua_spend", metrics: ["spend"], dimensions: ["app_code", "platform", "media_source"], limit: 5000 },
199
+ { dataset: "ua_spend", metrics: ["spend"], dimensions: ["app_code", "platform", "media_source"], limit: 1000 },
196
200
  signal,
197
201
  );
198
- const values: UaDimensionValues = { app_code: new Set(), platform: new Set(), media_source: new Set() };
202
+ const values: UaDimensionValues = { app_code: new Set(), platform: new Set(), media_source: new Set(), tuples: new Set() };
199
203
  for (const row of result.rows) {
200
204
  if (row.app_code != null) values.app_code.add(String(row.app_code));
201
205
  if (row.platform != null) values.platform.add(String(row.platform));
202
206
  if (row.media_source != null) values.media_source.add(String(row.media_source));
207
+ if (row.app_code != null && row.platform != null && row.media_source != null) {
208
+ values.tuples?.add(sliceKey({
209
+ app_id: String(row.app_code),
210
+ store: String(row.platform),
211
+ channel_group: String(row.media_source),
212
+ }));
213
+ }
203
214
  }
204
215
  return values;
205
216
  }
206
217
 
207
- function sample(values: Set<string>, limit = 8): string {
208
- const list = [...values].sort();
209
- return list.length <= limit ? list.join(", ") : `${list.slice(0, limit).join(", ")}, … (${list.length} total)`;
218
+ /**
219
+ * Read only the candidate tuples named by the plan. This avoids treating the
220
+ * structured query's 1000-row safety cap as a complete dimension catalog.
221
+ */
222
+ async function loadUaDimensionValuesForSlices(slices: PlanSlice[], signal?: AbortSignal): Promise<UaDimensionValues> {
223
+ const appIds = new Set(slices.map((slice) => slice.app_id));
224
+ const collapsedAppId = appIds.size === 1 ? [...appIds][0] : null;
225
+ let portfolioMode = false;
226
+ if (collapsedAppId !== null) {
227
+ const appProbe = await runStructuredQuery({
228
+ dataset: "ua_spend",
229
+ metrics: ["spend"],
230
+ dimensions: ["app_code"],
231
+ filters: { app_code: collapsedAppId },
232
+ limit: 1,
233
+ }, signal);
234
+ portfolioMode = appProbe.rows.length === 0;
235
+ }
236
+
237
+ const uniqueSlices = [...new Map(slices.map((slice) => [sliceKey(slice), slice])).values()];
238
+ const result = portfolioMode
239
+ ? await runStructuredQuery({
240
+ dataset: "ua_spend",
241
+ metrics: ["spend"],
242
+ dimensions: ["platform", "media_source"],
243
+ exactUaChannelScope: uniqueSlices.map((slice) => ({ platform: slice.store, media_source: slice.channel_group })),
244
+ limit: uniqueSlices.length,
245
+ }, signal)
246
+ : await runStructuredQuery({
247
+ dataset: "ua_spend",
248
+ metrics: ["spend"],
249
+ dimensions: ["app_code", "platform", "media_source"],
250
+ exactUaScope: uniqueSlices.map((slice) => ({
251
+ app_code: slice.app_id,
252
+ platform: slice.store,
253
+ media_source: slice.channel_group,
254
+ })),
255
+ limit: uniqueSlices.length,
256
+ }, signal);
257
+ const values: UaDimensionValues = { app_code: new Set(), platform: new Set(), media_source: new Set(), tuples: new Set() };
258
+ for (const row of result.rows) {
259
+ if (row.app_code != null) values.app_code.add(String(row.app_code));
260
+ if (row.platform != null) values.platform.add(String(row.platform));
261
+ if (row.media_source != null) values.media_source.add(String(row.media_source));
262
+ if (row.platform != null && row.media_source != null) {
263
+ values.tuples?.add(sliceKey({
264
+ app_id: row.app_code == null ? "" : String(row.app_code),
265
+ store: String(row.platform),
266
+ channel_group: String(row.media_source),
267
+ }));
268
+ }
269
+ }
270
+ return values;
210
271
  }
211
272
 
212
273
  /**
@@ -218,33 +279,8 @@ function sample(values: Set<string>, limit = 8): string {
218
279
  * test the dashboard applies, so the two agree on what "portfolio mode" means.
219
280
  * Store and channel have no such mode — a value the mart never uses is an error.
220
281
  */
221
- export function validateSliceKeys(slices: PlanSlice[], values: UaDimensionValues): { portfolioAppId: string | null } {
222
- const appIds = new Set(slices.map((slice) => slice.app_id));
223
- const collapsed = appIds.size === 1 ? [...appIds][0] : null;
224
- const portfolioAppId = collapsed !== null && !values.app_code.has(collapsed) ? collapsed : null;
225
-
226
- const problems: string[] = [];
227
- const badStores = [...new Set(slices.map((slice) => slice.store).filter((store) => !values.platform.has(store)))];
228
- if (badStores.length > 0) {
229
- problems.push(`store ${badStores.map((value) => JSON.stringify(value)).join(", ")} is not a ua_spend.platform value. Valid values: ${sample(values.platform)}.`);
230
- }
231
- const badChannels = [...new Set(slices.map((slice) => slice.channel_group).filter((channel) => !values.media_source.has(channel)))];
232
- if (badChannels.length > 0) {
233
- problems.push(`channel_group ${badChannels.map((value) => JSON.stringify(value)).join(", ")} is not a ua_spend.media_source value. Valid values: ${sample(values.media_source)}.`);
234
- }
235
- if (portfolioAppId === null) {
236
- const badApps = [...new Set(slices.map((slice) => slice.app_id).filter((appId) => !values.app_code.has(appId)))];
237
- if (badApps.length > 0) {
238
- problems.push(`app_id ${badApps.map((value) => JSON.stringify(value)).join(", ")} is not a ua_spend.app_code value. Valid values: ${sample(values.app_code)}.`);
239
- }
240
- }
241
- if (problems.length > 0) {
242
- throw new Error(
243
- `Plan slice keys do not exist in ua_spend, so every Actuals lookup for this forecast would match nothing:\n`
244
- + problems.map((problem) => `- ${problem}`).join("\n"),
245
- );
246
- }
247
- return { portfolioAppId };
282
+ export function validateSliceKeys(slices: PlanSlice[], values: UaDimensionValues): ForecastQualityAssessment {
283
+ return assessForecastQuality(slices, values);
248
284
  }
249
285
 
250
286
  export interface ComposeOptions {
@@ -263,6 +299,7 @@ export async function composeApprovedForecast(plan: unknown, options: ComposeOpt
263
299
  }
264
300
  if (!Array.isArray(source.slices) || source.slices.length === 0) throw new Error("plan.slices must be a non-empty array.");
265
301
  const slices = source.slices.map((value, index) => planSlice(value, index));
302
+ if (slices.length > 500) throw new Error("plan.slices cannot exceed 500 slices.");
266
303
 
267
304
  const seen = new Set<string>();
268
305
  for (const slice of slices) {
@@ -289,9 +326,54 @@ export async function composeApprovedForecast(plan: unknown, options: ComposeOpt
289
326
  + "Scope deliberately excluded upstream is not a limit — if nothing in scope is missing, the status is complete.",
290
327
  );
291
328
  }
329
+ if (status === "complete_with_limits") {
330
+ const limitationErrors: string[] = [];
331
+ const capture = (check: () => void): void => {
332
+ try {
333
+ check();
334
+ } catch (error) {
335
+ limitationErrors.push(error instanceof Error ? error.message : String(error));
336
+ }
337
+ };
338
+ for (const [index, value] of unsupported.entries()) {
339
+ const path = `plan.unsupported_metrics[${index}]`;
340
+ let item: Record<string, unknown>;
341
+ try {
342
+ item = record(value, path);
343
+ } catch (error) {
344
+ limitationErrors.push(error instanceof Error ? error.message : String(error));
345
+ continue;
346
+ }
347
+ capture(() => { text(item.metric, `${path}.metric`); });
348
+ capture(() => { text(item.reason, `${path}.reason`); });
349
+ if (!Array.isArray(item.affected_scope) || item.affected_scope.length === 0) {
350
+ limitationErrors.push(`${path}.affected_scope must be a non-empty array.`);
351
+ } else {
352
+ for (const [scopeIndex, scope] of item.affected_scope.entries()) {
353
+ capture(() => { text(scope, `${path}.affected_scope[${scopeIndex}]`); });
354
+ }
355
+ }
356
+ const evidence = item.evidence === undefined ? [] : Array.isArray(item.evidence) ? item.evidence : [item.evidence];
357
+ if (evidence.length === 0) {
358
+ limitationErrors.push(`${path}.evidence must not be empty.`);
359
+ } else {
360
+ for (const [evidenceIndex, entry] of evidence.entries()) {
361
+ capture(() => { text(entry, `${path}.evidence[${evidenceIndex}]`); });
362
+ }
363
+ }
364
+ capture(() => { text(item.remediation, `${path}.remediation`); });
365
+ capture(() => { text(item.owner_role, `${path}.owner_role`); });
366
+ }
367
+ if (limitationErrors.length > 0) {
368
+ throw new Error(`Forecast plan limitations are invalid:\n- ${limitationErrors.join("\n- ")}`);
369
+ }
370
+ }
292
371
 
293
- const loadValues = options.loadDimensionValues ?? loadUaDimensionValues;
294
- const { portfolioAppId } = validateSliceKeys(slices, await loadValues(options.signal));
372
+ const dimensionValues = options.loadDimensionValues
373
+ ? await options.loadDimensionValues(options.signal)
374
+ : await loadUaDimensionValuesForSlices(slices, options.signal);
375
+ const quality = validateSliceKeys(slices, dimensionValues);
376
+ const { portfolioAppId } = quality;
295
377
 
296
378
  const period = record(source.target_period, "plan.target_period") as unknown as Period;
297
379
  const currency = text(source.reporting_currency, "plan.reporting_currency").toUpperCase();
@@ -310,8 +392,11 @@ export async function composeApprovedForecast(plan: unknown, options: ComposeOpt
310
392
  // Spend is the decision, not a prediction: an approved allocation is the same
311
393
  // number in every scenario. Only revenue moves, through the ROAS assumption.
312
394
  const forecastBySlice: ForecastSlice[] = slices.map((slice) => {
395
+ const eligible = quality.eligibleSliceKeys.has(sliceKey(slice));
313
396
  const derived = Object.fromEntries(
314
- SCENARIOS.map((scenario) => [scenario, sliceScenario(slice.approved_spend, slice.roas[scenario])]),
397
+ SCENARIOS.map((scenario) => [scenario, eligible
398
+ ? sliceScenario(slice.approved_spend, slice.roas[scenario])
399
+ : { revenue: null, roas: null }]),
315
400
  ) as Record<Scenario, { revenue: number | null; roas: number | null }>;
316
401
  return {
317
402
  app_id: slice.app_id,
@@ -357,6 +442,45 @@ export async function composeApprovedForecast(plan: unknown, options: ComposeOpt
357
442
  roas: scenarioMetric(consolidatedRoas, "ratio", window),
358
443
  };
359
444
 
445
+ const eligibleForecastSlices = forecastBySlice.filter((slice) => quality.eligibleSliceKeys.has(sliceKey(slice)));
446
+ const conclusionStatus: ForecastConclusion["status"] = quality.dataQuality.status === "complete"
447
+ ? "available"
448
+ : quality.dataQuality.status === "partial"
449
+ ? "available_with_limits"
450
+ : "unavailable";
451
+ const conclusionScope: ForecastConclusion["scope"] = quality.dataQuality.status === "complete"
452
+ ? "portfolio"
453
+ : quality.dataQuality.status === "partial"
454
+ ? "calculable_slices"
455
+ : "none";
456
+ const conclusionScenario = (metric: "spend" | "revenue"): Record<Scenario, number | null> => Object.fromEntries(
457
+ SCENARIOS.map((scenario) => {
458
+ if (conclusionStatus === "unavailable") return [scenario, null];
459
+ return [scenario, sumScenario(eligibleForecastSlices.map((slice) => slice.metrics[metric][scenario]))];
460
+ }),
461
+ ) as Record<Scenario, number | null>;
462
+ const conclusionSpend = conclusionScenario("spend");
463
+ const conclusionRevenue = conclusionScenario("revenue");
464
+ const conclusionRoas = Object.fromEntries(SCENARIOS.map((scenario) => {
465
+ const spend = conclusionSpend[scenario];
466
+ const revenue = conclusionRevenue[scenario];
467
+ return [scenario, spend === null || revenue === null || spend === 0 ? null : revenue / spend];
468
+ })) as Record<Scenario, number | null>;
469
+ const conclusion: ForecastConclusion = {
470
+ status: conclusionStatus,
471
+ scope: conclusionScope,
472
+ metrics: {
473
+ spend: scenarioMetric(conclusionSpend, currency, window),
474
+ revenue: scenarioMetric(conclusionRevenue, currency, window),
475
+ roas: scenarioMetric(conclusionRoas, "ratio", window),
476
+ },
477
+ warning: quality.dataQuality.status === "complete"
478
+ ? null
479
+ : quality.dataQuality.status === "partial"
480
+ ? `结论仅覆盖可计算切片,占计划支出的 ${(quality.dataQuality.calculable_spend_pct * 100).toFixed(2)}%;未覆盖切片未参与收入和 ROAS 计算。`
481
+ : "没有可计算切片;已保留全部批准预算,但不提供收入或 ROAS 数值结论。",
482
+ };
483
+
360
484
  // Portfolio-level series the model measured rather than derived — organic
361
485
  // revenue and the like. They pass through untouched apart from the window,
362
486
  // which is the artifact's to define, not the plan's.
@@ -379,9 +503,19 @@ export async function composeApprovedForecast(plan: unknown, options: ComposeOpt
379
503
  }
380
504
  }
381
505
 
506
+ const artifactStatus = status === "blocked"
507
+ ? "blocked"
508
+ : quality.dataQuality.status === "complete"
509
+ ? status
510
+ : "complete_with_limits";
511
+ const qualityUnsupported = quality.dataQuality.issues.length === 0 ? [] : [{
512
+ metric: "revenue_roas_by_affected_slice",
513
+ reason: "slice_key_not_in_actuals",
514
+ affected_slice_count: quality.dataQuality.issue_count,
515
+ }];
382
516
  const artifact = {
383
517
  artifact_type: "approved_cycle_forecast",
384
- status,
518
+ status: artifactStatus,
385
519
  forecast_version: text(source.forecast_version, "plan.forecast_version"),
386
520
  strategy_version: text(source.strategy_version, "plan.strategy_version"),
387
521
  strategy_review_id: text(source.strategy_review_id, "plan.strategy_review_id"),
@@ -397,8 +531,10 @@ export async function composeApprovedForecast(plan: unknown, options: ComposeOpt
397
531
  forecast_by_slice: forecastBySlice,
398
532
  consolidated_forecast: consolidated,
399
533
  calibration_policy: source.calibration_policy,
400
- unsupported_metrics: unsupported,
534
+ unsupported_metrics: [...unsupported, ...qualityUnsupported],
401
535
  reconciliation_checks: source.reconciliation_checks,
536
+ data_quality: quality.dataQuality,
537
+ conclusion,
402
538
  frozen_at: typeof source.frozen_at === "string" && source.frozen_at.trim()
403
539
  ? source.frozen_at
404
540
  : (options.now?.() ?? new Date()).toISOString().replace(/\.\d{3}Z$/, "Z"),
@@ -409,7 +545,10 @@ export async function composeApprovedForecast(plan: unknown, options: ComposeOpt
409
545
  diagnostics: {
410
546
  slice_count: slices.length,
411
547
  stopped_slice_count: slices.filter((slice) => slice.approved_spend === 0).length,
548
+ limited_slice_count: quality.limitedSliceKeys.size,
412
549
  total_approved_spend: slices.reduce((total, slice) => total + slice.approved_spend, 0),
550
+ calculable_approved_spend: quality.dataQuality.calculable_spend,
551
+ calculable_spend_pct: quality.dataQuality.calculable_spend_pct,
413
552
  validated_dimensions: true,
414
553
  portfolio_placeholder_app_id: portfolioAppId,
415
554
  },
@@ -35,6 +35,35 @@ export interface ForecastSlice {
35
35
  metrics: Record<string, ScenarioMetric>;
36
36
  }
37
37
 
38
+ export interface ForecastDataQualityIssue {
39
+ issue_id: string;
40
+ code: "slice_key_not_in_actuals";
41
+ severity: "warning";
42
+ slice: Pick<ForecastSlice, "app_id" | "store" | "channel_group">;
43
+ affected_metrics: string[];
44
+ disposition: "excluded_from_calculation";
45
+ evidence: string[];
46
+ remediation: string;
47
+ owner_role: "data_steward";
48
+ }
49
+
50
+ export interface ForecastDataQuality {
51
+ status: "complete" | "partial" | "unavailable";
52
+ planned_spend: number;
53
+ calculable_spend: number;
54
+ calculable_spend_pct: number;
55
+ excluded_spend: number;
56
+ issue_count: number;
57
+ issues: ForecastDataQualityIssue[];
58
+ }
59
+
60
+ export interface ForecastConclusion {
61
+ status: "available" | "available_with_limits" | "unavailable";
62
+ scope: "portfolio" | "calculable_slices" | "none";
63
+ metrics: Record<string, ScenarioMetric>;
64
+ warning: string | null;
65
+ }
66
+
38
67
  export interface CalibrationPolicy {
39
68
  stop_loss_roas_lt: number;
40
69
  deviation_warning_abs_gte: number;
@@ -62,6 +91,8 @@ export interface ApprovedCycleForecastInput {
62
91
  calibration_policy: CalibrationPolicy;
63
92
  unsupported_metrics: unknown[];
64
93
  reconciliation_checks: unknown[];
94
+ data_quality?: ForecastDataQuality;
95
+ conclusion?: ForecastConclusion;
65
96
  frozen_at: string;
66
97
  }
67
98
 
@@ -235,6 +266,26 @@ function jsonArray(value: unknown, path: string): unknown[] {
235
266
  return array(value, path).map((item, index) => jsonValue(item, `${path}[${index}]`));
236
267
  }
237
268
 
269
+ function actionableUnsupportedMetrics(value: unknown, path: string): unknown[] {
270
+ const items = jsonArray(value, path);
271
+ for (const [index, raw] of items.entries()) {
272
+ const itemPath = `${path}[${index}]`;
273
+ const item = record(raw, itemPath);
274
+ string(item.metric, `${itemPath}.metric`);
275
+ const reason = string(item.reason, `${itemPath}.reason`);
276
+ if (reason === "slice_key_not_in_actuals") continue;
277
+ const scopes = strings(item.affected_scope, `${itemPath}.affected_scope`);
278
+ if (scopes.length === 0) throw new Error(`${itemPath}.affected_scope must not be empty.`);
279
+ const evidence = Array.isArray(item.evidence)
280
+ ? strings(item.evidence, `${itemPath}.evidence`)
281
+ : [string(item.evidence, `${itemPath}.evidence`)];
282
+ if (evidence.length === 0) throw new Error(`${itemPath}.evidence must not be empty.`);
283
+ string(item.remediation, `${itemPath}.remediation`);
284
+ string(item.owner_role, `${itemPath}.owner_role`);
285
+ }
286
+ return items;
287
+ }
288
+
238
289
  function strings(value: unknown, path: string): string[] {
239
290
  return array(value, path).map((item, index) => string(item, `${path}[${index}]`));
240
291
  }
@@ -316,6 +367,70 @@ function scenarioMetrics(value: unknown, path: string): Record<string, ScenarioM
316
367
  return Object.fromEntries(entries.map(([name, metric]) => [string(name, `${path} key`), scenarioMetric(metric, `${path}.${name}`)]));
317
368
  }
318
369
 
370
+ function forecastDataQuality(value: unknown, path: string): ForecastDataQuality {
371
+ const source = record(value, path);
372
+ exactKeys(source, ["status", "planned_spend", "calculable_spend", "calculable_spend_pct", "excluded_spend", "issue_count", "issues"], path);
373
+ const issues = array(source.issues, `${path}.issues`).map((raw, index) => {
374
+ const issuePath = `${path}.issues[${index}]`;
375
+ const issue = record(raw, issuePath);
376
+ exactKeys(issue, ["issue_id", "code", "severity", "slice", "affected_metrics", "disposition", "evidence", "remediation", "owner_role"], issuePath);
377
+ const slice = record(issue.slice, `${issuePath}.slice`);
378
+ exactKeys(slice, ["app_id", "store", "channel_group"], `${issuePath}.slice`);
379
+ return {
380
+ issue_id: string(issue.issue_id, `${issuePath}.issue_id`),
381
+ code: enumValue(issue.code, ["slice_key_not_in_actuals"] as const, `${issuePath}.code`),
382
+ severity: enumValue(issue.severity, ["warning"] as const, `${issuePath}.severity`),
383
+ slice: {
384
+ app_id: string(slice.app_id, `${issuePath}.slice.app_id`),
385
+ store: string(slice.store, `${issuePath}.slice.store`),
386
+ channel_group: string(slice.channel_group, `${issuePath}.slice.channel_group`),
387
+ },
388
+ affected_metrics: strings(issue.affected_metrics, `${issuePath}.affected_metrics`),
389
+ disposition: enumValue(issue.disposition, ["excluded_from_calculation"] as const, `${issuePath}.disposition`),
390
+ evidence: strings(issue.evidence, `${issuePath}.evidence`),
391
+ remediation: string(issue.remediation, `${issuePath}.remediation`),
392
+ owner_role: enumValue(issue.owner_role, ["data_steward"] as const, `${issuePath}.owner_role`),
393
+ };
394
+ });
395
+ for (const [index, issue] of issues.entries()) {
396
+ const allowedAffectedMetrics = new Set(["actuals_comparison", "revenue", "roas", "execution"]);
397
+ const actualAffectedMetrics = new Set(issue.affected_metrics);
398
+ if (actualAffectedMetrics.size !== issue.affected_metrics.length
399
+ || actualAffectedMetrics.size !== allowedAffectedMetrics.size
400
+ || [...allowedAffectedMetrics].some((metric) => !actualAffectedMetrics.has(metric))) {
401
+ throw new Error(`${path}.issues[${index}].affected_metrics must contain actuals_comparison, revenue, roas, and execution exactly once.`);
402
+ }
403
+ }
404
+ const plannedSpend = nonNegative(source.planned_spend, `${path}.planned_spend`) as number;
405
+ const calculableSpend = nonNegative(source.calculable_spend, `${path}.calculable_spend`) as number;
406
+ const excludedSpend = nonNegative(source.excluded_spend, `${path}.excluded_spend`) as number;
407
+ const calculableSpendPct = nonNegative(source.calculable_spend_pct, `${path}.calculable_spend_pct`) as number;
408
+ const issueCount = nonNegative(source.issue_count, `${path}.issue_count`) as number;
409
+ if (!Number.isInteger(issueCount) || issueCount !== issues.length) throw new Error(`${path}.issue_count must equal issues.length.`);
410
+ if (calculableSpendPct > 1) throw new Error(`${path}.calculable_spend_pct cannot exceed 1.`);
411
+ if (Math.abs(plannedSpend - calculableSpend - excludedSpend) > 0.01) throw new Error(`${path} spend coverage does not reconcile.`);
412
+ return {
413
+ status: enumValue(source.status, ["complete", "partial", "unavailable"] as const, `${path}.status`),
414
+ planned_spend: plannedSpend,
415
+ calculable_spend: calculableSpend,
416
+ calculable_spend_pct: calculableSpendPct,
417
+ excluded_spend: excludedSpend,
418
+ issue_count: issueCount,
419
+ issues,
420
+ };
421
+ }
422
+
423
+ function forecastConclusion(value: unknown, path: string): ForecastConclusion {
424
+ const source = record(value, path);
425
+ exactKeys(source, ["status", "scope", "metrics", "warning"], path);
426
+ return {
427
+ status: enumValue(source.status, ["available", "available_with_limits", "unavailable"] as const, `${path}.status`),
428
+ scope: enumValue(source.scope, ["portfolio", "calculable_slices", "none"] as const, `${path}.scope`),
429
+ metrics: scenarioMetrics(source.metrics, `${path}.metrics`),
430
+ warning: source.warning === null ? null : string(source.warning, `${path}.warning`),
431
+ };
432
+ }
433
+
319
434
  export function sliceKey(value: { app_id: string; store: string; channel_group: string }): string {
320
435
  return `${value.app_id}\u0000${value.store}\u0000${value.channel_group}`;
321
436
  }
@@ -366,7 +481,7 @@ function validateFinancialMetricSemantics(
366
481
  }
367
482
 
368
483
  function validateApprovedForecast(source: Record<string, unknown>): ApprovedCycleForecastInput {
369
- exactKeys(source, TOP_LEVEL_KEYS.approved_cycle_forecast, "artifact");
484
+ requiredAndOptionalKeys(source, TOP_LEVEL_KEYS.approved_cycle_forecast, ["data_quality", "conclusion"], "artifact");
370
485
  const targetPeriod = period(source.target_period, "artifact.target_period");
371
486
  const allocations = array(source.approved_allocation, "artifact.approved_allocation").map((value, index) => {
372
487
  const path = `artifact.approved_allocation[${index}]`;
@@ -460,6 +575,85 @@ function validateApprovedForecast(source: Record<string, unknown>): ApprovedCycl
460
575
  }
461
576
 
462
577
  const status = enumValue(source.status, ["complete", "complete_with_limits", "blocked"] as const, "artifact.status");
578
+ const unsupportedMetrics = actionableUnsupportedMetrics(source.unsupported_metrics, "artifact.unsupported_metrics");
579
+ if (status === "complete" && unsupportedMetrics.length > 0) {
580
+ throw new Error("A complete forecast cannot carry unsupported_metrics; use complete_with_limits.");
581
+ }
582
+ if (status === "complete_with_limits" && unsupportedMetrics.length === 0) {
583
+ throw new Error("A complete_with_limits forecast must describe at least one unsupported metric or data limit.");
584
+ }
585
+ const dataQuality = source.data_quality === undefined ? undefined : forecastDataQuality(source.data_quality, "artifact.data_quality");
586
+ const conclusion = source.conclusion === undefined ? undefined : forecastConclusion(source.conclusion, "artifact.conclusion");
587
+ if ((dataQuality === undefined) !== (conclusion === undefined)) {
588
+ throw new Error("artifact.data_quality and artifact.conclusion must be supplied together.");
589
+ }
590
+ const declaresSliceKeyLimit = unsupportedMetrics.some((item) => item !== null
591
+ && typeof item === "object"
592
+ && !Array.isArray(item)
593
+ && (item as Record<string, unknown>).reason === "slice_key_not_in_actuals");
594
+ if (declaresSliceKeyLimit && (!dataQuality || dataQuality.issues.length === 0)) {
595
+ throw new Error("A slice_key_not_in_actuals limit requires data_quality issues and a partial conclusion.");
596
+ }
597
+ if (status === "complete" && dataQuality && dataQuality.status !== "complete") {
598
+ throw new Error("A complete forecast cannot carry partial or unavailable data quality.");
599
+ }
600
+ if (dataQuality && conclusion) {
601
+ const issueKeys = new Set<string>();
602
+ for (const issue of dataQuality.issues) {
603
+ const key = sliceKey(issue.slice);
604
+ if (!allocationKeys.has(key)) throw new Error(`artifact.data_quality issue slice is not in approved_allocation: ${key.replaceAll("\u0000", " / ")}.`);
605
+ if (issueKeys.has(key)) throw new Error(`artifact.data_quality contains duplicate issue slice ${key.replaceAll("\u0000", " / ")}.`);
606
+ issueKeys.add(key);
607
+ const affectedForecast = forecastSlices.find((slice) => sliceKey(slice) === key) as ForecastSlice;
608
+ for (const scenario of SCENARIOS) {
609
+ if (affectedForecast.metrics.revenue[scenario] !== null || affectedForecast.metrics.roas?.[scenario] !== null) {
610
+ throw new Error(`artifact.data_quality affected slice ${key.replaceAll("\u0000", " / ")} must keep revenue and ROAS null.`);
611
+ }
612
+ }
613
+ }
614
+ const expectedExcludedSpend = allocations
615
+ .filter((allocation) => issueKeys.has(sliceKey(allocation)))
616
+ .reduce((total, allocation) => total + allocation.approved_spend, 0);
617
+ const expectedCalculableSpend = allocationTotal - expectedExcludedSpend;
618
+ reconcileNumber(dataQuality.planned_spend, allocationTotal, "artifact.data_quality.planned_spend vs approved allocation");
619
+ reconcileNumber(dataQuality.excluded_spend, expectedExcludedSpend, "artifact.data_quality.excluded_spend vs affected allocation");
620
+ reconcileNumber(dataQuality.calculable_spend, expectedCalculableSpend, "artifact.data_quality.calculable_spend vs eligible allocation");
621
+ const expectedPct = allocationTotal === 0 ? (issueKeys.size === 0 ? 1 : 0) : expectedCalculableSpend / allocationTotal;
622
+ reconcileNumber(dataQuality.calculable_spend_pct, expectedPct, "artifact.data_quality.calculable_spend_pct coverage");
623
+ const expectedQualityStatus: ForecastDataQuality["status"] = issueKeys.size === 0
624
+ ? "complete"
625
+ : expectedCalculableSpend === 0
626
+ ? "unavailable"
627
+ : "partial";
628
+ if (dataQuality.status !== expectedQualityStatus) throw new Error(`artifact.data_quality.status must be ${expectedQualityStatus}.`);
629
+ const expectedConclusionStatus: ForecastConclusion["status"] = expectedQualityStatus === "complete"
630
+ ? "available"
631
+ : expectedQualityStatus === "partial"
632
+ ? "available_with_limits"
633
+ : "unavailable";
634
+ const expectedConclusionScope: ForecastConclusion["scope"] = expectedQualityStatus === "complete"
635
+ ? "portfolio"
636
+ : expectedQualityStatus === "partial"
637
+ ? "calculable_slices"
638
+ : "none";
639
+ if (conclusion.status !== expectedConclusionStatus || conclusion.scope !== expectedConclusionScope) {
640
+ throw new Error(`artifact.conclusion must use ${expectedConclusionStatus} / ${expectedConclusionScope} for its data coverage.`);
641
+ }
642
+ validateFinancialMetricSemantics(conclusion.metrics, reportingCurrency, expectedWindow, "artifact.conclusion.metrics");
643
+ reconcileRoas(conclusion.metrics, "artifact.conclusion.metrics");
644
+ const eligibleForecastSlices = forecastSlices.filter((slice) => !issueKeys.has(sliceKey(slice)));
645
+ for (const metricName of ["spend", "revenue"] as const) {
646
+ for (const scenario of SCENARIOS) {
647
+ const values = eligibleForecastSlices.map((slice) => slice.metrics[metricName][scenario]);
648
+ const expected = expectedQualityStatus === "unavailable"
649
+ ? null
650
+ : values.every((value) => value !== null)
651
+ ? values.reduce<number>((total, value) => total + (value as number), 0)
652
+ : null;
653
+ reconcileNumber(conclusion.metrics[metricName][scenario], expected, `artifact.conclusion.metrics.${metricName}.${scenario}`);
654
+ }
655
+ }
656
+ }
463
657
  const approvalConditionsSatisfied = boolean(source.approval_conditions_satisfied, "artifact.approval_conditions_satisfied");
464
658
  if (status !== "blocked" && !approvalConditionsSatisfied) {
465
659
  throw new Error("A non-blocked approved forecast requires all approval conditions to be satisfied.");
@@ -482,8 +676,10 @@ function validateApprovedForecast(source: Record<string, unknown>): ApprovedCycl
482
676
  forecast_by_slice: forecastSlices,
483
677
  consolidated_forecast: consolidated,
484
678
  calibration_policy: policy,
485
- unsupported_metrics: jsonArray(source.unsupported_metrics, "artifact.unsupported_metrics"),
679
+ unsupported_metrics: unsupportedMetrics,
486
680
  reconciliation_checks: jsonArray(source.reconciliation_checks, "artifact.reconciliation_checks"),
681
+ ...(dataQuality ? { data_quality: dataQuality } : {}),
682
+ ...(conclusion ? { conclusion } : {}),
487
683
  frozen_at: isoTimestamp(source.frozen_at, "artifact.frozen_at"),
488
684
  };
489
685
  }
@@ -617,9 +813,13 @@ export function validateExecutionAgainstForecast(
617
813
  if (execution.strategy_version !== forecast.strategy_version) throw new Error("Execution receipt strategy_version does not match the approved forecast.");
618
814
  if (execution.human_approval_id !== forecast.human_approval_id) throw new Error("Execution receipt human_approval_id does not match the approved forecast.");
619
815
  const allocationByKey = new Map(forecast.approved_allocation.map((allocation) => [sliceKey(allocation), allocation]));
816
+ const isolatedExecutionKeys = new Set((forecast.data_quality?.issues ?? []).map((issue) => sliceKey(issue.slice)));
620
817
  const executionKeys = new Set<string>();
621
818
  for (const slice of execution.slices) {
622
819
  const key = sliceKey(slice);
820
+ if (isolatedExecutionKeys.has(key)) {
821
+ throw new Error(`Execution receipt contains a slice isolated by forecast data quality: ${key.replaceAll("\u0000", " / ")}.`);
822
+ }
623
823
  executionKeys.add(key);
624
824
  const allocation = allocationByKey.get(key);
625
825
  if (!allocation) throw new Error(`Execution receipt contains an unapproved slice ${key.replaceAll("\u0000", " / ")}.`);