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