Import AI_Companion_Financial_Model source files

This commit is contained in:
2026-11-05 00:07:10 +08:00
commit 93e2440ede
2 changed files with 363 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
.DS_Store
node_modules/
/AI_Companion_Infrastructure_Cost_Model.xlsx
/previews/
/support/verification.json
/support/workbook-inspect.ndjson
+357
View File
@@ -0,0 +1,357 @@
import fs from "node:fs/promises";
import { SpreadsheetFile, Workbook } from "@oai/artifact-tool";
const projectDir = "/Users/zzyang/ProjectAI/AI_Companion_Financial_Model";
const outputDir = projectDir;
const previewDir = `${projectDir}/previews`;
const supportDir = `${projectDir}/support`;
const outputPath = `${outputDir}/AI_Companion_Infrastructure_Cost_Model.xlsx`;
const font = "Arial";
const navy = "#17365D", blue = "#1F4E78", midBlue = "#5B9BD5", lightBlue = "#D9EAF7";
const green = "#2F6B4F", lightGreen = "#E2F0D9", amber = "#FFF2CC";
const orange = "#ED7D31", red = "#C00000", gray = "#6B7280";
await fs.mkdir(outputDir, { recursive: true });
await fs.mkdir(previewDir, { recursive: true });
await fs.mkdir(supportDir, { recursive: true });
const workbook = Workbook.create();
const summary = workbook.worksheets.add("Summary");
const cost = workbook.worksheets.add("Cost Model");
const revenue = workbook.worksheets.add("Revenue Model");
const assumptions = workbook.worksheets.add("Assumptions");
summary.tabColor = navy; cost.tabColor = blue; revenue.tabColor = green; assumptions.tabColor = midBlue;
for (const sheet of [summary, cost, revenue, assumptions]) sheet.showGridLines = false;
function title(sheet, range, textValue) {
sheet.getRange(range).merge();
sheet.getRange(range.split(":")[0]).values = [[textValue]];
sheet.getRange(range).format.font = { name: font, size: 15, bold: true, color: navy };
}
function section(sheet, range, textValue) {
sheet.getRange(range).merge();
sheet.getRange(range.split(":")[0]).values = [[textValue]];
sheet.getRange(range).format = { fill: lightBlue, font: { name: font, size: 10, bold: true, color: navy } };
}
// Assumptions contains only externally verifiable prices and units.
title(assumptions, "A2:E2", "Sourced infrastructure prices");
assumptions.getRange("A3:E3").format.borders = { bottom: { style: "thin", color: "#9EADBA" } };
assumptions.getRange("A4:E4").merge();
assumptions.getRange("A4").values = [["Only sourced prices belong here. User behavior, growth, retention and workload estimates are modeled as explicit scenarios on the relevant model pages."]];
assumptions.getRange("A4:E4").format = { font: { name: font, size: 10, italic: true, color: gray }, wrapText: true };
assumptions.getRange("A6:E6").values = [["Price input", "Value", "Unit", "Scope / date", "Source"]];
assumptions.getRange("A6:E6").format = { fill: navy, font: { name: font, size: 10, bold: true, color: "#FFFFFF" }, horizontalAlignment: "center" };
assumptions.getRange("A7:E12").values = [
["CloudFront / data egress", 0.11, "USD per GB", "Tokyo planning rate, 2026-10-06", "AWS Pricing Calculator"],
["S3 blended storage", 0.025, "USD per GB-month", "Tokyo planning rate, 2026-10-06", "AWS Pricing Calculator"],
["EBS gp3", 0.096, "USD per GB-month", "Tokyo planning rate, 2026-10-06", "AWS Pricing Calculator"],
["t4g.medium", 31.54, "USD per instance-month", "Linux on-demand, 730 hours", "AWS Pricing Calculator"],
["g6.xlarge", 852.06, "USD per instance-month", "Linux on-demand, 730 hours", "AWS Pricing Calculator"],
["g6e.xlarge", 1970.27, "USD per instance-month", "Linux on-demand, 730 hours", "AWS Pricing Calculator"],
];
assumptions.getRange("A7:E12").format.font = { name: font, size: 10 };
assumptions.getRange("B7:B12").format = { fill: amber, font: { name: font, size: 10, color: "#0000FF" } };
assumptions.getRange("B7:B9").setNumberFormat("$0.000");
assumptions.getRange("B10:B12").setNumberFormat("$#,##0.00");
assumptions.getRange("A6:E12").format.borders = { insideHorizontal: { style: "thin", color: "#E6E6E6" }, bottom: { style: "thin", color: "#9EADBA" } };
section(assumptions, "A15:E15", "Source links and interpretation");
assumptions.getRange("A16:E20").values = [
["AWS calculator", "https://calculator.aws/", null, null, null],
["EC2 accelerated instances", "https://aws.amazon.com/ec2/instance-types/accelerated-computing/", null, null, null],
["Revenue benchmark", "https://www.revenuecat.com/state-of-subscription-apps", null, null, null],
["Pricing limitation", "Rates are sourced inputs, but still vary by region, commitment, taxes and future AWS price changes. Refresh them before an investment or procurement decision.", null, null, null],
["Model boundary", "No Planning DAU, conversion rate, reply frequency or retention assumption is stored on this page.", null, null, null],
];
assumptions.getRange("B16:E20").merge(true);
assumptions.getRange("A16:A20").format.font = { name: font, size: 9, bold: true, color: navy };
assumptions.getRange("B16:E20").format = { font: { name: font, size: 9, color: gray }, wrapText: true };
assumptions.getRange("A:A").format.columnWidth = 32; assumptions.getRange("B:B").format.columnWidth = 20;
assumptions.getRange("C:C").format.columnWidth = 24; assumptions.getRange("D:D").format.columnWidth = 32; assumptions.getRange("E:E").format.columnWidth = 58;
assumptions.getRange("16:20").format.rowHeight = 34;
// Cost Model: scenario assumptions, capacity estimates, charts, and detailed build on one sheet.
title(cost, "A2:V2", "Infrastructure cost model");
cost.getRange("A3:V3").format.borders = { bottom: { style: "thin", color: "#9EADBA" } };
cost.getRange("A4:V4").merge();
cost.getRange("A4").values = [["The x-axis is DAU. Each line isolates one reply-frequency assumption while speech share, duration, bitrate, other traffic and retention remain fixed."]];
cost.getRange("A4:V4").format = { font: { name: font, size: 10, italic: true, color: gray } };
cost.getRange("A6:G6").values = [["Reply-frequency line", "AI replies / active user / day", "Replies with speech", "Speech seconds", "Audio bitrate kbps", "Other egress MB / DAU", "Audio retention months"]];
cost.getRange("A6:G6").format = { fill: navy, font: { name: font, size: 9, bold: true, color: "#FFFFFF" }, horizontalAlignment: "center", wrapText: true };
const replyLevels = [5, 15, 30, 60, 120, 240, 480];
const usageScenarios = replyLevels.map(v => `${v} replies/user/day`);
cost.getRange("A7:G13").values = replyLevels.map((v, i) => [usageScenarios[i], v, 0.60, 10, 64, 100, 3]);
cost.getRange("A7:G13").format = { fill: amber, font: { name: font, size: 9, color: "#0000FF" } };
cost.getRange("C7:C13").setNumberFormat("0.0%");
cost.getRange("A6:G13").format.borders = { insideHorizontal: { style: "thin", color: "#E6E6E6" }, bottom: { style: "thin", color: "#9EADBA" } };
cost.getRange("I6:U6").values = [["DAU threshold", "Service tier", "App nodes", "Worker nodes", "LLM GPU type", "LLM GPUs", "LLM unit cost", "TTS GPUs", "TTS unit cost", "Database and backup", "Cache", "Network and observability", "EBS GB"]];
cost.getRange("I6:U6").format = { fill: blue, font: { name: font, size: 9, bold: true, color: "#FFFFFF" }, horizontalAlignment: "center", wrapText: true };
cost.getRange("I7:U11").values = [
[0, "Launch", 1, 1, "g6.xlarge", 1, 852.06, 1, 852.06, 120, 0, 120, 500],
[300, "Validation", 1, 1, "g6.xlarge", 1, 852.06, 1, 852.06, 140, 20, 145, 600],
[1000, "Production I", 2, 2, "g6e.xlarge", 2, 1970.27, 2, 852.06, 500, 80, 300, 1200],
[2500, "Production II", 2, 3, "g6e.xlarge", 3, 1970.27, 3, 852.06, 600, 120, 380, 1800],
[4000, "Scale", 4, 4, "g6e.xlarge", 4, 1970.27, 4, 852.06, 800, 200, 520, 2400],
];
cost.getRange("I7:U11").format = { fill: "#FFFDF2", font: { name: font, size: 9, color: "#0000FF" } };
cost.getRange("O7:O11").setNumberFormat("$#,##0.00"); cost.getRange("Q7:Q11").setNumberFormat("$#,##0.00"); cost.getRange("R7:T11").setNumberFormat("$#,##0");
cost.getRange("I6:U11").format.borders = { insideHorizontal: { style: "thin", color: "#D9E2F3" }, insideVertical: { style: "thin", color: "#EEF2F7" }, bottom: { style: "thin", color: "#9EADBA" } };
cost.getRange("A15:U15").merge();
cost.getRange("A15").values = [["Capacity tiers are engineering estimates. Replace GPU replicas and tier thresholds after throughput, latency and peak-concurrency tests."]];
cost.getRange("A15:U15").format = { font: { name: font, size: 9, italic: true, color: gray } };
cost.getRange("A16:U17").merge();
cost.getRange("A16").values = [["Unit note: the input is replies per user per day, not thousands. At 1,000 DAU, 15 replies/user/day becomes 15,000 total replies/day = 15k. These lines currently change audio storage and egress; GPU replicas remain controlled by the capacity tier until token throughput, peak concurrency and TTS real-time factor are benchmarked."]];
cost.getRange("A16:U17").format = { fill: lightBlue, font: { name: font, size: 10, bold: true, color: navy }, wrapText: true };
const dauSet = new Set();
for (let dau = 0; dau <= 5000; dau += 100) dauSet.add(dau);
for (const dau of [299, 301, 999, 1001, 2499, 2501, 3999, 4001]) dauSet.add(dau);
const dauPoints = [...dauSet].sort((a, b) => a - b);
const costHeader = 65, costFirst = 66, pointsPerScenario = dauPoints.length, costLast = costFirst + pointsPerScenario * usageScenarios.length - 1;
cost.getRange(`A${costHeader}:O${costHeader}`).values = [["DAU", "Usage scenario", "Service tier", "App and worker", "LLM GPU", "TTS GPU", "DB, cache and network", "EBS", "Monthly audio GB", "S3", "Egress", "Monthly infrastructure", "Annual run rate", "Cost per DAU", "Modeled range"]];
cost.getRange(`A${costHeader}:O${costHeader}`).format = { fill: navy, font: { name: font, size: 9, bold: true, color: "#FFFFFF" }, horizontalAlignment: "center", wrapText: true };
const costKeys = [];
for (const scenario of usageScenarios) for (const dau of dauPoints) costKeys.push([dau, scenario]);
cost.getRange(`A${costFirst}:B${costLast}`).values = costKeys;
for (let r = costFirst; r <= costLast; r++) {
cost.getRange(`C${r}:O${r}`).formulas = [[
`=VLOOKUP(A${r},$I$7:$U$11,2,TRUE)`, `=(VLOOKUP(A${r},$I$7:$U$11,3,TRUE)+VLOOKUP(A${r},$I$7:$U$11,4,TRUE))*Assumptions!$B$10`,
`=VLOOKUP(A${r},$I$7:$U$11,6,TRUE)*VLOOKUP(A${r},$I$7:$U$11,7,TRUE)`, `=VLOOKUP(A${r},$I$7:$U$11,8,TRUE)*VLOOKUP(A${r},$I$7:$U$11,9,TRUE)`,
`=SUM(VLOOKUP(A${r},$I$7:$U$11,10,TRUE),VLOOKUP(A${r},$I$7:$U$11,11,TRUE),VLOOKUP(A${r},$I$7:$U$11,12,TRUE))`, `=VLOOKUP(A${r},$I$7:$U$11,13,TRUE)*Assumptions!$B$9`,
`=A${r}*VLOOKUP(B${r},$A$7:$G$13,2,FALSE)*30.4*VLOOKUP(B${r},$A$7:$G$13,3,FALSE)*VLOOKUP(B${r},$A$7:$G$13,4,FALSE)*VLOOKUP(B${r},$A$7:$G$13,5,FALSE)/8/1024/1024`,
`=(300+I${r}*VLOOKUP(B${r},$A$7:$G$13,7,FALSE))*Assumptions!$B$8`, `=(I${r}+A${r}*VLOOKUP(B${r},$A$7:$G$13,6,FALSE)/1024)*Assumptions!$B$7`,
`=SUM(D${r}:K${r})`, `=L${r}*12`, `=IF(A${r}=0,\"\",L${r}/A${r})`, `=IF(A${r}<=5000,1,0)`,
]];
}
cost.getRange(`A${costFirst}:O${costLast}`).format.font = { name: font, size: 8 };
cost.getRange(`D${costFirst}:H${costLast}`).setNumberFormat("$#,##0.00"); cost.getRange(`I${costFirst}:I${costLast}`).setNumberFormat("#,##0.0");
cost.getRange(`J${costFirst}:N${costLast}`).setNumberFormat("$#,##0.00");
cost.getRange(`A${costHeader}:O${costLast}`).format.borders = { insideHorizontal: { style: "thin", color: "#E6E6E6" } };
const scenarioStarts = usageScenarios.map((_, i) => costFirst + i * pointsPerScenario);
const baseScenarioIndex = replyLevels.indexOf(15);
const baseFirst = scenarioStarts[baseScenarioIndex], baseLast = baseFirst + pointsPerScenario - 1;
cost.getRange("W1:AD1").values = [["DAU", ...usageScenarios]];
cost.getRange("AF1:AM1").values = [["DAU", ...usageScenarios]];
for (let i = 0; i < pointsPerScenario; i++) {
const r = 2 + i;
cost.getRange(`W${r}:AD${r}`).formulas = [[`=A${scenarioStarts[0]+i}`, ...scenarioStarts.map(s => `=L${s+i}`)]];
cost.getRange(`AF${r}:AM${r}`).formulas = [[`=A${scenarioStarts[0]+i}`, ...scenarioStarts.map(s => `=N${s+i}`)]];
}
const costCurve = cost.charts.add("line", cost.getRange(`W1:AD${1+pointsPerScenario}`));
costCurve.title = "Monthly cost vs DAU by replies per active user (USD)";
costCurve.titleTextStyle.typeface = font; costCurve.titleTextStyle.fontSize = 12;
costCurve.legend = { position: "top", textStyle: { typeface: font, fontSize: 8 } };
costCurve.xAxis = { axisType: "textAxis", tickLabelInterval: 5, textStyle: { typeface: font, fontSize: 9 } };
costCurve.yAxis = { numberFormatCode: "$#,##0", numberFormatSourceLinked: false, textStyle: { typeface: font, fontSize: 9 } };
costCurve.setPosition("A20", "J36");
const scenarioColors = ["#5B9BD5", "#70AD47", "#A5A5A5", "#1F4E78", "#FFC000", "#ED7D31", "#C00000"];
for (let i = 0; i < costCurve.series.items.length; i++) costCurve.series.items[i].line = { fill: scenarioColors[i], style: "solid", width: 2 };
const unitCurve = cost.charts.add("line", cost.getRange(`AF1:AM${1+pointsPerScenario}`));
unitCurve.title = "Infrastructure cost per DAU by replies per active user (USD)";
unitCurve.titleTextStyle.typeface = font; unitCurve.titleTextStyle.fontSize = 12;
unitCurve.legend = { position: "top", textStyle: { typeface: font, fontSize: 8 } };
unitCurve.xAxis = { axisType: "textAxis", tickLabelInterval: 5, textStyle: { typeface: font, fontSize: 9 } };
unitCurve.yAxis = { numberFormatCode: "$0.00", numberFormatSourceLinked: false, textStyle: { typeface: font, fontSize: 9 } };
unitCurve.setPosition("K20", "U36");
for (let i = 0; i < unitCurve.series.items.length; i++) unitCurve.series.items[i].line = { fill: scenarioColors[i], style: "solid", width: 2 };
const launchZoomCount = dauPoints.filter(v => v <= 1000).length;
const launchCostCurve = cost.charts.add("line", cost.getRange(`W1:AD${1+launchZoomCount}`));
launchCostCurve.title = "Launch stage 0–1,000 DAU: monthly cost (USD)";
launchCostCurve.titleTextStyle.typeface = font; launchCostCurve.titleTextStyle.fontSize = 12;
launchCostCurve.legend = { position: "top", textStyle: { typeface: font, fontSize: 8 } };
launchCostCurve.xAxis = { axisType: "textAxis", tickLabelInterval: 1, textStyle: { typeface: font, fontSize: 9 } };
launchCostCurve.yAxis = { numberFormatCode: "$#,##0", numberFormatSourceLinked: false, textStyle: { typeface: font, fontSize: 9 } };
launchCostCurve.setPosition("A45", "J61");
for (let i = 0; i < launchCostCurve.series.items.length; i++) launchCostCurve.series.items[i].line = { fill: scenarioColors[i], style: "solid", width: 2 };
const launchUnitCurve = cost.charts.add("line", cost.getRange(`AF1:AM${1+launchZoomCount}`));
launchUnitCurve.title = "Launch stage 0–1,000 DAU: cost per DAU (USD)";
launchUnitCurve.titleTextStyle.typeface = font; launchUnitCurve.titleTextStyle.fontSize = 12;
launchUnitCurve.legend = { position: "top", textStyle: { typeface: font, fontSize: 8 } };
launchUnitCurve.xAxis = { axisType: "textAxis", tickLabelInterval: 1, textStyle: { typeface: font, fontSize: 9 } };
launchUnitCurve.yAxis = { numberFormatCode: "$0.00", numberFormatSourceLinked: false, textStyle: { typeface: font, fontSize: 9 } };
launchUnitCurve.setPosition("K45", "U61");
for (let i = 0; i < launchUnitCurve.series.items.length; i++) launchUnitCurve.series.items[i].line = { fill: scenarioColors[i], style: "solid", width: 2 };
section(cost, "A39:U39", "How to read the curves");
cost.getRange("A40:U43").values = [
["Single-variable sensitivity", "Only reply frequency changes across the seven curves: 5, 15, 30, 60, 120, 240 and 480 replies per active user per day. Speech share, duration, bitrate, other egress and retention remain fixed.", null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null],
["Public ChatGPT anchor", "OpenAI and NBER reported more than 2.5 billion messages per day and more than 700 million weekly users in July 2025. This implies about 3.6 messages per weekly user per calendar day, but it is not a DAU-based or AI-companion reply benchmark. The official paper does not publish an absolute replies-per-DAU figure. Source: OpenAI / NBER, How People Use ChatGPT.", null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null],
["GPU limitation", "Reply frequency currently changes storage and egress. GPU counts remain capacity-tier estimates until token throughput, peak concurrency and TTS real-time factor are benchmarked.", null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null],
["2C4G interpretation", "A t4g.medium is only the CPU application or worker layer. At the 1,000-DAU threshold the model uses 2 App nodes plus 2 Worker nodes, while LLM and TTS inference run on separate GPU instances.", null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null],
];
cost.getRange("B40:U43").merge(true); cost.getRange("A40:A43").format.font = { name: font, size: 9, bold: true, color: navy };
cost.getRange("B40:U43").format = { font: { name: font, size: 9, color: gray }, wrapText: true };
cost.getRange("40:43").format.rowHeight = 30;
cost.getRange("A:A").format.columnWidth = 15; cost.getRange("B:B").format.columnWidth = 18; cost.getRange("C:U").format.columnWidth = 14;
cost.getRange("6:6").format.rowHeight = 40; cost.getRange(`${costHeader}:${costHeader}`).format.rowHeight = 40; cost.freezePanes.freezeRows(costHeader);
// Revenue scenarios live on their model page, not on the sourced-price page.
title(revenue, "A2:T2", "User and revenue scenario model");
revenue.getRange("A3:T3").format.borders = { bottom: { style: "thin", color: "#9EADBA" } };
revenue.getRange("A4").values = [["Active case"]]; revenue.getRange("B4").values = [[2]];
revenue.getRange("B4").dataValidation = { rule: { type: "list", values: [1, 2, 3] } };
revenue.getRange("C4").formulas = [["=CHOOSE(B4,\"Downside\",\"Base\",\"Upside\")"]];
revenue.getRange("A4:C4").format.font = { name: font, size: 11, bold: true, color: navy }; revenue.getRange("B4").format.fill = amber;
const revParams = ["Scenario", "Starting marketing", "Spend growth", "CPI", "Organic uplift", "Download-to-register", "Register-to-activation", "MAU churn", "DAU/MAU", "D35 paid", "Paid churn", "ARPPU", "Refund rate", "Channel fee"];
revenue.getRange("A6:N6").values = [revParams];
revenue.getRange("A6:N6").format = { fill: blue, font: { name: font, size: 9, bold: true, color: "#FFFFFF" }, horizontalAlignment: "center", wrapText: true };
revenue.getRange("A7:N9").values = [
["Downside", 5000, 0, 5, 0.10, 0.45, 0.40, 0.45, 0.15, 0.01, 0.25, 12, 0.08, 0.15],
["Base", 10000, 0.03, 3, 0.25, 0.60, 0.55, 0.30, 0.25, 0.024, 0.20, 18.92, 0.042, 0.10],
["Upside", 15000, 0.06, 2, 0.50, 0.75, 0.70, 0.20, 0.35, 0.05, 0.12, 25, 0.025, 0.06],
];
revenue.getRange("A7:N9").format = { fill: amber, font: { name: font, size: 9, color: "#0000FF" } };
revenue.getRange("B7:B9").setNumberFormat("$#,##0"); revenue.getRange("D7:D9").setNumberFormat("$0.00"); revenue.getRange("L7:L9").setNumberFormat("$0.00");
for (const col of ["C", "E", "F", "G", "H", "I", "J", "K", "M", "N"]) revenue.getRange(`${col}7:${col}9`).setNumberFormat("0.0%");
revenue.getRange("A10:N10").merge();
revenue.getRange("A10").values = [["Blue-font yellow cells are estimates. RevenueCat 2026 informs Base D35 paid conversion (2.4%), refund rate (4.2%), month-one revenue per payer ($18.92) and the paid-churn proxy; all other values are management estimates."]];
revenue.getRange("A10:N10").format = { font: { name: font, size: 9, italic: true, color: gray }, wrapText: true };
const revHeader = 13, revFirst = 14, months = 36, scenarios = ["Downside", "Base", "Upside"], revLast = revFirst + months * 3 - 1;
revenue.getRange(`A${revHeader}:T${revHeader}`).values = [["Month", "Scenario", "Marketing spend", "Paid downloads", "Total downloads", "New activated MAU", "Ending MAU", "DAU", "New paid", "Churned paid", "Ending paid", "Gross billings", "Net revenue", "Infrastructure", "Gross profit", "Gross margin", "CAC", "Net revenue / avg paid", "Contribution / avg paid", "Simple LTV"]];
revenue.getRange(`A${revHeader}:T${revHeader}`).format = { fill: navy, font: { name: font, size: 9, bold: true, color: "#FFFFFF" }, horizontalAlignment: "center", wrapText: true };
const revKeys = [];
for (const scenario of scenarios) for (let m = 1; m <= months; m++) revKeys.push([m, scenario]);
revenue.getRange(`A${revFirst}:B${revLast}`).values = revKeys;
for (let r = revFirst; r <= revLast; r++) {
const first = r === revFirst || r === revFirst + months || r === revFirst + months * 2;
const prevMau = first ? "0" : `G${r-1}`, prevPaid = first ? "0" : `K${r-1}`;
const avgPaid = `((${prevPaid})+K${r})/2`;
revenue.getRange(`C${r}:T${r}`).formulas = [[
`=VLOOKUP(B${r},$A$7:$N$9,2,FALSE)*(1+VLOOKUP(B${r},$A$7:$N$9,3,FALSE))^(A${r}-1)`, `=C${r}/VLOOKUP(B${r},$A$7:$N$9,4,FALSE)`,
`=D${r}*(1+VLOOKUP(B${r},$A$7:$N$9,5,FALSE))`, `=E${r}*VLOOKUP(B${r},$A$7:$N$9,6,FALSE)*VLOOKUP(B${r},$A$7:$N$9,7,FALSE)`,
`=${prevMau}*(1-VLOOKUP(B${r},$A$7:$N$9,8,FALSE))+F${r}`, `=G${r}*VLOOKUP(B${r},$A$7:$N$9,9,FALSE)`, `=E${r}*VLOOKUP(B${r},$A$7:$N$9,10,FALSE)`,
`=${prevPaid}*VLOOKUP(B${r},$A$7:$N$9,11,FALSE)`, `=${prevPaid}+I${r}-J${r}`, `=${avgPaid}*VLOOKUP(B${r},$A$7:$N$9,12,FALSE)`,
`=L${r}*(1-VLOOKUP(B${r},$A$7:$N$9,13,FALSE))*(1-VLOOKUP(B${r},$A$7:$N$9,14,FALSE))`,
`=VLOOKUP(MIN(H${r},5000),'Cost Model'!$A$${baseFirst}:$L$${baseLast},12,TRUE)`, `=M${r}-N${r}`, `=IF(M${r}=0,\"\",O${r}/M${r})`,
`=IF(I${r}=0,\"\",C${r}/I${r})`, `=IF(${avgPaid}=0,\"\",M${r}/${avgPaid})`, `=IF(${avgPaid}=0,\"\",O${r}/${avgPaid})`,
`=IF(VLOOKUP(B${r},$A$7:$N$9,11,FALSE)=0,\"\",MAX(0,S${r})/VLOOKUP(B${r},$A$7:$N$9,11,FALSE))`,
]];
}
revenue.getRange(`A${revFirst}:T${revLast}`).format.font = { name: font, size: 8 };
revenue.getRange(`C${revFirst}:C${revLast}`).setNumberFormat("$#,##0"); revenue.getRange(`D${revFirst}:K${revLast}`).setNumberFormat("#,##0");
revenue.getRange(`L${revFirst}:O${revLast}`).setNumberFormat("$#,##0"); revenue.getRange(`P${revFirst}:P${revLast}`).setNumberFormat("0.0%"); revenue.getRange(`Q${revFirst}:T${revLast}`).setNumberFormat("$#,##0.00");
revenue.getRange(`H${revFirst}:H${revLast}`).conditionalFormats.add("cellIs", { operator: "greaterThan", formula: 5000, format: { fill: "#FCE4D6", font: { color: red, bold: true } } });
revenue.getRange(`A${revHeader}:T${revLast}`).format.borders = { insideHorizontal: { style: "thin", color: "#E6E6E6" } };
revenue.getRange("V1:Y1").values = [["Month", "Downside", "Base", "Upside"]];
for (let m = 1; m <= months; m++) {
const r = m + 1; revenue.getRange(`V${r}`).values = [[m]];
revenue.getRange(`W${r}:Y${r}`).formulas = [[
`=SUMIFS($M$${revFirst}:$M$${revLast},$A$${revFirst}:$A$${revLast},V${r},$B$${revFirst}:$B$${revLast},W$1)`,
`=SUMIFS($M$${revFirst}:$M$${revLast},$A$${revFirst}:$A$${revLast},V${r},$B$${revFirst}:$B$${revLast},X$1)`,
`=SUMIFS($M$${revFirst}:$M$${revLast},$A$${revFirst}:$A$${revLast},V${r},$B$${revFirst}:$B$${revLast},Y$1)`,
]];
}
const revChart = revenue.charts.add("line", revenue.getRange("V1:Y37"));
revChart.title = "Monthly net revenue by scenario (USD)"; revChart.titleTextStyle.typeface = font; revChart.titleTextStyle.fontSize = 12;
revChart.legend = { position: "top", textStyle: { typeface: font, fontSize: 9 } }; revChart.xAxis = { axisType: "textAxis", tickLabelInterval: 3, textStyle: { typeface: font, fontSize: 9 } };
revChart.yAxis = { numberFormatCode: "$#,##0", numberFormatSourceLinked: false, textStyle: { typeface: font, fontSize: 9 } }; revChart.setPosition("V40", "AG56");
for (let i = 0; i < revChart.series.items.length; i++) revChart.series.items[i].line = { fill: [red, blue, green][i], style: "solid", width: 2 };
revenue.getRange("A:A").format.columnWidth = 16; revenue.getRange("B:B").format.columnWidth = 13; revenue.getRange("C:T").format.columnWidth = 15;
revenue.getRange("6:6").format.rowHeight = 38; revenue.getRange("10:10").format.rowHeight = 34; revenue.getRange(`${revHeader}:${revHeader}`).format.rowHeight = 42;
revenue.freezePanes.freezeRows(revHeader); revenue.freezePanes.freezeColumns(2);
// Summary and live scenario comparison.
title(summary, "A2:P2", "AI companion investor summary");
summary.getRange("A3:P3").format.borders = { bottom: { style: "thin", color: "#9EADBA" } };
summary.getRange("A4").values = [["Active revenue case"]]; summary.getRange("B4").formulas = [["='Revenue Model'!C4"]]; summary.getRange("B4").format.fill = lightGreen;
summary.getRange("A4:B4").format.font = { name: font, size: 11, bold: true, color: navy };
summary.getRange("A6:H6").values = [["Year 1 net revenue", null, "Month 12 DAU", null, "Month 12 paid users", null, "Month 12 gross margin", null]];
summary.getRange("A6:H6").format = { fill: lightBlue, font: { name: font, size: 10, bold: true, color: navy } };
summary.getRange("A7").formulas = [[`=SUMIFS('Revenue Model'!$M$${revFirst}:$M$${revLast},'Revenue Model'!$B$${revFirst}:$B$${revLast},$B$4,'Revenue Model'!$A$${revFirst}:$A$${revLast},\"<=12\")`]];
summary.getRange("C7").formulas = [[`=SUMIFS('Revenue Model'!$H$${revFirst}:$H$${revLast},'Revenue Model'!$B$${revFirst}:$B$${revLast},$B$4,'Revenue Model'!$A$${revFirst}:$A$${revLast},12)`]];
summary.getRange("E7").formulas = [[`=SUMIFS('Revenue Model'!$K$${revFirst}:$K$${revLast},'Revenue Model'!$B$${revFirst}:$B$${revLast},$B$4,'Revenue Model'!$A$${revFirst}:$A$${revLast},12)`]];
summary.getRange("G7").formulas = [[`=SUMIFS('Revenue Model'!$P$${revFirst}:$P$${revLast},'Revenue Model'!$B$${revFirst}:$B$${revLast},$B$4,'Revenue Model'!$A$${revFirst}:$A$${revLast},12)`]];
summary.getRange("A7:H7").format.font = { name: font, size: 14, bold: true, color: navy }; summary.getRange("A7").setNumberFormat("$#,##0"); summary.getRange("C7:E7").setNumberFormat("#,##0"); summary.getRange("G7").setNumberFormat("0.0%");
summary.getRange("A9:H9").values = [["Month 12 infrastructure", null, "Month 12 CAC", null, "Month 12 simple LTV", null, "Month 12 LTV / CAC", null]];
summary.getRange("A9:H9").format = { fill: lightBlue, font: { name: font, size: 10, bold: true, color: navy } };
for (const [cell, col] of [["A10","N"],["C10","Q"],["E10","T"]]) summary.getRange(cell).formulas = [[`=SUMIFS('Revenue Model'!$${col}$${revFirst}:$${col}$${revLast},'Revenue Model'!$B$${revFirst}:$B$${revLast},$B$4,'Revenue Model'!$A$${revFirst}:$A$${revLast},12)`]];
summary.getRange("G10").formulas = [["=IF(C10=0,\"\",E10/C10)"]]; summary.getRange("A10:H10").format.font = { name: font, size: 13, bold: true, color: green };
summary.getRange("A10:E10").setNumberFormat("$#,##0.00"); summary.getRange("G10").setNumberFormat("0.0x");
summary.getRange("A13:F13").values = [["Metric", "Downside", "Base", "Upside", "Downside vs Base", "Upside vs Base"]];
summary.getRange("A13:F13").format = { fill: navy, font: { name: font, size: 10, bold: true, color: "#FFFFFF" }, horizontalAlignment: "center" };
summary.getRange("A14:A21").values = [["Year 1 net revenue"],["Year 3 net revenue"],["Month 36 DAU"],["Month 36 paid users"],["Month 36 infrastructure"],["Month 36 gross margin"],["Month 36 CAC"],["Month 36 simple LTV"]];
for (let c = 2; c <= 4; c++) {
const col = String.fromCharCode(64+c), s = `${col}$13`;
const formulas = [
`=SUMIFS('Revenue Model'!$M$${revFirst}:$M$${revLast},'Revenue Model'!$B$${revFirst}:$B$${revLast},${s},'Revenue Model'!$A$${revFirst}:$A$${revLast},\"<=12\")`,
`=SUMIFS('Revenue Model'!$M$${revFirst}:$M$${revLast},'Revenue Model'!$B$${revFirst}:$B$${revLast},${s},'Revenue Model'!$A$${revFirst}:$A$${revLast},\">24\")`,
`=SUMIFS('Revenue Model'!$H$${revFirst}:$H$${revLast},'Revenue Model'!$B$${revFirst}:$B$${revLast},${s},'Revenue Model'!$A$${revFirst}:$A$${revLast},36)`,
`=SUMIFS('Revenue Model'!$K$${revFirst}:$K$${revLast},'Revenue Model'!$B$${revFirst}:$B$${revLast},${s},'Revenue Model'!$A$${revFirst}:$A$${revLast},36)`,
`=SUMIFS('Revenue Model'!$N$${revFirst}:$N$${revLast},'Revenue Model'!$B$${revFirst}:$B$${revLast},${s},'Revenue Model'!$A$${revFirst}:$A$${revLast},36)`,
`=SUMIFS('Revenue Model'!$P$${revFirst}:$P$${revLast},'Revenue Model'!$B$${revFirst}:$B$${revLast},${s},'Revenue Model'!$A$${revFirst}:$A$${revLast},36)`,
`=SUMIFS('Revenue Model'!$Q$${revFirst}:$Q$${revLast},'Revenue Model'!$B$${revFirst}:$B$${revLast},${s},'Revenue Model'!$A$${revFirst}:$A$${revLast},36)`,
`=SUMIFS('Revenue Model'!$T$${revFirst}:$T$${revLast},'Revenue Model'!$B$${revFirst}:$B$${revLast},${s},'Revenue Model'!$A$${revFirst}:$A$${revLast},36)`,
];
summary.getRange(`${col}14:${col}21`).formulas = formulas.map(f => [f]);
}
for (let r = 14; r <= 21; r++) {
summary.getRange(`E${r}`).formulas = [[r===19 ? `=B${r}-C${r}` : `=IF(C${r}=0,\"\",B${r}/C${r}-1)`]];
summary.getRange(`F${r}`).formulas = [[r===19 ? `=D${r}-C${r}` : `=IF(C${r}=0,\"\",D${r}/C${r}-1)`]];
}
summary.getRange("B14:D15").setNumberFormat("$#,##0"); summary.getRange("B16:D17").setNumberFormat("#,##0"); summary.getRange("B18:D18").setNumberFormat("$#,##0");
summary.getRange("B19:D19").setNumberFormat("0.0%"); summary.getRange("B20:D21").setNumberFormat("$#,##0.00"); summary.getRange("E14:F21").setNumberFormat("0.0%");
summary.getRange("A13:F21").format.borders = { insideHorizontal: { style: "thin", color: "#D9E2F3" }, bottom: { style: "thin", color: "#9EADBA" } };
summary.getRange("R1:Y1").values = [["DAU", ...usageScenarios]];
summary.getRange("AA1:AH1").values = [["DAU", ...usageScenarios]];
for (let i = 0; i < pointsPerScenario; i++) {
const r = 2 + i;
summary.getRange(`R${r}:Y${r}`).formulas = [[`='Cost Model'!A${scenarioStarts[0]+i}`, ...scenarioStarts.map(s => `='Cost Model'!L${s+i}`)]];
summary.getRange(`AA${r}:AH${r}`).formulas = [[`='Cost Model'!A${scenarioStarts[0]+i}`, ...scenarioStarts.map(s => `='Cost Model'!N${s+i}`)]];
}
const summaryCostChart = summary.charts.add("line", summary.getRange(`R1:Y${1+pointsPerScenario}`));
summaryCostChart.title = "Monthly infrastructure cost by DAU and reply frequency (USD)";
summaryCostChart.titleTextStyle.typeface = font; summaryCostChart.titleTextStyle.fontSize = 12;
summaryCostChart.legend = { position: "top", textStyle: { typeface: font, fontSize: 8 } };
summaryCostChart.xAxis = { axisType: "textAxis", tickLabelInterval: 5, textStyle: { typeface: font, fontSize: 9 } };
summaryCostChart.yAxis = { numberFormatCode: "$#,##0", numberFormatSourceLinked: false, textStyle: { typeface: font, fontSize: 9 } };
summaryCostChart.setPosition("H6", "P16");
for (let i = 0; i < summaryCostChart.series.items.length; i++) summaryCostChart.series.items[i].line = { fill: scenarioColors[i], style: "solid", width: 2 };
const summaryUnitChart = summary.charts.add("line", summary.getRange(`AA1:AH${1+pointsPerScenario}`));
summaryUnitChart.title = "Infrastructure cost per DAU by reply frequency (USD)";
summaryUnitChart.titleTextStyle.typeface = font; summaryUnitChart.titleTextStyle.fontSize = 12;
summaryUnitChart.legend = { position: "top", textStyle: { typeface: font, fontSize: 8 } };
summaryUnitChart.xAxis = { axisType: "textAxis", tickLabelInterval: 5, textStyle: { typeface: font, fontSize: 9 } };
summaryUnitChart.yAxis = { numberFormatCode: "$0.00", numberFormatSourceLinked: false, textStyle: { typeface: font, fontSize: 9 } };
summaryUnitChart.setPosition("H18", "P29");
for (let i = 0; i < summaryUnitChart.series.items.length; i++) summaryUnitChart.series.items[i].line = { fill: scenarioColors[i], style: "solid", width: 2 };
section(summary, "A24:F24", "Model interpretation");
summary.getRange("A25:F29").values = [
["Sourced prices", "Only AWS price inputs with units and sources are stored on Assumptions.", null, null, null, null],
["Cost uncertainty", "Seven curves isolate 5–480 AI replies per active user per day. Other workload assumptions remain fixed.", null, null, null, null],
["Revenue uncertainty", "Downside, Base and Upside calculate independently using acquisition, conversion, churn and pricing scenarios.", null, null, null, null],
["Capacity limit", "Infrastructure capacity is modeled through 5,000 DAU. Revenue rows above that level are highlighted and use the 5,000-DAU cost as a temporary floor.", null, null, null, null],
["Next step", "P&L, cash flow and balance sheet can now be added without mixing business estimates into sourced price inputs.", null, null, null, null],
];
summary.getRange("B25:F29").merge(true); summary.getRange("A25:A29").format.font = { name: font, size: 9, bold: true, color: navy };
summary.getRange("B25:F29").format = { font: { name: font, size: 9, color: gray }, wrapText: true };
summary.getRange("A:A").format.columnWidth = 28; summary.getRange("B:F").format.columnWidth = 17; summary.getRange("G:G").format.columnWidth = 23; summary.getRange("H:P").format.columnWidth = 12;
summary.getRange("25:29").format.rowHeight = 32;
for (const sheet of [summary, cost, revenue, assumptions]) sheet.getUsedRange().format.verticalAlignment = "center";
workbook.recalculate();
const checks = {};
checks.summary = (await workbook.inspect({ kind: "table", range: "Summary!A1:P29", include: "values,formulas", tableMaxRows: 32, tableMaxCols: 16 })).ndjson;
checks.costTop = (await workbook.inspect({ kind: "table", range: "Cost Model!A1:U17", include: "values,formulas", tableMaxRows: 18, tableMaxCols: 21 })).ndjson;
checks.cost = (await workbook.inspect({ kind: "table", range: `Cost Model!A${costHeader}:O${costHeader+12}`, include: "values,formulas", tableMaxRows: 14, tableMaxCols: 15 })).ndjson;
checks.revenue = (await workbook.inspect({ kind: "table", range: "Revenue Model!A4:T22", include: "values,formulas", tableMaxRows: 20, tableMaxCols: 20 })).ndjson;
checks.assumptions = (await workbook.inspect({ kind: "table", range: "Assumptions!A1:E20", include: "values,formulas", tableMaxRows: 22, tableMaxCols: 5 })).ndjson;
checks.drawings = (await workbook.inspect({ kind: "drawing", maxChars: 12000 })).ndjson;
checks.errors = (await workbook.inspect({ kind: "match", searchTerm: "#REF!|#DIV/0!|#VALUE!|#NAME\\?|#N/A|#NUM!|#NULL!|#SPILL!|#CALC!", options: { useRegex: true, maxResults: 300 }, summary: "final formula error scan" })).ndjson;
await fs.writeFile(`${supportDir}/verification.json`, JSON.stringify(checks, null, 2));
for (const [sheetName, fileName, range] of [
["Summary", "summary-preview.png", "A1:P29"], ["Cost Model", "cost-model-preview.png", "A1:U62"],
["Revenue Model", "revenue-model-preview.png", "A1:T42"], ["Assumptions", "assumptions-preview.png", "A1:E20"],
]) {
const preview = await workbook.render({ sheetName, range, scale: 1.15, format: "png" });
await fs.writeFile(`${previewDir}/${fileName}`, new Uint8Array(await preview.arrayBuffer()));
}
const out = await SpreadsheetFile.exportXlsx(workbook);
await out.save(outputPath);
try {
await fs.rename(`${outputPath}.inspect.ndjson`, `${supportDir}/workbook-inspect.ndjson`);
} catch (error) {
if (error?.code !== "ENOENT") throw error;
}
console.log(JSON.stringify({ outputPath, costRows: pointsPerScenario * usageScenarios.length, revenueRows: months * 3 }, null, 2));