@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.
@@ -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 { commitArtifact, commitArtifactFromPath } from "./store.ts";
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("artifact_path must be a non-empty path.");
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("artifact_path must stay inside the project directory.");
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("artifact_path must point at a regular file, not a symlink.");
79
- if (stat.size > ARTIFACT_MAX_BYTES) throw new Error(`artifact_path exceeds the ${ARTIFACT_MAX_BYTES / (1024 * 1024)}MB artifact limit.`);
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
- const path = await resolveDraftPath(projectRoot, artifactPath);
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
- return { build, forecast, forecastRead, executionRead };
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") return toolResult({ status: build.warnings.length > 0 ? "ready_with_limits" : "ready", ...summary });
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.4.1",
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. Build the canonical `approved_cycle_forecast` input using [artifact-contract.md](references/artifact-contract.md), writing it to `artifacts/approved_cycle_forecast.input.json`.
29
- 7. Commit it with `fpa_artifact_commit` using `artifact_path`.
30
-
31
- ## Why the draft file
32
-
33
- A full forecast is far too large to emit as a single inline tool argument: the
34
- call gets cut off at the model output limit, and what is left behind reads like
35
- a finished turn. Write the draft with the file tools instead across as many
36
- turns and corrections as it takes — then commit the finished file by path. If a
37
- later step in this graph already commits the draft for you, stop after writing
38
- it and say so; do not claim a fingerprint you were not handed.
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 themwhich 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
- Pass this artifact by path, not inline:
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
- write artifacts/approved_cycle_forecast.input.json
52
- fpa_artifact_commit { "artifact_path": "artifacts/approved_cycle_forecast.input.json" }
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
- `artifact_path` is project-relative, must stay inside the project, and must be a regular file. The inline `artifact` parameter still works and is fine for small artifacts, but a forecast of any real size will be truncated at the model output limit if you try to emit it in one call.
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.