@viccydev/pi-fpa 0.4.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -4
- package/bin/fpa-dashboard-worker.mjs +98 -0
- package/extensions/fpa-artifacts/compose.ts +417 -0
- package/extensions/fpa-artifacts/contracts.ts +9 -1
- package/extensions/fpa-artifacts/index.ts +119 -4
- package/extensions/fpa-artifacts/store.ts +376 -50
- package/extensions/fpa-dashboard/actuals.ts +243 -40
- package/extensions/fpa-dashboard/coordinator.ts +831 -0
- package/extensions/fpa-dashboard/cycle-operating-projection.ts +559 -0
- package/extensions/fpa-dashboard/forecast-accuracy.ts +252 -0
- package/extensions/fpa-dashboard/forward-outlook.ts +142 -0
- package/extensions/fpa-dashboard/index.ts +96 -72
- package/extensions/fpa-dashboard/projector.ts +19 -0
- package/extensions/fpa-dashboard/provenance.ts +147 -0
- package/extensions/fpa-dashboard/publisher.ts +99 -9
- package/extensions/fpa-dashboard/service.ts +179 -0
- package/extensions/fpa-dashboard/source.ts +136 -1
- package/extensions/fpa-dashboard/status.ts +56 -5
- package/package.json +14 -4
- package/skills/fpa-execute-approved-strategy/SKILL.md +1 -1
- package/skills/fpa-forecast-approved-strategy/SKILL.md +22 -12
- package/skills/fpa-forecast-approved-strategy/references/artifact-contract.md +55 -4
- package/skills/fpa-refresh-dashboard/SKILL.md +20 -1
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { lstat, readFile, readdir } from "node:fs/promises";
|
|
3
|
+
import { basename, join } from "node:path";
|
|
4
|
+
|
|
5
|
+
import { stableJson } from "../fpa-artifacts/store.ts";
|
|
6
|
+
import type { CycleOperatingProjection } from "./cycle-operating-projection.ts";
|
|
7
|
+
import type { DashboardWidget } from "./projector.ts";
|
|
8
|
+
import { resolveDashboardDir } from "./publisher.ts";
|
|
9
|
+
import { validateActualsSnapshot, type DashboardActualsSnapshot } from "./source.ts";
|
|
10
|
+
|
|
11
|
+
const MAX_FILE_BYTES = 256 * 1024;
|
|
12
|
+
const GENERATION_RE = /^[a-f0-9]{64}\.json$/;
|
|
13
|
+
const RECEIPT_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}\.json$/;
|
|
14
|
+
|
|
15
|
+
export interface ForecastAccuracyProjection {
|
|
16
|
+
kind: "fpa.forecast-accuracy";
|
|
17
|
+
schema_version: 1;
|
|
18
|
+
status: "available" | "insufficient_history";
|
|
19
|
+
sample_size: number;
|
|
20
|
+
mean_absolute_percentage_error: number | null;
|
|
21
|
+
mean_percentage_bias: number | null;
|
|
22
|
+
mean_absolute_error: number | null;
|
|
23
|
+
weighted_absolute_percentage_error: number | null;
|
|
24
|
+
mean_absolute_scaled_error: number | null;
|
|
25
|
+
cycles: Array<{
|
|
26
|
+
cycle_id: string;
|
|
27
|
+
forecast_version: string;
|
|
28
|
+
data_as_of: string;
|
|
29
|
+
forecast_revenue: number;
|
|
30
|
+
actual_revenue: number;
|
|
31
|
+
revenue_delta: number;
|
|
32
|
+
revenue_delta_pct: number;
|
|
33
|
+
}>;
|
|
34
|
+
warnings: string[];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function round(value: number): number {
|
|
38
|
+
return Math.round(value * 1_000_000) / 1_000_000;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function closedCycle(projection: CycleOperatingProjection): ForecastAccuracyProjection["cycles"][number] | null {
|
|
42
|
+
if (!projection || typeof projection !== "object") return null;
|
|
43
|
+
const current = projection.current_cycle;
|
|
44
|
+
if (!current || typeof current !== "object") return null;
|
|
45
|
+
const variance = current.variance;
|
|
46
|
+
const forecast = current.forecast?.revenue?.base;
|
|
47
|
+
const actual = current.actual_to_date?.revenue;
|
|
48
|
+
if (
|
|
49
|
+
variance?.status !== "closed_comparable"
|
|
50
|
+
|| typeof forecast !== "number"
|
|
51
|
+
|| !Number.isFinite(forecast)
|
|
52
|
+
|| typeof actual !== "number"
|
|
53
|
+
|| !Number.isFinite(actual)
|
|
54
|
+
|| typeof variance.revenue_delta !== "number"
|
|
55
|
+
|| !Number.isFinite(variance.revenue_delta)
|
|
56
|
+
|| typeof variance.revenue_delta_pct !== "number"
|
|
57
|
+
|| !Number.isFinite(variance.revenue_delta_pct)
|
|
58
|
+
|| typeof projection.cycle_id !== "string"
|
|
59
|
+
|| typeof current.forecast_version !== "string"
|
|
60
|
+
|| typeof projection.data_as_of !== "string"
|
|
61
|
+
) return null;
|
|
62
|
+
return {
|
|
63
|
+
cycle_id: projection.cycle_id,
|
|
64
|
+
forecast_version: projection.current_cycle.forecast_version,
|
|
65
|
+
data_as_of: projection.data_as_of,
|
|
66
|
+
forecast_revenue: forecast,
|
|
67
|
+
actual_revenue: actual,
|
|
68
|
+
revenue_delta: variance.revenue_delta,
|
|
69
|
+
revenue_delta_pct: variance.revenue_delta_pct,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function sha256(value: string): string {
|
|
74
|
+
return createHash("sha256").update(value).digest("hex");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function receiptDigestMatches(filename: string, receipt: Record<string, unknown>): boolean {
|
|
78
|
+
const match = /^build-receipt\.[a-f0-9]{12}\.([a-f0-9]{12})\.json$/.exec(filename);
|
|
79
|
+
return match !== null && sha256(stableJson(receipt)).startsWith(match[1]);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function boundedString(value: unknown, max = 256): value is string {
|
|
83
|
+
return typeof value === "string" && value.length > 0 && value.length <= max && !/[\u0000-\u001f\u007f]/.test(value);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function finite(value: unknown): value is number {
|
|
87
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Validate every field consumed by the accuracy projection before casting. */
|
|
91
|
+
function historicalClosedProjection(value: unknown, scopeId: string, actuals: DashboardActualsSnapshot): CycleOperatingProjection | null {
|
|
92
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
93
|
+
const source = value as Record<string, unknown>;
|
|
94
|
+
if (source.kind !== "fpa.cycle-operating-projection" || source.schema_version !== 1 || source.scope_id !== scopeId) return null;
|
|
95
|
+
if (!boundedString(source.cycle_id) || !boundedString(source.data_as_of) || Number.isNaN(Date.parse(`${source.data_as_of}T00:00:00Z`))) return null;
|
|
96
|
+
if (!boundedString(source.reporting_currency, 3) || !/^[A-Z]{3}$/.test(source.reporting_currency)) return null;
|
|
97
|
+
if (!source.current_cycle || typeof source.current_cycle !== "object" || Array.isArray(source.current_cycle)) return null;
|
|
98
|
+
const current = source.current_cycle as Record<string, unknown>;
|
|
99
|
+
if (!boundedString(current.forecast_version)) return null;
|
|
100
|
+
if (!current.variance || typeof current.variance !== "object" || Array.isArray(current.variance)) return null;
|
|
101
|
+
if (!current.forecast || typeof current.forecast !== "object" || Array.isArray(current.forecast)) return null;
|
|
102
|
+
if (!current.actual_to_date || typeof current.actual_to_date !== "object" || Array.isArray(current.actual_to_date)) return null;
|
|
103
|
+
const variance = current.variance as Record<string, unknown>;
|
|
104
|
+
const forecast = current.forecast as Record<string, unknown>;
|
|
105
|
+
const actual = current.actual_to_date as Record<string, unknown>;
|
|
106
|
+
if (variance.status !== "closed_comparable") return null;
|
|
107
|
+
if (!forecast.revenue || typeof forecast.revenue !== "object" || Array.isArray(forecast.revenue)) return null;
|
|
108
|
+
if (!finite((forecast.revenue as Record<string, unknown>).base) || !finite(actual.revenue) || !finite(variance.revenue_delta) || !finite(variance.revenue_delta_pct)) return null;
|
|
109
|
+
if (source.data_as_of !== actuals.data_as_of || actual.revenue !== actuals.current.revenue) return null;
|
|
110
|
+
if (actuals.snapshot_evidence.consistency !== "single_statement" || !actuals.snapshot_evidence.period_complete || !actuals.snapshot_evidence.reconciled) return null;
|
|
111
|
+
if (!current.target_period || typeof current.target_period !== "object" || Array.isArray(current.target_period)) return null;
|
|
112
|
+
const target = current.target_period as Record<string, unknown>;
|
|
113
|
+
if (target.start_inclusive !== actuals.period.start_inclusive || target.end_exclusive !== actuals.period.end_exclusive || target.timezone !== actuals.period.timezone) return null;
|
|
114
|
+
if (!Array.isArray(source.warnings) || source.warnings.length > 128 || source.warnings.some((warning) => !boundedString(warning, 4096))) return null;
|
|
115
|
+
return value as CycleOperatingProjection;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function projectForecastAccuracy(projections: CycleOperatingProjection[]): ForecastAccuracyProjection {
|
|
119
|
+
const byCycle = new Map<string, ForecastAccuracyProjection["cycles"][number]>();
|
|
120
|
+
for (const projection of projections) {
|
|
121
|
+
const cycle = closedCycle(projection);
|
|
122
|
+
if (cycle) byCycle.set(cycle.cycle_id, cycle);
|
|
123
|
+
}
|
|
124
|
+
const cycles = [...byCycle.values()].sort((left, right) => left.data_as_of.localeCompare(right.data_as_of)).slice(-24);
|
|
125
|
+
const sampleSize = cycles.length;
|
|
126
|
+
const mape = sampleSize === 0 ? null : round(cycles.reduce((sum, cycle) => sum + Math.abs(cycle.revenue_delta_pct), 0) / sampleSize);
|
|
127
|
+
const bias = sampleSize === 0 ? null : round(cycles.reduce((sum, cycle) => sum + cycle.revenue_delta_pct, 0) / sampleSize);
|
|
128
|
+
const mae = sampleSize === 0 ? null : round(cycles.reduce((sum, cycle) => sum + Math.abs(cycle.revenue_delta), 0) / sampleSize);
|
|
129
|
+
const actualTotal = cycles.reduce((sum, cycle) => sum + Math.abs(cycle.actual_revenue), 0);
|
|
130
|
+
const wape = sampleSize === 0 || actualTotal === 0 ? null : round(cycles.reduce((sum, cycle) => sum + Math.abs(cycle.revenue_delta), 0) / actualTotal);
|
|
131
|
+
const naiveErrors = cycles.slice(1).map((cycle, index) => Math.abs(cycle.actual_revenue - cycles[index].actual_revenue));
|
|
132
|
+
const naiveMae = naiveErrors.length === 0 ? null : naiveErrors.reduce((sum, value) => sum + value, 0) / naiveErrors.length;
|
|
133
|
+
const mase = mae === null || naiveMae === null || naiveMae === 0 ? null : round(mae / naiveMae);
|
|
134
|
+
const warnings = sampleSize < 3 ? ["Forecast accuracy has fewer than three closed, comparable original-forecast cycles; trend conclusions are not yet reliable."] : [];
|
|
135
|
+
return {
|
|
136
|
+
kind: "fpa.forecast-accuracy",
|
|
137
|
+
schema_version: 1,
|
|
138
|
+
status: sampleSize >= 3 ? "available" : "insufficient_history",
|
|
139
|
+
sample_size: sampleSize,
|
|
140
|
+
mean_absolute_percentage_error: mape,
|
|
141
|
+
mean_percentage_bias: bias,
|
|
142
|
+
mean_absolute_error: mae,
|
|
143
|
+
weighted_absolute_percentage_error: wape,
|
|
144
|
+
mean_absolute_scaled_error: mase,
|
|
145
|
+
cycles,
|
|
146
|
+
warnings,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function boundedJson(path: string, maxBytes = MAX_FILE_BYTES): Promise<Record<string, unknown> | null> {
|
|
151
|
+
try {
|
|
152
|
+
const file = await lstat(path);
|
|
153
|
+
if (file.isSymbolicLink() || !file.isFile() || file.size > maxBytes) return null;
|
|
154
|
+
const value: unknown = JSON.parse(await readFile(path, "utf8"));
|
|
155
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null;
|
|
156
|
+
} catch {
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export async function loadHistoricalOperatingProjections(cwd: string, scopeId: string): Promise<CycleOperatingProjection[]> {
|
|
162
|
+
const dashboardDir = await resolveDashboardDir(cwd);
|
|
163
|
+
const generationsDir = join(dashboardDir, "generations");
|
|
164
|
+
let names: string[];
|
|
165
|
+
try {
|
|
166
|
+
names = (await readdir(generationsDir)).filter((name) => GENERATION_RE.test(name)).slice(0, 500);
|
|
167
|
+
} catch (error) {
|
|
168
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
|
|
169
|
+
throw error;
|
|
170
|
+
}
|
|
171
|
+
const result: Array<{ publishedAt: string; projection: CycleOperatingProjection }> = [];
|
|
172
|
+
for (const name of names) {
|
|
173
|
+
const generation = await boundedJson(join(generationsDir, name));
|
|
174
|
+
const generationId = basename(name, ".json");
|
|
175
|
+
if (
|
|
176
|
+
!generation
|
|
177
|
+
|| generation.kind !== "fpa.dashboard.generation"
|
|
178
|
+
|| generation.schema_version !== 1
|
|
179
|
+
|| generation.generation_id !== generationId
|
|
180
|
+
|| typeof generation.published_at !== "string"
|
|
181
|
+
|| Number.isNaN(Date.parse(generation.published_at))
|
|
182
|
+
|| typeof generation.build_receipt !== "string"
|
|
183
|
+
|| !RECEIPT_RE.test(generation.build_receipt)
|
|
184
|
+
) continue;
|
|
185
|
+
const receipt = await boundedJson(join(dashboardDir, generation.build_receipt));
|
|
186
|
+
if (
|
|
187
|
+
!receipt
|
|
188
|
+
|| receipt.generation_id !== generationId
|
|
189
|
+
|| receipt.published_at !== generation.published_at
|
|
190
|
+
|| !receiptDigestMatches(generation.build_receipt, receipt)
|
|
191
|
+
|| !receipt.source
|
|
192
|
+
|| typeof receipt.source !== "object"
|
|
193
|
+
|| (receipt.source as Record<string, unknown>).scope_id !== scopeId
|
|
194
|
+
) continue;
|
|
195
|
+
const actualsRef = (receipt.source as Record<string, unknown>).actuals_snapshot_ref;
|
|
196
|
+
if (typeof actualsRef !== "string" || !/^[a-f0-9]{64}$/.test(actualsRef)) continue;
|
|
197
|
+
const actualsObject = await boundedJson(join(dashboardDir, "actuals", `${actualsRef}.json`), 2 * 1024 * 1024);
|
|
198
|
+
if (!actualsObject) continue;
|
|
199
|
+
let actuals: DashboardActualsSnapshot;
|
|
200
|
+
try {
|
|
201
|
+
actuals = validateActualsSnapshot(actualsObject);
|
|
202
|
+
if (sha256(stableJson(actuals)) !== actualsRef) continue;
|
|
203
|
+
} catch {
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
const candidate = historicalClosedProjection(receipt.operating_projection, scopeId, actuals);
|
|
207
|
+
if (!candidate) continue;
|
|
208
|
+
result.push({ publishedAt: generation.published_at, projection: candidate });
|
|
209
|
+
}
|
|
210
|
+
return result.sort((left, right) => left.publishedAt.localeCompare(right.publishedAt)).map((item) => item.projection);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function percent(value: number | null, locale: string): string | null {
|
|
214
|
+
return value === null ? null : new Intl.NumberFormat(locale, { style: "percent", minimumFractionDigits: 1, maximumFractionDigits: 1, signDisplay: "always" }).format(value);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function number(value: number | null, locale: string): string {
|
|
218
|
+
return value === null ? "—" : new Intl.NumberFormat(locale, { maximumFractionDigits: 2 }).format(value);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function projectForecastAccuracyWidgets(projection: ForecastAccuracyProjection, locale = "zh-CN"): DashboardWidget[] {
|
|
222
|
+
const zh = locale.toLowerCase().startsWith("zh");
|
|
223
|
+
return [
|
|
224
|
+
{
|
|
225
|
+
id: "forecast-accuracy-mape",
|
|
226
|
+
type: "stat",
|
|
227
|
+
span: "quarter",
|
|
228
|
+
dataset: "forecast-accuracy-mape.json",
|
|
229
|
+
data: {
|
|
230
|
+
label: zh ? "历史预测误差 MAPE" : "Historical forecast MAPE",
|
|
231
|
+
value: percent(projection.mean_absolute_percentage_error, locale),
|
|
232
|
+
...(projection.sample_size === 0 ? { missingReason: zh ? "尚无可比较的已关闭周期" : "No comparable closed cycles are available." } : {}),
|
|
233
|
+
description: zh
|
|
234
|
+
? `${projection.sample_size} 个 original Forecast 样本 · WAPE ${percent(projection.weighted_absolute_percentage_error, locale) ?? "—"} · MAE ${number(projection.mean_absolute_error, locale)} · MASE ${number(projection.mean_absolute_scaled_error, locale)}`
|
|
235
|
+
: `${projection.sample_size} original-forecast cycle(s) · WAPE ${percent(projection.weighted_absolute_percentage_error, locale) ?? "—"} · MAE ${number(projection.mean_absolute_error, locale)} · MASE ${number(projection.mean_absolute_scaled_error, locale)}`,
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
{
|
|
239
|
+
id: "forecast-accuracy-history",
|
|
240
|
+
type: "timeseries",
|
|
241
|
+
span: "full",
|
|
242
|
+
dataset: "forecast-accuracy-history.json",
|
|
243
|
+
data: {
|
|
244
|
+
label: zh ? "冻结预测与最终实际收入" : "Frozen forecast vs final Actual revenue",
|
|
245
|
+
series: [
|
|
246
|
+
{ name: zh ? "原始预测" : "Original forecast", points: projection.cycles.map((cycle) => ({ x: cycle.cycle_id, y: cycle.forecast_revenue })) },
|
|
247
|
+
{ name: "Actual", points: projection.cycles.map((cycle) => ({ x: cycle.cycle_id, y: cycle.actual_revenue })) },
|
|
248
|
+
],
|
|
249
|
+
},
|
|
250
|
+
},
|
|
251
|
+
];
|
|
252
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { validateArtifact, type ApprovedCycleForecastInput, type ScenarioMetric } from "../fpa-artifacts/contracts.ts";
|
|
2
|
+
import type { ArtifactRefV2 } from "../fpa-artifacts/store.ts";
|
|
3
|
+
import type { DashboardWidget, TableCell } from "./projector.ts";
|
|
4
|
+
|
|
5
|
+
interface ForwardForecastInput {
|
|
6
|
+
ref: ArtifactRefV2;
|
|
7
|
+
forecast: unknown;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface ForwardOutlookProjection {
|
|
11
|
+
kind: "fpa.forward-outlook";
|
|
12
|
+
schema_version: 1;
|
|
13
|
+
reporting_currency: string;
|
|
14
|
+
status: "six_months_ready" | "partial" | "unavailable";
|
|
15
|
+
months: Array<{
|
|
16
|
+
artifact_ref: ArtifactRefV2;
|
|
17
|
+
forecast_version: string;
|
|
18
|
+
target_period: ApprovedCycleForecastInput["target_period"];
|
|
19
|
+
expected_spend: { downside: number | null; base: number | null; upside: number | null };
|
|
20
|
+
expected_revenue: { downside: number | null; base: number | null; upside: number | null };
|
|
21
|
+
expected_roas: { downside: number | null; base: number | null; upside: number | null };
|
|
22
|
+
}>;
|
|
23
|
+
warnings: string[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function approvedForecast(value: unknown, path: string): ApprovedCycleForecastInput {
|
|
27
|
+
const artifact = validateArtifact(value);
|
|
28
|
+
if (artifact.artifact_type !== "approved_cycle_forecast") throw new Error(`${path} must be an approved_cycle_forecast.`);
|
|
29
|
+
if (artifact.status !== "complete" || !artifact.approval_conditions_satisfied) throw new Error(`${path} must be complete and approved.`);
|
|
30
|
+
return artifact;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function values(metric: ScenarioMetric | undefined, path: string) {
|
|
34
|
+
if (!metric) throw new Error(`${path} is required.`);
|
|
35
|
+
return { downside: metric.downside, base: metric.base, upside: metric.upside };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function projectForwardOutlook(input: {
|
|
39
|
+
current_forecast: unknown;
|
|
40
|
+
forward_forecasts: ForwardForecastInput[];
|
|
41
|
+
}): ForwardOutlookProjection {
|
|
42
|
+
const current = approvedForecast(input.current_forecast, "current_forecast");
|
|
43
|
+
if (!Array.isArray(input.forward_forecasts) || input.forward_forecasts.length > 6) throw new Error("forward_forecasts must contain at most six approved forecasts.");
|
|
44
|
+
const months: ForwardOutlookProjection["months"] = [];
|
|
45
|
+
let expectedStart = current.target_period.end_exclusive;
|
|
46
|
+
for (const [index, item] of input.forward_forecasts.entries()) {
|
|
47
|
+
const forecast = approvedForecast(item.forecast, `forward_forecasts[${index}].forecast`);
|
|
48
|
+
if (item.ref.artifact_type !== "approved_cycle_forecast" || item.ref.body_fingerprint === "" || item.ref.cycle_id === "") throw new Error(`forward_forecasts[${index}].ref is invalid.`);
|
|
49
|
+
if (forecast.reporting_currency !== current.reporting_currency) throw new Error(`forward_forecasts[${index}] reporting currency does not match the current forecast.`);
|
|
50
|
+
if (forecast.target_period.timezone !== current.target_period.timezone || Date.parse(forecast.target_period.start_inclusive) !== Date.parse(expectedStart)) {
|
|
51
|
+
throw new Error(`forward_forecasts[${index}] must be the exact consecutive successor in the same timezone.`);
|
|
52
|
+
}
|
|
53
|
+
expectedStart = forecast.target_period.end_exclusive;
|
|
54
|
+
months.push({
|
|
55
|
+
artifact_ref: item.ref,
|
|
56
|
+
forecast_version: forecast.forecast_version,
|
|
57
|
+
target_period: forecast.target_period,
|
|
58
|
+
expected_spend: values(forecast.consolidated_forecast.spend, `forward_forecasts[${index}].spend`),
|
|
59
|
+
expected_revenue: values(forecast.consolidated_forecast.revenue, `forward_forecasts[${index}].revenue`),
|
|
60
|
+
expected_roas: values(forecast.consolidated_forecast.roas, `forward_forecasts[${index}].roas`),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
const warnings: string[] = [];
|
|
64
|
+
if (months.length < 6) warnings.push(`Only ${months.length} of 6 forward monthly forecasts are approved and linked.`);
|
|
65
|
+
if (months.some((month) => month.expected_revenue.base === null || month.expected_spend.base === null || month.expected_roas.base === null)) {
|
|
66
|
+
warnings.push("At least one forward month has unsupported base metrics; its expectation remains unavailable.");
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
kind: "fpa.forward-outlook",
|
|
70
|
+
schema_version: 1,
|
|
71
|
+
reporting_currency: current.reporting_currency,
|
|
72
|
+
status: months.length === 0 ? "unavailable" : months.length === 6 ? "six_months_ready" : "partial",
|
|
73
|
+
months,
|
|
74
|
+
warnings,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function currency(value: number | null, code: string, locale: string): string | null {
|
|
79
|
+
return value === null ? null : new Intl.NumberFormat(locale, { style: "currency", currency: code, currencyDisplay: "narrowSymbol", maximumFractionDigits: 0 }).format(value);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function number(value: number | null): string | null {
|
|
83
|
+
return value === null ? null : value.toFixed(2);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function neutral(value: string | null): TableCell {
|
|
87
|
+
return { value, tone: "neutral" };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function projectForwardOutlookWidgets(projection: ForwardOutlookProjection, locale = "zh-CN"): DashboardWidget[] {
|
|
91
|
+
const zh = locale.toLowerCase().startsWith("zh");
|
|
92
|
+
if (projection.status === "unavailable") return [{
|
|
93
|
+
id: "forward-outlook-readiness",
|
|
94
|
+
type: "stat",
|
|
95
|
+
span: "quarter",
|
|
96
|
+
dataset: "forward-outlook-readiness.json",
|
|
97
|
+
data: {
|
|
98
|
+
label: zh ? "未来六个月预测" : "Six-month outlook",
|
|
99
|
+
value: null,
|
|
100
|
+
missingReason: zh ? "尚无连续的未来周期批准预测" : "No consecutive approved future forecasts are linked.",
|
|
101
|
+
},
|
|
102
|
+
}];
|
|
103
|
+
const monthLabel = (month: ForwardOutlookProjection["months"][number]) => month.target_period.start_inclusive.slice(0, 7);
|
|
104
|
+
return [
|
|
105
|
+
{
|
|
106
|
+
id: "forward-outlook-revenue",
|
|
107
|
+
type: "timeseries",
|
|
108
|
+
span: "full",
|
|
109
|
+
dataset: "forward-outlook-revenue.json",
|
|
110
|
+
data: {
|
|
111
|
+
label: zh ? "未来六个月收入情景" : "Six-month revenue scenarios",
|
|
112
|
+
description: zh ? `已批准 ${projection.months.length}/6 个连续月份` : `${projection.months.length}/6 consecutive months approved`,
|
|
113
|
+
series: [
|
|
114
|
+
{ name: zh ? "下行" : "Downside", points: projection.months.map((month) => ({ x: monthLabel(month), y: month.expected_revenue.downside })) },
|
|
115
|
+
{ name: zh ? "基准" : "Base", points: projection.months.map((month) => ({ x: monthLabel(month), y: month.expected_revenue.base })) },
|
|
116
|
+
{ name: zh ? "上行" : "Upside", points: projection.months.map((month) => ({ x: monthLabel(month), y: month.expected_revenue.upside })) },
|
|
117
|
+
],
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
id: "forward-outlook-table",
|
|
122
|
+
type: "table",
|
|
123
|
+
span: "full",
|
|
124
|
+
dataset: "forward-outlook-table.json",
|
|
125
|
+
data: {
|
|
126
|
+
label: zh ? "未来周期批准预测" : "Approved forward forecasts",
|
|
127
|
+
columns: [
|
|
128
|
+
{ key: "period", label: zh ? "周期" : "Period" },
|
|
129
|
+
{ key: "spend", label: zh ? "预算" : "Spend", align: "right" },
|
|
130
|
+
{ key: "revenue", label: zh ? "预期收入" : "Expected revenue", align: "right" },
|
|
131
|
+
{ key: "roas", label: "ROAS", align: "right" },
|
|
132
|
+
],
|
|
133
|
+
rows: projection.months.map((month) => ({
|
|
134
|
+
period: monthLabel(month),
|
|
135
|
+
spend: currency(month.expected_spend.base, projection.reporting_currency, locale),
|
|
136
|
+
revenue: currency(month.expected_revenue.base, projection.reporting_currency, locale),
|
|
137
|
+
roas: neutral(number(month.expected_roas.base)),
|
|
138
|
+
})),
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
];
|
|
142
|
+
}
|
|
@@ -2,22 +2,14 @@ import { StringEnum } from "@earendil-works/pi-ai";
|
|
|
2
2
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { Type } from "typebox";
|
|
4
4
|
|
|
5
|
-
import {
|
|
6
|
-
readCommittedArtifact,
|
|
7
|
-
readOptionalCommittedArtifact,
|
|
8
|
-
type ReadArtifactResult,
|
|
9
|
-
} from "../fpa-artifacts/store.ts";
|
|
10
|
-
import type {
|
|
11
|
-
ApprovedCycleForecast,
|
|
12
|
-
ApprovedCycleForecastInput,
|
|
13
|
-
CanonicalArtifact,
|
|
14
|
-
ExecutionReceipt,
|
|
15
|
-
ExecutionReceiptInput,
|
|
16
|
-
} from "../fpa-artifacts/contracts.ts";
|
|
17
|
-
import { isExecutionReceiptForForecast, sliceKey } from "../fpa-artifacts/contracts.ts";
|
|
18
|
-
import { loadDashboardActuals } from "./actuals.ts";
|
|
19
|
-
import { projectDashboard, type DashboardBuild } from "./projector.ts";
|
|
5
|
+
import type { ArtifactRefV2 } from "../fpa-artifacts/store.ts";
|
|
20
6
|
import { dashboardBuildFingerprint, publishDashboard } from "./publisher.ts";
|
|
7
|
+
import {
|
|
8
|
+
enqueueDashboardRefresh,
|
|
9
|
+
inspectDashboardRefreshQueue,
|
|
10
|
+
processDashboardRefreshQueue,
|
|
11
|
+
} from "./coordinator.ts";
|
|
12
|
+
import { buildDashboardProjection } from "./service.ts";
|
|
21
13
|
import { inspectDashboard } from "./status.ts";
|
|
22
14
|
|
|
23
15
|
function toolResult(value: Record<string, unknown>) {
|
|
@@ -27,60 +19,14 @@ function toolResult(value: Record<string, unknown>) {
|
|
|
27
19
|
};
|
|
28
20
|
}
|
|
29
21
|
|
|
30
|
-
function withoutFingerprint<T extends CanonicalArtifact>(artifact: T): Omit<T, "immutable_fingerprint"> {
|
|
31
|
-
const { immutable_fingerprint: _fingerprint, ...input } = artifact;
|
|
32
|
-
return input;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
async function buildDashboard(
|
|
36
|
-
cwd: string,
|
|
37
|
-
locale: string,
|
|
38
|
-
signal?: AbortSignal,
|
|
39
|
-
): Promise<{
|
|
40
|
-
build: DashboardBuild;
|
|
41
|
-
forecast: ApprovedCycleForecast;
|
|
42
|
-
forecastRead: ReadArtifactResult;
|
|
43
|
-
executionRead: ReadArtifactResult | null;
|
|
44
|
-
}> {
|
|
45
|
-
const forecastRead = await readCommittedArtifact(cwd, "approved_cycle_forecast");
|
|
46
|
-
if (forecastRead.artifact.artifact_type !== "approved_cycle_forecast") throw new Error("Committed approved forecast has the wrong artifact type.");
|
|
47
|
-
const forecast = forecastRead.artifact as ApprovedCycleForecast;
|
|
48
|
-
const committedExecutionRead = await readOptionalCommittedArtifact(cwd, "execution_receipt");
|
|
49
|
-
const executionRead = committedExecutionRead?.artifact.artifact_type === "execution_receipt" && isExecutionReceiptForForecast(committedExecutionRead.artifact, forecast)
|
|
50
|
-
? committedExecutionRead
|
|
51
|
-
: null;
|
|
52
|
-
const execution = executionRead?.artifact.artifact_type === "execution_receipt"
|
|
53
|
-
? executionRead.artifact as ExecutionReceipt
|
|
54
|
-
: null;
|
|
55
|
-
const actuals = await loadDashboardActuals(
|
|
56
|
-
forecast.target_period,
|
|
57
|
-
{
|
|
58
|
-
slices: forecast.approved_allocation,
|
|
59
|
-
},
|
|
60
|
-
signal,
|
|
61
|
-
);
|
|
62
|
-
const build = projectDashboard({
|
|
63
|
-
forecast: withoutFingerprint(forecast) as ApprovedCycleForecastInput,
|
|
64
|
-
actuals,
|
|
65
|
-
...(execution ? { execution: withoutFingerprint(execution) as ExecutionReceiptInput } : {}),
|
|
66
|
-
locale,
|
|
67
|
-
});
|
|
68
|
-
build.source.forecast_fingerprint = forecastRead.fingerprint;
|
|
69
|
-
if (executionRead) build.source.execution_fingerprint = executionRead.fingerprint;
|
|
70
|
-
|
|
71
|
-
const plannedKeys = new Set(forecast.approved_allocation.map(sliceKey));
|
|
72
|
-
const actualKeys = new Set(actuals.slices.map(sliceKey));
|
|
73
|
-
const unplanned = actuals.slices.filter((slice) => !plannedKeys.has(sliceKey(slice)) && ((slice.spend ?? 0) > 0 || (slice.revenue ?? 0) !== 0));
|
|
74
|
-
const missing = forecast.approved_allocation.filter((slice) => !actualKeys.has(sliceKey(slice)));
|
|
75
|
-
if (unplanned.length > 0) build.warnings.push(`${unplanned.length} paid Actuals slices are outside the approved allocation and were excluded from like-for-like forecast totals and the strategy table.`);
|
|
76
|
-
if (missing.length > 0) build.warnings.push(`${missing.length} approved slices have no current-period Actuals row; their values remain null.`);
|
|
77
|
-
if (committedExecutionRead && !executionRead) build.warnings.push(`Ignored stale execution receipt for forecast ${committedExecutionRead.artifact.forecast_version}; current forecast is ${forecast.forecast_version}.`);
|
|
78
|
-
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
|
-
if (actuals.data_as_of === "unavailable") build.warnings.push("No current-period UA Actuals are available.");
|
|
80
|
-
return { build, forecast, forecastRead, executionRead };
|
|
81
|
-
}
|
|
82
|
-
|
|
83
22
|
export default function fpaDashboardExtension(pi: ExtensionAPI): void {
|
|
23
|
+
const artifactRefSchema = Type.Object({
|
|
24
|
+
scope_id: Type.String({ minLength: 1, maxLength: 256 }),
|
|
25
|
+
cycle_id: Type.String({ minLength: 1, maxLength: 256 }),
|
|
26
|
+
artifact_type: Type.Union([Type.Literal("approved_cycle_forecast"), Type.Literal("execution_receipt")]),
|
|
27
|
+
entry_id: Type.String({ pattern: "^[a-f0-9]{64}$" }),
|
|
28
|
+
body_fingerprint: Type.String({ pattern: "^[a-f0-9]{64}$" }),
|
|
29
|
+
}, { additionalProperties: false });
|
|
84
30
|
pi.registerTool({
|
|
85
31
|
name: "fpa_dashboard_status",
|
|
86
32
|
label: "FP&A Dashboard Status",
|
|
@@ -110,13 +56,32 @@ export default function fpaDashboardExtension(pi: ExtensionAPI): void {
|
|
|
110
56
|
mode: StringEnum(["preview", "publish"] as const),
|
|
111
57
|
locale: Type.Optional(StringEnum(["zh-CN", "en-US"] as const)),
|
|
112
58
|
expected_preview_fingerprint: Type.Optional(Type.String({ pattern: "^[a-f0-9]{64}$" })),
|
|
59
|
+
scope_id: Type.Optional(Type.String({ minLength: 1, maxLength: 256 })),
|
|
60
|
+
cycle_id: Type.Optional(Type.String({ minLength: 1, maxLength: 256 })),
|
|
61
|
+
forecast_ref: Type.Optional(artifactRefSchema),
|
|
62
|
+
execution_ref: Type.Optional(artifactRefSchema),
|
|
63
|
+
next_forecast_ref: Type.Optional(artifactRefSchema),
|
|
64
|
+
forward_forecast_refs: Type.Optional(Type.Array(artifactRefSchema, { maxItems: 6 })),
|
|
113
65
|
},
|
|
114
66
|
{ additionalProperties: false },
|
|
115
67
|
),
|
|
116
68
|
executionMode: "sequential",
|
|
117
69
|
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
118
|
-
const
|
|
119
|
-
const
|
|
70
|
+
const exactFields = [params.scope_id, params.cycle_id, params.forecast_ref];
|
|
71
|
+
const hasAnyExactField = exactFields.some((value) => value !== undefined) || params.execution_ref !== undefined || params.next_forecast_ref !== undefined || params.forward_forecast_refs !== undefined;
|
|
72
|
+
if (hasAnyExactField && exactFields.some((value) => value === undefined)) {
|
|
73
|
+
throw new Error("scope_id, cycle_id, and forecast_ref must be supplied together; exact refresh never falls back to current artifacts.");
|
|
74
|
+
}
|
|
75
|
+
const exact = hasAnyExactField ? {
|
|
76
|
+
scopeId: params.scope_id as string,
|
|
77
|
+
cycleId: params.cycle_id as string,
|
|
78
|
+
forecastRef: params.forecast_ref as ArtifactRefV2,
|
|
79
|
+
...(params.execution_ref ? { executionRef: params.execution_ref as ArtifactRefV2 } : {}),
|
|
80
|
+
...(params.next_forecast_ref ? { nextForecastRef: params.next_forecast_ref as ArtifactRefV2 } : {}),
|
|
81
|
+
...(params.forward_forecast_refs ? { forwardForecastRefs: params.forward_forecast_refs as ArtifactRefV2[] } : {}),
|
|
82
|
+
} : undefined;
|
|
83
|
+
const { build, forecast, actuals, sliceKeyMismatch, projector, runtime } = await buildDashboardProjection(ctx.cwd, params.preset, params.locale ?? "zh-CN", exact, signal);
|
|
84
|
+
const previewFingerprint = dashboardBuildFingerprint(build, projector);
|
|
120
85
|
const summary = {
|
|
121
86
|
preset: params.preset,
|
|
122
87
|
preview_fingerprint: previewFingerprint,
|
|
@@ -128,9 +93,29 @@ export default function fpaDashboardExtension(pi: ExtensionAPI): void {
|
|
|
128
93
|
widgets: build.widgets.map((widget) => ({ id: widget.id, type: widget.type, dataset: widget.dataset })),
|
|
129
94
|
warnings: build.warnings,
|
|
130
95
|
query_receipts: build.source.query_receipts,
|
|
96
|
+
projector,
|
|
97
|
+
runtime,
|
|
98
|
+
...(build.operating_projection ? { operating_projection: build.operating_projection } : {}),
|
|
99
|
+
...(build.forward_outlook ? { forward_outlook: build.forward_outlook } : {}),
|
|
100
|
+
...(build.forecast_accuracy ? { forecast_accuracy: build.forecast_accuracy } : {}),
|
|
101
|
+
...(exact ? {
|
|
102
|
+
scope_id: exact.scopeId,
|
|
103
|
+
cycle_id: exact.cycleId,
|
|
104
|
+
forecast_ref: exact.forecastRef,
|
|
105
|
+
...(exact.executionRef ? { execution_ref: exact.executionRef } : {}),
|
|
106
|
+
...(exact.nextForecastRef ? { next_forecast_ref: exact.nextForecastRef } : {}),
|
|
107
|
+
...(exact.forwardForecastRefs ? { forward_forecast_refs: exact.forwardForecastRefs } : {}),
|
|
108
|
+
} : {}),
|
|
131
109
|
};
|
|
132
|
-
if (params.mode === "preview")
|
|
110
|
+
if (params.mode === "preview") {
|
|
111
|
+
return toolResult({
|
|
112
|
+
status: sliceKeyMismatch ? "blocked_slice_key_mismatch" : build.warnings.length > 0 ? "ready_with_limits" : "ready",
|
|
113
|
+
...summary,
|
|
114
|
+
...(sliceKeyMismatch ? { blocking_reason: sliceKeyMismatch } : {}),
|
|
115
|
+
});
|
|
116
|
+
}
|
|
133
117
|
|
|
118
|
+
if (sliceKeyMismatch) throw new Error(`Dashboard publish refused. ${sliceKeyMismatch}`);
|
|
134
119
|
if (forecast.status !== "complete" || !forecast.approval_conditions_satisfied) {
|
|
135
120
|
throw new Error("Dashboard publish requires a complete approved forecast with all approval conditions satisfied.");
|
|
136
121
|
}
|
|
@@ -138,8 +123,47 @@ export default function fpaDashboardExtension(pi: ExtensionAPI): void {
|
|
|
138
123
|
if (params.expected_preview_fingerprint !== previewFingerprint) {
|
|
139
124
|
throw new Error("Dashboard inputs changed after preview; run preview again before publishing.");
|
|
140
125
|
}
|
|
141
|
-
const published = await publishDashboard({ cwd: ctx.cwd, build });
|
|
126
|
+
const published = await publishDashboard({ cwd: ctx.cwd, build, projector, runtime, actualsSnapshot: actuals });
|
|
142
127
|
return toolResult({ status: "published", ...summary, dashboard_dir: published.dashboardDir, published_datasets: published.publishedDatasets });
|
|
143
128
|
},
|
|
144
129
|
});
|
|
130
|
+
|
|
131
|
+
pi.registerTool({
|
|
132
|
+
name: "fpa_dashboard_refresh_queue",
|
|
133
|
+
label: "FP&A Dashboard Refresh Queue",
|
|
134
|
+
description: "Inspect or drain the durable dashboard refresh queue, or enqueue an Actuals-watermark refresh against exact immutable artifact refs.",
|
|
135
|
+
promptSnippet: "Inspect or process durable FP&A dashboard refresh work",
|
|
136
|
+
parameters: Type.Object({
|
|
137
|
+
action: StringEnum(["status", "drain", "enqueue_actuals"] as const),
|
|
138
|
+
locale: Type.Optional(StringEnum(["zh-CN", "en-US"] as const)),
|
|
139
|
+
scope_id: Type.Optional(Type.String({ minLength: 1, maxLength: 256 })),
|
|
140
|
+
cycle_id: Type.Optional(Type.String({ minLength: 1, maxLength: 256 })),
|
|
141
|
+
forecast_ref: Type.Optional(artifactRefSchema),
|
|
142
|
+
execution_ref: Type.Optional(artifactRefSchema),
|
|
143
|
+
next_forecast_ref: Type.Optional(artifactRefSchema),
|
|
144
|
+
forward_forecast_refs: Type.Optional(Type.Array(artifactRefSchema, { maxItems: 6 })),
|
|
145
|
+
actuals_watermark: Type.Optional(Type.String({ minLength: 1, maxLength: 256 })),
|
|
146
|
+
limit: Type.Optional(Type.Number({ minimum: 1, maximum: 100 })),
|
|
147
|
+
}, { additionalProperties: false }),
|
|
148
|
+
executionMode: "sequential",
|
|
149
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
150
|
+
if (params.action === "status") return toolResult(await inspectDashboardRefreshQueue(ctx.cwd) as unknown as Record<string, unknown>);
|
|
151
|
+
if (params.action === "drain") return toolResult(await processDashboardRefreshQueue(ctx.cwd, { limit: params.limit, signal }) as unknown as Record<string, unknown>);
|
|
152
|
+
if (!params.scope_id || !params.cycle_id || !params.forecast_ref || !params.actuals_watermark) {
|
|
153
|
+
throw new Error("enqueue_actuals requires scope_id, cycle_id, forecast_ref, and actuals_watermark.");
|
|
154
|
+
}
|
|
155
|
+
return toolResult(await enqueueDashboardRefresh(ctx.cwd, {
|
|
156
|
+
preset: "forecast-closed-loop-v1",
|
|
157
|
+
locale: params.locale ?? "zh-CN",
|
|
158
|
+
scope_id: params.scope_id,
|
|
159
|
+
cycle_id: params.cycle_id,
|
|
160
|
+
forecast_ref: params.forecast_ref as ArtifactRefV2,
|
|
161
|
+
...(params.execution_ref ? { execution_ref: params.execution_ref as ArtifactRefV2 } : {}),
|
|
162
|
+
...(params.next_forecast_ref ? { next_forecast_ref: params.next_forecast_ref as ArtifactRefV2 } : {}),
|
|
163
|
+
...(params.forward_forecast_refs ? { forward_forecast_refs: params.forward_forecast_refs as ArtifactRefV2[] } : {}),
|
|
164
|
+
actuals_watermark: params.actuals_watermark,
|
|
165
|
+
reason: "actuals_watermark",
|
|
166
|
+
}) as unknown as Record<string, unknown>);
|
|
167
|
+
},
|
|
168
|
+
});
|
|
145
169
|
}
|
|
@@ -8,6 +8,9 @@ import {
|
|
|
8
8
|
type ForecastSlice,
|
|
9
9
|
} from "../fpa-artifacts/contracts.ts";
|
|
10
10
|
import { validateActualsSnapshot, ACTUALS_DATA_AS_OF_UNAVAILABLE, type DashboardActualsSnapshot, type DashboardActualSlice } from "./source.ts";
|
|
11
|
+
import type { CycleOperatingProjection } from "./cycle-operating-projection.ts";
|
|
12
|
+
import type { ForwardOutlookProjection } from "./forward-outlook.ts";
|
|
13
|
+
import type { ForecastAccuracyProjection } from "./forecast-accuracy.ts";
|
|
11
14
|
|
|
12
15
|
export type Tone = "positive" | "negative" | "warning" | "neutral";
|
|
13
16
|
export type TableCell = string | null | { value: string | null; tone?: Tone };
|
|
@@ -58,14 +61,30 @@ export interface DashboardBuild {
|
|
|
58
61
|
title: string;
|
|
59
62
|
widgets: DashboardWidget[];
|
|
60
63
|
source: {
|
|
64
|
+
project_name?: string;
|
|
65
|
+
scope_id?: string;
|
|
66
|
+
cycle_id?: string;
|
|
67
|
+
forecast_ref?: string;
|
|
68
|
+
next_forecast_ref?: string;
|
|
69
|
+
execution_ref?: string;
|
|
70
|
+
forecast_role?: "original" | "eac" | "next_plan";
|
|
61
71
|
forecast_version: string;
|
|
62
72
|
forecast_fingerprint?: string;
|
|
73
|
+
next_forecast_fingerprint?: string;
|
|
74
|
+
forward_forecast_refs?: string[];
|
|
75
|
+
forward_forecast_fingerprints?: string[];
|
|
63
76
|
execution_fingerprint?: string;
|
|
64
77
|
data_as_of: string;
|
|
78
|
+
actuals_watermark?: string;
|
|
79
|
+
actuals_snapshot_ref?: string;
|
|
80
|
+
actuals_snapshot_evidence?: DashboardActualsSnapshot["snapshot_evidence"];
|
|
65
81
|
actuals_scope: { slice_keys: string[] };
|
|
66
82
|
query_receipts: DashboardActualsSnapshot["query_receipts"];
|
|
67
83
|
};
|
|
68
84
|
warnings: string[];
|
|
85
|
+
operating_projection?: CycleOperatingProjection;
|
|
86
|
+
forward_outlook?: ForwardOutlookProjection;
|
|
87
|
+
forecast_accuracy?: ForecastAccuracyProjection;
|
|
69
88
|
}
|
|
70
89
|
|
|
71
90
|
interface ProjectionInput {
|