@viccydev/pi-fpa 0.8.0 → 0.9.0

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.
Files changed (29) hide show
  1. package/README.md +15 -4
  2. package/extensions/fpa-artifacts/compose.ts +156 -42
  3. package/extensions/fpa-artifacts/contracts.ts +202 -2
  4. package/extensions/fpa-artifacts/forecast-quality.ts +138 -0
  5. package/extensions/fpa-artifacts/index.ts +24 -1
  6. package/extensions/fpa-dashboard/coordinator.ts +2 -2
  7. package/extensions/fpa-dashboard/cycle-operating-projection.ts +5 -4
  8. package/extensions/fpa-dashboard/forward-outlook.ts +5 -2
  9. package/extensions/fpa-dashboard/index.ts +12 -3
  10. package/extensions/fpa-dashboard/projector.ts +58 -1
  11. package/extensions/fpa-dashboard/service.ts +13 -2
  12. package/extensions/fpa-dashboard/stage-projector.ts +131 -10
  13. package/extensions/fpa-routing-guard/graph-installer.ts +234 -0
  14. package/extensions/fpa-routing-guard/index.ts +114 -28
  15. package/graphs/fpa-forecast-freeze.json +103 -7
  16. package/graphs/fpa-strategy-planning.json +159 -0
  17. package/package.json +2 -2
  18. package/prompts/fpa-plan-cycle.md +12 -12
  19. package/skills/fpa-apply-core-rules/SKILL.md +8 -8
  20. package/skills/fpa-apply-core-rules/references/core-rules.md +11 -13
  21. package/skills/fpa-diagnose-actuals/references/artifact-contract.md +2 -0
  22. package/skills/fpa-forecast-approved-strategy/SKILL.md +12 -0
  23. package/skills/fpa-forecast-approved-strategy/references/artifact-contract.md +26 -1
  24. package/skills/fpa-recommend-strategy/SKILL.md +1 -1
  25. package/skills/fpa-recommend-strategy/references/artifact-contract.md +2 -1
  26. package/skills/fpa-refresh-dashboard/SKILL.md +7 -4
  27. package/skills/fpa-review-strategy/references/artifact-contract.md +22 -0
  28. package/graphs/fpa-period-analysis.json +0 -10
  29. package/graphs/fpa-strategy-recommendation.json +0 -11
package/README.md CHANGED
@@ -33,7 +33,9 @@ Skill:
33
33
  阶段的请求都必须先查询 Graph catalog,再运行当前第一个满足入口条件的
34
34
  Graph;在此之前不得调用 `fpa_*` 工具或写 Artifact。四个工作流的边界为:
35
35
 
36
- - `fpa-strategy-planning`:规划至 `reviewed_strategy_handoff` 后停止。
36
+ - `fpa-strategy-planning`:在一个组合 Graph 中完成规划至
37
+ `reviewed_strategy_handoff` 后停止。数据/驱动审计与三类策略场景分别并行;
38
+ 并行子节点只读,正式 Artifact 由后续汇总节点顺序写入。
37
39
  - `fpa-forecast-freeze`:只接受精确 handoff,经人工批准后冻结 Forecast。
38
40
  - `fpa-strategy-execution`:只接受精确 committed Forecast ref 和单独执行授权。
39
41
  - `fpa-cycle-review`:只接受精确 Forecast/Execution refs 与新 Actuals。
@@ -47,6 +49,11 @@ Graph 的业务阻塞不能触发父 Agent 降级执行。Graph 工具不可用
47
49
  它从展开后的请求识别多阶段 FP&A 意图,要求目标 Graph 与请求阶段一致,并
48
50
  阻断父 Agent 的提前 `fpa_*` 调用和 `artifacts/` 写入。Graph worker 没有
49
51
  `graph_list`/`graph_run` 时不会激活该护栏,因此节点仍可执行被分配的单一阶段。
52
+ 首次在可信项目进入 FP&A 工作流时,护栏会把 package 自带的 Graph 模板安装到
53
+ `.agent-graph/graphs`;较旧的同名模板和已经退役的拆分 Graph 会先移入
54
+ `.agent-graph/graphs_backup_v*` 再更新,因此 `graph_list` 能直接发现当前组合
55
+ Graph,同时保留可恢复的旧定义。非可信项目、符号链接目录和更高版本的项目
56
+ Graph 均不会被覆盖。
50
57
 
51
58
  ## 数据 Extension(fpa-data)
52
59
 
@@ -75,10 +82,14 @@ Extension 内置的关键防护:
75
82
 
76
83
  `fpa-artifacts` 提供 `fpa_artifact_commit`,对 `approved_cycle_forecast` 和 `execution_receipt` 做严格字段校验、对账、稳定指纹和原子落盘。只有工具返回成功后的 JSON 才是冻结产物,Markdown 不是正式数据源。
77
84
 
78
- `fpa-dashboard` 提供三个工具:
85
+ `fpa-dashboard` 提供分模块发布工具和兼容的闭环刷新工具:
79
86
 
80
87
  | 工具 | 作用 |
81
88
  | --- | --- |
89
+ | `fpa_dashboard_module_status` | 只读检查各独立模块及当前 Dashboard revision |
90
+ | `fpa_dashboard_publish_review` | 只发布 `period-review`,保留其他模块 |
91
+ | `fpa_dashboard_publish_strategy` | 只发布带确认/修改动作的 `next-strategy`,并绑定当前主会话 |
92
+ | `fpa_dashboard_publish_forecast` | 确认策略并冻结预测后,只发布 `next-forecast` |
82
93
  | `fpa_dashboard_status` | 只读检查当前 manifest、构建回执和各数据集是否可读 |
83
94
  | `fpa_dashboard_refresh` | 从冻结预测、可选执行回执和实时 Actuals 生成固定的闭环看板;先 preview,再携带相同指纹原子 publish |
84
95
  | `fpa_dashboard_refresh_queue` | 检查或处理持久刷新队列;主 Agent 用 `enqueue_artifact` 显式交接 Forecast/Execution,Actuals watermark 按 SLA 轮询并幂等发布 |
@@ -148,12 +159,12 @@ pi list
148
159
  团队分发建议使用固定 Git tag:
149
160
 
150
161
  ```bash
151
- pi install git:github.com/linyqh/pi-fpa@v0.8.0
162
+ pi install git:github.com/linyqh/pi-fpa@v0.9.0
152
163
  ```
153
164
 
154
165
  ## 发布到 npm
155
166
 
156
- 发布动作由 GitHub Release 触发。Release 标签必须严格使用 `v<package.json version>`,例如版本 `0.8.0` 对应 `v0.8.0`。工作流会检出该标签,执行 `npm ci`、`npm test` 和包内容预检,全部通过后发布公开包 `@viccydev/pi-fpa`。普通 Release 发布到 `latest`,Prerelease 发布到 `next`。
167
+ 发布动作由 GitHub Release 触发。Release 标签必须严格使用 `v<package.json version>`,例如版本 `0.9.0` 对应 `v0.9.0`。工作流会检出该标签,执行 `npm ci`、`npm test` 和包内容预检,全部通过后发布公开包 `@viccydev/pi-fpa`。普通 Release 发布到 `latest`,Prerelease 发布到 `next`。
157
168
 
158
169
  发布认证使用 npm Trusted Publishing / OIDC,不使用长期 npm Token。npm 包后台的 Trusted Publisher 配置为:
159
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.
@@ -36,11 +42,7 @@ import {
36
42
  // ============================================================================
37
43
 
38
44
  /** 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
- }
45
+ export interface UaDimensionValues extends ForecastDimensionSnapshot {}
44
46
 
45
47
  export interface PlanSliceRoas {
46
48
  downside: number | null;
@@ -172,7 +174,10 @@ function sumScenario(values: Array<number | null>): number | null {
172
174
  export interface ComposeDiagnostics {
173
175
  slice_count: number;
174
176
  stopped_slice_count: number;
177
+ limited_slice_count: number;
175
178
  total_approved_spend: number;
179
+ calculable_approved_spend: number;
180
+ calculable_spend_pct: number;
176
181
  validated_dimensions: boolean;
177
182
  portfolio_placeholder_app_id: string | null;
178
183
  }
@@ -192,21 +197,78 @@ export interface ComposeResult {
192
197
  */
193
198
  export async function loadUaDimensionValues(signal?: AbortSignal): Promise<UaDimensionValues> {
194
199
  const result = await runStructuredQuery(
195
- { dataset: "ua_spend", metrics: ["spend"], dimensions: ["app_code", "platform", "media_source"], limit: 5000 },
200
+ { dataset: "ua_spend", metrics: ["spend"], dimensions: ["app_code", "platform", "media_source"], limit: 1000 },
196
201
  signal,
197
202
  );
198
- const values: UaDimensionValues = { app_code: new Set(), platform: new Set(), media_source: new Set() };
203
+ const values: UaDimensionValues = { app_code: new Set(), platform: new Set(), media_source: new Set(), tuples: new Set() };
199
204
  for (const row of result.rows) {
200
205
  if (row.app_code != null) values.app_code.add(String(row.app_code));
201
206
  if (row.platform != null) values.platform.add(String(row.platform));
202
207
  if (row.media_source != null) values.media_source.add(String(row.media_source));
208
+ if (row.app_code != null && row.platform != null && row.media_source != null) {
209
+ values.tuples?.add(sliceKey({
210
+ app_id: String(row.app_code),
211
+ store: String(row.platform),
212
+ channel_group: String(row.media_source),
213
+ }));
214
+ }
203
215
  }
204
216
  return values;
205
217
  }
206
218
 
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)`;
219
+ /**
220
+ * Read only the candidate tuples named by the plan. This avoids treating the
221
+ * structured query's 1000-row safety cap as a complete dimension catalog.
222
+ */
223
+ async function loadUaDimensionValuesForSlices(slices: PlanSlice[], signal?: AbortSignal): Promise<UaDimensionValues> {
224
+ const appIds = new Set(slices.map((slice) => slice.app_id));
225
+ const collapsedAppId = appIds.size === 1 ? [...appIds][0] : null;
226
+ let portfolioMode = false;
227
+ if (collapsedAppId !== null) {
228
+ const appProbe = await runStructuredQuery({
229
+ dataset: "ua_spend",
230
+ metrics: ["spend"],
231
+ dimensions: ["app_code"],
232
+ filters: { app_code: collapsedAppId },
233
+ limit: 1,
234
+ }, signal);
235
+ portfolioMode = appProbe.rows.length === 0;
236
+ }
237
+
238
+ const uniqueSlices = [...new Map(slices.map((slice) => [sliceKey(slice), slice])).values()];
239
+ const result = portfolioMode
240
+ ? await runStructuredQuery({
241
+ dataset: "ua_spend",
242
+ metrics: ["spend"],
243
+ dimensions: ["platform", "media_source"],
244
+ exactUaChannelScope: uniqueSlices.map((slice) => ({ platform: slice.store, media_source: slice.channel_group })),
245
+ limit: uniqueSlices.length,
246
+ }, signal)
247
+ : await runStructuredQuery({
248
+ dataset: "ua_spend",
249
+ metrics: ["spend"],
250
+ dimensions: ["app_code", "platform", "media_source"],
251
+ exactUaScope: uniqueSlices.map((slice) => ({
252
+ app_code: slice.app_id,
253
+ platform: slice.store,
254
+ media_source: slice.channel_group,
255
+ })),
256
+ limit: uniqueSlices.length,
257
+ }, signal);
258
+ const values: UaDimensionValues = { app_code: new Set(), platform: new Set(), media_source: new Set(), tuples: new Set() };
259
+ for (const row of result.rows) {
260
+ if (row.app_code != null) values.app_code.add(String(row.app_code));
261
+ if (row.platform != null) values.platform.add(String(row.platform));
262
+ if (row.media_source != null) values.media_source.add(String(row.media_source));
263
+ if (row.platform != null && row.media_source != null) {
264
+ values.tuples?.add(sliceKey({
265
+ app_id: row.app_code == null ? "" : String(row.app_code),
266
+ store: String(row.platform),
267
+ channel_group: String(row.media_source),
268
+ }));
269
+ }
270
+ }
271
+ return values;
210
272
  }
211
273
 
212
274
  /**
@@ -218,33 +280,8 @@ function sample(values: Set<string>, limit = 8): string {
218
280
  * test the dashboard applies, so the two agree on what "portfolio mode" means.
219
281
  * Store and channel have no such mode — a value the mart never uses is an error.
220
282
  */
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 };
283
+ export function validateSliceKeys(slices: PlanSlice[], values: UaDimensionValues): ForecastQualityAssessment {
284
+ return assessForecastQuality(slices, values);
248
285
  }
249
286
 
250
287
  export interface ComposeOptions {
@@ -263,6 +300,7 @@ export async function composeApprovedForecast(plan: unknown, options: ComposeOpt
263
300
  }
264
301
  if (!Array.isArray(source.slices) || source.slices.length === 0) throw new Error("plan.slices must be a non-empty array.");
265
302
  const slices = source.slices.map((value, index) => planSlice(value, index));
303
+ if (slices.length > 500) throw new Error("plan.slices cannot exceed 500 slices.");
266
304
 
267
305
  const seen = new Set<string>();
268
306
  for (const slice of slices) {
@@ -289,9 +327,28 @@ export async function composeApprovedForecast(plan: unknown, options: ComposeOpt
289
327
  + "Scope deliberately excluded upstream is not a limit — if nothing in scope is missing, the status is complete.",
290
328
  );
291
329
  }
330
+ if (status === "complete_with_limits") {
331
+ for (const [index, value] of unsupported.entries()) {
332
+ const item = record(value, `plan.unsupported_metrics[${index}]`);
333
+ text(item.metric, `plan.unsupported_metrics[${index}].metric`);
334
+ text(item.reason, `plan.unsupported_metrics[${index}].reason`);
335
+ if (!Array.isArray(item.affected_scope) || item.affected_scope.length === 0) {
336
+ throw new Error(`plan.unsupported_metrics[${index}].affected_scope must be a non-empty array.`);
337
+ }
338
+ for (const [scopeIndex, scope] of item.affected_scope.entries()) text(scope, `plan.unsupported_metrics[${index}].affected_scope[${scopeIndex}]`);
339
+ const evidence = Array.isArray(item.evidence) ? item.evidence : [item.evidence];
340
+ if (evidence.length === 0) throw new Error(`plan.unsupported_metrics[${index}].evidence must not be empty.`);
341
+ for (const [evidenceIndex, entry] of evidence.entries()) text(entry, `plan.unsupported_metrics[${index}].evidence[${evidenceIndex}]`);
342
+ text(item.remediation, `plan.unsupported_metrics[${index}].remediation`);
343
+ text(item.owner_role, `plan.unsupported_metrics[${index}].owner_role`);
344
+ }
345
+ }
292
346
 
293
- const loadValues = options.loadDimensionValues ?? loadUaDimensionValues;
294
- const { portfolioAppId } = validateSliceKeys(slices, await loadValues(options.signal));
347
+ const dimensionValues = options.loadDimensionValues
348
+ ? await options.loadDimensionValues(options.signal)
349
+ : await loadUaDimensionValuesForSlices(slices, options.signal);
350
+ const quality = validateSliceKeys(slices, dimensionValues);
351
+ const { portfolioAppId } = quality;
295
352
 
296
353
  const period = record(source.target_period, "plan.target_period") as unknown as Period;
297
354
  const currency = text(source.reporting_currency, "plan.reporting_currency").toUpperCase();
@@ -310,8 +367,11 @@ export async function composeApprovedForecast(plan: unknown, options: ComposeOpt
310
367
  // Spend is the decision, not a prediction: an approved allocation is the same
311
368
  // number in every scenario. Only revenue moves, through the ROAS assumption.
312
369
  const forecastBySlice: ForecastSlice[] = slices.map((slice) => {
370
+ const eligible = quality.eligibleSliceKeys.has(sliceKey(slice));
313
371
  const derived = Object.fromEntries(
314
- SCENARIOS.map((scenario) => [scenario, sliceScenario(slice.approved_spend, slice.roas[scenario])]),
372
+ SCENARIOS.map((scenario) => [scenario, eligible
373
+ ? sliceScenario(slice.approved_spend, slice.roas[scenario])
374
+ : { revenue: null, roas: null }]),
315
375
  ) as Record<Scenario, { revenue: number | null; roas: number | null }>;
316
376
  return {
317
377
  app_id: slice.app_id,
@@ -357,6 +417,45 @@ export async function composeApprovedForecast(plan: unknown, options: ComposeOpt
357
417
  roas: scenarioMetric(consolidatedRoas, "ratio", window),
358
418
  };
359
419
 
420
+ const eligibleForecastSlices = forecastBySlice.filter((slice) => quality.eligibleSliceKeys.has(sliceKey(slice)));
421
+ const conclusionStatus: ForecastConclusion["status"] = quality.dataQuality.status === "complete"
422
+ ? "available"
423
+ : quality.dataQuality.status === "partial"
424
+ ? "available_with_limits"
425
+ : "unavailable";
426
+ const conclusionScope: ForecastConclusion["scope"] = quality.dataQuality.status === "complete"
427
+ ? "portfolio"
428
+ : quality.dataQuality.status === "partial"
429
+ ? "calculable_slices"
430
+ : "none";
431
+ const conclusionScenario = (metric: "spend" | "revenue"): Record<Scenario, number | null> => Object.fromEntries(
432
+ SCENARIOS.map((scenario) => {
433
+ if (conclusionStatus === "unavailable") return [scenario, null];
434
+ return [scenario, sumScenario(eligibleForecastSlices.map((slice) => slice.metrics[metric][scenario]))];
435
+ }),
436
+ ) as Record<Scenario, number | null>;
437
+ const conclusionSpend = conclusionScenario("spend");
438
+ const conclusionRevenue = conclusionScenario("revenue");
439
+ const conclusionRoas = Object.fromEntries(SCENARIOS.map((scenario) => {
440
+ const spend = conclusionSpend[scenario];
441
+ const revenue = conclusionRevenue[scenario];
442
+ return [scenario, spend === null || revenue === null || spend === 0 ? null : revenue / spend];
443
+ })) as Record<Scenario, number | null>;
444
+ const conclusion: ForecastConclusion = {
445
+ status: conclusionStatus,
446
+ scope: conclusionScope,
447
+ metrics: {
448
+ spend: scenarioMetric(conclusionSpend, currency, window),
449
+ revenue: scenarioMetric(conclusionRevenue, currency, window),
450
+ roas: scenarioMetric(conclusionRoas, "ratio", window),
451
+ },
452
+ warning: quality.dataQuality.status === "complete"
453
+ ? null
454
+ : quality.dataQuality.status === "partial"
455
+ ? `结论仅覆盖可计算切片,占计划支出的 ${(quality.dataQuality.calculable_spend_pct * 100).toFixed(2)}%;未覆盖切片未参与收入和 ROAS 计算。`
456
+ : "没有可计算切片;已保留全部批准预算,但不提供收入或 ROAS 数值结论。",
457
+ };
458
+
360
459
  // Portfolio-level series the model measured rather than derived — organic
361
460
  // revenue and the like. They pass through untouched apart from the window,
362
461
  // which is the artifact's to define, not the plan's.
@@ -379,9 +478,19 @@ export async function composeApprovedForecast(plan: unknown, options: ComposeOpt
379
478
  }
380
479
  }
381
480
 
481
+ const artifactStatus = status === "blocked"
482
+ ? "blocked"
483
+ : quality.dataQuality.status === "complete"
484
+ ? status
485
+ : "complete_with_limits";
486
+ const qualityUnsupported = quality.dataQuality.issues.length === 0 ? [] : [{
487
+ metric: "revenue_roas_by_affected_slice",
488
+ reason: "slice_key_not_in_actuals",
489
+ affected_slice_count: quality.dataQuality.issue_count,
490
+ }];
382
491
  const artifact = {
383
492
  artifact_type: "approved_cycle_forecast",
384
- status,
493
+ status: artifactStatus,
385
494
  forecast_version: text(source.forecast_version, "plan.forecast_version"),
386
495
  strategy_version: text(source.strategy_version, "plan.strategy_version"),
387
496
  strategy_review_id: text(source.strategy_review_id, "plan.strategy_review_id"),
@@ -397,8 +506,10 @@ export async function composeApprovedForecast(plan: unknown, options: ComposeOpt
397
506
  forecast_by_slice: forecastBySlice,
398
507
  consolidated_forecast: consolidated,
399
508
  calibration_policy: source.calibration_policy,
400
- unsupported_metrics: unsupported,
509
+ unsupported_metrics: [...unsupported, ...qualityUnsupported],
401
510
  reconciliation_checks: source.reconciliation_checks,
511
+ data_quality: quality.dataQuality,
512
+ conclusion,
402
513
  frozen_at: typeof source.frozen_at === "string" && source.frozen_at.trim()
403
514
  ? source.frozen_at
404
515
  : (options.now?.() ?? new Date()).toISOString().replace(/\.\d{3}Z$/, "Z"),
@@ -409,7 +520,10 @@ export async function composeApprovedForecast(plan: unknown, options: ComposeOpt
409
520
  diagnostics: {
410
521
  slice_count: slices.length,
411
522
  stopped_slice_count: slices.filter((slice) => slice.approved_spend === 0).length,
523
+ limited_slice_count: quality.limitedSliceKeys.size,
412
524
  total_approved_spend: slices.reduce((total, slice) => total + slice.approved_spend, 0),
525
+ calculable_approved_spend: quality.dataQuality.calculable_spend,
526
+ calculable_spend_pct: quality.dataQuality.calculable_spend_pct,
413
527
  validated_dimensions: true,
414
528
  portfolio_placeholder_app_id: portfolioAppId,
415
529
  },
@@ -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", " / ")}.`);