probability-flow 0.2.0__tar.gz → 0.3.0__tar.gz

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.
Files changed (42) hide show
  1. {probability_flow-0.2.0 → probability_flow-0.3.0}/PKG-INFO +8 -1
  2. {probability_flow-0.2.0 → probability_flow-0.3.0}/README.md +7 -0
  3. {probability_flow-0.2.0 → probability_flow-0.3.0}/probability_flow/__init__.py +4 -0
  4. {probability_flow-0.2.0 → probability_flow-0.3.0}/probability_flow/aspic/argument.py +53 -13
  5. {probability_flow-0.2.0 → probability_flow-0.3.0}/probability_flow/aspic/compile.py +12 -0
  6. {probability_flow-0.2.0 → probability_flow-0.3.0}/probability_flow/aspic/handle.py +35 -10
  7. {probability_flow-0.2.0 → probability_flow-0.3.0}/probability_flow/aspic/visualization.py +25 -4
  8. {probability_flow-0.2.0 → probability_flow-0.3.0}/probability_flow/core/__init__.py +4 -0
  9. {probability_flow-0.2.0 → probability_flow-0.3.0}/probability_flow/core/bp/engine.py +1 -0
  10. {probability_flow-0.2.0 → probability_flow-0.3.0}/probability_flow/core/cpd/__init__.py +4 -1
  11. {probability_flow-0.2.0 → probability_flow-0.3.0}/probability_flow/core/cpd/base.py +8 -0
  12. probability_flow-0.3.0/probability_flow/core/cpd/correlated_evidence.py +241 -0
  13. {probability_flow-0.2.0 → probability_flow-0.3.0}/probability_flow/core/cpd/independent_evidence.py +13 -9
  14. {probability_flow-0.2.0 → probability_flow-0.3.0}/probability_flow/core/cpd/noisy_and.py +2 -2
  15. {probability_flow-0.2.0 → probability_flow-0.3.0}/probability_flow/core/cpd/noisy_or.py +2 -2
  16. {probability_flow-0.2.0 → probability_flow-0.3.0}/probability_flow/core/exact.py +1 -0
  17. {probability_flow-0.2.0 → probability_flow-0.3.0}/probability_flow/core/network.py +41 -3
  18. {probability_flow-0.2.0 → probability_flow-0.3.0}/probability_flow/core/node.py +21 -0
  19. probability_flow-0.3.0/probability_flow/visualization/animate.py +210 -0
  20. {probability_flow-0.2.0 → probability_flow-0.3.0}/probability_flow/visualization/image.py +113 -25
  21. probability_flow-0.3.0/probability_flow/visualization/layout.py +301 -0
  22. {probability_flow-0.2.0 → probability_flow-0.3.0}/pyproject.toml +6 -1
  23. probability_flow-0.2.0/probability_flow/visualization/layout.py +0 -166
  24. {probability_flow-0.2.0 → probability_flow-0.3.0}/.gitignore +0 -0
  25. {probability_flow-0.2.0 → probability_flow-0.3.0}/LICENSE +0 -0
  26. {probability_flow-0.2.0 → probability_flow-0.3.0}/probability_flow/aspic/__init__.py +0 -0
  27. {probability_flow-0.2.0 → probability_flow-0.3.0}/probability_flow/aspic/calibrate.py +0 -0
  28. {probability_flow-0.2.0 → probability_flow-0.3.0}/probability_flow/aspic/generate.py +0 -0
  29. {probability_flow-0.2.0 → probability_flow-0.3.0}/probability_flow/core/_logmath.py +0 -0
  30. {probability_flow-0.2.0 → probability_flow-0.3.0}/probability_flow/core/bp/__init__.py +0 -0
  31. {probability_flow-0.2.0 → probability_flow-0.3.0}/probability_flow/core/bp/message.py +0 -0
  32. {probability_flow-0.2.0 → probability_flow-0.3.0}/probability_flow/core/cpd/tabular.py +0 -0
  33. {probability_flow-0.2.0 → probability_flow-0.3.0}/probability_flow/metrics/__init__.py +0 -0
  34. {probability_flow-0.2.0 → probability_flow-0.3.0}/probability_flow/metrics/_util.py +0 -0
  35. {probability_flow-0.2.0 → probability_flow-0.3.0}/probability_flow/metrics/difficulty.py +0 -0
  36. {probability_flow-0.2.0 → probability_flow-0.3.0}/probability_flow/metrics/dseparation.py +0 -0
  37. {probability_flow-0.2.0 → probability_flow-0.3.0}/probability_flow/metrics/loopiness.py +0 -0
  38. {probability_flow-0.2.0 → probability_flow-0.3.0}/probability_flow/metrics/manipulability.py +0 -0
  39. {probability_flow-0.2.0 → probability_flow-0.3.0}/probability_flow/metrics/structure.py +0 -0
  40. {probability_flow-0.2.0 → probability_flow-0.3.0}/probability_flow/py.typed +0 -0
  41. {probability_flow-0.2.0 → probability_flow-0.3.0}/probability_flow/visualization/__init__.py +0 -0
  42. {probability_flow-0.2.0 → probability_flow-0.3.0}/probability_flow/visualization/style.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: probability-flow
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: A from-scratch, modular discrete Bayesian-network library.
5
5
  Project-URL: Homepage, https://github.com/scalable-oversight-benchmarks/probability-flow
6
6
  Project-URL: Repository, https://github.com/scalable-oversight-benchmarks/probability-flow
@@ -118,6 +118,13 @@ combiner) from the *object* that implements it (the CPD).
118
118
  sources of evidence: `logit P(node=1) = logit(prior) + sum of log(lr)` over the
119
119
  active inputs. Adding weights of evidence is Bayes' rule for independent
120
120
  likelihood ratios. Set per edge with `add_input(x, lr=...)`.
121
+ - **CorrelatedEvidenceCPD**. Independent evidence plus pairwise couplings, for
122
+ *redundant* inputs the additive rule would otherwise double-count (two reports of
123
+ one fact, two clues from a shared cause): it adds a pairwise term
124
+ `+ sum J_ij s_i s_j` (a negative `J` makes two inputs sub-additive when both fire,
125
+ a positive one synergistic). The coupling lives inside the single CPD factor, so
126
+ it adds no edge to the graph and no loop to the solver, and stays a valid
127
+ distribution for any real `J`.
121
128
  - **NoisyOrCPD**. "Any one cause can fire the effect":
122
129
  `P(node=0) = (1 - leak) * product of (1 - activation)` over present causes.
123
130
  Declared with `node.noisy_or(leak=...)` and `add_input(cause, activation=...)`.
@@ -84,6 +84,13 @@ combiner) from the *object* that implements it (the CPD).
84
84
  sources of evidence: `logit P(node=1) = logit(prior) + sum of log(lr)` over the
85
85
  active inputs. Adding weights of evidence is Bayes' rule for independent
86
86
  likelihood ratios. Set per edge with `add_input(x, lr=...)`.
87
+ - **CorrelatedEvidenceCPD**. Independent evidence plus pairwise couplings, for
88
+ *redundant* inputs the additive rule would otherwise double-count (two reports of
89
+ one fact, two clues from a shared cause): it adds a pairwise term
90
+ `+ sum J_ij s_i s_j` (a negative `J` makes two inputs sub-additive when both fire,
91
+ a positive one synergistic). The coupling lives inside the single CPD factor, so
92
+ it adds no edge to the graph and no loop to the solver, and stays a valid
93
+ distribution for any real `J`.
87
94
  - **NoisyOrCPD**. "Any one cause can fire the effect":
88
95
  `P(node=0) = (1 - leak) * product of (1 - activation)` over present causes.
89
96
  Declared with `node.noisy_or(leak=...)` and `add_input(cause, activation=...)`.
@@ -12,12 +12,14 @@ from .core import (
12
12
  CPD,
13
13
  BayesianNetwork,
14
14
  CompiledCPD,
15
+ CorrelatedEvidenceCPD,
15
16
  ExactSolver,
16
17
  IndependentEvidenceCPD,
17
18
  LoopySolver,
18
19
  Node,
19
20
  NoisyAndCPD,
20
21
  NoisyOrCPD,
22
+ PendingWeightError,
21
23
  TabularCPD,
22
24
  )
23
25
 
@@ -36,6 +38,8 @@ __all__ = [
36
38
  "CPD",
37
39
  "TabularCPD",
38
40
  "IndependentEvidenceCPD",
41
+ "CorrelatedEvidenceCPD",
39
42
  "NoisyOrCPD",
40
43
  "NoisyAndCPD",
44
+ "PendingWeightError",
41
45
  ]
@@ -41,9 +41,12 @@ class _Claim(Node):
41
41
 
42
42
  def __init__(self, name: str, prior: float = 0.5):
43
43
  super().__init__(name, prior=prior)
44
- self._edges: list[tuple["Node", float, str]] = [] # (src, lr, kind)
44
+ self._edges: list[tuple["Node", "float | None", str]] = [] # (src, lr, kind)
45
45
  self._strict: list["Node"] = [] # strict sources
46
46
  self._undercuts: list[tuple["Node", "Node"]] = [] # (attacked source, undercutter)
47
+ # (source_a, source_b, J): residual correlation between two defeasible
48
+ # sources of this conclusion, lowered to a CorrelatedEvidenceCPD coupling.
49
+ self._correlations: list[tuple["Node", "Node", "float | None"]] = []
47
50
  self._no_undermine = False
48
51
  # opaque, serialized but uninterpreted by the library (see docs/aspic.md):
49
52
  self.desc: str | None = None # a longer description
@@ -63,21 +66,24 @@ class _Claim(Node):
63
66
 
64
67
  # --- defeasible and strict support (methods on the downstream conclusion) --
65
68
 
66
- def support(self, src: "Node", lr: float) -> "Node":
69
+ def support(self, src: "Node", lr: "float | None" = None) -> "Node":
67
70
  """Add a defeasible argument *for* this conclusion (`lr > 1`). Returns
68
71
  `src`, so an inline source can be built further upstream, as with core
69
- `add_input`."""
70
- if not lr > 1:
72
+ `add_input`. `lr` may be omitted (`None`) to declare the edge before its
73
+ strength is known the topology compiles and renders (dashed), but the
74
+ network cannot be solved until every weight is assigned."""
75
+ if lr is not None and not lr > 1:
71
76
  raise ValueError(
72
77
  f"support lr must be > 1 (got {lr}); use rebut for an argument against"
73
78
  )
74
79
  self._require_new_edge(src)
75
- self._edges.append((src, float(lr), "support"))
80
+ self._edges.append((src, None if lr is None else float(lr), "support"))
76
81
  return src
77
82
 
78
- def rebut(self, src: "Node", lr: float) -> "Node":
79
- """Add a defeasible argument *against* this conclusion (`0 < lr < 1`)."""
80
- if not 0 < lr < 1:
83
+ def rebut(self, src: "Node", lr: "float | None" = None) -> "Node":
84
+ """Add a defeasible argument *against* this conclusion (`0 < lr < 1`). `lr`
85
+ may be omitted (`None`) to declare the edge before its strength is known."""
86
+ if lr is not None and not 0 < lr < 1:
81
87
  raise ValueError(
82
88
  f"rebut lr must be in (0, 1) (got {lr}); use support for an argument for"
83
89
  )
@@ -89,7 +95,7 @@ class _Claim(Node):
89
95
  stacklevel=2,
90
96
  )
91
97
  self._require_new_edge(src)
92
- self._edges.append((src, float(lr), "rebut"))
98
+ self._edges.append((src, None if lr is None else float(lr), "rebut"))
93
99
  return src
94
100
 
95
101
  def strict(self, src: "Node") -> "Node":
@@ -101,18 +107,19 @@ class _Claim(Node):
101
107
 
102
108
  # --- attacks (also methods on the downstream node) ------------------------
103
109
 
104
- def undermine(self, by: "Node", lr: float) -> "Node":
110
+ def undermine(self, by: "Node", lr: "float | None" = None) -> "Node":
105
111
  """Attack this premise with `by` (`0 < lr < 1`). Mechanically a rebut into
106
112
  the attacked node, named distinctly because it is the ASPIC-correct verb
107
- for attacking a premise. An axiom cannot be undermined."""
113
+ for attacking a premise. An axiom cannot be undermined. `lr` may be omitted
114
+ (`None`) to declare the edge before its strength is known."""
108
115
  if self._no_undermine:
109
116
  raise ValueError(
110
117
  f"cannot undermine {self.name!r}: an axiom has no defeasible premise to attack"
111
118
  )
112
- if not 0 < lr < 1:
119
+ if lr is not None and not 0 < lr < 1:
113
120
  raise ValueError(f"undermine lr must be in (0, 1) (got {lr})")
114
121
  self._require_new_edge(by)
115
- self._edges.append((by, float(lr), "undermine"))
122
+ self._edges.append((by, None if lr is None else float(lr), "undermine"))
116
123
  return by
117
124
 
118
125
  def undercut(self, source: "Node", by: "Node") -> "Node":
@@ -123,6 +130,39 @@ class _Claim(Node):
123
130
  self._undercuts.append((source, by))
124
131
  return by
125
132
 
133
+ def correlate(self, source_a: "Node", source_b: "Node",
134
+ coupling: "float | None" = None) -> "_Claim":
135
+ """Declare residual correlation between two **defeasible sources** of this
136
+ conclusion — redundancy (two reports of one fact, a shared cause) or
137
+ synergy. Lowered to a pairwise `CorrelatedEvidenceCPD` coupling `J`:
138
+ negative discounts the pair (redundant / sub-additive), positive boosts it.
139
+ `J` is the engine-native quantity (a caller with a correlation or a pair of
140
+ conditional probabilities does the inverse map). `coupling` may be omitted
141
+ to scaffold the pair before its value is chosen. Returns self for chaining.
142
+
143
+ This overrides the core `Node.correlate`: a `_Claim` declares the pairing
144
+ against its argument sources here, and `compile` lowers it onto whichever
145
+ core node carries the defeasible inputs.
146
+ """
147
+ sources = {id(s) for s, _lr, kind in self._edges if kind in ("support", "rebut")}
148
+ for x in (source_a, source_b):
149
+ if id(x) not in sources:
150
+ raise ValueError(
151
+ f"{getattr(x, 'name', x)!r} is not a support/rebut source of "
152
+ f"{self.name!r}; correlate couples two of its defeasible sources"
153
+ )
154
+ if source_a is source_b:
155
+ raise ValueError("cannot correlate a source with itself")
156
+ if any({id(source_a), id(source_b)} == {id(a), id(b)}
157
+ for a, b, _J in self._correlations):
158
+ raise ValueError(
159
+ f"{source_a.name!r} and {source_b.name!r} are already correlated on "
160
+ f"{self.name!r}"
161
+ )
162
+ self._correlations.append(
163
+ (source_a, source_b, None if coupling is None else float(coupling)))
164
+ return self
165
+
126
166
  def compile(self) -> "BayesianNetwork":
127
167
  """Lower this argument (as the target) to a core `BayesianNetwork`."""
128
168
  from .compile import compile_argument
@@ -166,6 +166,7 @@ def _lower(c: "_Claim") -> None:
166
166
  d = Node(f"{c.name}/defeasible", prior=c.prior)
167
167
  for src, lr in defeasible:
168
168
  d.add_input(src, lr=lr)
169
+ _apply_correlations(c, d, resolve)
169
170
  c.noisy_or(leak=0.0)
170
171
  for src in strict:
171
172
  c.add_input(src, activation=1.0)
@@ -173,3 +174,14 @@ def _lower(c: "_Claim") -> None:
173
174
  else:
174
175
  for src, lr in defeasible:
175
176
  c.add_input(src, lr=lr)
177
+ _apply_correlations(c, c, resolve)
178
+
179
+
180
+ def _apply_correlations(c: "_Claim", host: "Node", resolve) -> None:
181
+ """Lower `c`'s declared source correlations onto `host` — the core node that
182
+ actually carries the defeasible inputs (the conclusion itself, or its hidden
183
+ `/defeasible` node when strict edges divorced it). Uses `Node.correlate`
184
+ explicitly: `c` overrides `correlate` with the argument-level declaration, so
185
+ the core coupling must be reached through the base class."""
186
+ for a, b, coupling in getattr(c, "_correlations", []):
187
+ Node.correlate(host, resolve(a), resolve(b), coupling=coupling)
@@ -30,7 +30,7 @@ if TYPE_CHECKING:
30
30
  from ..core import BayesianNetwork, Node
31
31
  from .argument import _Claim
32
32
 
33
- SCHEMA_VERSION = "0.1"
33
+ SCHEMA_VERSION = "0.2" # 0.2 adds the optional `correlations` array
34
34
 
35
35
  # Above this many nodes the brute-force ExactSolver fallback (2**n joint
36
36
  # enumeration, used only on non-polytree arguments) gets painfully slow. See the
@@ -154,7 +154,10 @@ class Argument:
154
154
  from .argument import Axiom, Premise, _Claim
155
155
 
156
156
  bn = self.bn
157
- solver = self.solver
157
+ # a pending argument (some edge weight unassigned) cannot be solved, so the
158
+ # posterior fields serialize as null; the topology round-trips regardless.
159
+ complete = bn.is_complete
160
+ solver = self.solver if complete else None
158
161
  claims = [c for c in _reachable(self.target) if isinstance(c, _Claim)]
159
162
  non_claim = [c for c in _reachable(self.target) if not isinstance(c, _Claim)]
160
163
  if non_claim:
@@ -178,16 +181,16 @@ class Argument:
178
181
  return name[:-3] if name.endswith("CPD") else name
179
182
 
180
183
  def node_dict(c: "_Claim") -> dict:
181
- lo, hi = self.posterior_range(c)
184
+ lo, hi = self.posterior_range(c) if complete else (None, None)
182
185
  return {
183
186
  "id": ids[c],
184
187
  "type": type_of(c),
185
188
  "label": c.name,
186
189
  "desc": c.desc,
187
190
  "prior": float(c.prior),
188
- "posterior": float(solver.prob(c, 1)),
189
- "min_posterior": float(lo),
190
- "max_posterior": float(hi),
191
+ "posterior": float(solver.prob(c, 1)) if solver else None,
192
+ "min_posterior": None if lo is None else float(lo),
193
+ "max_posterior": None if hi is None else float(hi),
191
194
  "input_groups_sizes": self.input_groups_sizes(c),
192
195
  "cpd_type": cpd_type(c),
193
196
  "revealable_to_judge": c.revealable_to_judge,
@@ -220,8 +223,19 @@ class Argument:
220
223
  for src in c._strict:
221
224
  edges.append(edge(src, "strict", None))
222
225
 
226
+ # residual correlations between two defeasible sources of a conclusion
227
+ # (lowered to CorrelatedEvidenceCPD couplings); not source->target edges.
228
+ correlations = []
229
+ for c in claims:
230
+ for a, b, coupling in getattr(c, "_correlations", []):
231
+ correlations.append({
232
+ "target": ids[c],
233
+ "sources": [ids[a], ids[b]],
234
+ "coupling": None if coupling is None else float(coupling),
235
+ })
236
+
223
237
  return {"version": SCHEMA_VERSION, "target": ids[self.target],
224
- "nodes": nodes, "edges": edges}
238
+ "nodes": nodes, "edges": edges, "correlations": correlations}
225
239
 
226
240
  def to_json(self, *, indent: int = 2) -> str:
227
241
  return json.dumps(self.to_dict(), indent=indent)
@@ -267,15 +281,26 @@ class Argument:
267
281
  edge_meta[ed["id"]] = {"label": ed.get("label"),
268
282
  "revealable_to_judge": ed.get("revealable_to_judge")}
269
283
 
284
+ for cor in data.get("correlations", []):
285
+ a, b = cor["sources"]
286
+ by_id[cor["target"]].correlate(
287
+ by_id[a], by_id[b], coupling=cor.get("coupling"))
288
+
270
289
  arg = cls(by_id[data["target"]])
271
290
  arg._edge_meta = edge_meta
272
291
  return arg
273
292
 
274
293
  @classmethod
275
- def from_json(cls, s: str) -> "Argument":
276
- return cls.from_dict(json.loads(s))
294
+ def from_json(cls, s: str, *, source: str = "<string>") -> "Argument":
295
+ if not s.strip():
296
+ raise ValueError(f"cannot load an Argument from empty JSON ({source})")
297
+ try:
298
+ data = json.loads(s)
299
+ except json.JSONDecodeError as exc:
300
+ raise ValueError(f"{source} is not valid JSON: {exc}") from exc
301
+ return cls.from_dict(data)
277
302
 
278
303
  @classmethod
279
304
  def load(cls, path: str) -> "Argument":
280
305
  with open(path) as f:
281
- return cls.from_json(f.read())
306
+ return cls.from_json(f.read(), source=path)
@@ -10,7 +10,12 @@ from __future__ import annotations
10
10
  from typing import Mapping, Optional
11
11
 
12
12
  from ..visualization import draw_layered, marginals
13
- from ..visualization.image import _lr_colormap, _require_matplotlib, lr_colorbar_spec
13
+ from ..visualization.image import (
14
+ _NEUTRAL_EDGE,
15
+ _lr_colormap,
16
+ _require_matplotlib,
17
+ lr_colorbar_spec,
18
+ )
14
19
  from ..visualization.style import fmt
15
20
  from .argument import Axiom, Premise, _Claim
16
21
  from .compile import _reachable
@@ -21,6 +26,7 @@ _AXIOM = "#ffe6a8"
21
26
  _CONCLUSION = "#ffffff"
22
27
  _STRICT = {"color": "#222222", "style": "-", "width": 2.6} # bold black (forcing)
23
28
  _UNDERCUT = {"color": "#7d3c98", "style": ":", "width": 1.6} # purple, attacks an edge
29
+ _CORRELATION = "#2a9d8f" # teal, undirected dashed: residual correlation between sources
24
30
  _CONFIRMING = "#3b4cc0" # coolwarm_r extremes, for the legend swatches
25
31
  _DISCONFIRMING = "#b40426"
26
32
 
@@ -64,8 +70,12 @@ def render_argument(
64
70
  # support/rebut/undermine coloured by LR; strict bold black. Emit each edge
65
71
  # then its undercutters, so the layout puts an undercutter beside the source
66
72
  # it attacks (v=c only sets the layout; the arrow lands on the source->c edge).
67
- rows = [(src, {"u": src, "v": c, "label": f"lr={fmt(lr)}",
68
- "color": color_for_lr(lr), "style": "-", "width": 2.0})
73
+ # a defeasible edge whose lr is not yet assigned draws dashed and grey with
74
+ # no label ("edge exists, strength TBD"); an assigned one is coloured by lr.
75
+ rows = [(src, {"u": src, "v": c, "label": "" if lr is None else f"lr={fmt(lr)}",
76
+ "color": _NEUTRAL_EDGE if lr is None else color_for_lr(lr),
77
+ "style": "--" if lr is None else "-",
78
+ "width": 1.4 if lr is None else 2.0})
69
79
  for src, lr, _kind in c._edges]
70
80
  rows += [(s, {"u": s, "v": c, "label": "strict", **_STRICT}) for s in c._strict]
71
81
 
@@ -82,6 +92,12 @@ def render_argument(
82
92
  for by in bys:
83
93
  edges.append({"u": by, "v": c, "attacks": (source, c), **_UNDERCUT})
84
94
 
95
+ # residual correlation between two sources: an undirected dashed coupling.
96
+ for a, b, coupling in getattr(c, "_correlations", []):
97
+ edges.append({"u": a, "v": b, "arrow": False, "style": "--",
98
+ "color": _CORRELATION, "width": 1.3,
99
+ "label": "" if coupling is None else f"J={fmt(coupling)}"})
100
+
85
101
  if values is None and beliefs:
86
102
  m = marginals(target.compile(), evidence)
87
103
  values = {c: m[c] for c in claims if c in m}
@@ -106,6 +122,7 @@ def render_argument(
106
122
  color_of=color_of, label_of=label_of, font=_ARG_FONT,
107
123
  ax=ax, title=title, path=path, emphasize=set(evidence or {}),
108
124
  positions=positions, orientation=orientation,
125
+ center=(0.0, 0.0) if layout == "radial" else None,
109
126
  colorbar=lr_colorbar_spec(all_lrs) if colorbar else None,
110
127
  legend=_argument_legend(claims) if legend else None,
111
128
  )
@@ -122,13 +139,17 @@ def _argument_legend(claims) -> list:
122
139
  node_row.append(("conclusion", {"marker": "o", "fill": _CONCLUSION}))
123
140
 
124
141
  edge_row = []
125
- lrs = [lr for c in claims for _src, lr, _kind in c._edges]
142
+ lrs = [lr for c in claims for _src, lr, _kind in c._edges if lr is not None]
126
143
  if any(lr > 1 for lr in lrs):
127
144
  edge_row.append(("confirming", {"color": _CONFIRMING, "linestyle": "-"}))
128
145
  if any(lr < 1 for lr in lrs):
129
146
  edge_row.append(("disconfirming", {"color": _DISCONFIRMING, "linestyle": "-"}))
147
+ if any(lr is None for c in claims for _src, lr, _kind in c._edges):
148
+ edge_row.append(("unassigned", {"color": _NEUTRAL_EDGE, "linestyle": "--"}))
130
149
  if any(c._strict for c in claims):
131
150
  edge_row.append(("strict", {"color": _STRICT["color"], "linestyle": "-"}))
132
151
  if any(c._undercuts for c in claims):
133
152
  edge_row.append(("undercut", {"color": _UNDERCUT["color"], "linestyle": ":"}))
153
+ if any(getattr(c, "_correlations", None) for c in claims):
154
+ edge_row.append(("correlation", {"color": _CORRELATION, "linestyle": "--"}))
134
155
  return [r for r in (node_row, edge_row) if r]
@@ -6,9 +6,11 @@ and query with the `ExactSolver` (later: loopy BP).
6
6
  from .bp import LoopySolver
7
7
  from .cpd import (
8
8
  CPD,
9
+ CorrelatedEvidenceCPD,
9
10
  IndependentEvidenceCPD,
10
11
  NoisyAndCPD,
11
12
  NoisyOrCPD,
13
+ PendingWeightError,
12
14
  TabularCPD,
13
15
  )
14
16
  from .exact import ExactSolver
@@ -24,6 +26,8 @@ __all__ = [
24
26
  "CPD",
25
27
  "TabularCPD",
26
28
  "IndependentEvidenceCPD",
29
+ "CorrelatedEvidenceCPD",
27
30
  "NoisyOrCPD",
28
31
  "NoisyAndCPD",
32
+ "PendingWeightError",
29
33
  ]
@@ -47,6 +47,7 @@ class LoopySolver:
47
47
  ):
48
48
  if not 0.0 <= damping < 1.0:
49
49
  raise ValueError("damping must be in [0, 1)")
50
+ network.require_complete()
50
51
  self.network = network
51
52
  self.max_iters = max_iters
52
53
  self.tol = tol
@@ -1,4 +1,5 @@
1
- from .base import CPD
1
+ from .base import CPD, PendingWeightError
2
+ from .correlated_evidence import CorrelatedEvidenceCPD
2
3
  from .independent_evidence import IndependentEvidenceCPD
3
4
  from .noisy_and import NoisyAndCPD
4
5
  from .noisy_or import NoisyOrCPD
@@ -6,7 +7,9 @@ from .tabular import TabularCPD
6
7
 
7
8
  __all__ = [
8
9
  "CPD",
10
+ "PendingWeightError",
9
11
  "IndependentEvidenceCPD",
12
+ "CorrelatedEvidenceCPD",
10
13
  "NoisyAndCPD",
11
14
  "NoisyOrCPD",
12
15
  "TabularCPD",
@@ -22,6 +22,14 @@ if TYPE_CHECKING:
22
22
  from .tabular import TabularCPD
23
23
 
24
24
 
25
+ class PendingWeightError(ValueError):
26
+ """An edge exists but its weight (an LR, or a noisy activation) is not yet
27
+ assigned, so the CPD cannot be made into a probability table. Subclasses
28
+ `ValueError` so existing handlers still catch it; raised distinctly so
29
+ `BayesianNetwork.from_nodes` can tell a *pending* network (one still being
30
+ authored — topology drawn, weights TBD) from a genuinely malformed one."""
31
+
32
+
25
33
  class CPD(ABC):
26
34
  node: "Node" # the node this CPD is for
27
35