@viccydev/pi-fpa 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -4
- package/extensions/fpa-artifacts/compose.ts +156 -42
- package/extensions/fpa-artifacts/contracts.ts +202 -2
- package/extensions/fpa-artifacts/forecast-quality.ts +138 -0
- package/extensions/fpa-artifacts/index.ts +24 -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 +12 -3
- package/extensions/fpa-dashboard/projector.ts +58 -1
- package/extensions/fpa-dashboard/service.ts +13 -2
- package/extensions/fpa-dashboard/stage-projector.ts +131 -10
- package/extensions/fpa-routing-guard/graph-installer.ts +234 -0
- package/extensions/fpa-routing-guard/index.ts +114 -28
- package/graphs/fpa-forecast-freeze.json +103 -7
- package/graphs/fpa-strategy-planning.json +159 -0
- package/package.json +2 -2
- package/prompts/fpa-plan-cycle.md +12 -12
- package/skills/fpa-apply-core-rules/SKILL.md +8 -8
- package/skills/fpa-apply-core-rules/references/core-rules.md +11 -13
- package/skills/fpa-diagnose-actuals/references/artifact-contract.md +2 -0
- package/skills/fpa-forecast-approved-strategy/SKILL.md +12 -0
- package/skills/fpa-forecast-approved-strategy/references/artifact-contract.md +26 -1
- package/skills/fpa-recommend-strategy/SKILL.md +1 -1
- package/skills/fpa-recommend-strategy/references/artifact-contract.md +2 -1
- package/skills/fpa-refresh-dashboard/SKILL.md +7 -4
- package/skills/fpa-review-strategy/references/artifact-contract.md +22 -0
- package/graphs/fpa-period-analysis.json +0 -10
- package/graphs/fpa-strategy-recommendation.json +0 -11
package/README.md
CHANGED
|
@@ -33,7 +33,9 @@ Skill:
|
|
|
33
33
|
阶段的请求都必须先查询 Graph catalog,再运行当前第一个满足入口条件的
|
|
34
34
|
Graph;在此之前不得调用 `fpa_*` 工具或写 Artifact。四个工作流的边界为:
|
|
35
35
|
|
|
36
|
-
- `fpa-strategy-planning
|
|
36
|
+
- `fpa-strategy-planning`:在一个组合 Graph 中完成规划至
|
|
37
|
+
`reviewed_strategy_handoff` 后停止。数据/驱动审计与三类策略场景分别并行;
|
|
38
|
+
并行子节点只读,正式 Artifact 由后续汇总节点顺序写入。
|
|
37
39
|
- `fpa-forecast-freeze`:只接受精确 handoff,经人工批准后冻结 Forecast。
|
|
38
40
|
- `fpa-strategy-execution`:只接受精确 committed Forecast ref 和单独执行授权。
|
|
39
41
|
- `fpa-cycle-review`:只接受精确 Forecast/Execution refs 与新 Actuals。
|
|
@@ -47,6 +49,11 @@ Graph 的业务阻塞不能触发父 Agent 降级执行。Graph 工具不可用
|
|
|
47
49
|
它从展开后的请求识别多阶段 FP&A 意图,要求目标 Graph 与请求阶段一致,并
|
|
48
50
|
阻断父 Agent 的提前 `fpa_*` 调用和 `artifacts/` 写入。Graph worker 没有
|
|
49
51
|
`graph_list`/`graph_run` 时不会激活该护栏,因此节点仍可执行被分配的单一阶段。
|
|
52
|
+
首次在可信项目进入 FP&A 工作流时,护栏会把 package 自带的 Graph 模板安装到
|
|
53
|
+
`.agent-graph/graphs`;较旧的同名模板和已经退役的拆分 Graph 会先移入
|
|
54
|
+
`.agent-graph/graphs_backup_v*` 再更新,因此 `graph_list` 能直接发现当前组合
|
|
55
|
+
Graph,同时保留可恢复的旧定义。非可信项目、符号链接目录和更高版本的项目
|
|
56
|
+
Graph 均不会被覆盖。
|
|
50
57
|
|
|
51
58
|
## 数据 Extension(fpa-data)
|
|
52
59
|
|
|
@@ -75,10 +82,14 @@ Extension 内置的关键防护:
|
|
|
75
82
|
|
|
76
83
|
`fpa-artifacts` 提供 `fpa_artifact_commit`,对 `approved_cycle_forecast` 和 `execution_receipt` 做严格字段校验、对账、稳定指纹和原子落盘。只有工具返回成功后的 JSON 才是冻结产物,Markdown 不是正式数据源。
|
|
77
84
|
|
|
78
|
-
`fpa-dashboard`
|
|
85
|
+
`fpa-dashboard` 提供分模块发布工具和兼容的闭环刷新工具:
|
|
79
86
|
|
|
80
87
|
| 工具 | 作用 |
|
|
81
88
|
| --- | --- |
|
|
89
|
+
| `fpa_dashboard_module_status` | 只读检查各独立模块及当前 Dashboard revision |
|
|
90
|
+
| `fpa_dashboard_publish_review` | 只发布 `period-review`,保留其他模块 |
|
|
91
|
+
| `fpa_dashboard_publish_strategy` | 只发布带确认/修改动作的 `next-strategy`,并绑定当前主会话 |
|
|
92
|
+
| `fpa_dashboard_publish_forecast` | 确认策略并冻结预测后,只发布 `next-forecast` |
|
|
82
93
|
| `fpa_dashboard_status` | 只读检查当前 manifest、构建回执和各数据集是否可读 |
|
|
83
94
|
| `fpa_dashboard_refresh` | 从冻结预测、可选执行回执和实时 Actuals 生成固定的闭环看板;先 preview,再携带相同指纹原子 publish |
|
|
84
95
|
| `fpa_dashboard_refresh_queue` | 检查或处理持久刷新队列;主 Agent 用 `enqueue_artifact` 显式交接 Forecast/Execution,Actuals watermark 按 SLA 轮询并幂等发布 |
|
|
@@ -148,12 +159,12 @@ pi list
|
|
|
148
159
|
团队分发建议使用固定 Git tag:
|
|
149
160
|
|
|
150
161
|
```bash
|
|
151
|
-
pi install git:github.com/linyqh/pi-fpa@v0.
|
|
162
|
+
pi install git:github.com/linyqh/pi-fpa@v0.9.0
|
|
152
163
|
```
|
|
153
164
|
|
|
154
165
|
## 发布到 npm
|
|
155
166
|
|
|
156
|
-
发布动作由 GitHub Release 触发。Release 标签必须严格使用 `v<package.json version>`,例如版本 `0.
|
|
167
|
+
发布动作由 GitHub Release 触发。Release 标签必须严格使用 `v<package.json version>`,例如版本 `0.9.0` 对应 `v0.9.0`。工作流会检出该标签,执行 `npm ci`、`npm test` 和包内容预检,全部通过后发布公开包 `@viccydev/pi-fpa`。普通 Release 发布到 `latest`,Prerelease 发布到 `next`。
|
|
157
168
|
|
|
158
169
|
发布认证使用 npm Trusted Publishing / OIDC,不使用长期 npm Token。npm 包后台的 Trusted Publisher 配置为:
|
|
159
170
|
|
|
@@ -6,10 +6,16 @@ import {
|
|
|
6
6
|
type AllocationAction,
|
|
7
7
|
type ApprovedCycleForecastInput,
|
|
8
8
|
type ForecastAllocation,
|
|
9
|
+
type ForecastConclusion,
|
|
9
10
|
type ForecastSlice,
|
|
10
11
|
type Period,
|
|
11
12
|
type ScenarioMetric,
|
|
12
13
|
} from "./contracts.ts";
|
|
14
|
+
import {
|
|
15
|
+
assessForecastQuality,
|
|
16
|
+
type ForecastDimensionSnapshot,
|
|
17
|
+
type ForecastQualityAssessment,
|
|
18
|
+
} from "./forecast-quality.ts";
|
|
13
19
|
|
|
14
20
|
// ============================================================================
|
|
15
21
|
// Composing an approved forecast from a compact plan.
|
|
@@ -36,11 +42,7 @@ import {
|
|
|
36
42
|
// ============================================================================
|
|
37
43
|
|
|
38
44
|
/** Distinct dimension values a plan's slice keys must be drawn from. */
|
|
39
|
-
export interface UaDimensionValues {
|
|
40
|
-
app_code: Set<string>;
|
|
41
|
-
platform: Set<string>;
|
|
42
|
-
media_source: Set<string>;
|
|
43
|
-
}
|
|
45
|
+
export interface UaDimensionValues extends ForecastDimensionSnapshot {}
|
|
44
46
|
|
|
45
47
|
export interface PlanSliceRoas {
|
|
46
48
|
downside: number | null;
|
|
@@ -172,7 +174,10 @@ function sumScenario(values: Array<number | null>): number | null {
|
|
|
172
174
|
export interface ComposeDiagnostics {
|
|
173
175
|
slice_count: number;
|
|
174
176
|
stopped_slice_count: number;
|
|
177
|
+
limited_slice_count: number;
|
|
175
178
|
total_approved_spend: number;
|
|
179
|
+
calculable_approved_spend: number;
|
|
180
|
+
calculable_spend_pct: number;
|
|
176
181
|
validated_dimensions: boolean;
|
|
177
182
|
portfolio_placeholder_app_id: string | null;
|
|
178
183
|
}
|
|
@@ -192,21 +197,78 @@ export interface ComposeResult {
|
|
|
192
197
|
*/
|
|
193
198
|
export async function loadUaDimensionValues(signal?: AbortSignal): Promise<UaDimensionValues> {
|
|
194
199
|
const result = await runStructuredQuery(
|
|
195
|
-
{ dataset: "ua_spend", metrics: ["spend"], dimensions: ["app_code", "platform", "media_source"], limit:
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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):
|
|
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 };
|
|
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
|
|
294
|
-
|
|
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,
|
|
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
|
-
|
|
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", " / ")}.`);
|