@viccydev/pi-fpa 0.4.1 → 0.5.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/extensions/fpa-artifacts/compose.ts +414 -0
- package/extensions/fpa-artifacts/index.ts +35 -1
- package/extensions/fpa-artifacts/store.ts +23 -13
- package/extensions/fpa-dashboard/actuals.ts +39 -0
- package/extensions/fpa-dashboard/index.ts +19 -4
- package/extensions/fpa-dashboard/source.ts +18 -0
- package/package.json +3 -3
- package/skills/fpa-forecast-approved-strategy/SKILL.md +16 -11
- package/skills/fpa-forecast-approved-strategy/references/artifact-contract.md +53 -4
- package/skills/fpa-refresh-dashboard/SKILL.md +11 -0
|
@@ -0,0 +1,414 @@
|
|
|
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
|
+
roas: PlanSliceRoas;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const SCENARIOS = ["downside", "base", "upside"] as const;
|
|
62
|
+
type Scenario = (typeof SCENARIOS)[number];
|
|
63
|
+
|
|
64
|
+
const PLAN_KEYS = [
|
|
65
|
+
"status",
|
|
66
|
+
"forecast_version",
|
|
67
|
+
"strategy_version",
|
|
68
|
+
"strategy_review_id",
|
|
69
|
+
"human_approval_id",
|
|
70
|
+
"approval_conditions_satisfied",
|
|
71
|
+
"target_period",
|
|
72
|
+
"data_as_of",
|
|
73
|
+
"source_snapshot_ids",
|
|
74
|
+
"assumption_version",
|
|
75
|
+
"model_version",
|
|
76
|
+
"reporting_currency",
|
|
77
|
+
"calibration_policy",
|
|
78
|
+
"unsupported_metrics",
|
|
79
|
+
"reconciliation_checks",
|
|
80
|
+
"slices",
|
|
81
|
+
] as const;
|
|
82
|
+
const OPTIONAL_PLAN_KEYS = ["artifact_type", "consolidated_extra_metrics", "frozen_at"] as const;
|
|
83
|
+
|
|
84
|
+
function record(value: unknown, path: string): Record<string, unknown> {
|
|
85
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) throw new Error(`${path} must be an object.`);
|
|
86
|
+
return value as Record<string, unknown>;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function requireKeys(value: Record<string, unknown>, path: string): void {
|
|
90
|
+
const allowed = new Set<string>([...PLAN_KEYS, ...OPTIONAL_PLAN_KEYS]);
|
|
91
|
+
const unknown = Object.keys(value).filter((key) => !allowed.has(key));
|
|
92
|
+
if (unknown.length > 0) {
|
|
93
|
+
throw new Error(
|
|
94
|
+
`${path} has unsupported keys: ${unknown.join(", ")}. `
|
|
95
|
+
+ "A plan carries only the decision and its assumptions; derived metrics are computed here, not supplied.",
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
const missing = PLAN_KEYS.filter((key) => !(key in value));
|
|
99
|
+
if (missing.length > 0) throw new Error(`${path} is missing required keys: ${missing.join(", ")}.`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function finite(value: unknown, path: string, nullable = false): number | null {
|
|
103
|
+
if (nullable && value === null) return null;
|
|
104
|
+
if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`${path} must be a finite number${nullable ? " or null" : ""}.`);
|
|
105
|
+
return value;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function nonNegative(value: unknown, path: string, nullable = false): number | null {
|
|
109
|
+
const parsed = finite(value, path, nullable);
|
|
110
|
+
if (parsed !== null && parsed < 0) throw new Error(`${path} must be non-negative.`);
|
|
111
|
+
return parsed;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function text(value: unknown, path: string): string {
|
|
115
|
+
if (typeof value !== "string" || value.trim() === "") throw new Error(`${path} must be a non-empty string.`);
|
|
116
|
+
return value;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function planSlice(value: unknown, index: number): PlanSlice {
|
|
120
|
+
const path = `plan.slices[${index}]`;
|
|
121
|
+
const source = record(value, path);
|
|
122
|
+
const allowed = new Set(["app_id", "store", "channel_group", "baseline_spend", "approved_spend", "action", "roas"]);
|
|
123
|
+
const unknown = Object.keys(source).filter((key) => !allowed.has(key));
|
|
124
|
+
if (unknown.length > 0) throw new Error(`${path} has unsupported keys: ${unknown.join(", ")}.`);
|
|
125
|
+
const action = source.action;
|
|
126
|
+
if (typeof action !== "string" || !ALLOCATION_ACTIONS.includes(action as AllocationAction)) {
|
|
127
|
+
throw new Error(`${path}.action must be one of: ${ALLOCATION_ACTIONS.join(", ")}.`);
|
|
128
|
+
}
|
|
129
|
+
const roasSource = record(source.roas, `${path}.roas`);
|
|
130
|
+
const roasUnknown = Object.keys(roasSource).filter((key) => !SCENARIOS.includes(key as Scenario));
|
|
131
|
+
if (roasUnknown.length > 0) throw new Error(`${path}.roas has unsupported keys: ${roasUnknown.join(", ")}.`);
|
|
132
|
+
return {
|
|
133
|
+
app_id: text(source.app_id, `${path}.app_id`),
|
|
134
|
+
store: text(source.store, `${path}.store`),
|
|
135
|
+
channel_group: text(source.channel_group, `${path}.channel_group`),
|
|
136
|
+
baseline_spend: nonNegative(source.baseline_spend, `${path}.baseline_spend`, true),
|
|
137
|
+
approved_spend: nonNegative(source.approved_spend, `${path}.approved_spend`) as number,
|
|
138
|
+
action: action as AllocationAction,
|
|
139
|
+
roas: {
|
|
140
|
+
downside: nonNegative(roasSource.downside, `${path}.roas.downside`, true),
|
|
141
|
+
base: nonNegative(roasSource.base, `${path}.roas.base`, true),
|
|
142
|
+
upside: nonNegative(roasSource.upside, `${path}.roas.upside`, true),
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function scenarioMetric(values: Record<Scenario, number | null>, unit: string, window: string): ScenarioMetric {
|
|
148
|
+
return { downside: values.downside, base: values.base, upside: values.upside, unit, window };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Revenue implied by an approved spend and a ROAS assumption.
|
|
153
|
+
*
|
|
154
|
+
* A stopped slice spends nothing, so it earns nothing and its ROAS is not a
|
|
155
|
+
* number — the contract derives ROAS as revenue/spend and treats a zero
|
|
156
|
+
* denominator as null, so anything else fails reconciliation. Hand-written
|
|
157
|
+
* forecasts get this wrong precisely because 0 looks like a reasonable answer.
|
|
158
|
+
*/
|
|
159
|
+
function sliceScenario(spend: number, roas: number | null): { revenue: number | null; roas: number | null } {
|
|
160
|
+
if (spend === 0) return { revenue: 0, roas: null };
|
|
161
|
+
if (roas === null) return { revenue: null, roas: null };
|
|
162
|
+
return { revenue: spend * roas, roas };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function sumScenario(values: Array<number | null>): number | null {
|
|
166
|
+
if (values.some((value) => value === null)) return null;
|
|
167
|
+
return values.reduce<number>((total, value) => total + (value as number), 0);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export interface ComposeDiagnostics {
|
|
171
|
+
slice_count: number;
|
|
172
|
+
stopped_slice_count: number;
|
|
173
|
+
total_approved_spend: number;
|
|
174
|
+
validated_dimensions: boolean;
|
|
175
|
+
portfolio_placeholder_app_id: string | null;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export interface ComposeResult {
|
|
179
|
+
artifact: ApprovedCycleForecastInput;
|
|
180
|
+
diagnostics: ComposeDiagnostics;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Every distinct app_code / platform / media_source ua_spend actually uses.
|
|
185
|
+
*
|
|
186
|
+
* Read once and compared as sets: a plan that names `google_play` where the
|
|
187
|
+
* mart only ever says `android` produces a forecast whose every slice silently
|
|
188
|
+
* matches nothing, and the dashboard built from it is correct-looking and
|
|
189
|
+
* entirely empty. That is worth one query to prevent.
|
|
190
|
+
*/
|
|
191
|
+
export async function loadUaDimensionValues(signal?: AbortSignal): Promise<UaDimensionValues> {
|
|
192
|
+
const result = await runStructuredQuery(
|
|
193
|
+
{ dataset: "ua_spend", metrics: ["spend"], dimensions: ["app_code", "platform", "media_source"], limit: 5000 },
|
|
194
|
+
signal,
|
|
195
|
+
);
|
|
196
|
+
const values: UaDimensionValues = { app_code: new Set(), platform: new Set(), media_source: new Set() };
|
|
197
|
+
for (const row of result.rows) {
|
|
198
|
+
if (row.app_code != null) values.app_code.add(String(row.app_code));
|
|
199
|
+
if (row.platform != null) values.platform.add(String(row.platform));
|
|
200
|
+
if (row.media_source != null) values.media_source.add(String(row.media_source));
|
|
201
|
+
}
|
|
202
|
+
return values;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function sample(values: Set<string>, limit = 8): string {
|
|
206
|
+
const list = [...values].sort();
|
|
207
|
+
return list.length <= limit ? list.join(", ") : `${list.slice(0, limit).join(", ")}, … (${list.length} total)`;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Check plan slice keys against the values ua_spend really uses.
|
|
212
|
+
*
|
|
213
|
+
* The App axis has one legitimate exception: a forecast whose App dimension was
|
|
214
|
+
* scoped out carries a single placeholder app_id for the whole portfolio, which
|
|
215
|
+
* by design appears nowhere in the mart. That is the same collapsed-single-app
|
|
216
|
+
* test the dashboard applies, so the two agree on what "portfolio mode" means.
|
|
217
|
+
* Store and channel have no such mode — a value the mart never uses is an error.
|
|
218
|
+
*/
|
|
219
|
+
export function validateSliceKeys(slices: PlanSlice[], values: UaDimensionValues): { portfolioAppId: string | null } {
|
|
220
|
+
const appIds = new Set(slices.map((slice) => slice.app_id));
|
|
221
|
+
const collapsed = appIds.size === 1 ? [...appIds][0] : null;
|
|
222
|
+
const portfolioAppId = collapsed !== null && !values.app_code.has(collapsed) ? collapsed : null;
|
|
223
|
+
|
|
224
|
+
const problems: string[] = [];
|
|
225
|
+
const badStores = [...new Set(slices.map((slice) => slice.store).filter((store) => !values.platform.has(store)))];
|
|
226
|
+
if (badStores.length > 0) {
|
|
227
|
+
problems.push(`store ${badStores.map((value) => JSON.stringify(value)).join(", ")} is not a ua_spend.platform value. Valid values: ${sample(values.platform)}.`);
|
|
228
|
+
}
|
|
229
|
+
const badChannels = [...new Set(slices.map((slice) => slice.channel_group).filter((channel) => !values.media_source.has(channel)))];
|
|
230
|
+
if (badChannels.length > 0) {
|
|
231
|
+
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)}.`);
|
|
232
|
+
}
|
|
233
|
+
if (portfolioAppId === null) {
|
|
234
|
+
const badApps = [...new Set(slices.map((slice) => slice.app_id).filter((appId) => !values.app_code.has(appId)))];
|
|
235
|
+
if (badApps.length > 0) {
|
|
236
|
+
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)}.`);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
if (problems.length > 0) {
|
|
240
|
+
throw new Error(
|
|
241
|
+
`Plan slice keys do not exist in ua_spend, so every Actuals lookup for this forecast would match nothing:\n`
|
|
242
|
+
+ problems.map((problem) => `- ${problem}`).join("\n"),
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
return { portfolioAppId };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export interface ComposeOptions {
|
|
249
|
+
/** Test injection point; defaults to reading the values out of ua_spend. */
|
|
250
|
+
loadDimensionValues?: (signal?: AbortSignal) => Promise<UaDimensionValues>;
|
|
251
|
+
signal?: AbortSignal;
|
|
252
|
+
/** Stamped onto the artifact when the plan does not carry one. */
|
|
253
|
+
now?: () => Date;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export async function composeApprovedForecast(plan: unknown, options: ComposeOptions = {}): Promise<ComposeResult> {
|
|
257
|
+
const source = record(plan, "plan");
|
|
258
|
+
requireKeys(source, "plan");
|
|
259
|
+
if (source.artifact_type !== undefined && source.artifact_type !== "approved_cycle_forecast") {
|
|
260
|
+
throw new Error('plan.artifact_type must be "approved_cycle_forecast" when supplied.');
|
|
261
|
+
}
|
|
262
|
+
if (!Array.isArray(source.slices) || source.slices.length === 0) throw new Error("plan.slices must be a non-empty array.");
|
|
263
|
+
const slices = source.slices.map((value, index) => planSlice(value, index));
|
|
264
|
+
|
|
265
|
+
const seen = new Set<string>();
|
|
266
|
+
for (const slice of slices) {
|
|
267
|
+
const key = sliceKey({ app_id: slice.app_id, store: slice.store, channel_group: slice.channel_group });
|
|
268
|
+
if (seen.has(key)) throw new Error(`plan.slices contains duplicate slice ${key.replaceAll("", " / ")}.`);
|
|
269
|
+
seen.add(key);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const status = source.status;
|
|
273
|
+
if (status !== "complete" && status !== "complete_with_limits" && status !== "blocked") {
|
|
274
|
+
throw new Error("plan.status must be complete, complete_with_limits, or blocked.");
|
|
275
|
+
}
|
|
276
|
+
const unsupported = source.unsupported_metrics;
|
|
277
|
+
if (!Array.isArray(unsupported)) throw new Error("plan.unsupported_metrics must be an array.");
|
|
278
|
+
// The status and the list of undelivered metrics are two statements about the
|
|
279
|
+
// same fact, and a forecast that disagrees with itself is what the publish
|
|
280
|
+
// gate later rejects with no explanation of which half was wrong.
|
|
281
|
+
if (status === "complete" && unsupported.length > 0) {
|
|
282
|
+
throw new Error("plan.status is complete but unsupported_metrics is not empty; a forecast with undelivered metrics is complete_with_limits.");
|
|
283
|
+
}
|
|
284
|
+
if (status === "complete_with_limits" && unsupported.length === 0) {
|
|
285
|
+
throw new Error(
|
|
286
|
+
"plan.status is complete_with_limits but unsupported_metrics is empty. "
|
|
287
|
+
+ "Scope deliberately excluded upstream is not a limit — if nothing in scope is missing, the status is complete.",
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const loadValues = options.loadDimensionValues ?? loadUaDimensionValues;
|
|
292
|
+
const { portfolioAppId } = validateSliceKeys(slices, await loadValues(options.signal));
|
|
293
|
+
|
|
294
|
+
const period = record(source.target_period, "plan.target_period") as unknown as Period;
|
|
295
|
+
const currency = text(source.reporting_currency, "plan.reporting_currency").toUpperCase();
|
|
296
|
+
const window = canonicalForecastWindow(period);
|
|
297
|
+
|
|
298
|
+
const approvedAllocation: ForecastAllocation[] = slices.map((slice) => ({
|
|
299
|
+
app_id: slice.app_id,
|
|
300
|
+
store: slice.store,
|
|
301
|
+
channel_group: slice.channel_group,
|
|
302
|
+
baseline_spend: slice.baseline_spend,
|
|
303
|
+
approved_spend: slice.approved_spend,
|
|
304
|
+
action: slice.action,
|
|
305
|
+
}));
|
|
306
|
+
|
|
307
|
+
// Spend is the decision, not a prediction: an approved allocation is the same
|
|
308
|
+
// number in every scenario. Only revenue moves, through the ROAS assumption.
|
|
309
|
+
const forecastBySlice: ForecastSlice[] = slices.map((slice) => {
|
|
310
|
+
const derived = Object.fromEntries(
|
|
311
|
+
SCENARIOS.map((scenario) => [scenario, sliceScenario(slice.approved_spend, slice.roas[scenario])]),
|
|
312
|
+
) as Record<Scenario, { revenue: number | null; roas: number | null }>;
|
|
313
|
+
return {
|
|
314
|
+
app_id: slice.app_id,
|
|
315
|
+
store: slice.store,
|
|
316
|
+
channel_group: slice.channel_group,
|
|
317
|
+
metrics: {
|
|
318
|
+
spend: scenarioMetric(
|
|
319
|
+
{ downside: slice.approved_spend, base: slice.approved_spend, upside: slice.approved_spend },
|
|
320
|
+
currency,
|
|
321
|
+
window,
|
|
322
|
+
),
|
|
323
|
+
revenue: scenarioMetric(
|
|
324
|
+
{ downside: derived.downside.revenue, base: derived.base.revenue, upside: derived.upside.revenue },
|
|
325
|
+
currency,
|
|
326
|
+
window,
|
|
327
|
+
),
|
|
328
|
+
roas: scenarioMetric(
|
|
329
|
+
{ downside: derived.downside.roas, base: derived.base.roas, upside: derived.upside.roas },
|
|
330
|
+
"ratio",
|
|
331
|
+
window,
|
|
332
|
+
),
|
|
333
|
+
},
|
|
334
|
+
};
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
const consolidatedSpend = Object.fromEntries(
|
|
338
|
+
SCENARIOS.map((scenario) => [scenario, sumScenario(forecastBySlice.map((slice) => slice.metrics.spend[scenario]))]),
|
|
339
|
+
) as Record<Scenario, number | null>;
|
|
340
|
+
const consolidatedRevenue = Object.fromEntries(
|
|
341
|
+
SCENARIOS.map((scenario) => [scenario, sumScenario(forecastBySlice.map((slice) => slice.metrics.revenue[scenario]))]),
|
|
342
|
+
) as Record<Scenario, number | null>;
|
|
343
|
+
const consolidatedRoas = Object.fromEntries(
|
|
344
|
+
SCENARIOS.map((scenario) => {
|
|
345
|
+
const spend = consolidatedSpend[scenario];
|
|
346
|
+
const revenue = consolidatedRevenue[scenario];
|
|
347
|
+
return [scenario, spend === null || revenue === null || spend === 0 ? null : revenue / spend];
|
|
348
|
+
}),
|
|
349
|
+
) as Record<Scenario, number | null>;
|
|
350
|
+
|
|
351
|
+
const consolidated: Record<string, ScenarioMetric> = {
|
|
352
|
+
spend: scenarioMetric(consolidatedSpend, currency, window),
|
|
353
|
+
revenue: scenarioMetric(consolidatedRevenue, currency, window),
|
|
354
|
+
roas: scenarioMetric(consolidatedRoas, "ratio", window),
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
// Portfolio-level series the model measured rather than derived — organic
|
|
358
|
+
// revenue and the like. They pass through untouched apart from the window,
|
|
359
|
+
// which is the artifact's to define, not the plan's.
|
|
360
|
+
if (source.consolidated_extra_metrics !== undefined) {
|
|
361
|
+
const extra = record(source.consolidated_extra_metrics, "plan.consolidated_extra_metrics");
|
|
362
|
+
for (const [name, value] of Object.entries(extra)) {
|
|
363
|
+
if (name === "spend" || name === "revenue" || name === "roas") {
|
|
364
|
+
throw new Error(`plan.consolidated_extra_metrics.${name} is derived from the slices and cannot be supplied.`);
|
|
365
|
+
}
|
|
366
|
+
const metricSource = record(value, `plan.consolidated_extra_metrics.${name}`);
|
|
367
|
+
consolidated[name] = scenarioMetric(
|
|
368
|
+
{
|
|
369
|
+
downside: finite(metricSource.downside, `plan.consolidated_extra_metrics.${name}.downside`, true),
|
|
370
|
+
base: finite(metricSource.base, `plan.consolidated_extra_metrics.${name}.base`, true),
|
|
371
|
+
upside: finite(metricSource.upside, `plan.consolidated_extra_metrics.${name}.upside`, true),
|
|
372
|
+
},
|
|
373
|
+
text(metricSource.unit, `plan.consolidated_extra_metrics.${name}.unit`),
|
|
374
|
+
window,
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const artifact = {
|
|
380
|
+
artifact_type: "approved_cycle_forecast",
|
|
381
|
+
status,
|
|
382
|
+
forecast_version: text(source.forecast_version, "plan.forecast_version"),
|
|
383
|
+
strategy_version: text(source.strategy_version, "plan.strategy_version"),
|
|
384
|
+
strategy_review_id: text(source.strategy_review_id, "plan.strategy_review_id"),
|
|
385
|
+
human_approval_id: text(source.human_approval_id, "plan.human_approval_id"),
|
|
386
|
+
approval_conditions_satisfied: source.approval_conditions_satisfied,
|
|
387
|
+
target_period: period,
|
|
388
|
+
data_as_of: source.data_as_of,
|
|
389
|
+
source_snapshot_ids: source.source_snapshot_ids,
|
|
390
|
+
assumption_version: text(source.assumption_version, "plan.assumption_version"),
|
|
391
|
+
model_version: text(source.model_version, "plan.model_version"),
|
|
392
|
+
reporting_currency: currency,
|
|
393
|
+
approved_allocation: approvedAllocation,
|
|
394
|
+
forecast_by_slice: forecastBySlice,
|
|
395
|
+
consolidated_forecast: consolidated,
|
|
396
|
+
calibration_policy: source.calibration_policy,
|
|
397
|
+
unsupported_metrics: unsupported,
|
|
398
|
+
reconciliation_checks: source.reconciliation_checks,
|
|
399
|
+
frozen_at: typeof source.frozen_at === "string" && source.frozen_at.trim()
|
|
400
|
+
? source.frozen_at
|
|
401
|
+
: (options.now?.() ?? new Date()).toISOString().replace(/\.\d{3}Z$/, "Z"),
|
|
402
|
+
} as unknown as ApprovedCycleForecastInput;
|
|
403
|
+
|
|
404
|
+
return {
|
|
405
|
+
artifact,
|
|
406
|
+
diagnostics: {
|
|
407
|
+
slice_count: slices.length,
|
|
408
|
+
stopped_slice_count: slices.filter((slice) => slice.approved_spend === 0).length,
|
|
409
|
+
total_approved_spend: slices.reduce((total, slice) => total + slice.approved_spend, 0),
|
|
410
|
+
validated_dimensions: true,
|
|
411
|
+
portfolio_placeholder_app_id: portfolioAppId,
|
|
412
|
+
},
|
|
413
|
+
};
|
|
414
|
+
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Type } from "typebox";
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import { composeApprovedForecast } from "./compose.ts";
|
|
5
|
+
import { commitArtifact, commitArtifactFromPath, readProjectJsonFile } from "./store.ts";
|
|
5
6
|
|
|
6
7
|
function toolResult(value: Record<string, unknown>) {
|
|
7
8
|
return {
|
|
@@ -52,4 +53,37 @@ export default function fpaArtifactsExtension(pi: ExtensionAPI): void {
|
|
|
52
53
|
});
|
|
53
54
|
},
|
|
54
55
|
});
|
|
56
|
+
|
|
57
|
+
pi.registerTool({
|
|
58
|
+
name: "fpa_forecast_compose",
|
|
59
|
+
label: "Compose Approved Forecast",
|
|
60
|
+
description:
|
|
61
|
+
"Derive a complete, self-consistent approved_cycle_forecast from a compact plan file holding only the approved allocation and its per-slice ROAS assumptions. " +
|
|
62
|
+
"Computes every scenario metric, the consolidated roll-up, units, windows, and frozen_at, and checks the plan's slice keys against the dimension values ua_spend actually uses. " +
|
|
63
|
+
"Returns the artifact for fpa_artifact_commit to freeze; writes nothing.",
|
|
64
|
+
promptSnippet: "Derive a complete approved forecast from a compact plan",
|
|
65
|
+
promptGuidelines: [
|
|
66
|
+
"Author the compact plan, not the full artifact: supply the allocation and each slice's ROAS assumption and let this tool derive the rest.",
|
|
67
|
+
"Do not compute revenue, ROAS, or consolidated totals by hand; hand-rounded values fail the commit reconciliation.",
|
|
68
|
+
],
|
|
69
|
+
parameters: Type.Object(
|
|
70
|
+
{
|
|
71
|
+
plan_path: Type.String({
|
|
72
|
+
minLength: 1,
|
|
73
|
+
description: "Project-relative path to the compact forecast plan JSON file.",
|
|
74
|
+
}),
|
|
75
|
+
},
|
|
76
|
+
{ additionalProperties: false },
|
|
77
|
+
),
|
|
78
|
+
executionMode: "parallel",
|
|
79
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
80
|
+
const plan = await readProjectJsonFile(ctx.cwd, params.plan_path, "plan_path");
|
|
81
|
+
const composed = await composeApprovedForecast(plan, { signal });
|
|
82
|
+
return toolResult({
|
|
83
|
+
status: "composed",
|
|
84
|
+
artifact: composed.artifact as unknown as Record<string, unknown>,
|
|
85
|
+
diagnostics: composed.diagnostics as unknown as Record<string, unknown>,
|
|
86
|
+
});
|
|
87
|
+
},
|
|
88
|
+
});
|
|
55
89
|
}
|
|
@@ -66,20 +66,36 @@ async function existingArtifactDirectory(projectRoot: string): Promise<string> {
|
|
|
66
66
|
* Same fail-closed posture as the committed artifacts themselves: no escaping
|
|
67
67
|
* the project root, no symlinks, no unbounded reads.
|
|
68
68
|
*/
|
|
69
|
-
async function resolveDraftPath(projectRoot: string, artifactPath: string): Promise<string> {
|
|
70
|
-
if (!artifactPath.trim()) throw new Error(
|
|
69
|
+
async function resolveDraftPath(projectRoot: string, artifactPath: string, label = "artifact_path"): Promise<string> {
|
|
70
|
+
if (!artifactPath.trim()) throw new Error(`${label} must be a non-empty path.`);
|
|
71
71
|
const root = await realpath(projectRoot);
|
|
72
72
|
const resolved = resolve(root, artifactPath);
|
|
73
73
|
const relation = relative(root, resolved);
|
|
74
74
|
if (relation === "" || relation.startsWith("..") || isAbsolute(relation)) {
|
|
75
|
-
throw new Error(
|
|
75
|
+
throw new Error(`${label} must stay inside the project directory.`);
|
|
76
76
|
}
|
|
77
77
|
const stat = await lstat(resolved);
|
|
78
|
-
if (stat.isSymbolicLink() || !stat.isFile()) throw new Error(
|
|
79
|
-
if (stat.size > ARTIFACT_MAX_BYTES) throw new Error(
|
|
78
|
+
if (stat.isSymbolicLink() || !stat.isFile()) throw new Error(`${label} must point at a regular file, not a symlink.`);
|
|
79
|
+
if (stat.size > ARTIFACT_MAX_BYTES) throw new Error(`${label} exceeds the ${ARTIFACT_MAX_BYTES / (1024 * 1024)}MB artifact limit.`);
|
|
80
80
|
return resolved;
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
/**
|
|
84
|
+
* Read and parse a JSON file the agent wrote somewhere inside the project.
|
|
85
|
+
*
|
|
86
|
+
* Shared by every by-path entry point so they all get the same fail-closed
|
|
87
|
+
* posture, and so a malformed file is reported as a parse error against the
|
|
88
|
+
* caller's own path rather than as something deeper and less obvious.
|
|
89
|
+
*/
|
|
90
|
+
export async function readProjectJsonFile(projectRoot: string, artifactPath: string, label = "artifact_path"): Promise<unknown> {
|
|
91
|
+
const path = await resolveDraftPath(projectRoot, artifactPath, label);
|
|
92
|
+
try {
|
|
93
|
+
return JSON.parse(await readFile(path, "utf8"));
|
|
94
|
+
} catch (error) {
|
|
95
|
+
throw new Error(`${label} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
83
99
|
/**
|
|
84
100
|
* Commit a canonical artifact an agent wrote to disk instead of passing inline.
|
|
85
101
|
*
|
|
@@ -91,16 +107,10 @@ async function resolveDraftPath(projectRoot: string, artifactPath: string): Prom
|
|
|
91
107
|
* reconciliation, fingerprinting, and atomic write identical to the inline path.
|
|
92
108
|
*/
|
|
93
109
|
export async function commitArtifactFromPath(projectRoot: string, artifactPath: string): Promise<CommitArtifactResult> {
|
|
94
|
-
|
|
95
|
-
let parsed: unknown;
|
|
96
|
-
try {
|
|
97
|
-
parsed = JSON.parse(await readFile(path, "utf8"));
|
|
98
|
-
} catch (error) {
|
|
99
|
-
throw new Error(`artifact_path is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
100
|
-
}
|
|
101
|
-
return commitArtifact(projectRoot, parsed);
|
|
110
|
+
return commitArtifact(projectRoot, await readProjectJsonFile(projectRoot, artifactPath));
|
|
102
111
|
}
|
|
103
112
|
|
|
113
|
+
|
|
104
114
|
export async function commitArtifact(projectRoot: string, input: unknown): Promise<CommitArtifactResult> {
|
|
105
115
|
const artifact = validateArtifact(input);
|
|
106
116
|
if (artifact.artifact_type === "execution_receipt") {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
|
|
3
3
|
import type { Period } from "../fpa-artifacts/contracts.ts";
|
|
4
|
+
import { sliceKey } from "../fpa-artifacts/contracts.ts";
|
|
4
5
|
import { toNumber, type NumericLike } from "../fpa-data/calc.ts";
|
|
5
6
|
import { runStructuredQuery } from "../fpa-data/runtime.ts";
|
|
6
7
|
import type { SqlRow } from "../fpa-data/supabase.ts";
|
|
@@ -97,6 +98,32 @@ export interface DashboardActualsScope {
|
|
|
97
98
|
slices: Array<{ app_id: string; store: string; channel_group: string }>;
|
|
98
99
|
}
|
|
99
100
|
|
|
101
|
+
/**
|
|
102
|
+
* Why every approved slice can be missing, and which of the two reasons it is.
|
|
103
|
+
*
|
|
104
|
+
* An early cycle and a forecast keyed on values ua_spend never uses produce
|
|
105
|
+
* byte-identical Actuals: nothing. The difference is only visible next to the
|
|
106
|
+
* unscoped probe — if the window holds paid rows that no approved key matched,
|
|
107
|
+
* the keys are wrong. Returns the operator-facing explanation, or null when the
|
|
108
|
+
* evidence does not support that conclusion.
|
|
109
|
+
*/
|
|
110
|
+
export function detectSliceKeyMismatch(
|
|
111
|
+
allocation: Array<{ app_id: string; store: string; channel_group: string }>,
|
|
112
|
+
actuals: Pick<DashboardActualsSnapshot, "slices" | "period_has_unscoped_actuals" | "period">,
|
|
113
|
+
): string | null {
|
|
114
|
+
if (allocation.length === 0) return null;
|
|
115
|
+
if (!actuals.period_has_unscoped_actuals) return null;
|
|
116
|
+
const actualKeys = new Set(actuals.slices.map(sliceKey));
|
|
117
|
+
const missing = allocation.filter((slice) => !actualKeys.has(sliceKey(slice)));
|
|
118
|
+
if (missing.length < allocation.length) return null;
|
|
119
|
+
return (
|
|
120
|
+
`All ${allocation.length} approved slices have no Actuals, yet ua_spend holds paid rows in `
|
|
121
|
+
+ `${actuals.period.start_inclusive.slice(0, 10)}..${actuals.period.end_exclusive.slice(0, 10)}. `
|
|
122
|
+
+ "The frozen slice keys do not match the dimension values ua_spend uses — check store against "
|
|
123
|
+
+ "ua_spend.platform and channel_group against ua_spend.media_source, then re-freeze the forecast."
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
100
127
|
/**
|
|
101
128
|
* Rows carry app_code only in "app" scope mode. In "portfolio" mode the App axis was
|
|
102
129
|
* collapsed onto a placeholder before the query ran, so the caller supplies it back.
|
|
@@ -198,12 +225,23 @@ export async function loadDashboardActuals(
|
|
|
198
225
|
]);
|
|
199
226
|
|
|
200
227
|
const comparisonRow = comparison?.rows[0];
|
|
228
|
+
// Only worth asking when the scoped queries came back empty, which is the one
|
|
229
|
+
// case where "no Actuals" is ambiguous between an early cycle and slice keys
|
|
230
|
+
// that match nothing.
|
|
231
|
+
const scopedFoundNothing = coverageMax === null || approvedSlices.rows.length === 0;
|
|
232
|
+
const unscopedProbe = scopedFoundNothing
|
|
233
|
+
? await runStructuredQuery({ ...common, dateFrom: dates.currentFrom, dateTo: dates.currentTo, limit: 1 }, signal)
|
|
234
|
+
: null;
|
|
235
|
+
const unscopedRow = unscopedProbe?.rows[0];
|
|
236
|
+
const periodHasUnscopedActuals = unscopedProbe !== null && unscopedRow?.date_max != null;
|
|
237
|
+
|
|
201
238
|
const queryReceipts = [
|
|
202
239
|
receipt("ua_spend.current", current.built.sql, current.rows.length),
|
|
203
240
|
...(comparison ? [receipt("ua_spend.comparison", comparison.built.sql, comparison.rows.length)] : []),
|
|
204
241
|
receipt("ua_spend.daily", daily.built.sql, daily.rows.length),
|
|
205
242
|
receipt("ua_spend.slices.approved", approvedSlices.built.sql, approvedSlices.rows.length),
|
|
206
243
|
receipt("ua_spend.slices.discovery", discoveredSlices.built.sql, discoveredSlices.rows.length),
|
|
244
|
+
...(unscopedProbe ? [receipt("ua_spend.period.unscoped", unscopedProbe.built.sql, periodHasUnscopedActuals ? 1 : 0)] : []),
|
|
207
245
|
];
|
|
208
246
|
const approvedKeys = new Set(
|
|
209
247
|
portfolio
|
|
@@ -222,6 +260,7 @@ export async function loadDashboardActuals(
|
|
|
222
260
|
data_as_of: coverageMax ?? ACTUALS_DATA_AS_OF_UNAVAILABLE,
|
|
223
261
|
reporting_currency: "USD",
|
|
224
262
|
scope_mode: mode,
|
|
263
|
+
period_has_unscoped_actuals: periodHasUnscopedActuals,
|
|
225
264
|
coverage: {
|
|
226
265
|
date_min: coverageMin,
|
|
227
266
|
date_max: coverageMax,
|
|
@@ -15,7 +15,7 @@ import type {
|
|
|
15
15
|
ExecutionReceiptInput,
|
|
16
16
|
} from "../fpa-artifacts/contracts.ts";
|
|
17
17
|
import { isExecutionReceiptForForecast, sliceKey } from "../fpa-artifacts/contracts.ts";
|
|
18
|
-
import { loadDashboardActuals } from "./actuals.ts";
|
|
18
|
+
import { detectSliceKeyMismatch, loadDashboardActuals } from "./actuals.ts";
|
|
19
19
|
import { projectDashboard, type DashboardBuild } from "./projector.ts";
|
|
20
20
|
import { dashboardBuildFingerprint, publishDashboard } from "./publisher.ts";
|
|
21
21
|
import { inspectDashboard } from "./status.ts";
|
|
@@ -41,6 +41,7 @@ async function buildDashboard(
|
|
|
41
41
|
forecast: ApprovedCycleForecast;
|
|
42
42
|
forecastRead: ReadArtifactResult;
|
|
43
43
|
executionRead: ReadArtifactResult | null;
|
|
44
|
+
sliceKeyMismatch: string | null;
|
|
44
45
|
}> {
|
|
45
46
|
const forecastRead = await readCommittedArtifact(cwd, "approved_cycle_forecast");
|
|
46
47
|
if (forecastRead.artifact.artifact_type !== "approved_cycle_forecast") throw new Error("Committed approved forecast has the wrong artifact type.");
|
|
@@ -77,7 +78,14 @@ async function buildDashboard(
|
|
|
77
78
|
if (committedExecutionRead && !executionRead) build.warnings.push(`Ignored stale execution receipt for forecast ${committedExecutionRead.artifact.forecast_version}; current forecast is ${forecast.forecast_version}.`);
|
|
78
79
|
if (actuals.query_receipts.some((receipt) => receipt.dataset === "ua_spend.slices.discovery" && receipt.row_count >= 1000)) build.warnings.push("Unplanned-slice discovery reached its 1000-row safety limit; approved slices and like-for-like totals remain complete, but additional warnings may be omitted.");
|
|
79
80
|
if (actuals.data_as_of === "unavailable") build.warnings.push("No current-period UA Actuals are available.");
|
|
80
|
-
|
|
81
|
+
|
|
82
|
+
// Every approved slice missing while the window demonstrably holds paid rows is
|
|
83
|
+
// not an early cycle — it is a forecast keyed on values ua_spend never uses, and
|
|
84
|
+
// the dashboard it produces looks complete while being entirely empty. Publishing
|
|
85
|
+
// that is worse than publishing nothing, so it is named here and refused below.
|
|
86
|
+
const sliceKeyMismatch = detectSliceKeyMismatch(forecast.approved_allocation, actuals);
|
|
87
|
+
if (sliceKeyMismatch) build.warnings.push(sliceKeyMismatch);
|
|
88
|
+
return { build, forecast, forecastRead, executionRead, sliceKeyMismatch };
|
|
81
89
|
}
|
|
82
90
|
|
|
83
91
|
export default function fpaDashboardExtension(pi: ExtensionAPI): void {
|
|
@@ -115,7 +123,7 @@ export default function fpaDashboardExtension(pi: ExtensionAPI): void {
|
|
|
115
123
|
),
|
|
116
124
|
executionMode: "sequential",
|
|
117
125
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
118
|
-
const { build, forecast } = await buildDashboard(ctx.cwd, params.locale ?? "zh-CN", signal);
|
|
126
|
+
const { build, forecast, sliceKeyMismatch } = await buildDashboard(ctx.cwd, params.locale ?? "zh-CN", signal);
|
|
119
127
|
const previewFingerprint = dashboardBuildFingerprint(build);
|
|
120
128
|
const summary = {
|
|
121
129
|
preset: params.preset,
|
|
@@ -129,8 +137,15 @@ export default function fpaDashboardExtension(pi: ExtensionAPI): void {
|
|
|
129
137
|
warnings: build.warnings,
|
|
130
138
|
query_receipts: build.source.query_receipts,
|
|
131
139
|
};
|
|
132
|
-
if (params.mode === "preview")
|
|
140
|
+
if (params.mode === "preview") {
|
|
141
|
+
return toolResult({
|
|
142
|
+
status: sliceKeyMismatch ? "blocked_slice_key_mismatch" : build.warnings.length > 0 ? "ready_with_limits" : "ready",
|
|
143
|
+
...summary,
|
|
144
|
+
...(sliceKeyMismatch ? { blocking_reason: sliceKeyMismatch } : {}),
|
|
145
|
+
});
|
|
146
|
+
}
|
|
133
147
|
|
|
148
|
+
if (sliceKeyMismatch) throw new Error(`Dashboard publish refused. ${sliceKeyMismatch}`);
|
|
134
149
|
if (forecast.status !== "complete" || !forecast.approval_conditions_satisfied) {
|
|
135
150
|
throw new Error("Dashboard publish requires a complete approved forecast with all approval conditions satisfied.");
|
|
136
151
|
}
|
|
@@ -39,6 +39,18 @@ export interface DashboardActualsSnapshot {
|
|
|
39
39
|
data_as_of: string;
|
|
40
40
|
reporting_currency: string;
|
|
41
41
|
scope_mode: DashboardScopeMode;
|
|
42
|
+
/**
|
|
43
|
+
* Whether ua_spend holds any paid rows in the target window at all, ignoring
|
|
44
|
+
* the approved slice keys.
|
|
45
|
+
*
|
|
46
|
+
* A forecast whose slice keys do not exist in the mart produces exactly the
|
|
47
|
+
* same empty Actuals as a cycle that simply has not started yet, and the two
|
|
48
|
+
* are indistinguishable from the scoped queries alone — the scoped query is
|
|
49
|
+
* what determines coverage, so a wrong key erases its own evidence. Probing
|
|
50
|
+
* once without the key predicate separates them: data in the window plus no
|
|
51
|
+
* matching slice means the keys are wrong, not that the period is early.
|
|
52
|
+
*/
|
|
53
|
+
period_has_unscoped_actuals: boolean;
|
|
42
54
|
coverage: {
|
|
43
55
|
date_min: string | null;
|
|
44
56
|
date_max: string | null;
|
|
@@ -143,11 +155,17 @@ export function validateActualsSnapshot(value: unknown): DashboardActualsSnapsho
|
|
|
143
155
|
if (!/^[A-Z]{3}$/.test(reportingCurrency)) throw new Error("actuals.reporting_currency must be an ISO-4217 currency code.");
|
|
144
156
|
const scopeMode = source.scope_mode === undefined ? "app" : string(source.scope_mode, "actuals.scope_mode");
|
|
145
157
|
if (scopeMode !== "app" && scopeMode !== "portfolio") throw new Error('actuals.scope_mode must be "app" or "portfolio".');
|
|
158
|
+
if (source.period_has_unscoped_actuals !== undefined && typeof source.period_has_unscoped_actuals !== "boolean") {
|
|
159
|
+
throw new Error("actuals.period_has_unscoped_actuals must be a boolean.");
|
|
160
|
+
}
|
|
146
161
|
return {
|
|
147
162
|
period: period(source.period, "actuals.period"),
|
|
148
163
|
data_as_of: string(source.data_as_of, "actuals.data_as_of"),
|
|
149
164
|
reporting_currency: reportingCurrency,
|
|
150
165
|
scope_mode: scopeMode,
|
|
166
|
+
// Absent means the probe was never run, which is only ever the benign
|
|
167
|
+
// reading: nothing here may invent evidence that the keys are wrong.
|
|
168
|
+
period_has_unscoped_actuals: source.period_has_unscoped_actuals === true,
|
|
151
169
|
coverage: {
|
|
152
170
|
date_min: coverage.date_min === null ? null : string(coverage.date_min, "actuals.coverage.date_min"),
|
|
153
171
|
date_max: coverage.date_max === null ? null : string(coverage.date_max, "actuals.coverage.date_max"),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@viccydev/pi-fpa",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Full-cycle FP&A planning, strategy, forecast, and review prompts, skills, and data tools for Pi",
|
|
6
6
|
"license": "UNLICENSED",
|
|
@@ -23,9 +23,9 @@
|
|
|
23
23
|
"extensions"
|
|
24
24
|
],
|
|
25
25
|
"scripts": {
|
|
26
|
-
"test": "node tests/package-structure.test.mjs && node tests/extension-unit.test.mjs && node --test tests/catalog-timeout.test.mjs tests/artifact-store.test.mjs tests/dashboard-actuals.test.mjs tests/dashboard-projector.test.mjs tests/dashboard-publisher.test.mjs && node tests/pi-loader-smoke.mjs && node tests/publish-workflow.test.mjs",
|
|
26
|
+
"test": "node tests/package-structure.test.mjs && node tests/extension-unit.test.mjs && node --test tests/catalog-timeout.test.mjs tests/artifact-store.test.mjs tests/forecast-compose.test.mjs tests/dashboard-actuals.test.mjs tests/dashboard-projector.test.mjs tests/dashboard-publisher.test.mjs && node tests/pi-loader-smoke.mjs && node tests/publish-workflow.test.mjs",
|
|
27
27
|
"test:structure": "node tests/package-structure.test.mjs",
|
|
28
|
-
"test:unit": "node tests/extension-unit.test.mjs && node --test tests/catalog-timeout.test.mjs tests/artifact-store.test.mjs tests/dashboard-actuals.test.mjs tests/dashboard-projector.test.mjs tests/dashboard-publisher.test.mjs",
|
|
28
|
+
"test:unit": "node tests/extension-unit.test.mjs && node --test tests/catalog-timeout.test.mjs tests/artifact-store.test.mjs tests/forecast-compose.test.mjs tests/dashboard-actuals.test.mjs tests/dashboard-projector.test.mjs tests/dashboard-publisher.test.mjs",
|
|
29
29
|
"test:loader": "node tests/pi-loader-smoke.mjs",
|
|
30
30
|
"test:live": "node tests/live-smoke.mjs",
|
|
31
31
|
"pack:check": "npm pack --dry-run"
|
|
@@ -25,17 +25,22 @@ If approval evidence is absent, ambiguous, expired, conditional but unmet, or re
|
|
|
25
25
|
3. Recalculate future-period operating outcomes from that allocation using the declared model.
|
|
26
26
|
4. Provide downside, base, and upside values for each supported KPI.
|
|
27
27
|
5. Reconcile allocation totals, formulas, and cross-metric identities.
|
|
28
|
-
6.
|
|
29
|
-
7.
|
|
30
|
-
|
|
31
|
-
##
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
28
|
+
6. Write the forecast plan to `artifacts/forecast_plan.json` using [artifact-contract.md](references/artifact-contract.md).
|
|
29
|
+
7. Derive the artifact with `fpa_forecast_compose`, then freeze it with `fpa_artifact_commit`.
|
|
30
|
+
|
|
31
|
+
## Write the decision, not the arithmetic
|
|
32
|
+
|
|
33
|
+
The only judgement in a forecast is the allocation and each slice's ROAS
|
|
34
|
+
assumption. Revenue, the consolidated roll-up, units, windows, and `frozen_at`
|
|
35
|
+
all follow from those, and `fpa_forecast_compose` derives them — which is what
|
|
36
|
+
makes the identities the commit checks true by construction rather than
|
|
37
|
+
dependent on transcribing several hundred numbers without a slip.
|
|
38
|
+
|
|
39
|
+
So do not hand-compute revenue, totals, or ratios, and do not emit the full
|
|
40
|
+
artifact as one tool argument: that is the shape that gets cut off at the model
|
|
41
|
+
output limit and leaves a turn that reads finished but committed nothing. If a
|
|
42
|
+
later node in this graph composes and commits for you, stop after writing the
|
|
43
|
+
plan and say so; never claim a fingerprint you were not handed.
|
|
39
44
|
|
|
40
45
|
## Status and the publish gate
|
|
41
46
|
|
|
@@ -45,11 +45,60 @@ frozen_at: timestamp
|
|
|
45
45
|
|
|
46
46
|
Do not include `immutable_fingerprint` in the tool input; a draft that carries one is rejected. `fpa_artifact_commit` validates exact fields, approval state, allocation totals, every scenario's slice-to-consolidated totals, and supplied ROAS against aggregated revenue/spend. It computes the fingerprint and returns it after durable storage. The agent must not claim the artifact is frozen until that tool succeeds.
|
|
47
47
|
|
|
48
|
-
|
|
48
|
+
## Author the plan, not the artifact
|
|
49
49
|
|
|
50
|
+
Almost none of the artifact above is a decision. The decision is the allocation
|
|
51
|
+
and the ROAS assumption behind each slice; revenue, the ROAS write-back, the
|
|
52
|
+
consolidated roll-up, every unit and window string, and `frozen_at` all follow
|
|
53
|
+
from those by arithmetic. Write the plan and let `fpa_forecast_compose` derive
|
|
54
|
+
the rest — it makes the identities the commit checks true by construction
|
|
55
|
+
instead of true if you typed them correctly.
|
|
56
|
+
|
|
57
|
+
```yaml
|
|
58
|
+
# artifacts/forecast_plan.json
|
|
59
|
+
status: complete | complete_with_limits | blocked
|
|
60
|
+
forecast_version: string
|
|
61
|
+
strategy_version: string
|
|
62
|
+
strategy_review_id: string
|
|
63
|
+
human_approval_id: string
|
|
64
|
+
approval_conditions_satisfied: boolean
|
|
65
|
+
target_period: {start_inclusive: timestamp, end_exclusive: timestamp, timezone: string}
|
|
66
|
+
data_as_of: timestamp
|
|
67
|
+
source_snapshot_ids: []
|
|
68
|
+
assumption_version: string
|
|
69
|
+
model_version: string
|
|
70
|
+
reporting_currency: string
|
|
71
|
+
calibration_policy: {stop_loss_roas_lt, deviation_warning_abs_gte, deviation_trigger_abs_gt, policy_version}
|
|
72
|
+
unsupported_metrics: []
|
|
73
|
+
reconciliation_checks: []
|
|
74
|
+
slices:
|
|
75
|
+
- app_id: string # ua_spend.app_code
|
|
76
|
+
store: string # ua_spend.platform — ios | android, never a store name
|
|
77
|
+
channel_group: string # ua_spend.media_source, verbatim
|
|
78
|
+
baseline_spend: number | null
|
|
79
|
+
approved_spend: number
|
|
80
|
+
action: stop | decrease | hold | increase | explore
|
|
81
|
+
roas: {downside: number|null, base: number|null, upside: number|null}
|
|
82
|
+
consolidated_extra_metrics: # optional; measured portfolio series only
|
|
83
|
+
metric_name: {downside, base, upside, unit}
|
|
50
84
|
```
|
|
51
|
-
|
|
52
|
-
|
|
85
|
+
|
|
86
|
+
Then:
|
|
87
|
+
|
|
88
|
+
```
|
|
89
|
+
write artifacts/forecast_plan.json
|
|
90
|
+
fpa_forecast_compose { "plan_path": "artifacts/forecast_plan.json" }
|
|
91
|
+
fpa_artifact_commit { "artifact": <the artifact compose returned> }
|
|
53
92
|
```
|
|
54
93
|
|
|
55
|
-
|
|
94
|
+
Rules the plan has to respect, because compose enforces them:
|
|
95
|
+
|
|
96
|
+
- **Spend is a decision, not a prediction.** It is identical in all three scenarios, so a slice carries one `approved_spend`. Only revenue moves, through `roas`.
|
|
97
|
+
- **A stopped slice has no ROAS.** `approved_spend: 0` means revenue 0 and `roas` null in all three scenarios — the contract derives ROAS as revenue/spend and calls a zero denominator null.
|
|
98
|
+
- **Slice keys must be values `ua_spend` actually uses.** A plan naming `google_play` where the mart says `android` is rejected with the valid values listed. The single-placeholder App axis for a scoped-out portfolio is the one permitted exception.
|
|
99
|
+
- **`status` and `unsupported_metrics` must agree.** `complete` requires an empty list; `complete_with_limits` requires a non-empty one. Scope excluded upstream is neither.
|
|
100
|
+
- **Do not hand-compute anything derived.** A ROAS rounded to four decimals misses the commit tolerance and costs a re-draft.
|
|
101
|
+
|
|
102
|
+
## Committing an artifact you already hold
|
|
103
|
+
|
|
104
|
+
`fpa_artifact_commit` also takes `artifact_path` for an artifact written to disk, and `artifact` for one passed inline. Both are project-relative and fail closed. Emitting a full artifact by hand in a single tool call is what gets cut off at the model output limit — prefer the plan above.
|
|
@@ -20,6 +20,17 @@ Require a committed `approved_cycle_forecast` for the target project. Treat a co
|
|
|
20
20
|
5. Publish only when the user explicitly asked to rebuild or publish the dashboard. Call the same tool with `mode: publish` and the exact preview fingerprint.
|
|
21
21
|
6. Report the published generation, source forecast fingerprint, data cutoff, warnings, and dashboard path.
|
|
22
22
|
|
|
23
|
+
## When preview returns `blocked_slice_key_mismatch`
|
|
24
|
+
|
|
25
|
+
Every approved slice is missing its Actuals while the target window demonstrably
|
|
26
|
+
holds paid rows. That is not an early cycle and not a scope limitation: the
|
|
27
|
+
frozen forecast is keyed on dimension values `ua_spend` never uses, so the
|
|
28
|
+
dashboard would publish complete-looking and entirely empty. Publish is refused.
|
|
29
|
+
|
|
30
|
+
Report it as a failure, name the offending key axis, and send the work back to
|
|
31
|
+
the node that froze the forecast. Do not re-preview hoping for a different
|
|
32
|
+
answer, and do not describe it as "the period has no data yet".
|
|
33
|
+
|
|
23
34
|
## Boundaries
|
|
24
35
|
|
|
25
36
|
- Never write `.fpa-dashboard` with generic file or shell tools.
|