@viccydev/pi-fpa 0.4.1 → 0.6.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 +21 -4
- package/bin/fpa-dashboard-worker.mjs +98 -0
- package/extensions/fpa-artifacts/compose.ts +417 -0
- package/extensions/fpa-artifacts/contracts.ts +9 -1
- package/extensions/fpa-artifacts/index.ts +119 -4
- package/extensions/fpa-artifacts/store.ts +376 -50
- package/extensions/fpa-dashboard/actuals.ts +243 -40
- package/extensions/fpa-dashboard/coordinator.ts +831 -0
- package/extensions/fpa-dashboard/cycle-operating-projection.ts +559 -0
- package/extensions/fpa-dashboard/forecast-accuracy.ts +252 -0
- package/extensions/fpa-dashboard/forward-outlook.ts +142 -0
- package/extensions/fpa-dashboard/index.ts +96 -72
- package/extensions/fpa-dashboard/projector.ts +19 -0
- package/extensions/fpa-dashboard/provenance.ts +147 -0
- package/extensions/fpa-dashboard/publisher.ts +99 -9
- package/extensions/fpa-dashboard/service.ts +179 -0
- package/extensions/fpa-dashboard/source.ts +136 -1
- package/extensions/fpa-dashboard/status.ts +56 -5
- package/package.json +14 -4
- package/skills/fpa-execute-approved-strategy/SKILL.md +1 -1
- package/skills/fpa-forecast-approved-strategy/SKILL.md +22 -12
- package/skills/fpa-forecast-approved-strategy/references/artifact-contract.md +55 -4
- package/skills/fpa-refresh-dashboard/SKILL.md +20 -1
package/README.md
CHANGED
|
@@ -54,14 +54,31 @@ Extension 内置的关键防护:
|
|
|
54
54
|
|
|
55
55
|
`fpa-artifacts` 提供 `fpa_artifact_commit`,对 `approved_cycle_forecast` 和 `execution_receipt` 做严格字段校验、对账、稳定指纹和原子落盘。只有工具返回成功后的 JSON 才是冻结产物,Markdown 不是正式数据源。
|
|
56
56
|
|
|
57
|
-
`fpa-dashboard`
|
|
57
|
+
`fpa-dashboard` 提供三个工具:
|
|
58
58
|
|
|
59
59
|
| 工具 | 作用 |
|
|
60
60
|
| --- | --- |
|
|
61
61
|
| `fpa_dashboard_status` | 只读检查当前 manifest、构建回执和各数据集是否可读 |
|
|
62
62
|
| `fpa_dashboard_refresh` | 从冻结预测、可选执行回执和实时 Actuals 生成固定的闭环看板;先 preview,再携带相同指纹原子 publish |
|
|
63
|
+
| `fpa_dashboard_refresh_queue` | 检查或处理持久刷新队列;Forecast/Execution commit 自动入队,Actuals watermark 按 SLA 轮询并幂等发布 |
|
|
63
64
|
|
|
64
|
-
|
|
65
|
+
持续刷新由 package 自带的独立 worker 驱动,Web 保持严格只读:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
fpa-dashboard-worker --workspace /absolute/path/to/workspace
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
使用 `--once` 可接 cron/systemd timer;常驻运行时默认每 30 秒检查队列,并按内部 5 分钟 Actuals 水位 SLA 轮询。worker 只消费精确 immutable artifact refs,执行有界重试,并通过与交互工具相同的原子 publisher 发布。
|
|
72
|
+
|
|
73
|
+
周期关账不按“过了若干小时”推断。ETL 必须原子写入一份有界、非 group/world-writable 的 JSON 关账信号,并同时配置 `FPA_ACTUALS_CLOSE_SIGNAL_PATH` 与 `FPA_ACTUALS_CLOSE_SIGNAL_ROOT`。可信根必须位于 Agent workspace 之外,且文件及其目录链必须由 Agent 运行身份之外的控制面身份拥有;否则 worker 会拒绝把周期判为已关账:
|
|
74
|
+
|
|
75
|
+
```json
|
|
76
|
+
{"kind":"fpa.actuals.source-close","schema_version":1,"dataset":"ua_spend","signal_id":"ua-close-2026-08-v1","closed_through":"2026-08-31","emitted_at":"2026-09-01T03:00:00Z"}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
缺少该信号时,完整日期覆盖仍只算累计 Actual,不开放整周期 Forecast vs Actual 差异。
|
|
80
|
+
|
|
81
|
+
看板按 `app_id + store + channel_group` 精确限定同口径 Actuals,比率全部在聚合后重算,缺值保持 `NULL`;未获批的付费分片只形成告警,不混入预测对比。整周期 Forecast vs Actual 只在 Actuals 提供统一、完整、可勾稽的单快照,且比较基线是周期开始前冻结的 `original` Forecast 时开放;EAC、稀疏覆盖和多查询未核验快照都不会生成伪差异。发布器写内容寻址的数据集、不可变 generation catalog,并最后原子提升 `manifest.json`,不会让 Web 端读到半成品代际。
|
|
65
82
|
|
|
66
83
|
### 凭证配置
|
|
67
84
|
|
|
@@ -110,12 +127,12 @@ pi list
|
|
|
110
127
|
团队分发建议使用固定 Git tag:
|
|
111
128
|
|
|
112
129
|
```bash
|
|
113
|
-
pi install git:github.com/linyqh/pi-fpa@v0.
|
|
130
|
+
pi install git:github.com/linyqh/pi-fpa@v0.6.0
|
|
114
131
|
```
|
|
115
132
|
|
|
116
133
|
## 发布到 npm
|
|
117
134
|
|
|
118
|
-
发布动作由 GitHub Release 触发。Release 标签必须严格使用 `v<package.json version>`,例如版本 `0.
|
|
135
|
+
发布动作由 GitHub Release 触发。Release 标签必须严格使用 `v<package.json version>`,例如版本 `0.6.0` 对应 `v0.6.0`。工作流会检出该标签,执行 `npm ci`、`npm test` 和包内容预检,全部通过后发布公开包 `@viccydev/pi-fpa`。普通 Release 发布到 `latest`,Prerelease 发布到 `next`。
|
|
119
136
|
|
|
120
137
|
发布认证使用 npm Trusted Publishing / OIDC,不使用长期 npm Token。npm 包后台的 Trusted Publisher 配置为:
|
|
121
138
|
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { readdir, realpath } from "node:fs/promises";
|
|
4
|
+
import { dirname, resolve } from "node:path";
|
|
5
|
+
import { createJiti } from "jiti";
|
|
6
|
+
|
|
7
|
+
const args = process.argv.slice(2);
|
|
8
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
9
|
+
process.stdout.write([
|
|
10
|
+
"Usage: fpa-dashboard-worker (--project <project-cwd> | --workspace <workspace-root>) [--once] [--interval-ms <milliseconds>]",
|
|
11
|
+
"",
|
|
12
|
+
"Runs the package-owned durable dashboard refresh coordinator.",
|
|
13
|
+
"The Web application remains read-only and must not invoke this worker in-process.",
|
|
14
|
+
"",
|
|
15
|
+
].join("\n"));
|
|
16
|
+
process.exit(0);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function option(name) {
|
|
20
|
+
const index = args.indexOf(name);
|
|
21
|
+
return index === -1 ? undefined : args[index + 1];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const projectArg = option("--project");
|
|
25
|
+
const workspaceArg = option("--workspace");
|
|
26
|
+
if ((!projectArg && !workspaceArg) || (projectArg && workspaceArg)) {
|
|
27
|
+
process.stderr.write("fpa-dashboard-worker requires exactly one of --project or --workspace.\n");
|
|
28
|
+
process.exit(2);
|
|
29
|
+
}
|
|
30
|
+
const configuredProject = projectArg ? resolve(projectArg) : null;
|
|
31
|
+
const workspace = workspaceArg ? resolve(workspaceArg) : null;
|
|
32
|
+
const interval = Number(option("--interval-ms") ?? 30_000);
|
|
33
|
+
if (!Number.isInteger(interval) || interval < 5_000 || interval > 3_600_000) {
|
|
34
|
+
process.stderr.write("--interval-ms must be an integer between 5000 and 3600000.\n");
|
|
35
|
+
process.exit(2);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Node does not strip TypeScript under node_modules. Use the same runtime
|
|
39
|
+
// loader as Pi itself so the published package works from an installed tree.
|
|
40
|
+
const jiti = createJiti(import.meta.url);
|
|
41
|
+
const { runDashboardRefreshCoordinator } = await jiti.import("../extensions/fpa-dashboard/coordinator.ts");
|
|
42
|
+
let stopping = false;
|
|
43
|
+
const abortController = new AbortController();
|
|
44
|
+
let wakeSleep = null;
|
|
45
|
+
for (const signal of ["SIGINT", "SIGTERM"]) process.on(signal, () => {
|
|
46
|
+
stopping = true;
|
|
47
|
+
abortController.abort(new Error(`Dashboard worker received ${signal}.`));
|
|
48
|
+
wakeSleep?.();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
async function runOnce() {
|
|
52
|
+
const project = configuredProject ?? await selectWorkspaceProject(workspace);
|
|
53
|
+
if (!project) {
|
|
54
|
+
process.stdout.write(`${JSON.stringify({ at: new Date().toISOString(), workspace, status: "no_project" })}\n`);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const result = await runDashboardRefreshCoordinator(project, { limit: 32, signal: abortController.signal });
|
|
58
|
+
process.stdout.write(`${JSON.stringify({ at: new Date().toISOString(), project, ...result })}\n`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function selectWorkspaceProject(root) {
|
|
62
|
+
const canonicalRoot = await realpath(root);
|
|
63
|
+
const entries = await readdir(canonicalRoot, { withFileTypes: true });
|
|
64
|
+
for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) {
|
|
65
|
+
if (!entry.isDirectory() || entry.isSymbolicLink() || entry.name === ".fpa-dashboard") continue;
|
|
66
|
+
const project = await realpath(resolve(canonicalRoot, entry.name));
|
|
67
|
+
if (dirname(project) === canonicalRoot) return project;
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (args.includes("--once")) {
|
|
73
|
+
try {
|
|
74
|
+
await runOnce();
|
|
75
|
+
} catch (error) {
|
|
76
|
+
if (!abortController.signal.aborted) throw error;
|
|
77
|
+
}
|
|
78
|
+
} else {
|
|
79
|
+
while (!stopping) {
|
|
80
|
+
try {
|
|
81
|
+
await runOnce();
|
|
82
|
+
} catch (error) {
|
|
83
|
+
if (abortController.signal.aborted) break;
|
|
84
|
+
process.stderr.write(`${new Date().toISOString()} ${error instanceof Error ? error.stack ?? error.message : String(error)}\n`);
|
|
85
|
+
}
|
|
86
|
+
if (!stopping) await new Promise((resolveWait) => {
|
|
87
|
+
const timer = setTimeout(() => {
|
|
88
|
+
wakeSleep = null;
|
|
89
|
+
resolveWait();
|
|
90
|
+
}, interval);
|
|
91
|
+
wakeSleep = () => {
|
|
92
|
+
clearTimeout(timer);
|
|
93
|
+
wakeSleep = null;
|
|
94
|
+
resolveWait();
|
|
95
|
+
};
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
import { runStructuredQuery } from "../fpa-data/runtime.ts";
|
|
2
|
+
import {
|
|
3
|
+
ALLOCATION_ACTIONS,
|
|
4
|
+
canonicalForecastWindow,
|
|
5
|
+
sliceKey,
|
|
6
|
+
type AllocationAction,
|
|
7
|
+
type ApprovedCycleForecastInput,
|
|
8
|
+
type ForecastAllocation,
|
|
9
|
+
type ForecastSlice,
|
|
10
|
+
type Period,
|
|
11
|
+
type ScenarioMetric,
|
|
12
|
+
} from "./contracts.ts";
|
|
13
|
+
|
|
14
|
+
// ============================================================================
|
|
15
|
+
// Composing an approved forecast from a compact plan.
|
|
16
|
+
//
|
|
17
|
+
// Authoring a full `approved_cycle_forecast` by hand means writing three
|
|
18
|
+
// scenarios x three metrics x N slices, plus a consolidated roll-up, plus the
|
|
19
|
+
// unit and window string on every one of them — tens of thousands of tokens of
|
|
20
|
+
// pure transcription in which every arithmetic identity the contract checks has
|
|
21
|
+
// to come out exact. In practice it does not: rounding a ROAS to four decimals
|
|
22
|
+
// misses the reconciliation tolerance, a consolidated total drifts from the sum
|
|
23
|
+
// of its slices, a slice loses its partner row, and each failure costs a
|
|
24
|
+
// re-draft.
|
|
25
|
+
//
|
|
26
|
+
// None of that is judgement. The only judgement in a forecast is the
|
|
27
|
+
// allocation and the ROAS assumption behind each slice; everything else is
|
|
28
|
+
// derived. So the plan carries just those, and this module computes the rest —
|
|
29
|
+
// which makes the identities true by construction rather than true if checked.
|
|
30
|
+
//
|
|
31
|
+
// Deliberately returns the artifact instead of writing it. A node that writes
|
|
32
|
+
// is a mutating node, and the engine bars mutating nodes from automatic retry
|
|
33
|
+
// and from failure routes that lead back to themselves. Keeping composition
|
|
34
|
+
// read-only is what lets a bad plan be repaired and recomposed automatically,
|
|
35
|
+
// while the irreversible freeze stays in its own node.
|
|
36
|
+
// ============================================================================
|
|
37
|
+
|
|
38
|
+
/** 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
|
+
|
|
45
|
+
export interface PlanSliceRoas {
|
|
46
|
+
downside: number | null;
|
|
47
|
+
base: number | null;
|
|
48
|
+
upside: number | null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface PlanSlice {
|
|
52
|
+
app_id: string;
|
|
53
|
+
store: string;
|
|
54
|
+
channel_group: string;
|
|
55
|
+
baseline_spend: number | null;
|
|
56
|
+
approved_spend: number;
|
|
57
|
+
action: AllocationAction;
|
|
58
|
+
owner?: string;
|
|
59
|
+
roas: PlanSliceRoas;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const SCENARIOS = ["downside", "base", "upside"] as const;
|
|
63
|
+
type Scenario = (typeof SCENARIOS)[number];
|
|
64
|
+
|
|
65
|
+
const PLAN_KEYS = [
|
|
66
|
+
"status",
|
|
67
|
+
"forecast_version",
|
|
68
|
+
"strategy_version",
|
|
69
|
+
"strategy_review_id",
|
|
70
|
+
"human_approval_id",
|
|
71
|
+
"approval_conditions_satisfied",
|
|
72
|
+
"target_period",
|
|
73
|
+
"data_as_of",
|
|
74
|
+
"source_snapshot_ids",
|
|
75
|
+
"assumption_version",
|
|
76
|
+
"model_version",
|
|
77
|
+
"reporting_currency",
|
|
78
|
+
"calibration_policy",
|
|
79
|
+
"unsupported_metrics",
|
|
80
|
+
"reconciliation_checks",
|
|
81
|
+
"slices",
|
|
82
|
+
] as const;
|
|
83
|
+
const OPTIONAL_PLAN_KEYS = ["artifact_type", "consolidated_extra_metrics", "frozen_at"] as const;
|
|
84
|
+
|
|
85
|
+
function record(value: unknown, path: string): Record<string, unknown> {
|
|
86
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error(`${path} must be an object.`);
|
|
87
|
+
return value as Record<string, unknown>;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function requireKeys(value: Record<string, unknown>, path: string): void {
|
|
91
|
+
const allowed = new Set<string>([...PLAN_KEYS, ...OPTIONAL_PLAN_KEYS]);
|
|
92
|
+
const unknown = Object.keys(value).filter((key) => !allowed.has(key));
|
|
93
|
+
if (unknown.length > 0) {
|
|
94
|
+
throw new Error(
|
|
95
|
+
`${path} has unsupported keys: ${unknown.join(", ")}. `
|
|
96
|
+
+ "A plan carries only the decision and its assumptions; derived metrics are computed here, not supplied.",
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
const missing = PLAN_KEYS.filter((key) => !(key in value));
|
|
100
|
+
if (missing.length > 0) throw new Error(`${path} is missing required keys: ${missing.join(", ")}.`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function finite(value: unknown, path: string, nullable = false): number | null {
|
|
104
|
+
if (nullable && value === null) return null;
|
|
105
|
+
if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`${path} must be a finite number${nullable ? " or null" : ""}.`);
|
|
106
|
+
return value;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function nonNegative(value: unknown, path: string, nullable = false): number | null {
|
|
110
|
+
const parsed = finite(value, path, nullable);
|
|
111
|
+
if (parsed !== null && parsed < 0) throw new Error(`${path} must be non-negative.`);
|
|
112
|
+
return parsed;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function text(value: unknown, path: string): string {
|
|
116
|
+
if (typeof value !== "string" || value.trim() === "") throw new Error(`${path} must be a non-empty string.`);
|
|
117
|
+
return value;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function planSlice(value: unknown, index: number): PlanSlice {
|
|
121
|
+
const path = `plan.slices[${index}]`;
|
|
122
|
+
const source = record(value, path);
|
|
123
|
+
const allowed = new Set(["app_id", "store", "channel_group", "baseline_spend", "approved_spend", "action", "owner", "roas"]);
|
|
124
|
+
const unknown = Object.keys(source).filter((key) => !allowed.has(key));
|
|
125
|
+
if (unknown.length > 0) throw new Error(`${path} has unsupported keys: ${unknown.join(", ")}.`);
|
|
126
|
+
const action = source.action;
|
|
127
|
+
if (typeof action !== "string" || !ALLOCATION_ACTIONS.includes(action as AllocationAction)) {
|
|
128
|
+
throw new Error(`${path}.action must be one of: ${ALLOCATION_ACTIONS.join(", ")}.`);
|
|
129
|
+
}
|
|
130
|
+
const roasSource = record(source.roas, `${path}.roas`);
|
|
131
|
+
const roasUnknown = Object.keys(roasSource).filter((key) => !SCENARIOS.includes(key as Scenario));
|
|
132
|
+
if (roasUnknown.length > 0) throw new Error(`${path}.roas has unsupported keys: ${roasUnknown.join(", ")}.`);
|
|
133
|
+
return {
|
|
134
|
+
app_id: text(source.app_id, `${path}.app_id`),
|
|
135
|
+
store: text(source.store, `${path}.store`),
|
|
136
|
+
channel_group: text(source.channel_group, `${path}.channel_group`),
|
|
137
|
+
baseline_spend: nonNegative(source.baseline_spend, `${path}.baseline_spend`, true),
|
|
138
|
+
approved_spend: nonNegative(source.approved_spend, `${path}.approved_spend`) as number,
|
|
139
|
+
action: action as AllocationAction,
|
|
140
|
+
...(source.owner === undefined ? {} : { owner: text(source.owner, `${path}.owner`) }),
|
|
141
|
+
roas: {
|
|
142
|
+
downside: nonNegative(roasSource.downside, `${path}.roas.downside`, true),
|
|
143
|
+
base: nonNegative(roasSource.base, `${path}.roas.base`, true),
|
|
144
|
+
upside: nonNegative(roasSource.upside, `${path}.roas.upside`, true),
|
|
145
|
+
},
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function scenarioMetric(values: Record<Scenario, number | null>, unit: string, window: string): ScenarioMetric {
|
|
150
|
+
return { downside: values.downside, base: values.base, upside: values.upside, unit, window };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Revenue implied by an approved spend and a ROAS assumption.
|
|
155
|
+
*
|
|
156
|
+
* A stopped slice spends nothing, so it earns nothing and its ROAS is not a
|
|
157
|
+
* number — the contract derives ROAS as revenue/spend and treats a zero
|
|
158
|
+
* denominator as null, so anything else fails reconciliation. Hand-written
|
|
159
|
+
* forecasts get this wrong precisely because 0 looks like a reasonable answer.
|
|
160
|
+
*/
|
|
161
|
+
function sliceScenario(spend: number, roas: number | null): { revenue: number | null; roas: number | null } {
|
|
162
|
+
if (spend === 0) return { revenue: 0, roas: null };
|
|
163
|
+
if (roas === null) return { revenue: null, roas: null };
|
|
164
|
+
return { revenue: spend * roas, roas };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function sumScenario(values: Array<number | null>): number | null {
|
|
168
|
+
if (values.some((value) => value === null)) return null;
|
|
169
|
+
return values.reduce<number>((total, value) => total + (value as number), 0);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export interface ComposeDiagnostics {
|
|
173
|
+
slice_count: number;
|
|
174
|
+
stopped_slice_count: number;
|
|
175
|
+
total_approved_spend: number;
|
|
176
|
+
validated_dimensions: boolean;
|
|
177
|
+
portfolio_placeholder_app_id: string | null;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export interface ComposeResult {
|
|
181
|
+
artifact: ApprovedCycleForecastInput;
|
|
182
|
+
diagnostics: ComposeDiagnostics;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Every distinct app_code / platform / media_source ua_spend actually uses.
|
|
187
|
+
*
|
|
188
|
+
* Read once and compared as sets: a plan that names `google_play` where the
|
|
189
|
+
* mart only ever says `android` produces a forecast whose every slice silently
|
|
190
|
+
* matches nothing, and the dashboard built from it is correct-looking and
|
|
191
|
+
* entirely empty. That is worth one query to prevent.
|
|
192
|
+
*/
|
|
193
|
+
export async function loadUaDimensionValues(signal?: AbortSignal): Promise<UaDimensionValues> {
|
|
194
|
+
const result = await runStructuredQuery(
|
|
195
|
+
{ dataset: "ua_spend", metrics: ["spend"], dimensions: ["app_code", "platform", "media_source"], limit: 5000 },
|
|
196
|
+
signal,
|
|
197
|
+
);
|
|
198
|
+
const values: UaDimensionValues = { app_code: new Set(), platform: new Set(), media_source: new Set() };
|
|
199
|
+
for (const row of result.rows) {
|
|
200
|
+
if (row.app_code != null) values.app_code.add(String(row.app_code));
|
|
201
|
+
if (row.platform != null) values.platform.add(String(row.platform));
|
|
202
|
+
if (row.media_source != null) values.media_source.add(String(row.media_source));
|
|
203
|
+
}
|
|
204
|
+
return values;
|
|
205
|
+
}
|
|
206
|
+
|
|
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)`;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Check plan slice keys against the values ua_spend really uses.
|
|
214
|
+
*
|
|
215
|
+
* The App axis has one legitimate exception: a forecast whose App dimension was
|
|
216
|
+
* scoped out carries a single placeholder app_id for the whole portfolio, which
|
|
217
|
+
* by design appears nowhere in the mart. That is the same collapsed-single-app
|
|
218
|
+
* test the dashboard applies, so the two agree on what "portfolio mode" means.
|
|
219
|
+
* Store and channel have no such mode — a value the mart never uses is an error.
|
|
220
|
+
*/
|
|
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 };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export interface ComposeOptions {
|
|
251
|
+
/** Test injection point; defaults to reading the values out of ua_spend. */
|
|
252
|
+
loadDimensionValues?: (signal?: AbortSignal) => Promise<UaDimensionValues>;
|
|
253
|
+
signal?: AbortSignal;
|
|
254
|
+
/** Stamped onto the artifact when the plan does not carry one. */
|
|
255
|
+
now?: () => Date;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export async function composeApprovedForecast(plan: unknown, options: ComposeOptions = {}): Promise<ComposeResult> {
|
|
259
|
+
const source = record(plan, "plan");
|
|
260
|
+
requireKeys(source, "plan");
|
|
261
|
+
if (source.artifact_type !== undefined && source.artifact_type !== "approved_cycle_forecast") {
|
|
262
|
+
throw new Error('plan.artifact_type must be "approved_cycle_forecast" when supplied.');
|
|
263
|
+
}
|
|
264
|
+
if (!Array.isArray(source.slices) || source.slices.length === 0) throw new Error("plan.slices must be a non-empty array.");
|
|
265
|
+
const slices = source.slices.map((value, index) => planSlice(value, index));
|
|
266
|
+
|
|
267
|
+
const seen = new Set<string>();
|
|
268
|
+
for (const slice of slices) {
|
|
269
|
+
const key = sliceKey({ app_id: slice.app_id, store: slice.store, channel_group: slice.channel_group });
|
|
270
|
+
if (seen.has(key)) throw new Error(`plan.slices contains duplicate slice ${key.replaceAll("", " / ")}.`);
|
|
271
|
+
seen.add(key);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const status = source.status;
|
|
275
|
+
if (status !== "complete" && status !== "complete_with_limits" && status !== "blocked") {
|
|
276
|
+
throw new Error("plan.status must be complete, complete_with_limits, or blocked.");
|
|
277
|
+
}
|
|
278
|
+
const unsupported = source.unsupported_metrics;
|
|
279
|
+
if (!Array.isArray(unsupported)) throw new Error("plan.unsupported_metrics must be an array.");
|
|
280
|
+
// The status and the list of undelivered metrics are two statements about the
|
|
281
|
+
// same fact, and a forecast that disagrees with itself is what the publish
|
|
282
|
+
// gate later rejects with no explanation of which half was wrong.
|
|
283
|
+
if (status === "complete" && unsupported.length > 0) {
|
|
284
|
+
throw new Error("plan.status is complete but unsupported_metrics is not empty; a forecast with undelivered metrics is complete_with_limits.");
|
|
285
|
+
}
|
|
286
|
+
if (status === "complete_with_limits" && unsupported.length === 0) {
|
|
287
|
+
throw new Error(
|
|
288
|
+
"plan.status is complete_with_limits but unsupported_metrics is empty. "
|
|
289
|
+
+ "Scope deliberately excluded upstream is not a limit — if nothing in scope is missing, the status is complete.",
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const loadValues = options.loadDimensionValues ?? loadUaDimensionValues;
|
|
294
|
+
const { portfolioAppId } = validateSliceKeys(slices, await loadValues(options.signal));
|
|
295
|
+
|
|
296
|
+
const period = record(source.target_period, "plan.target_period") as unknown as Period;
|
|
297
|
+
const currency = text(source.reporting_currency, "plan.reporting_currency").toUpperCase();
|
|
298
|
+
const window = canonicalForecastWindow(period);
|
|
299
|
+
|
|
300
|
+
const approvedAllocation: ForecastAllocation[] = slices.map((slice) => ({
|
|
301
|
+
app_id: slice.app_id,
|
|
302
|
+
store: slice.store,
|
|
303
|
+
channel_group: slice.channel_group,
|
|
304
|
+
baseline_spend: slice.baseline_spend,
|
|
305
|
+
approved_spend: slice.approved_spend,
|
|
306
|
+
action: slice.action,
|
|
307
|
+
...(slice.owner ? { owner: slice.owner } : {}),
|
|
308
|
+
}));
|
|
309
|
+
|
|
310
|
+
// Spend is the decision, not a prediction: an approved allocation is the same
|
|
311
|
+
// number in every scenario. Only revenue moves, through the ROAS assumption.
|
|
312
|
+
const forecastBySlice: ForecastSlice[] = slices.map((slice) => {
|
|
313
|
+
const derived = Object.fromEntries(
|
|
314
|
+
SCENARIOS.map((scenario) => [scenario, sliceScenario(slice.approved_spend, slice.roas[scenario])]),
|
|
315
|
+
) as Record<Scenario, { revenue: number | null; roas: number | null }>;
|
|
316
|
+
return {
|
|
317
|
+
app_id: slice.app_id,
|
|
318
|
+
store: slice.store,
|
|
319
|
+
channel_group: slice.channel_group,
|
|
320
|
+
metrics: {
|
|
321
|
+
spend: scenarioMetric(
|
|
322
|
+
{ downside: slice.approved_spend, base: slice.approved_spend, upside: slice.approved_spend },
|
|
323
|
+
currency,
|
|
324
|
+
window,
|
|
325
|
+
),
|
|
326
|
+
revenue: scenarioMetric(
|
|
327
|
+
{ downside: derived.downside.revenue, base: derived.base.revenue, upside: derived.upside.revenue },
|
|
328
|
+
currency,
|
|
329
|
+
window,
|
|
330
|
+
),
|
|
331
|
+
roas: scenarioMetric(
|
|
332
|
+
{ downside: derived.downside.roas, base: derived.base.roas, upside: derived.upside.roas },
|
|
333
|
+
"ratio",
|
|
334
|
+
window,
|
|
335
|
+
),
|
|
336
|
+
},
|
|
337
|
+
};
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
const consolidatedSpend = Object.fromEntries(
|
|
341
|
+
SCENARIOS.map((scenario) => [scenario, sumScenario(forecastBySlice.map((slice) => slice.metrics.spend[scenario]))]),
|
|
342
|
+
) as Record<Scenario, number | null>;
|
|
343
|
+
const consolidatedRevenue = Object.fromEntries(
|
|
344
|
+
SCENARIOS.map((scenario) => [scenario, sumScenario(forecastBySlice.map((slice) => slice.metrics.revenue[scenario]))]),
|
|
345
|
+
) as Record<Scenario, number | null>;
|
|
346
|
+
const consolidatedRoas = Object.fromEntries(
|
|
347
|
+
SCENARIOS.map((scenario) => {
|
|
348
|
+
const spend = consolidatedSpend[scenario];
|
|
349
|
+
const revenue = consolidatedRevenue[scenario];
|
|
350
|
+
return [scenario, spend === null || revenue === null || spend === 0 ? null : revenue / spend];
|
|
351
|
+
}),
|
|
352
|
+
) as Record<Scenario, number | null>;
|
|
353
|
+
|
|
354
|
+
const consolidated: Record<string, ScenarioMetric> = {
|
|
355
|
+
spend: scenarioMetric(consolidatedSpend, currency, window),
|
|
356
|
+
revenue: scenarioMetric(consolidatedRevenue, currency, window),
|
|
357
|
+
roas: scenarioMetric(consolidatedRoas, "ratio", window),
|
|
358
|
+
};
|
|
359
|
+
|
|
360
|
+
// Portfolio-level series the model measured rather than derived — organic
|
|
361
|
+
// revenue and the like. They pass through untouched apart from the window,
|
|
362
|
+
// which is the artifact's to define, not the plan's.
|
|
363
|
+
if (source.consolidated_extra_metrics !== undefined) {
|
|
364
|
+
const extra = record(source.consolidated_extra_metrics, "plan.consolidated_extra_metrics");
|
|
365
|
+
for (const [name, value] of Object.entries(extra)) {
|
|
366
|
+
if (name === "spend" || name === "revenue" || name === "roas") {
|
|
367
|
+
throw new Error(`plan.consolidated_extra_metrics.${name} is derived from the slices and cannot be supplied.`);
|
|
368
|
+
}
|
|
369
|
+
const metricSource = record(value, `plan.consolidated_extra_metrics.${name}`);
|
|
370
|
+
consolidated[name] = scenarioMetric(
|
|
371
|
+
{
|
|
372
|
+
downside: finite(metricSource.downside, `plan.consolidated_extra_metrics.${name}.downside`, true),
|
|
373
|
+
base: finite(metricSource.base, `plan.consolidated_extra_metrics.${name}.base`, true),
|
|
374
|
+
upside: finite(metricSource.upside, `plan.consolidated_extra_metrics.${name}.upside`, true),
|
|
375
|
+
},
|
|
376
|
+
text(metricSource.unit, `plan.consolidated_extra_metrics.${name}.unit`),
|
|
377
|
+
window,
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const artifact = {
|
|
383
|
+
artifact_type: "approved_cycle_forecast",
|
|
384
|
+
status,
|
|
385
|
+
forecast_version: text(source.forecast_version, "plan.forecast_version"),
|
|
386
|
+
strategy_version: text(source.strategy_version, "plan.strategy_version"),
|
|
387
|
+
strategy_review_id: text(source.strategy_review_id, "plan.strategy_review_id"),
|
|
388
|
+
human_approval_id: text(source.human_approval_id, "plan.human_approval_id"),
|
|
389
|
+
approval_conditions_satisfied: source.approval_conditions_satisfied,
|
|
390
|
+
target_period: period,
|
|
391
|
+
data_as_of: source.data_as_of,
|
|
392
|
+
source_snapshot_ids: source.source_snapshot_ids,
|
|
393
|
+
assumption_version: text(source.assumption_version, "plan.assumption_version"),
|
|
394
|
+
model_version: text(source.model_version, "plan.model_version"),
|
|
395
|
+
reporting_currency: currency,
|
|
396
|
+
approved_allocation: approvedAllocation,
|
|
397
|
+
forecast_by_slice: forecastBySlice,
|
|
398
|
+
consolidated_forecast: consolidated,
|
|
399
|
+
calibration_policy: source.calibration_policy,
|
|
400
|
+
unsupported_metrics: unsupported,
|
|
401
|
+
reconciliation_checks: source.reconciliation_checks,
|
|
402
|
+
frozen_at: typeof source.frozen_at === "string" && source.frozen_at.trim()
|
|
403
|
+
? source.frozen_at
|
|
404
|
+
: (options.now?.() ?? new Date()).toISOString().replace(/\.\d{3}Z$/, "Z"),
|
|
405
|
+
} as unknown as ApprovedCycleForecastInput;
|
|
406
|
+
|
|
407
|
+
return {
|
|
408
|
+
artifact,
|
|
409
|
+
diagnostics: {
|
|
410
|
+
slice_count: slices.length,
|
|
411
|
+
stopped_slice_count: slices.filter((slice) => slice.approved_spend === 0).length,
|
|
412
|
+
total_approved_spend: slices.reduce((total, slice) => total + slice.approved_spend, 0),
|
|
413
|
+
validated_dimensions: true,
|
|
414
|
+
portfolio_placeholder_app_id: portfolioAppId,
|
|
415
|
+
},
|
|
416
|
+
};
|
|
417
|
+
}
|
|
@@ -25,6 +25,7 @@ export interface ForecastAllocation {
|
|
|
25
25
|
baseline_spend: number | null;
|
|
26
26
|
approved_spend: number;
|
|
27
27
|
action: AllocationAction;
|
|
28
|
+
owner?: string;
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
export interface ForecastSlice {
|
|
@@ -187,6 +188,12 @@ function exactKeys(value: Record<string, unknown>, allowed: readonly string[], p
|
|
|
187
188
|
}
|
|
188
189
|
}
|
|
189
190
|
|
|
191
|
+
function requiredAndOptionalKeys(value: Record<string, unknown>, required: readonly string[], optional: readonly string[], path: string): void {
|
|
192
|
+
const allowed = new Set([...required, ...optional]);
|
|
193
|
+
for (const key of Object.keys(value)) if (!allowed.has(key)) throw new Error(`${path}.${key} is not allowed.`);
|
|
194
|
+
for (const key of required) if (!(key in value)) throw new Error(`${path}.${key} is required.`);
|
|
195
|
+
}
|
|
196
|
+
|
|
190
197
|
function string(value: unknown, path: string): string {
|
|
191
198
|
if (typeof value !== "string" || value.trim() === "") throw new Error(`${path} must be a non-empty string.`);
|
|
192
199
|
return value;
|
|
@@ -364,7 +371,7 @@ function validateApprovedForecast(source: Record<string, unknown>): ApprovedCycl
|
|
|
364
371
|
const allocations = array(source.approved_allocation, "artifact.approved_allocation").map((value, index) => {
|
|
365
372
|
const path = `artifact.approved_allocation[${index}]`;
|
|
366
373
|
const item = record(value, path);
|
|
367
|
-
|
|
374
|
+
requiredAndOptionalKeys(item, ["app_id", "store", "channel_group", "baseline_spend", "approved_spend", "action"], ["owner"], path);
|
|
368
375
|
return {
|
|
369
376
|
app_id: string(item.app_id, `${path}.app_id`),
|
|
370
377
|
store: string(item.store, `${path}.store`),
|
|
@@ -372,6 +379,7 @@ function validateApprovedForecast(source: Record<string, unknown>): ApprovedCycl
|
|
|
372
379
|
baseline_spend: nonNegative(item.baseline_spend, `${path}.baseline_spend`, true),
|
|
373
380
|
approved_spend: nonNegative(item.approved_spend, `${path}.approved_spend`) as number,
|
|
374
381
|
action: enumValue(item.action, ALLOCATION_ACTIONS, `${path}.action`),
|
|
382
|
+
...(item.owner === undefined ? {} : { owner: string(item.owner, `${path}.owner`) }),
|
|
375
383
|
};
|
|
376
384
|
});
|
|
377
385
|
if (allocations.length === 0) throw new Error("artifact.approved_allocation must not be empty.");
|