probability-flow 1.4.2__tar.gz → 1.4.4__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.4}/.gitignore +7 -4
- {probability_flow-1.4.2 → probability_flow-1.4.4}/CHANGELOG.md +139 -1
- {probability_flow-1.4.2 → probability_flow-1.4.4}/PKG-INFO +9 -5
- {probability_flow-1.4.2 → probability_flow-1.4.4}/README.md +8 -4
- {probability_flow-1.4.2 → probability_flow-1.4.4}/probability_flow/__init__.py +3 -2
- {probability_flow-1.4.2 → probability_flow-1.4.4}/probability_flow/cpd.py +132 -8
- probability_flow-1.4.2/probability_flow/generate.py → probability_flow-1.4.4/probability_flow/generation.py +24 -16
- {probability_flow-1.4.2 → probability_flow-1.4.4}/probability_flow/graph.py +162 -1
- {probability_flow-1.4.2 → probability_flow-1.4.4}/probability_flow/independent.py +159 -9
- {probability_flow-1.4.2 → probability_flow-1.4.4}/probability_flow/logical.py +75 -5
- {probability_flow-1.4.2 → probability_flow-1.4.4}/probability_flow/noisy.py +34 -0
- {probability_flow-1.4.2 → probability_flow-1.4.4}/probability_flow/reveal.py +87 -38
- {probability_flow-1.4.2 → probability_flow-1.4.4}/probability_flow/serialize.py +20 -5
- {probability_flow-1.4.2 → probability_flow-1.4.4}/probability_flow/variable.py +52 -4
- {probability_flow-1.4.2 → probability_flow-1.4.4}/probability_flow/viz.py +109 -30
- {probability_flow-1.4.2 → probability_flow-1.4.4}/pyproject.toml +9 -3
- {probability_flow-1.4.2 → probability_flow-1.4.4}/LICENSE +0 -0
- {probability_flow-1.4.2 → probability_flow-1.4.4}/probability_flow/decompose.py +0 -0
- {probability_flow-1.4.2 → probability_flow-1.4.4}/probability_flow/edges.py +0 -0
- {probability_flow-1.4.2 → probability_flow-1.4.4}/probability_flow/eliminate.py +0 -0
- {probability_flow-1.4.2 → probability_flow-1.4.4}/probability_flow/engines.py +0 -0
- {probability_flow-1.4.2 → probability_flow-1.4.4}/probability_flow/evidence.py +0 -0
- {probability_flow-1.4.2 → probability_flow-1.4.4}/probability_flow/factors.py +0 -0
- {probability_flow-1.4.2 → probability_flow-1.4.4}/probability_flow/inference.py +0 -0
- {probability_flow-1.4.2 → probability_flow-1.4.4}/probability_flow/junction.py +0 -0
- {probability_flow-1.4.2 → probability_flow-1.4.4}/probability_flow/ordering.py +0 -0
- {probability_flow-1.4.2 → probability_flow-1.4.4}/probability_flow/py.typed +0 -0
- {probability_flow-1.4.2 → probability_flow-1.4.4}/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,143 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.4.4
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **`variable.set_baseline(probability)`**, for changing P(a node) with every
|
|
8
|
+
parent absent after the CPD is written.
|
|
9
|
+
|
|
10
|
+
It takes a PROBABILITY, which is the unit `set_prior` takes and the unit the
|
|
11
|
+
file format stores. There is no relative form and no `bits=` flag: a
|
|
12
|
+
`shift_baseline(0.2)` beside a `set_baseline(0.2)` is two calls that read the
|
|
13
|
+
same and mean different things, since `0.2` is a plausible probability and a
|
|
14
|
+
plausible number of bits.
|
|
15
|
+
|
|
16
|
+
### Fixed
|
|
17
|
+
|
|
18
|
+
- **A removed id could be handed out again.** Removing a variable from the
|
|
19
|
+
middle of a graph left a hole that the next auto-assigned id stepped past,
|
|
20
|
+
but removing the LAST variable simply freed its id, and the next variable
|
|
21
|
+
took it back. Anything still referring to the removed node then pointed at a
|
|
22
|
+
different proposition, silently. Ids are retired on removal now.
|
|
23
|
+
|
|
24
|
+
- **Three more silent no-ops in the additive family.** `cpd.baseline`,
|
|
25
|
+
`cpd.gates` and `Gate.weight_when_gated` all accepted an assignment, changed
|
|
26
|
+
nothing and reported nothing, because the value every query reads is a
|
|
27
|
+
derived copy. `baseline` was the worst: it read back as the number you had
|
|
28
|
+
assigned, so the object agreed with you while going on answering the old
|
|
29
|
+
question. All three raise now.
|
|
30
|
+
|
|
31
|
+
## 1.4.3
|
|
32
|
+
|
|
33
|
+
### Added
|
|
34
|
+
|
|
35
|
+
- **Structural editing.** `graph.remove_edge(cause, effect)` and
|
|
36
|
+
`graph.remove_variable(variable)`, plus `cpd.set_weight(parent, bits)` for
|
|
37
|
+
changing what an edge carries. Until now the library could only grow a graph,
|
|
38
|
+
so every consumer who needed to drop a node edited the serialised form and
|
|
39
|
+
read it back.
|
|
40
|
+
|
|
41
|
+
Removal **asks the CPD to drop the parent** rather than rewriting its
|
|
42
|
+
serialised form, because only the family knows its own shape. A caller
|
|
43
|
+
patching the document has to know that `weights` is a dict and `gates` is a
|
|
44
|
+
list and that a logical CPD keeps its inputs under `constituents` with no
|
|
45
|
+
`parents` key at all. One measured consequence of getting that wrong: an
|
|
46
|
+
orphaned gate re-creates the edge it names on the way back in, so the file
|
|
47
|
+
said two parents, the graph had three, every posterior was unchanged, and
|
|
48
|
+
nothing reported a problem.
|
|
49
|
+
|
|
50
|
+
Ids are never renumbered or reused. A gate goes when either end of it does,
|
|
51
|
+
with a warning. A family that cannot lose a parent without inventing
|
|
52
|
+
something has its CPD cleared, which makes `check()` and every query fail
|
|
53
|
+
until a new one is written.
|
|
54
|
+
|
|
55
|
+
- `IndependentEvidence.weights` is now read-only. Assigning into it changed
|
|
56
|
+
nothing and reported nothing, because `_log_w` is what every query reads.
|
|
57
|
+
Use `set_weight`.
|
|
58
|
+
|
|
59
|
+
### Added
|
|
60
|
+
|
|
61
|
+
- **The documentation site is published**, at
|
|
62
|
+
<http://probabilityflow.com/docs/>, one directory per release with a version
|
|
63
|
+
selector and a banner on any version that is not the newest. It replaces a
|
|
64
|
+
hand-written page that described a version of the library which no longer
|
|
65
|
+
existed. Until Netlify issues a certificate for the domain, that link is
|
|
66
|
+
http and not https.
|
|
67
|
+
- **Figures.** The pages that describe a graph now draw one. `illustrations/build.py`
|
|
68
|
+
writes them from the library into `docs/figures/`, so a figure cannot depict a
|
|
69
|
+
graph the library would not produce, and three tests keep the embeds and the
|
|
70
|
+
files in step.
|
|
71
|
+
- **An API reference**, at `docs/api/` and on the documentation site. Grouped by
|
|
72
|
+
what you are doing rather than by which module a name lives in, with a section
|
|
73
|
+
for the things you receive rather than construct, because ten exported names
|
|
74
|
+
are returned to you and never called by anybody.
|
|
75
|
+
|
|
76
|
+
### Changed
|
|
77
|
+
|
|
78
|
+
- **The API reference is five pages** rather than one, indented under one
|
|
79
|
+
section: graphs and CPDs, asking questions, saving and loading, drawing,
|
|
80
|
+
generating, and the reveal game.
|
|
81
|
+
- **The reveal game has its own page**, and the formalism page no longer
|
|
82
|
+
mentions it. It is an application built on the library rather than part of
|
|
83
|
+
the formalism, and the benchmark's own vocabulary has been taken off the site
|
|
84
|
+
entirely.
|
|
85
|
+
- **The formalism page says edges come in three kinds.** Gating used to be
|
|
86
|
+
described as "not a third kind" on the grounds that the schema writes it
|
|
87
|
+
`causal`. That is a fact about the schema. A gate is an edge with its own
|
|
88
|
+
rules, and the page now says so and draws one.
|
|
89
|
+
- **The tutorial is on Colab** rather than rendered as a static page, so a
|
|
90
|
+
reader can run it. `mkdocs-jupyter` is no longer a documentation dependency.
|
|
91
|
+
|
|
92
|
+
### Fixed
|
|
93
|
+
|
|
94
|
+
- **A fully revealed CPD came back as a table, losing its structure.** Nothing is
|
|
95
|
+
hidden at a full reveal, so the restriction is the identity and flattening is
|
|
96
|
+
pure loss: it preserves every probability exactly while destroying the operator,
|
|
97
|
+
the constituent count and the per-input activations. On a real decorated graph
|
|
98
|
+
that turned three `logical` CPDs into tables with every posterior still correct.
|
|
99
|
+
A definitional node has no per-edge strength, so the operator and the count are
|
|
100
|
+
the whole content of a sentence about a constitutive edge, and a consumer that
|
|
101
|
+
lost them had to reach back into the source document for structure. `logical`
|
|
102
|
+
and the noisy families now stay in family when nothing is hidden.
|
|
103
|
+
|
|
104
|
+
A **partial** reveal still returns a table, and that is not an oversight: `and`
|
|
105
|
+
over three constituents with two shown and the third at `q = 0.5` is `0.5` on
|
|
106
|
+
the row where both hold, where an `and` over the two would be `1.0`. Tagging
|
|
107
|
+
that `logical` would misdescribe the numbers.
|
|
108
|
+
|
|
109
|
+
### Fixed
|
|
110
|
+
|
|
111
|
+
- **An evidence node's `name` is free text.** The reader used to refuse any name
|
|
112
|
+
that was not exactly `"{parent}:{label}"`, so a document naming its
|
|
113
|
+
virtual-evidence nodes after the observation was rejected outright. The parent
|
|
114
|
+
is carried structurally in `evidence_for`, which is what consumers use, and
|
|
115
|
+
nothing parses the suffix back out of the name, so the rule enforced a format
|
|
116
|
+
on the one field a reader reads in order to encode something the document
|
|
117
|
+
already states. `add_evidence` still generates the suffixed form: what the
|
|
118
|
+
library produces and what the reader accepts are separate questions. The
|
|
119
|
+
integrity checks are untouched, so `evidence_for` must still name a real
|
|
120
|
+
variable, an evidence node must still have a CPD, and it is still at most one
|
|
121
|
+
per variable and never a constitutive target.
|
|
122
|
+
|
|
123
|
+
### Changed
|
|
124
|
+
|
|
125
|
+
- **`reveal.induce` now preserves `metadata`, on nodes and on edges.** It used to
|
|
126
|
+
strip it. The guarantee that nothing in `metadata` reached a reader has been
|
|
127
|
+
given up deliberately, because decoration lives there: several wordings of one
|
|
128
|
+
edge at different levels of precision, with one promoted into `description`. A
|
|
129
|
+
strip forced a consumer to choose a wording before inducing, and carrying it across
|
|
130
|
+
lets them induce once and render the same subgraph more than one way.
|
|
131
|
+
|
|
132
|
+
**The boundary moves rather than disappearing.** A reader never saw the document
|
|
133
|
+
in the first place, only text produced from it, so what protects them is what a
|
|
134
|
+
verbaliser emits. That is a weaker promise in a more honest place.
|
|
135
|
+
|
|
136
|
+
Nothing inside `metadata` is validated, which is what makes it useful and what
|
|
137
|
+
makes it the wrong place for anything a reader must not see. No number changes:
|
|
138
|
+
an implicit prior is read off the source graph, so carrying it into the result
|
|
139
|
+
cannot move an answer, and this is asserted rather than assumed.
|
|
140
|
+
|
|
3
141
|
## 1.4.2 (2026-10-12)
|
|
4
142
|
|
|
5
143
|
### Added
|
|
@@ -17,7 +155,7 @@
|
|
|
17
155
|
### Fixed
|
|
18
156
|
|
|
19
157
|
- **An unknown key on an edge was accepted and silently dropped.** The document
|
|
20
|
-
and each variable were checked against a key set
|
|
158
|
+
and each variable were checked against a key set, while edges were checked for an
|
|
21
159
|
unknown `kind` and never for unknown keys, and the writer rebuilt each edge
|
|
22
160
|
from a three-key literal. So an extra field survived a read, vanished on the
|
|
23
161
|
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.4
|
|
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.4"
|
|
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 {})]
|