Andrei DubovikA. Dubovik
Blog

Modelling French Sovereign Debt in Haskell

Nov. 5, 2026

Unabridged Introduction

I did a project at work the other day, where I programmed a simulation of the evolution of sovereign debt for any given European country, following the specification outlined in the Debt Sustainability Monitor by the European Commission. From a purely technical perspective, that project was a downgrade from the projects at my previous job. Before, I would design from scratch and then implement an algorithm for incrementally computing prices for some hundreds of thousands of tokens, across multiple blockchains, multiple times a second, while keeping the computations robust to junk tokens and various price machinations. Now I was putting together a dozen yearly series and a computation that would run with a yearly cadence. Only later, when documenting the simulation, did I come to a conclusion that I had made a wrong architectural choice. Noticing that and imagining how a better alternative would look renders the project more interesting all of a sudden. A better alternative, jumping ahead, is a declarative style with one function for each recursive equation involved. This blog is about that.

This blog is also about Haskell. That is not to suggest Haskell is a language that is technically better suited for a declarative style of programming. The computation is still a directed acyclic graph, no Prolog or logical programming is required, and it might as well be written in C without much difficulty. Rather, by pure coincidence, I’ve been reading up on Haskell and the two topics overlapped in my head. Still, Haskell is also a commitment device: it feels more natural to write declarative programs in Haskell than imperative ones, and that nudges you in the right direction.

Let me start by outlining the architecture I used for my work project. It is easy to do for it is a typical one. Go with Python. Load the required subsets of public databases as Polars data frames. Combine. Temporarily switch to Numpy and Numba for a fast recursive simulation over one big data array. Proliferate some plots and tables. Or, as a high-level diagram,

EurostatECBHistorical DataSimulationHistorical & Forecast DataScenarioReport

The code was reasonably modular (no function was bigger than the height of my screen), purely functional except for IO (Polars is functional by design), and the stochastic simulations ran fast thanks to Numba. Some of my friends might say the docstrings could have used more lines, but shhhh... I was happy with the overall result.

And yet something was nagging at me. The many, many little rules. Under this scenario this variable in this year is computed thus. And under that scenario during those years it is computed so. And, by the way, these rules are different the year before, just in case you were thinking of adding backtesting. I am not saying the official rules themselves are nonsensical (some of them totally are), rather there is no coherent structure to all the rules, no way to neatly abstract them away into some table with parameters.

Fortunately, the scope of the project was small and that messiness never came back to haunt me. However, I can easily imagine it becoming expensive to maintain on a bigger simulation problem.

With the aforementioned approach the data moves in blocks. A block of years together, because those years are consecutive. A block of variables together, either because they come from the same dataset, or because they are all needed for a joint computation or a report. At least two consequences follow.

  1. Any specific variable and its historical and forecasted evolution across various scenarios is not directly traceable through the code. The variables are not to be found in any of the functions’ signatures: they stay hidden as Polars columns. Reasoning about the code becomes more expensive.

  2. The way the data is partitioned into blocks, even if well suited for the task in its original formulation, is unlikely to fit the future demand for new rules and new tests. Expanding the code becomes expensive.

So, instead of writing

df[i, DEBT] = df[i-1, DEBT] + ...

inside a loop inside a broader function I could write:

def debt(t): return debt(t-1) + ...

What does that bring? Seemingly more boilerplate, function calling overhead becomes noticeable, memoization becomes a hard requirement, as we shall see later on. Yet, these issues scale linearly, whereas the drawbacks of the standard approach scale superlinearly, judging from my humble experience. Therefore, at a certain project size this declarative approach becomes the cheaper one.

A Few Words on Haskell

I am learning the language as I am writing this blog. The linked lists and the functional parts are familiar: I have been doing an occasional hobby project in Lisp for over a decade now. I have worried I would miss my parentheses, but I’ve worried for nothing.

(min (sin 1.0) (cos 1.0))

is both valid Lisp and valid Haskell.

The type system is partially new to me. I have worked with semantic types, as opposed to machine types such as Int, in Rust but Haskell’s type system is more complete and—I am having difficulty nailing it—more prominent? Say, it is easier to learn and understand algebraic types when they are called such. In Rust, there is of course a product type called struct and a sum type called enum but I never thought of them as related on some fundamental level. Not as pieces in the Curry-Howard correspondence with propositional logic. Neither has it previously occurred to me that Option<T>.and_then() is nothing other than a monadic bind.

Types help ensure the correctness of your program. Types are also a prerequisite for compile-time polymorphism. And polymorphism, at the end of the day, simply allows you to give the same name to semantically same operations. The absence of the latter in Lisp always bugged me a little. My code would have elt, svref, gethash sprinkled around, which all retrieve an element at a given position, just from different containers. In a polymorphic language that could simply be elt or a more familiar []. High hopes for Haskell! I get map, mapM, mapM_ instead, which all do the same operation just in different contexts.

The “M” in mapM brings me to monads. Monads have been discussed to death on the Internet, so let me just remark it took me more thought to wrap my head around the Applicative class than the Monad class. It clicked when I thought to check if the following code works:

pure (+) <*> pure 1 <*> pure 2

On that, back to the main story.

Model Specification

The model we’ll discuss in this blog is a rather simplified version of the debt dynamics model outlined in the European Commission’s Debt Sustainability Monitor 2025.

Let us denote:

  • - gross domestic product in period ;

  • - sovereign debt at the start of period ;

  • - debt maturing during period .

  • - interest rate on the new debt issued at the start of period ;

  • - implicit interest rate on the outstanding debt at the start of period ;

  • - primary budget surplus (i.e. before paying interest on debt) in period ;

  • - budget surplus (i.e. after paying interest on debt) in period .

In the best traditions of Requests for Comments,

  t-1 t t+1 --|---------|---------|---------|--> Y_{t-1} Y_t Y_{t+1} M_{t-1} M_t M_{t+1} S_{t-1} S_t S_{t+1} B_t B_{t+1} r_t r_{t+1} rho_t rho_{t+1}

I am being pedantic about the “start of period” but for a good reason. In theoretical papers and textbooks the convention for a debt series is typically start of period, but in Eurostat it is end of period. Easy to confuse and fall victim to one of the two hard problems in computer science.

Additionally, let us define these ratios:

  • - debt maturing during period , expressed as a share of ;

  • - primary budget surplus in period , expressed as a share of .

The budget surplus starts with the primary budget surplus from which the interest payment on the full outstanding debt is deducted,

where we compute the nominal primary budget surplus from its GDP share,

The reason for modelling the primary budget surplus via its GDP share comes from it being a more fitting parametrization for defining scenarios. We will come to the scenarios later in the story.

The surplus is used to repay the maturing debt. If there is insufficient surplus, new debt gets issued. If the budget surplus exceeds the maturing debt, the country typically acquires new financial assets (or channels the money into its state enterprises). For simplicity, we’ll assume the country repays its non-maturing debt instead. Then both situations, whether the surplus is below or above the maturing debt, collapse to a single linear equation:

The new implicit interest rate is a mix of the previous implicit interest rate and the ongoing rate on new debt issuance:

where

is non-maturing (deferred) debt at the start of .

If the implicit rate equation smells suspicious, that is because it is a coarse approximation in its first summand. The rate on deferred debt next year will not be exactly the same as the implicit rate last year; it ultimately depends on the exact debt composition.

What remains is to specify the evolution of , , and . For the GDP, we’ll assume a steady growth path; for the primary budget surplus, expressed as a share of GDP, we’ll introduce scenarios; and for the maturing debt share we’ll carry the last observation forward, in essence assuming a stable debt composition. The details can be omitted for now but they will follow once we start coding the respective parts.

First Steps

This section’s code: refactor0-first-steps.hs.

Let us walk through the programming exercise backwards. We have the equations for the simulation, so we can start from those and then build our pipeline towards loading the historical data. We will make a few rewrites along the way, so as to introduce the building blocks gradually rather than all at once. Each rewrite is its own section and results in a program that can be compiled and run standalone, and these are linked to at the start of each section.

The European Commission does its debt sustainability exercise for various scenarios, including a stochastic simulation. The latter can be viewed as a collection of scenarios as well, just random ones. For simplicity, let us limit our attention to a few deterministic mock scenarios.

-- Simulation scenario data Scenario = Low | Medium | High deriving Show

We can start with the debt equation and the associated types.

-- Period index, a year in our case type Index = Int -- Account value, in million euros or as a ratio type Value = Double -- A function signature for any simulated series type ForecastSeries = Scenario -> Index -> Value -- Outstanding debt debt :: ForecastSeries debt s t = debt s (t-1) - surplus s (t-1)

And let us postpone modelling the budget surplus.

-- Mock surplus series surplus :: ForecastSeries surplus s t = -80_000

As written, the debt equation is missing the starting conditions (if compiled, it’ll run recursively until it exhausts all RAM). One option to introduce the starting conditions is a global cutoff: we use historical data till a given year, and we run the simulation after that. In practice, however, many series are ragged, because some are published with a bigger delay. A more flexible approach then is simply to start using historical data once we hit a period where it is available.

-- A function signature for any historical series type HistoricalSeries = Index -> Maybe Value -- Mock historical series for debt debtHs :: HistoricalSeries debtHs t = if t >= 2027 then Nothing else Just 3_500_000 -- Outstanding debt debt :: ForecastSeries debt s t = case debtHs t of Just x -> x Nothing -> debt s (t-1) - surplus s (t-1)

Admittedly, I am not writing an alternative history novel, so I have regretfully dropped the scenario argument from the historical series.

We are going to need to concatenate historical and simulated series for each variable, so let’s generalize that operation.

-- A function signature for combined historical and simulated series type CombinedSeries = Scenario -> Index -> Value -- Concatenate historical and simulated series withHistory :: HistoricalSeries -> ForecastSeries -> CombinedSeries withHistory h f s t = case h t of Just x -> x Nothing -> f s t -- Outstanding debt debtFc :: ForecastSeries debtFc s t = debt s (t-1) - surplus s (t-1) debt :: CombinedSeries debt = withHistory debtHs debtFc

Currying is nice. In any other language I know I would have to explicitly create an anonymous function and then return it as the result of withHistory.

At this point our minimal program compiles and runs, and the basics for historical and forecast series and how they recurse have been laid out. We can verify the recursion is running as intended by sprinkling trace messages throughout the code. Skipping over the details, here is such a trace when computing debt Medium 2028:

calling withHistory Medium 2028 calling debtHs 2028 -> Nothing calling debtFc Medium 2028 calling withHistory Medium 2027 calling debtHs 2027 -> Nothing calling debtFc Medium 2027 calling withHistory Medium 2026 calling debtHs 2026 -> Just 3500000.0 calling surplus Medium 2026 calling surplus Medium 2027

The trace confirms the recursion is working as intended. Before expanding the code to fully cover the mathematical specification outlined earlier, let us change tack and address the loading of historical data.

Historical Data

This section’s code: refactor1-historical-data.hs.

The Eurostat and ECB APIs fall outside the scope of this blog, which is going to be too big as it is, so I have simply prepared the necessary data as a collection of JSON files. There is one file per series; this way we can faithfully model the typical situation that each series gets fetched individually. The data is for France, for no specific reason really, and it’s the actual data from the aforementioned providers.

The Other Day in France, (c) Lucas Destrem
The Other Day in France, (c)

Here is head -22 debt.json as a quick preview:

{ "label": "Outstanding sovereign debt", "publicationDate": "2026-11-05", "trace": [ "https://data-api.ecb.europa.eu/service/data/ GFS/A.N.FR.W0.S13.S1.C.L.LE.GD.T._Z.XDC._T.F.V.N._T" ], "filter": { "geo": "France", "frequency": "annual", "order": "stock", "alignment": "start of period", "unit": "million euros" }, "values": [ { "year": 1996, "value": 701638.0 }, { "year": 1997, "value": 755585.0 },

I am old enough to remember going to a library to xerox pages from a statistics book with national accounts. I am certainly not nostalgic about that, but the book was easier to browse and to get a picture of what is available than when using Eurostat’s website or its SDMX 2.1 API. Also, I cannot resist but mention, the library did provide historical vintages. That is something neither Eurostat nor the ECB provide at the time of writing.

Haskell requires us to be explicit about the fact that we are using external data down the call stack. Planning ahead, let us store historical data in arrays, with one array per series.

{-# LANGUAGE DuplicateRecordFields #-} {-# LANGUAGE NoFieldSelectors #-} {-# LANGUAGE OverloadedRecordDot #-} import Data.Array.Unboxed -- Data for historical series type Table = UArray Index Value -- A dataset with all historical data data DataSet = DataSet { debt :: Table } deriving Show

There are two options to pass a DataSet around. Option one is to thread it through all the functions defined so far. Then the historical series can lookup the required numbers in the respective arrays. The forecast series would also need it, because they call the historical series in turn. Option two is to run all the computations in a Reader context, then the historical series can ask the Reader for the necessary data.

If I were writing C, I would just use global variables, and I’d worry about thread-safety when that need arises. In Lisp I would use dynamic bindings, and a quick search says there is the ImplicitParams extension, which gives typed dynamic bindings in Haskell. That reads like a handy type system hack but a hack nevertheless. In Lisp I can also have fun and request data from higher in the call stack by signalling a condition and then receiving the data via a restart, which is similar though not identical to algebraic effects, and the latter can also be done in Haskell via any number of popular effects libraries. In Rust I would use a structure to hold the historical data and would define the simulation functions as methods on that structure, which is just a different syntax for threading an extra argument down the call stack. Having remarked all that, this project is my first foray into Haskell, so obviously I am eager to play around with monads. A Reader it is.

import Control.Monad.Reader import Control.Monad.Trans.Maybe -- A dataset context type DS = Reader DataSet -- Mabye values inside a dataset context type DSM = MaybeT (Reader DataSet) -- A function signature for any historical series type HistoricalSeries = Index -> DSM Value -- Get a table element or Nothing if out of bounds on the right indexMaybe :: (Ix i, IArray UArray e) => UArray i e -> i -> Maybe e indexMaybe a i | i < fst (bounds a) = error "left out-of-bounds" | i <= snd (bounds a) = Just (a ! i) | otherwise = Nothing -- Outstanding debt debtHs :: HistoricalSeries debtHs t = MaybeT $ do table <- asks (.debt) pure $ indexMaybe table t

The indexMaybe function implements the earlier adopted convention that if historical data exists, then a Just value is returned and if the data is not yet available, then Nothing is returned. The exception is still raised if the data is requested for a period prior to the start of statistical observations, as otherwise the program might end up in an infinite loop, say if a forecast is accidentally requested for very early years.

Seeing how we’ll need to define multiple historical series in much the same way, we can abstract the common functionality.

-- Construct a function representing historical series fromTable :: (DataSet -> Table) -> HistoricalSeries fromTable getter t = MaybeT $ do table <- asks getter pure $ indexMaybe table t debtHs :: HistoricalSeries debtHs = fromTable (.debt)

Some refactoring remains. We have already redefined the signature for the historical series. The signatures for the forecast and combined series change as well and so does the definition for withHistory. The definitions for forecast series stay as they are. (For convenience, I’ll repeat the signature for a historical series.)

-- A function signature for any historical series type HistoricalSeries = Index -> DS (Maybe Value) -- A function signature for any simulated series type ForecastSeries = Scenario -> Index -> DS Value -- A function signature for combined historical and simulated series type CombinedSeries = Scenario -> Index -> DS Value -- Concatenate historical and simulated series withHistory :: HistoricalSeries -> ForecastSeries -> CombinedSeries withHistory h f s t = do x <- runMaybeT $ h t case x of Just x -> pure x Nothing -> f s t

Or, with a cryptic one-liner:

withHistory :: HistoricalSeries -> ForecastSeries -> CombinedSeries withHistory h f s t = runMaybeT (h t) >>= maybe (f s t) pure

Having drafted a pipeline from tabular data to functionally defined series, we can implement the loading of said data. This part is straightforward: we just use a suitable library, which is Aeson in the case of Haskell. Though I have to say, it takes me a while to decide on the exact Aeson functions that I need. Say, eitherDecodeFileStrict returns a Left String on some errors and throws an exception on other errors. I imagine there is a reason for the discrepancy, but that does not make it any less confusing. Python is easier in this regard: outside of some high performance solutions there is just json.loads and nothing else.

import Data.Aeson (FromJSON, throwDecodeStrict) import Data.ByteString qualified as BS import GHC.Generics -- Raw JSON value data RawValue = RawValue { year :: Int, value :: Double } deriving (Generic, Show) instance FromJSON RawValue -- Raw JSON series data RawSeries = RawSeries { values :: [RawValue] } deriving (Generic, Show) instance FromJSON RawSeries -- Construct a table from JSON data valuesArray :: [RawValue] -> Table valuesArray values = array (tmin, tmax) pairs where tmin = minimum years tmax = maximum years years = [v.year | v <- values] pairs = [(v.year, v.value) | v <- values] -- Load a table from a JSON file loadTable :: FilePath -> IO (Table) loadTable path = do content <- BS.readFile path rawSeries <- throwDecodeStrict @RawSeries content pure $ valuesArray rawSeries.values

Tying it all together in a simple example,

-- Example usage main :: IO () main = do debtTbl <- loadTable "data/debt.json" let ds = DataSet { debt = debtTbl } putStrLn $ show $ runReader (debt Medium 2028) ds

and compiling gives

draft2-history.hs:95:27: error: [GHC-39999] • No instance for ‘Num (DS Value)’ arising from a use of ‘-’ • In the expression: debt s (t - 1) - surplus s (t - 1) In an equation for ‘debtFc’: debtFc s t = debt s (t - 1) - surplus s (t - 1) | 95 | debtFc s t = debt s (t-1) - surplus s (t-1) | ^

The return values from debt and surplus are not numbers anymore but rather numbers wrapped in a DS monad, so we cannot subtract them from one another. Somehow it has completely skipped my mind this is going to happen.

At this point what am I to do? I take a walk to my favourite coffee place in the Hague, at Prinsestraat 114. The place has changed owners once but the coffee stayed excellent. What I am thinking is, I can abandon my DS monad and go back to the idea of passing a DataSet record around as an extra argument to all the functions. Or, I can write all the forecast functions using do notation, but then the result of each subcall would need to be assigned to its own variable, e.g. debt_s_t1 <- debt s (t-1). That is a lot of visual noise. Or, I can define what it means to do arithmetic on the DS monad. The choice is obvious, down the rabbit hole I go. Hopefully the compiler can optimize some of that away.

-- Algebraic operations over values in a dataset context instance Num (Maybe Value) where (+) = liftA2 (+) (-) = liftA2 (-) (*) = liftA2 (*) negate = fmap negate abs = fmap abs signum = fmap signum fromInteger = pure . fromInteger instance Fractional (DS Value) where (/) = liftA2 (/) recip = fmap recip fromRational = pure . fromRational

And similarly for the Maybe values inside a DS context, so that we can do algebra on historical series as well. (There must be a way to avoid copy-pasting, but I haven’t figured it out.)

instance Num (DSM Value) where (+) = liftA2 (+) (-) = liftA2 (-) (*) = liftA2 (*) negate = fmap negate abs = fmap abs signum = fmap signum fromInteger = pure . fromInteger instance Fractional (DSM Value) where (/) = liftA2 (/) recip = fmap recip fromRational = pure . fromRational

The updated program compiles and runs. This elegant fix raises a more general question, though. In which languages can one write f = g + h to mean f x = g x + h x and will it be a zero-cost abstraction? But the hour is small and I am thinking not all questions need answers.

Filling in the Details

This section’s code: refactor2-complete-model.hs.

So, the prototype is working. The next step is to fill in the details. All the input data has been prepared in advance, and we simply load it and link it with the corresponding historical series.

-- A dataset with all historical data data DataSet = DataSet { debt :: Table, debtMaturing :: Table, gdp :: Table, primarySurplus :: Table, rate :: Table, rho :: Table } deriving Show -- Load all historical data loadDataset :: IO (DataSet) loadDataset = do debt <- loadTable "data/debt.json" debtMaturing <- loadTable "data/debt-maturing.json" gdp <- loadTable "data/gdp.json" primarySurplus <- loadTable "data/primary-surplus.json" rate <- loadTable "data/rate-newdebt.json" rho <- loadTable "data/rate-implicit.json" pure $ DataSet { debt, debtMaturing, gdp, primarySurplus, rate, rho } -- All historical series debtHs :: HistoricalSeries debtHs = fromTable (.debt) debtMaturingHs :: HistoricalSeries debtMaturingHs = fromTable (.debtMaturing) gdpHs :: HistoricalSeries gdpHs = fromTable (.gdp) primarySurplusHs :: HistoricalSeries primarySurplusHs = fromTable (.primarySurplus) rateHs :: HistoricalSeries rateHs = fromTable (.rate) rhoHs :: HistoricalSeries rhoHs = fromTable (.rho)

The forecast for the interest rate can be computed from the observed yield curves. As with the Eurostat API earlier, working with yield curves would take us too far off-topic. Instead, I have done the computations elsewhere and have saved the results as a historical series, which the above code exposes as rateHs. (This series runs till 2046.) As for the related forecast series, we’ll say it must come from the historical series.

-- Interest rate on new debt rateFc :: ForecastSeries rateFc s t = undefined rate :: CombinedSeries rate = withHistory rateHs rateFc

In economics and especially in macroeconomics there inevitably comes a moment when one needs to make assumptions. Following the European Commission we are going to assume that the share of sovereign debt that matures in any forthcoming year stays constant. In practice this share fluctuates, though relatively slowly.

-- Maturing debt shareMaturingHs :: HistoricalSeries shareMaturingHs t = debtMaturingHs t / debtHs t shareMaturingFc :: ForecastSeries shareMaturingFc s t = shareMaturing s (t-1) shareMaturing :: CombinedSeries shareMaturing = withHistory shareMaturingHs shareMaturingFc

I would like to pause here for a moment. What we have here is a certain rule for computing one of the series. The rule is simple yet it still has two steps, so it is not trivial in that sense. First, we compute a ratio using historical data. Then we carry the last observation forward. Imagine we have a Polars dataframe, where the columns debt and debt_maturing have been populated with historical data and which contain null for forecast data. The analogous computation in Python would be:

data = data.with_columns( share_maturing = (pl.col('debt_maturing')/pl.col('debt')).forward_fill() )

This small snippet hints at the tradeoff I was talking about at the beginning of the blog. Discounting the machinery we have set up so far and discounting the type signatures, the Haskell code is the same length as the Python code, so there is no difference there. The upside of the Python code is that I have written it in under a minute, whereas setting up the machinery for the declarative approach took me a couple of weeks I reckon, albeit a part of that was learning Haskell. The upside of the Haskell code, on the other hand, is that it clearly documents the rule. The historical share is computed using historical data, check. The forecasts series simply use the previous value, check. Importantly, when I need to look up or change the rule, say for the forecast series, there is but one place in the code where it can possibly be: where shareMaturingFc is defined. Whereas in Python I’ll have to grep for “share_maturing”, and I’ll have to look around to understand whether debt_maturing and debt contain historical data only or whether those columns contain forecast data as well. And the latter is less evident than it might seem. For instance, after the above Python snippet has run, the Polars dataframe will have a mix of both purely historical data as well as historical data augmented with forecasts. Imagine that being just one out of several dozen rules.

I do not think it is controversial to say the aforementioned tradeoff exists. What people would disagree on, of course, is how that tradeoff scales with project size. In my humble experience, in a Python project organized around Polars or NumPy arrays, as rules grow in complexity, any given rule tends to get scattered across multiple functions, with its pieces buried inside increasingly cryptic Polars or NumPy expressions. That makes it difficult to understand and reliably update those rules. A declarative approach, whether done in Haskell or in Python for that matter, imposes a stricter discipline about what goes where. It also allows collecting the code for a single rule in a single place, because the division into functions is more granular and so it is that much easier to move code chunks around.

Still, thinking recursively remains mentally demanding and that starts to show in the declarative approach with more interesting rules. In our case, let’s consider the primary budget surplus and let’s use that one to introduce scenarios.

Under the medium scenario the primary budget surplus, as a share of GDP, will stay at its median value, computed over the past 30 years. Under the low and the high scenarios the primary budget surplus, once again as a share of GDP, will stay at its 10th and its 90th percentiles respectively. To be clear, these are not the scenarios that the European Commission uses, rather some example scenarios that I’ve pulled out of thin air. Still, historical performance is often the best predictor, unless there are clear reasons why a break in the pattern is expected. As for the underlying nominal GDP, we’ll simply assume it grows at 3 pp per annum (1 pp real growth rate and 2 pp target ECB inflation rate).

Let’s unpack this construction step by step. Formalizing the GDP logic is straightforward:

-- GDP gdpFc :: ForecastSeries gdpFc s t = gdp s (t-1) * nominalGrowth where nominalGrowth = 1.03 gdp :: CombinedSeries gdp = withHistory gdpHs gdpFc

Let’s move on to quantiles next. The forecasting rule, as described just prior, differs depending on the forecasting horizon. If we are computing the forecast for the first year, just past the historical data, we need to compute the respective quantiles over that historical data. If we are computing the forecast for the second and consequent years, then we simply carry the forecast from the previous year forward. Another way to phrase it, if we can compute the quantiles on the historical data just prior to the forecast, we use those, otherwise we carry the last observation forward. And so one idea is to write a quantile estimation function that returns a Just value if the requested historical data is available and Nothing otherwise.

We’ll use the Statistics package for the quantile function. We need to collect the data into a Vector first, and after that we can write our estimator using Statistics.Quantile as a backend.

import Data.Vector.Unboxed (Vector) import Data.Vector.Unboxed qualified as Vector import Statistics.Quantile qualified as Quantile -- Convert historical series to a Vector toVectorHs :: HistoricalSeries -> Index -> Index -> DSM (Vector Value) toVectorHs h start end = Vector.generateM (end - start + 1) (h . (+ start)) -- Compute quantiles over historical series quantile :: HistoricalSeries -> Index -> Index -> Int -> Int -> DSM Value quantile h start end k q = fmap f v where f = Quantile.quantile Quantile.s k q v = toVectorHs h start end

A technical shortcoming of this implementation is that it traverses the historical series left to right. Say, if the data ends in 2025 and we request a quantile over 2020–2026, then the historical series gets called 7 times before returning Nothing. Whereas were we to traverse the data right to left, then the program could short-circuit after computing the historical series for 2026 and getting Nothing back. This is easy to address:

-- Construct a vector backwards (for early short-circuiting on Nothing) generateRM :: (Monad m, Vector.Unbox a) => Int -> (Int -> m a) -> m (Vector a) generateRM n f = Vector.reverse <$> Vector.generateM n (f . (n0 -)) where n0 = n - 1 -- Convert historical series to a Vector toVectorHs :: HistoricalSeries -> Index -> Index -> DSM (Vector Value) toVectorHs h start end = generateRM (end - start + 1) (h . (+ start))

Now that we have these prerequisites sorted out, we can proceed with defining the logic for the primary budget surplus series. First, we define the historical primary budget surplus expressed as a share of GDP, that is .

primarySurplusGdpHs :: HistoricalSeries primarySurplusGdpHs t = primarySurplusHs t / gdpHs t

We can then implement the core forecasting logic, where we also add a few more types and functions to keep the exposition clean.

-- A function signature for a specific forecast -- (This is a forecast that can return Nothing for years where it is not defined.) type SpecificForecast = Scenario -> Index -> DSM Value -- Stack forecast series (this is a variation of withHistory, in a sense) stackForecasts :: SpecificForecast -> ForecastSeries -> ForecastSeries stackForecasts f1 f2 s t = do x <- runMaybeT $ f1 s t case x of Just x -> pure x Nothing -> f2 s t -- Primary budget surplus (share of GDP) primarySurplusGdpFc1 :: SpecificForecast primarySurplusGdpFc1 s t = quantile primarySurplusGdpHs (t-window) (t-1) k 10 where window = 30 k = case s of Low -> 1 Medium -> 5 High -> 9 primarySurplusGdpFc :: ForecastSeries primarySurplusGdpFc s t = primarySurplusGdp s (t-1) primarySurplusGdp :: CombinedSeries primarySurplusGdp = withHistory primarySurplusGdpHs (stackForecasts primarySurplusGdpFc1 primarySurplusGdpFc)

We’ll get back to this one in a moment, but let us finish first by defining the forecast for the nominal primary budget surplus, which is just the respective share times the GDP.

-- Primary budget surplus (nominal) primarySurplusFc :: ForecastSeries primarySurplusFc s t = primarySurplusGdp s t * gdp s t primarySurplus :: CombinedSeries primarySurplus = withHistory primarySurplusHs primarySurplusFc

Coming back to primarySurplusGdp*. On one hand, the rule is easy to read. As discussed, we try to compute the historical quantile on the data just prior to the forecasting year. If that works, we use that number for the forecast. If that doesn’t work, we simply carry forward the value from the previous year, where that previous year’s value comes from the combined series. On the other hand—and I am just speaking for myself here, others might find it trivial—I still need to lean back in my chair, close my eyes, visualize the dependency graph and convince myself this recursive code is actually doing what it looks like it is doing.

Having the primary budget surplus in place, all that remains is to model the two remaining equations: one for the budget surplus after paying interest on debt, , and one for the implicit interest rate, . These are mutually dependent, so we add them in one go.

-- A function signature for accounting identities type AccountingIdentity = Scenario -> Index -> DS Value -- Budget surplus surplus :: AccountingIdentity surplus s t = primarySurplus s t - debt s t * rho s t -- Implicit interest rate rhoFc :: ForecastSeries rhoFc s t = (interestDeferred + interestNew)/debtTotal where interestDeferred = debtDeferred * rho s (t-1) interestNew = (debtTotal - debtDeferred) * rate s t debtDeferred = (1 - shareMaturing s (t-1)) * debt s (t-1) debtTotal = debt s t rho :: CombinedSeries rho = withHistory rhoHs rhoFc

The equation for the implicit interest rate simply mirrors its mathematical definition. The equation for the budget surplus is more interesting from a design perspective, because it is an accounting identity and so it doesn’t need to be split between historical and forecast series. For clarity, I marked it as such using a type synonym.

On a side note, there seems to be a difference between inline type annotations, as seen in Rust or Python, and standalone function signatures, as seen in Haskell. With standalone signatures it is trivial to assign a semantic type to a whole function. With inline type annotations, there is simply no syntax to do so. And an attempt at cheating,

from enum import Enum, auto from collections.abc import Callable class Scenario(Enum): Low = auto() Medium = auto() High = auto() type Index = int type Value = float type AccountingIdentity = Callable[[Scenario, Index], Value] surplus: AccountingIdentity def surplus(s, t): return primary_surplus(s, t) - debt(s, t)*rho(s, t)

gives error: Name "surplus" already defined on line 13 [no-redef] when checked with mypy (outside of mypy it’s valid Python though, the code gets interpreted without errors).

The toy model is complete now. Phew. As a final step, let’s draft a short summary function that collects the past and forecasted debt dynamics across scenarios and pretty prints the results. The forecasts we have computed so far are in nominal terms but for comparison across years it would be more clear to have those in relative terms. That would be debt to GDP in our case.

-- End-of-year debt to GDP (aligns with official reporting) debtGdp :: AccountingIdentity debtGdp s t = debt s (t+1) / gdp s t

Finally, we can next define a few auxiliary types for a summary table, quick and dirty.

import Data.List import Text.Printf -- A row in a summary table data SummaryRow = SummaryRow { year :: Index, low :: Value, medium :: Value, high :: Value } instance Show SummaryRow where show row = printf "%4d%12.1f%12.1f%12.1f" row.year (100 * row.low) (100 * row.medium) (100 * row.high) -- A summary table newtype Summary = Summary [SummaryRow] instance PrintfArg Scenario where formatArg x = formatArg (show x) instance Show Summary where show (Summary rows) = printf "%4s%12s%12s%12s\n" "Year" Low Medium High ++ intercalate "\n" (map show rows)

With these preliminaries checked, we can draft a function that populates a summary table, and then do an example call from main.

-- Compute a summary table summary :: Index -> Index -> DS Summary summary start end = fmap Summary $ traverse row [start..end] where row t = SummaryRow t <$> debtGdp Low t <*> debtGdp Medium t <*> debtGdp High t -- Example usage main :: IO () main = do ds <- loadDataset putStrLn $ show $ runReader (summary 2025 2035) ds

This—not quite final—version compiles, runs, and prints

Year Low Medium High 2025 115.7 115.7 115.7 2026 119.5 116.3 113.8 2027 123.8 117.3 112.4 2028 128.6 118.7 111.3 2029 133.7 120.5 110.5 2030 139.3 122.6 110.0 2031 145.2 125.0 109.8 2032 151.4 127.7 109.8 2033 158.0 130.6 110.0 2034 165.0 133.8 110.3 2035 172.2 137.2 110.9

or, as a plot,

French debt to GDP
Low
Medium
High

France is likely on a downward spiral and the chance that the rest of the union has to pay for the French debt with higher inflation is non-negligible, but that’s a problem for another day. A more pressing issue is that the code takes its sweet 2.5 s to run, which brings us to memoization.

Memoization

This section’s code: refactor3-memoization.hs.

A humble loop that iterates over years in ascending order would solve the debt dynamics in linear time. However, the code, as written so far, has exponential complexity. For instance, computing triggers the computation of and of , among other variables. And computing triggers the computation of and once more. So, computing those two values at time triggers a computation of at least four values at time . By induction, we get exponential complexity. Say, when computing a summary table for 2025–2035 the function rhoFc gets called 847,062 times. “Yes, chief, we have implemented the new architecture. But you’re gonna need a bigger server.” Of course, a standard way to avoid recomputations is to memoize the results of the computations already done.

Almost all the way till this point I expected memoization to be easier in a purely functional programming language than elsewhere. Ironically, that is not true. The way we have it, we want to memoize computations inside a Reader monad. We need a suitable memoization library for that, which seems to be monad-memo. So far so good. That library uses a shared cache across all functions, meaning f 13 and g 13 will share a cache for the argument 13 even if the functions evaluate to distinct values at that point. Wait, what?!

import Control.Monad.Memo type CachedIO = MemoT Int Int IO Int f :: Int -> CachedIO f 13 = return 1 g :: Int -> CachedIO g 13 = return 2 h :: Int -> CachedIO h 13 = do y1 <- memo f 13 y2 <- memo g 13 -- one and same cache with the previous line return $ y1 + y2 main :: IO () main = do y <- startEvalMemoT (h 13) putStrLn $ show y

will print 2. No compiler warning to that end, no nothing. My knowledge of Haskell is preliminary at the moment, so I cannot say if the library could have been better designed or if that is the real limitation of the language itself. In either case, I had this rosy vision of Haskell and its type system that keeps you safe and that vision is currently fraying at the edges.

To work around the limitation, we will need to manually mark to which function which cache belongs. So, we start by creating a list of tags, one for every function defined with withHistory.

-- Tags for distinguishing caches of combined series data CacheTag = Debt | Gdp | PrimarySurplus | PrimarySurplusGdp | Rate | Rho | ShareMaturing deriving (Eq, Ord, Show)

Here we have already added deriving(Eq, Ord), which is required by the memoization package as it uses a binary tree by default for storing cached values. The scenario type needs the same adjustment.

-- Simulation scenario data Scenario = Low | Medium | High deriving (Eq, Ord, Show)

The core refactoring is fairly limited. The return type for the combined and forecast series needs to change, so those series can work in a context that supports memoization.

-- A cached dataset context type DS = MemoT (CacheTag, Scenario, Index) Value (Reader DataSet)

All combined series are constructed using withHistory, so that is also the only place where memoization needs to be added.

-- Concatenate historical and simulated series withHistoryNocache :: HistoricalSeries -> ForecastSeries -> CombinedSeries withHistoryNocache h f s t = do x <- lift $ runMaybeT $ h t case x of Just x -> pure x Nothing -> f s t withHistory :: CacheTag -> HistoricalSeries -> ForecastSeries -> CombinedSeries withHistory tag h f s t = memo (\(tag, s, t) -> withHistoryNocache h f s t) (tag, s, t)

Here we have added CacheTag as one of the arguments so that the caches can be distinguished from one another. Consequently, we also need to tag each call to withHistory. (I couldn’t think of a clean way to avoid this boilerplate tagging.)

debt :: CombinedSeries debt = withHistory Debt debtHs debtFc gdp :: CombinedSeries gdp = withHistory Gdp gdpHs gdpFc primarySurplus :: CombinedSeries primarySurplus = withHistory PrimarySurplus primarySurplusHs primarySurplusFc primarySurplusGdp :: CombinedSeries primarySurplusGdp = withHistory PrimarySurplusGdp primarySurplusGdpHs (stackForecasts primarySurplusGdpFc1 primarySurplusGdpFc) rate :: CombinedSeries rate = withHistory Rate rateHs rateFc rho :: CombinedSeries rho = withHistory Rho rhoHs rhoFc shareMaturing :: CombinedSeries shareMaturing = withHistory ShareMaturing shareMaturingHs shareMaturingFc

The function for stacking forecasts needs a small adjustment as well, given its return type has changed.

-- Stack forecast series (this is a variation of withHistory, in a sense) stackForecasts :: SpecificForecast -> ForecastSeries -> ForecastSeries stackForecasts f1 f2 s t = do x <- lift $ runMaybeT $ f1 s t case x of Just x -> pure x Nothing -> f2 s t

Finally, when running the program, we need to run both the reader and the memoization monads.

-- Example usage main :: IO () main = do ds <- loadDataset putStrLn $ show $ (runReader . startEvalMemoT) (summary 2025 2035) ds

This version compiles, runs in 0.035 s, calls rhoFc 30 times—we are back to linear complexity—and prints the same forecasts as before.

Potentially, one can optimize memoization further by switching its backing storage from a binary tree to a dense array. I have tinkered with that for a while and, while the monad-memo library does provide array-based and vector-based caches, their interface is less composable and does not seem to fit the architecture at hand. So, I am calling it a day. The Haskell code is complete.

What happens, conceptually, when we move from an imperative to a declarative approach for debt simulation? In my original implementation that used Polars and NumPy there was, at some point in the program, one big array with all the data. With the declarative approach there is still a container holding exactly the same data: it is the binary tree used as the underlying storage for memoization. In both cases, as the program progresses, the array or the binary tree gets filled with the forecasted values, from earlier to later years. So, that part is effectively the same. However, there are also two notable differences between the approaches. First, how the business logic gets organized across functions changes. With the imperative approach, the business logic is orthogonal to the program architecture and that logic gets split across functions in somewhat arbitrary ways. With the declarative approach, the functions are more granular, the whole program is more malleable, and the business logic can be better aligned with the overall architecture. That is my main proposition. Second, the computation as a whole can be viewed as a directed acyclic graph. With the imperative approach, you have to walk that graph manually, carefully thinking about sequencing: what needs to be computed first, what second, etc. That is where hard to capture bugs live. (I am speaking from experience here, unfortunately.) With the declarative approach you, well, declare that DAG and the code does the walking for you, using depth-first search. That is a genuine optimization for the development process, even when accounting for the occasional head-scratching when reasoning about recursion.

There is also a third change when going from an imperative to a declarative style, but of a purely technical sort: many more function calls. Which brings the remaining reader to the fun part.

Haskell, Rust, Benchmarks

For debt sustainability analysis better performance is not needed. It is nice to have if stochastic scenarios are considered but even then, whether a program takes a second or a minute to run hardly matters if the underlying data is updated yearly. That is to say, I don’t have a business case for spending time on benchmarking the Haskell code from this blog, or the declarative approach more generally. Luckily, this write-up is a hobby project, so my own curiosity is all that is needed to commit some time to run a few benchmarks.

My plan is this. First, I’ll rewrite the Haskell program in Rust, keeping as close to the Haskell code as possible, just sans monadic return types. I reckon Rust to be a good choice for a baseline benchmark: while at times it is possible to write more performant numerical code in C, for most projects Rust is a better overall choice. Second, I’ll rewrite this new Rust program once again, but now in an imperative style, with one big array and one loop over it.

Having made a plan and some coffee, I simply run with the plan. And this is what I get (the rows in the table link to the respective code):

Language (paradigm)100k runs
Haskell (declarative)8.05 s
Rust (declarative)2.08 s
Rust (imperative)0.21 s

LLVM cannot optimize away a system of memoized recursive function calls, no surprises here, plus the functions themselves are tiny. And Haskell has a runtime responsible for lazy evaluation and garbage collection. Hence the results.

For me personally, my blogs are typically a way to organize my own thoughts. And to fiddle with natural and programming languages at my own pace, which I rather enjoy. In particular, I did the Haskell to Rust rewrite by hand, which in light of the stated goals might seem less strange than it otherwise would nowadays. And while doing so, I was surprised by how similar the basic grammars of these languages are. With the exception of monads, which simply don’t fit idiomatic Rust, the rewrite was almost one-to-one. I actually spent more time doing the imperative rewrite, not least because that one turned out to be buggy in two places...

As for drawing a conclusion, I still think the declarative approach is better. Who is going to believe my micro benchmarks anyway?

A Few More Words on Haskell

A monad can be viewed as a runtime model of computation, i.e. it prescribes how any specific computation gets executed at runtime. As such, all programming languages use monads. However, most languages only support a predetermined set of monads but doing so allows them to provide syntax and compiler support tailored specifically to those monads. An easy example is the Result type in Rust, with its dedicated ? operator. Any async/await functionality, as far as I understand it, is a monad. Strict evaluation languages with untyped effects all use an IO monad that is implicit in their syntax, typically with the semicolon or the newline being the bind operator.

Haskell operates at a higher level of abstraction, where a monad is just a type like any other. Good for learning: Maybe, lists, IO, async/await, these are all different runtime models of computation, and certain code can be polymorphic over them. Good for working with niche models of computation, I imagine, e.g. with probabilistic programming. Or—just a thought—an AI monad to keep the slop contained. But realistically, how often do we need to reach for custom models of computation?

Debt Modelling
The Yubikey
Comput. Linguist.
Search Algorithms
RePEc Map
RePEc Trends
# Hash Tables
Lambda Pipes
On Lisp
Motorbike Diaries
Raspberry Pi