Breaking Circular Imports in Python Without Losing Type Safety

Orcaset is a framework for building financial statement models in code. It gives users, particularly AI agents, tools to directly build and run financial analysis. There are experimental libraries for both Python and OCaml. This post primarily considers the Python version.
At its core, Orcaset builds models as line_item(dates) -> value functions. In other words, line items are represented by functions that accept some period or date and return the value for that period or date. This structure maps to a financial statement layout where line items are functions, columns are function arguments, and cells are the return values.

This setup is intuitive and makes it easy to answer questions in the form "how much revenue accrues from dates X to Y?" or "what is the total cash balance on date Z?"
While the
line_item(date) -> valuestructure is effectively how Orcaset builds models, users don't necessarily define these functions directly. Instead, users might define iterators describing how line item values unfold over time, which Orcaset wraps into an accessor function in the formline_item(date) -> valueAdditionally, return values are not rawfloatorint, but instead lazily executed formulas that resolve circular value-level dependencies and trace execution paths.
Orcaset provides combinators to work with line item functions. For example, costs could be projected based on some constant expense margin and profit could be defined as the sum of revenue and costs.
Definitions reference other line item dependencies by referencing them directly. For example, costs depends on revenue by direct reference. Direct referencing makes it easy to trace dependencies across the codebase and resolve their types. Unfortunately, it can also make code hard to distribute across files.
Consider a simple debt model that capitalizes interest over time. Interest depends on the amount of outstanding debt at the beginning of the period, and the debt balance depends on the accrued interest. In this case, the interest and debt functions are mutually dependent (although values are not, since they always look back to the prior period). A simple definition would look like this:
This works fine as long as interest and debt are defined in the same file. debt eagerly consumes interest in the point.accumulate combinator, but it's defined after interest. interest doesn't try to access debt until the body is executed, at which point it's already defined. All the references work as intended. No undefined variables are encountered.
This breaks if interest and debt are defined in separate files, though. For example, it might make sense to define interest with other income items and debt with other balance sheet items. However, since each module would need to import the other, this would raise a circular module import error.
Orcaset takes the simplest resolution: delay imports until they are needed with local import statements. This breaks the import circularity by delaying imports until they are needed, *after* the module has been fully defined.
Local imports generally aren't great since they bury module dependencies in functions instead of declaring them at the top of the file. They also add a bit of overhead since the import is evaluated every time the function is called. This overhead is negligible, though, since Python caches module imports and, at least in this case, Orcaset caches the generator so that it is only called once per evaluation per context.
There's a natural place to break the circularity in the interest definition. But what if interest also used some convenience constructor like span.scale? Where would you localize the debt import? Orcaset permits dependencies in convenience constructors to either be the dependency directly or a thunk that returns the dependency. Lambda functions cannot contain imports, unfortunately, so a separate def function is required. The updated interest would look something like:
This won't actually work because `debt` would need to be defined differently as an over-time `span` series rather than a point-in-time series, but the dependency thunk pattern is valid.
The separate thunk with local imports isn't the most beautiful pattern, but in practice this import-thunk escape hatch isn't needed frequently.
Alternative approaches
It's completely valid to ask whether the library is just poorly defined if the recommended path includes local imports. It might be. It's still experimental. So far, local imports seem like the least bad approach. They have some nice properties compared to alternatives.
Co-location: Dependencies are explicit within the line item definition. Dependencies between line items are clear and in a single place.
Typed: Direct value references are easy for static type checkers to evaluate.
Statically complete: Since static type checkers can track all dependencies, incomplete models raise an error when they are written, not at runtime.
Terse: No separate manifests or other type declarations enumerating expected line items within a model (which would typically number in the hundreds).
Other considered approaches include the following.
Organize without circular imports
It would be great if all mutually dependent line items could just be defined in the same file. This is the preferred solution. Unfortunately, the world isn't that tidy.
In a standard three-statement model, there are clear links between statements.

Organizing by statement is a clear and logical way to organize code. Trying to put all circular dependencies in the same file would devolve into the entire model being in a single file. Technically fine, but harder to navigate and maintain.
A related idea is to define all types in a standalone, independent file. Models often have hundreds of line items within a single company model (and even more at the portfolio level). Maintaining a comprehensive line item manifest adds significant organizational overhead and would make modifications more complicated.
Context dictionary
The primary idea here is that some context dictionary would hold functions keyed by strings.
Dependencies could then be retrieved by accessing the context dictionary.
Since dependencies are retrieved through the context with strings, cross-module imports aren't needed anymore.
There are two issues with this approach.
Typing: Return values from the dictionary aren't typed, or at least are only broadly typed to the dict's value type.
Static verification: Existence of the dependency within the context dict is unknown until runtime.
The lack of static verification could be mitigated by a build step that checks all required dependencies exist, but it still leaves a gap between write-time and runtime verification. If the writer and end-user are different (different teams, agent-human, agent-agent, etc.), static verification is strongly preferred so issues are raised upfront.
Builder pattern
Instead of defining line items directly, you could define functions that take dependencies as an argument and return the line item value. There are a couple variants this could take.
First, builder functions could take other line item builders.
Alternatively, some "model" type could be provided instead.
There are a couple drawbacks in either case. First, some higher-level constructor needs to build the line items correctly. If the builder function takes a generic dependency type (e.g. def costs(revenue: SpanSeries)), then the caller needs to know which span series the constructor expects and pass it correctly. Not the end of the world, but it adds a point of silent failure. It gets increasingly hard to maintain as models grow and are modified. If builders take typed dependency parameters (e.g. def costs(revenue: Revenue)), then you're back to figuring out where types should be defined to avoid circular imports.
Wrap Up
Cyclic dependencies are a pain. Local imports work, but they aren't great. Independent type declaration files, or some other solution, may end up being a better long-term path. If you have opinions on the best approach, please join the discussion or post an issue!