@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 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
- 看板按 `app_id + store + channel_group` 精确限定同口径 Actuals,比率全部在聚合后重算,缺值保持 `NULL`;未获批的付费分片只形成告警,不混入预测对比,上周期对比按当前 Actuals 已覆盖的等长日历窗口计算。发布器写内容寻址的数据集,并最后原子提升 `manifest.json`,不会让 Web 端读到半成品代际。
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.4.1
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.4.1` 对应 `v0.4.1`。工作流会检出该标签,执行 `npm ci`、`npm test` 和包内容预检,全部通过后发布公开包 `@viccydev/pi-fpa`。普通 Release 发布到 `latest`,Prerelease 发布到 `next`。
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
- exactKeys(item, ["app_id", "store", "channel_group", "baseline_spend", "approved_spend", "action"], path);
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.");