probability-flow 1.4.2__tar.gz → 1.4.3__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.
- {probability_flow-1.4.2 → probability_flow-1.4.3}/.gitignore +7 -4
- {probability_flow-1.4.2 → probability_flow-1.4.3}/CHANGELOG.md +113 -1
- {probability_flow-1.4.2 → probability_flow-1.4.3}/PKG-INFO +9 -5
- {probability_flow-1.4.2 → probability_flow-1.4.3}/README.md +8 -4
- {probability_flow-1.4.2 → probability_flow-1.4.3}/probability_flow/__init__.py +3 -2
- {probability_flow-1.4.2 → probability_flow-1.4.3}/probability_flow/cpd.py +132 -8
- probability_flow-1.4.2/probability_flow/generate.py → probability_flow-1.4.3/probability_flow/generation.py +24 -16
- {probability_flow-1.4.2 → probability_flow-1.4.3}/probability_flow/graph.py +153 -0
- {probability_flow-1.4.2 → probability_flow-1.4.3}/probability_flow/independent.py +95 -4
- {probability_flow-1.4.2 → probability_flow-1.4.3}/probability_flow/logical.py +75 -5
- {probability_flow-1.4.2 → probability_flow-1.4.3}/probability_flow/noisy.py +34 -0
- {probability_flow-1.4.2 → probability_flow-1.4.3}/probability_flow/reveal.py +87 -38
- {probability_flow-1.4.2 → probability_flow-1.4.3}/probability_flow/serialize.py +20 -5
- {probability_flow-1.4.2 → probability_flow-1.4.3}/probability_flow/variable.py +22 -4
- {probability_flow-1.4.2 → probability_flow-1.4.3}/probability_flow/viz.py +109 -30
- {probability_flow-1.4.2 → probability_flow-1.4.3}/pyproject.toml +9 -3
- {probability_flow-1.4.2 → probability_flow-1.4.3}/LICENSE +0 -0
- {probability_flow-1.4.2 → probability_flow-1.4.3}/probability_flow/decompose.py +0 -0
- {probability_flow-1.4.2 → probability_flow-1.4.3}/probability_flow/edges.py +0 -0
- {probability_flow-1.4.2 → probability_flow-1.4.3}/probability_flow/eliminate.py +0 -0
- {probability_flow-1.4.2 → probability_flow-1.4.3}/probability_flow/engines.py +0 -0
- {probability_flow-1.4.2 → probability_flow-1.4.3}/probability_flow/evidence.py +0 -0
- {probability_flow-1.4.2 → probability_flow-1.4.3}/probability_flow/factors.py +0 -0
- {probability_flow-1.4.2 → probability_flow-1.4.3}/probability_flow/inference.py +0 -0
- {probability_flow-1.4.2 → probability_flow-1.4.3}/probability_flow/junction.py +0 -0
- {probability_flow-1.4.2 → probability_flow-1.4.3}/probability_flow/ordering.py +0 -0
- {probability_flow-1.4.2 → probability_flow-1.4.3}/probability_flow/py.typed +0 -0
- {probability_flow-1.4.2 → probability_flow-1.4.3}/probability_flow/query.py +0 -0
|
@@ -16,16 +16,19 @@ _previews/
|
|
|
16
16
|
# kept ON DISK and out of the repo -- removal from disk is Alex's call.
|
|
17
17
|
exam.py
|
|
18
18
|
exam2.ipynb
|
|
19
|
-
figures
|
|
19
|
+
# ROOT-ANCHORED ON PURPOSE. An unanchored `figures/` matches a directory of that
|
|
20
|
+
# name at ANY depth, and it silently swallowed `docs/figures/`, which is
|
|
21
|
+
# COMMITTED output the site embeds. The generator for those lives in
|
|
22
|
+
# `illustrations/`.
|
|
23
|
+
/figures/
|
|
20
24
|
|
|
21
25
|
# Written by notebook/tutorial.ipynb when it runs, as the save-and-load
|
|
22
26
|
# section requires an actual file on disk. An artifact of executing the
|
|
23
27
|
# notebook, not an input to it.
|
|
24
28
|
notebook/sidewalk.json
|
|
25
29
|
|
|
26
|
-
# Staged into docs/ at site-build time by hooks/
|
|
27
|
-
# that is not generated lives in
|
|
28
|
-
docs/tutorial.ipynb
|
|
30
|
+
# Staged into docs/ at site-build time by hooks/stage_examples.py. The one copy
|
|
31
|
+
# that is not generated lives in examples/, beside the script that writes it.
|
|
29
32
|
docs/examples/
|
|
30
33
|
|
|
31
34
|
# mkdocs output
|
|
@@ -1,5 +1,117 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.4.3
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **Structural editing.** `graph.remove_edge(cause, effect)` and
|
|
8
|
+
`graph.remove_variable(variable)`, plus `cpd.set_weight(parent, bits)` for
|
|
9
|
+
changing what an edge carries. Until now the library could only grow a graph,
|
|
10
|
+
so every consumer who needed to drop a node edited the serialised form and
|
|
11
|
+
read it back.
|
|
12
|
+
|
|
13
|
+
Removal **asks the CPD to drop the parent** rather than rewriting its
|
|
14
|
+
serialised form, because only the family knows its own shape. A caller
|
|
15
|
+
patching the document has to know that `weights` is a dict and `gates` is a
|
|
16
|
+
list and that a logical CPD keeps its inputs under `constituents` with no
|
|
17
|
+
`parents` key at all. One measured consequence of getting that wrong: an
|
|
18
|
+
orphaned gate re-creates the edge it names on the way back in, so the file
|
|
19
|
+
said two parents, the graph had three, every posterior was unchanged, and
|
|
20
|
+
nothing reported a problem.
|
|
21
|
+
|
|
22
|
+
Ids are never renumbered or reused. A gate goes when either end of it does,
|
|
23
|
+
with a warning. A family that cannot lose a parent without inventing
|
|
24
|
+
something has its CPD cleared, which makes `check()` and every query fail
|
|
25
|
+
until a new one is written.
|
|
26
|
+
|
|
27
|
+
- `IndependentEvidence.weights` is now read-only. Assigning into it changed
|
|
28
|
+
nothing and reported nothing, because `_log_w` is what every query reads.
|
|
29
|
+
Use `set_weight`.
|
|
30
|
+
|
|
31
|
+
### Added
|
|
32
|
+
|
|
33
|
+
- **The documentation site is published**, at
|
|
34
|
+
<http://probabilityflow.com/docs/>, one directory per release with a version
|
|
35
|
+
selector and a banner on any version that is not the newest. It replaces a
|
|
36
|
+
hand-written page that described a version of the library which no longer
|
|
37
|
+
existed. Until Netlify issues a certificate for the domain, that link is
|
|
38
|
+
http and not https.
|
|
39
|
+
- **Figures.** The pages that describe a graph now draw one. `illustrations/build.py`
|
|
40
|
+
writes them from the library into `docs/figures/`, so a figure cannot depict a
|
|
41
|
+
graph the library would not produce, and three tests keep the embeds and the
|
|
42
|
+
files in step.
|
|
43
|
+
- **An API reference**, at `docs/api/` and on the documentation site. Grouped by
|
|
44
|
+
what you are doing rather than by which module a name lives in, with a section
|
|
45
|
+
for the things you receive rather than construct, because ten exported names
|
|
46
|
+
are returned to you and never called by anybody.
|
|
47
|
+
|
|
48
|
+
### Changed
|
|
49
|
+
|
|
50
|
+
- **The API reference is five pages** rather than one, indented under one
|
|
51
|
+
section: graphs and CPDs, asking questions, saving and loading, drawing,
|
|
52
|
+
generating, and the reveal game.
|
|
53
|
+
- **The reveal game has its own page**, and the formalism page no longer
|
|
54
|
+
mentions it. It is an application built on the library rather than part of
|
|
55
|
+
the formalism, and the benchmark's own vocabulary has been taken off the site
|
|
56
|
+
entirely.
|
|
57
|
+
- **The formalism page says edges come in three kinds.** Gating used to be
|
|
58
|
+
described as "not a third kind" on the grounds that the schema writes it
|
|
59
|
+
`causal`. That is a fact about the schema. A gate is an edge with its own
|
|
60
|
+
rules, and the page now says so and draws one.
|
|
61
|
+
- **The tutorial is on Colab** rather than rendered as a static page, so a
|
|
62
|
+
reader can run it. `mkdocs-jupyter` is no longer a documentation dependency.
|
|
63
|
+
|
|
64
|
+
### Fixed
|
|
65
|
+
|
|
66
|
+
- **A fully revealed CPD came back as a table, losing its structure.** Nothing is
|
|
67
|
+
hidden at a full reveal, so the restriction is the identity and flattening is
|
|
68
|
+
pure loss: it preserves every probability exactly while destroying the operator,
|
|
69
|
+
the constituent count and the per-input activations. On a real decorated graph
|
|
70
|
+
that turned three `logical` CPDs into tables with every posterior still correct.
|
|
71
|
+
A definitional node has no per-edge strength, so the operator and the count are
|
|
72
|
+
the whole content of a sentence about a constitutive edge, and a consumer that
|
|
73
|
+
lost them had to reach back into the source document for structure. `logical`
|
|
74
|
+
and the noisy families now stay in family when nothing is hidden.
|
|
75
|
+
|
|
76
|
+
A **partial** reveal still returns a table, and that is not an oversight: `and`
|
|
77
|
+
over three constituents with two shown and the third at `q = 0.5` is `0.5` on
|
|
78
|
+
the row where both hold, where an `and` over the two would be `1.0`. Tagging
|
|
79
|
+
that `logical` would misdescribe the numbers.
|
|
80
|
+
|
|
81
|
+
## Unreleased
|
|
82
|
+
|
|
83
|
+
### Fixed
|
|
84
|
+
|
|
85
|
+
- **An evidence node's `name` is free text.** The reader used to refuse any name
|
|
86
|
+
that was not exactly `"{parent}:{label}"`, so a document naming its
|
|
87
|
+
virtual-evidence nodes after the observation was rejected outright. The parent
|
|
88
|
+
is carried structurally in `evidence_for`, which is what consumers use, and
|
|
89
|
+
nothing parses the suffix back out of the name, so the rule enforced a format
|
|
90
|
+
on the one field a reader reads in order to encode something the document
|
|
91
|
+
already states. `add_evidence` still generates the suffixed form: what the
|
|
92
|
+
library produces and what the reader accepts are separate questions. The
|
|
93
|
+
integrity checks are untouched, so `evidence_for` must still name a real
|
|
94
|
+
variable, an evidence node must still have a CPD, and it is still at most one
|
|
95
|
+
per variable and never a constitutive target.
|
|
96
|
+
|
|
97
|
+
### Changed
|
|
98
|
+
|
|
99
|
+
- **`reveal.induce` now preserves `metadata`, on nodes and on edges.** It used to
|
|
100
|
+
strip it. The guarantee that nothing in `metadata` reached a reader has been
|
|
101
|
+
given up deliberately, because decoration lives there: several wordings of one
|
|
102
|
+
edge at different levels of precision, with one promoted into `description`. A
|
|
103
|
+
strip forced a consumer to choose a wording before inducing, and carrying it across
|
|
104
|
+
lets them induce once and render the same subgraph more than one way.
|
|
105
|
+
|
|
106
|
+
**The boundary moves rather than disappearing.** A reader never saw the document
|
|
107
|
+
in the first place, only text produced from it, so what protects them is what a
|
|
108
|
+
verbaliser emits. That is a weaker promise in a more honest place.
|
|
109
|
+
|
|
110
|
+
Nothing inside `metadata` is validated, which is what makes it useful and what
|
|
111
|
+
makes it the wrong place for anything a reader must not see. No number changes:
|
|
112
|
+
an implicit prior is read off the source graph, so carrying it into the result
|
|
113
|
+
cannot move an answer, and this is asserted rather than assumed.
|
|
114
|
+
|
|
3
115
|
## 1.4.2 (2026-10-12)
|
|
4
116
|
|
|
5
117
|
### Added
|
|
@@ -17,7 +129,7 @@
|
|
|
17
129
|
### Fixed
|
|
18
130
|
|
|
19
131
|
- **An unknown key on an edge was accepted and silently dropped.** The document
|
|
20
|
-
and each variable were checked against a key set
|
|
132
|
+
and each variable were checked against a key set, while edges were checked for an
|
|
21
133
|
unknown `kind` and never for unknown keys, and the writer rebuilt each edge
|
|
22
134
|
from a three-key literal. So an extra field survived a read, vanished on the
|
|
23
135
|
next write, and raised nothing. A generator shipping worded edge strengths lost
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: probability-flow
|
|
3
|
-
Version: 1.4.
|
|
3
|
+
Version: 1.4.3
|
|
4
4
|
Summary: Discrete causal graphs and exact inference.
|
|
5
5
|
Author-email: Alex Roman <dr.alexroman@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -459,10 +459,14 @@ keys of `weights` plus any gate source, and a gate source joins the CPD's input
|
|
|
459
459
|
list automatically at **weight `0.0` bits**, which is what "contributes no evidence
|
|
460
460
|
of its own, gating only" means.
|
|
461
461
|
|
|
462
|
-
**You
|
|
463
|
-
|
|
464
|
-
is
|
|
465
|
-
|
|
462
|
+
**You do not have to draw the edge.** Declaring the gate is enough: naming the
|
|
463
|
+
source in a `Gate` is a statement that it bears on this node, so the edge it
|
|
464
|
+
implies is drawn for you. Drawing it by hand first also works and is not
|
|
465
|
+
duplicated.
|
|
466
|
+
|
|
467
|
+
The edge is there for the arithmetic rather than for the picture. A gate source
|
|
468
|
+
carrying no weight of its own gets no arrow in a rendering at all. It gets a
|
|
469
|
+
switch wired into the edge it modulates.
|
|
466
470
|
|
|
467
471
|
**These are not `logical` with a leak.** A `logical` is deterministic over a
|
|
468
472
|
predicate on a count, and these are probabilistic over per-input activations.
|
|
@@ -435,10 +435,14 @@ keys of `weights` plus any gate source, and a gate source joins the CPD's input
|
|
|
435
435
|
list automatically at **weight `0.0` bits**, which is what "contributes no evidence
|
|
436
436
|
of its own, gating only" means.
|
|
437
437
|
|
|
438
|
-
**You
|
|
439
|
-
|
|
440
|
-
is
|
|
441
|
-
|
|
438
|
+
**You do not have to draw the edge.** Declaring the gate is enough: naming the
|
|
439
|
+
source in a `Gate` is a statement that it bears on this node, so the edge it
|
|
440
|
+
implies is drawn for you. Drawing it by hand first also works and is not
|
|
441
|
+
duplicated.
|
|
442
|
+
|
|
443
|
+
The edge is there for the arithmetic rather than for the picture. A gate source
|
|
444
|
+
carrying no weight of its own gets no arrow in a rendering at all. It gets a
|
|
445
|
+
switch wired into the edge it modulates.
|
|
442
446
|
|
|
443
447
|
**These are not `logical` with a leak.** A `logical` is deterministic over a
|
|
444
448
|
predicate on a count, and these are probabilistic over per-input activations.
|
|
@@ -26,7 +26,7 @@ graph: being "the hypothesis" is a fact about the question, not about the causal
|
|
|
26
26
|
structure.
|
|
27
27
|
"""
|
|
28
28
|
|
|
29
|
-
from . import eliminate, engines, junction, logical, noisy, reveal
|
|
29
|
+
from . import eliminate, engines, generation, junction, logical, noisy, reveal
|
|
30
30
|
from .cpd import CPD, CPT, Table
|
|
31
31
|
from .edges import Causal, Constitutive, Edge
|
|
32
32
|
from .evidence import Evidence
|
|
@@ -38,7 +38,7 @@ from .serialize import FORMAT, VERSION, from_json, to_json
|
|
|
38
38
|
from .variable import BINARY, Definition, Variable
|
|
39
39
|
from .viz import render, to_text
|
|
40
40
|
|
|
41
|
-
__version__ = "1.4.
|
|
41
|
+
__version__ = "1.4.3"
|
|
42
42
|
|
|
43
43
|
__all__ = [
|
|
44
44
|
"__version__",
|
|
@@ -54,6 +54,7 @@ __all__ = [
|
|
|
54
54
|
"Table",
|
|
55
55
|
"logical",
|
|
56
56
|
"noisy",
|
|
57
|
+
"generation",
|
|
57
58
|
"eliminate",
|
|
58
59
|
"junction",
|
|
59
60
|
"engines",
|
|
@@ -127,6 +127,51 @@ class CPD(ABC):
|
|
|
127
127
|
"what it says; it has to be written again"
|
|
128
128
|
)
|
|
129
129
|
|
|
130
|
+
#: THE MIRROR OF `extensible`, and the same test read backwards: whether a
|
|
131
|
+
#: parent can be DROPPED from this CPD without inventing anything. An
|
|
132
|
+
#: additive form just loses a term. A table cannot: its rows are indexed by
|
|
133
|
+
#: the full assignment, so dropping a parent means choosing which slice of
|
|
134
|
+
#: the table survives, and nothing in the table says which.
|
|
135
|
+
#:
|
|
136
|
+
#: Where there is no answer the author clearly meant, the CPD is cleared
|
|
137
|
+
#: with a warning rather than silently reinterpreted, exactly as on the way
|
|
138
|
+
#: in. The downstream guard is the same one: a variable with no CPD fails
|
|
139
|
+
#: `check()` and every query, loudly.
|
|
140
|
+
reducible = False
|
|
141
|
+
|
|
142
|
+
def reduction_note(self, dropped) -> "str | None":
|
|
143
|
+
"""What to warn about after dropping `dropped`, or None."""
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
def reduction_reason(self) -> str:
|
|
147
|
+
"""Why this CPD cannot lose a parent, for the warning."""
|
|
148
|
+
return (f"it is indexed by all of "
|
|
149
|
+
f"({', '.join(short(p.name) for p in self.parents) or 'nothing'}) "
|
|
150
|
+
"and nothing in it says which slice should survive")
|
|
151
|
+
|
|
152
|
+
def reduced_to(self, parents) -> "CPD":
|
|
153
|
+
"""This CPD over `parents`, which drops one from its current set."""
|
|
154
|
+
raise ValueError(
|
|
155
|
+
f"{type(self).__name__} cannot drop a parent without changing what "
|
|
156
|
+
"it says; it has to be written again"
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
def _in_family_over(self, parents, order):
|
|
160
|
+
"""This CPD over `parents`, in its own family, or None if it cannot be.
|
|
161
|
+
|
|
162
|
+
`order[i]` is the index in `self.parents` of the parent now at position
|
|
163
|
+
`i`, derived by STABLE ID rather than by identity, because the caller's
|
|
164
|
+
parents may be a different graph's objects with the same names. That is
|
|
165
|
+
why `reorder` cannot be used here: it matches by identity and raises
|
|
166
|
+
across graphs.
|
|
167
|
+
|
|
168
|
+
None means there is no in-family form and the caller should fall back to
|
|
169
|
+
a table. The base returns None; a family that can respin itself says so
|
|
170
|
+
by overriding. `Table` does not need to, because a table restricted to
|
|
171
|
+
all of its parents is a table either way.
|
|
172
|
+
"""
|
|
173
|
+
return None
|
|
174
|
+
|
|
130
175
|
def reorder(self, parents) -> "CPD":
|
|
131
176
|
"""Return this CPD with its conditioning set written in `parents` order.
|
|
132
177
|
|
|
@@ -219,6 +264,21 @@ class CPD(ABC):
|
|
|
219
264
|
"count would silently restrict to the wrong states. See #22."
|
|
220
265
|
)
|
|
221
266
|
|
|
267
|
+
# NOTHING HIDDEN MEANS NOTHING TO AVERAGE, so the restriction is the
|
|
268
|
+
# IDENTITY and flattening here is pure loss. It preserves every
|
|
269
|
+
# probability exactly while destroying the structure, which is the
|
|
270
|
+
# failure the format document already names about the emitter: "nothing
|
|
271
|
+
# checking numbers would notice". The same principle binds this method,
|
|
272
|
+
# and nobody connected the two until a consumer needed the operator.
|
|
273
|
+
#
|
|
274
|
+
# A full reveal is not an edge case, it is the state a reveal game ENDS
|
|
275
|
+
# in, and a consumer that needs a node's operator to put it into English
|
|
276
|
+
# needs it most there. Verified identical to the original to 1e-15.
|
|
277
|
+
if not hidden:
|
|
278
|
+
kept = self._in_family_over(list(shown), at)
|
|
279
|
+
if kept is not None:
|
|
280
|
+
return kept.for_child(target)
|
|
281
|
+
|
|
222
282
|
rows = {}
|
|
223
283
|
for assignment in _assignments(list(shown)):
|
|
224
284
|
total = [0.0] * len(target.states)
|
|
@@ -226,14 +286,14 @@ class CPD(ABC):
|
|
|
226
286
|
weight = 1.0
|
|
227
287
|
for i, index in zip(hidden, combo):
|
|
228
288
|
parent = self.parents[i]
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
289
|
+
# ONE PATH FOR EVERY ARITY. A multi-state parent used to be
|
|
290
|
+
# taken as uniform here, which was a stand-in rather than a
|
|
291
|
+
# derivation. Its implicit prior is now a distribution over
|
|
292
|
+
# its states, exactly as a prior over a three-state variable
|
|
293
|
+
# is three numbers, and the binary case is the two-entry
|
|
294
|
+
# instance of the same thing.
|
|
295
|
+
weight *= as_implicit_distribution(
|
|
296
|
+
unrevealed_q[_stable_key(parent)], parent)[index]
|
|
237
297
|
if weight == 0.0:
|
|
238
298
|
continue
|
|
239
299
|
full = [0] * len(self.parents)
|
|
@@ -262,6 +322,70 @@ class CPD(ABC):
|
|
|
262
322
|
_keys_are_indices=True)
|
|
263
323
|
|
|
264
324
|
|
|
325
|
+
def as_implicit_distribution(value, parent):
|
|
326
|
+
"""An implicit prior as a DISTRIBUTION over `parent`'s states.
|
|
327
|
+
|
|
328
|
+
`value` may be either form, because both are useful and one is ambiguous:
|
|
329
|
+
|
|
330
|
+
a number P(the parent is not in its FIRST state), which is what a
|
|
331
|
+
binary `q` has always meant, so `0.3` gives `(0.7, 0.3)`.
|
|
332
|
+
REFUSED for a multi-state parent, because there is no
|
|
333
|
+
honest way to spread one number over three states.
|
|
334
|
+
a sequence one entry per state, in `parent.states` order, summing to
|
|
335
|
+
one. This is the general form and the only one a
|
|
336
|
+
multi-state parent can use.
|
|
337
|
+
|
|
338
|
+
The vector is what a non-binary parent needs, exactly as a prior over a
|
|
339
|
+
three-state variable needs three numbers. Before it existed, a multi-state
|
|
340
|
+
unrevealed parent was taken as UNIFORM, which is a stand-in rather than a
|
|
341
|
+
derivation and was recorded as an open gap.
|
|
342
|
+
|
|
343
|
+
Validated here rather than where it is stored, because `metadata` is a
|
|
344
|
+
container this library does not inspect and this is the first thing that
|
|
345
|
+
looks at the value.
|
|
346
|
+
"""
|
|
347
|
+
n = len(parent.states)
|
|
348
|
+
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
349
|
+
if n != 2:
|
|
350
|
+
raise ValueError(
|
|
351
|
+
f"{parent.name!r} has {n} states, so its implicit prior needs "
|
|
352
|
+
f"{n} numbers, one per state in {parent.states!r}, and a single "
|
|
353
|
+
f"number was given ({value!r}). One number means P(not the first "
|
|
354
|
+
"state), which says nothing about how the rest divides."
|
|
355
|
+
)
|
|
356
|
+
q = float(value)
|
|
357
|
+
if not 0.0 <= q <= 1.0:
|
|
358
|
+
raise ValueError(
|
|
359
|
+
f"{parent.name!r} has an implicit prior of {value!r}, which is "
|
|
360
|
+
"not a probability."
|
|
361
|
+
)
|
|
362
|
+
return (1.0 - q, q)
|
|
363
|
+
|
|
364
|
+
try:
|
|
365
|
+
dist = tuple(float(x) for x in value)
|
|
366
|
+
except (TypeError, ValueError):
|
|
367
|
+
raise ValueError(
|
|
368
|
+
f"{parent.name!r} has an implicit prior of {value!r}, which is "
|
|
369
|
+
"neither a number nor a sequence of them."
|
|
370
|
+
) from None
|
|
371
|
+
if len(dist) != n:
|
|
372
|
+
raise ValueError(
|
|
373
|
+
f"{parent.name!r} has {n} states {parent.states!r} and its implicit "
|
|
374
|
+
f"prior gives {len(dist)} numbers. It needs one per state, in that "
|
|
375
|
+
"order, exactly as a prior does."
|
|
376
|
+
)
|
|
377
|
+
if any(x < 0.0 for x in dist):
|
|
378
|
+
raise ValueError(
|
|
379
|
+
f"{parent.name!r} has a negative entry in its implicit prior: {dist}"
|
|
380
|
+
)
|
|
381
|
+
if abs(sum(dist) - 1.0) > 1e-9:
|
|
382
|
+
raise ValueError(
|
|
383
|
+
f"{parent.name!r} has an implicit prior summing to {sum(dist)!r} "
|
|
384
|
+
"rather than 1. It is a distribution over the parent's states."
|
|
385
|
+
)
|
|
386
|
+
return dist
|
|
387
|
+
|
|
388
|
+
|
|
265
389
|
def _stable_key(variable):
|
|
266
390
|
"""How a parent is matched ACROSS graphs: its stable id, falling back to
|
|
267
391
|
object identity for a variable that is not in a graph yet."""
|
|
@@ -30,6 +30,7 @@ from .edges import Causal, Constitutive
|
|
|
30
30
|
from .evidence import Evidence
|
|
31
31
|
from .graph import CausalGraph
|
|
32
32
|
from .independent import Gate, IndependentEvidence
|
|
33
|
+
from .variable import Definition, Variable
|
|
33
34
|
|
|
34
35
|
__all__ = ["StructureSpec", "GraphSpec", "generate", "generate_batch",
|
|
35
36
|
"check_reveal_legal", "GenerationError"]
|
|
@@ -104,8 +105,9 @@ class GraphSpec:
|
|
|
104
105
|
|
|
105
106
|
#: Probability that an UNDETERMINED node with at least two parents is
|
|
106
107
|
#: definitional. Only the undetermined are rolled: see `_assign_types`.
|
|
107
|
-
#: (Alex has not settled this name
|
|
108
|
-
|
|
108
|
+
#: (Alex has not settled this name; he asked on 2026-10-13 that it not be
|
|
109
|
+
#: renamed before the Friday demo, so the churn does not land near it.)
|
|
110
|
+
definitional_prob: float = 0.25
|
|
109
111
|
#: Definitional nodes get their own CPD parameters, not the ordinary ones.
|
|
110
112
|
definitional_leak_range: tuple[float, float] = (0.0, 0.02)
|
|
111
113
|
|
|
@@ -662,7 +664,7 @@ def _generator_fingerprint():
|
|
|
662
664
|
"""
|
|
663
665
|
from . import __version__
|
|
664
666
|
|
|
665
|
-
fingerprint = {"generator": "probability_flow.
|
|
667
|
+
fingerprint = {"generator": "probability_flow.generation",
|
|
666
668
|
"generator_version": __version__}
|
|
667
669
|
try:
|
|
668
670
|
import subprocess
|
|
@@ -702,9 +704,14 @@ def generate(spec: GraphSpec | None = None, seed: int = 0) -> CausalGraph:
|
|
|
702
704
|
# assembly: now, and only now, can a Variable be created with its type
|
|
703
705
|
graph = CausalGraph(f"generated/{seed}")
|
|
704
706
|
var: dict = {}
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
707
|
+
# ids are n-based by convention (Alex, 2026-10-13). The library would assign
|
|
708
|
+
# `v0, v1, ...` on insert, so the id has to be supplied BEFORE the variable
|
|
709
|
+
# joins the graph -- afterwards it is read-only, deliberately, since the
|
|
710
|
+
# document format references it.
|
|
711
|
+
for index, (name, tag) in enumerate(plan.items()):
|
|
712
|
+
node = (Definition(name, id=f"n{index}") if name in definitional
|
|
713
|
+
else Variable(name, id=f"n{index}"))
|
|
714
|
+
var[name] = graph.add(node)
|
|
708
715
|
hypothesis = var[spec.hypothesis_name]
|
|
709
716
|
for source, target in edges:
|
|
710
717
|
kind = Constitutive if target in definitional else Causal
|
|
@@ -749,7 +756,11 @@ def generate(spec: GraphSpec | None = None, seed: int = 0) -> CausalGraph:
|
|
|
749
756
|
if node is hypothesis or isinstance(node, Evidence):
|
|
750
757
|
continue
|
|
751
758
|
p_if, p_if_not = _evidence_pair(rng, spec)
|
|
752
|
-
|
|
759
|
+
# `<claim id>-ve`, so one id convention runs through the whole document
|
|
760
|
+
# (Alex, 2026-10-13). The NAME is left as `add_evidence` builds it; only
|
|
761
|
+
# the id is in scope here.
|
|
762
|
+
observation = node.add_evidence("observation", p_if=p_if, p_if_not=p_if_not,
|
|
763
|
+
id=f"{node.id}-ve")
|
|
753
764
|
# Virtual evidence DOES get a cluster tag, unlike implicit_prior: it is
|
|
754
765
|
# revealed with the variable it is about, so it belongs to the same
|
|
755
766
|
# cluster, and a consumer walking every variable should not meet a hole.
|
|
@@ -846,7 +857,7 @@ def check_reveal_legal(graph: CausalGraph) -> None:
|
|
|
846
857
|
if leaked:
|
|
847
858
|
raise GenerationError(
|
|
848
859
|
f"the ground-truth world reached the document on {leaked}. It used to "
|
|
849
|
-
"live in node metadata and
|
|
860
|
+
"live in node metadata, and the reveal game no longer strips "
|
|
850
861
|
"metadata -- one undocumented line in another repo. It is now simply "
|
|
851
862
|
"not written, so this is a generator bug rather than a leak"
|
|
852
863
|
)
|
|
@@ -885,14 +896,11 @@ def leaks_ground_truth(doc: dict) -> list[str]:
|
|
|
885
896
|
more, so this returns empty for anything this module produces. It stays
|
|
886
897
|
because the day somebody puts it back is the day this fires.
|
|
887
898
|
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
retired: `reveal.induce` strips metadata here, says why in its docstring, and
|
|
894
|
-
`test_induce_STRIPS_METADATA_because_it_is_where_ground_truth_lives` holds it
|
|
895
|
-
in place.
|
|
899
|
+
THE STRIP IT USED TO RELY ON IS GONE. `reveal.induce` carried metadata from
|
|
900
|
+
1.4.3 onward, so "it survives only because the reveal game strips metadata" is
|
|
901
|
+
no longer true and this check is the whole protection rather than the second
|
|
902
|
+
layer. That is the reason it stays: the write was removed, and this is what
|
|
903
|
+
fires if it returns.
|
|
896
904
|
"""
|
|
897
905
|
return [v.get("name", v.get("id")) for v in doc.get("variables", [])
|
|
898
906
|
if "ground_truth" in (v.get("metadata") or {})]
|
|
@@ -220,6 +220,139 @@ class CausalGraph:
|
|
|
220
220
|
stacklevel=4,
|
|
221
221
|
)
|
|
222
222
|
|
|
223
|
+
def remove_edge(self, cause: Variable, effect: Variable) -> None:
|
|
224
|
+
"""Remove the edge `cause -> effect`, and the effect CPD's term for it.
|
|
225
|
+
|
|
226
|
+
THE CPD IS ASKED TO DROP THE PARENT, not rewritten from outside. Only
|
|
227
|
+
the family knows its own shape: an additive CPD loses a term and any
|
|
228
|
+
gate that touched either end, a conjunction loses a constituent, a noisy
|
|
229
|
+
family loses a factor, and a table cannot lose anything without somebody
|
|
230
|
+
choosing which slice survives, so it is cleared with a warning.
|
|
231
|
+
|
|
232
|
+
That division is the whole point. A caller editing the SERIALISED form
|
|
233
|
+
has to know that `weights` is a dict and `gates` is a list and that a
|
|
234
|
+
logical CPD keeps its inputs under `constituents` with no `parents` key
|
|
235
|
+
at all. Getting one of those wrong writes a document that still loads,
|
|
236
|
+
because the orphaned gate re-creates the edge it names, and the removal
|
|
237
|
+
then silently did not happen.
|
|
238
|
+
"""
|
|
239
|
+
self._require(cause)
|
|
240
|
+
self._require(effect)
|
|
241
|
+
from .evidence import Evidence
|
|
242
|
+
if isinstance(effect, Evidence):
|
|
243
|
+
raise ValueError(
|
|
244
|
+
f"{short(effect.name)!r} is a virtual-evidence node and "
|
|
245
|
+
f"{short(cause.name)!r} is what it is evidence ABOUT, so this "
|
|
246
|
+
"edge is what makes it evidence at all. Remove the evidence "
|
|
247
|
+
f"node itself with remove_variable({short(effect.name)!r}), or "
|
|
248
|
+
f"remove {short(cause.name)!r} and it goes too."
|
|
249
|
+
)
|
|
250
|
+
if self._edges.get((id(cause), id(effect))) is None:
|
|
251
|
+
raise ValueError(
|
|
252
|
+
f"there is no edge {short(cause.name)!r} -> {short(effect.name)!r} "
|
|
253
|
+
"to remove. Edges are directed, so check the direction: "
|
|
254
|
+
f"{short(effect.name)!r} -> {short(cause.name)!r} "
|
|
255
|
+
+ ("DOES exist." if self._edges.get((id(effect), id(cause)))
|
|
256
|
+
else "does not either.")
|
|
257
|
+
)
|
|
258
|
+
self._drop_edge(cause, effect)
|
|
259
|
+
|
|
260
|
+
def _drop_edge(self, cause: Variable, effect: Variable) -> None:
|
|
261
|
+
"""Remove an edge without the Evidence guard.
|
|
262
|
+
|
|
263
|
+
Module-internal, and split out for the same reason `_insert` is split
|
|
264
|
+
from `add`: `remove_variable` has to take an evidence node's own edge
|
|
265
|
+
with it, and the public guard exists to stop a CALLER doing that on its
|
|
266
|
+
own and leaving evidence about nothing behind.
|
|
267
|
+
"""
|
|
268
|
+
del self._edges[(id(cause), id(effect))]
|
|
269
|
+
self._parent_list[id(effect)] = [
|
|
270
|
+
p for p in self._parent_list[id(effect)] if p is not cause]
|
|
271
|
+
self._child_list[id(cause)] = [
|
|
272
|
+
c for c in self._child_list[id(cause)] if c is not effect]
|
|
273
|
+
self._reconcile_cpd_dropped(effect, cause)
|
|
274
|
+
|
|
275
|
+
def remove_variable(self, variable: Variable) -> None:
|
|
276
|
+
"""Remove a variable, its virtual evidence, and every edge touching either.
|
|
277
|
+
|
|
278
|
+
IDS ARE NEVER REUSED OR RENUMBERED. The ids in a document are the keys
|
|
279
|
+
every external reference uses, so a removal that renumbered would be
|
|
280
|
+
schema-breaking rather than merely surprising. Removing `n3` leaves
|
|
281
|
+
`n4` as `n4`, and a later `add_variable` takes the next id that has
|
|
282
|
+
never been used rather than the hole.
|
|
283
|
+
|
|
284
|
+
Virtual evidence goes with the variable it is about. It models the
|
|
285
|
+
uncertainty of OBSERVING that variable, so keeping it afterwards would
|
|
286
|
+
leave a node that is evidence about nothing.
|
|
287
|
+
"""
|
|
288
|
+
self._require(variable)
|
|
289
|
+
if variable is self._hypothesis:
|
|
290
|
+
raise ValueError(
|
|
291
|
+
f"{short(variable.name)!r} is this graph's hypothesis, so "
|
|
292
|
+
"removing it would leave the graph with nothing to be about. "
|
|
293
|
+
"Set `graph.hypothesis` to another variable first if that is "
|
|
294
|
+
"what you mean."
|
|
295
|
+
)
|
|
296
|
+
from .evidence import Evidence
|
|
297
|
+
doomed = [variable]
|
|
298
|
+
if not isinstance(variable, Evidence) and variable.has_evidence:
|
|
299
|
+
doomed.append(variable.evidence)
|
|
300
|
+
for victim in doomed:
|
|
301
|
+
# Copy both lists first: removing an edge mutates them.
|
|
302
|
+
for parent in list(self._parent_list[id(victim)]):
|
|
303
|
+
self._drop_edge(parent, victim)
|
|
304
|
+
for child in list(self._child_list[id(victim)]):
|
|
305
|
+
self._drop_edge(victim, child)
|
|
306
|
+
for victim in doomed:
|
|
307
|
+
self._variables.remove(victim)
|
|
308
|
+
del self._by_name[victim.name]
|
|
309
|
+
del self._by_id[victim.id]
|
|
310
|
+
self._cpds.pop(id(victim), None)
|
|
311
|
+
self._parent_list.pop(id(victim), None)
|
|
312
|
+
self._child_list.pop(id(victim), None)
|
|
313
|
+
self._position.pop(id(victim), None)
|
|
314
|
+
victim._graph = None
|
|
315
|
+
# POSITIONS ARE AN INTERNAL ORDERING, NOT THE SCHEMA IDS. Parent lists
|
|
316
|
+
# are sorted by position so that the same graph always serialises one
|
|
317
|
+
# way, so the index has to stay dense after a removal. `variable.id` is
|
|
318
|
+
# untouched by this.
|
|
319
|
+
for i, remaining in enumerate(self._variables):
|
|
320
|
+
self._position[id(remaining)] = i
|
|
321
|
+
|
|
322
|
+
def _reconcile_cpd_dropped(self, effect: Variable, dropped: Variable) -> None:
|
|
323
|
+
"""The effect just lost a parent. Reduce its CPD, or clear it.
|
|
324
|
+
|
|
325
|
+
The mirror of `_reconcile_cpd`, and deliberately the same shape: reduce
|
|
326
|
+
where the reduction is the only thing the author could have meant, clear
|
|
327
|
+
where it is not. Clearing is safe for the same reason it is on the way
|
|
328
|
+
in, which is that a variable with no CPD fails `check()` and every query
|
|
329
|
+
rather than answering with something nobody wrote.
|
|
330
|
+
"""
|
|
331
|
+
cpd = self._cpds.get(id(effect))
|
|
332
|
+
if cpd is None:
|
|
333
|
+
return
|
|
334
|
+
parents = tuple(self._parent_list[id(effect)])
|
|
335
|
+
if cpd.reducible:
|
|
336
|
+
self._cpds[id(effect)] = cpd.reduced_to(parents)
|
|
337
|
+
note = cpd.reduction_note(dropped)
|
|
338
|
+
if note:
|
|
339
|
+
warnings.warn(
|
|
340
|
+
f"{short(effect.name)!r} lost the parent "
|
|
341
|
+
f"{short(dropped.name)!r}, and its {type(cpd).__name__} "
|
|
342
|
+
f"{note}",
|
|
343
|
+
stacklevel=4,
|
|
344
|
+
)
|
|
345
|
+
return
|
|
346
|
+
del self._cpds[id(effect)]
|
|
347
|
+
warnings.warn(
|
|
348
|
+
f"{short(effect.name)!r} lost the parent {short(dropped.name)!r}, "
|
|
349
|
+
f"and its {type(cpd).__name__} was CLEARED: "
|
|
350
|
+
f"{cpd.reduction_reason()}. Set a new CPD for {short(effect.name)!r} "
|
|
351
|
+
f"over ({', '.join(short(p.name) for p in parents) or 'nothing'}); "
|
|
352
|
+
"until you do, every query on this graph will fail.",
|
|
353
|
+
stacklevel=4,
|
|
354
|
+
)
|
|
355
|
+
|
|
223
356
|
@staticmethod
|
|
224
357
|
def _check_kind(edge: Edge, effect: Variable) -> None:
|
|
225
358
|
"""The constitutive invariant, enforced as each edge arrives.
|
|
@@ -303,6 +436,26 @@ class CausalGraph:
|
|
|
303
436
|
- a ready-made `CPD` (a compact parameterised form, say).
|
|
304
437
|
"""
|
|
305
438
|
self._require(variable)
|
|
439
|
+
|
|
440
|
+
# A GATE NAMES ITS SOURCE, so the edge it implies is drawn here rather
|
|
441
|
+
# than left to the author.
|
|
442
|
+
#
|
|
443
|
+
# THIS IS NOT A PARAMETERS CALL INVENTING STRUCTURE, which is the
|
|
444
|
+
# objection it looks like. `Gate(u, modulates=a)` states that u bears on
|
|
445
|
+
# this node; the edge is that statement's mechanical consequence, not a
|
|
446
|
+
# relationship nobody mentioned. Structure before parameters still holds
|
|
447
|
+
# for everything a CPD does NOT name.
|
|
448
|
+
#
|
|
449
|
+
# Declaring a gate and stopping used to raise, and the refusal told the
|
|
450
|
+
# author to draw an edge the renderer then HIDES: a gate that carries no
|
|
451
|
+
# weight of its own gets no arrow to the child, it gets a switch wired
|
|
452
|
+
# into the edge it modulates. So the author was being asked to write a
|
|
453
|
+
# line whose only visible effect was to stop an error.
|
|
454
|
+
for gate in getattr(cpd_or_rows, "gates", ()) or ():
|
|
455
|
+
if not any(p is gate.source
|
|
456
|
+
for p in self._parent_list[id(variable)]):
|
|
457
|
+
self.add_edge(gate.source, variable)
|
|
458
|
+
|
|
306
459
|
parents = tuple(self._parent_list[id(variable)])
|
|
307
460
|
if isinstance(cpd_or_rows, CPT):
|
|
308
461
|
cpd = cpd_or_rows.build(variable, parents)
|