@viccydev/pi-fpa 0.2.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 +135 -0
- package/extensions/fpa-data/calc.ts +544 -0
- package/extensions/fpa-data/index.ts +478 -0
- package/extensions/fpa-data/registry.ts +303 -0
- package/extensions/fpa-data/sql.ts +412 -0
- package/extensions/fpa-data/supabase.ts +96 -0
- package/package.json +54 -0
- package/prompts/fpa-plan-cycle.md +44 -0
- package/prompts/fpa-review-cycle.md +33 -0
- package/skills/fpa-analyze-drivers/SKILL.md +30 -0
- package/skills/fpa-analyze-drivers/references/artifact-contract.md +34 -0
- package/skills/fpa-apply-core-rules/SKILL.md +34 -0
- package/skills/fpa-apply-core-rules/references/core-rules.md +96 -0
- package/skills/fpa-diagnose-actuals/SKILL.md +30 -0
- package/skills/fpa-diagnose-actuals/references/artifact-contract.md +38 -0
- package/skills/fpa-execute-approved-strategy/SKILL.md +40 -0
- package/skills/fpa-execute-approved-strategy/references/artifact-contract.md +29 -0
- package/skills/fpa-forecast-approved-strategy/SKILL.md +39 -0
- package/skills/fpa-forecast-approved-strategy/references/artifact-contract.md +36 -0
- package/skills/fpa-plan-cycle/SKILL.md +29 -0
- package/skills/fpa-plan-cycle/references/artifact-contract.md +41 -0
- package/skills/fpa-recommend-strategy/SKILL.md +29 -0
- package/skills/fpa-recommend-strategy/references/artifact-contract.md +30 -0
- package/skills/fpa-review-cycle/SKILL.md +32 -0
- package/skills/fpa-review-cycle/references/artifact-contract.md +30 -0
- package/skills/fpa-review-strategy/SKILL.md +28 -0
- package/skills/fpa-review-strategy/references/artifact-contract.md +25 -0
- package/skills/fpa-simulate-strategies/SKILL.md +32 -0
- package/skills/fpa-simulate-strategies/references/artifact-contract.md +35 -0
package/README.md
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# pi-fpa
|
|
2
|
+
|
|
3
|
+
面向 Pi 的完整 FP&A 周期资源包,目标运行时为 `@earendil-works/pi-coding-agent` 0.84.1。
|
|
4
|
+
|
|
5
|
+
本包分发 Prompt Template、Skill、参考合同,以及一个只读数据 Extension(`fpa-data`)。包内不含业务数据与模型凭证,也不实现工作流状态机。多 Agent 隔离、人工审批、Artifact 持久化和真实外部执行仍由宿主运行时或独立工作流承担。
|
|
6
|
+
|
|
7
|
+
## 包含的资源
|
|
8
|
+
|
|
9
|
+
Prompt Template:
|
|
10
|
+
|
|
11
|
+
- `/fpa-plan-cycle`:从周期规划开始,经过 Actuals 诊断、驱动分析、策略模拟、推荐和独立复核;只有拿到匹配版本的外部人工批准后,才生成并冻结正式预测。
|
|
12
|
+
- `/fpa-review-cycle`:新周期 Actuals 到达后,对比冻结的正式预测并形成周期复盘。
|
|
13
|
+
|
|
14
|
+
Skill:
|
|
15
|
+
|
|
16
|
+
- `fpa-apply-core-rules`
|
|
17
|
+
- `fpa-plan-cycle`
|
|
18
|
+
- `fpa-diagnose-actuals`
|
|
19
|
+
- `fpa-analyze-drivers`
|
|
20
|
+
- `fpa-simulate-strategies`
|
|
21
|
+
- `fpa-recommend-strategy`
|
|
22
|
+
- `fpa-review-strategy`
|
|
23
|
+
- `fpa-forecast-approved-strategy`
|
|
24
|
+
- `fpa-review-cycle`
|
|
25
|
+
- `fpa-execute-approved-strategy`
|
|
26
|
+
|
|
27
|
+
执行 Skill 设置了 `disable-model-invocation: true`,不会出现在模型可主动调用的 Skill 摘要中,也没有对应 Prompt。只有用户显式输入 `/skill:fpa-execute-approved-strategy` 才能加载它;即使显式加载,缺少精确批准、单独执行授权、真实 adapter、幂等键或成功 preflight 时也必须保持 `blocked`,不得产生外部变更。
|
|
28
|
+
|
|
29
|
+
## 数据 Extension(fpa-data)
|
|
30
|
+
|
|
31
|
+
`extensions/fpa-data` 注册五个只读工具,连接 Supabase 数据集市:
|
|
32
|
+
|
|
33
|
+
| 工具 | 作用 |
|
|
34
|
+
| --- | --- |
|
|
35
|
+
| `fpa_data_catalog` | 数据字典:数据集、维度、指标定义与聚合语义、各表实时日期覆盖、已知数据坑 |
|
|
36
|
+
| `fpa_query` | 结构化聚合查询:指标 + 维度 + 时间粒度 + 过滤;SQL 由代码生成,比率按“先聚合分子分母、再相除”计算 |
|
|
37
|
+
| `fpa_cohort` | 安装 cohort 的 LTV / ROAS / 留存曲线(D0/D3/D7/…),未成熟或缺分母一律返回 NULL 并给出原因 |
|
|
38
|
+
| `fpa_calc` | 确定性计算器:命名公式求值(四则、abs/min/max/round),NULL 与除零安全传播 |
|
|
39
|
+
| `fpa_compare` | 双期间对比:差值、百分比变化、逐行贡献度全部由代码计算 |
|
|
40
|
+
|
|
41
|
+
设计契约:**模型不写 SQL、不做任何算术**。模型只从注册表中选择数据集、指标和维度;SQL 生成、数据库聚合和全部派生计算(比率、差异、LTV/ROAS/留存、临时公式)都在 Extension 代码内完成,缺数据或除零返回 `NULL`,绝不编造数值。
|
|
42
|
+
|
|
43
|
+
Extension 内置的关键防护:
|
|
44
|
+
|
|
45
|
+
- `appsflyer_ua_campaign_daily` 的三种 `breakdown_type` 是同一份花费的重叠切分;每次查询自动锁定一种,避免花费被重复计算。
|
|
46
|
+
- 每个查询结果都带 `date_min` / `date_max` / `source_rows` 覆盖率元数据;各数据集日期覆盖不一致时以此为准。
|
|
47
|
+
- Apple 指标按来源语义聚合:COUNT 求和、AVERAGE 取日均、LATEST 取期末值。
|
|
48
|
+
- cohort 规模缺失(2026-08-01 之前)时 LTV/留存分母返回 NULL。
|
|
49
|
+
|
|
50
|
+
### 凭证配置
|
|
51
|
+
|
|
52
|
+
Extension 通过 Supabase Management API 只读查询,凭证仅从环境变量读取,绝不写入包内:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
export SUPABASE_PROJECT_REF="<project ref>"
|
|
56
|
+
export SUPABASE_ACCESS_TOKEN="<personal access token, sbp_...>"
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
在启动 Pi 前设置。未配置时工具报错并说明缺哪个变量,不发出任何查询。
|
|
60
|
+
|
|
61
|
+
## 输入契约
|
|
62
|
+
|
|
63
|
+
两个 Prompt 的基本参数都是:
|
|
64
|
+
|
|
65
|
+
```text
|
|
66
|
+
<project-root> <cycle-id> [instructions]
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
数据来源优先级:
|
|
70
|
+
|
|
71
|
+
1. `fpa_data_catalog` 等 `fpa_*` 工具(Supabase 集市,推荐)。
|
|
72
|
+
2. 运行时显式提供的字段目录与 Schema 路径。
|
|
73
|
+
3. `<project-root>/LOCAL_FPA_MART_FIELD_CATALOG.md` 与 `<project-root>/LOCAL_FPA_MART_SCHEMA.sql`(本地文件回退)。
|
|
74
|
+
|
|
75
|
+
资源不存在时必须报告缺口,不得回退到开发者机器路径或编造输入。
|
|
76
|
+
|
|
77
|
+
## 本地安装
|
|
78
|
+
|
|
79
|
+
从 npm 安装正式版本:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
pi install npm:@viccydev/pi-fpa
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
从本地工作区安装开发版本:
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
pi install /absolute/path/to/pi-fpa
|
|
89
|
+
pi list
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
本地路径只写入 Pi settings,不复制源目录。修改包后使用 `/reload` 或重启 Pi。
|
|
93
|
+
|
|
94
|
+
团队分发建议使用固定 Git tag:
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
pi install git:github.com/linyqh/pi-fpa@v0.2.0
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## 发布到 npm
|
|
101
|
+
|
|
102
|
+
发布动作由 GitHub Release 触发。Release 标签必须严格使用 `v<package.json version>`,例如当前 `0.2.0` 对应 `v0.2.0`。工作流会检出该标签,执行 `npm ci`、`npm test` 和包内容预检,全部通过后发布公开包 `@viccydev/pi-fpa`。普通 Release 发布到 `latest`,Prerelease 发布到 `next`。
|
|
103
|
+
|
|
104
|
+
首次发布前需要完成一次仓库配置:
|
|
105
|
+
|
|
106
|
+
1. 确认 npm 账号或组织拥有 `@viccydev` scope。
|
|
107
|
+
2. 在 GitHub 仓库的 Actions secrets 中创建 `NPM_TOKEN`。Token 需要允许发布该 scope,并允许 CI 绕过发布时的 2FA。
|
|
108
|
+
3. 将仓库推送到 GitHub,然后创建与 `package.json` 版本匹配的 GitHub Release。
|
|
109
|
+
|
|
110
|
+
首发成功后,建议到 npm 包设置中将 Trusted Publisher 配置为 GitHub Actions;GitHub 用户填写 `linyqh`,仓库填写 `pi-fpa`,工作流文件填写 `publish.yml`,Allowed actions 选择 `npm publish`。验证 OIDC 发布成功后即可删除长期 `NPM_TOKEN`。当前许可证仍是 `UNLICENSED`;若准备让第三方使用或修改本包,应在首发前明确许可证。
|
|
111
|
+
|
|
112
|
+
## 使用
|
|
113
|
+
|
|
114
|
+
```text
|
|
115
|
+
/fpa-plan-cycle /path/to/project 2026-Q3 "按 App、Store、Channel Group 规划;预算上限见 planning input"
|
|
116
|
+
/fpa-review-cycle /path/to/project 2026-Q3 "使用已冻结 forecast 和新到达的 Actuals snapshot"
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
规划入口不会进入真实策略执行。需要执行获批策略时,必须在单独、已授权的运行中显式调用:
|
|
120
|
+
|
|
121
|
+
```text
|
|
122
|
+
/skill:fpa-execute-approved-strategy <exact strategy version and execution scope>
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## 资源迁移注意
|
|
126
|
+
|
|
127
|
+
如果 `~/.pi/agent/skills/` 中仍有同名 `fpa-*` Skill,Pi 会报告命名冲突并采用先发现的资源。先在隔离配置中验证本包,确认来源路径后再通过 `pi config` 禁用旧副本或将旧目录移出发现路径。全局 `~/.pi/agent/extensions/` 下如有旧的数据 extension(如 `ios-fpa`),工具名不同不会冲突,但建议确认是否仍需保留。
|
|
128
|
+
|
|
129
|
+
## 验证
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
npm test # 包结构 + extension 单元测试 + Pi loader 冒烟
|
|
133
|
+
npm run test:live # 可选:需要 SUPABASE_* 环境变量,对真实库做只读冒烟
|
|
134
|
+
npm run pack:check
|
|
135
|
+
```
|
|
@@ -0,0 +1,544 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic calculation engine for the FP&A tools.
|
|
3
|
+
*
|
|
4
|
+
* All arithmetic exposed to the workflow happens here or in SQL — never in
|
|
5
|
+
* the LLM. Missing inputs and zero denominators propagate as null (per the
|
|
6
|
+
* core FP&A rules), never as fabricated zeros.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { DerivedDef } from "./registry.ts";
|
|
10
|
+
|
|
11
|
+
export type NumericLike = number | string | null | undefined;
|
|
12
|
+
|
|
13
|
+
export const DEFAULT_PRECISION = 6;
|
|
14
|
+
|
|
15
|
+
/** Coerce a SQL result cell (Postgres numerics arrive as strings) to number | null. */
|
|
16
|
+
export function toNumber(value: NumericLike): number | null {
|
|
17
|
+
if (value === null || value === undefined || value === "") return null;
|
|
18
|
+
const n = typeof value === "number" ? value : Number(value);
|
|
19
|
+
return Number.isFinite(n) ? n : null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function round(value: number | null, precision: number = DEFAULT_PRECISION): number | null {
|
|
23
|
+
if (value === null) return null;
|
|
24
|
+
const factor = 10 ** precision;
|
|
25
|
+
return Math.round(value * factor) / factor;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Null-safe division: null when either side is null or the denominator is 0. */
|
|
29
|
+
export function safeDiv(numerator: number | null, denominator: number | null): number | null {
|
|
30
|
+
if (numerator === null || denominator === null || denominator === 0) return null;
|
|
31
|
+
return numerator / denominator;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Compute registry-derived ratio metrics for one aggregated row. */
|
|
35
|
+
export function computeDerived(
|
|
36
|
+
row: Record<string, unknown>,
|
|
37
|
+
derived: DerivedDef[],
|
|
38
|
+
precision: number = DEFAULT_PRECISION,
|
|
39
|
+
): Record<string, number | null> {
|
|
40
|
+
const out: Record<string, number | null> = {};
|
|
41
|
+
for (const def of derived) {
|
|
42
|
+
const numerator = toNumber(row[def.numerator] as NumericLike);
|
|
43
|
+
const denominator = toNumber(row[def.denominator] as NumericLike);
|
|
44
|
+
const scaled = numerator === null ? null : numerator * (def.scale ?? 1);
|
|
45
|
+
out[def.name] = round(safeDiv(scaled, denominator), precision);
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
// Expression evaluator (used by fpa_calc). Supports + - * / ( ), unary minus,
|
|
52
|
+
// numeric literals, named variables, and abs/min/max/round. No eval().
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
type Token =
|
|
56
|
+
| { kind: "number"; value: number }
|
|
57
|
+
| { kind: "ident"; value: string }
|
|
58
|
+
| { kind: "op"; value: "+" | "-" | "*" | "/" }
|
|
59
|
+
| { kind: "lparen" }
|
|
60
|
+
| { kind: "rparen" }
|
|
61
|
+
| { kind: "comma" };
|
|
62
|
+
|
|
63
|
+
const IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*/;
|
|
64
|
+
const NUMBER_RE = /^(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?/;
|
|
65
|
+
|
|
66
|
+
function tokenize(formula: string): Token[] {
|
|
67
|
+
const tokens: Token[] = [];
|
|
68
|
+
let rest = formula;
|
|
69
|
+
while (rest.length > 0) {
|
|
70
|
+
const ws = rest.match(/^\s+/);
|
|
71
|
+
if (ws) {
|
|
72
|
+
rest = rest.slice(ws[0].length);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const num = rest.match(NUMBER_RE);
|
|
76
|
+
if (num) {
|
|
77
|
+
tokens.push({ kind: "number", value: Number(num[0]) });
|
|
78
|
+
rest = rest.slice(num[0].length);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
const ident = rest.match(IDENT_RE);
|
|
82
|
+
if (ident) {
|
|
83
|
+
tokens.push({ kind: "ident", value: ident[0] });
|
|
84
|
+
rest = rest.slice(ident[0].length);
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const ch = rest[0];
|
|
88
|
+
if (ch === "+" || ch === "-" || ch === "*" || ch === "/") {
|
|
89
|
+
tokens.push({ kind: "op", value: ch });
|
|
90
|
+
} else if (ch === "(") {
|
|
91
|
+
tokens.push({ kind: "lparen" });
|
|
92
|
+
} else if (ch === ")") {
|
|
93
|
+
tokens.push({ kind: "rparen" });
|
|
94
|
+
} else if (ch === ",") {
|
|
95
|
+
tokens.push({ kind: "comma" });
|
|
96
|
+
} else {
|
|
97
|
+
throw new Error(`Unexpected character "${ch}" in formula.`);
|
|
98
|
+
}
|
|
99
|
+
rest = rest.slice(1);
|
|
100
|
+
}
|
|
101
|
+
return tokens;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const FUNCTIONS: Record<string, { arity: [number, number]; apply: (args: Array<number | null>) => number | null }> = {
|
|
105
|
+
abs: {
|
|
106
|
+
arity: [1, 1],
|
|
107
|
+
apply: ([x]) => (x === null ? null : Math.abs(x)),
|
|
108
|
+
},
|
|
109
|
+
min: {
|
|
110
|
+
arity: [2, 8],
|
|
111
|
+
apply: (args) => (args.some((a) => a === null) ? null : Math.min(...(args as number[]))),
|
|
112
|
+
},
|
|
113
|
+
max: {
|
|
114
|
+
arity: [2, 8],
|
|
115
|
+
apply: (args) => (args.some((a) => a === null) ? null : Math.max(...(args as number[]))),
|
|
116
|
+
},
|
|
117
|
+
round: {
|
|
118
|
+
arity: [1, 2],
|
|
119
|
+
apply: ([x, p]) => {
|
|
120
|
+
if (x === null) return null;
|
|
121
|
+
const precision = p === null || p === undefined ? 0 : p;
|
|
122
|
+
if (!Number.isInteger(precision) || precision < 0 || precision > 12) {
|
|
123
|
+
throw new Error("round() precision must be an integer between 0 and 12.");
|
|
124
|
+
}
|
|
125
|
+
return round(x, precision);
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
class Parser {
|
|
131
|
+
private tokens: Token[];
|
|
132
|
+
private pos = 0;
|
|
133
|
+
private vars: Record<string, number | null>;
|
|
134
|
+
|
|
135
|
+
constructor(formula: string, vars: Record<string, number | null>) {
|
|
136
|
+
this.tokens = tokenize(formula);
|
|
137
|
+
this.vars = vars;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
parse(): number | null {
|
|
141
|
+
const value = this.expression();
|
|
142
|
+
if (this.pos < this.tokens.length) {
|
|
143
|
+
throw new Error("Unexpected trailing input in formula.");
|
|
144
|
+
}
|
|
145
|
+
return value;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
private peek(): Token | undefined {
|
|
149
|
+
return this.tokens[this.pos];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private next(): Token {
|
|
153
|
+
const token = this.tokens[this.pos];
|
|
154
|
+
if (!token) throw new Error("Formula ended unexpectedly.");
|
|
155
|
+
this.pos += 1;
|
|
156
|
+
return token;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
private expression(): number | null {
|
|
160
|
+
let left = this.term();
|
|
161
|
+
for (;;) {
|
|
162
|
+
const token = this.peek();
|
|
163
|
+
if (token?.kind !== "op" || (token.value !== "+" && token.value !== "-")) return left;
|
|
164
|
+
this.next();
|
|
165
|
+
const right = this.term();
|
|
166
|
+
if (left === null || right === null) {
|
|
167
|
+
left = null;
|
|
168
|
+
} else {
|
|
169
|
+
left = token.value === "+" ? left + right : left - right;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private term(): number | null {
|
|
175
|
+
let left = this.factor();
|
|
176
|
+
for (;;) {
|
|
177
|
+
const token = this.peek();
|
|
178
|
+
if (token?.kind !== "op" || (token.value !== "*" && token.value !== "/")) return left;
|
|
179
|
+
this.next();
|
|
180
|
+
const right = this.factor();
|
|
181
|
+
if (left === null || right === null) {
|
|
182
|
+
left = null;
|
|
183
|
+
} else if (token.value === "*") {
|
|
184
|
+
left = left * right;
|
|
185
|
+
} else {
|
|
186
|
+
left = right === 0 ? null : left / right;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
private factor(): number | null {
|
|
192
|
+
const token = this.peek();
|
|
193
|
+
if (token?.kind === "op" && token.value === "-") {
|
|
194
|
+
this.next();
|
|
195
|
+
const value = this.factor();
|
|
196
|
+
return value === null ? null : -value;
|
|
197
|
+
}
|
|
198
|
+
if (token?.kind === "op" && token.value === "+") {
|
|
199
|
+
this.next();
|
|
200
|
+
return this.factor();
|
|
201
|
+
}
|
|
202
|
+
return this.primary();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private primary(): number | null {
|
|
206
|
+
const token = this.next();
|
|
207
|
+
if (token.kind === "number") return token.value;
|
|
208
|
+
if (token.kind === "lparen") {
|
|
209
|
+
const value = this.expression();
|
|
210
|
+
const closing = this.next();
|
|
211
|
+
if (closing.kind !== "rparen") throw new Error("Expected ')' in formula.");
|
|
212
|
+
return value;
|
|
213
|
+
}
|
|
214
|
+
if (token.kind === "ident") {
|
|
215
|
+
if (this.peek()?.kind === "lparen") {
|
|
216
|
+
return this.functionCall(token.value);
|
|
217
|
+
}
|
|
218
|
+
if (!(token.value in this.vars)) {
|
|
219
|
+
const available = Object.keys(this.vars);
|
|
220
|
+
throw new Error(
|
|
221
|
+
`Unknown variable "${token.value}". Available: ${available.length > 0 ? available.join(", ") : "(none)"}.`,
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
return this.vars[token.value];
|
|
225
|
+
}
|
|
226
|
+
throw new Error("Unexpected token in formula.");
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
private functionCall(name: string): number | null {
|
|
230
|
+
const fn = FUNCTIONS[name];
|
|
231
|
+
if (!fn) {
|
|
232
|
+
throw new Error(`Unknown function "${name}". Available: ${Object.keys(FUNCTIONS).join(", ")}.`);
|
|
233
|
+
}
|
|
234
|
+
this.next(); // consume '('
|
|
235
|
+
const args: Array<number | null> = [];
|
|
236
|
+
if (this.peek()?.kind !== "rparen") {
|
|
237
|
+
args.push(this.expression());
|
|
238
|
+
while (this.peek()?.kind === "comma") {
|
|
239
|
+
this.next();
|
|
240
|
+
args.push(this.expression());
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
const closing = this.next();
|
|
244
|
+
if (closing.kind !== "rparen") throw new Error(`Expected ')' after ${name}(...).`);
|
|
245
|
+
const [minArity, maxArity] = fn.arity;
|
|
246
|
+
if (args.length < minArity || args.length > maxArity) {
|
|
247
|
+
throw new Error(`${name}() expects ${minArity}${maxArity > minArity ? `..${maxArity}` : ""} arguments.`);
|
|
248
|
+
}
|
|
249
|
+
return fn.apply(args);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Evaluate one formula against named values. Null inputs and /0 yield null. */
|
|
254
|
+
export function evaluateFormula(
|
|
255
|
+
formula: string,
|
|
256
|
+
vars: Record<string, number | null>,
|
|
257
|
+
): number | null {
|
|
258
|
+
if (typeof formula !== "string" || formula.trim().length === 0) {
|
|
259
|
+
throw new Error("Formula must be a non-empty string.");
|
|
260
|
+
}
|
|
261
|
+
if (formula.length > 2000) {
|
|
262
|
+
throw new Error("Formula exceeds the 2000-character limit.");
|
|
263
|
+
}
|
|
264
|
+
return new Parser(formula, vars).parse();
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export interface CalcExpression {
|
|
268
|
+
name: string;
|
|
269
|
+
formula: string;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export interface CalcResult {
|
|
273
|
+
name: string;
|
|
274
|
+
formula: string;
|
|
275
|
+
value: number | null;
|
|
276
|
+
error?: string;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** Evaluate expressions in order; later expressions can use earlier results. */
|
|
280
|
+
export function runCalc(
|
|
281
|
+
values: Record<string, NumericLike>,
|
|
282
|
+
expressions: CalcExpression[],
|
|
283
|
+
precision: number = DEFAULT_PRECISION,
|
|
284
|
+
): CalcResult[] {
|
|
285
|
+
const scope: Record<string, number | null> = {};
|
|
286
|
+
for (const [key, raw] of Object.entries(values)) {
|
|
287
|
+
if (!IDENT_RE.test(key) || key.match(IDENT_RE)?.[0] !== key) {
|
|
288
|
+
throw new Error(`Variable name "${key}" must be a valid identifier (letters, digits, underscore).`);
|
|
289
|
+
}
|
|
290
|
+
scope[key] = toNumber(raw);
|
|
291
|
+
}
|
|
292
|
+
const results: CalcResult[] = [];
|
|
293
|
+
for (const expr of expressions) {
|
|
294
|
+
if (!IDENT_RE.test(expr.name) || expr.name.match(IDENT_RE)?.[0] !== expr.name) {
|
|
295
|
+
throw new Error(`Expression name "${expr.name}" must be a valid identifier.`);
|
|
296
|
+
}
|
|
297
|
+
try {
|
|
298
|
+
const value = round(evaluateFormula(expr.formula, scope), precision);
|
|
299
|
+
scope[expr.name] = value;
|
|
300
|
+
results.push({ name: expr.name, formula: expr.formula, value });
|
|
301
|
+
} catch (error) {
|
|
302
|
+
results.push({
|
|
303
|
+
name: expr.name,
|
|
304
|
+
formula: expr.formula,
|
|
305
|
+
value: null,
|
|
306
|
+
error: error instanceof Error ? error.message : String(error),
|
|
307
|
+
});
|
|
308
|
+
scope[expr.name] = null;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return results;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ---------------------------------------------------------------------------
|
|
315
|
+
// Period comparison (used by fpa_compare).
|
|
316
|
+
// ---------------------------------------------------------------------------
|
|
317
|
+
|
|
318
|
+
export interface MetricComparison {
|
|
319
|
+
metric: string;
|
|
320
|
+
current: number | null;
|
|
321
|
+
baseline: number | null;
|
|
322
|
+
delta: number | null;
|
|
323
|
+
delta_pct: number | null;
|
|
324
|
+
/** Share of the total delta contributed by this row (base measures only). */
|
|
325
|
+
contribution_pct?: number | null;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
export interface RowComparison {
|
|
329
|
+
key: Record<string, unknown>;
|
|
330
|
+
metrics: MetricComparison[];
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function rowKey(row: Record<string, unknown>, dims: string[]): string {
|
|
334
|
+
return dims.map((d) => String(row[d] ?? "")).join("");
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function compareValues(current: number | null, baseline: number | null): {
|
|
338
|
+
delta: number | null;
|
|
339
|
+
delta_pct: number | null;
|
|
340
|
+
} {
|
|
341
|
+
if (current === null || baseline === null) return { delta: null, delta_pct: null };
|
|
342
|
+
const delta = current - baseline;
|
|
343
|
+
return { delta, delta_pct: baseline === 0 ? null : delta / Math.abs(baseline) };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Compare two aggregated result sets row-by-row on shared dimension values.
|
|
348
|
+
* Rows present on only one side compare against null. Contribution shares are
|
|
349
|
+
* computed per base measure against the total delta across all rows.
|
|
350
|
+
*/
|
|
351
|
+
export function comparePeriods(
|
|
352
|
+
currentRows: Array<Record<string, unknown>>,
|
|
353
|
+
baselineRows: Array<Record<string, unknown>>,
|
|
354
|
+
dims: string[],
|
|
355
|
+
metrics: string[],
|
|
356
|
+
baseMeasures: Set<string>,
|
|
357
|
+
precision: number = DEFAULT_PRECISION,
|
|
358
|
+
): RowComparison[] {
|
|
359
|
+
const baselineByKey = new Map(baselineRows.map((row) => [rowKey(row, dims), row]));
|
|
360
|
+
const seen = new Set<string>();
|
|
361
|
+
const paired: Array<{
|
|
362
|
+
key: Record<string, unknown>;
|
|
363
|
+
current: Record<string, unknown> | null;
|
|
364
|
+
baseline: Record<string, unknown> | null;
|
|
365
|
+
}> = [];
|
|
366
|
+
|
|
367
|
+
for (const row of currentRows) {
|
|
368
|
+
const key = rowKey(row, dims);
|
|
369
|
+
seen.add(key);
|
|
370
|
+
paired.push({ key: Object.fromEntries(dims.map((d) => [d, row[d]])), current: row, baseline: baselineByKey.get(key) ?? null });
|
|
371
|
+
}
|
|
372
|
+
for (const row of baselineRows) {
|
|
373
|
+
const key = rowKey(row, dims);
|
|
374
|
+
if (!seen.has(key)) {
|
|
375
|
+
paired.push({ key: Object.fromEntries(dims.map((d) => [d, row[d]])), current: null, baseline: row });
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const totalDelta = new Map<string, number>();
|
|
380
|
+
for (const metric of metrics) {
|
|
381
|
+
if (!baseMeasures.has(metric)) continue;
|
|
382
|
+
let total = 0;
|
|
383
|
+
for (const pair of paired) {
|
|
384
|
+
const current = toNumber(pair.current?.[metric] as NumericLike);
|
|
385
|
+
const baseline = toNumber(pair.baseline?.[metric] as NumericLike);
|
|
386
|
+
if (current !== null || baseline !== null) {
|
|
387
|
+
total += (current ?? 0) - (baseline ?? 0);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
totalDelta.set(metric, total);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
return paired.map((pair) => ({
|
|
394
|
+
key: pair.key,
|
|
395
|
+
metrics: metrics.map((metric) => {
|
|
396
|
+
const current = toNumber(pair.current?.[metric] as NumericLike);
|
|
397
|
+
const baseline = toNumber(pair.baseline?.[metric] as NumericLike);
|
|
398
|
+
const { delta, delta_pct } = compareValues(current, baseline);
|
|
399
|
+
const comparison: MetricComparison = {
|
|
400
|
+
metric,
|
|
401
|
+
current: round(current, precision),
|
|
402
|
+
baseline: round(baseline, precision),
|
|
403
|
+
delta: round(delta, precision),
|
|
404
|
+
delta_pct: round(delta_pct, precision),
|
|
405
|
+
};
|
|
406
|
+
if (baseMeasures.has(metric)) {
|
|
407
|
+
const total = totalDelta.get(metric) ?? 0;
|
|
408
|
+
const rowDelta =
|
|
409
|
+
current === null && baseline === null ? null : (current ?? 0) - (baseline ?? 0);
|
|
410
|
+
comparison.contribution_pct =
|
|
411
|
+
rowDelta === null || total === 0 ? null : round(rowDelta / total, precision);
|
|
412
|
+
}
|
|
413
|
+
return comparison;
|
|
414
|
+
}),
|
|
415
|
+
}));
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// ---------------------------------------------------------------------------
|
|
419
|
+
// Cohort result assembly (used by fpa_cohort).
|
|
420
|
+
// ---------------------------------------------------------------------------
|
|
421
|
+
|
|
422
|
+
export interface CohortHorizonMetrics {
|
|
423
|
+
horizon: number;
|
|
424
|
+
mature: boolean;
|
|
425
|
+
net_proceeds: number | null;
|
|
426
|
+
ltv: number | null;
|
|
427
|
+
retention: number | null;
|
|
428
|
+
roas: number | null;
|
|
429
|
+
reasons: string[];
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
export interface CohortRow {
|
|
433
|
+
bucket: string;
|
|
434
|
+
key: Record<string, unknown>;
|
|
435
|
+
install_days: number | null;
|
|
436
|
+
install_min: string;
|
|
437
|
+
install_max: string;
|
|
438
|
+
cohort_size: number | null;
|
|
439
|
+
days_missing_cohort_size: number | null;
|
|
440
|
+
spend: number | null;
|
|
441
|
+
ua_installs: number | null;
|
|
442
|
+
horizons: CohortHorizonMetrics[];
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
export function buildCohortRows(
|
|
446
|
+
rows: Array<Record<string, unknown>>,
|
|
447
|
+
spendRows: Array<Record<string, unknown>>,
|
|
448
|
+
groupBy: string[],
|
|
449
|
+
horizons: number[],
|
|
450
|
+
maxEventDate: string | null,
|
|
451
|
+
minEventDate: string | null = null,
|
|
452
|
+
precision: number = DEFAULT_PRECISION,
|
|
453
|
+
): CohortRow[] {
|
|
454
|
+
const keyOf = (row: Record<string, unknown>) =>
|
|
455
|
+
["bucket", ...groupBy].map((k) => String(row[k] ?? "")).join("");
|
|
456
|
+
const spendByKey = new Map(spendRows.map((row) => [keyOf(row), row]));
|
|
457
|
+
|
|
458
|
+
return rows.map((row) => {
|
|
459
|
+
const spendRow = spendByKey.get(keyOf(row)) ?? null;
|
|
460
|
+
const cohortSize = toNumber(row.cohort_size as NumericLike);
|
|
461
|
+
const missingSizeDays = toNumber(row.days_missing_cohort_size as NumericLike) ?? 0;
|
|
462
|
+
const installDays = toNumber(row.install_days as NumericLike);
|
|
463
|
+
const spend = toNumber(spendRow?.spend as NumericLike);
|
|
464
|
+
const spendDays = toNumber(spendRow?.spend_days as NumericLike);
|
|
465
|
+
const installMax = String(row.install_max ?? "");
|
|
466
|
+
|
|
467
|
+
const installMin = String(row.install_min ?? "");
|
|
468
|
+
|
|
469
|
+
const horizonMetrics = horizons.map((n): CohortHorizonMetrics => {
|
|
470
|
+
const reasons: string[] = [];
|
|
471
|
+
const net = toNumber(row[`net_d${n}`] as NumericLike);
|
|
472
|
+
const active = toNumber(row[`active_d${n}`] as NumericLike);
|
|
473
|
+
|
|
474
|
+
if (minEventDate && installMin && minEventDate > installMin) {
|
|
475
|
+
reasons.push(
|
|
476
|
+
`event data only starts ${minEventDate}, after the first install date ${installMin}; ` +
|
|
477
|
+
"the source is a rolling activity window, so horizon revenue for these cohorts is incomplete and all horizon metrics are NULL",
|
|
478
|
+
);
|
|
479
|
+
return { horizon: n, mature: false, net_proceeds: null, ltv: null, retention: null, roas: null, reasons };
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
let mature = false;
|
|
483
|
+
if (maxEventDate && installMax) {
|
|
484
|
+
const cutoff = new Date(`${installMax}T00:00:00Z`);
|
|
485
|
+
cutoff.setUTCDate(cutoff.getUTCDate() + n);
|
|
486
|
+
mature = cutoff <= new Date(`${maxEventDate}T00:00:00Z`);
|
|
487
|
+
}
|
|
488
|
+
if (!mature) {
|
|
489
|
+
reasons.push(
|
|
490
|
+
`immature: install_max ${installMax} + ${n}d exceeds the latest event_date ${maxEventDate ?? "(unknown)"}; per core rules the horizon metrics are NULL`,
|
|
491
|
+
);
|
|
492
|
+
return { horizon: n, mature, net_proceeds: null, ltv: null, retention: null, roas: null, reasons };
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
let ltv: number | null = null;
|
|
496
|
+
let retention: number | null = null;
|
|
497
|
+
if (missingSizeDays > 0 || cohortSize === null || cohortSize === 0) {
|
|
498
|
+
reasons.push(
|
|
499
|
+
missingSizeDays > 0
|
|
500
|
+
? `cohort size missing for ${missingSizeDays} install day(s); LTV/retention denominators are NULL`
|
|
501
|
+
: "cohort size is NULL or zero; LTV/retention are NULL",
|
|
502
|
+
);
|
|
503
|
+
} else {
|
|
504
|
+
ltv = safeDiv(net, cohortSize);
|
|
505
|
+
retention = safeDiv(active, cohortSize);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
let roas: number | null = null;
|
|
509
|
+
if (spend === null || spend === 0) {
|
|
510
|
+
reasons.push("no UA spend rows for this bucket; ROAS is NULL");
|
|
511
|
+
} else {
|
|
512
|
+
if (spendDays !== null && installDays !== null && spendDays < installDays) {
|
|
513
|
+
reasons.push(
|
|
514
|
+
`spend covers only ${spendDays}/${installDays} install day(s); ROAS may be overstated`,
|
|
515
|
+
);
|
|
516
|
+
}
|
|
517
|
+
roas = safeDiv(net, spend);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
return {
|
|
521
|
+
horizon: n,
|
|
522
|
+
mature,
|
|
523
|
+
net_proceeds: round(net, precision),
|
|
524
|
+
ltv: round(ltv, precision),
|
|
525
|
+
retention: round(retention, precision),
|
|
526
|
+
roas: round(roas, precision),
|
|
527
|
+
reasons,
|
|
528
|
+
};
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
return {
|
|
532
|
+
bucket: String(row.bucket ?? ""),
|
|
533
|
+
key: Object.fromEntries(groupBy.map((k) => [k, row[k]])),
|
|
534
|
+
install_days: installDays,
|
|
535
|
+
install_min: installMin,
|
|
536
|
+
install_max: installMax,
|
|
537
|
+
cohort_size: cohortSize,
|
|
538
|
+
days_missing_cohort_size: missingSizeDays,
|
|
539
|
+
spend: round(spend, precision),
|
|
540
|
+
ua_installs: toNumber(spendRow?.ua_installs as NumericLike),
|
|
541
|
+
horizons: horizonMetrics,
|
|
542
|
+
};
|
|
543
|
+
});
|
|
544
|
+
}
|