Migration guidelines for converting calculation collections to the new format
These instructions are for an LLM (or human) to migrate existing calculations to namespaced, typed registrations using Collection, phantom units, and policy-aware semantics.
0) Goals (what “done” looks like)
Namespaced registration: Every calculation is registered via a namespaced
Collection(e.g.,pricing.gross_profit).String dependencies: Dependencies use string names with relative or absolute qualification (see §3).
Phantom units: Public signatures use phantom units (
FinancialValue[Money],FinancialValue[Ratio], etc.).Ratios over percents: Calculations return ratios for rate-like results; provide a separate
..._percentwrapper only when needed.Precision: No float math for precision-sensitive ops; favor
Decimal(andContext.ln/expfor pow/root).Policy-driven None/invalid handling: Handling is policy-driven (
arithmetic_strict) and otherwise returnsFV.none(policy).Graph hygiene: Cross-package deps compile & resolve; the graph is cycle-free.
1) File & import layout
Keep a single canonical
units.py,value.py,policy.py,registry.py,registry_collections.pyat the package root.Place calculations under
calculations/:
metricengine/
calculations/
__init__.py # contains load_all()
pricing.py
growth.py
profitability.py
...
Do not import all calculations from package
__init__.py. Instead, expose a loader:
# calculations/__init__.py
def load_all() -> None:
from . import pricing, growth, profitability # noqa: F401
Use relative imports inside calculation modules:
from ..registry_collections import Collection
from ..value import FinancialValue
from ..units import Money, Ratio, Percent, Dimensionless
from ..policy import DEFAULT_POLICY
from ..policy_context import get_policy
from ..exceptions import CalculationError
2) Registering calculations via Collection
Create a collection per module:
pricing = Collection("pricing")
Register each function with
@pricing.calc("name", depends_on=(...)).Dependencies are strings; the collection auto-prefixes relative names.
Template:
pricing = Collection("pricing")
@pricing.calc("gross_profit", depends_on=("sales", "cost"))
def gross_profit(sales: FinancialValue[Money],
cost: FinancialValue[Money]) -> FinancialValue[Money]:
return sales - cost
3) Dependency naming rules (critical)
Relative name (no dot):
"sales"→ auto-qualified topricing.sales.Absolute name (has dot or starts with
":"):"growth.compound_growth_rate"or":growth.compound_growth_rate"→ no re-prefixing. Use for cross-package deps.
Examples:
@sales.calc("total_cost", depends_on=("pricing.unit_cost", "quantity"))
# "quantity" -> "sales.quantity" (relative)
# "pricing.unit_cost" stays absolute
4) Function signatures & phantom units
Use phantom types at API boundaries: - Money amounts:
FinancialValue[Money]- Rates/ratios:FinancialValue[Ratio]- Percent display:FinancialValue[Percent](convert at the end) - Counts/time:FinancialValue[Dimensionless]Prefer returning Ratio for growth/margins; provide a
..._percentsibling that converts.
Examples:
def gross_margin(gross_profit: FinancialValue[Money],
sales: FinancialValue[Money]) -> FinancialValue[Ratio]:
return (gross_profit / sales).ratio()
def gross_margin_percent(gross_margin: FinancialValue[Ratio]) -> FinancialValue[Percent]:
return gross_margin.as_percentage()
5) Policy resolution & None handling
Resolve a concrete policy for results:
pol = (a.policy or b.policy or get_policy() or DEFAULT_POLICY)
If any input is None, return
FinancialValue.none(pol)(or the unit-aware variant).For invalid domain (e.g., division by zero, non-positive inputs for CAGR): - If
pol.arithmetic_strict:raise CalculationError("...")- Else: returnFinancialValue.none(pol).
6) Precision rules
Never do
Decimal(float)directly. Let the engine/inputs provideFinancialValue; operate onFinancialValuewhere possible.For exponentiation with fractional exponents (CAGR, geometric means), avoid float pow. Use
Decimalcontext:
from decimal import getcontext, Decimal
ctx = getcontext().copy(); ctx.prec = max(28, pol.decimal_places + 10)
ratio = f / i # Decimal > 0
cagr = ctx.exp(ctx.ln(ratio) / n) - Decimal(1)
Prefer existing reducers (
fv_sum,fv_mean,fv_weighted_mean) for aggregations.
7) Percent vs ratio
Store and compute as ratios (0..1).
Convert to percent only in presentation or in a convenience calc:
return ratio_value.as_percentage()
Let
Policy.percent_displaycontrol string rendering, not the underlying math.
8) Example migration (before → after)
Before:
from .registry import calc
@calc("gross_margin_percentage", depends_on=("gross_profit", "sales"))
def gross_margin_percentage(gross_profit, sales):
if sales is None or sales == 0:
return None
return (gross_profit / sales) * 100
After:
from ..registry_collections import Collection
from ..units import Money, Ratio, Percent, Dimensionless
from ..value import FinancialValue
from ..policy_context import get_policy
from ..policy import DEFAULT_POLICY
from ..exceptions import CalculationError
profitability = Collection("profitability")
@profitability.calc("gross_margin_ratio", depends_on=("gross_profit", "sales"))
def gross_margin_ratio(gross_profit: FinancialValue[Money],
sales: FinancialValue[Money]) -> FinancialValue[Ratio]:
pol = gross_profit.policy or sales.policy or get_policy() or DEFAULT_POLICY
if gross_profit.is_none() or sales.is_none():
return FinancialValue.none(pol).ratio()
# engine's FV division handles domain; still guard sales == 0 if you prefer:
if sales._value == 0:
return FinancialValue.none(pol).ratio()
return (gross_profit / sales).ratio()
@profitability.calc("gross_margin_percent", depends_on=("gross_margin_ratio",))
def gross_margin_percent(gmr: FinancialValue[Ratio]) -> FinancialValue[Percent]:
pol = gmr.policy or get_policy() or DEFAULT_POLICY
if gmr.is_none():
return FinancialValue.none(pol).as_percentage()
return gmr.as_percentage()
9) Decorators / business rules
Keep generic math in
reductions.py.Put domain guards like
skip_if_negative_salesincalculations/rules.py(not in generic utilities). Re-export if needed.Make guards policy-aware and argument-named:
def skip_if_negative_sales(arg="sales"):
return skip_if(arg=arg, policy_flag="negative_sales_is_none",
predicate=lambda fv: fv < 0)
10) Cross-package dependencies
Use absolute names in
depends_onfor cross-package links:"pricing.unit_cost".Ensure packages are registered before use:
from metricengine.calculations import load_all
load_all()
(Optional) add a bootstrap that auto-imports known namespaces, or an entry-point loader for plugins.
11) Validation & acceptance checks (add to CI)
Cycle detection: run a registry cycle check after
load_all()and fail CI on cycles.Existence: assert every dependency name resolves to a registered calc.
Smoke run: call
Engine().get_all_calculations()and ensure expected names appear.Type check: run mypy/pyright with stricter settings to enforce phantom types at boundaries.
12) Common pitfalls (avoid these)
Returning Percent for intermediate rates; prefer Ratio until the edge.
Using float pow for CAGR; use Decimal + ln/exp.
Forgetting policy resolution (
pol = a.policy or b.policy or get_policy() or DEFAULT_POLICY).Mixing units silently; rely on
FinancialValueruntime checks, and keep overloads for common Money/Ratio ops for dev ergonomics.Using duplicate module names (
units.pyin multiple places). Keep a single canonical source.
13) Boilerplate you can reuse
Collection:
# registry_collections.py
from .registry import calc as _calc
class Collection:
def __init__(self, namespace: str = ""):
self.ns = namespace.strip(".")
def _qualify(self, name: str) -> str:
if name.startswith(":") or "." in name:
return name.lstrip(":")
return f"{self.ns}.{name}" if self.ns else name
def calc(self, name: str, *, depends_on: tuple[str, ...] = ()):
return _calc(self._qualify(name),
depends_on=tuple(self._qualify(d) for d in depends_on))
Policy-aware result policy:
pol = (a.policy if isinstance(a, FinancialValue) else None) \
or (b.policy if isinstance(b, FinancialValue) else None) \
or get_policy() or DEFAULT_POLICY
If you follow this checklist for each module—namespacing, typed signatures, policy/None handling, precision rules—you’ll end up with a coherent, strongly-typed, and cross-package-friendly calculation graph.