Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,20 @@ The API can run in several configurations depending on what you need:
| **API + Modal deployed** | Everything, Modal functions run on Modal.com | Add `make modal-deploy` |
| **Full stack** | Everything + agent endpoint | Add `ANTHROPIC_API_KEY` to `.env` |

### Bundle provenance

The API records the installed `policyengine` bundle metadata on new simulation
records and exposes the process-level bundle at:

```bash
curl http://localhost:8000/metadata/bundle
```

Set `POLICYENGINE_BUNDLE_STRICT=true` to fail startup when the installed
`policyengine` package set does not match its vendored bundle. This is intended
for reproducible deployments; local development can leave it disabled while the
bundle rollout is in progress.

To connect Modal:

```bash
Expand Down
35 changes: 35 additions & 0 deletions alembic/versions/20260511_add_bundle_metadata_to_simulations.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
"""add_bundle_metadata_to_simulations

Revision ID: add_bundle_metadata
Revises: fb663a6e28e4
Create Date: 2026-05-11

"""

from typing import Sequence, Union

import sqlalchemy as sa
from sqlalchemy.dialects import postgresql

from alembic import op

# revision identifiers, used by Alembic.
revision: str = "add_bundle_metadata"
down_revision: Union[str, Sequence[str], None] = "fb663a6e28e4"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
op.add_column(
"simulations",
sa.Column(
"bundle_metadata",
postgresql.JSON(astext_type=sa.Text()),
nullable=True,
),
)


def downgrade() -> None:
op.drop_column("simulations", "bundle_metadata")
1 change: 1 addition & 0 deletions changelog.d/bundle-metadata.changed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Expose PolicyEngine bundle metadata and record it on simulation records.
6 changes: 3 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@ dependencies = [
"psycopg2-binary>=2.9.10",
"supabase>=2.10.0",
"storage3>=0.8.1",
"policyengine>=3.2.3",
"policyengine-uk==2.75.1",
"policyengine-us==1.666.1",
"policyengine==4.4.2",
"policyengine-uk==2.88.14",
"policyengine-us==1.687.0",
"pydantic>=2.9.2",
"pydantic-settings>=2.6.0",
"rich>=13.9.4",
Expand Down
2 changes: 2 additions & 0 deletions src/policyengine_api/api/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
household,
household_analysis,
households,
metadata,
outputs,
parameter_values,
parameters,
Expand Down Expand Up @@ -43,6 +44,7 @@
api_router.include_router(household.router)
api_router.include_router(household_analysis.router)
api_router.include_router(households.router)
api_router.include_router(metadata.router)
api_router.include_router(analysis.router)
api_router.include_router(agent.router)
api_router.include_router(user_household_associations.router)
Expand Down
2 changes: 2 additions & 0 deletions src/policyengine_api/api/analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
resolve_shared_runtime_model_version_from_db,
)
from policyengine_api.security import require_api_key
from policyengine_api.services.bundle_metadata import current_bundle_metadata
from policyengine_api.services.database import get_session
from policyengine_api.services.model_resolver import (
resolve_country_from_simulation,
Expand Down Expand Up @@ -322,6 +323,7 @@ def _get_or_create_simulation(
filter_strategy=filter_strategy,
region_id=region_id,
year=year,
bundle_metadata=current_bundle_metadata(),
)
from sqlalchemy.exc import IntegrityError

Expand Down
16 changes: 16 additions & 0 deletions src/policyengine_api/api/metadata.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from __future__ import annotations

from typing import Any

from fastapi import APIRouter

from policyengine_api.services.bundle_metadata import current_bundle_metadata

router = APIRouter(prefix="/metadata", tags=["metadata"])


@router.get("/bundle")
def get_bundle_metadata() -> dict[str, Any]:
"""Return the policyengine bundle metadata used by this API process."""

return current_bundle_metadata()
4 changes: 4 additions & 0 deletions src/policyengine_api/api/simulations.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ class HouseholdSimulationResponse(BaseModel):
household_id: UUID | None = None
policy_id: UUID | None = None
household_result: dict[str, Any] | None = None
bundle_metadata: dict[str, Any] | None = None
error_message: str | None = None


Expand Down Expand Up @@ -117,6 +118,7 @@ class EconomySimulationResponse(BaseModel):
filter_field: str | None = None
filter_value: str | None = None
region: RegionInfo | None = None
bundle_metadata: dict[str, Any] | None = None
error_message: str | None = None


Expand Down Expand Up @@ -196,6 +198,7 @@ def _build_household_response(simulation: Simulation) -> HouseholdSimulationResp
household_id=simulation.household_id,
policy_id=simulation.policy_id,
household_result=simulation.household_result,
bundle_metadata=simulation.bundle_metadata,
error_message=simulation.error_message,
)

Expand Down Expand Up @@ -224,6 +227,7 @@ def _build_economy_response(
filter_field=simulation.filter_field,
filter_value=simulation.filter_value,
region=region_info,
bundle_metadata=simulation.bundle_metadata,
error_message=simulation.error_message,
)

Expand Down
1 change: 1 addition & 0 deletions src/policyengine_api/config/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ class Settings(BaseSettings):
api_version: str = _get_version()
api_port: int = 8000
debug: bool = False
policyengine_bundle_strict: bool = False

# Seeding
limit_seed_parameters: bool = False
Expand Down
2 changes: 2 additions & 0 deletions src/policyengine_api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

from policyengine_api.api import api_router
from policyengine_api.config.settings import settings
from policyengine_api.services.bundle_metadata import current_bundle_metadata

console = Console()

Expand Down Expand Up @@ -49,6 +50,7 @@ async def lifespan(app: FastAPI):
console.print("[bold green]Initializing cache...[/bold green]")
FastAPICache.init(InMemoryBackend(), prefix="fastapi-cache")
console.print("[bold green]Cache initialized[/bold green]")
current_bundle_metadata(strict=settings.policyengine_bundle_strict)

# Warn if the agent callback HMAC secret is unset. Without a configured
# secret, ``policyengine_api.security`` falls back to a per-process
Expand Down
4 changes: 4 additions & 0 deletions src/policyengine_api/models/simulation.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,9 @@ class Simulation(SimulationBase, table=True):
household_result: dict[str, Any] | None = Field(
default=None, sa_column=Column(JSON)
)
bundle_metadata: dict[str, Any] | None = Field(
default=None, sa_column=Column(JSON)
)

# Relationships
dataset: "Dataset" = Relationship(
Expand Down Expand Up @@ -153,3 +156,4 @@ class SimulationRead(SimulationBase):
started_at: datetime | None
completed_at: datetime | None
household_result: dict[str, Any] | None = None
bundle_metadata: dict[str, Any] | None = None
58 changes: 58 additions & 0 deletions src/policyengine_api/services/bundle_metadata.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
from __future__ import annotations

import functools
from typing import Any


class BundleMetadataUnavailable(RuntimeError):
"""Raised when the installed policyengine package cannot expose a bundle."""


@functools.lru_cache(maxsize=1)
def current_bundle_metadata(*, strict: bool = False) -> dict[str, Any]:
"""Return bundle metadata from the installed policyengine package."""

try:
import policyengine.bundle as pe_bundle
except Exception as exc: # pragma: no cover - import failure shape is env-specific
if strict:
raise BundleMetadataUnavailable(
"Installed policyengine package does not expose bundle metadata."
) from exc
return {
"available": False,
"error": "Installed policyengine package does not expose bundle metadata.",
}

try:
if strict:
pe_bundle.require_bundle(strict=True)
manifest = pe_bundle.get_bundle_manifest()
except Exception as exc:
if strict:
raise
return {
"available": False,
"error": str(exc),
}

return {
"available": True,
"policyengine_version": manifest["policyengine"]["version"],
"bundle_version": manifest["bundle_version"],
"bundle_digest": manifest.get("bundle_digest"),
"profiles": {
profile_name: {
"packages": profile.get("packages", []),
"countries": profile.get("countries", []),
"install_targets": profile.get("install_targets", {}),
}
for profile_name, profile in manifest.get("profiles", {}).items()
},
"packages": manifest.get("packages", {}),
"validation_report": manifest.get("validation_report"),
}


def reset_bundle_metadata_cache() -> None:
current_bundle_metadata.cache_clear()
102 changes: 102 additions & 0 deletions tests/test_bundle_metadata.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
from __future__ import annotations

import importlib
import sys
import types

from policyengine_api.services import bundle_metadata


def test_bundle_metadata_endpoint_returns_current_bundle(client, monkeypatch):
payload = {
"available": True,
"bundle_version": "4.4.2",
"bundle_digest": "sha256:test",
"policyengine_version": "4.4.2",
"profiles": {},
"packages": {},
"validation_report": "validation-report.json",
}
monkeypatch.setattr(
"policyengine_api.api.metadata.current_bundle_metadata",
lambda: payload,
)

response = client.get("/metadata/bundle")

assert response.status_code == 200
assert response.json() == payload


def test_current_bundle_metadata_reads_policyengine_bundle(monkeypatch):
fake_bundle = types.ModuleType("policyengine.bundle")
fake_bundle.get_bundle_manifest = lambda: {
"bundle_version": "4.4.2",
"bundle_digest": "sha256:test",
"policyengine": {"version": "4.4.2"},
"profiles": {
"us": {
"packages": ["policyengine", "policyengine-us"],
"countries": ["us"],
"install_targets": {},
}
},
"packages": {"policyengine": {"version": "4.4.2"}},
"validation_report": "validation-report.json",
}
fake_bundle.require_bundle = lambda strict=True: None
fake_policyengine = types.ModuleType("policyengine")
fake_policyengine.bundle = fake_bundle
monkeypatch.setitem(sys.modules, "policyengine", fake_policyengine)
monkeypatch.setitem(sys.modules, "policyengine.bundle", fake_bundle)
bundle_metadata.reset_bundle_metadata_cache()

metadata = bundle_metadata.current_bundle_metadata(strict=True)

assert metadata["available"] is True
assert metadata["bundle_version"] == "4.4.2"
assert metadata["profiles"]["us"]["packages"] == [
"policyengine",
"policyengine-us",
]
bundle_metadata.reset_bundle_metadata_cache()


def test_simulation_creation_records_bundle_metadata(session, monkeypatch):
analysis = importlib.import_module("policyengine_api.api.analysis")
monkeypatch.setattr(
analysis,
"current_bundle_metadata",
lambda: {"available": True, "bundle_version": "4.4.2"},
)

from policyengine_api.api.analysis import _get_or_create_simulation
from policyengine_api.models import (
SimulationStatus,
SimulationType,
TaxBenefitModel,
TaxBenefitModelVersion,
)

model = TaxBenefitModel(name="policyengine-us", description="US model")
session.add(model)
session.commit()
session.refresh(model)
model_version = TaxBenefitModelVersion(model_id=model.id, version="1.0.0")
session.add(model_version)
session.commit()
session.refresh(model_version)

simulation = _get_or_create_simulation(
simulation_type=SimulationType.HOUSEHOLD,
model_version_id=model_version.id,
policy_id=None,
dynamic_id=None,
session=session,
)

assert simulation.status == SimulationStatus.PENDING
assert simulation.bundle_metadata == {
"available": True,
"bundle_version": "4.4.2",
}
Loading
Loading