mixle.analysis.real_options module

Real options & decision-under-uncertainty (work-plan Sec.7-J, J3).

A risk-neutral NPV distribution (J2’s NPVDistribution) answers “what is the project worth if we commit now?” It does not answer the question a real decision-maker actually faces: “should we commit now, or is it worth paying for the option to wait, expand, or walk away once more is known?” Two tools close that gap:

  • real_option_value() – prices the option embedded in the decision (defer / expand / abandon) with a binomial lattice on the NPV process itself, American-exercised at every step. NPV – unlike a commodity price – is a signed quantity that must be able to cross zero for “wait and see” to mean anything, so the lattice is additive (an arithmetic random walk around npv_dist.mean, scaled by volatility as a fraction of the NPV’s own magnitude) rather than the usual multiplicative GBM lattice: a strictly-positive geometric process started above zero can never reach the max(V, 0) kink that gives the option its value. Volatility is what makes the option valuable: by Jensen’s inequality, an American option on a driftless process is worth strictly more than the naive point NPV whenever there is dispersion wide enough to reach the kink, and it collapses to the naive max(NPV, 0) as volatility falls to zero (no dispersion, nothing to wait for). Each kind carries its own exercise economics: expand nets the capacity bonus against an expansion_cost investment, so expanding is a genuine trade-off rather than a bonus every positive-NPV project gets for free, and abandon recovers an optional salvage_value instead of assuming a total write-off.

  • voi_dollars() – the dollar value of a piece of information (e.g. a delineation drillhole): the expected value of the best decision with that information, minus the expected value of the best decision without it. A declared observation_model is required by default so this is a real expected value of sample information (EVSI). A Gaussian-dispersion HEURISTIC remains available only through the explicit drill_info["method"] = "variance_rescaling_heuristic" opt-in: a hypothetical drillhole’s effect on the posterior is summarized as a fractional variance reduction (either supplied directly via drill_info["variance_reduction"], or – when available – via C8’s mixle_pde.voi.expected_variance_reduction hook), and the pre-posterior simulation invents plausible future-posterior centers/spreads by RESCALING today’s posterior draws rather than sampling a hypothetical observation from a real likelihood and conditioning on it – exact only for a Gaussian, unimodal, unconstrained posterior, and flagged with a warning otherwise. For a genuine sample-then-condition preposterior, declare a linear-Gaussian GaussianObservationModel (observation_model=): a hypothetical observation is then simulated from the latent’s actual prior-predictive marginal and the posterior is conditioned on it analytically via closed-form conjugate-Gaussian updating – the textbook EVSI construction, for that declared-likelihood case. A rational decision-maker never loses from more information in expectation, but a Monte Carlo estimate of that gain is noisy around zero when there is little or no information to gain; voi_estimate() reports the (unfloored) estimate together with its own standard error rather than clipping the noise away, which – naively floored at zero – was systematically biased upward rather than merely conservative (VoiEstimate).

Repo-boundary note: J2’s mixle.analysis.valuation module (NPVDistribution / monte_carlo_npv) has since landed and is imported normally below; C8’s mixle_pde VOI hooks have not, so voi_dollars still best-effort imports that hook and falls back to a self-contained posterior-refinement simulation over the frozen IC-1 Posterior when it is unavailable. real_option_value’s npv_dist parameter stays duck-typed against anything exposing a .mean – the hard import below is for the type annotation and documentation value only (previously a TYPE_CHECKING-only forward reference, which left the name unresolvable to typing.get_type_hints() – a real, if rarely exercised, runtime introspection break); no isinstance check was added, so any duck-typed object still works exactly as before.

class OptionValue(value, exercise_boundary, premium_over_npv)[source]

Bases: NamedTuple

The priced option, alongside the exercise policy that generated it.

value is the total value of holding the option (project value + optionality). exercise_boundary is the per-time-step critical underlying value at which immediate exercise first becomes optimal (nan at any step where it is never optimal to exercise). premium_over_npv is value minus the naive npv_dist.mean – the dollar amount the flexibility to defer/expand/abandon is worth over just committing to the point-estimate NPV today.

Parameters:
value: float

Alias for field number 0

exercise_boundary: ndarray

Alias for field number 1

premium_over_npv: float

Alias for field number 2

real_option_value(npv_dist, *, volatility, horizon, kind='defer', rate, n_steps=None, expand_fraction=0.3, expansion_cost=0.0, salvage_value=0.0)[source]

Price the defer/expand/abandon option on a project via an additive binomial lattice on NPV.

The NPV process is modeled as a driftless arithmetic random walk started at npv_dist.mean: each of n_steps (default: max(horizon, 1)) steps up to horizon moves the value by +-h with h = volatility * scale * sqrt(dt), scale = abs(npv_dist.mean) (or 1.0 if the mean is exactly zero, so volatility is never degenerate) – i.e. volatility is a fractional per-sqrt-period dispersion relative to the project’s own scale, matching how the parameter is usually quoted (e.g. 0.3 ~ “30% dispersion”), while still letting the walk go negative the way a signed NPV must be able to. The risk-neutral measure is taken driftless (an NPV is already the discounted expectation of a self-financing project, so there is no further risk-neutral drift to add); rate only discounts the continuation value, which is what makes immediate exercise optimal once volatility vanishes. At every node the holder may exercise (payoff via _intrinsic(), depending on kind) or continue holding the option; the American value is the larger of the two, by backward induction from the horizon.

Parameters:
  • npv_dist (NPVDistribution) – anything exposing .mean (a float) – typically J2’s NPVDistribution. Only the mean is used; real_option_value prices the option on top of the point estimate, not a re-derivation of the distribution itself. Must be finite.

  • volatility (float) – fractional (per-sqrt-period) dispersion of NPV around its mean. Must be finite and >= 0; 0 means no dispersion and the option collapses to immediate exercise of kind’s own payoff on npv_dist.mean (_intrinsic()) – max(npv_dist.mean, 0) for "defer", max(npv_dist.mean, salvage_value) for "abandon", npv_dist.mean + max(expand_fraction * max(npv_dist.mean, 0) - expansion_cost, 0) for "expand".

  • horizon (int) – number of periods over which the option may be exercised. 0 means “decide now”. Must be a non-Boolean integral scalar >= 0.

  • kind (str) – one of "defer", "expand", "abandon".

  • rate (float) – per-period discount rate applied to the continuation value. Must be finite.

  • n_steps (int | None) – lattice steps (defaults to max(horizon, 1), i.e. one step per period). Must be a non-Boolean positive integral scalar when given explicitly.

  • expand_fraction (float) – for kind="expand", the fractional capacity bonus applied to a positive underlying value when the expansion is exercised. Must be finite and nonnegative.

  • expansion_cost (float) – for kind="expand", the investment cost of actually exercising the expansion, netted against expand_fraction’s capacity bonus – without it, a positive-NPV project always “expands” for free and there is no real exercise decision being priced (see _intrinsic()). Ignored for other kinds. Must be finite and nonnegative; default 0.0 (no cost, matching this function’s behavior before expansion_cost existed).

  • salvage_value (float) – for kind="abandon", the value recovered by abandoning the project instead of riding out its NPV, replacing the previous hardcoded assumption of a total write-off. Ignored for other kinds. Must be finite; default 0.0 (total write-off, matching this function’s behavior before salvage_value existed).

Returns:

An OptionValue.

Return type:

OptionValue

class GaussianObservationModel(obs_matrix, obs_cov)[source]

Bases: NamedTuple

A declared linear-Gaussian observation model: y = obs_matrix @ theta + noise, noise ~ N(0, obs_cov), for a latent theta distributed as the current posterior’s (mean, cov).

MXR-080-0109: the opt-in heuristic invents future-posterior centers/spreads by rescaling draws from the CURRENT posterior; it never samples an observation from a likelihood/forward model and conditions on it, so it is not a real Bayesian preposterior for non-Gaussian, multimodal, or constrained beliefs. This is the simplest well-defined experiment likelihood for which the preposterior – the distribution of what TOMORROW’s posterior could look like, before the observation is actually taken – has a closed form: conjugate-Gaussian updating. Passing this to voi_dollars() / voi_estimate() (as observation_model=) is what makes them compute a genuine expected value of sample information (EVSI): simulate a hypothetical y from theta’s actual prior-predictive marginal, analytically condition the CURRENT posterior on that y to get a real future posterior, decide against it, and average over many simulated y – rather than the opt-in heuristic’s rescaling of today’s draws (see voi_estimate()’s docstring for why that heuristic is not a real preposterior in general).

obs_matrix (H, shape (k, d)) and obs_cov (R, shape (k, k), symmetric positive definite) together say: a real experiment would return a k-dimensional noisy linear readout of the d-dimensional latent quantity the posterior is over (d = posterior.mean’s length). This path requires posterior.mean / posterior.cov to fully characterize the belief – i.e. a genuinely Gaussian posterior – since that is what makes the conjugate update exact; a dense posterior.cov is required (a matrix-free LinearOperator covariance is not supported here). obs_matrix = 0 (or obs_cov large) declares a literally uninformative experiment – VOI comes back exactly zero for that case, not just approximately (see voi_estimate()’s docstring).

Parameters:
obs_matrix: ndarray

Alias for field number 0

obs_cov: ndarray

Alias for field number 1

class VoiEstimate(value, standard_error, ci_low, ci_high, method)[source]

Bases: NamedTuple

A Monte Carlo value-of-information estimate, reported honestly alongside its own sampling noise (MXR-080-0108: independent no-info/with-info draws made a should-be-zero difference noisy, and flooring that noise at zero upward-biased it; see voi_estimate()’s docstring for the fix).

value is the point estimate, E[value | info] - E[value | no info] – NOT floored at zero (see voi_estimate()’s docstring for why flooring a noisy estimate at zero systematically biases it upward, rather than merely rounding it). standard_error is the paired-difference Monte Carlo standard error of value across the n_outer hypothetical-information replicates (paired because each replicate’s with-info and no-info evaluations share their underlying random draw – see voi_estimate()). ci_low/ci_high are an approximate two-sided 95% confidence interval (value +- 1.96 * standard_error; a normal approximation, not exact for small n_outer). When ci_low <= 0 <= ci_high, value is statistically indistinguishable from “no additional value” – a more honest read than a bare point estimate that happens to be (noisily) positive or negative.

method names which computation produced the estimate: "variance_rescaling_heuristic" (an explicit opt-in Gaussian-dispersion approximation, exact only when the posterior is close to Gaussian, unimodal, and unconstrained) or "gaussian_conjugate_evsi" (a genuine sample-then-condition preposterior computation under a declared linear-Gaussian observation_model).

Parameters:
value: float

Alias for field number 0

standard_error: float

Alias for field number 1

ci_low: float

Alias for field number 2

ci_high: float

Alias for field number 3

method: str

Alias for field number 4

voi_estimate(posterior, decision_fn, drill_info, *, rng, n_outer=64, n_inner=256, observation_model=None)[source]

Value of information, honestly: the point estimate together with its own Monte Carlo uncertainty.

decision_fn maps a set of posterior draws (an (n, d) array, physical units) to the dollar value of the single best decision made using that belief state – e.g. a risk-neutral go/no-go choice is max(samples.mean(), 0), not an average of a per-draw payoff: the latter implicitly assumes the realization is already known, which is exactly the perfect-information case this function is pricing the gap to, not the belief itself.

Two methods are available:

  • observation_model=None together with explicit drill_info["method"] == "variance_rescaling_heuristic": a pre-posterior Monte Carlo built on the law of total variance, NOT real Bayesian conditioning on a simulated observation. Today’s posterior variance splits into “where the post-drill posterior will be centered” (unknown until the drillhole is actually put in) and “how much spread remains once it lands there.” If the drillhole is expected to remove a fraction r of variance (_variance_reduction()), then for n_outer hypothetical drill outcomes a center is drawn with standard deviation scaled by sqrt(r) around today’s posterior mean (how much the belief could plausibly shift), and a shared base draw (see “Common random numbers” below) is re-centered there and rescaled by sqrt(1 - r) for the remaining spread. This INVENTS future-belief “centers” by rescaling today’s draws rather than drawing them from an actual forward/likelihood model, so it is accurate only when the posterior is (close to) Gaussian, unimodal, and unconstrained – outside that regime it is flagged with a UserWarning (see _warn_if_not_gaussian_like()), not silently trusted.

  • observation_model= a GaussianObservationModel (method="gaussian_conjugate_evsi"): a genuine sample-then-condition preposterior. A hypothetical observation y is simulated from the latent’s real prior-predictive marginal (y = H @ theta + noise for theta ~ N(posterior.mean, posterior.cov)), the CURRENT posterior is analytically conditioned on that y via closed-form conjugate-Gaussian updating to get a genuine future posterior, and the decision is re-made against it – averaged over many simulated y for E[value | info]. This is the textbook EVSI construction for the declared-likelihood case, but it still requires the PRIOR (posterior.mean / posterior.cov) to be genuinely Gaussian for the conjugate update to be exact – also screened by the same warning.

Common random numbers (MXR-080-0108): each of the n_outer replicates draws ONE base/innovation sample and uses it for BOTH the no-info decision and the with-info (refined) decision for that replicate, differing only by a deterministic transform (the heuristic’s re-centering/rescaling, or the conjugate update’s affine map) – not independent draws for the two sides. This matters when there is truly no information to gain (r = 0 for the heuristic; obs_matrix = 0 or obs_cov arbitrarily large for the declared-likelihood path): with independent draws, the no-info and with-info values would be two separately noisy estimates of the same quantity, so their difference would be noise centered at zero; flooring that noise at zero (the previous behavior) kept only its positive half, upward-biasing the reported VOI (a standard-normal fake posterior with a go/no-go decision spuriously returned positive VOI at zero information for most tested seeds). With a shared draw, the with-info value is bit-for-bit identical to the no-info value whenever there is truly no information (checked directly for the heuristic via an analytic short-circuit below, not just left to fall out of the Monte Carlo – and provably exact by construction for the declared-likelihood path too), and the two stay correlated – hence lower-variance in their difference – away from that boundary as well.

Parameters:
  • posterior (Posterior) – the current (pre-drill) belief, satisfying IC-1’s Posterior protocol.

  • decision_fn (Callable[[ndarray], float]) – belief state ((n, d) draws) -> expected dollar value of the best decision.

  • drill_info (dict[str, Any]) – describes the hypothetical drillhole. Recognized keys: method (must explicitly be "variance_rescaling_heuristic" when no observation_model is supplied), variance_reduction (direct fractional variance reduction, default 0.5), or candidate_geometry + forward_op (+ optional region / cell_volumes) to route through the C8 VOI hook when available; n_outer_samples / n_inner_samples override the Monte Carlo sample counts. Ignored (aside from the sample-count overrides) when observation_model is given.

  • rng (Generator) – seeded random generator for reproducibility.

  • n_outer (int) – number of hypothetical-information replicates (overridden by drill_info).

  • n_inner (int) – posterior draws per replicate (overridden by drill_info).

  • observation_model (GaussianObservationModel | None) – when given, a declared GaussianObservationModel routes the computation through the genuine "gaussian_conjugate_evsi" path instead of the opt-in heuristic; see above. Requires a dense (non-LinearOperator) posterior.cov.

Returns:

the (unfloored) point estimate plus its Monte Carlo uncertainty.

Return type:

A VoiEstimate

voi_dollars(posterior, decision_fn, drill_info, *, rng, n_outer=64, n_inner=256, observation_model=None)[source]

The value-of-information point estimate, in dollars: E[value | info] - E[value | no info].

A thin convenience wrapper around voi_estimate() for callers that only want the point estimate; see its docstring for the full method (the opt-in heuristic vs. the genuine observation_model EVSI path, common random numbers, the zero-information analytic equality, and what method the estimate used) and for how to also get the Monte Carlo standard error / CI.

Parameters:
  • posterior (Posterior) – forwarded to voi_estimate() unchanged.

  • decision_fn (Callable[[ndarray], float]) – forwarded to voi_estimate() unchanged.

  • drill_info (dict[str, Any]) – forwarded to voi_estimate() unchanged.

  • rng (Generator) – forwarded to voi_estimate() unchanged.

  • n_outer (int) – forwarded to voi_estimate() unchanged.

  • n_inner (int) – forwarded to voi_estimate() unchanged.

  • observation_model (GaussianObservationModel | None) – forwarded to voi_estimate() unchanged.

Returns:

The value-of-information point estimate, in dollars. NOT floored at zero – see voi_estimate()’s docstring for why a floor is dishonest, not merely conservative; this can be a small negative number when the true VOI is at or near zero, which is the honest reflection of Monte Carlo noise, not a defect. Use voi_estimate() if you need to tell that case apart from a real, larger difference.

Return type:

float

class VoiStoppingDecision(voi_dollars, sample_cost, net_value, keep_sampling, standard_error)[source]

Bases: NamedTuple

A real decision-theoretic answer to “should we sample again?”: compare the value of one more sample against its cost, rather than an arbitrary uncertainty threshold picked by hand.

standard_error is the underlying VoiEstimate’s Monte Carlo standard error, carried through so a caller can tell a close-to-the-cost net_value (statistical noise) apart from a real, larger gap – the same honesty voi_estimate() reports at the VOI level (MXR-080-0108/0110).

Parameters:
voi_dollars: float

Alias for field number 0

sample_cost: float

Alias for field number 1

net_value: float

Alias for field number 2

keep_sampling: bool

Alias for field number 3

standard_error: float

Alias for field number 4

voi_stopping_decision(posterior, decision_fn, drill_info, *, sample_cost, rng, n_outer=64, n_inner=256, observation_model=None)[source]

Should the next sample (drillhole, monitoring well, survey station, …) actually be taken?

A real, principled alternative to picking an uncertainty threshold by hand and telling an LLM loop-controller to stop when it’s cleared (the pattern used, and flagged as a real gap, in experiments/adaptive-groundwater-monitoring and experiments/adaptive-gravity-survey-design): sample again iff voi_dollars() – the actual expected dollar value the next sample would add to the decision – exceeds what that sample costs. As uncertainty tightens, voi_dollars shrinks toward zero (there’s less left to learn that would change the decision), so this converges to a stopping rule on its own without any separately-chosen threshold; the only free parameter is sample_cost, which is an actual real-world number (drilling/survey cost) rather than an arbitrary uncertainty width.

Parameters:
  • posterior (Posterior) – forwarded to voi_estimate() unchanged – see its docstring for what each means.

  • decision_fn (Callable[[ndarray], float]) – forwarded to voi_estimate() unchanged – see its docstring for what each means.

  • drill_info (dict[str, Any]) – forwarded to voi_estimate() unchanged – see its docstring for what each means.

  • rng (Generator) – forwarded to voi_estimate() unchanged – see its docstring for what each means.

  • n_outer (int) – forwarded to voi_estimate() unchanged – see its docstring for what each means.

  • n_inner (int) – forwarded to voi_estimate() unchanged – see its docstring for what each means.

  • observation_model (GaussianObservationModel | None) – forwarded to voi_estimate() unchanged – see its docstring for what each means.

  • sample_cost (float) – the real dollar cost of taking the next sample. Must be finite (MXR-080-0110: an unvalidated NaN cost previously compared as False against everything, silently forcing keep_sampling=False regardless of the actual VOI).

Returns:

the computed VOI (with its standard error), the cost it was compared against, their difference, and whether sampling should continue (voi_dollars > sample_cost).

Return type:

A VoiStoppingDecision