diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..d4bb2cb --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = . +BUILDDIR = _build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/_build/doctrees/api.doctree b/docs/_build/doctrees/api.doctree new file mode 100644 index 0000000..44665ae Binary files /dev/null and b/docs/_build/doctrees/api.doctree differ diff --git a/docs/_build/doctrees/environment.pickle b/docs/_build/doctrees/environment.pickle new file mode 100644 index 0000000..555db37 Binary files /dev/null and b/docs/_build/doctrees/environment.pickle differ diff --git a/docs/_build/doctrees/index.doctree b/docs/_build/doctrees/index.doctree new file mode 100644 index 0000000..dac67ca Binary files /dev/null and b/docs/_build/doctrees/index.doctree differ diff --git a/docs/_build/html/.buildinfo b/docs/_build/html/.buildinfo new file mode 100644 index 0000000..48386cf --- /dev/null +++ b/docs/_build/html/.buildinfo @@ -0,0 +1,4 @@ +# Sphinx build info version 1 +# This file records the configuration used when building these files. When it is not found, a full rebuild will be done. +config: e3f99bd6e7885fe2e4c58ae42481fecc +tags: 645f666f9bcd5a90fca523b33c5a78b7 diff --git a/docs/_build/html/.nojekyll b/docs/_build/html/.nojekyll new file mode 100644 index 0000000..e69de29 diff --git a/docs/_build/html/_modules/index.html b/docs/_build/html/_modules/index.html new file mode 100644 index 0000000..8b53843 --- /dev/null +++ b/docs/_build/html/_modules/index.html @@ -0,0 +1,266 @@ + + +
+ + + + +
+import numpy as np
+from tinygrad.tensor import Tensor
+from tinygrad.nn.optim import Adam
+from tinygrad import dtypes
+import matplotlib.pyplot as plt
+
+import warnings
+
+
+[docs]
+class MarketingReturnCurve:
+ """A marketing intelligence tool to determine inflection points of a media response curve.
+
+ Based on the Hill Function (Google Meridian methodology), this tool identifies
+ the Minimal Marginal Cost Point (peak efficiency) and the Point of Diminishing
+ Returns (profitability floor).
+
+ Attributes:
+ beta (float): The asymptote (maximum possible return/capacity).
+ alpha (float): The shape parameter (>1 for S-shape, <=1 for C-shape).
+ K (float): The half-saturation point (spend where half of beta is reached).
+ channel_name (str): Name of the media channel.
+ posterior_samples (dict, optional): MCMC samples for alpha, beta, K, and sigma.
+ """
+
+ def __init__(self, beta, alpha, half_saturation_k, channel_name="Generic", posterior_samples=None):
+ """Initializes the MarketingReturnCurve with specific parameters.
+
+ Args:
+ beta (float): Maximum possible capacity.
+ alpha (float): Shape parameter.
+ half_saturation_k (float): Half-saturation spend amount.
+ channel_name (str): Label for the channel. Defaults to "Generic".
+ posterior_samples (dict, optional): Dictionary of parameter samples.
+ """
+ self.beta = float(beta)
+ self.alpha = float(alpha)
+ self.K = float(half_saturation_k)
+ self.channel_name = channel_name
+ self.posterior_samples = posterior_samples
+
+
+[docs]
+ @classmethod
+ def fit_bayesian(cls, spend_array, return_array, channel_name="Generic", priors=None, n_samples=2000, chains=4, burn_in=1000):
+ """Fits a Hill Curve using Bayesian MCMC (Metropolis-Hastings).
+
+ Args:
+ spend_array (array-like): Historical spend data.
+ return_array (array-like): Historical return/KPI data.
+ channel_name (str): Label for the channel. Defaults to "Generic".
+ priors (dict, optional): LogNormal priors for 'beta', 'alpha', 'K'.
+ Format: {'param': (mu, sigma)}.
+ n_samples (int): Number of samples per chain. Defaults to 2000.
+ chains (int): Number of MCMC chains. Defaults to 4.
+ burn_in (int): Number of initial samples to discard. Defaults to 1000.
+
+ Returns:
+ MarketingReturnCurve: An instance of the curve fitted with posterior means.
+ """
+ x = np.array(spend_array, dtype=float) + 1e-5
+ y = np.array(return_array, dtype=float)
+
+ # Default Priors (LogNormal)
+ if priors is None:
+ max_y = np.max(y)
+ median_x = np.median(x[x > 1e-4]) if np.any(x > 1e-4) else 1.0
+ priors = {
+ 'beta': (np.log(max_y * 1.2), 0.5),
+ 'alpha': (0.0, 0.5), # Centered at 1.0 (log(1)=0)
+ 'K': (np.log(median_x), 0.5)
+ }
+
+ def log_likelihood(beta, alpha, K, sigma):
+ if beta <= 0 or alpha <= 0 or K <= 0 or sigma <= 0: return -np.inf
+ y_pred = (beta * (x ** alpha)) / (K ** alpha + x ** alpha)
+ return -0.5 * np.sum(((y - y_pred) / sigma) ** 2) - len(y) * np.log(sigma)
+
+ def log_prior(beta, alpha, K, sigma):
+ lp = 0
+ # LogNormal priors for beta, alpha, K
+ for name, val in [('beta', beta), ('alpha', alpha), ('K', K)]:
+ mu, s = priors[name]
+ lp += -0.5 * ((np.log(val) - mu) / s) ** 2 - np.log(val)
+ # Half-Normal prior for sigma
+ lp += -0.5 * (sigma / (np.max(y) * 0.1)) ** 2
+ return lp
+
+ def log_posterior(params):
+ beta, alpha, K, sigma = params
+ return log_likelihood(beta, alpha, K, sigma) + log_prior(beta, alpha, K, sigma)
+
+ # Simple Metropolis-Hastings Sampler
+ all_samples = []
+ for _ in range(chains):
+ # Initialize
+ current_params = np.array([
+ np.exp(priors['beta'][0]),
+ np.exp(priors['alpha'][0]),
+ np.exp(priors['K'][0]),
+ np.std(y) * 0.1
+ ])
+ current_log_post = log_posterior(current_params)
+
+ samples = []
+ # Adaptive step size (simplified)
+ step_size = current_params * 0.05
+
+ for i in range(n_samples + burn_in):
+ proposal = current_params + np.random.normal(0, step_size)
+ proposal_log_post = log_posterior(proposal)
+
+ if proposal_log_post > current_log_post or np.random.rand() < np.exp(proposal_log_post - current_log_post):
+ current_params = proposal
+ current_log_post = proposal_log_post
+
+ if i >= burn_in:
+ samples.append(current_params.copy())
+
+ # Small adaptation during burn-in
+ if i < burn_in and i % 100 == 0 and i > 0:
+ # This is a very crude adaptation
+ pass
+
+ all_samples.append(np.array(samples))
+
+ posterior = np.vstack(all_samples)
+ samples_dict = {
+ 'beta': posterior[:, 0],
+ 'alpha': posterior[:, 1],
+ 'K': posterior[:, 2],
+ 'sigma': posterior[:, 3]
+ }
+
+ # Point estimates (posterior mean)
+ beta_mean = np.mean(samples_dict['beta'])
+ alpha_mean = np.mean(samples_dict['alpha'])
+ K_mean = np.mean(samples_dict['K'])
+
+ print(f"[{channel_name}] Bayesian fit complete. Samples: {len(posterior)}")
+ return cls(beta_mean, alpha_mean, K_mean, channel_name, posterior_samples=samples_dict)
+
+
+
+[docs]
+ @classmethod
+ def from_historical_data(cls, spend_array, return_array, channel_name="Generic", epochs=5000, lr=0.05):
+ """Fits a Hill Curve to historical data using MLE (Adam optimizer).
+
+ Args:
+ spend_array (array-like): Historical spend data.
+ return_array (array-like): Historical return/KPI data.
+ channel_name (str): Label for the channel. Defaults to "Generic".
+ epochs (int): Number of optimization epochs. Defaults to 5000.
+ lr (float): Learning rate for the optimizer. Defaults to 0.05.
+
+ Returns:
+ MarketingReturnCurve: An instance of the curve fitted with optimized parameters.
+ """
+ max_y = np.max(return_array)
+ median_x = np.median(spend_array[spend_array > 0]) if np.any(spend_array > 0) else 1.0
+
+ Tensor.traning = True
+ x = Tensor(spend_array, dtype=dtypes.float32)
+ x.requires_grad = False
+ y = Tensor(return_array, dtype=dtypes.float32)
+ y.requires_grad = False
+
+ log_beta = Tensor([np.log(max_y * 1.5)], dtype=dtypes.float32)
+ log_beta.requires_grad = True
+ log_k = Tensor([np.log(median_x + 1e-5)], dtype=dtypes.float32)
+ log_k.requires_grad = True
+ log_alpha = Tensor([0.5], dtype=dtypes.float32)
+ log_alpha.requires_grad = True
+ optimizer = Adam([log_beta, log_k, log_alpha], lr=lr)
+
+ Tensor.traning = True
+ with Tensor.train():
+ for _ in range(epochs):
+ optimizer.zero_grad()
+ beta = log_beta.exp()
+ k = log_k.exp()
+ alpha = log_alpha.exp()
+ x_safe = x + 1e-5 # Add tiny epsilon to x to prevent 0^alpha resulting in NaNs
+ y_pred = (beta * (x_safe ** alpha)) / (k ** alpha + x_safe ** alpha) # Hill Function
+ loss = ((y_pred - y) ** 2).mean() # Loss Function (Mean Squared Error)
+ loss.backward()
+ optimizer.step()
+ Tensor.traning = False
+ final_loss = loss.numpy().item()
+ print(f"[{channel_name}] Curve fit complete. Loss: {final_loss:.4f}")
+ return cls(log_beta.exp().numpy().item(), log_alpha.exp().numpy().item(), log_k.exp().numpy().item(), channel_name)
+
+
+
+[docs]
+ def predict_incremental_return(self, spend, use_samples=False):
+ """Calculates the total incremental return for a given spend.
+
+ Args:
+ spend (float or array-like): The spend amount(s) to evaluate.
+ use_samples (bool): If True, returns a distribution using posterior samples.
+ Defaults to False.
+
+ Returns:
+ float or numpy.ndarray: The predicted incremental return(s).
+ """
+ spend = np.array(spend, dtype=float) + 1e-5
+ if use_samples and self.posterior_samples:
+ beta = self.posterior_samples['beta'][:, np.newaxis]
+ alpha = self.posterior_samples['alpha'][:, np.newaxis]
+ K = self.posterior_samples['K'][:, np.newaxis]
+ return (beta * (spend ** alpha)) / (K ** alpha + spend ** alpha)
+ return (self.beta * (spend ** self.alpha)) / (self.K ** self.alpha + spend ** self.alpha)
+
+
+
+[docs]
+ def predict_marginal_return(self, spend, use_samples=False):
+ """Calculates the first derivative (Marginal ROAS) at a given spend.
+
+ Args:
+ spend (float or array-like): The spend amount(s) to evaluate.
+ use_samples (bool): If True, returns a distribution using posterior samples.
+ Defaults to False.
+
+ Returns:
+ float or numpy.ndarray: The predicted marginal return(s).
+ """
+ spend = np.array(spend, dtype=float) + 1e-5
+ if use_samples and self.posterior_samples:
+ beta = self.posterior_samples['beta'][:, np.newaxis]
+ alpha = self.posterior_samples['alpha'][:, np.newaxis]
+ K = self.posterior_samples['K'][:, np.newaxis]
+ numerator = beta * alpha * (K ** alpha) * (spend ** (alpha - 1))
+ denominator = (K ** alpha + spend ** alpha) ** 2
+ return numerator / denominator
+ numerator = self.beta * self.alpha * (self.K ** self.alpha) * (spend ** (self.alpha - 1))
+ denominator = (self.K ** self.alpha + spend ** self.alpha) ** 2
+ return numerator / denominator
+
+
+
+[docs]
+ def get_minimal_marginal_cost_point(self):
+ """Identifies the inflection point where marginal return peaks.
+
+ This corresponds to the spend level where efficiency is maximized (f''(x) = 0).
+
+ Returns:
+ float: The spend amount at the inflection point.
+ """
+ if self.alpha <= 1: return 0.0 # If alpha <= 1, it's a C-Curve. Diminishing returns occur instantly at Spend = 0.
+ inflection_point = self.K * (((self.alpha - 1) / (self.alpha + 1)) ** (1 / self.alpha)) # Closed-form solution for the inflection point of a Hill function)
+ return inflection_point
+
+
+
+[docs]
+ def get_diminishing_returns_point(self, target_mroas=1.0, tol=1e-5, max_iter=100):
+ """Solves for the spend level where Marginal ROAS hits a specific target.
+
+ Args:
+ target_mroas (float): The minimum acceptable marginal return. Defaults to 1.0.
+ tol (float): Convergence tolerance for the bisection search. Defaults to 1e-5.
+ max_iter (int): Maximum iterations for the search. Defaults to 100.
+
+ Returns:
+ float or None: The spend amount at the diminishing returns point, or None if unreachable.
+ """
+ inflection = max(self.get_minimal_marginal_cost_point(), 1e-5)
+ max_mroas = self.predict_marginal_return(inflection)
+ if target_mroas >= max_mroas:
+ warnings.warn(f"Target mROAS ({target_mroas}) is mathematically unreachable.\nMax possible mROAS is {max_mroas:.2f}.")
+ return None
+ lower_bound = inflection
+ upper_bound = inflection + self.K
+ while self.predict_marginal_return(upper_bound) > target_mroas:
+ upper_bound += self.K
+ if upper_bound > self.K * 1000: # Safety break
+ warnings.warn("Could not find an upper bound for the target mROAS.")
+ return None
+ for _ in range(max_iter): # Native Bisection Search
+ midpoint = (lower_bound + upper_bound) / 2.0
+ mroas_at_mid = self.predict_marginal_return(midpoint)
+ if abs(mroas_at_mid - target_mroas) < tol: return midpoint # If we are within the acceptable tolerance, return the spend amount
+ # Because mROAS is strictly decreasing after the inflection point:
+ if mroas_at_mid > target_mroas: lower_bound = midpoint # mROAS is too high, we need to spend MORE to drive it down to the target
+ else: upper_bound = midpoint # mROAS is too low, we need to spend LESS to bring it back up to the target
+ return (lower_bound + upper_bound) / 2.0 # Return the best approximation if max iterations are reached
+
+
+
+[docs]
+ def evaluate_current_budget(self, current_spend, target_mroas=1.0):
+ """Provides a strategic evaluation of the current budget allocation.
+
+ Prints recommendations based on the relationship between current spend,
+ the peak efficiency point, and the diminishing returns point.
+
+ Args:
+ current_spend (float): The current amount being spent.
+ target_mroas (float): The target marginal return floor. Defaults to 1.0.
+ """
+ min_spend = self.get_minimal_marginal_cost_point()
+ max_spend = self.get_diminishing_returns_point(target_mroas)
+ mroas = self.predict_marginal_return(current_spend)
+ print(f"--- Budget Evaluation: {self.channel_name} ---")
+ print(f"Current Spend: ${current_spend:,.2f} | Current mROAS: {mroas:.2f}")
+ if current_spend < min_spend: print("Status: WARMING UP (Inefficient)\nRecommendation: Increase spend to at least ${min_spend:,.2f} to reach peak acquisition efficiency.")
+ elif max_spend is not None and current_spend > max_spend: print("Status: OVER-SATURATED (Unprofitable Marginal Growth)\n Recommendation: Scale back spend to ${max_spend:,.2f} to maintain target unit economics.")
+ else: print("Status: OPTIMAL SCALING ZONE.\nRecommendation: You are operating within the highly efficient growth window.")
+
+
+
+[docs]
+ def plot_response_curve(self, target_mroas=1.0, current_spend=None, show_intervals=True):
+ """Generates a visualization of the media response and marginal return curves.
+
+ Args:
+ target_mroas (float): The target marginal return floor. Defaults to 1.0.
+ current_spend (float, optional): The current spend to mark on the chart.
+ show_intervals (bool): If True and posterior samples exist, plots
+ the 90% credible interval. Defaults to True.
+ """
+ min_spend = self.get_minimal_marginal_cost_point()
+ max_spend = self.get_diminishing_returns_point(target_mroas)
+
+ plot_limit = max_spend * 1.5 if max_spend else min_spend * 4
+ plot_limit = max(plot_limit, current_spend * 1.2 if current_spend else 0)
+
+ x_vals = np.linspace(0, plot_limit, 500)
+
+ if show_intervals and self.posterior_samples:
+ y_returns_dist = self.predict_incremental_return(x_vals, use_samples=True)
+ y_return = np.mean(y_returns_dist, axis=0)
+ y_return_low = np.percentile(y_returns_dist, 5, axis=0)
+ y_return_high = np.percentile(y_returns_dist, 95, axis=0)
+
+ y_mroas_dist = self.predict_marginal_return(x_vals, use_samples=True)
+ y_mroas = np.mean(y_mroas_dist, axis=0)
+ y_mroas_low = np.percentile(y_mroas_dist, 5, axis=0)
+ y_mroas_high = np.percentile(y_mroas_dist, 95, axis=0)
+ else:
+ y_return = self.predict_incremental_return(x_vals)
+ y_mroas = self.predict_marginal_return(x_vals)
+
+ fig, ax1 = plt.subplots(figsize=(12, 6))
+ # Primary Axis: Response Curve
+ ax1.plot(x_vals, y_return, color='#2CA02C', linewidth=3, label="Incremental Return")
+ if show_intervals and self.posterior_samples:
+ ax1.fill_between(x_vals, y_return_low, y_return_high, color='#2CA02C', alpha=0.2, label="90% Credible Interval")
+
+ ax1.set_xlabel('Spend ($)', fontsize=12, fontweight='bold')
+ ax1.set_ylabel('Incremental Return', color='#2CA02C', fontsize=12, fontweight='bold')
+ ax1.tick_params(axis='y', labelcolor='#2CA02C')
+ ax1.grid(True, linestyle='--', alpha=0.5)
+
+ # Secondary Axis: Marginal Return
+ ax2 = ax1.twinx()
+ ax2.plot(x_vals, y_mroas, color='#1F77B4', linestyle='--', linewidth=2, label="Marginal ROAS")
+ if show_intervals and self.posterior_samples:
+ ax2.fill_between(x_vals, y_mroas_low, y_mroas_high, color='#1F77B4', alpha=0.1)
+
+ ax2.set_ylabel('Marginal ROAS (mROAS)', color='#1F77B4', fontsize=12, fontweight='bold')
+ ax2.tick_params(axis='y', labelcolor='#1F77B4')
+ ax2.axhline(target_mroas, color='gray', linestyle=':', label="Target mROAS Floor")
+ # Mark Inflection Points
+ ax2.plot(min_spend, self.predict_marginal_return(min_spend), marker='*', color='gold', markersize=15, markeredgecolor='black', label="Minimal Marginal Cost (Peak Efficiency)")
+ if max_spend:
+ ax2.plot(max_spend, target_mroas, marker='X', color='red', markersize=12, label="Point of Diminishing Returns")
+ ax1.axvspan(min_spend, max_spend, color='green', alpha=0.1, label='Optimal Scaling Zone') # Shade the "Optimal Scaling Zone"
+ if current_spend: ax1.axvline(current_spend, color='purple', linestyle='-.', label="Current Spend")
+ # Combine Legends
+ lines_1, labels_1 = ax1.get_legend_handles_labels()
+ lines_2, labels_2 = ax2.get_legend_handles_labels()
+ ax1.legend(lines_1 + lines_2, labels_1 + labels_2, loc='upper left', bbox_to_anchor=(1.05, 1))
+ plt.title(f'Response Curve Analysis: {self.channel_name}\n$\\alpha={self.alpha:.2f}, K={self.K:,.0f}, \\beta={self.beta:,.0f}$', fontsize=14)
+ plt.tight_layout()
+ plt.show()
+
+
+' + + '' + + _("Hide Search Matches") + + "
", + ), + ); + }, + + /** + * helper function to hide the search marks again + */ + hideSearchWords: () => { + document + .querySelectorAll("#searchbox .highlight-link") + .forEach((el) => el.remove()); + document + .querySelectorAll("span.highlighted") + .forEach((el) => el.classList.remove("highlighted")); + localStorage.removeItem("sphinx_highlight_terms"); + }, + + initEscapeListener: () => { + // only install a listener if it is really needed + if (!DOCUMENTATION_OPTIONS.ENABLE_SEARCH_SHORTCUTS) return; + + document.addEventListener("keydown", (event) => { + // bail for input elements + if (BLACKLISTED_KEY_CONTROL_ELEMENTS.has(document.activeElement.tagName)) + return; + // bail with special keys + if (event.shiftKey || event.altKey || event.ctrlKey || event.metaKey) + return; + if ( + DOCUMENTATION_OPTIONS.ENABLE_SEARCH_SHORTCUTS + && event.key === "Escape" + ) { + SphinxHighlight.hideSearchWords(); + event.preventDefault(); + } + }); + }, +}; + +_ready(() => { + /* Do not call highlightSearchWords() when we are on the search page. + * It will highlight words from the *previous* search query. + */ + if (typeof Search === "undefined") SphinxHighlight.highlightSearchWords(); + SphinxHighlight.initEscapeListener(); +}); diff --git a/docs/_build/html/_static/styles/furo-extensions.css b/docs/_build/html/_static/styles/furo-extensions.css new file mode 100644 index 0000000..2d74267 --- /dev/null +++ b/docs/_build/html/_static/styles/furo-extensions.css @@ -0,0 +1,2 @@ +#furo-sidebar-ad-placement{padding:var(--sidebar-item-spacing-vertical) var(--sidebar-item-spacing-horizontal)}#furo-sidebar-ad-placement .ethical-sidebar{background:var(--color-background-secondary);border:none;box-shadow:none}#furo-sidebar-ad-placement .ethical-sidebar:hover{background:var(--color-background-hover)}#furo-sidebar-ad-placement .ethical-sidebar a{color:var(--color-foreground-primary)}#furo-sidebar-ad-placement .ethical-callout a{color:var(--color-foreground-secondary)!important}#furo-readthedocs-versions{background:transparent;display:block;position:static;width:100%}#furo-readthedocs-versions .rst-versions{background:#1a1c1e}#furo-readthedocs-versions .rst-current-version{background:var(--color-sidebar-item-background);cursor:unset}#furo-readthedocs-versions .rst-current-version:hover{background:var(--color-sidebar-item-background)}#furo-readthedocs-versions .rst-current-version .fa-book{color:var(--color-foreground-primary)}#furo-readthedocs-versions>.rst-other-versions{padding:0}#furo-readthedocs-versions>.rst-other-versions small{opacity:1}#furo-readthedocs-versions .injected .rst-versions{position:unset}#furo-readthedocs-versions:focus-within,#furo-readthedocs-versions:hover{box-shadow:0 0 0 1px var(--color-sidebar-background-border)}#furo-readthedocs-versions:focus-within .rst-current-version,#furo-readthedocs-versions:hover .rst-current-version{background:#1a1c1e;font-size:inherit;height:auto;line-height:inherit;padding:12px;text-align:right}#furo-readthedocs-versions:focus-within .rst-current-version .fa-book,#furo-readthedocs-versions:hover .rst-current-version .fa-book{color:#fff;float:left}#furo-readthedocs-versions:focus-within .fa-caret-down,#furo-readthedocs-versions:hover .fa-caret-down{display:none}#furo-readthedocs-versions:focus-within .injected,#furo-readthedocs-versions:focus-within .rst-current-version,#furo-readthedocs-versions:focus-within .rst-other-versions,#furo-readthedocs-versions:hover .injected,#furo-readthedocs-versions:hover .rst-current-version,#furo-readthedocs-versions:hover .rst-other-versions{display:block}#furo-readthedocs-versions:focus-within>.rst-current-version,#furo-readthedocs-versions:hover>.rst-current-version{display:none}.highlight:hover button.copybtn{color:var(--color-code-foreground)}.highlight button.copybtn{align-items:center;background-color:var(--color-code-background);border:none;color:var(--color-background-item);cursor:pointer;height:1.25em;right:.5rem;top:.625rem;transition:color .3s,opacity .3s;width:1.25em}.highlight button.copybtn:hover{background-color:var(--color-code-background);color:var(--color-brand-content)}.highlight button.copybtn:after{background-color:transparent;color:var(--color-code-foreground);display:none}.highlight button.copybtn.success{color:#22863a;transition:color 0s}.highlight button.copybtn.success:after{display:block}.highlight button.copybtn svg{padding:0}body{--sd-color-primary:var(--color-brand-primary);--sd-color-primary-highlight:var(--color-brand-content);--sd-color-primary-text:var(--color-background-primary);--sd-color-shadow:rgba(0,0,0,.05);--sd-color-card-border:var(--color-card-border);--sd-color-card-border-hover:var(--color-brand-content);--sd-color-card-background:var(--color-card-background);--sd-color-card-text:var(--color-foreground-primary);--sd-color-card-header:var(--color-card-marginals-background);--sd-color-card-footer:var(--color-card-marginals-background);--sd-color-tabs-label-active:var(--color-brand-content);--sd-color-tabs-label-hover:var(--color-foreground-muted);--sd-color-tabs-label-inactive:var(--color-foreground-muted);--sd-color-tabs-underline-active:var(--color-brand-content);--sd-color-tabs-underline-hover:var(--color-foreground-border);--sd-color-tabs-underline-inactive:var(--color-background-border);--sd-color-tabs-overline:var(--color-background-border);--sd-color-tabs-underline:var(--color-background-border)}.sd-tab-content{box-shadow:0 -2px var(--sd-color-tabs-overline),0 1px var(--sd-color-tabs-underline)}.sd-card{box-shadow:0 .1rem .25rem var(--sd-color-shadow),0 0 .0625rem rgba(0,0,0,.1)}.sd-shadow-sm{box-shadow:0 .1rem .25rem var(--sd-color-shadow),0 0 .0625rem rgba(0,0,0,.1)!important}.sd-shadow-md{box-shadow:0 .3rem .75rem var(--sd-color-shadow),0 0 .0625rem rgba(0,0,0,.1)!important}.sd-shadow-lg{box-shadow:0 .6rem 1.5rem var(--sd-color-shadow),0 0 .0625rem rgba(0,0,0,.1)!important}.sd-card-hover:hover{transform:none}.sd-cards-carousel{gap:.25rem;padding:.25rem}body{--tabs--label-text:var(--color-foreground-muted);--tabs--label-text--hover:var(--color-foreground-muted);--tabs--label-text--active:var(--color-brand-content);--tabs--label-text--active--hover:var(--color-brand-content);--tabs--label-background:transparent;--tabs--label-background--hover:transparent;--tabs--label-background--active:transparent;--tabs--label-background--active--hover:transparent;--tabs--padding-x:0.25em;--tabs--margin-x:1em;--tabs--border:var(--color-background-border);--tabs--label-border:transparent;--tabs--label-border--hover:var(--color-foreground-muted);--tabs--label-border--active:var(--color-brand-content);--tabs--label-border--active--hover:var(--color-brand-content)}[role=main] .container{max-width:none;padding-left:0;padding-right:0}.shadow.docutils{border:none;box-shadow:0 .2rem .5rem rgba(0,0,0,.05),0 0 .0625rem rgba(0,0,0,.1)!important}.sphinx-bs .card{background-color:var(--color-background-secondary);color:var(--color-foreground)} +/*# sourceMappingURL=furo-extensions.css.map*/ \ No newline at end of file diff --git a/docs/_build/html/_static/styles/furo-extensions.css.map b/docs/_build/html/_static/styles/furo-extensions.css.map new file mode 100644 index 0000000..68fb7fd --- /dev/null +++ b/docs/_build/html/_static/styles/furo-extensions.css.map @@ -0,0 +1 @@ +{"version":3,"file":"styles/furo-extensions.css","mappings":"AAGA,2BACE,oFACA,4CAKE,6CAHA,YACA,eAEA,CACA,kDACE,yCAEF,8CACE,sCAEJ,8CACE,kDAEJ,2BAGE,uBACA,cAHA,gBACA,UAEA,CAGA,yCACE,mBAEF,gDAEE,gDADA,YACA,CACA,sDACE,gDACF,yDACE,sCAEJ,+CACE,UACA,qDACE,UAGF,mDACE,eAEJ,yEAEE,4DAEA,mHASE,mBAPA,kBAEA,YADA,oBAGA,aADA,gBAIA,CAEA,qIAEE,WADA,UACA,CAEJ,uGACE,aAEF,iUAGE,cAEF,mHACE,aC1EJ,gCACE,mCAEF,0BAEE,mBAUA,8CACA,YAFA,mCAKA,eAZA,cAIA,YADA,YAYA,iCAdA,YAcA,CAEA,gCAEE,8CADA,gCACA,CAEF,gCAGE,6BADA,mCADA,YAEA,CAEF,kCAEE,cADA,mBACA,CACA,wCACE,cAEJ,8BACE,UCzCN,KAEE,6CAA8C,CAC9C,uDAAwD,CACxD,uDAAwD,CAGxD,iCAAsC,CAGtC,+CAAgD,CAChD,uDAAwD,CACxD,uDAAwD,CACxD,oDAAqD,CACrD,6DAA8D,CAC9D,6DAA8D,CAG9D,uDAAwD,CACxD,yDAA0D,CAC1D,4DAA6D,CAC7D,2DAA4D,CAC5D,8DAA+D,CAC/D,iEAAkE,CAClE,uDAAwD,CACxD,wDAAyD,CAG3D,gBACE,qFAGF,SACE,6EAEF,cACE,uFAEF,cACE,uFAEF,cACE,uFAGF,qBACE,eAEF,mBACE,WACA,eChDF,KACE,gDAAiD,CACjD,uDAAwD,CACxD,qDAAsD,CACtD,4DAA6D,CAC7D,oCAAqC,CACrC,2CAA4C,CAC5C,4CAA6C,CAC7C,mDAAoD,CACpD,wBAAyB,CACzB,oBAAqB,CACrB,6CAA8C,CAC9C,gCAAiC,CACjC,yDAA0D,CAC1D,uDAAwD,CACxD,8DAA+D,CCbjE,uBACE,eACA,eACA,gBAGF,iBACE,YACA,+EAGF,iBACE,mDACA","sources":["webpack:///./src/furo/assets/styles/extensions/_readthedocs.sass","webpack:///./src/furo/assets/styles/extensions/_copybutton.sass","webpack:///./src/furo/assets/styles/extensions/_sphinx-design.sass","webpack:///./src/furo/assets/styles/extensions/_sphinx-inline-tabs.sass","webpack:///./src/furo/assets/styles/extensions/_sphinx-panels.sass"],"sourcesContent":["// This file contains the styles used for tweaking how ReadTheDoc's embedded\n// contents would show up inside the theme.\n\n#furo-sidebar-ad-placement\n padding: var(--sidebar-item-spacing-vertical) var(--sidebar-item-spacing-horizontal)\n .ethical-sidebar\n // Remove the border and box-shadow.\n border: none\n box-shadow: none\n // Manage the background colors.\n background: var(--color-background-secondary)\n &:hover\n background: var(--color-background-hover)\n // Ensure the text is legible.\n a\n color: var(--color-foreground-primary)\n\n .ethical-callout a\n color: var(--color-foreground-secondary) !important\n\n#furo-readthedocs-versions\n position: static\n width: 100%\n background: transparent\n display: block\n\n // Make the background color fit with the theme's aesthetic.\n .rst-versions\n background: rgb(26, 28, 30)\n\n .rst-current-version\n cursor: unset\n background: var(--color-sidebar-item-background)\n &:hover\n background: var(--color-sidebar-item-background)\n .fa-book\n color: var(--color-foreground-primary)\n\n > .rst-other-versions\n padding: 0\n small\n opacity: 1\n\n .injected\n .rst-versions\n position: unset\n\n &:hover,\n &:focus-within\n box-shadow: 0 0 0 1px var(--color-sidebar-background-border)\n\n .rst-current-version\n // Undo the tweaks done in RTD's CSS\n font-size: inherit\n line-height: inherit\n height: auto\n text-align: right\n padding: 12px\n\n // Match the rest of the body\n background: #1a1c1e\n\n .fa-book\n float: left\n color: white\n\n .fa-caret-down\n display: none\n\n .rst-current-version,\n .rst-other-versions,\n .injected\n display: block\n\n > .rst-current-version\n display: none\n",".highlight\n &:hover button.copybtn\n color: var(--color-code-foreground)\n\n button.copybtn\n // Align things correctly\n align-items: center\n\n height: 1.25em\n width: 1.25em\n\n top: 0.625rem // $code-spacing-vertical\n right: 0.5rem\n\n // Make it look better\n color: var(--color-background-item)\n background-color: var(--color-code-background)\n border: none\n\n // Change to cursor to make it obvious that you can click on it\n cursor: pointer\n\n // Transition smoothly, for aesthetics\n transition: color 300ms, opacity 300ms\n\n &:hover\n color: var(--color-brand-content)\n background-color: var(--color-code-background)\n\n &::after\n display: none\n color: var(--color-code-foreground)\n background-color: transparent\n\n &.success\n transition: color 0ms\n color: #22863a\n &::after\n display: block\n\n svg\n padding: 0\n","body\n // Colors\n --sd-color-primary: var(--color-brand-primary)\n --sd-color-primary-highlight: var(--color-brand-content)\n --sd-color-primary-text: var(--color-background-primary)\n\n // Shadows\n --sd-color-shadow: rgba(0, 0, 0, 0.05)\n\n // Cards\n --sd-color-card-border: var(--color-card-border)\n --sd-color-card-border-hover: var(--color-brand-content)\n --sd-color-card-background: var(--color-card-background)\n --sd-color-card-text: var(--color-foreground-primary)\n --sd-color-card-header: var(--color-card-marginals-background)\n --sd-color-card-footer: var(--color-card-marginals-background)\n\n // Tabs\n --sd-color-tabs-label-active: var(--color-brand-content)\n --sd-color-tabs-label-hover: var(--color-foreground-muted)\n --sd-color-tabs-label-inactive: var(--color-foreground-muted)\n --sd-color-tabs-underline-active: var(--color-brand-content)\n --sd-color-tabs-underline-hover: var(--color-foreground-border)\n --sd-color-tabs-underline-inactive: var(--color-background-border)\n --sd-color-tabs-overline: var(--color-background-border)\n --sd-color-tabs-underline: var(--color-background-border)\n\n// Tabs\n.sd-tab-content\n box-shadow: 0 -2px var(--sd-color-tabs-overline), 0 1px var(--sd-color-tabs-underline)\n\n// Shadows\n.sd-card // Have a shadow by default\n box-shadow: 0 0.1rem 0.25rem var(--sd-color-shadow), 0 0 0.0625rem rgba(0, 0, 0, 0.1)\n\n.sd-shadow-sm\n box-shadow: 0 0.1rem 0.25rem var(--sd-color-shadow), 0 0 0.0625rem rgba(0, 0, 0, 0.1) !important\n\n.sd-shadow-md\n box-shadow: 0 0.3rem 0.75rem var(--sd-color-shadow), 0 0 0.0625rem rgba(0, 0, 0, 0.1) !important\n\n.sd-shadow-lg\n box-shadow: 0 0.6rem 1.5rem var(--sd-color-shadow), 0 0 0.0625rem rgba(0, 0, 0, 0.1) !important\n\n// Cards\n.sd-card-hover:hover // Don't change scale on hover\n transform: none\n\n.sd-cards-carousel // Have a bit of gap in the carousel by default\n gap: 0.25rem\n padding: 0.25rem\n","// This file contains styles to tweak sphinx-inline-tabs to work well with Furo.\n\nbody\n --tabs--label-text: var(--color-foreground-muted)\n --tabs--label-text--hover: var(--color-foreground-muted)\n --tabs--label-text--active: var(--color-brand-content)\n --tabs--label-text--active--hover: var(--color-brand-content)\n --tabs--label-background: transparent\n --tabs--label-background--hover: transparent\n --tabs--label-background--active: transparent\n --tabs--label-background--active--hover: transparent\n --tabs--padding-x: 0.25em\n --tabs--margin-x: 1em\n --tabs--border: var(--color-background-border)\n --tabs--label-border: transparent\n --tabs--label-border--hover: var(--color-foreground-muted)\n --tabs--label-border--active: var(--color-brand-content)\n --tabs--label-border--active--hover: var(--color-brand-content)\n","// This file contains styles to tweak sphinx-panels to work well with Furo.\n\n// sphinx-panels includes Bootstrap 4, which uses .container which can conflict\n// with docutils' `.. container::` directive.\n[role=\"main\"] .container\n max-width: initial\n padding-left: initial\n padding-right: initial\n\n// Make the panels look nicer!\n.shadow.docutils\n border: none\n box-shadow: 0 0.2rem 0.5rem rgba(0, 0, 0, 0.05), 0 0 0.0625rem rgba(0, 0, 0, 0.1) !important\n\n// Make panel colors respond to dark mode\n.sphinx-bs .card\n background-color: var(--color-background-secondary)\n color: var(--color-foreground)\n"],"names":[],"sourceRoot":""} \ No newline at end of file diff --git a/docs/_build/html/_static/styles/furo.css b/docs/_build/html/_static/styles/furo.css new file mode 100644 index 0000000..a5b614d --- /dev/null +++ b/docs/_build/html/_static/styles/furo.css @@ -0,0 +1,2 @@ +/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */html{line-height:1.15;-webkit-text-size-adjust:100%}body{margin:0}main{display:block}h1{font-size:2em;margin:.67em 0}hr{box-sizing:content-box;height:0;overflow:visible}pre{font-family:monospace,monospace;font-size:1em}a{background-color:transparent}abbr[title]{border-bottom:none;text-decoration:underline;text-decoration:underline dotted}b,strong{font-weight:bolder}code,kbd,samp{font-family:monospace,monospace;font-size:1em}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}img{border-style:none}button,input,optgroup,select,textarea{font-family:inherit;font-size:100%;line-height:1.15;margin:0}button,input{overflow:visible}button,select{text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{border-style:none;padding:0}[type=button]:-moz-focusring,[type=reset]:-moz-focusring,[type=submit]:-moz-focusring,button:-moz-focusring{outline:1px dotted ButtonText}fieldset{padding:.35em .75em .625em}legend{box-sizing:border-box;color:inherit;display:table;max-width:100%;padding:0;white-space:normal}progress{vertical-align:baseline}textarea{overflow:auto}[type=checkbox],[type=radio]{box-sizing:border-box;padding:0}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}details{display:block}summary{display:list-item}[hidden],template{display:none}@media print{.content-icon-container,.headerlink,.mobile-header,.related-pages{display:none!important}.highlight{border:.1pt solid var(--color-foreground-border)}a,blockquote,dl,ol,p,pre,table,ul{page-break-inside:avoid}caption,figure,h1,h2,h3,h4,h5,h6,img{page-break-after:avoid;page-break-inside:avoid}dl,ol,ul{page-break-before:avoid}}.visually-hidden{height:1px!important;margin:-1px!important;overflow:hidden!important;padding:0!important;position:absolute!important;width:1px!important;clip:rect(0,0,0,0)!important;background:var(--color-background-primary);border:0!important;color:var(--color-foreground-primary);white-space:nowrap!important}:-moz-focusring{outline:auto}body{--font-stack:-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif,Apple Color Emoji,Segoe UI Emoji;--font-stack--monospace:"SFMono-Regular",Menlo,Consolas,Monaco,Liberation Mono,Lucida Console,monospace;--font-stack--headings:var(--font-stack);--font-size--normal:100%;--font-size--small:87.5%;--font-size--small--2:81.25%;--font-size--small--3:75%;--font-size--small--4:62.5%;--sidebar-caption-font-size:var(--font-size--small--2);--sidebar-item-font-size:var(--font-size--small);--sidebar-search-input-font-size:var(--font-size--small);--toc-font-size:var(--font-size--small--3);--toc-font-size--mobile:var(--font-size--normal);--toc-title-font-size:var(--font-size--small--4);--admonition-font-size:0.8125rem;--admonition-title-font-size:0.8125rem;--code-font-size:var(--font-size--small--2);--api-font-size:var(--font-size--small);--header-height:calc(var(--sidebar-item-line-height) + var(--sidebar-item-spacing-vertical)*4);--header-padding:0.5rem;--sidebar-tree-space-above:1.5rem;--sidebar-caption-space-above:1rem;--sidebar-item-line-height:1rem;--sidebar-item-spacing-vertical:0.5rem;--sidebar-item-spacing-horizontal:1rem;--sidebar-item-height:calc(var(--sidebar-item-line-height) + var(--sidebar-item-spacing-vertical)*2);--sidebar-expander-width:var(--sidebar-item-height);--sidebar-search-space-above:0.5rem;--sidebar-search-input-spacing-vertical:0.5rem;--sidebar-search-input-spacing-horizontal:0.5rem;--sidebar-search-input-height:1rem;--sidebar-search-icon-size:var(--sidebar-search-input-height);--toc-title-padding:0.25rem 0;--toc-spacing-vertical:1.5rem;--toc-spacing-horizontal:1.5rem;--toc-item-spacing-vertical:0.4rem;--toc-item-spacing-horizontal:1rem;--icon-search:url('data:image/svg+xml;charset=utf-8,