Skip to content

ump_spec.rules.estimands

module ump-spec

Estimand rules (spec §5): canonical form, compare, align ladder (G-EST-02), fill undeclared with the rule 4 envelope (G-EST-01), plain words.

class

class CanonicalEstimand

Spec §5 canonical form. fields maps each of the seven fields to its canonical value, or None when undeclared (rule (c): None never equals anything). sign is -1 when the source was in the reverse orientation, so its quantity must be negated. steps names the rules applied.

Fields

NameTypeDefaultDescription
fieldsdict[str, Any]
scopedict[str, Any]
signint1
stepstuple[str, ...]()
Methods and properties (1)

init constructor

CanonicalEstimand(
self,
fields: dict[str, Any],
scope: dict[str, Any],
sign: int = 1,
steps: tuple[str, ...] = (),
) -> None

Source: packages/ump-spec/src/ump_spec/rules/estimands.py, line 51

class

class EstimandComparison

combinable only when every canonical field is declared and equal, the scopes are equal and both sides share one orientation (rule 1). scope is contained when one geo set strictly contains the other (aligned, not equal); sign_flip when one side is in the reverse orientation.

Fields

NameTypeDefaultDescription
combinablebool
mismatchedlist[str]
undeclaredlist[str]field(default_factory=list)
detailsdict[str, tuple[Any, Any]]field(default_factory=dict)
scopeScopeRelation'equal'
sign_flipboolFalse
stepslist[str]field(default_factory=list)
Methods and properties (1)

init constructor

EstimandComparison(
self,
combinable: bool,
mismatched: list[str],
undeclared: list[str] = list(),
details: dict[str, tuple[Any, Any]] = dict(),
scope: ScopeRelation = 'equal',
sign_flip: bool = False,
steps: list[str] = list(),
) -> None

Source: packages/ump-spec/src/ump_spec/rules/estimands.py, line 150

class

class Converter

Fields

NameTypeDefaultDescription
namestr
fieldstr
descriptionstr
needstuple[str, ...]
per_drawboolFalse
Methods and properties (2)

applies method

def applies(self, ea: Estimand, eb: Estimand) -> bool

init constructor

Converter(
self,
name: str,
field: str,
description: str,
needs: tuple[str, ...],
per_draw: bool = False,
) -> None

Source: packages/ump-spec/src/ump_spec/rules/estimands.py, line 195

class

class AlignmentResult

rule is the spec §5 combination rule used (1, 2.1 to 2.4); resummary marks the rule 2.2 re-summary of draws; scope is the comparison’s scope relation.

Fields

NameTypeDefaultDescription
stepAlignStep
combinablebool
mismatchedlist[str]
converterslist[str]field(default_factory=list)
fallbackFallbackEntry | NoneNone
noteslist[Note]field(default_factory=list)
intervalInterval | NoneNone
reasonstr''
rulestr''
resummaryboolFalse
scopeScopeRelation'equal'
Methods and properties (1)

init constructor

AlignmentResult(
self,
step: AlignStep,
combinable: bool,
mismatched: list[str],
converters: list[str] = list(),
fallback: FallbackEntry | None = None,
notes: list[Note] = list(),
interval: Interval | None = None,
reason: str = '',
rule: str = '',
resummary: bool = False,
scope: ScopeRelation = 'equal',
) -> None

Source: packages/ump-spec/src/ump_spec/rules/estimands.py, line 240

function

def canonical_estimand(estimand: Estimand | dict[str, Any]) -> CanonicalEstimand

Reduce an estimand to the spec §5 canonical form before G-EST-02 compares it:

  • (a) scale with relative size -1.0 against current_plan becomes remove_all against current_plan; the reverse orientation (remove_all or factor 0 against zero_spend, the ROI-versus-zero-spend form adapters write) takes the same form with sign -1;
  • (b) total and a total_to_decay with no threshold take TOTAL_TO_DECAY_DEFAULT_THRESHOLD;
  • (d) interval levels are rounded to three decimals;
  • (e) a schema_version that is not a registered estimand registry version is undeclared;
  • (f) a bare point_rule name becomes {rule, scale: identity}.

The scope keeps every applies_to member, with media_ids and geo_ids as sorted sets.

Source: packages/ump-spec/src/ump_spec/rules/estimands.py, line 77

function

def compare_estimands(
a: Estimand | dict[str, Any],
b: Estimand | dict[str, Any],
) -> EstimandComparison

Compare two estimands in canonical form (spec §5). Undeclared fields count as mismatched and are also listed in undeclared. A strict geo containment is reported in scope, not mismatched.

Source: packages/ump-spec/src/ump_spec/rules/estimands.py, line 165

function

def align(
a: Estimand | dict[str, Any],
b: Estimand | dict[str, Any],
*,
a_is_model: bool = False,
b_is_model: bool = False,
available_inputs: set[str] | None = None,
can_reask: bool = True,
candidate_intervals: list[Interval] | None = None,
a_has_draws: bool = False,
b_has_draws: bool = False,
mode: Literal['fallback', 'strict'] = 'fallback',
) -> AlignmentResult

G-EST-02 ladder on the canonical forms: converter -> re-ask the model (or, when both sides carry draws and only point_rule or interval differ, a re-summary of the draws: rule 2.2, no widening) -> assumed alignment with envelope widening -> side by side. Records the step used. A reverse orientation is the orientation_sign_flip converter step. A strict geo containment is aligned, not equal: ESTIMAND_ALIGNED and the representativeness check. candidate_intervals (one per plausible alignment) gives the envelope for an assumed alignment. A remove_all vs marginal intervention mismatch (ROI vs mROI) is never assumed away: it goes side by side. Strict mode raises ESTIMAND_ALIGNED (409) for any rung after the converter.

Source: packages/ump-spec/src/ump_spec/rules/estimands.py, line 276

function

def max_alternatives() -> int

The cap in force: MAX_ALTERNATIVES (tests/test_estimands.py pins it to the registry).

Source: packages/ump-spec/src/ump_spec/rules/estimands.py, line 421

function

def widen_factor(source_type: str | None = None) -> float

Proposed widening for an assumed field with no registered alternatives: the source type’s own widen_factor, else the registry’s envelope.widen_factor, else DEFAULT_UNDECLARED_WIDEN.

Source: packages/ump-spec/src/ump_spec/rules/estimands.py, line 426

function

def plausible_alternatives(
estimand: Estimand | dict[str, Any],
source_type: str | None = None,
) -> list[Estimand] | None

The rule 4 plausible set: one filled estimand per element of the cross product of the registered alternatives of every assumed field, the registered default first. None when the product exceeds the registered max_alternatives (the step then goes side by side). An estimand with nothing assumed gives a list of itself.

Source: packages/ump-spec/src/ump_spec/rules/estimands.py, line 539

function

def fill_undeclared(
estimand: Estimand | dict[str, Any],
source_type: str | None = None,
*,
interval: Interval | None = None,
alternative_intervals: list[Interval] | None = None,
score: Callable[[Estimand], Interval | None] | None = None,
mode: Literal['fallback', 'strict'] = 'fallback',
) -> tuple[Estimand, list[FallbackEntry], Interval | None]

G-EST-01 under spec §5 rule 4. Each undeclared field takes the registered default for the source type and is marked assumed; a field with no registered default stays undeclared and goes side by side (there are no global defaults). The interval:

  • the envelope (method envelope, the declared level kept) over score(alternative) for each element of the registered alternatives’ cross product (plausible_alternatives), or over interval and alternative_intervals when the caller scored them itself;
  • beyond max_alternatives the step goes side by side: nothing is assumed, the interval is kept;
  • with no registered alternatives (or none scored), interval widened by widen_factor around its midpoint, method imputed.

The point stays the caller’s value under the registered default, labelled assumed. Strict mode raises ESTIMAND_UNDECLARED (422).

Source: packages/ump-spec/src/ump_spec/rules/estimands.py, line 551

function

def reallocation_intervention(
source: str,
destination: str,
*,
amount: float | None = None,
share: float | None = None,
population: str | None = None,
) -> Intervention

How to express a budget shift between channels without a fifth intervention type.

Spec §5 fixes four intervention types (remove_all, scale, add, marginal), so a reallocation is written as a two-arm intervention against the current plan:

  • a known amount moved: {type: "add", size: amount, size_kind: "absolute", arms: ["from:<src>", "to:<dst>"]};
  • a share of the source’s spend moved: {type: "scale", size: -share, size_kind: "relative", arms: [...]}.

Pair it with baseline="current_plan" and spend_unit(currency) (per unit of currency moved). Total spend is unchanged. compare_estimands treats two reallocations as matching only when type, size and both arms match. plain_words renders it as “moving 10% of search spend from search to social”.

Source: packages/ump-spec/src/ump_spec/rules/estimands.py, line 651

function

def reallocation_arms(
iv: Intervention | dict[str, Any] | None,
) -> tuple[str, str] | None

(sources, destinations) when the intervention uses the from:/to: arm convention.

Every arm is kept: with several from: or to: arms the names are joined with ”, ” in arm order (["from:tv", "from:social", "to:search"] gives ("tv, social", "search")).

Source: packages/ump-spec/src/ump_spec/rules/estimands.py, line 674

function

def plain_words(estimand: Estimand | dict[str, Any]) -> str

Render the estimand in plain words, naming every assumed or undeclared field (§5 rule 5). Example: “revenue per dollar from removing all spend versus zero spend (assumed; the source did not say), total including carryover, posterior median, 90% eti”.

Source: packages/ump-spec/src/ump_spec/rules/estimands.py, line 687

NameTypeValueDescription
AlignStepLiteral['match', 'converter', 're_ask', 'assumed_alignment', 'side_by_side']
ALIGN_LADDERtuple[str, ...]('converter', 're_ask', 'assumed_alignment', 'side_by_side')
SCOPE_FIELD'applies_to'
COMPARED_FIELDStuple[str, ...](*ESTIMAND_FIELDS, SCOPE_FIELD)
TOTAL_TO_DECAY_DEFAULT_THRESHOLD0.05Proposed registered default decay threshold (0.05 of the peak adstock weight, spec §5 rule (b)): total and a total_to_decay with no threshold take it.
ORIENTATION_STEP'orientation_sign_flip'Converter step recorded when one side is in the reverse orientation (spec §5 rule (a)).
ScopeRelationLiteral['equal', 'contained', 'differ']
CONVERTERStuple[Converter, ...](Converter('immediate_to_total_adstock', 'horizon', 'immediate to total via t...
ASSUMABLE_FIELDSfrozenset[str]frozenset({'baseline', 'horizon', 'point_rule', 'interval', 'schema_version',...Fields where an assumed alignment with envelope widening is defensible. A different KPI, unit denominator or scope is never assumed away: those go side by side.
RESUMMARY_FIELDSfrozenset[str]frozenset({'point_rule', 'interval'})Rule 2.2: when both sides carry draws and only these differ, the draws are re-summarised.
NON_ASSUMABLE_INTERVENTION_PAIRSfrozenset[frozenset[str]]frozenset({frozenset({'remove_all', 'marginal'})})Intervention-type pairs that an assumed alignment never bridges (MOD-EST-03): a remove-all ROI against zero spend and a marginal ROI at the current plan are different numbers, so they go side by side unless a registered converter or a re-ask answers the other question.
MAX_ALTERNATIVES8Proposed cap on the cross product of assumed-field alternatives (spec §5 rule 4); mirrors envelope.max_alternatives in registries/source_types.yaml. Beyond it the step goes side by side.
REALLOCATION_PREFIXES('from:', 'to:')