@viccydev/pi-fpa 0.8.1 → 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.
package/README.md CHANGED
@@ -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.0
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.0` 对应 `v0.9.0`。工作流会检出该标签,执行 `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.
@@ -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", " / ")}.`);
@@ -0,0 +1,138 @@
1
+ import {
2
+ sliceKey,
3
+ type ForecastDataQuality,
4
+ type ForecastDataQualityIssue,
5
+ } from "./contracts.ts";
6
+
7
+ export interface ForecastQualitySlice {
8
+ app_id: string;
9
+ store: string;
10
+ channel_group: string;
11
+ approved_spend: number;
12
+ }
13
+
14
+ export interface ForecastDimensionSnapshot {
15
+ app_code: Set<string>;
16
+ platform: Set<string>;
17
+ media_source: Set<string>;
18
+ /** Canonical app_code / platform / media_source combinations observed in ua_spend. */
19
+ tuples?: Set<string>;
20
+ }
21
+
22
+ export interface ForecastQualityAssessment {
23
+ portfolioAppId: string | null;
24
+ eligibleSliceKeys: Set<string>;
25
+ limitedSliceKeys: Set<string>;
26
+ dataQuality: ForecastDataQuality;
27
+ }
28
+
29
+ function sample(values: Set<string>, limit = 8): string {
30
+ const list = [...values].sort();
31
+ return list.length <= limit ? list.join(", ") : `${list.slice(0, limit).join(", ")}, … (${list.length} total)`;
32
+ }
33
+
34
+ function issueForSlice(
35
+ slice: ForecastQualitySlice,
36
+ index: number,
37
+ evidence: string[],
38
+ ): ForecastDataQualityIssue {
39
+ return {
40
+ issue_id: `DQ-SLICE-${String(index + 1).padStart(3, "0")}`,
41
+ code: "slice_key_not_in_actuals",
42
+ severity: "warning",
43
+ slice: {
44
+ app_id: slice.app_id,
45
+ store: slice.store,
46
+ channel_group: slice.channel_group,
47
+ },
48
+ affected_metrics: ["actuals_comparison", "revenue", "roas", "execution"],
49
+ disposition: "excluded_from_calculation",
50
+ evidence,
51
+ remediation:
52
+ "请由数据工作人员修正 ua_spend 的 app_code/platform/media_source 映射,或更新计划切片为数据集中真实存在的组合。",
53
+ owner_role: "data_steward",
54
+ };
55
+ }
56
+
57
+ /**
58
+ * Classify slice identity problems as data-quality limits, not governance
59
+ * failures. The allocation remains intact; callers use eligibleSliceKeys to
60
+ * decide which derived metrics may be calculated and which slices may execute.
61
+ */
62
+ export function assessForecastQuality(
63
+ slices: ForecastQualitySlice[],
64
+ values: ForecastDimensionSnapshot,
65
+ ): ForecastQualityAssessment {
66
+ const appIds = new Set(slices.map((slice) => slice.app_id));
67
+ const collapsed = appIds.size === 1 ? [...appIds][0] : null;
68
+ const portfolioAppId = collapsed !== null && !values.app_code.has(collapsed) ? collapsed : null;
69
+ const eligibleSliceKeys = new Set<string>();
70
+ const limitedSliceKeys = new Set<string>();
71
+ const issues: ForecastDataQualityIssue[] = [];
72
+
73
+ for (const [index, slice] of slices.entries()) {
74
+ const evidence: string[] = [];
75
+ if (portfolioAppId === null && !values.app_code.has(slice.app_id)) {
76
+ evidence.push(`app_id ${JSON.stringify(slice.app_id)} is not a ua_spend.app_code value. Valid values: ${sample(values.app_code)}.`);
77
+ }
78
+ if (!values.platform.has(slice.store)) {
79
+ evidence.push(`store ${JSON.stringify(slice.store)} is not a ua_spend.platform value. Valid values: ${sample(values.platform)}.`);
80
+ }
81
+ if (!values.media_source.has(slice.channel_group)) {
82
+ evidence.push(`channel_group ${JSON.stringify(slice.channel_group)} is not a ua_spend.media_source value. Valid values: ${sample(values.media_source)}.`);
83
+ }
84
+ if (
85
+ evidence.length === 0
86
+ && portfolioAppId === null
87
+ && values.tuples
88
+ && !values.tuples.has(sliceKey(slice))
89
+ ) {
90
+ evidence.push(
91
+ `The app_code/platform/media_source combination ${slice.app_id} / ${slice.store} / ${slice.channel_group} does not occur in ua_spend.`,
92
+ );
93
+ }
94
+ if (
95
+ evidence.length === 0
96
+ && portfolioAppId !== null
97
+ && values.tuples
98
+ && ![...values.tuples].some((tuple) => tuple.endsWith(`\u0000${slice.store}\u0000${slice.channel_group}`))
99
+ ) {
100
+ evidence.push(
101
+ `The platform/media_source combination ${slice.store} / ${slice.channel_group} does not occur in ua_spend portfolio data.`,
102
+ );
103
+ }
104
+
105
+ const key = sliceKey(slice);
106
+ if (evidence.length === 0) eligibleSliceKeys.add(key);
107
+ else {
108
+ limitedSliceKeys.add(key);
109
+ issues.push(issueForSlice(slice, index, evidence));
110
+ }
111
+ }
112
+
113
+ const plannedSpend = slices.reduce((total, slice) => total + slice.approved_spend, 0);
114
+ const calculableSpend = slices
115
+ .filter((slice) => eligibleSliceKeys.has(sliceKey(slice)))
116
+ .reduce((total, slice) => total + slice.approved_spend, 0);
117
+ const excludedSpend = plannedSpend - calculableSpend;
118
+ const status = issues.length === 0
119
+ ? "complete"
120
+ : calculableSpend === 0
121
+ ? "unavailable"
122
+ : "partial";
123
+
124
+ return {
125
+ portfolioAppId,
126
+ eligibleSliceKeys,
127
+ limitedSliceKeys,
128
+ dataQuality: {
129
+ status,
130
+ planned_spend: plannedSpend,
131
+ calculable_spend: calculableSpend,
132
+ calculable_spend_pct: plannedSpend === 0 ? (issues.length === 0 ? 1 : 0) : calculableSpend / plannedSpend,
133
+ excluded_spend: excludedSpend,
134
+ issue_count: issues.length,
135
+ issues,
136
+ },
137
+ };
138
+ }
@@ -142,7 +142,8 @@ export default function fpaArtifactsExtension(pi: ExtensionAPI): void {
142
142
  label: "Compose Approved Forecast",
143
143
  description:
144
144
  "Derive a complete, self-consistent approved_cycle_forecast from a compact plan file holding only the approved allocation and its per-slice ROAS assumptions. " +
145
- "Computes every scenario metric, the consolidated roll-up, units, windows, and frozen_at, and checks the plan's slice keys against the dimension values ua_spend actually uses. " +
145
+ "Computes every scenario metric, the consolidated roll-up, units, windows, and frozen_at, and checks plan slice keys against the exact dimension combinations ua_spend actually uses. " +
146
+ "Data-quality mismatches are isolated into complete_with_limits with coverage and repair diagnostics; approved allocation is never dropped or renormalized. " +
146
147
  "Returns the artifact for fpa_artifact_commit to freeze; writes nothing.",
147
148
  promptSnippet: "Derive a complete approved forecast from a compact plan",
148
149
  promptGuidelines: [
@@ -717,8 +717,8 @@ async function defaultWorker(event: DashboardRefreshEvent, projectRoot: string,
717
717
  ...(event.forward_forecast_refs ? { forwardForecastRefs: event.forward_forecast_refs } : {}),
718
718
  }, signal);
719
719
  if (result.sliceKeyMismatch) throw new Error(`Dashboard publish refused. ${result.sliceKeyMismatch}`);
720
- if (result.forecast.status !== "complete" || !result.forecast.approval_conditions_satisfied) {
721
- throw new Error("Dashboard publish requires a complete approved forecast with all approval conditions satisfied.");
720
+ if (result.forecast.status === "blocked" || !result.forecast.approval_conditions_satisfied) {
721
+ throw new Error("Dashboard publish requires a completed approved forecast with all approval conditions satisfied.");
722
722
  }
723
723
  const generationId = dashboardBuildFingerprint(result.build, result.projector);
724
724
  if (event.desired_generation_id && generationId !== event.desired_generation_id) {
@@ -218,8 +218,8 @@ export function projectCycleOperatingProjection(input: CycleOperatingProjectionI
218
218
  throw new Error("next_forecast target period must be the exact successor of the current cycle in the same timezone.");
219
219
  }
220
220
  if (next && next.reporting_currency !== current.reporting_currency) throw new Error("next_forecast reporting_currency must match current_forecast.");
221
- if (next && (next.status !== "complete" || !next.approval_conditions_satisfied)) {
222
- throw new Error("next_forecast must be complete with approval conditions satisfied.");
221
+ if (next && (next.status === "blocked" || !next.approval_conditions_satisfied)) {
222
+ throw new Error("next_forecast must be completed with approval conditions satisfied.");
223
223
  }
224
224
 
225
225
  const forecastSpend = scenario(current.consolidated_forecast.spend, "current_forecast.consolidated_forecast.spend");
@@ -253,9 +253,10 @@ export function projectCycleOperatingProjection(input: CycleOperatingProjectionI
253
253
  const nextSpend = next ? scenario(next.consolidated_forecast.spend, "next_forecast.consolidated_forecast.spend") : null;
254
254
  const nextRevenue = next ? scenario(next.consolidated_forecast.revenue, "next_forecast.consolidated_forecast.revenue") : null;
255
255
  const nextRoas = next ? scenario(next.consolidated_forecast.roas, "next_forecast.consolidated_forecast.roas") : null;
256
- const nextHasLimits = !!next && (nextSpend?.base === null || nextRevenue?.base === null || nextRoas?.base === null
256
+ const nextHasLimits = !!next && (next.status === "complete_with_limits"
257
+ || nextSpend?.base === null || nextRevenue?.base === null || nextRoas?.base === null
257
258
  || next.approved_allocation.some((item) => !item.owner));
258
- if (nextHasLimits) warnings.push("The next-cycle forecast is approved with unsupported base metrics; unavailable expectations remain null.");
259
+ if (nextHasLimits) warnings.push("The next-cycle forecast is approved with limits; unsupported expectations remain null and documented data issues still require remediation.");
259
260
  if (next?.approved_allocation.some((item) => !item.owner)) warnings.push("At least one next-cycle allocation has no accountable owner; strategy readiness is limited.");
260
261
 
261
262
  return {
@@ -26,7 +26,7 @@ export interface ForwardOutlookProjection {
26
26
  function approvedForecast(value: unknown, path: string): ApprovedCycleForecastInput {
27
27
  const artifact = validateArtifact(value);
28
28
  if (artifact.artifact_type !== "approved_cycle_forecast") throw new Error(`${path} must be an approved_cycle_forecast.`);
29
- if (artifact.status !== "complete" || !artifact.approval_conditions_satisfied) throw new Error(`${path} must be complete and approved.`);
29
+ if (artifact.status === "blocked" || !artifact.approval_conditions_satisfied) throw new Error(`${path} must be completed and approved.`);
30
30
  return artifact;
31
31
  }
32
32
 
@@ -46,6 +46,7 @@ export function projectForwardOutlook(input: {
46
46
  const current = approvedForecast(input.current_forecast, "current_forecast");
47
47
  if (!Array.isArray(input.forward_forecasts) || input.forward_forecasts.length > 6) throw new Error("forward_forecasts must contain at most six approved forecasts.");
48
48
  const months: ForwardOutlookProjection["months"] = [];
49
+ const warnings: string[] = [];
49
50
  let expectedStart = current.target_period.end_exclusive;
50
51
  for (const [index, item] of input.forward_forecasts.entries()) {
51
52
  const forecast = approvedForecast(item.forecast, `forward_forecasts[${index}].forecast`);
@@ -63,8 +64,10 @@ export function projectForwardOutlook(input: {
63
64
  expected_revenue: values(forecast.consolidated_forecast.revenue, `forward_forecasts[${index}].revenue`),
64
65
  expected_roas: values(forecast.consolidated_forecast.roas, `forward_forecasts[${index}].roas`),
65
66
  });
67
+ if (forecast.status === "complete_with_limits") {
68
+ warnings.push(`${forecast.forecast_version} has data limits; unsupported expectations remain null.`);
69
+ }
66
70
  }
67
- const warnings: string[] = [];
68
71
  if (months.length < 6) warnings.push(`Only ${months.length} of 6 forward monthly forecasts are approved and linked.`);
69
72
  if (months.some((month) => month.expected_revenue.base === null || month.expected_spend.base === null || month.expected_roas.base === null)) {
70
73
  warnings.push("At least one forward month has unsupported base metrics; its expectation remains unavailable.");
@@ -384,8 +384,8 @@ export default function fpaDashboardExtension(pi: ExtensionAPI): void {
384
384
  }
385
385
 
386
386
  if (sliceKeyMismatch) throw new Error(`Dashboard publish refused. ${sliceKeyMismatch}`);
387
- if (forecast.status !== "complete" || !forecast.approval_conditions_satisfied) {
388
- throw new Error("Dashboard publish requires a complete approved forecast with all approval conditions satisfied.");
387
+ if (forecast.status === "blocked" || !forecast.approval_conditions_satisfied) {
388
+ throw new Error("Dashboard publish requires a completed approved forecast with all approval conditions satisfied.");
389
389
  }
390
390
  if (!params.expected_preview_fingerprint) throw new Error("Publish requires expected_preview_fingerprint from a preceding preview.");
391
391
  if (params.expected_preview_fingerprint !== previewFingerprint) {
@@ -226,10 +226,11 @@ function joinSlices(
226
226
  const forecastByKey = new Map(forecast.forecast_by_slice.map((slice) => [sliceKey(slice), slice]));
227
227
  const actualByKey = new Map(actuals.slices.map((slice) => [sliceKey(slice), slice]));
228
228
  const executionByKey = new Map((execution?.slices ?? []).map((slice) => [sliceKey(slice), slice]));
229
+ const isolatedKeys = new Set((forecast.data_quality?.issues ?? []).map((issue) => sliceKey(issue.slice)));
229
230
  return forecast.approved_allocation.map((allocation) => {
230
231
  const forecastSlice = forecastByKey.get(sliceKey(allocation));
231
232
  if (!forecastSlice) throw new Error(`Forecast slice is missing for ${allocation.app_id} / ${allocation.store} / ${allocation.channel_group}.`);
232
- const actual = actualByKey.get(sliceKey(allocation)) ?? null;
233
+ const actual = isolatedKeys.has(sliceKey(allocation)) ? null : actualByKey.get(sliceKey(allocation)) ?? null;
233
234
  const forecastRoas = safeDiv(
234
235
  forecastSlice.metrics.revenue?.base ?? null,
235
236
  forecastSlice.metrics.spend?.base ?? null,
@@ -390,8 +391,64 @@ export function projectDashboard(input: ProjectionInput): DashboardBuild {
390
391
  const deviationCount = alerts.rows.length - stopCount;
391
392
  const window = `${formatPeriodDate(forecast.target_period.start_inclusive, forecast.target_period.timezone, locale)} → ${formatPeriodDate(forecast.target_period.end_exclusive, forecast.target_period.timezone, locale)}(end exclusive)`;
392
393
  const asOfNote = actuals.data_as_of === ACTUALS_DATA_AS_OF_UNAVAILABLE ? "本周期暂无 Actuals" : `Actuals 截至 ${actuals.data_as_of}`;
394
+ const quality = forecast.data_quality;
395
+ const qualityWidgets: DashboardWidget[] = !quality || quality.status === "complete" ? [] : [
396
+ {
397
+ id: "forecast-data-coverage",
398
+ type: "stat",
399
+ span: "full",
400
+ dataset: "forecast-data-coverage.json",
401
+ data: {
402
+ label: "预测数据覆盖率",
403
+ value: `${(quality.calculable_spend_pct * 100).toFixed(2)}%`,
404
+ description: `可计算预算 ${formatCurrency(quality.calculable_spend, forecast.reporting_currency, locale)} / 计划预算 ${formatCurrency(quality.planned_spend, forecast.reporting_currency, locale)}`,
405
+ footnote: forecast.conclusion?.warning ?? "异常切片未参与收入与 ROAS 计算,批准预算未被删除或重分配。",
406
+ progress: { fraction: quality.calculable_spend_pct, label: `${quality.issue_count} 个待修复切片`, tone: "warning" },
407
+ },
408
+ },
409
+ {
410
+ id: "forecast-data-issues",
411
+ type: "table",
412
+ span: "full",
413
+ dataset: "forecast-data-issues.json",
414
+ data: {
415
+ label: "数据待修复项",
416
+ description: "问题切片保持在批准计划中,但不参与预测计算、Actuals 比较或执行。",
417
+ columns: [
418
+ { key: "slice", label: "App / 商店 / 渠道" },
419
+ { key: "problem", label: "数据问题" },
420
+ { key: "remediation", label: "修复建议" },
421
+ { key: "owner", label: "负责人" },
422
+ ],
423
+ rows: quality.issues.map((issue) => ({
424
+ slice: `${issue.slice.app_id} · ${issue.slice.store} · ${issue.slice.channel_group}`,
425
+ problem: issue.evidence.join("\n"),
426
+ remediation: issue.remediation,
427
+ owner: issue.owner_role,
428
+ })),
429
+ },
430
+ },
431
+ ];
432
+ const generalLimits = forecast.unsupported_metrics.filter((item) => {
433
+ return !(item && typeof item === "object" && !Array.isArray(item)
434
+ && (item as Record<string, unknown>).reason === "slice_key_not_in_actuals");
435
+ });
436
+ const limitWidgets: DashboardWidget[] = generalLimits.length === 0 ? [] : [{
437
+ id: "forecast-limitations",
438
+ type: "table",
439
+ span: "full",
440
+ dataset: "forecast-limitations.json",
441
+ data: {
442
+ label: "预测限制提醒",
443
+ description: "以下上游数据或指标限制未阻断预测;不支持的值保持为空,待数据工作人员修复。",
444
+ columns: [{ key: "item", label: "限制与修复信息" }],
445
+ rows: generalLimits.map((item) => ({ item: typeof item === "string" ? item : JSON.stringify(item) })),
446
+ },
447
+ }];
393
448
 
394
449
  const widgets: DashboardWidget[] = [
450
+ ...qualityWidgets,
451
+ ...limitWidgets,
395
452
  {
396
453
  id: "revenue-vs-forecast",
397
454
  type: "stat",
@@ -173,7 +173,18 @@ export async function buildDashboardProjection(
173
173
  if (actuals.query_receipts.some((receipt) => receipt.dataset === "ua_spend.slices.discovery" && receipt.row_count >= 1000)) build.warnings.push("Unplanned-slice discovery reached its 1000-row safety limit; approved slices and like-for-like totals remain complete, but additional warnings may be omitted.");
174
174
  if (actuals.data_as_of === "unavailable") build.warnings.push("No current-period UA Actuals are available.");
175
175
 
176
- const sliceKeyMismatch = detectSliceKeyMismatch(forecast.approved_allocation, actuals);
177
- if (sliceKeyMismatch) build.warnings.push(sliceKeyMismatch);
176
+ const detectedSliceKeyMismatch = detectSliceKeyMismatch(forecast.approved_allocation, actuals);
177
+ const qualityIssueKeys = new Set((forecast.data_quality?.issues ?? []).map((issue) => sliceKey(issue.slice)));
178
+ const qualityAcknowledgesMismatch = forecast.data_quality !== undefined
179
+ && forecast.data_quality.status !== "complete"
180
+ && forecast.approved_allocation
181
+ .filter((allocation) => allocation.approved_spend > 0)
182
+ .every((allocation) => qualityIssueKeys.has(sliceKey(allocation)));
183
+ const sliceKeyMismatch = detectedSliceKeyMismatch && !qualityAcknowledgesMismatch ? detectedSliceKeyMismatch : null;
184
+ if (detectedSliceKeyMismatch) {
185
+ build.warnings.push(qualityAcknowledgesMismatch
186
+ ? `${detectedSliceKeyMismatch} The affected slices are already isolated by forecast data quality, so dashboard publication continues with limits.`
187
+ : detectedSliceKeyMismatch);
188
+ }
178
189
  return { build, forecast, forecastRead, executionRead, actuals, sliceKeyMismatch, ...provenance };
179
190
  }
@@ -245,6 +245,61 @@ export function projectStrategyModule(
245
245
 
246
246
  export function projectForecastModule(forecast: ApprovedCycleForecast, artifactRef: ArtifactRefV2): DashboardModuleBuild {
247
247
  const allocationByKey = new Map(forecast.approved_allocation.map((item) => [`${item.app_id}\u0000${item.store}\u0000${item.channel_group}`, item]));
248
+ const quality = forecast.data_quality;
249
+ const summary = quality?.status !== "complete" && forecast.conclusion
250
+ ? forecast.conclusion.metrics
251
+ : forecast.consolidated_forecast;
252
+ const qualityWidgets: DashboardModuleBuild["widgets"] = !quality || quality.status === "complete" ? [] : [{
253
+ id: "forecast-data-coverage",
254
+ type: "stat",
255
+ span: "full",
256
+ dataset: "forecast-data-coverage.json",
257
+ data: {
258
+ label: "预测数据覆盖率",
259
+ value: `${(quality.calculable_spend_pct * 100).toFixed(2)}%`,
260
+ description: `可计算预算 ${quality.calculable_spend.toFixed(2)} / 计划预算 ${quality.planned_spend.toFixed(2)};异常切片 ${quality.issue_count} 个。`,
261
+ footnote: forecast.conclusion?.warning ?? "异常切片未参与收入与 ROAS 计算,批准预算未被删除或重分配。",
262
+ progress: { fraction: quality.calculable_spend_pct, label: `${(quality.calculable_spend_pct * 100).toFixed(2)}%`, tone: "warning" },
263
+ },
264
+ }];
265
+ const issueWidgets: DashboardModuleBuild["widgets"] = !quality || quality.issues.length === 0 ? [] : [{
266
+ id: "forecast-data-issues",
267
+ type: "table",
268
+ span: "full",
269
+ dataset: "forecast-data-issues.json",
270
+ data: {
271
+ label: "数据待修复项",
272
+ description: "这些问题不阻断预测结论,但对应切片不参与计算且不可执行。",
273
+ columns: [
274
+ { key: "slice", label: "App / 商店 / 渠道" },
275
+ { key: "problem", label: "问题" },
276
+ { key: "remediation", label: "修复建议" },
277
+ { key: "owner", label: "负责人" },
278
+ ],
279
+ rows: quality.issues.map((issue) => ({
280
+ slice: `${issue.slice.app_id} · ${issue.slice.store} · ${issue.slice.channel_group}`,
281
+ problem: issue.evidence.join("\n"),
282
+ remediation: issue.remediation,
283
+ owner: issue.owner_role,
284
+ })),
285
+ },
286
+ }];
287
+ const generalLimits = forecast.unsupported_metrics.filter((item) => {
288
+ return !(item && typeof item === "object" && !Array.isArray(item)
289
+ && (item as Record<string, unknown>).reason === "slice_key_not_in_actuals");
290
+ });
291
+ const limitWidgets: DashboardModuleBuild["widgets"] = generalLimits.length === 0 ? [] : [{
292
+ id: "forecast-limitations",
293
+ type: "table",
294
+ span: "full",
295
+ dataset: "forecast-limitations.json",
296
+ data: {
297
+ label: "预测限制提醒",
298
+ description: "以下上游数据或指标限制未阻断预测;不支持的值保持为空,待数据工作人员修复。",
299
+ columns: [{ key: "item", label: "限制与修复信息" }],
300
+ rows: generalLimits.map((item) => ({ item: typeof item === "string" ? item : JSON.stringify(item) })),
301
+ },
302
+ }];
248
303
  return {
249
304
  id: "next-forecast",
250
305
  title: "下周期数据预测",
@@ -259,6 +314,7 @@ export function projectForecastModule(forecast: ApprovedCycleForecast, artifactR
259
314
  data_as_of: forecast.data_as_of,
260
315
  },
261
316
  widgets: [
317
+ ...qualityWidgets,
262
318
  {
263
319
  id: "forecast-summary",
264
320
  type: "table",
@@ -267,13 +323,13 @@ export function projectForecastModule(forecast: ApprovedCycleForecast, artifactR
267
323
  data: {
268
324
  label: "预测摘要",
269
325
  columns: [{ key: "metric", label: "指标" }, { key: "downside", label: "Downside", align: "right" }, { key: "base", label: "Base", align: "right" }, { key: "upside", label: "Upside", align: "right" }],
270
- rows: Object.entries(forecast.consolidated_forecast).map(([metric, scenario]) => ({
326
+ rows: Object.entries(summary).map(([metric, scenario]) => ({
271
327
  metric,
272
328
  downside: cell(scenario.downside),
273
329
  base: cell(scenario.base),
274
330
  upside: cell(scenario.upside),
275
331
  })),
276
- description: `${forecast.target_period.start_inclusive} → ${forecast.target_period.end_exclusive}`,
332
+ description: `${forecast.target_period.start_inclusive} → ${forecast.target_period.end_exclusive}${quality?.status !== "complete" ? " · 部分口径" : ""}`,
277
333
  },
278
334
  },
279
335
  {
@@ -299,6 +355,8 @@ export function projectForecastModule(forecast: ApprovedCycleForecast, artifactR
299
355
  }),
300
356
  },
301
357
  },
358
+ ...issueWidgets,
359
+ ...limitWidgets,
302
360
  ],
303
361
  };
304
362
  }
@@ -33,7 +33,7 @@
33
33
  "label": "报告预测阻断",
34
34
  "outputKey": "forecast_blocked",
35
35
  "parseJson": true,
36
- "prompt": "核验结果:{{data.confirmed_strategy}}\n\n只输出 JSON:{\"kind\":\"fpa.graph-handoff\",\"status\":\"blocked\",\"graph\":\"fpa-forecast-freeze\",\"blockers\":[],\"required_action\":\"由原主会话提交与当前 handoff 精确匹配的策略确认\"}",
36
+ "prompt": "策略核验结果:{{data.confirmed_strategy}}\n预测计划修复结果:{{data.repair_forecast_plan}}\n\n汇总实际阻断原因。只输出 JSON:{\"kind\":\"fpa.graph-handoff\",\"status\":\"blocked\",\"graph\":\"fpa-forecast-freeze\",\"blockers\":[],\"required_action\":\"需要主会话处理的治理或策略动作\"}",
37
37
  "systemPrompt": "用中文简洁报告预测阻断,只输出约定 JSON。",
38
38
  "type": "prompt"
39
39
  },
@@ -43,7 +43,7 @@
43
43
  "label": "正式预测决策计划",
44
44
  "next": "compose_forecast",
45
45
  "outputKey": "draft_forecast",
46
- "prompt": "操作备注:{{goal}}\n\n已核验策略与决策:{{data.confirmed_strategy}}\n\n读取 reviewed_strategy_handoff、strategy_proposal、strategy_review 和精确 strategy_decision,按 fpa-forecast-approved-strategy 写 artifacts/forecast_plan.json。不得重新优化策略;业务分配、范围、假设或审核条件需变化时必须 blocked 并回到策略规划。只写 allocation 与每个切片的 ROAS assumptions;revenue、汇总、单位和 frozen_at 由下游 fpa_forecast_compose 推导。切片键必须使用 ua_spend 的 app_code/platform/media_source 合法值。不得调用任何 fpa_dashboard_* 工具。",
46
+ "prompt": "操作备注:{{goal}}\n\n已核验策略与决策:{{data.confirmed_strategy}}\n\n读取 reviewed_strategy_handoff、strategy_proposal、strategy_review 和精确 strategy_decision,按 fpa-forecast-approved-strategy 写 artifacts/forecast_plan.json。不得重新优化策略;业务分配、范围、假设或审核条件需变化时必须 blocked 并回到策略规划。只写 allocation 与每个切片的 ROAS assumptions;revenue、汇总、单位和 frozen_at 由下游 fpa_forecast_compose 推导。优先使用 ua_spend 的 app_code/platform/media_source 真实组合;若已确认分配中的部分切片与当前上游映射不一致,不得擅自删除、置零、重分配或伪造映射,保留原切片交给 compose 自动产出 complete_with_limits、覆盖率和数据修复项。不得调用任何 fpa_dashboard_* 工具。",
47
47
  "skills": ["fpa-apply-core-rules", "fpa-forecast-approved-strategy"],
48
48
  "systemPrompt": "你是 FP&A 正式预测 Agent。只按已确认策略写预测计划,不请求审批、不发布仪表盘、不手算派生值。",
49
49
  "tools": ["read", "write", "fpa_calc"],
@@ -63,14 +63,26 @@
63
63
  "agentName": "repair_forecast_plan",
64
64
  "id": "repair_forecast_plan",
65
65
  "label": "定点修正预测计划",
66
- "next": "compose_forecast",
66
+ "next": "route_repair_forecast_plan",
67
67
  "outputKey": "repair_forecast_plan",
68
- "prompt": "合成失败:{{data.__graphError}}\n\n读取 artifacts/forecast_plan.json、planning_brief 和 reviewed_strategy_handoff,只定点修正格式、合法切片键或 forecast plan 契约问题。不得改变已确认策略的业务分配、范围、假设或条件;若错误要求此类变化,报告 blocked。不得调用任何 fpa_dashboard_* 工具。",
68
+ "parseJson": true,
69
+ "prompt": "合成失败:{{data.__graphError}}\n\n读取 artifacts/forecast_plan.json、planning_brief 和 reviewed_strategy_handoff,只定点修正格式或 forecast plan 契约问题。不得通过删除、置零、重分配或伪造映射来修正数据质量问题;这类问题应由 compose 降级为 complete_with_limits。不得改变已确认策略的业务分配、范围、假设或条件;若错误要求此类变化,返回 blocked。不得调用任何 fpa_dashboard_* 工具。只输出 JSON:{\"status\":\"repaired|blocked\",\"changes\":[],\"blockers\":[]}",
69
70
  "skills": ["fpa-forecast-approved-strategy"],
70
71
  "systemPrompt": "你是预测计划修复 Agent,只按确定性合成报错做最小修正。",
71
72
  "tools": ["read", "write", "edit"],
72
73
  "type": "subagent"
73
74
  },
75
+ {
76
+ "cases": [
77
+ { "equals": "repaired", "label": "已修正契约", "to": "compose_forecast" },
78
+ { "equals": "blocked", "label": "需要治理处理", "to": "report_blocked" }
79
+ ],
80
+ "default": { "label": "其他结果", "to": "report_blocked" },
81
+ "id": "route_repair_forecast_plan",
82
+ "label": "判断能否重新合成",
83
+ "path": "data.repair_forecast_plan.status",
84
+ "type": "router"
85
+ },
74
86
  {
75
87
  "args": {
76
88
  "artifact": "{{data.compose_forecast.details.artifact}}",
@@ -91,5 +103,5 @@
91
103
  "schemaVersion": 1,
92
104
  "start": "load_confirmed_strategy",
93
105
  "transitionLabels": { "default": "其他情况", "error": "失败", "next": "继续" },
94
- "version": "2.1.0"
106
+ "version": "2.2.0"
95
107
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@viccydev/pi-fpa",
3
- "version": "0.8.1",
3
+ "version": "0.9.0",
4
4
  "type": "module",
5
5
  "description": "Full-cycle FP&A planning, strategy, forecast, and review prompts, skills, and data tools for Pi",
6
6
  "license": "UNLICENSED",
@@ -31,6 +31,8 @@ checks:
31
31
  affected_scope: []
32
32
  evidence: string
33
33
  remediation: string
34
+ owner_role: data_steward
35
+ disposition: excluded_from_calculation | warning_only | blocking
34
36
  blocking_issues: []
35
37
  non_blocking_limits: []
36
38
  ```
@@ -84,11 +84,23 @@ tool. `complete_with_limits` remains publishable when its limitations are
84
84
  explicit, while `blocked` is not. Do not hide a real defect to get through the
85
85
  gate.
86
86
 
87
+ Upstream identity or coverage defects are non-blocking data limits. Preserve
88
+ the full approved allocation, leave affected revenue and ROAS values `NULL`,
89
+ and let `fpa_forecast_compose` emit `data_quality`, a coverage-labelled partial
90
+ `conclusion`, and actionable repair items. Never delete a bad slice, turn it
91
+ into zero spend, or renormalize the remaining allocation. The affected slice
92
+ remains ineligible for Actuals comparison and execution until its mapping is
93
+ fixed. Approval mismatch, duplicate or negative allocation, budget mismatch,
94
+ invalid period/currency/role, and unauthorized execution remain blocking
95
+ governance failures.
96
+
87
97
  ## Boundaries
88
98
 
89
99
  - Do not optimize, revise, or substitute the approved strategy while forecasting.
90
100
  - A required change invalidates the current approval and routes back to recommendation and review.
91
101
  - Unsupported KPIs remain `NULL` with a reason.
102
+ - A partial conclusion must say it covers only calculable slices and must show
103
+ `calculable_spend_pct`; it must never be labelled portfolio ROAS.
92
104
  - Do not execute the strategy and do not start continuous monitoring.
93
105
 
94
106
  ## Completion
@@ -41,6 +41,28 @@ calibration_policy:
41
41
  policy_version: string
42
42
  unsupported_metrics: []
43
43
  reconciliation_checks: []
44
+ data_quality:
45
+ status: complete | partial | unavailable
46
+ planned_spend: number
47
+ calculable_spend: number
48
+ calculable_spend_pct: number # 0..1; never renormalized
49
+ excluded_spend: number
50
+ issue_count: number
51
+ issues:
52
+ - issue_id: string
53
+ code: slice_key_not_in_actuals
54
+ severity: warning
55
+ slice: {app_id: string, store: string, channel_group: string}
56
+ affected_metrics: [actuals_comparison, revenue, roas, execution]
57
+ disposition: excluded_from_calculation
58
+ evidence: []
59
+ remediation: string
60
+ owner_role: data_steward
61
+ conclusion:
62
+ status: available | available_with_limits | unavailable
63
+ scope: portfolio | calculable_slices | none
64
+ metrics: {spend: scenario_metric, revenue: scenario_metric, roas: scenario_metric}
65
+ warning: string | null
44
66
  frozen_at: timestamp
45
67
  ```
46
68
 
@@ -97,7 +119,10 @@ Rules the plan has to respect, because compose enforces them:
97
119
 
98
120
  - **Spend is a decision, not a prediction.** It is identical in all three scenarios, so a slice carries one `approved_spend`. Only revenue moves, through `roas`.
99
121
  - **A stopped slice has no ROAS.** `approved_spend: 0` means revenue 0 and `roas` null in all three scenarios — the contract derives ROAS as revenue/spend and calls a zero denominator null.
100
- - **Slice keys must be values `ua_spend` actually uses.** A plan naming `google_play` where the mart says `android` is rejected with the valid values listed. The single-placeholder App axis for a scoped-out portfolio is the one permitted exception.
122
+ - **Slice keys are checked as real triples.** `app_code`, `platform`, and `media_source` must occur together in `ua_spend`; validating three independent value lists is insufficient. A mismatch is isolated as a data-quality limit: approved spend remains in the allocation and consolidated spend, while affected revenue/ROAS are `NULL`. The single-placeholder App axis for a scoped-out portfolio remains permitted.
123
+ - **Do not hide partiality.** Compose reports planned, calculable, and excluded spend. Its partial conclusion covers only calculable slices; remaining spend is never scaled to 100%, and partial ROAS is never labelled portfolio ROAS.
124
+ - **Data quality does not weaken governance.** Missing/mismatched approval, duplicate or negative allocation, invalid period/currency/forecast role, and budget reconciliation failures still block.
125
+ - **Describe other upstream limits actionably.** For non-identity defects, each `unsupported_metrics` item should name the affected metric/scope, evidence, remediation, and data owner. Dashboard publication keeps these items visible even when `data_quality.status` is otherwise complete.
101
126
  - **`status` and `unsupported_metrics` must agree.** `complete` requires an empty list; `complete_with_limits` requires a non-empty one. Scope excluded upstream is neither.
102
127
  - **Do not hand-compute anything derived.** A ROAS rounded to four decimals misses the commit tolerance and costs a re-draft.
103
128