evidencelib 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,8 @@
1
+ """Belief-function calculations for DST and DSmT."""
2
+
3
+ from evidencelib.frame import Frame
4
+ from evidencelib.mass import MassFunction
5
+ from evidencelib.proposition import Proposition
6
+
7
+ __all__ = ["Frame", "MassFunction", "Proposition"]
8
+
@@ -0,0 +1,14 @@
1
+ """Package-specific exceptions."""
2
+
3
+
4
+ class EvidenceLibError(Exception):
5
+ """Base exception for evidencelib."""
6
+
7
+
8
+ class InvalidMassError(EvidenceLibError, ValueError):
9
+ """Raised when a mass assignment is invalid."""
10
+
11
+
12
+ class TotalConflictError(EvidenceLibError, ZeroDivisionError):
13
+ """Raised when normalized combination is undefined due to total conflict."""
14
+
evidencelib/frame.py ADDED
@@ -0,0 +1,198 @@
1
+ """Frames of discernment for DST and DSmT."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from itertools import combinations
6
+ from typing import Iterable, Iterator, Mapping, Sequence
7
+
8
+ from evidencelib.parser import PropositionParser
9
+ from evidencelib.proposition import Proposition
10
+
11
+
12
+ class Frame:
13
+ """A finite frame of discernment.
14
+
15
+ The internal representation uses Venn regions. This lets the same
16
+ proposition algebra represent Shafer's exclusive DST model, the free DSmT
17
+ model, and constrained hybrid DSm models.
18
+ """
19
+
20
+ def __init__(
21
+ self,
22
+ atoms: Sequence[str],
23
+ *,
24
+ empty: Iterable[str | Proposition] = (),
25
+ exclusive: bool | Iterable[Sequence[str]] = False,
26
+ model: str = "hybrid",
27
+ ) -> None:
28
+ if not atoms:
29
+ raise ValueError("A frame needs at least one atom.")
30
+ if len(set(atoms)) != len(atoms):
31
+ raise ValueError("Frame atom names must be unique.")
32
+
33
+ self.atoms = tuple(atoms)
34
+ self.model = model
35
+ self._index = {name: i for i, name in enumerate(self.atoms)}
36
+ self._full_universe = frozenset(range(1, 1 << len(self.atoms)))
37
+ self._impossible_regions: set[int] = set()
38
+ self._universe = self._full_universe
39
+
40
+ constraints: list[str | Proposition] = []
41
+ if exclusive is True:
42
+ constraints.extend("&".join(pair) for pair in combinations(self.atoms, 2))
43
+ elif exclusive:
44
+ constraints.extend("&".join(group) for group in exclusive)
45
+ constraints.extend(empty)
46
+
47
+ for constraint in constraints:
48
+ prop = self._parse_free(constraint)
49
+ self._impossible_regions.update(prop.regions)
50
+ self._universe = frozenset(self._full_universe - self._impossible_regions)
51
+
52
+ self.empty = Proposition(self, frozenset())
53
+ self.total = Proposition(self, self._universe)
54
+
55
+ @classmethod
56
+ def dst(cls, atoms: Sequence[str]) -> "Frame":
57
+ """Create Shafer's DST model with mutually exclusive hypotheses."""
58
+
59
+ return cls(atoms, exclusive=True, model="dst")
60
+
61
+ @classmethod
62
+ def dsmt(cls, atoms: Sequence[str]) -> "Frame":
63
+ """Create the free DSm model where hypotheses may overlap."""
64
+
65
+ return cls(atoms, model="dsmt")
66
+
67
+ @classmethod
68
+ def hybrid(
69
+ cls,
70
+ atoms: Sequence[str],
71
+ *,
72
+ empty: Iterable[str | Proposition] = (),
73
+ exclusive: bool | Iterable[Sequence[str]] = False,
74
+ ) -> "Frame":
75
+ """Create a constrained DSm model."""
76
+
77
+ return cls(atoms, empty=empty, exclusive=exclusive, model="hybrid")
78
+
79
+ def symbols(self, names: str | None = None) -> tuple[Proposition, ...]:
80
+ """Return atom propositions.
81
+
82
+ ``names`` may be omitted to return all frame atoms, or supplied as a
83
+ whitespace/comma-separated subset.
84
+ """
85
+
86
+ if names is None:
87
+ selected = self.atoms
88
+ else:
89
+ selected = tuple(part for part in names.replace(",", " ").split() if part)
90
+ return tuple(self.atom(name) for name in selected)
91
+
92
+ def atom(self, name: str) -> Proposition:
93
+ if name not in self._index:
94
+ raise KeyError(f"Unknown frame atom: {name!r}")
95
+ bit = 1 << self._index[name]
96
+ return Proposition(self, frozenset(r for r in self._universe if r & bit))
97
+
98
+ def proposition(self, value: str | Proposition | Iterable[str]) -> Proposition:
99
+ """Coerce a string, proposition, or iterable of atoms into a proposition."""
100
+
101
+ if isinstance(value, Proposition):
102
+ if value.frame is not self:
103
+ raise ValueError("Proposition belongs to a different frame.")
104
+ return value
105
+ if isinstance(value, str):
106
+ return self._parse(value)
107
+ prop = self.empty
108
+ for atom in value:
109
+ prop = prop | self.atom(atom)
110
+ return prop
111
+
112
+ def mass(self, values: Mapping[str | Proposition | Iterable[str], float], **kwargs):
113
+ from evidencelib.mass import MassFunction
114
+
115
+ return MassFunction(self, values, **kwargs)
116
+
117
+ @property
118
+ def region_count(self) -> int:
119
+ """Number of non-empty disjoint Venn regions in the current model."""
120
+
121
+ return len(self._universe)
122
+
123
+ def elements(self, *, max_count: int | None = 100_000) -> tuple[Proposition, ...]:
124
+ """Generate the model's closure under union and intersection.
125
+
126
+ The result is the power set for DST and the hyper-power set for the free
127
+ DSm model. DSmT cardinality grows very quickly; pass ``max_count=None``
128
+ only when you really want the full closure.
129
+ """
130
+
131
+ elements = {self.empty, *self.symbols()}
132
+ changed = True
133
+ while changed:
134
+ changed = False
135
+ current = tuple(elements)
136
+ for left in current:
137
+ for right in current:
138
+ for combined in (left | right, left & right):
139
+ if combined not in elements:
140
+ elements.add(combined)
141
+ changed = True
142
+ if max_count is not None and len(elements) > max_count:
143
+ raise RuntimeError(
144
+ "Element generation exceeded max_count; "
145
+ "DSmT hyper-power sets grow very quickly."
146
+ )
147
+ return tuple(sorted(elements, key=lambda p: (len(p.regions), str(p))))
148
+
149
+ def format(self, prop: Proposition) -> str:
150
+ if not prop.regions:
151
+ return "empty"
152
+
153
+ terms = self._minimal_terms(prop.regions)
154
+ rendered_terms = []
155
+ for term in terms:
156
+ names = [name for i, name in enumerate(self.atoms) if term & (1 << i)]
157
+ rendered_terms.append("&".join(names))
158
+ return "|".join(rendered_terms)
159
+
160
+ def _normalize_regions(self, regions: Iterable[int]) -> frozenset[int]:
161
+ return frozenset(r for r in regions if r in self._universe)
162
+
163
+ def _parse_free(self, value: str | Proposition) -> Proposition:
164
+ old_universe = self._universe
165
+ self._universe = self._full_universe
166
+ try:
167
+ return self._parse(value)
168
+ finally:
169
+ self._universe = old_universe
170
+
171
+ def _parse(self, value: str | Proposition) -> Proposition:
172
+ if isinstance(value, Proposition):
173
+ if value.frame is not self:
174
+ raise ValueError("Proposition belongs to a different frame.")
175
+ return value
176
+
177
+ try:
178
+ result = PropositionParser(self).parse(value)
179
+ except Exception as exc: # pragma: no cover - exception message is the value.
180
+ raise ValueError(f"Could not parse proposition {value!r}") from exc
181
+ return result
182
+
183
+ def _minimal_terms(self, regions: Iterable[int]) -> tuple[int, ...]:
184
+ region_set = set(regions)
185
+ terms: list[int] = []
186
+ for region in sorted(region_set, key=lambda r: (r.bit_count(), r)):
187
+ if any((term & region) == term for term in terms):
188
+ continue
189
+ upward = {r for r in self._universe if (r & region) == region}
190
+ if upward & region_set:
191
+ terms.append(region)
192
+ return tuple(terms)
193
+
194
+ def __iter__(self) -> Iterator[Proposition]:
195
+ return iter(self.symbols())
196
+
197
+ def __repr__(self) -> str:
198
+ return f"Frame({self.atoms!r}, model={self.model!r})"
evidencelib/mass.py ADDED
@@ -0,0 +1,305 @@
1
+ """Mass functions and fusion rules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from itertools import product
6
+ from math import prod
7
+ from typing import Iterable, Iterator, Mapping
8
+
9
+ from evidencelib.exceptions import InvalidMassError, TotalConflictError
10
+ from evidencelib.proposition import Proposition
11
+
12
+
13
+ class MassFunction:
14
+ """A basic belief assignment over a frame."""
15
+
16
+ normalization_tolerance = 1e-6
17
+
18
+ def __init__(
19
+ self,
20
+ frame,
21
+ values: Mapping[str | Proposition | Iterable[str], float],
22
+ *,
23
+ validate: bool = True,
24
+ tolerance: float = 1e-9,
25
+ ) -> None:
26
+ self.frame = frame
27
+ self.tolerance = tolerance
28
+ masses: dict[Proposition, float] = {}
29
+ for key, value in values.items():
30
+ prop = frame.proposition(key)
31
+ mass = float(value)
32
+ if mass < -tolerance:
33
+ raise InvalidMassError("Mass values must be non-negative.")
34
+ if abs(mass) <= tolerance:
35
+ continue
36
+ masses[prop] = masses.get(prop, 0.0) + mass
37
+ self._masses = self._clean(masses)
38
+ if validate:
39
+ self._validate_sum()
40
+
41
+ def __getitem__(self, key: str | Proposition | Iterable[str]) -> float:
42
+ return self.mass(key)
43
+
44
+ def __iter__(self) -> Iterator[tuple[Proposition, float]]:
45
+ return iter(self.items())
46
+
47
+ def __repr__(self) -> str:
48
+ body = ", ".join(f"{prop}: {value:.6g}" for prop, value in self.items())
49
+ return f"MassFunction({{{body}}})"
50
+
51
+ def items(self) -> tuple[tuple[Proposition, float], ...]:
52
+ return tuple(sorted(self._masses.items(), key=lambda item: str(item[0])))
53
+
54
+ def focal(self) -> tuple[Proposition, ...]:
55
+ return tuple(prop for prop, _ in self.items())
56
+
57
+ def to_dict(self, *, string_keys: bool = True) -> dict[str | Proposition, float]:
58
+ """Return the mass assignment as a plain dictionary."""
59
+
60
+ if string_keys:
61
+ return {str(prop): value for prop, value in self.items()}
62
+ return dict(self.items())
63
+
64
+ @property
65
+ def total_mass(self) -> float:
66
+ """Sum of all stored masses."""
67
+
68
+ return sum(self._masses.values())
69
+
70
+ def mass(self, key: str | Proposition | Iterable[str]) -> float:
71
+ return self._masses.get(self.frame.proposition(key), 0.0)
72
+
73
+ def belief(self, key: str | Proposition | Iterable[str]) -> float:
74
+ target = self.frame.proposition(key)
75
+ return sum(value for prop, value in self._masses.items() if prop <= target)
76
+
77
+ def plausibility(self, key: str | Proposition | Iterable[str]) -> float:
78
+ target = self.frame.proposition(key)
79
+ return sum(value for prop, value in self._masses.items() if prop.intersects(target))
80
+
81
+ def commonality(self, key: str | Proposition | Iterable[str]) -> float:
82
+ target = self.frame.proposition(key)
83
+ return sum(value for prop, value in self._masses.items() if target <= prop)
84
+
85
+ @property
86
+ def conflict(self) -> float:
87
+ return self.mass(self.frame.empty)
88
+
89
+ def conjunctive(self, *others: "MassFunction") -> "MassFunction":
90
+ """Unnormalized conjunctive rule.
91
+
92
+ On a free DSm frame this is the classic DSm rule (DSmC). On Shafer's
93
+ DST model, contradictory intersections are accumulated on ``empty``.
94
+ """
95
+
96
+ return self._combine_intersection((self, *others), normalize=False)
97
+
98
+ def dsmc(self, *others: "MassFunction") -> "MassFunction":
99
+ """Alias for the classic conjunctive DSm rule."""
100
+
101
+ return self.conjunctive(*others)
102
+
103
+ def smets(self, *others: "MassFunction") -> "MassFunction":
104
+ """Smets/TBM unnormalized rule, keeping conflict on the empty set."""
105
+
106
+ return self.conjunctive(*others)
107
+
108
+ def dempster(self, *others: "MassFunction") -> "MassFunction":
109
+ """Dempster's normalized rule of combination."""
110
+
111
+ return self._combine_intersection((self, *others), normalize=True)
112
+
113
+ def yager(self, *others: "MassFunction") -> "MassFunction":
114
+ """Yager's rule: transfer total conflict to total ignorance."""
115
+
116
+ conjunctive = self.conjunctive(*others)
117
+ conflict = conjunctive.conflict
118
+ masses = {prop: value for prop, value in conjunctive.items() if prop}
119
+ if conflict:
120
+ masses[self.frame.total] = masses.get(self.frame.total, 0.0) + conflict
121
+ return MassFunction(self.frame, masses)
122
+
123
+ def dubois_prade(self, *others: "MassFunction") -> "MassFunction":
124
+ """Dubois-Prade style transfer of conflicts to disjunctions."""
125
+
126
+ return self.dsmh(*others)
127
+
128
+ def dsmh(self, *others: "MassFunction") -> "MassFunction":
129
+ """Hybrid DSm rule for constrained models.
130
+
131
+ Products whose intersection is non-empty go to that intersection.
132
+ Products whose intersection is empty are transferred to the union of
133
+ the involved propositions. If that union is also empty under the model,
134
+ the mass goes to total ignorance.
135
+ """
136
+
137
+ self._check_sources((self, *others))
138
+ masses: dict[Proposition, float] = {}
139
+ for props, values in self._focal_product((self, *others)):
140
+ amount = prod(values)
141
+ intersection = self._intersection_all(props)
142
+ if intersection:
143
+ target = intersection
144
+ else:
145
+ target = self._union_all(props)
146
+ if not target:
147
+ target = self.frame.total
148
+ masses[target] = masses.get(target, 0.0) + amount
149
+ masses.pop(self.frame.empty, None)
150
+ return MassFunction(self.frame, masses)
151
+
152
+ def pcr5(self, other: "MassFunction") -> "MassFunction":
153
+ """PCR5 for two sources."""
154
+
155
+ return self.pcr6(other)
156
+
157
+ def pcr6(self, *others: "MassFunction") -> "MassFunction":
158
+ """PCR6 proportional conflict redistribution for two or more sources."""
159
+
160
+ sources = (self, *others)
161
+ self._check_sources(sources)
162
+ masses: dict[Proposition, float] = {}
163
+ for props, values in self._focal_product(sources):
164
+ amount = prod(values)
165
+ intersection = self._intersection_all(props)
166
+ if intersection:
167
+ masses[intersection] = masses.get(intersection, 0.0) + amount
168
+ continue
169
+
170
+ denominator = sum(values)
171
+ if denominator <= self.tolerance:
172
+ continue
173
+ for prop, source_mass in zip(props, values, strict=True):
174
+ target = prop if prop else self.frame.total
175
+ if not target:
176
+ continue
177
+ share = amount * source_mass / denominator
178
+ masses[target] = masses.get(target, 0.0) + share
179
+ masses.pop(self.frame.empty, None)
180
+ return MassFunction(self.frame, masses)
181
+
182
+ def normalize(self) -> "MassFunction":
183
+ """Normalize a conjunctive result by removing empty-set conflict."""
184
+
185
+ conflict = self.conflict
186
+ denominator = 1.0 - conflict
187
+ if denominator <= self.tolerance:
188
+ raise TotalConflictError("Dempster normalization is undefined at total conflict.")
189
+ masses = {
190
+ prop: value / denominator
191
+ for prop, value in self._masses.items()
192
+ if prop and abs(value) > self.tolerance
193
+ }
194
+ return MassFunction(self.frame, masses)
195
+
196
+ def pignistic(self) -> dict[str, float]:
197
+ """Return pignistic scores for singleton hypotheses.
198
+
199
+ This is the classical pignistic transformation on DST frames. On free
200
+ or hybrid DSmT frames, singleton hypotheses can overlap, so the returned
201
+ event scores are useful for decisions but do not have to sum to one.
202
+ """
203
+
204
+ result = {name: 0.0 for name in self.frame.atoms}
205
+ singletons = dict(zip(self.frame.atoms, self.frame.symbols(), strict=True))
206
+ for prop, mass in self._masses.items():
207
+ if not prop:
208
+ continue
209
+ cardinality = prop.cardinality
210
+ if cardinality == 0:
211
+ continue
212
+ for name, atom in singletons.items():
213
+ overlap = (atom & prop).cardinality
214
+ if overlap:
215
+ result[name] += mass * overlap / cardinality
216
+ return result
217
+
218
+ def pignistic_regions(self) -> dict[str, float]:
219
+ """Return a probability distribution over model Venn regions."""
220
+
221
+ result = {self._format_region(region): 0.0 for region in self.frame._universe}
222
+ for prop, mass in self._masses.items():
223
+ if not prop:
224
+ continue
225
+ cardinality = prop.cardinality
226
+ if cardinality == 0:
227
+ continue
228
+ share = mass / cardinality
229
+ for region in prop.regions:
230
+ result[self._format_region(region)] += share
231
+ return result
232
+
233
+ def decision(self) -> str:
234
+ """Return the singleton with the largest pignistic probability."""
235
+
236
+ probabilities = self.pignistic()
237
+ return max(probabilities, key=probabilities.__getitem__)
238
+
239
+ @classmethod
240
+ def _from_unchecked(cls, frame, values: Mapping[Proposition, float]) -> "MassFunction":
241
+ return cls(frame, values, validate=False)
242
+
243
+ def _combine_intersection(
244
+ self,
245
+ sources: tuple["MassFunction", ...],
246
+ *,
247
+ normalize: bool,
248
+ ) -> "MassFunction":
249
+ self._check_sources(sources)
250
+ masses: dict[Proposition, float] = {}
251
+ for props, values in self._focal_product(sources):
252
+ target = self._intersection_all(props)
253
+ masses[target] = masses.get(target, 0.0) + prod(values)
254
+ result = MassFunction(self.frame, masses)
255
+ return result.normalize() if normalize else result
256
+
257
+ def _focal_product(
258
+ self,
259
+ sources: tuple["MassFunction", ...],
260
+ ) -> Iterator[tuple[tuple[Proposition, ...], tuple[float, ...]]]:
261
+ item_groups = [source.items() for source in sources]
262
+ for combo in product(*item_groups):
263
+ props = tuple(prop for prop, _ in combo)
264
+ values = tuple(value for _, value in combo)
265
+ yield props, values
266
+
267
+ def _intersection_all(self, props: Iterable[Proposition]) -> Proposition:
268
+ iterator = iter(props)
269
+ result = next(iterator)
270
+ for prop in iterator:
271
+ result = result & prop
272
+ return result
273
+
274
+ def _union_all(self, props: Iterable[Proposition]) -> Proposition:
275
+ result = self.frame.empty
276
+ for prop in props:
277
+ result = result | prop
278
+ return result
279
+
280
+ def _check_sources(self, sources: tuple["MassFunction", ...]) -> None:
281
+ if len(sources) < 2:
282
+ raise ValueError("At least two sources are required.")
283
+ if any(source.frame is not self.frame for source in sources):
284
+ raise ValueError("All mass functions must belong to the same frame.")
285
+
286
+ def _validate_sum(self) -> None:
287
+ total = sum(self._masses.values())
288
+ if abs(total - 1.0) <= self.tolerance:
289
+ return
290
+ if abs(total - 1.0) <= self.normalization_tolerance:
291
+ self._masses = {prop: value / total for prop, value in self._masses.items()}
292
+ return
293
+ if abs(total - 1.0) > self.tolerance:
294
+ raise InvalidMassError(f"Mass values must sum to 1.0, got {total}.")
295
+
296
+ def _clean(self, masses: Mapping[Proposition, float]) -> dict[Proposition, float]:
297
+ return {
298
+ prop: value
299
+ for prop, value in masses.items()
300
+ if abs(value) > self.tolerance
301
+ }
302
+
303
+ def _format_region(self, region: int) -> str:
304
+ names = [name for i, name in enumerate(self.frame.atoms) if region & (1 << i)]
305
+ return "&".join(names)
evidencelib/parser.py ADDED
@@ -0,0 +1,104 @@
1
+ """Small parser for proposition expressions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from evidencelib.proposition import Proposition
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class _Token:
12
+ kind: str
13
+ value: str
14
+
15
+
16
+ class PropositionParser:
17
+ """Parse proposition expressions without executing Python code."""
18
+
19
+ def __init__(self, frame) -> None:
20
+ self.frame = frame
21
+
22
+ def parse(self, expression: str) -> Proposition:
23
+ self._tokens = self._tokenize(expression)
24
+ self._position = 0
25
+ result = self._parse_union()
26
+ if self._peek().kind != "end":
27
+ raise ValueError(f"Unexpected token {self._peek().value!r}.")
28
+ return result
29
+
30
+ def _parse_union(self) -> Proposition:
31
+ result = self._parse_intersection()
32
+ while self._accept("|"):
33
+ result = result | self._parse_intersection()
34
+ return result
35
+
36
+ def _parse_intersection(self) -> Proposition:
37
+ result = self._parse_primary()
38
+ while self._accept("&"):
39
+ result = result & self._parse_primary()
40
+ return result
41
+
42
+ def _parse_primary(self) -> Proposition:
43
+ token = self._peek()
44
+ if self._accept("("):
45
+ result = self._parse_union()
46
+ self._expect(")")
47
+ return result
48
+ if token.kind == "atom":
49
+ self._position += 1
50
+ if token.value in {"empty", "EMPTY"}:
51
+ return Proposition(self.frame, frozenset())
52
+ return self.frame.atom(token.value)
53
+ raise ValueError(f"Expected proposition, got {token.value!r}.")
54
+
55
+ def _accept(self, kind: str) -> bool:
56
+ if self._peek().kind == kind:
57
+ self._position += 1
58
+ return True
59
+ return False
60
+
61
+ def _expect(self, kind: str) -> None:
62
+ if not self._accept(kind):
63
+ raise ValueError(f"Expected {kind!r}, got {self._peek().value!r}.")
64
+
65
+ def _peek(self) -> _Token:
66
+ return self._tokens[self._position]
67
+
68
+ def _tokenize(self, expression: str) -> list[_Token]:
69
+ tokens: list[_Token] = []
70
+ i = 0
71
+ while i < len(expression):
72
+ char = expression[i]
73
+ if char.isspace():
74
+ i += 1
75
+ continue
76
+ if char in "()":
77
+ tokens.append(_Token(char, char))
78
+ i += 1
79
+ continue
80
+ if char in {"&", "∩", "∧"}:
81
+ tokens.append(_Token("&", char))
82
+ i += 1
83
+ continue
84
+ if char in {"|", "∪", "∨"}:
85
+ tokens.append(_Token("|", char))
86
+ i += 1
87
+ continue
88
+ if char == "∅":
89
+ tokens.append(_Token("atom", "empty"))
90
+ i += 1
91
+ continue
92
+
93
+ start = i
94
+ while i < len(expression):
95
+ current = expression[i]
96
+ if current.isspace() or current in "()&|∩∧∪∨":
97
+ break
98
+ i += 1
99
+ value = expression[start:i]
100
+ if value not in self.frame.atoms and value not in {"empty", "EMPTY"}:
101
+ raise ValueError(f"Unknown proposition atom {value!r}.")
102
+ tokens.append(_Token("atom", value))
103
+ tokens.append(_Token("end", "end of expression"))
104
+ return tokens
@@ -0,0 +1,80 @@
1
+ """Symbolic propositions used by DST and DSmT frames."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import TYPE_CHECKING, FrozenSet
7
+
8
+ if TYPE_CHECKING:
9
+ from evidencelib.frame import Frame
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class Proposition:
14
+ """A canonical proposition represented by possible Venn regions.
15
+
16
+ Users normally create propositions through a :class:`evidencelib.Frame` and
17
+ combine them with ``|`` for union and ``&`` for intersection.
18
+ """
19
+
20
+ frame: "Frame"
21
+ regions: FrozenSet[int]
22
+
23
+ def __post_init__(self) -> None:
24
+ normalized = self.frame._normalize_regions(self.regions)
25
+ object.__setattr__(self, "regions", frozenset(normalized))
26
+
27
+ def __or__(self, other: "Proposition") -> "Proposition":
28
+ self._check_same_frame(other)
29
+ return Proposition(self.frame, self.regions | other.regions)
30
+
31
+ def __and__(self, other: "Proposition") -> "Proposition":
32
+ self._check_same_frame(other)
33
+ return Proposition(self.frame, self.regions & other.regions)
34
+
35
+ def __le__(self, other: "Proposition") -> bool:
36
+ self._check_same_frame(other)
37
+ return self.regions <= other.regions
38
+
39
+ def __lt__(self, other: "Proposition") -> bool:
40
+ return self <= other and self.regions != other.regions
41
+
42
+ def __bool__(self) -> bool:
43
+ return bool(self.regions)
44
+
45
+ def __str__(self) -> str:
46
+ return self.frame.format(self)
47
+
48
+ def __format__(self, format_spec: str) -> str:
49
+ return format(str(self), format_spec)
50
+
51
+ def __repr__(self) -> str:
52
+ return f"Proposition({str(self)!r})"
53
+
54
+ @property
55
+ def is_empty(self) -> bool:
56
+ return not self.regions
57
+
58
+ @property
59
+ def cardinality(self) -> int:
60
+ """DSm cardinality: number of non-empty model regions."""
61
+
62
+ return len(self.regions)
63
+
64
+ def intersects(self, other: "Proposition") -> bool:
65
+ self._check_same_frame(other)
66
+ return bool(self.regions & other.regions)
67
+
68
+ def union_atoms(self) -> "Proposition":
69
+ """Return the disjunction of singleton hypotheses involved in this proposition."""
70
+
71
+ mask = 0
72
+ for region in self.regions:
73
+ mask |= region
74
+ if mask == 0:
75
+ return self.frame.empty
76
+ return Proposition(self.frame, frozenset(r for r in self.frame._universe if r & mask))
77
+
78
+ def _check_same_frame(self, other: "Proposition") -> None:
79
+ if not isinstance(other, Proposition) or other.frame is not self.frame:
80
+ raise ValueError("Propositions must belong to the same frame.")
evidencelib/py.typed ADDED
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,225 @@
1
+ Metadata-Version: 2.4
2
+ Name: evidencelib
3
+ Version: 1.0.0
4
+ Summary: Computational belief functions for DST and DSmT.
5
+ Project-URL: Documentation, https://evidencelib.readthedocs.io/en/latest/
6
+ Project-URL: Source, https://github.com/itaprac/evidencelib
7
+ Project-URL: Issues, https://github.com/itaprac/evidencelib/issues
8
+ Author: evidencelib contributors
9
+ License: MIT License
10
+
11
+ Copyright (c) 2026 evidencelib contributors
12
+
13
+ Permission is hereby granted, free of charge, to any person obtaining a copy
14
+ of this software and associated documentation files (the "Software"), to deal
15
+ in the Software without restriction, including without limitation the rights
16
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
17
+ copies of the Software, and to permit persons to whom the Software is
18
+ furnished to do so, subject to the following conditions:
19
+
20
+ The above copyright notice and this permission notice shall be included in all
21
+ copies or substantial portions of the Software.
22
+
23
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
24
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
25
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
26
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
27
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
28
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
29
+ SOFTWARE.
30
+
31
+ License-File: LICENSE
32
+ Keywords: belief-functions,dempster-shafer,dsmt,dst,evidence-theory
33
+ Classifier: Development Status :: 5 - Production/Stable
34
+ Classifier: Intended Audience :: Science/Research
35
+ Classifier: License :: OSI Approved :: MIT License
36
+ Classifier: Programming Language :: Python :: 3
37
+ Classifier: Programming Language :: Python :: 3.10
38
+ Classifier: Programming Language :: Python :: 3.11
39
+ Classifier: Programming Language :: Python :: 3.12
40
+ Classifier: Topic :: Scientific/Engineering :: Mathematics
41
+ Requires-Python: >=3.10
42
+ Provides-Extra: dev
43
+ Requires-Dist: build>=1.0; extra == 'dev'
44
+ Requires-Dist: myst-parser>=4.0; extra == 'dev'
45
+ Requires-Dist: pytest>=8.0; extra == 'dev'
46
+ Requires-Dist: sphinx-rtd-theme>=3.0; extra == 'dev'
47
+ Requires-Dist: sphinx>=8.0; extra == 'dev'
48
+ Provides-Extra: docs
49
+ Requires-Dist: myst-parser>=4.0; extra == 'docs'
50
+ Requires-Dist: sphinx-rtd-theme>=3.0; extra == 'docs'
51
+ Requires-Dist: sphinx>=8.0; extra == 'docs'
52
+ Description-Content-Type: text/markdown
53
+
54
+ # evidencelib
55
+
56
+ [![CI](https://github.com/itaprac/evidencelib/actions/workflows/ci.yml/badge.svg)](https://github.com/itaprac/evidencelib/actions/workflows/ci.yml)
57
+ [![Documentation Status](https://readthedocs.org/projects/evidencelib/badge/?version=latest)](https://evidencelib.readthedocs.io/en/latest/?badge=latest)
58
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
59
+
60
+ Python library for belief-function calculations in Dempster-Shafer theory
61
+ (DST) and Dezert-Smarandache theory (DSmT).
62
+
63
+ `evidencelib` provides a compact quantitative core for finite frames: symbolic
64
+ propositions, basic belief assignments, evidence fusion rules, belief measures,
65
+ and pignistic decision support.
66
+
67
+ Documentation is available on
68
+ [Read the Docs](https://evidencelib.readthedocs.io/en/latest/).
69
+
70
+ ---
71
+
72
+ ## Installation
73
+
74
+ You can install `evidencelib` using pip:
75
+
76
+ ```bash
77
+ pip install evidencelib
78
+ ```
79
+
80
+ For local development:
81
+
82
+ ```bash
83
+ git clone https://github.com/itaprac/evidencelib.git
84
+ cd evidencelib
85
+ python3.10 -m venv .venv
86
+ source .venv/bin/activate
87
+ pip install -e ".[dev,docs]"
88
+ ```
89
+
90
+ Run the test suite:
91
+
92
+ ```bash
93
+ python -m pytest -q
94
+ ```
95
+
96
+ Build the documentation locally:
97
+
98
+ ```bash
99
+ python -m sphinx -W -b html docs docs/_build/html
100
+ ```
101
+
102
+ ---
103
+
104
+ ## Available Functionality
105
+
106
+ The library contains:
107
+
108
+ ### Models
109
+
110
+ | Constructor | Description |
111
+ | --- | --- |
112
+ | `Frame.dst(...)` | Shafer's classical DST model with exhaustive and mutually exclusive hypotheses. |
113
+ | `Frame.dsmt(...)` | Free DSm model where hypotheses may overlap. |
114
+ | `Frame.hybrid(...)` | Constrained DSm model with explicit emptiness or exclusivity constraints. |
115
+
116
+ ### Proposition Algebra
117
+
118
+ | Operation | Meaning |
119
+ | --- | --- |
120
+ | `A \| B` | Union / disjunction, `A ∪ B`. |
121
+ | `A & B` | Intersection / conjunction, `A ∩ B`. |
122
+ | `frame.proposition("A ∩ (B ∪ C)")` | Parse a proposition from text. |
123
+ | `frame.elements()` | Generate the model's power set or hyper-power set. |
124
+
125
+ ### Belief Measures
126
+
127
+ | Method | Description |
128
+ | --- | --- |
129
+ | `mass(A)` | Direct mass assigned to a proposition. |
130
+ | `belief(A)` | Sum of masses contained in `A`. |
131
+ | `plausibility(A)` | Sum of masses intersecting `A`. |
132
+ | `commonality(A)` | Sum of masses containing `A`. |
133
+ | `conflict` | Mass assigned to the empty proposition. |
134
+
135
+ ### Fusion Rules
136
+
137
+ | Method | Description |
138
+ | --- | --- |
139
+ | `conjunctive(...)` | Unnormalized conjunctive rule. |
140
+ | `dsmc(...)` | Classic DSm rule on a free DSm frame. |
141
+ | `smets(...)` | TBM/Smets rule, keeping conflict on the empty proposition. |
142
+ | `dempster(...)` | Normalized Dempster rule. |
143
+ | `yager(...)` | Yager rule, moving conflict to total ignorance. |
144
+ | `dsmh(...)` | Hybrid DSm rule for constrained models. |
145
+ | `dubois_prade(...)` | Static Dubois-Prade-style conflict transfer. |
146
+ | `pcr5(...)` | PCR5 for two sources. |
147
+ | `pcr6(...)` | PCR6 for two or more sources. |
148
+
149
+ ### Decision Support
150
+
151
+ | Method | Description |
152
+ | --- | --- |
153
+ | `pignistic()` | Singleton pignistic scores. |
154
+ | `pignistic_regions()` | Probability distribution over disjoint model regions. |
155
+ | `decision()` | Singleton with the largest pignistic score. |
156
+
157
+ ---
158
+
159
+ ## Usage Example
160
+
161
+ ```python
162
+ from evidencelib import Frame
163
+
164
+ frame = Frame.dst(["A", "B"])
165
+ A, B = frame.symbols()
166
+
167
+ m1 = frame.mass({
168
+ A: 0.6,
169
+ A | B: 0.4,
170
+ })
171
+
172
+ m2 = frame.mass({
173
+ B: 0.3,
174
+ A | B: 0.7,
175
+ })
176
+
177
+ print(m1.dempster(m2).to_dict())
178
+ print(m1.pcr5(m2).to_dict())
179
+ ```
180
+
181
+ Output:
182
+
183
+ ```python
184
+ {"A": 0.5121951219512195, "A|B": 0.34146341463414637, "B": 0.14634146341463414}
185
+ {"A": 0.54, "A|B": 0.28, "B": 0.18}
186
+ ```
187
+
188
+ Free DSmT example:
189
+
190
+ ```python
191
+ frame = Frame.dsmt(["A", "B"])
192
+ A, B = frame.symbols()
193
+
194
+ m = frame.mass({
195
+ A: 0.2,
196
+ B: 0.3,
197
+ A & B: 0.4,
198
+ A | B: 0.1,
199
+ })
200
+
201
+ print(m.pignistic())
202
+ print(m.pignistic_regions())
203
+ ```
204
+
205
+ In DSmT, singleton hypotheses can overlap, so `pignistic()` returns decision
206
+ scores that do not necessarily sum to one. Use `pignistic_regions()` for a
207
+ probability distribution over disjoint Venn regions.
208
+
209
+ More examples are available in the [`examples/`](examples/) directory and in
210
+ the documentation.
211
+
212
+ ---
213
+
214
+ ## References
215
+
216
+ - Shafer, G. (1976). *A Mathematical Theory of Evidence*. Princeton University Press.
217
+ - Smarandache, F., & Dezert, J. (eds.). *Advances and Applications of DSmT for Information Fusion*.
218
+ - Dezert, J., & Smarandache, F. *An Introduction to DSmT*.
219
+ - Zadeh, L. A. (1986). A simple view of the Dempster-Shafer theory of evidence and its implication for the rule of combination. *AI Magazine*, 7(2), 85-90.
220
+
221
+ ---
222
+
223
+ ## License
224
+
225
+ `evidencelib` is released under the MIT License.
@@ -0,0 +1,11 @@
1
+ evidencelib/__init__.py,sha256=s9pRq0RXF1j6Kl7IRjxNBhroVla0lPif-cxcBd5rBFQ,233
2
+ evidencelib/exceptions.py,sha256=vh683OUtlryjLX6l5OKZL_jOwqJgus-XKpJSBXFEfjg,369
3
+ evidencelib/frame.py,sha256=62AFTVpVAfPCMzeBoVzUok1IAYhRNT54CGDdgOfVsn0,7436
4
+ evidencelib/mass.py,sha256=Vjl8kmo8tJsseHvY5tZJwKORms6va2oBq7myAc2ti5c,11699
5
+ evidencelib/parser.py,sha256=aMm_Ai2cB2E4DUvUX67059EIyft1zhBmCgtqF8TCh8M,3333
6
+ evidencelib/proposition.py,sha256=zLaUOWskXI8hMbxfQ6X2KQ-l9peyK0s6-fsWLd-ma2w,2584
7
+ evidencelib/py.typed,sha256=AbpHGcgLb-kRsJGnwFEktk7uzpZOCcBY74-YBdrKVGs,1
8
+ evidencelib-1.0.0.dist-info/METADATA,sha256=x6D0Qbm7F7H20xFOFPzSyVyJqzKb_dfIrNGzGA2s0mc,7137
9
+ evidencelib-1.0.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
10
+ evidencelib-1.0.0.dist-info/licenses/LICENSE,sha256=4LvwJSWU4qyrxxGYsh0ABUM28zI-5Q7MOO1NFhqlEQ8,1082
11
+ evidencelib-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 evidencelib contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+