build(agent): semicolon#54de0b iteration

This commit is contained in:
agent-54de0bcc6a17828b 2026-04-24 20:10:37 +02:00
parent 06c8ddd791
commit 1466780eca
12 changed files with 841 additions and 1 deletions

21
.gitignore vendored Normal file
View File

@ -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

32
AGENTS.md Normal file
View File

@ -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`

View File

@ -1,3 +1,59 @@
# idea39-vizforge-interactive-economic
Source logic for Idea #39
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.

29
pyproject.toml Normal file
View File

@ -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"]

View File

@ -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

View File

@ -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")

View File

@ -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()

View File

@ -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")

View File

@ -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

View File

@ -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

7
test.sh Normal file
View File

@ -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

105
tests/test_vizforge.py Normal file
View File

@ -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()