"""Mass functions and fusion rules."""
from __future__ import annotations
import csv
import json
from collections.abc import Mapping as MappingABC
from io import StringIO
from itertools import product
from math import isfinite, prod
from typing import TYPE_CHECKING, Any, Iterable, Iterator, Mapping, Sequence, cast
from evidencelib.exceptions import InvalidMassError, TotalConflictError
from evidencelib.proposition import Proposition
if TYPE_CHECKING:
from evidencelib.frame import Frame
_MASS_JSON_SCHEMA = "evidencelib.mass.v2"
_LEGACY_MASS_JSON_SCHEMA = "evidencelib.mass.v1"
_EXPORT_COLUMNS = {
"m": ("Mass", "mass"),
"mass": ("Mass", "mass"),
"belief": ("Belief", "belief"),
"bel": ("Belief", "belief"),
"plausibility": ("Plausibility", "plausibility"),
"pl": ("Plausibility", "plausibility"),
"commonality": ("Commonality", "commonality"),
"q": ("Commonality", "commonality"),
}
[docs]
class MassFunction:
"""A basic belief assignment over a frame.
Parameters
----------
frame:
Frame of discernment that owns all propositions in the assignment.
values:
Mapping from propositions, proposition expressions, or iterables of
atom names to assigned masses.
validate:
Validate that masses are non-negative and sum to one.
tolerance:
Numerical tolerance used when cleaning and validating masses.
"""
normalization_tolerance = 1e-6
def __init__(
self,
frame: "Frame",
values: Mapping[Any, float],
*,
validate: bool = True,
tolerance: float = 1e-9,
) -> None:
if not isfinite(tolerance) or not 0 <= tolerance < 1:
raise ValueError("tolerance must be a finite number in the range [0, 1).")
self.frame = frame
self.tolerance = tolerance
masses: dict[Proposition, float] = {}
for key, value in values.items():
prop = frame.proposition(key)
mass = float(value)
if not isfinite(mass):
raise InvalidMassError("Mass values must be finite numbers.")
if mass < -tolerance:
raise InvalidMassError("Mass values must be non-negative.")
if abs(mass) <= tolerance:
continue
masses[prop] = masses.get(prop, 0.0) + mass
self._masses = self._clean(masses)
if validate:
self._validate_sum()
def __getitem__(self, key: str | Proposition | Iterable[str]) -> float:
return self.mass(key)
def __iter__(self) -> Iterator[tuple[Proposition, float]]:
return iter(self.items())
def __repr__(self) -> str:
body = ", ".join(f"{prop}: {value:.6g}" for prop, value in self.items())
return f"MassFunction({{{body}}})"
[docs]
def items(self) -> tuple[tuple[Proposition, float], ...]:
"""Return focal propositions and masses sorted by proposition label."""
return tuple(sorted(self._masses.items(), key=lambda item: str(item[0])))
[docs]
def focal(self) -> tuple[Proposition, ...]:
"""Return propositions with non-zero assigned mass."""
return tuple(prop for prop, _ in self.items())
[docs]
def to_dict(self, *, string_keys: bool = True) -> dict[str | Proposition, float]:
"""Return the mass assignment as a plain dictionary."""
if string_keys:
return {str(prop): value for prop, value in self.items()}
return dict(self.items())
[docs]
@classmethod
def from_dict(
cls,
frame: "Frame",
data: Mapping[Any, Any],
**kwargs: Any,
) -> "MassFunction":
"""Create a mass function from a plain or schema-wrapped dictionary.
``data`` may be a direct mapping such as ``{"A": 0.2, "A|B": 0.8}``
or the object produced by :meth:`to_json` after JSON decoding.
"""
if not isinstance(data, MappingABC):
raise TypeError("Mass data must be a mapping.")
values = cast(Mapping[Any, float], data)
if isinstance(data.get("masses"), MappingABC):
schema = data.get("schema")
if schema is not None and schema not in {
_MASS_JSON_SCHEMA,
_LEGACY_MASS_JSON_SCHEMA,
}:
raise ValueError(f"Unsupported mass JSON schema: {schema!r}.")
cls._validate_frame_metadata(frame, data.get("frame"), schema=schema)
values = cast(Mapping[Any, float], data["masses"])
return cls(frame, values, **kwargs)
[docs]
def to_json(self, *, indent: int | None = 2) -> str:
"""Serialize this mass function to a JSON string.
The JSON stores the mass assignment and lightweight frame metadata for
validation. Import still requires the caller to provide the target
frame, because hybrid DSm constraints are model semantics rather than
just mass data.
"""
data = {
"schema": _MASS_JSON_SCHEMA,
"frame": {
"atoms": list(self.frame.atoms),
"model": self.frame.model,
"region_count": self.frame.region_count,
"regions": list(self.frame.model_signature),
},
"masses": self.to_dict(),
}
return json.dumps(data, indent=indent)
[docs]
@classmethod
def from_json(
cls,
frame: "Frame",
text: str | bytes,
**kwargs: Any,
) -> "MassFunction":
"""Create a mass function from JSON produced by :meth:`to_json`."""
data = json.loads(text)
if not isinstance(data, MappingABC):
raise ValueError("Mass JSON must contain an object.")
schema = data.get("schema")
if schema is not None and schema not in {
_MASS_JSON_SCHEMA,
_LEGACY_MASS_JSON_SCHEMA,
}:
raise ValueError(f"Unsupported mass JSON schema: {schema!r}.")
return cls.from_dict(frame, data, **kwargs)
[docs]
def to_csv(
self,
*,
include_header: bool = True,
float_format: str | None = None,
) -> str:
"""Serialize this mass assignment to CSV text.
The CSV has two columns: ``proposition`` and ``mass``. It is intended
for data exchange and round trips, not for presentation tables.
"""
output = StringIO()
writer = csv.writer(output, lineterminator="\n")
if include_header:
writer.writerow(("proposition", "mass"))
for prop, value in self.items():
writer.writerow((str(prop), self._format_number(value, float_format)))
return output.getvalue()
[docs]
@classmethod
def from_csv(
cls,
frame: "Frame",
text: str,
*,
has_header: bool = True,
**kwargs: Any,
) -> "MassFunction":
"""Create a mass function from CSV text with proposition and mass columns."""
rows = csv.reader(StringIO(text))
values: dict[str, float] = {}
if has_header:
try:
header = next(rows)
except StopIteration as exc:
raise ValueError("Mass CSV is empty.") from exc
normalized = [cell.strip().lower() for cell in header]
if normalized != ["proposition", "mass"]:
raise ValueError("Mass CSV header must be: proposition,mass.")
for row_number, row in enumerate(rows, start=2 if has_header else 1):
if not row or all(not cell.strip() for cell in row):
continue
if len(row) != 2:
raise ValueError(f"Mass CSV row {row_number} must have two columns.")
proposition, value = row
try:
mass = float(value)
except ValueError as exc:
raise ValueError(f"Mass CSV row {row_number} has invalid mass {value!r}.") from exc
values[proposition] = values.get(proposition, 0.0) + mass
return cls(frame, values, **kwargs)
[docs]
def to_latex(
self,
*,
columns: Sequence[str] = ("mass",),
rows: str = "focal",
caption: str | None = None,
label: str | None = None,
float_format: str | None = ".4f",
booktabs: bool = True,
position: str = "htbp",
) -> str:
"""Export this mass function as a LaTeX table string.
Parameters
----------
columns:
Any of ``mass``, ``belief``, ``plausibility``, or ``commonality``.
Short aliases ``m``, ``bel``, ``pl``, and ``q`` are accepted.
rows:
``"focal"`` for stored non-zero masses, or ``"all"`` for every
proposition generated by the frame. ``"all"`` can be large for
DSmT frames.
caption, label:
Optional LaTeX table metadata.
float_format:
Python format specifier such as ``".4f"``. Percent-style formats
such as ``"%0.4f"`` are also accepted.
booktabs:
Use ``toprule``/``midrule``/``bottomrule`` instead of ``hline``.
position:
LaTeX table position specifier.
"""
resolved_columns = self._resolve_export_columns(columns)
row_props = self._export_rows(rows)
alignment = "l" + ("r" * len(resolved_columns))
lines = [f"\\begin{{table}}[{position}]", "\\centering"]
if caption is not None:
lines.append(f"\\caption{{{self._latex_escape_text(caption)}}}")
if label is not None:
lines.append(f"\\label{{{label}}}")
lines.append(f"\\begin{{tabular}}{{{alignment}}}")
lines.append("\\toprule" if booktabs else "\\hline")
headers = ["Proposition", *(heading for heading, _ in resolved_columns)]
lines.append(" & ".join(headers) + r" \\")
lines.append("\\midrule" if booktabs else "\\hline")
for prop in row_props:
values = [
self._format_number(self._export_value(prop, method), float_format)
for _, method in resolved_columns
]
lines.append(" & ".join((self._latex_proposition(prop), *values)) + r" \\")
lines.append("\\bottomrule" if booktabs else "\\hline")
lines.extend(["\\end{tabular}", "\\end{table}"])
return "\n".join(lines)
@property
def total_mass(self) -> float:
"""Sum of all stored masses."""
return sum(self._masses.values())
[docs]
def mass(self, key: str | Proposition | Iterable[str]) -> float:
"""Return the direct mass assigned to a proposition."""
return self._masses.get(self.frame.proposition(key), 0.0)
[docs]
def belief(self, key: str | Proposition | Iterable[str]) -> float:
"""Return belief, the mass of propositions contained in ``key``."""
target = self.frame.proposition(key)
# Generalized bbas have m(empty)=0, so excluding empty is equivalent to
# equation (3) in that setting. The explicit guard also gives belief a
# coherent TBM interpretation for unnormalized Smets results.
return sum(value for prop, value in self._masses.items() if prop and prop <= target)
[docs]
def plausibility(self, key: str | Proposition | Iterable[str]) -> float:
"""Return plausibility, the mass of propositions intersecting ``key``."""
target = self.frame.proposition(key)
return sum(value for prop, value in self._masses.items() if prop.intersects(target))
[docs]
def commonality(self, key: str | Proposition | Iterable[str]) -> float:
"""Return commonality, the mass of propositions containing ``key``."""
target = self.frame.proposition(key)
return sum(value for prop, value in self._masses.items() if target <= prop)
@property
def conflict(self) -> float:
"""Mass assigned to the empty proposition."""
return self.mass(self.frame.empty)
[docs]
def conjunctive(
self,
*others: "MassFunction",
model: "Frame | None" = None,
) -> "MassFunction":
"""Unnormalized conjunctive rule.
On a free DSm frame this is the classic DSm rule (DSmC). On Shafer's
DST model, contradictory intersections are accumulated on ``empty``.
"""
return self._combine_intersection((self, *others), normalize=False, model=model)
[docs]
def dsmc(self, *others: "MassFunction") -> "MassFunction":
"""Alias for the classic conjunctive DSm rule."""
return self.conjunctive(*others)
[docs]
def smets(
self,
*others: "MassFunction",
model: "Frame | None" = None,
) -> "MassFunction":
"""Smets/TBM unnormalized rule, keeping conflict on the empty set."""
return self.conjunctive(*others, model=model)
[docs]
def dempster(
self,
*others: "MassFunction",
model: "Frame | None" = None,
) -> "MassFunction":
"""Dempster's normalized rule of combination."""
return self._combine_intersection((self, *others), normalize=True, model=model)
[docs]
def yager(
self,
*others: "MassFunction",
model: "Frame | None" = None,
) -> "MassFunction":
"""Yager's rule: transfer total conflict to total ignorance."""
conjunctive = self.conjunctive(*others, model=model)
conflict = conjunctive.conflict
masses = {prop: value for prop, value in conjunctive.items() if prop}
if conflict:
masses[conjunctive.frame.total] = (
masses.get(conjunctive.frame.total, 0.0) + conflict
)
return MassFunction(conjunctive.frame, masses, tolerance=self.tolerance)
[docs]
def dubois_prade(
self,
*others: "MassFunction",
model: "Frame | None" = None,
) -> "MassFunction":
"""Apply the static Dubois-Prade conflict-transfer rule.
Dubois-Prade is a static rule. Passing a distinct target ``model``
denotes a dynamic model change and is rejected rather than silently
returning a DSmH result.
"""
sources = (self, *others)
self._check_sources(sources)
if len(sources) != 2:
raise ValueError("Dubois-Prade requires exactly two sources.")
if model is not None and model is not self.frame:
raise ValueError(
"Dubois-Prade is defined here only for static models; "
"use dsmh(..., model=target_frame) for a dynamic model change."
)
self._require_zero_source_conflict(sources, rule="Dubois-Prade")
masses: dict[Proposition, float] = {}
for props, values in self._focal_product(sources):
amount = prod(values)
intersection = self._intersection_all(props)
target = intersection if intersection else self._union_all(props)
if not target:
raise ValueError(
"Dubois-Prade cannot preserve mass for this dynamic/non-existential case."
)
masses[target] = masses.get(target, 0.0) + amount
return MassFunction(self.frame, masses, tolerance=self.tolerance)
[docs]
def dsmh(
self,
*others: "MassFunction",
model: "Frame | None" = None,
) -> "MassFunction":
"""Apply the hybrid DSm rule, including its S1, S2, and S3 terms.
For a dynamic model change, create the source assignments on their
original frame and pass the constrained target frame as ``model``. This
preserves each focal proposition and ``u(X)`` until constraints are
applied. With no explicit model, the rule operates statically on the
sources' existing frame and rejects mass already collapsed onto empty.
"""
sources = (self, *others)
self._check_sources(sources)
target_frame = self.frame if model is None else model
self._validate_target_model(target_frame, rule="DSmH")
if any(source.conflict > source.tolerance for source in sources):
if model is None:
raise ValueError(
"DSmH cannot recover the origin of mass already collapsed onto empty. "
"Create sources on their original frame and pass the constrained "
"target explicitly with dsmh(..., model=target_frame)."
)
raise ValueError("DSmH requires source assignments with m(empty) = 0.")
masses: dict[Proposition, float] = {}
for props, values in self._focal_product(sources):
amount = prod(values)
modeled_props = tuple(
self._proposition_in_frame(prop, target_frame) for prop in props
)
intersection = self._intersection_all(modeled_props)
if intersection:
target = intersection
elif all(not prop for prop in modeled_props):
# S2 in the hybrid DSm rule: all focal elements have become
# relatively/absolutely empty. Their original atom unions u(X)
# must be retained; if those are empty too, transfer to total
# ignorance.
target = target_frame.empty
for original in props:
target = target | self._proposition_in_frame(
original.union_atoms(), target_frame
)
if not target:
target = target_frame.total
else:
# S3: a relatively empty intersection is transferred to the
# canonical disjunction of the involved focal elements.
target = self._union_all(modeled_props, frame=target_frame)
if not target:
target = target_frame.total
masses[target] = masses.get(target, 0.0) + amount
masses.pop(target_frame.empty, None)
return MassFunction(target_frame, masses, tolerance=self.tolerance)
[docs]
def pcr5(self, other: "MassFunction") -> "MassFunction":
"""PCR5 for two sources."""
return self.pcr6(other)
[docs]
def pcr6(self, *others: "MassFunction") -> "MassFunction":
"""PCR6 proportional conflict redistribution for two or more sources."""
sources = (self, *others)
self._check_sources(sources)
self._require_zero_source_conflict(sources, rule="PCR6")
masses: dict[Proposition, float] = {}
for props, values in self._focal_product(sources):
amount = prod(values)
intersection = self._intersection_all(props)
if intersection:
masses[intersection] = masses.get(intersection, 0.0) + amount
continue
denominator = sum(values)
if denominator <= self.tolerance:
continue
for prop, source_mass in zip(props, values, strict=True):
target = prop if prop else self.frame.total
if not target:
continue
share = amount * source_mass / denominator
masses[target] = masses.get(target, 0.0) + share
masses.pop(self.frame.empty, None)
return MassFunction(self.frame, masses, tolerance=self.tolerance)
[docs]
def normalize(self) -> "MassFunction":
"""Normalize a conjunctive result by removing empty-set conflict."""
conflict = self.conflict
denominator = 1.0 - conflict
if denominator <= self.tolerance:
raise TotalConflictError("Dempster normalization is undefined at total conflict.")
masses = {
prop: value / denominator
for prop, value in self._masses.items()
if prop and abs(value) > self.tolerance
}
return MassFunction(self.frame, masses, tolerance=self.tolerance)
[docs]
def pignistic_of(
self,
key: str | Proposition | Iterable[str],
*,
normalize_conflict: bool = True,
) -> float:
"""Return the generalized pignistic probability of one proposition.
This implements ``C_M(X & A) / C_M(X)`` for arbitrary ``A`` in the
frame's hyper-power set. Empty-set conflict is normalized consistently
with :meth:`pignistic` and :meth:`pignistic_regions`.
"""
target = self.frame.proposition(key)
denominator = self._pignistic_denominator(normalize_conflict)
result = 0.0
for prop, mass in self._masses.items():
if not prop:
continue
result += mass * (prop & target).cardinality / prop.cardinality / denominator
return result
[docs]
def pignistic(self, *, normalize_conflict: bool = True) -> dict[str, float]:
"""Return pignistic scores for singleton hypotheses.
This is the classical pignistic transformation on DST frames. On free
or hybrid DSmT frames, singleton hypotheses can overlap, so the returned
event scores are useful for decisions but do not have to sum to one.
If ``normalize_conflict`` is true, mass assigned to the empty proposition
is ignored and the remaining scores are rescaled by ``1 - conflict``.
This makes TBM/Smets results usable for pignistic decisions while still
allowing raw unnormalized scores with ``normalize_conflict=False``.
"""
return {
name: self.pignistic_of(atom, normalize_conflict=normalize_conflict)
for name, atom in zip(self.frame.atoms, self.frame.symbols(), strict=True)
}
[docs]
def pignistic_regions(self, *, normalize_conflict: bool = True) -> dict[str, float]:
"""Return a probability distribution over model Venn regions.
If ``normalize_conflict`` is true, empty-set conflict is excluded and
the non-empty region probabilities are rescaled by ``1 - conflict``.
"""
denominator = self._pignistic_denominator(normalize_conflict)
result = {self._format_region(region): 0.0 for region in self.frame._universe}
for prop, mass in self._masses.items():
if not prop:
continue
cardinality = prop.cardinality
if cardinality == 0:
continue
share = mass / cardinality / denominator
for region in prop.regions:
result[self._format_region(region)] += share
return result
[docs]
def decision(self) -> str:
"""Return the singleton with the largest pignistic probability."""
probabilities = self.pignistic()
return max(probabilities, key=probabilities.__getitem__)
[docs]
def plot(self, *, ax: Any = None, **kwargs: Any) -> Any:
"""Plot this mass assignment as a horizontal bar chart.
This method requires the optional plotting dependency. Install it with
``pip install 'evidencelib[plot]'``.
"""
from evidencelib.plotting import plot_mass
return plot_mass(self, ax=ax, **kwargs)
[docs]
def plot_comparison(
self,
*others: "MassFunction",
labels: Sequence[str] | None = None,
ax: Any = None,
**kwargs: Any,
) -> Any:
"""Plot a heatmap comparing this mass assignment with other sources."""
from evidencelib.plotting import plot_mass_comparison
return plot_mass_comparison((self, *others), labels=labels, ax=ax, **kwargs)
[docs]
def plot_belief_plausibility(self, *, ax: Any = None, **kwargs: Any) -> Any:
"""Plot belief-plausibility intervals for this mass assignment."""
from evidencelib.plotting import plot_belief_plausibility
return plot_belief_plausibility(self, ax=ax, **kwargs)
[docs]
def plot_pignistic_decision(self, *, ax: Any = None, **kwargs: Any) -> Any:
"""Plot the pignistic decision ranking for this mass assignment."""
from evidencelib.plotting import plot_pignistic_decision
return plot_pignistic_decision(self, ax=ax, **kwargs)
[docs]
def plot_venn(self, *, ax: Any = None, **kwargs: Any) -> Any:
"""Plot pignistic or direct mass values over disjoint Venn regions."""
from evidencelib.plotting import plot_venn
return plot_venn(self, ax=ax, **kwargs)
@classmethod
def _from_unchecked(
cls,
frame: "Frame",
values: Mapping[Any, float],
*,
tolerance: float = 1e-9,
) -> "MassFunction":
return cls(frame, values, validate=False, tolerance=tolerance)
def _combine_intersection(
self,
sources: tuple["MassFunction", ...],
*,
normalize: bool,
model: "Frame | None" = None,
) -> "MassFunction":
self._check_sources(sources)
target_frame = self.frame if model is None else model
self._validate_target_model(target_frame, rule="fusion")
masses: dict[Proposition, float] = {}
for props, values in self._focal_product(sources):
modeled_props = tuple(
self._proposition_in_frame(prop, target_frame) for prop in props
)
target = self._intersection_all(modeled_props)
masses[target] = masses.get(target, 0.0) + prod(values)
result = MassFunction(target_frame, masses, tolerance=self.tolerance)
return result.normalize() if normalize else result
def _focal_product(
self,
sources: tuple["MassFunction", ...],
) -> Iterator[tuple[tuple[Proposition, ...], tuple[float, ...]]]:
item_groups = [source.items() for source in sources]
for combo in product(*item_groups):
props = tuple(prop for prop, _ in combo)
values = tuple(value for _, value in combo)
yield props, values
def _intersection_all(self, props: Iterable[Proposition]) -> Proposition:
iterator = iter(props)
result = next(iterator)
for prop in iterator:
result = result & prop
return result
def _union_all(
self,
props: Iterable[Proposition],
*,
frame: "Frame | None" = None,
) -> Proposition:
owner = self.frame if frame is None else frame
result = owner.empty
for prop in props:
result = result | prop
return result
def _proposition_in_frame(self, prop: Proposition, frame: "Frame") -> Proposition:
if prop.frame is frame:
return prop
if prop.is_empty:
return frame.empty
return frame.proposition(str(prop))
def _pignistic_denominator(self, normalize_conflict: bool) -> float:
if not normalize_conflict:
return 1.0
denominator = 1.0 - self.conflict
if denominator <= self.tolerance:
raise TotalConflictError("Pignistic transformation is undefined at total conflict.")
return denominator
def _validate_target_model(self, target: "Frame", *, rule: str) -> None:
if target.atoms != self.frame.atoms:
raise ValueError(
f"The {rule} target model must use the same ordered frame atoms."
)
if not set(target.model_signature) <= set(self.frame.model_signature):
raise ValueError(
f"The {rule} target model may add constraints but cannot make "
"regions possible that were absent from the source frame."
)
def _check_sources(self, sources: tuple["MassFunction", ...]) -> None:
if len(sources) < 2:
raise ValueError("At least two sources are required.")
if any(source.frame is not self.frame for source in sources):
raise ValueError("All mass functions must belong to the same frame.")
@staticmethod
def _require_zero_source_conflict(
sources: tuple["MassFunction", ...],
*,
rule: str,
) -> None:
if any(source.conflict > source.tolerance for source in sources):
raise ValueError(f"{rule} requires source assignments with m(empty) = 0.")
def _validate_sum(self) -> None:
total = sum(self._masses.values())
difference = abs(total - 1.0)
if difference == 0:
return
if difference <= max(self.tolerance, self.normalization_tolerance):
self._masses = {prop: value / total for prop, value in self._masses.items()}
return
raise InvalidMassError(f"Mass values must sum to 1.0, got {total}.")
def _clean(self, masses: Mapping[Proposition, float]) -> dict[Proposition, float]:
return {
prop: value
for prop, value in masses.items()
if abs(value) > self.tolerance
}
def _format_region(self, region: int) -> str:
names = [name for i, name in enumerate(self.frame.atoms) if region & (1 << i)]
return "&".join(names)
@staticmethod
def _validate_frame_metadata(
frame: "Frame",
metadata: Any,
*,
schema: Any = None,
) -> None:
if schema == _LEGACY_MASS_JSON_SCHEMA and frame.model == "hybrid":
raise ValueError(
"Legacy v1 JSON cannot safely identify hybrid-model constraints; "
"re-export the data with schema v2."
)
if metadata is None:
if frame.model == "hybrid":
raise ValueError(
"Hybrid-model mass JSON must include exact frame-region metadata."
)
return
if not isinstance(metadata, MappingABC):
raise ValueError("Mass frame metadata must be an object.")
atoms = metadata.get("atoms")
if atoms is not None and tuple(atoms) != frame.atoms:
raise ValueError("Mass data frame atoms do not match the target frame.")
model = metadata.get("model")
if model is not None and model != frame.model:
raise ValueError("Mass data frame model does not match the target frame.")
region_count = metadata.get("region_count")
if region_count is not None and int(region_count) != frame.region_count:
raise ValueError("Mass data frame region count does not match the target frame.")
regions = metadata.get("regions")
if regions is not None and tuple(int(region) for region in regions) != frame.model_signature:
raise ValueError("Mass data model constraints do not match the target frame.")
if frame.model == "hybrid" and regions is None:
raise ValueError(
"Hybrid-model mass JSON must include exact frame-region metadata."
)
@staticmethod
def _format_number(value: float, float_format: str | None) -> str:
if float_format is None:
return str(value)
if "%" in float_format:
return float_format % value
return format(value, float_format)
@staticmethod
def _resolve_export_columns(columns: Sequence[str]) -> tuple[tuple[str, str], ...]:
if not columns:
raise ValueError("At least one export column is required.")
resolved: list[tuple[str, str]] = []
for column in columns:
key = column.lower().strip()
try:
resolved.append(_EXPORT_COLUMNS[key])
except KeyError as exc:
choices = ", ".join(sorted(_EXPORT_COLUMNS))
raise ValueError(f"Unknown export column {column!r}; choose from {choices}.") from exc
return tuple(resolved)
def _export_rows(self, rows: str) -> tuple[Proposition, ...]:
if rows == "focal":
return self.focal()
if rows == "all":
return self.frame.elements()
raise ValueError("rows must be 'focal' or 'all'.")
def _export_value(self, prop: Proposition, method: str) -> float:
if method == "mass":
return self.mass(prop)
if method == "belief":
return self.belief(prop)
if method == "plausibility":
return self.plausibility(prop)
if method == "commonality":
return self.commonality(prop)
raise AssertionError(f"Unhandled export method: {method}")
@classmethod
def _latex_proposition(cls, prop: Proposition) -> str:
if not prop:
return r"$\emptyset$"
terms = []
for term in str(prop).split("|"):
factors = [cls._latex_escape_math(part) for part in term.split("&")]
terms.append(r" \cap ".join(factors))
return "$" + r" \cup ".join(terms) + "$"
@staticmethod
def _latex_escape_text(value: str) -> str:
replacements = {
"\\": r"\textbackslash{}",
"&": r"\&",
"%": r"\%",
"$": r"\$",
"#": r"\#",
"_": r"\_",
"{": r"\{",
"}": r"\}",
"~": r"\textasciitilde{}",
"^": r"\textasciicircum{}",
}
return "".join(replacements.get(char, char) for char in value)
@classmethod
def _latex_escape_math(cls, value: str) -> str:
return cls._latex_escape_text(value).replace(r"\textbackslash{}", r"\backslash{}")