Skip to content

Compilation budgets

CompilationBudget is an immutable set of optional limits on compiler operation starts. Pass it through the separate budget= keyword on ContainerBuilder.build() or ScopeBuilder.build(). A limit accepts an exact built-in non-negative int; None means unlimited and 0 refuses the first operation in that unit. Booleans, integer subclasses, negative values and other types are rejected. Omitting budget creates no allowance state. CompilationBudget() enables counting with every limit unlimited.

from clean_ioc import CompilationBudget, CompilationProfiler, ContainerBuilder

class Leaf:
    pass

builder = ContainerBuilder()
builder.register(Leaf)
profile = CompilationProfiler()
with builder.build(
    budget=CompilationBudget(graph_occurrences=45, active_dependency_depth=3),
    profile=profile,
) as container:
    assert isinstance(container.resolve(Leaf), Leaf)
    assert dict(profile.report().budget_usage)["graph_occurrences"] == 45

A single leaf expands into 45 compiler occurrences because compilation also prepares its ordinary and managed providers and provider collections. Its public graph has one root, and its synthetic parent paths reach depth three. Limits use compiler work, rather than public graph visits or retained executable steps.

Limit One admitted unit
graph_occurrences Creation of a component draft or new synthetic preview occurrence. Includes source metadata, visibility/scope-slot views, recursive expansion, and provider/overlay clones. Reusing an existing frozen record or executable step does not itself count; creating its metadata clone does.
active_dependency_depth Maximum length of the active component parent path at occurrence creation, including the new component. Roots have depth one. Synthetic providers, collections, clone branches and their targets count. Independent roots do not add their depths together.
specialization_materializations Start of constructing a new specialized registration after the compiler has established that a cached registration or closed no-op cannot satisfy it. Cache hits do not count. Earlier binding/dependency preparation is a separate preparation operation.
generated_template_outputs Start of producing one new registration/decorator template output, before calling its factory. Invalid returns or subsequent failed materialization consume the unit. Already frozen inherited registration-template outputs do not count again. Decorator templates count the outputs they actually regenerate.
diagnostic_attempts Start of one recovery compilation for a closed root/boundary root after a failed primary compile. The primary compile is excluded. A failed recovery attempt still consumes its unit.
preparation_operations One discovery import attempt; one package-enumeration iterator advance (including terminal advance); one subclass candidate examination, including rejected/abstract candidates; one template source examination; one invoked source/visibility filter or template factory; one uncached specialization preparation; one invoked registration, parent, selection, preference, argument derivation, pre-configuration/decorator applicability, decorator position or provider-map key callback; one entry-point/warm-up selection callback; one build validation rule entry or iterator advance, including the terminal advance.

These are operation-start counters. An admitted operation consumes allowance even if it raises. A refused next operation does not run, increment its operation counter, or produce a success record. A new occurrence and its depth preflight are admitted together; likewise a new template output and its factory preparation entry. Depth is a high-water mark, while the other units accumulate across the whole explicit build.

One allowance covers discovery, registration/decorator expansion, source graph inspection, boundary preparation, primary compilation and diagnostic recovery. Recovery never resets it. The mandatory structural-pattern nontermination checks remain active with any budget. Overlay compilation counts its new inspections, materializations and clones; it does not charge the caller-owned parent's earlier build. Runtime resolution, provider invocation, ordinary scope creation and warm-up activation perform no budget work.

Exhaustion and diagnostics

from clean_ioc import CompilationBudget, ContainerBuilder, ContainerBuildError

class Leaf:
    pass

builder = ContainerBuilder()
builder.register(Leaf)
try:
    builder.build(budget=CompilationBudget(graph_occurrences=0))
except ContainerBuildError as error:
    issue = error.report.errors[0]
    assert issue.code == "compilation-budget-exceeded"
    assert issue.budget.kind == "graph_occurrences"
    assert (issue.budget.maximum, issue.budget.admitted, issue.budget.attempted) == (0, 0, 1)
    assert error.partial_graph.truncated
    assert error.compiled_graph is None
else:
    raise AssertionError("zero occurrences must refuse the first occurrence")

# A new explicit attempt receives a fresh allowance; the failed builder is editable.
with builder.build(budget=CompilationBudget(graph_occurrences=45)) as container:
    assert isinstance(container.resolve(Leaf), Leaf)

Exhaustion is an error that warning suppression cannot turn into a valid build. The immutable CompilationBudgetExhaustion sidecar records kind, maximum, admitted, attempted, phase, and a snapshot of all admitted units. Path/root and declaration source evidence are included where the operation has them. Text, JSON and SARIF include the failure; SARIF also includes the structured sidecar. Partial diagnostic graphs and triage show that further evidence was omitted. Their attempt totals describe actually started compiler views, including preparation inspections when an allowance is configured; omitted root counts include roots whose recovery was never started. A truncated graph is evidence only and cannot execute or create a runtime container.

If recovery runs out of allowance, reports retain the original finding and already captured findings, add the budget finding and stop recovery. Template callbacks are never replayed to reconstruct missing evidence. Compiler-owned validation callback-error messages are sanitized when included in an exhausted build; explicit user-authored BuildIssue messages remain public declarations. Private build values, owner tokens and callback representations are not budget metadata.

A CompilationProfiler adds optional budget_usage/budget_exhaustion fields and a JSON budget sidecar. Existing success counters retain their own units; for example source metadata created outside the primary draft method appears in the allowance's occurrence count without pretending it was a primary draft. Profiler detail truncation or clock failures do not affect allowance enforcement. Budget limits and outcomes do not enter graph fingerprints.

Matrix variants

from clean_ioc import BuildMatrix, BuildVariant, CompilationBudget, ContainerBuilder

class Leaf:
    pass

def make_builder():
    builder = ContainerBuilder()
    builder.register(Leaf)
    return builder

report = BuildMatrix(
    [
        BuildVariant("standard", make_builder, budget=CompilationBudget(graph_occurrences=45)),
        BuildVariant("restricted", make_builder, budget=CompilationBudget(graph_occurrences=0)),
    ],
    reference="standard",
).check()
assert report.variants[0].is_valid
assert not report.variants[1].is_valid
assert report.variants[1].build_report.errors[0].budget.maximum == 0

Each BuildVariant.budget applies independently to that variant's fresh build. There is no matrix-wide allowance, and overlay variants use their existing parent.

Scope of the limits

A budget is a deterministic work-count limit, not a timeout, memory cap or sandbox. Arbitrary code inside an admitted callback/import/iterator advance cannot be interrupted. A single operation can process a large annotation, value, callback result, package finder result or subclass inventory. Protocol methods such as hashing/equality and custom reflection are not separately metered. Snapshot copying, alias validation, inventory scans, graph freezing, indexing and report rendering have no separate units; occurrence/preparation limits constrain repeated expansion, but do not bound every byte or instruction in these steps. Choose the fields matching the work you need to limit. Limits on occurrences alone permit preparation that does not create occurrences. Registration and bundle application performed before build() are outside its allowance.