From 1466780eca4cbc4e4423e461ac4f318ed1326319 Mon Sep 17 00:00:00 2001 From: agent-54de0bcc6a17828b Date: Fri, 24 Apr 2026 20:10:37 +0200 Subject: [PATCH] build(agent): semicolon#54de0b iteration --- .gitignore | 21 ++ AGENTS.md | 32 +++ README.md | 58 ++++- pyproject.toml | 29 +++ .../__init__.py | 24 ++ .../artifacts.py | 127 ++++++++++ .../cli.py | 25 ++ .../dsl.py | 42 ++++ .../models.py | 148 ++++++++++++ .../simulation.py | 224 ++++++++++++++++++ test.sh | 7 + tests/test_vizforge.py | 105 ++++++++ 12 files changed, 841 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 pyproject.toml create mode 100644 src/idea39_vizforge_interactive_economic/__init__.py create mode 100644 src/idea39_vizforge_interactive_economic/artifacts.py create mode 100644 src/idea39_vizforge_interactive_economic/cli.py create mode 100644 src/idea39_vizforge_interactive_economic/dsl.py create mode 100644 src/idea39_vizforge_interactive_economic/models.py create mode 100644 src/idea39_vizforge_interactive_economic/simulation.py create mode 100644 test.sh create mode 100644 tests/test_vizforge.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bd5590b --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +node_modules/ +.npmrc +.env +.env.* +__tests__/ +coverage/ +.nyc_output/ +dist/ +build/ +.cache/ +*.log +.DS_Store +tmp/ +.tmp/ +__pycache__/ +*.pyc +.venv/ +venv/ +*.egg-info/ +.pytest_cache/ +READY_TO_PUBLISH diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..fae95c8 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,32 @@ +# AGENTS.md + +## Repository Purpose +VizForge models startup economics in a YAML DSL, runs stochastic scenario simulation, and generates investor-facing artifacts. + +## Architecture +- `src/idea39_vizforge_interactive_economic/models.py`: typed domain models and validation. +- `src/idea39_vizforge_interactive_economic/dsl.py`: YAML DSL parser, serializer, and model digesting. +- `src/idea39_vizforge_interactive_economic/simulation.py`: Monte Carlo engine, sensitivity analysis, dilution projection. +- `src/idea39_vizforge_interactive_economic/artifacts.py`: markdown report and SVG chart generation. +- `src/idea39_vizforge_interactive_economic/cli.py`: command-line entry point. +- `tests/`: integration-style tests for parsing, simulation, and artifacts. + +## Tech Stack +- Python 3.11+ +- `numpy` for stochastic simulation +- `pydantic` for validation +- `PyYAML` for DSL parsing +- `jinja2` for report templating +- `matplotlib` for chart rendering + +## Rules +- Keep the DSL declarative and versioned. +- Prefer deterministic tests with explicit seeds. +- Do not weaken validation to make a test pass. +- Preserve the README package metadata fields if packaging changes. +- Keep outputs reproducible by recording seeds and source digests. + +## Verification +- `bash test.sh` +- `python3 -m pytest` +- `python3 -m build` diff --git a/README.md b/README.md index 31d0a9e..7edde0f 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,59 @@ # idea39-vizforge-interactive-economic -Source logic for Idea #39 \ No newline at end of file +VizForge is a Python scenario studio for startup economics. It lets founders describe a business in a declarative YAML DSL, run stochastic simulations across macro shocks, and generate investor-facing markdown and SVG artifacts with a reproducible seed and source digest. + +## What it does +- Parses a versioned business model DSL with typed validation. +- Simulates revenue, cash runway, dilution, and LTV/CAC under macro uncertainty. +- Compares scenarios such as recession, supply shocks, or tighter funding. +- Generates an investor brief with KPI tables and slide-ready SVG charts. +- Exposes a CLI for exporting reports from a YAML model file. + +## Package layout +- `src/idea39_vizforge_interactive_economic/models.py` - typed model schema. +- `src/idea39_vizforge_interactive_economic/dsl.py` - YAML parser and serializer. +- `src/idea39_vizforge_interactive_economic/simulation.py` - Monte Carlo engine and sensitivity analysis. +- `src/idea39_vizforge_interactive_economic/artifacts.py` - markdown report and SVG generation. +- `src/idea39_vizforge_interactive_economic/cli.py` - command-line entry point. + +## DSL example +```yaml +name: Acme AI +horizon_months: 12 +starting_cash: 250000 +macro: + gdp_growth: 0.02 + inflation: 0.03 + consumer_confidence: 102 +revenue_streams: + - name: core + starting_customers: 100 + price_per_customer: 120 + monthly_growth_rate: 0.06 + monthly_churn: 0.04 + gross_margin: 0.82 + acquisition_rate: 15 +costs: + - name: cloud + monthly_amount: 10000 +financing: + - month: 0 + instrument: equity + amount: 150000 + valuation: 3000000 +``` + +## CLI +```bash +vizforge path/to/model.yaml --output-dir vizforge-output --sims 1000 --seed 7 +``` + +This writes `report.md` plus SVG charts into the output directory. + +## Development +- Install the package in editable mode: `python3 -m pip install -e .` +- Run tests: `python3 -m pytest` +- Build the distribution: `python3 -m build` + +## Reproducibility +Every simulation bundle records a deterministic seed and SHA-256 digest of the parsed DSL text so the same assumptions can be replayed later. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..e219001 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,29 @@ +[build-system] +requires = ["setuptools>=69", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "idea39-vizforge-interactive-economic" +version = "0.1.0" +description = "VizForge: interactive economic scenario simulation and investor artifact generation for startups" +readme = "README.md" +requires-python = ">=3.11" +dependencies = [ + "numpy>=2.0", + "pydantic>=2.0", + "PyYAML>=6.0", + "jinja2>=3.1", + "matplotlib>=3.8", +] + +[project.optional-dependencies] +test = ["pytest>=8.0"] + +[project.scripts] +vizforge = "idea39_vizforge_interactive_economic.cli:main" + +[tool.setuptools] +package-dir = {"" = "src"} + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/src/idea39_vizforge_interactive_economic/__init__.py b/src/idea39_vizforge_interactive_economic/__init__.py new file mode 100644 index 0000000..bde5beb --- /dev/null +++ b/src/idea39_vizforge_interactive_economic/__init__.py @@ -0,0 +1,24 @@ +__all__ = [ + "BusinessModel", + "MacroInputs", + "RevenueStream", + "CostBucket", + "HirePlan", + "CapexEvent", + "FinancingRound", + "ScenarioShock", + "load_business_model", + "dump_business_model", + "parse_business_model", + "simulate_business_model", + "compare_scenarios", + "sensitivity_analysis", + "generate_investor_artifact", +] + +__version__ = "0.1.0" + +from .models import BusinessModel, CapexEvent, CostBucket, FinancingRound, HirePlan, MacroInputs, RevenueStream, ScenarioShock +from .dsl import dump_business_model, load_business_model, parse_business_model +from .simulation import compare_scenarios, sensitivity_analysis, simulate_business_model +from .artifacts import generate_investor_artifact diff --git a/src/idea39_vizforge_interactive_economic/artifacts.py b/src/idea39_vizforge_interactive_economic/artifacts.py new file mode 100644 index 0000000..dd1d07d --- /dev/null +++ b/src/idea39_vizforge_interactive_economic/artifacts.py @@ -0,0 +1,127 @@ +from __future__ import annotations + +from dataclasses import dataclass +from io import BytesIO +from pathlib import Path + +import matplotlib + +matplotlib.use("Agg") +import matplotlib.pyplot as plt +import numpy as np +from jinja2 import Template + +from .dsl import parse_business_model +from .simulation import SimulationResult, compare_scenarios, sensitivity_analysis, simulate_business_model +from .models import BusinessModel, ScenarioShock + + +@dataclass(slots=True) +class ArtifactBundle: + markdown: str + charts: dict[str, str] + metadata: dict[str, str | float] + + +_REPORT = Template( + """# VizForge Investor Scenario Brief + +## Model +- Name: {{ name }} +- Source digest: {{ digest }} +- Horizon: {{ horizon }} months +- Seed: {{ seed }} + +## Base Case Summary +| Metric | Value | +| --- | ---: | +| Runway P50 | {{ runway_p50 | round(2) }} | +| Ending Cash P50 | {{ ending_cash_p50 | round(2) }} | +| LTV/CAC P50 | {{ ltv_cac_p50 | round(2) }} | +| Gross Margin P50 | {{ gross_margin_p50 | round(2) }} | + +## Narrative +{% for line in narrative %}- {{ line }} +{% endfor %} + +## Dilution +| Month | Event | Founder % | Investor % | Pool % | +| --- | --- | ---: | ---: | ---: | +{% for row in dilution %}| {{ row.month }} | {{ row.event }} | {{ (row.founder_pct * 100) | round(2) }} | {{ (row.investor_pct * 100) | round(2) }} | {{ (row.pool_pct * 100) | round(2) }} | +{% endfor %} +""" +) + + +def _svg_from_figure(figure) -> str: + buf = BytesIO() + figure.savefig(buf, format="svg", bbox_inches="tight") + plt.close(figure) + return buf.getvalue().decode("utf-8") + + +def _plot_cash_band(result: SimulationResult) -> str: + fig, ax = plt.subplots(figsize=(9, 4)) + x = np.arange(result.horizon_months + 1) + p10 = np.percentile(result.cash_paths, 10, axis=0) + p50 = np.percentile(result.cash_paths, 50, axis=0) + p90 = np.percentile(result.cash_paths, 90, axis=0) + ax.fill_between(x, p10, p90, alpha=0.18, label="P10-P90") + ax.plot(x, p50, linewidth=2, label="P50") + ax.axhline(0, color="black", linewidth=1) + ax.set_title("Cash Runway Distribution") + ax.set_xlabel("Month") + ax.set_ylabel("Cash") + ax.legend(loc="upper right") + return _svg_from_figure(fig) + + +def _plot_revenue_band(result: SimulationResult) -> str: + fig, ax = plt.subplots(figsize=(9, 4)) + x = np.arange(1, result.horizon_months + 1) + p10 = np.percentile(result.revenue_paths, 10, axis=0) + p50 = np.percentile(result.revenue_paths, 50, axis=0) + p90 = np.percentile(result.revenue_paths, 90, axis=0) + ax.fill_between(x, p10, p90, alpha=0.18, label="P10-P90") + ax.plot(x, p50, linewidth=2, label="P50") + ax.set_title("Revenue Trajectory Distribution") + ax.set_xlabel("Month") + ax.set_ylabel("Revenue") + ax.legend(loc="upper left") + return _svg_from_figure(fig) + + +def generate_investor_artifact(model_or_source: BusinessModel | str | Path, n_sims: int = 1000, seed: int = 7, scenario: ScenarioShock | None = None) -> ArtifactBundle: + model, digest = (model_or_source, "direct-model") if isinstance(model_or_source, BusinessModel) else parse_business_model(model_or_source) + result = simulate_business_model(model, n_sims=n_sims, seed=seed, scenario=scenario) + summary = result.summary() + narrative = [ + f"{result.scenario_name} keeps the business in a {summary['runway_p50']:.1f}-month runway band at the median outcome.", + f"Median LTV/CAC is {summary['ltv_cac_p50']:.2f}, which is {('healthy' if summary['ltv_cac_p50'] >= 3 else 'compressed')} for investor diligence.", + f"Dilution is captured as a reproducible event trail tied to the source digest.", + ] + markdown = _REPORT.render( + name=model.name, + digest=digest, + horizon=model.horizon_months, + seed=seed, + runway_p50=summary["runway_p50"], + ending_cash_p50=summary["ending_cash_p50"], + ltv_cac_p50=summary["ltv_cac_p50"], + gross_margin_p50=summary["gross_margin_p50"], + narrative=narrative, + dilution=result.dilution_table, + ) + return ArtifactBundle( + markdown=markdown, + charts={"cash_runway.svg": _plot_cash_band(result), "revenue_trajectory.svg": _plot_revenue_band(result)}, + metadata={"digest": digest, **summary, "scenario": result.scenario_name}, + ) + + +def export_artifact(bundle: ArtifactBundle, output_dir: str | Path) -> None: + path = Path(output_dir) + path.mkdir(parents=True, exist_ok=True) + (path / "report.md").write_text(bundle.markdown, encoding="utf-8") + for name, content in bundle.charts.items(): + (path / name).write_text(content, encoding="utf-8") diff --git a/src/idea39_vizforge_interactive_economic/cli.py b/src/idea39_vizforge_interactive_economic/cli.py new file mode 100644 index 0000000..c8c50d1 --- /dev/null +++ b/src/idea39_vizforge_interactive_economic/cli.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +import argparse +from pathlib import Path + +from .artifacts import export_artifact, generate_investor_artifact +from .dsl import load_business_model + + +def main() -> None: + parser = argparse.ArgumentParser(description="VizForge scenario studio") + parser.add_argument("input", type=Path, help="Path to a VizForge YAML model") + parser.add_argument("--output-dir", type=Path, default=Path("vizforge-output")) + parser.add_argument("--sims", type=int, default=1000) + parser.add_argument("--seed", type=int, default=7) + args = parser.parse_args() + + model = load_business_model(args.input) + bundle = generate_investor_artifact(model, n_sims=args.sims, seed=args.seed) + export_artifact(bundle, args.output_dir) + print(f"Wrote {args.output_dir / 'report.md'}") + + +if __name__ == "__main__": + main() diff --git a/src/idea39_vizforge_interactive_economic/dsl.py b/src/idea39_vizforge_interactive_economic/dsl.py new file mode 100644 index 0000000..547a78f --- /dev/null +++ b/src/idea39_vizforge_interactive_economic/dsl.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +import hashlib +from pathlib import Path +from typing import Any + +import yaml + +from .models import BusinessModel + + +def _read_source(source: str | Path) -> str: + path = Path(source) + try: + if path.exists(): + return path.read_text(encoding="utf-8") + except OSError: + pass + return str(source) + + +def parse_business_model(source: str | Path) -> tuple[BusinessModel, str]: + text = _read_source(source) + payload = yaml.safe_load(text) or {} + model = BusinessModel.model_validate(payload) + digest = hashlib.sha256(text.encode("utf-8")).hexdigest() + return model, digest + + +def load_business_model(source: str | Path) -> BusinessModel: + return parse_business_model(source)[0] + + +def dump_business_model(model: BusinessModel) -> str: + payload = model.model_dump(mode="json", exclude_none=True) + return yaml.safe_dump(payload, sort_keys=False) + + +def normalize_mapping(value: Any) -> dict[str, Any]: + if isinstance(value, dict): + return value + raise TypeError("expected mapping") diff --git a/src/idea39_vizforge_interactive_economic/models.py b/src/idea39_vizforge_interactive_economic/models.py new file mode 100644 index 0000000..3a29d7e --- /dev/null +++ b/src/idea39_vizforge_interactive_economic/models.py @@ -0,0 +1,148 @@ +from __future__ import annotations + +from typing import Literal + +from pydantic import BaseModel, Field, field_validator, model_validator + + +class MacroInputs(BaseModel): + gdp_growth: float = 0.0 + inflation: float = 0.03 + interest_rate: float = 0.05 + consumer_confidence: float = 100.0 + funding_environment: float = 0.5 + regime_switch_probability: float = 0.08 + fx_rates: dict[str, float] = Field(default_factory=dict) + region: str = "global" + + @field_validator("funding_environment", "regime_switch_probability") + @classmethod + def _bounded_probability(cls, value: float) -> float: + if not 0.0 <= value <= 1.0: + raise ValueError("value must be between 0 and 1") + return value + + @field_validator("consumer_confidence") + @classmethod + def _confidence_range(cls, value: float) -> float: + if value <= 0: + raise ValueError("consumer_confidence must be positive") + return value + + +class RevenueStream(BaseModel): + name: str + starting_customers: float = 0.0 + price_per_customer: float + monthly_growth_rate: float = 0.0 + monthly_churn: float = 0.04 + gross_margin: float = 0.75 + acquisition_rate: float = 0.0 + macro_sensitivity: float = 0.35 + currency: str = "USD" + + @field_validator("monthly_churn") + @classmethod + def _churn_range(cls, value: float) -> float: + if not 0.0 <= value < 1.0: + raise ValueError("monthly_churn must be in [0, 1)") + return value + + @field_validator("gross_margin") + @classmethod + def _margin_range(cls, value: float) -> float: + if not 0.0 <= value <= 1.0: + raise ValueError("gross_margin must be in [0, 1]") + return value + + +class CostBucket(BaseModel): + name: str + monthly_amount: float + inflation_sensitivity: float = 0.7 + macro_sensitivity: float = 0.15 + currency: str = "USD" + + +class HirePlan(BaseModel): + month: int + role: str + annual_salary: float + count: int = 1 + ramp_months: int = 1 + currency: str = "USD" + + @field_validator("month") + @classmethod + def _month_non_negative(cls, value: int) -> int: + if value < 0: + raise ValueError("month must be non-negative") + return value + + +class CapexEvent(BaseModel): + month: int + amount: float + description: str = "capex" + depreciation_months: int = 24 + currency: str = "USD" + + +class FinancingRound(BaseModel): + month: int + instrument: Literal["equity", "safe", "option_pool"] = "equity" + amount: float = 0.0 + valuation: float | None = None + discount: float = 0.2 + cap: float | None = None + option_pool_percent: float = 0.0 + currency: str = "USD" + + @model_validator(mode="after") + def _validate_round(self) -> "FinancingRound": + if self.instrument == "equity" and not self.valuation: + raise ValueError("equity financing requires valuation") + if self.instrument == "safe" and not (self.cap or self.discount): + raise ValueError("safe financing requires a cap or discount") + return self + + +class ScenarioShock(BaseModel): + name: str + probability: float = 0.2 + demand_multiplier: float = 0.9 + cac_multiplier: float = 1.1 + churn_delta: float = 0.01 + cost_inflation_delta: float = 0.01 + funding_environment_delta: float = -0.05 + interest_rate_delta: float = 0.01 + duration_months: int = 6 + regime_bias: float = -0.15 + + +class BusinessModel(BaseModel): + name: str + horizon_months: int = 24 + base_currency: str = "USD" + starting_cash: float = 0.0 + macro: MacroInputs = Field(default_factory=MacroInputs) + revenue_streams: list[RevenueStream] = Field(default_factory=list) + costs: list[CostBucket] = Field(default_factory=list) + hires: list[HirePlan] = Field(default_factory=list) + capex: list[CapexEvent] = Field(default_factory=list) + financing: list[FinancingRound] = Field(default_factory=list) + scenarios: list[ScenarioShock] = Field(default_factory=list) + fx_rates: dict[str, float] = Field(default_factory=dict) + + @field_validator("horizon_months") + @classmethod + def _horizon_positive(cls, value: int) -> int: + if value <= 0: + raise ValueError("horizon_months must be positive") + return value + + @model_validator(mode="after") + def _default_fx_rate(self) -> "BusinessModel": + if self.base_currency not in self.fx_rates: + self.fx_rates[self.base_currency] = 1.0 + return self diff --git a/src/idea39_vizforge_interactive_economic/simulation.py b/src/idea39_vizforge_interactive_economic/simulation.py new file mode 100644 index 0000000..b401e48 --- /dev/null +++ b/src/idea39_vizforge_interactive_economic/simulation.py @@ -0,0 +1,224 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Iterable + +import numpy as np + +from .dsl import parse_business_model +from .models import BusinessModel, ScenarioShock + + +@dataclass(slots=True) +class SimulationResult: + scenario_name: str + seed: int + horizon_months: int + revenue_paths: np.ndarray + cash_paths: np.ndarray + runway_months: np.ndarray + ending_cash: np.ndarray + ltv_cac: np.ndarray + gross_margin: np.ndarray + monthly_summary: list[dict[str, float]] + dilution_table: list[dict[str, float | str]] + model_digest: str + + def summary(self) -> dict[str, float]: + return { + "runway_p10": float(np.percentile(self.runway_months, 10)), + "runway_p50": float(np.percentile(self.runway_months, 50)), + "runway_p90": float(np.percentile(self.runway_months, 90)), + "ending_cash_p50": float(np.percentile(self.ending_cash, 50)), + "ltv_cac_p50": float(np.percentile(self.ltv_cac, 50)), + "gross_margin_p50": float(np.percentile(self.gross_margin, 50)), + } + + +def _fx(amount: float, currency: str, fx_rates: dict[str, float]) -> float: + if currency not in fx_rates: + raise KeyError(f"missing fx rate for {currency}") + return amount * fx_rates[currency] + + +def _monthly_rate_from_annual(salary: float) -> float: + return salary / 12.0 + + +def _dilution_projection(model: BusinessModel) -> list[dict[str, float | str]]: + founder = 1.0 + pool = 0.0 + rows: list[dict[str, float | str]] = [] + shares = 100.0 + for round_ in sorted(model.financing, key=lambda item: item.month): + if round_.instrument == "option_pool": + pool += round_.option_pool_percent + founder *= max(0.0, 1.0 - round_.option_pool_percent) + shares *= max(0.0, 1.0 - round_.option_pool_percent) + rows.append({"month": round_.month, "event": "option_pool", "founder_pct": founder, "investor_pct": 0.0, "pool_pct": pool}) + continue + if round_.instrument == "safe": + assumed_valuation = round_.cap or round_.valuation or 1.0 + investor = min(0.5, round_.amount / assumed_valuation) + else: + investor = min(0.9, round_.amount / (round_.valuation or 1.0)) + founder *= max(0.0, 1.0 - investor) + shares *= max(0.0, 1.0 - investor) + rows.append({"month": round_.month, "event": round_.instrument, "founder_pct": founder, "investor_pct": investor, "pool_pct": pool}) + return rows + + +def _simulate_single_run(model: BusinessModel, rng: np.random.Generator, scenario: ScenarioShock | None) -> tuple[np.ndarray, np.ndarray, float, float, float]: + horizon = model.horizon_months + revenue = np.zeros(horizon, dtype=float) + cash = np.zeros(horizon + 1, dtype=float) + cash[0] = model.starting_cash + total_gross_profit = 0.0 + total_cac_spend = 0.0 + + scenario = scenario or ScenarioShock(name="baseline", probability=1.0, demand_multiplier=1.0, cac_multiplier=1.0, churn_delta=0.0, cost_inflation_delta=0.0, funding_environment_delta=0.0, interest_rate_delta=0.0, duration_months=horizon, regime_bias=0.0) + regime = 1.0 + monthly_customers = [stream.starting_customers for stream in model.revenue_streams] + + financing_by_month: dict[int, list] = {} + for round_ in model.financing: + financing_by_month.setdefault(round_.month, []).append(round_) + + for month in range(horizon): + switch_p = model.macro.regime_switch_probability * (1.2 if model.macro.consumer_confidence < 95 else 0.8) + if rng.random() < switch_p: + regime *= -1.0 + active_scenario = 1.0 if month >= scenario.duration_months else scenario.demand_multiplier + regime_effect = 1.0 + (scenario.regime_bias if regime < 0 else -scenario.regime_bias / 2.0) + demand_shock = 1.0 + (0.02 * model.macro.gdp_growth) + ((model.macro.consumer_confidence - 100.0) / 1000.0) + rng.normal(0.0, 0.02) + price_shock = 1.0 + (0.5 * model.macro.inflation) + rng.normal(0.0, 0.01) + churn_shift = max(0.0, model.macro.interest_rate * 0.25 + scenario.churn_delta + rng.normal(0.0, 0.005)) + cost_inflation = max(0.0, model.macro.inflation + scenario.cost_inflation_delta + rng.normal(0.0, 0.01)) + funding_factor = 1.0 + scenario.funding_environment_delta + (model.macro.funding_environment - 0.5) * 0.1 + + month_revenue = 0.0 + month_gross_profit = 0.0 + month_cac = 0.0 + + for i, stream in enumerate(model.revenue_streams): + churn = min(0.95, stream.monthly_churn + churn_shift + rng.normal(0.0, 0.003)) + acquisition = max(0.0, stream.acquisition_rate * active_scenario * regime_effect * funding_factor * demand_shock) + customers = monthly_customers[i] * (1.0 - churn) + acquisition + customers = max(0.0, customers * (1.0 + stream.monthly_growth_rate * active_scenario * demand_shock * regime_effect)) + monthly_customers[i] = customers + price = stream.price_per_customer * price_shock * (1.0 + stream.macro_sensitivity * (demand_shock - 1.0)) + stream_revenue = _fx(customers * price, stream.currency, model.fx_rates) + stream_gross = stream_revenue * stream.gross_margin + month_revenue += stream_revenue + month_gross_profit += stream_gross + month_cac += max(0.0, acquisition * stream.price_per_customer * scenario.cac_multiplier * 0.15) + + cost_total = 0.0 + for cost in model.costs: + inflated = cost.monthly_amount * (1.0 + cost.inflation_sensitivity * cost_inflation) * (1.0 + cost.macro_sensitivity * (demand_shock - 1.0)) + cost_total += _fx(inflated, cost.currency, model.fx_rates) + + for hire in model.hires: + if month >= hire.month: + ramp = min(1.0, max(0.2, (month - hire.month + 1) / max(1, hire.ramp_months))) + cost_total += _fx(_monthly_rate_from_annual(hire.annual_salary) * hire.count * ramp, hire.currency, model.fx_rates) + + for capex in model.capex: + if month == capex.month: + cost_total += _fx(capex.amount, capex.currency, model.fx_rates) + + month_cash = cash[month] + month_gross_profit - cost_total + if month in financing_by_month: + for round_ in financing_by_month[month]: + if round_.instrument in {"equity", "safe"}: + month_cash += _fx(round_.amount, round_.currency, model.fx_rates) + cash[month + 1] = month_cash + revenue[month] = month_revenue + total_gross_profit += month_gross_profit + total_cac_spend += month_cac + + runway = int(np.argmax(cash[1:] <= 0)) if np.any(cash[1:] <= 0) else horizon + lifetime_value = (np.mean(revenue) / max(1.0, np.mean([s.starting_customers + s.acquisition_rate for s in model.revenue_streams]) or 1.0)) * max(0.0, np.mean([s.gross_margin for s in model.revenue_streams])) / max(0.001, np.mean([s.monthly_churn for s in model.revenue_streams])) + cac = max(1.0, total_cac_spend / max(1.0, sum(s.acquisition_rate for s in model.revenue_streams) * horizon)) + ltv_cac = lifetime_value / cac + gross_margin = total_gross_profit / max(1.0, np.sum(revenue)) + return revenue, cash, float(runway), float(cash[-1]), float(ltv_cac), float(gross_margin) + + +def _monthly_summary(revenue_paths: np.ndarray, cash_paths: np.ndarray) -> list[dict[str, float]]: + rows: list[dict[str, float]] = [] + for month in range(revenue_paths.shape[1]): + rows.append( + { + "month": float(month + 1), + "revenue_p10": float(np.percentile(revenue_paths[:, month], 10)), + "revenue_p50": float(np.percentile(revenue_paths[:, month], 50)), + "revenue_p90": float(np.percentile(revenue_paths[:, month], 90)), + "cash_p10": float(np.percentile(cash_paths[:, month + 1], 10)), + "cash_p50": float(np.percentile(cash_paths[:, month + 1], 50)), + "cash_p90": float(np.percentile(cash_paths[:, month + 1], 90)), + } + ) + return rows + + +def simulate_business_model(model_or_source: BusinessModel | str, n_sims: int = 1000, seed: int = 7, scenario: ScenarioShock | None = None) -> SimulationResult: + if isinstance(model_or_source, BusinessModel): + model = model_or_source + digest = "direct-model" + else: + model, digest = parse_business_model(model_or_source) + + rng = np.random.default_rng(seed) + revenue_paths = np.zeros((n_sims, model.horizon_months), dtype=float) + cash_paths = np.zeros((n_sims, model.horizon_months + 1), dtype=float) + runways = np.zeros(n_sims, dtype=float) + ending_cash = np.zeros(n_sims, dtype=float) + ltv_cac = np.zeros(n_sims, dtype=float) + gross_margin = np.zeros(n_sims, dtype=float) + + for i in range(n_sims): + revenue, cash, runway, final_cash, ltv, margin = _simulate_single_run(model, rng, scenario) + revenue_paths[i] = revenue + cash_paths[i] = cash + runways[i] = runway + ending_cash[i] = final_cash + ltv_cac[i] = ltv + gross_margin[i] = margin + + return SimulationResult( + scenario_name=(scenario.name if scenario else "baseline"), + seed=seed, + horizon_months=model.horizon_months, + revenue_paths=revenue_paths, + cash_paths=cash_paths, + runway_months=runways, + ending_cash=ending_cash, + ltv_cac=ltv_cac, + gross_margin=gross_margin, + monthly_summary=_monthly_summary(revenue_paths, cash_paths), + dilution_table=_dilution_projection(model), + model_digest=digest, + ) + + +def compare_scenarios(model_or_source: BusinessModel | str, scenarios: Iterable[ScenarioShock], n_sims: int = 1000, seed: int = 7) -> list[SimulationResult]: + model = model_or_source if isinstance(model_or_source, BusinessModel) else parse_business_model(model_or_source)[0] + results: list[SimulationResult] = [] + for index, scenario in enumerate(scenarios): + results.append(simulate_business_model(model, n_sims=n_sims, seed=seed + index, scenario=scenario)) + return results + + +def sensitivity_analysis(model_or_source: BusinessModel | str, parameter_path: str, values: Iterable[float], n_sims: int = 500, seed: int = 7) -> list[dict[str, float]]: + model = model_or_source if isinstance(model_or_source, BusinessModel) else parse_business_model(model_or_source)[0] + if parameter_path != "macro.inflation": + raise NotImplementedError("sensitivity analysis currently supports macro.inflation") + rows: list[dict[str, float]] = [] + for idx, value in enumerate(values): + cloned = model.model_copy(deep=True) + cloned.macro.inflation = float(value) + result = simulate_business_model(cloned, n_sims=n_sims, seed=seed + idx) + summary = result.summary() + rows.append({"value": float(value), "runway_p50": summary["runway_p50"], "ending_cash_p50": summary["ending_cash_p50"], "ltv_cac_p50": summary["ltv_cac_p50"]}) + return rows diff --git a/test.sh b/test.sh new file mode 100644 index 0000000..cf819af --- /dev/null +++ b/test.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -euo pipefail + +python3 -m pip install --quiet -e . +python3 -m pip install --quiet build pytest +python3 -m pytest +python3 -m build diff --git a/tests/test_vizforge.py b/tests/test_vizforge.py new file mode 100644 index 0000000..78cb60f --- /dev/null +++ b/tests/test_vizforge.py @@ -0,0 +1,105 @@ +from __future__ import annotations + +from pathlib import Path + +from idea39_vizforge_interactive_economic.artifacts import export_artifact, generate_investor_artifact +from idea39_vizforge_interactive_economic.dsl import dump_business_model, load_business_model, parse_business_model +from idea39_vizforge_interactive_economic.models import BusinessModel +from idea39_vizforge_interactive_economic.simulation import compare_scenarios, sensitivity_analysis, simulate_business_model + + +def sample_yaml() -> str: + return """ +name: Acme AI +horizon_months: 12 +base_currency: USD +starting_cash: 250000 +macro: + gdp_growth: 0.02 + inflation: 0.03 + interest_rate: 0.05 + consumer_confidence: 102 + funding_environment: 0.68 + regime_switch_probability: 0.05 +fx_rates: + USD: 1.0 +revenue_streams: + - name: core + starting_customers: 100 + price_per_customer: 120 + monthly_growth_rate: 0.06 + monthly_churn: 0.04 + gross_margin: 0.82 + acquisition_rate: 15 + macro_sensitivity: 0.4 +costs: + - name: cloud + monthly_amount: 10000 + inflation_sensitivity: 0.5 + macro_sensitivity: 0.1 +hires: + - month: 2 + role: engineer + annual_salary: 180000 + count: 1 +capex: + - month: 3 + amount: 25000 + description: hardware +financing: + - month: 0 + instrument: equity + amount: 150000 + valuation: 3000000 + - month: 6 + instrument: option_pool + option_pool_percent: 0.08 +scenarios: + - name: recession + probability: 0.2 + demand_multiplier: 0.82 + cac_multiplier: 1.25 + churn_delta: 0.03 + cost_inflation_delta: 0.02 + funding_environment_delta: -0.2 + interest_rate_delta: 0.02 + duration_months: 4 + regime_bias: -0.2 +""".strip() + + +def test_parse_roundtrip_and_digest() -> None: + model, digest = parse_business_model(sample_yaml()) + assert model.name == "Acme AI" + assert len(digest) == 64 + dumped = dump_business_model(model) + loaded = load_business_model(dumped) + assert loaded.name == model.name + + +def test_simulation_and_scenario_comparison() -> None: + model = load_business_model(sample_yaml()) + base = simulate_business_model(model, n_sims=64, seed=11) + assert base.revenue_paths.shape == (64, 12) + assert base.cash_paths.shape == (64, 13) + assert base.summary()["runway_p50"] >= 0 + + recession = model.scenarios[0] + results = compare_scenarios(model, [recession], n_sims=32, seed=12) + assert results[0].scenario_name == "recession" + assert results[0].runway_months.shape == (32,) + + +def test_sensitivity_and_artifacts(tmp_path: Path) -> None: + model = load_business_model(sample_yaml()) + rows = sensitivity_analysis(model, "macro.inflation", [0.02, 0.05], n_sims=24, seed=3) + assert len(rows) == 2 + assert rows[0]["value"] == 0.02 + + bundle = generate_investor_artifact(model, n_sims=32, seed=4) + assert "VizForge Investor Scenario Brief" in bundle.markdown + assert "cash_runway.svg" in bundle.charts + + export_artifact(bundle, tmp_path) + assert (tmp_path / "report.md").exists() + assert (tmp_path / "cash_runway.svg").exists()