--- title: "Investment styles panorama: API-only comparison" author: "Package cre.dcf" output: rmarkdown::html_vignette: toc: true number_sections: true vignette: > %\VignetteIndexEntry{Investment styles panorama: API-only comparison} %\VignetteEngine{knitr::rmarkdown} %\VignetteEncoding{UTF-8} --- ```{r setup, include=FALSE} knitr::opts_chunk$set(echo = TRUE, message = FALSE, warning = FALSE) library(cre.dcf) library(dplyr) library(tidyr) library(ggplot2) library(readr) library(scales) ``` ## Aim of this vignette This vignette uses the preset YAML files shipped in `inst/extdata` to compare four commercial real-estate (CRE) investment styles: * `core` * `core_plus` * `value_added` * `opportunistic` All four presets are processed through the same pipeline with `run_case()`. The vignette then extracts a small set of indicators: * unlevered project IRR, * levered equity IRR and equity NPV, * minimum DSCR along the debt path, * maximum forward LTV under a bullet structure, * and the present-value split between operations and terminal value. The main goal is to confirm that the presets preserve the expected ordering: * *core / core_plus* --> lower risk, lower return, stronger coverage, lower leverage; * *value_added / opportunistic* --> higher expected return, but weaker DSCR and more balance-sheet stress under transition. ## A style-by-style manifest To make the four profiles directly comparable, the vignette begins by constructing a compact “manifest” that records, for each style: * project IRR (all-equity), * equity IRR (levered), * minimum DSCR under a bullet structure, * maximum forward LTV under a bullet structure, * operations share of present value, * terminal-value share of present value, * equity NPV. Under the preset calibration, the four styles are expected to satisfy a clear risk-return and leverage-coverage hierarchy: * project and equity IRR increase monotonically along `core --> core_plus --> value_added --> opportunistic`; * minimum DSCR under the bullet structure decreases monotonically along the same sequence; * initial LTV increases monotonically along the same sequence. The package also reports `ltv_max_fwd`, but this metric should be read differently. It is a *conditional stress indicator* computed along the simulated business plan. In transitional presets, a temporarily depressed forward NOI can create a sharper early LTV spike than in a shorter opportunistic case, so `ltv_max_fwd` is informative without needing to be monotonic. ```{r} # Retrieve manifest tbl_print <- styles_manifest() # Ensure expected ordering tbl_print <- tbl_print |> dplyr::filter(style %in% c("core", "core_plus", "value_added", "opportunistic")) |> dplyr::mutate( style = factor( style, levels = c("core", "core_plus", "value_added", "opportunistic") ) ) |> dplyr::arrange(style) |> dplyr::select( style, irr_project, irr_equity, dscr_min_bul, ltv_max_fwd, ops_share, tv_share, npv_equity ) # Defensive: stop if table empty (should never happen if helpers/tests are correct) if (nrow(tbl_print) == 0L) { stop("No style presets were found. Check inst/extdata and helper logic.") } # Render table knitr::kable( tbl_print, digits = c(0, 0, 4, 4, 3, 3, 3, 3, 0), caption = "Style presets: returns, credit profile, and value composition" ) ``` ## Risk–return cloud: project vs equity IRR The next step places the four styles on a simple risk-return chart, with unlevered project IRR on the x-axis and levered equity IRR on the y-axis. The 45-degree line shows where leverage would leave IRR unchanged. * for each style, equity IRR is strictly higher than project IRR; * the leverage uplift (equity IRR minus project IRR) is non-decreasing along `core --> core_plus --> value_added --> opportunistic`. These inequalities are enforced both by automated tests and by the geometry of the figure. ```{r} tbl_rr <- styles_manifest() |> dplyr::filter(style %in% c("core", "core_plus", "value_added", "opportunistic")) |> dplyr::mutate( style = factor( style, levels = c("core", "core_plus", "value_added", "opportunistic") ), irr_uplift = irr_equity - irr_project ) |> dplyr::arrange(style) if (requireNamespace("ggplot2", quietly = TRUE)) { ggplot2::ggplot( tbl_rr, ggplot2::aes(x = irr_project, y = irr_equity, label = style, colour = style) ) + ggplot2::geom_abline(slope = 1, intercept = 0, linetype = 3) + ggplot2::geom_point(size = 3) + ggplot2::geom_text(nudge_y = 0.002, size = 3) + ggplot2::scale_x_continuous(labels = scales::percent_format(accuracy = 0.1)) + ggplot2::scale_y_continuous(labels = scales::percent_format(accuracy = 0.1)) + ggplot2::labs( title = "Risk–return cloud (project vs equity IRR)", x = "IRR project (unlevered)", y = "IRR equity (levered)" ) } ``` In typical calibrations, core-like styles cluster in the lower-left part of the chart, while non-core styles move to the north-east with a larger leverage uplift. ## Leverage–coverage map (initial LTV vs min-DSCR) The second chart focuses on the credit profile of each style from a lender's standpoint. For each preset, under the bullet-debt scenario, it considers: * the *initial LTV* at origination (`ltv_init`, x-axis), and * the *minimum DSCR* over the life of the loan (`dscr_min_bul`, y-axis). The initial LTV reflects a structural leverage choice at signing, before any business-plan uncertainty has materialised. It measures how much debt is carried relative to the acquisition price (plus costs). By contrast, the minimum DSCR captures the deepest coverage trough induced by the business plan, that is, the weakest ratio of NOI to debt service once vacancy and capex have bitten into rents while interest remains due. For completeness, the manifest also tracks the maximum forward LTV (`ltv_max_fwd`), which summarises the worst ratio of outstanding debt to revalued asset value under the simulated plan. This is a *conditional* balance-sheet indicator, after value creation and repricing have played out. Unlike initial LTV, it is not meant to form a rigid monotone ranking across styles, because lease-up or ramp-up years can temporarily depress forward value. ```{r} tbl_cov <- styles_manifest() |> dplyr::filter(style %in% c("core", "core_plus", "value_added", "opportunistic")) |> dplyr::mutate( style = factor( style, levels = c("core", "core_plus", "value_added", "opportunistic") ) ) |> dplyr::arrange(style) |> dplyr::select( style, irr_project, irr_equity, dscr_min_bul, ltv_init, # structural leverage at origination ltv_max_fwd, # worst forward LTV under the business plan npv_equity ) if (requireNamespace("ggplot2", quietly = TRUE)) { ggplot2::ggplot( tbl_cov, ggplot2::aes( x = ltv_init, y = dscr_min_bul, label = style, colour = style ) ) + ggplot2::geom_hline(yintercept = 1.2, linetype = 3) + # illustrative DSCR guardrail ggplot2::geom_vline(xintercept = 0.65, linetype = 3) + # illustrative initial-LTV guardrail ggplot2::geom_point(size = 3) + ggplot2::geom_text(nudge_y = 0.05, size = 3) + ggplot2::scale_x_continuous(labels = scales::percent_format(accuracy = 0.1)) + ggplot2::labs( title = "Leverage–coverage map", x = "Initial LTV (bullet)", y = "Min DSCR (bullet)" ) } ``` The dashed lines illustrate generic covenant guardrails (DSCR ≈ 1.20, initial LTV ≈ 65 %). In the preset scenarios: * `core` sits comfortably in the quadrant of low LTV and high DSCR; * `core_plus` moves closer to the guardrails but remains covenant-friendly; * `value_added` and `opportunistic` migrate towards higher LTV and lower DSCR, where covenant breaches become plausible if the business plan underperforms. ## Covenant flags and breach counts Beyond static summaries, one often wishes to know how frequently a given style approaches or breaches covenant thresholds over the life of the loan. The next block explores this dimension, again under the bullet-debt scenario for comparability. To keep the table discriminating after the recalibration, the counting exercise uses slightly tighter guardrails than the illustrative lines shown on the previous chart. ```{r} guard <- list(min_dscr = 1.50, max_ltv = 0.60) breach_tbl <- styles_breach_counts( styles = c("core", "core_plus", "value_added", "opportunistic"), min_dscr_guard = guard$min_dscr, max_ltv_guard = guard$max_ltv ) knitr::kable( breach_tbl, caption = "Covenant-breach counts by style (bullet)" ) ``` Under these tighter, underwriting-oriented guardrails, the main pressure appears on forward LTV rather than on a systematic DSCR collapse: * `core` and `core_plus` remain broadly covenant-friendly on balance-sheet metrics; * `value_added` and `opportunistic` are the first styles to breach the forward-LTV line, which is a more realistic signature of transitional and exit-dependent plans; * a `value_added` plan can even show the sharpest temporary LTV spike if the debt remains in place while NOI is still ramping up. ## Robustness to the discounting rule The presets are first calibrated under a WACC-based discounting rule. It is useful to check that the ranking of styles does not depend entirely on that choice. A simple robustness check consists in re-evaluating the same YAML presets under a simpler `"yield_plus_growth"` rule, while leaving cash-flow assumptions unchanged. In this alternative, the discount rate is reconstructed as * a property-yield component equal to `entry_yield`; and * a growth component equal to the global indexation rate `index_rate`, without explicit reference to capital structure. This remains a deliberately stylized convention. It starts from an NOI-based entry yield, not from a fully PBTCF-adjusted market cash yield, so it should be read as a robustness exercise rather than as a literal recovery of textbook OCC from transaction cap rates. `styles_revalue_yield_plus_growth()` returns leveraged equity IRR and NPV under the alternative convention. ```{r} styles_vec <- c("core", "core_plus", "value_added", "opportunistic") # Baseline (WACC) equity metrics from the manifest base_tbl <- styles_manifest(styles_vec) |> dplyr::select(style, irr_equity, npv_equity) # Re-evaluation under the yield+growth rule yg_tbl <- styles_revalue_yield_plus_growth(styles_vec) rob_tbl <- dplyr::left_join(base_tbl, yg_tbl, by = "style") |> dplyr::mutate( delta_npv = npv_equity_y - npv_equity ) knitr::kable( rob_tbl, digits = 4, caption = "Robustness: equity IRR (invariant) and NPV under WACC vs yield+growth" ) ``` In the current calibration, equity IRRs are identical across the two rules by construction, while equity NPVs differ. The ranking still holds. ## Time profile of equity cash flows Another useful angle is the *time profile* of leveraged equity cash flows. In these preset scenarios: * `core` and `core_plus` configurations are calibrated to return a meaningful fraction of equity progressively, on the back of relatively stable NOI and modest refinancing risk; * `value_added` and `opportunistic` strategies tend to back-load value creation into the terminal event, with thinner interim distributions and a stronger dependence on the exit. `styles_equity_cashflows()` extracts year-by-year equity cash flow under the leveraged scenario. The vignette then builds a timing indicator: the *share of total positive equity distributions received before the final year*. Formally, for each style, `share_early_equity` is defined as the ratio between: 1. the sum of positive equity cash flows in years strictly earlier than the horizon; and 2. the sum of all positive equity cash flows over the horizon. ```{r} styles_vec <- c("core", "core_plus", "value_added", "opportunistic") # 1) Equity cash flows and horizons ---------------------------------------- eq_tbl <- styles_equity_cashflows(styles_vec) |> dplyr::group_by(style) |> dplyr::arrange(style, year) horizon_tbl <- eq_tbl |> dplyr::group_by(style) |> dplyr::summarise( horizon_years = max(year), .groups = "drop" ) eq_with_h <- dplyr::left_join(eq_tbl, horizon_tbl, by = "style") # 2) Share of total positive equity CF received before the final year ------ timing_tbl <- eq_with_h |> dplyr::group_by(style) |> dplyr::summarise( total_pos_equity = sum(pmax(equity_cf, 0), na.rm = TRUE), early_pos_equity = sum( pmax(equity_cf, 0) * (year < horizon_years), na.rm = TRUE ), share_early_equity = dplyr::if_else( total_pos_equity > 0, early_pos_equity / total_pos_equity, NA_real_ ), .groups = "drop" ) knitr::kable( timing_tbl |> dplyr::select(style, share_early_equity), digits = 3, caption = "Share of total positive equity distributions received before the final year" ) if (requireNamespace("ggplot2", quietly = TRUE)) { eq_cum_tbl <- eq_with_h |> dplyr::group_by(style) |> dplyr::mutate(cum_equity = cumsum(equity_cf)) ggplot2::ggplot( eq_cum_tbl, ggplot2::aes(x = year, y = cum_equity, colour = style) ) + ggplot2::geom_hline(yintercept = 0, linetype = 3) + ggplot2::geom_line() + ggplot2::labs( title = "Cumulative leveraged equity cash flows by style", x = "Year", y = "Cumulative equity CF" ) } ``` In the present calibration, this metric declines monotonically from `core` to `opportunistic`. Core-like styles therefore return more cash before exit, while non-core styles rely more heavily on the final transaction. This timing indicator complements the return and credit metrics by showing *when* equity gets paid back. ## Value composition: operations vs exit The styles also differ in the relative contribution of ongoing operations versus terminal value to present value. In broad terms: * core strategies should retain a meaningful contribution from intermediate PBTCF; * opportunistic strategies can still be exit-driven, but a public preset should not become almost entirely a terminal-value trade. The style manifest now exposes this split directly through `ops_share` and `tv_share`, which are computed from the same DCF engine used everywhere else in the package. This is especially useful in light of the methodological discussion in Baum and Hartzell, where the analyst is encouraged to ask how much of value is expected to come from resale proceeds. ```{r} styles_vec <- c("core", "core_plus", "value_added", "opportunistic") pv_tbl <- styles_manifest(styles_vec) |> dplyr::mutate(style = factor(style, levels = styles_vec)) knitr::kable( pv_tbl |> dplyr::select(style, ops_share, tv_share), digits = 3, caption = "Present-value split between operations and terminal value by style" ) ``` In the recalibrated presets, `tv_share` still increases from `core` to `opportunistic`, but it stays in a range that is easier to reconcile with textbook-style underwriting. Non-core styles remain more exit-dependent, without becoming implausibly dominated by a single terminal event. ## Exit-yield and rental-growth sensitivities A different perspective on style differentiation is obtained by examining how sensitive each profile is to small shocks on exit yield and on rental growth. Strategies that rely heavily on value capture at exit should exhibit a larger change in equity IRR for a given shift in exit yield; they effectively have a longer “duration” with respect to terminal-value assumptions. ### Exit-yield shock The first sensitivity perturbs the exit-yield spread by +/- 50 basis points around its baseline value and recomputes leveraged equity IRR for each style. For each preset and each shock, the helper `styles_exit_sensitivity()` shifts [ \texttt{exit_yield_spread_bps} \leftarrow \texttt{exit_yield_spread_bps} + \Delta y ] and runs `run_case()` under otherwise unchanged assumptions. ```{r} ## Sensitivity to +/- 50 bps on exit yield ---------------------------------- styles_vec <- c("core", "core_plus", "value_added", "opportunistic") exit_sens <- styles_exit_sensitivity( styles = styles_vec, delta_bps = c(-50, 0, 50) ) knitr::kable( exit_sens |> tidyr::pivot_wider( names_from = shock_bps, values_from = irr_equity ), digits = 4, caption = "Equity IRR sensitivity to +/- 50 bps exit-yield shock by style" ) ``` In the current calibration, `core` and `core_plus` show relatively modest IRR changes when exit yields move by +/- 50 bps, consistent with a larger share of value coming from intermediate NOI. By contrast, `value_added` and `opportunistic` usually react more strongly because more of their performance sits in the terminal value. ### Rental-growth shock The next sensitivity focuses on rental growth and indexation. Here the global `index_rate` parameter is shifted by +/- 1 percentage point, and leveraged equity IRR is recomputed for each shocked scenario. ```{r} ## Sensitivity to rental-growth shocks -------------------------------------- growth_sens <- styles_growth_sensitivity( styles = styles_vec, delta = c(-0.01, 0, 0.01) ) knitr::kable( growth_sens |> tidyr::pivot_wider( names_from = shock_growth, values_from = irr_equity ), digits = 4, caption = "Equity IRR sensitivity to rental-growth shocks by style" ) ``` This table shows how strongly each profile depends on NOI growth to reach its target return. Core configurations are usually less sensitive to a +/- 1 percentage-point change in indexation, while value_added and opportunistic strategies move more because they rely more on lease-up, reversion and growth. ## Break-even exit yield for a target equity IRR A further synthetic indicator is the *break-even exit yield* required for each style to achieve a common target equity IRR. This gives a simple measure of how demanding the exit assumption must be for the business plan to meet a hurdle. For a style (s) and a target equity IRR (\bar{r}), the helper `styles_break_even_exit_yield()` solves, via `uniroot()`, for the exit yield (y^\ast) such that [ \mathrm{IRR}^{\text{equity}}_s(y^\ast) = \bar{r}, ] holding all other configuration parameters fixed. In practice, the function reconstructs the spread `exit_yield_spread_bps` implied by a candidate (y^\ast), reruns `run_case()`, and searches for the root over a bounded interval. ```{r} target_irr <- 0.10 # 10% equity IRR as illustrative hurdle be_tbl <- styles_break_even_exit_yield( styles = c("core", "core_plus", "value_added", "opportunistic"), target_irr = target_irr ) baseline_irr_tbl <- styles_manifest( c("core", "core_plus", "value_added", "opportunistic") ) |> dplyr::select(style, irr_equity) knitr::kable( be_tbl, digits = 4, caption = sprintf("Break-even exit yield to hit %.1f%% equity IRR by style", 100 * target_irr) ) ``` Interpreting this table requires keeping in view the baseline equity IRRs of the four presets under their unperturbed exit yields. In the current calibration, these baselines are approximately: * `core`: `r scales::percent(baseline_irr_tbl$irr_equity[baseline_irr_tbl$style == "core"], accuracy = 0.1)`, * `core_plus`: `r scales::percent(baseline_irr_tbl$irr_equity[baseline_irr_tbl$style == "core_plus"], accuracy = 0.1)`, * `value_added`: `r scales::percent(baseline_irr_tbl$irr_equity[baseline_irr_tbl$style == "value_added"], accuracy = 0.1)`, * `opportunistic`: `r scales::percent(baseline_irr_tbl$irr_equity[baseline_irr_tbl$style == "opportunistic"], accuracy = 0.1)`. A 10 % hurdle is therefore ambitious for the core and core_plus presets, but modest for the value_added and opportunistic ones. This asymmetry explains the pattern usually observed in `be_tbl`: * For `core`, the equity IRR never reaches 10 % within a realistic exit-yield bracket (for example ([3%, 10%])). The corresponding `be_exit_yield` is therefore `NA`. Economically, this means that, at the given purchase price and leverage, a 10 % equity IRR is simply unattainable without implausibly tight exit pricing. This is consistent with the role of core as a low-risk, low-return style. * For `core_plus`, the baseline IRR lies below 10 %, so the root is found by tightening the exit yield. The reported break-even exit yield is below the baseline yield and can be read as the level of pricing perfection required for a core_plus deal to attain a double-digit equity IRR. * For `value_added` and `opportunistic`, baseline IRRs exceed 10 %. The root is therefore reached by *widening* the exit yield (higher yield, lower price) until the IRR falls back down to 10 %. The corresponding break-even yields are markedly higher than the baselines, meaning that these non-core styles can absorb a substantial deterioration in exit pricing and still deliver 10 % to equity. The break-even table does not say that non-core styles "require tighter yields" to be viable. It shows how much adverse repricing each style can absorb before falling below a given equity-IRR benchmark. Core has almost no buffer relative to a 10% target; core_plus has a narrow margin; value_added and opportunistic have a larger buffer. ## Distressed exit comparison under covenant breach The same machinery used to construct baseline credit profiles can be mobilised to emulate a simplified distressed-exit mechanism. The aim is not to model a full restructuring process, but to approximate a lender-driven sale triggered when covenants are breached. In this stylised setting, a distressed exit is defined as follows: 1. Under the bullet-debt scenario, the paths of DSCR and forward LTV are computed for each style. 2. For a given covenant regime, the first period (t^\ast \ge 1) at which either * (\mathrm{DSCR}*t < \mathrm{DSCR}*{\min}), or * (LTV^{\text{fwd}}*t > LTV*{\max}) is interpreted as a *covenant breach*. 3. If a breach occurs *before* a stylised refinancing window (for instance year 3), the exit is shifted to the start of that window; otherwise, the exit takes place at the breach year. 4. At the distressed exit date, the exit yield is penalised by a fire-sale spread (e.g. +100 bps), and the case is re-run with a shortened horizon. The covenant clock itself can now be read in two ways. With `underwriting_mode = "transition"` (the default), covenant testing starts at the preset's `stabilization_year`, which is often more realistic for lease-up or refurbishment business plans. With `underwriting_mode = "stabilized"`, covenant testing starts in year 1, producing a stricter reading that is closer to a standard stabilized-income loan. Because distressed cash-flow patterns can be extreme, the equity IRR may become undefined when the equity cash-flow vector never changes sign. Rather than forcing an artificial IRR, the analysis keeps those `NA` outcomes and supplements them with more robust performance indicators: * the equity multiple, (\text{multiple} = \dfrac{\text{total equity returned}}{\text{total equity paid in}}); * the associated loss percentage, (\text{loss_pct} = \text{multiple} - 1). The helper `styles_distressed_exit()` (defined in the package utilities) encapsulates this logic. The vignette uses it with three illustrative covenant regimes: * a *strict* regime (DSCR 1.20, forward LTV 65 %), * a *baseline* regime (DSCR 1.15, forward LTV 70 %), * a *flexible* regime (DSCR 1.10, forward LTV 75 %), and applies a one-percentage-point fire-sale penalty to the exit yield in all regimes. ```{r} ## Distressed exit across regimes -------------------------------- # Covenant regimes: strict / baseline / flexible regimes <- tibble::tibble( regime = c("strict", "baseline", "flexible"), min_dscr = c(1.20, 1.15, 1.10), max_ltv = c(0.65, 0.70, 0.75) ) distress_tbl <- styles_distressed_exit( styles = c("core", "core_plus", "value_added", "opportunistic"), regimes = regimes, fire_sale_bps = 100, # +100 bps exit-yield penalty refi_min_year = 3L, # refinancing window opens in year 3 allow_year1_distress = FALSE, # breaches before year 3 --> exit at year 3 underwriting_mode = "transition" ) # For compact display in the vignette, focus on the baseline regime distress_baseline <- distress_tbl |> dplyr::filter(regime == "baseline") |> dplyr::select( style, underwriting_mode, covenant_start_year, breach_year, breach_type, irr_equity_base, irr_equity_distress, distress_undefined, equity_multiple_base, equity_multiple_distress, equity_loss_pct_distress ) |> dplyr::arrange(style) knitr::kable( distress_baseline, digits = c(0, 0, 0, 0, 0, 4, 4, 0, 2, 2, 2), caption = paste( "Baseline distressed-exit summary by style (bullet debt scenario,", "+100 bps fire-sale penalty; breaches before year 3 shifted to year 3)." ) ) ``` This table is read as follows: * For each style, `breach_year` and `breach_type` locate the first covenant failure under the baseline regime (DSCR 1.15, forward LTV 70 %). * `underwriting_mode` and `covenant_start_year` indicate whether covenant testing begins at year 1 or only once the asset is treated as stabilised. * `irr_equity_base` reports the baseline leveraged IRR under the standard horizon and exit yield, while `irr_equity_distress` reports the IRR under the shortened, fire-sale horizon. When the distressed cash-flow path does not contain both negative and positive equity flows, the IRR is left undefined and flagged by `distress_undefined = TRUE`. * `equity_multiple_base` and `equity_multiple_distress` summarise total equity returned relative to equity paid in in the baseline and distressed cases, respectively; `equity_loss_pct_distress` reports the loss percentage implied by the distressed multiple. In a typical calibration, `core` and `core_plus` presets exhibit: * breaches only in later years of the loan path; * a moderate erosion of equity IRR in the distressed run, rather than a full collapse of the business plan; * equity multiples that remain close to one, implying limited capital impairment. By contrast, `value_added` and especially `opportunistic` styles tend to: * experience their first breach very early in the horizon, even under a refinancing window; * display distressed equity IRRs that may be sharply reduced or undefined, because equity is not fully repaid within the truncated window; * record equity multiples well below one and strongly negative `equity_loss_pct_distress`, signalling substantial or near-total loss of the initial equity stake. This comparison operationalises, in reduced form, the idea that non-core strategies are structurally more exposed to covenant-driven forced-sale dynamics and to value capture concentrated in the terminal event. Core-like strategies, in contrast, show both delayed breaches and more resilient equity profiles, even under penalised exit conditions. ## Export for audit and replication ```{r} # Export results and breaches (CSV) to facilitate off-notebook auditing out_dir <- tempfile("cre_dcf_styles_") dir.create(out_dir, recursive = TRUE, showWarnings = FALSE) readr::write_csv(tbl_print, file.path(out_dir, "styles_summary.csv")) readr::write_csv(breach_tbl, file.path(out_dir, "covenant_breaches.csv")) cat(sprintf("\nArtifacts written to: %s\n", out_dir)) ```