probability-flow 1.4.0__tar.gz → 1.4.2__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.0 → probability_flow-1.4.2}/CHANGELOG.md +51 -0
- {probability_flow-1.4.0 → probability_flow-1.4.2}/PKG-INFO +17 -10
- {probability_flow-1.4.0 → probability_flow-1.4.2}/README.md +16 -6
- {probability_flow-1.4.0 → probability_flow-1.4.2}/probability_flow/__init__.py +1 -1
- {probability_flow-1.4.0 → probability_flow-1.4.2}/probability_flow/edges.py +18 -3
- {probability_flow-1.4.0 → probability_flow-1.4.2}/probability_flow/generate.py +14 -2
- {probability_flow-1.4.0 → probability_flow-1.4.2}/probability_flow/logical.py +26 -2
- {probability_flow-1.4.0 → probability_flow-1.4.2}/probability_flow/reveal.py +13 -3
- {probability_flow-1.4.0 → probability_flow-1.4.2}/probability_flow/serialize.py +42 -6
- {probability_flow-1.4.0 → probability_flow-1.4.2}/probability_flow/viz.py +52 -15
- {probability_flow-1.4.0 → probability_flow-1.4.2}/pyproject.toml +24 -5
- {probability_flow-1.4.0 → probability_flow-1.4.2}/.gitignore +0 -0
- {probability_flow-1.4.0 → probability_flow-1.4.2}/LICENSE +0 -0
- {probability_flow-1.4.0 → probability_flow-1.4.2}/probability_flow/cpd.py +0 -0
- {probability_flow-1.4.0 → probability_flow-1.4.2}/probability_flow/decompose.py +0 -0
- {probability_flow-1.4.0 → probability_flow-1.4.2}/probability_flow/eliminate.py +0 -0
- {probability_flow-1.4.0 → probability_flow-1.4.2}/probability_flow/engines.py +0 -0
- {probability_flow-1.4.0 → probability_flow-1.4.2}/probability_flow/evidence.py +0 -0
- {probability_flow-1.4.0 → probability_flow-1.4.2}/probability_flow/factors.py +0 -0
- {probability_flow-1.4.0 → probability_flow-1.4.2}/probability_flow/graph.py +0 -0
- {probability_flow-1.4.0 → probability_flow-1.4.2}/probability_flow/independent.py +0 -0
- {probability_flow-1.4.0 → probability_flow-1.4.2}/probability_flow/inference.py +0 -0
- {probability_flow-1.4.0 → probability_flow-1.4.2}/probability_flow/junction.py +0 -0
- {probability_flow-1.4.0 → probability_flow-1.4.2}/probability_flow/noisy.py +0 -0
- {probability_flow-1.4.0 → probability_flow-1.4.2}/probability_flow/ordering.py +0 -0
- {probability_flow-1.4.0 → probability_flow-1.4.2}/probability_flow/py.typed +0 -0
- {probability_flow-1.4.0 → probability_flow-1.4.2}/probability_flow/query.py +0 -0
- {probability_flow-1.4.0 → probability_flow-1.4.2}/probability_flow/variable.py +0 -0
|
@@ -1,5 +1,56 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 1.4.2 (2026-10-12)
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **`edges[].description`**, first-class, mirroring `variables[].description`. It
|
|
8
|
+
is content about the relation rather than about either end: why this parent
|
|
9
|
+
bears on this child, how strongly, in words. It crosses into the graph
|
|
10
|
+
`reveal.induce` builds, while an edge's `metadata` does not, and that split is
|
|
11
|
+
the reason it is a field rather than a key inside `metadata`: the reveal game
|
|
12
|
+
strips `metadata` as its ground-truth guard, so decoration kept there is
|
|
13
|
+
removed exactly when it is wanted.
|
|
14
|
+
- `edges[].metadata`, the private container, completing the bargain the document
|
|
15
|
+
and variable levels already struck.
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- **An unknown key on an edge was accepted and silently dropped.** The document
|
|
20
|
+
and each variable were checked against a key set; edges were checked for an
|
|
21
|
+
unknown `kind` and never for unknown keys, and the writer rebuilt each edge
|
|
22
|
+
from a three-key literal. So an extra field survived a read, vanished on the
|
|
23
|
+
next write, and raised nothing. A generator shipping worded edge strengths lost
|
|
24
|
+
every one of them with no way to tell. Edges are now checked like the other two
|
|
25
|
+
levels, and an undecorated edge still emits exactly three keys, so existing
|
|
26
|
+
documents are unchanged.
|
|
27
|
+
|
|
28
|
+
- **The PyPI page's three sidebar links were dead.** `Homepage`, `Repository` and
|
|
29
|
+
`Issues` all pointed at the source repository, which is private, so every one
|
|
30
|
+
returned a 404 to anyone who found the package and clicked. That shipped with
|
|
31
|
+
0.5.0 and was live until it was noticed. They are removed rather than
|
|
32
|
+
redirected, because no link beats a dead link and there is no public issue
|
|
33
|
+
tracker to offer while the repository is private. The long description and the
|
|
34
|
+
author's email are the contact surface. A homepage goes back when there is a
|
|
35
|
+
documentation site to point it at.
|
|
36
|
+
- **The README had no install instruction**, which is the first thing the page it
|
|
37
|
+
renders on is for. It also opened on three sentences about this project's own
|
|
38
|
+
branch layout, which says nothing to somebody who has just installed the
|
|
39
|
+
package and cannot see the repository. It now opens with what the library does,
|
|
40
|
+
how to install it, and a note that versions below 1.0 are a different library.
|
|
41
|
+
- A refusal that names the missing edge when a gate source has no edge of its own.
|
|
42
|
+
Declaring a gate puts the source in the CPD's input list and does not draw its
|
|
43
|
+
edge, and the error used to state the two sets without saying which call closed
|
|
44
|
+
the gap.
|
|
45
|
+
|
|
46
|
+
### Note
|
|
47
|
+
|
|
48
|
+
Regenerating the shipped documents is part of a version bump, even a patch one.
|
|
49
|
+
The emitter stamps the current version into every document it writes, so the two
|
|
50
|
+
worked examples are rewritten whenever the version moves. Reading a shipped
|
|
51
|
+
document and writing it back is a textual no-op, and that property is worth the
|
|
52
|
+
churn, but it is churn.
|
|
53
|
+
|
|
3
54
|
## 1.4.0 (2026-10-12)
|
|
4
55
|
|
|
5
56
|
The first release of the 1.x line. **PyPI currently serves 0.5.0**, which is the
|
|
@@ -1,10 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: probability-flow
|
|
3
|
-
Version: 1.4.
|
|
3
|
+
Version: 1.4.2
|
|
4
4
|
Summary: Discrete causal graphs and exact inference.
|
|
5
|
-
Project-URL: Homepage, https://github.com/scalable-oversight-benchmarks/probability-flow
|
|
6
|
-
Project-URL: Repository, https://github.com/scalable-oversight-benchmarks/probability-flow
|
|
7
|
-
Project-URL: Issues, https://github.com/scalable-oversight-benchmarks/probability-flow/issues
|
|
8
5
|
Author-email: Alex Roman <dr.alexroman@gmail.com>
|
|
9
6
|
License-Expression: MIT
|
|
10
7
|
License-File: LICENSE
|
|
@@ -27,14 +24,24 @@ Description-Content-Type: text/markdown
|
|
|
27
24
|
|
|
28
25
|
# probability-flow
|
|
29
26
|
|
|
30
|
-
Discrete **causal graphs** and exact inference
|
|
27
|
+
Discrete **causal graphs** and exact inference, in pure Python with no
|
|
28
|
+
dependencies.
|
|
31
29
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
30
|
+
```bash
|
|
31
|
+
pip install probability-flow
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
You write an argument down as a graph of propositions. Each proposition says how
|
|
35
|
+
it depends on its parents. The library answers what any of them is worth, given
|
|
36
|
+
everything or given only part of it.
|
|
37
|
+
|
|
38
|
+
Nothing here is approximate. Three engines, all exact, and you do not normally
|
|
39
|
+
choose between them.
|
|
40
|
+
|
|
41
|
+
**Versions below 1.0 on the index are a different library** and share no code with
|
|
42
|
+
this one. If you installed `probability-flow` before 1.4.0, see `CHANGELOG.md`.
|
|
36
43
|
|
|
37
|
-
##
|
|
44
|
+
## Start here
|
|
38
45
|
|
|
39
46
|
Define a causal graph, observe some variables, and ask for a posterior.
|
|
40
47
|
|
|
@@ -1,13 +1,23 @@
|
|
|
1
1
|
# probability-flow
|
|
2
2
|
|
|
3
|
-
Discrete **causal graphs** and exact inference
|
|
3
|
+
Discrete **causal graphs** and exact inference, in pure Python with no
|
|
4
|
+
dependencies.
|
|
4
5
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
```bash
|
|
7
|
+
pip install probability-flow
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
You write an argument down as a graph of propositions. Each proposition says how
|
|
11
|
+
it depends on its parents. The library answers what any of them is worth, given
|
|
12
|
+
everything or given only part of it.
|
|
13
|
+
|
|
14
|
+
Nothing here is approximate. Three engines, all exact, and you do not normally
|
|
15
|
+
choose between them.
|
|
16
|
+
|
|
17
|
+
**Versions below 1.0 on the index are a different library** and share no code with
|
|
18
|
+
this one. If you installed `probability-flow` before 1.4.0, see `CHANGELOG.md`.
|
|
9
19
|
|
|
10
|
-
##
|
|
20
|
+
## Start here
|
|
11
21
|
|
|
12
22
|
Define a causal graph, observe some variables, and ask for a posterior.
|
|
13
23
|
|
|
@@ -48,16 +48,31 @@ from __future__ import annotations
|
|
|
48
48
|
|
|
49
49
|
|
|
50
50
|
class Edge:
|
|
51
|
-
"""A directed relation from `source` to `target`.
|
|
51
|
+
"""A directed relation from `source` to `target`.
|
|
52
|
+
|
|
53
|
+
`description` is decorator-written content about the RELATION rather than
|
|
54
|
+
about either end: why this parent bears on this child, how strongly, in
|
|
55
|
+
words. It mirrors `Variable.description` and is first-class for a reason that
|
|
56
|
+
is easy to get backwards. Decoration has to survive into the graph a reader
|
|
57
|
+
is shown, and the reveal game STRIPS `metadata` as its ground-truth guard,
|
|
58
|
+
because that is where an implicit prior and a generator's sampled world live.
|
|
59
|
+
So text put in `metadata` would be removed exactly when it is wanted, and
|
|
60
|
+
text put here is carried across.
|
|
61
|
+
|
|
62
|
+
`metadata` is the private container, ignored by this library and round-tripped
|
|
63
|
+
verbatim, the same bargain it strikes at the document and variable levels.
|
|
64
|
+
"""
|
|
52
65
|
|
|
53
|
-
__slots__ = ("source", "target")
|
|
66
|
+
__slots__ = ("source", "target", "description", "metadata")
|
|
54
67
|
|
|
55
68
|
#: Shown in errors and renderings; subclasses override.
|
|
56
69
|
kind = "edge"
|
|
57
70
|
|
|
58
|
-
def __init__(self, source, target):
|
|
71
|
+
def __init__(self, source, target, description=None):
|
|
59
72
|
self.source = source
|
|
60
73
|
self.target = target
|
|
74
|
+
self.description = description
|
|
75
|
+
self.metadata = {}
|
|
61
76
|
|
|
62
77
|
@property
|
|
63
78
|
def endpoints(self):
|
|
@@ -516,12 +516,24 @@ def _definitional_cpd(var, rng, spec):
|
|
|
516
516
|
over = list(var.parents)
|
|
517
517
|
leak = rng.uniform(*spec.definitional_leak_range)
|
|
518
518
|
|
|
519
|
+
# Sorted by name BEFORE drawing, because `definition_cpds` is a public
|
|
520
|
+
# parameter that accepts a set -- and a set of CLASSES is not stably ordered:
|
|
521
|
+
# class hashes derive from id(), so iteration order changes between processes.
|
|
522
|
+
# Measured on {And, Or, AtLeast, Exactly, AtMost}: four runs of
|
|
523
|
+
# `generate(seed=11)` produced FOUR DIFFERENT DOCUMENTS. That would silently
|
|
524
|
+
# invalidate the provenance argument that lets the sampled world stay out of
|
|
525
|
+
# the document, since same-seed-plus-pinned-generator would stop reproducing
|
|
526
|
+
# it.
|
|
527
|
+
#
|
|
528
|
+
# Sorting always, not only for sets, so every input form with the same
|
|
529
|
+
# families draws identically -- order should not matter to a uniform choice.
|
|
530
|
+
# It leaves the default alone, since "And" already sorts before "Or".
|
|
519
531
|
choices = spec.definition_cpds
|
|
532
|
+
families = sorted(choices, key=lambda f: f.__name__)
|
|
520
533
|
if isinstance(choices, dict):
|
|
521
|
-
families = list(choices)
|
|
522
534
|
family = rng.choices(families, weights=[choices[f] for f in families])[0]
|
|
523
535
|
else:
|
|
524
|
-
family = rng.choice(
|
|
536
|
+
family = rng.choice(families) # a bare sequence is uniform
|
|
525
537
|
|
|
526
538
|
if family in _COUNTED:
|
|
527
539
|
# AtLeast / Exactly / AtMost take a threshold; the others do not.
|
|
@@ -501,13 +501,37 @@ def _bind(cpd, child, parents):
|
|
|
501
501
|
actual = {id(p) for p in parents}
|
|
502
502
|
if declared != actual:
|
|
503
503
|
missing = [short(p.name) for p in parents if id(p) not in declared]
|
|
504
|
-
extra = [
|
|
504
|
+
extra = [p for p in cpd.parents if id(p) not in actual]
|
|
505
|
+
# THE ONE-LINE FIX, NAMED. A refusal that states the two sets leaves the
|
|
506
|
+
# reader to work out which call closes the gap, and a blind author
|
|
507
|
+
# reaching for a gate hits this first: declaring `gates=[...]` puts the
|
|
508
|
+
# source in the CPD's input list and does NOT draw its edge, because a
|
|
509
|
+
# CPD is parameters and an edge is structure. So the message says the
|
|
510
|
+
# call rather than only the discrepancy.
|
|
511
|
+
gate_sources = {id(g.source) for g in getattr(cpd, "gates", ())}
|
|
512
|
+
fix = ""
|
|
513
|
+
if extra:
|
|
514
|
+
# NAMED, NOT SPELLED AS CODE. A node's name is a proposition with
|
|
515
|
+
# spaces in it, so `A supports H.causes(H)` looks pasteable and is
|
|
516
|
+
# not. Say which edge is missing and let the reader write the call
|
|
517
|
+
# with whatever variables they are holding.
|
|
518
|
+
lines = [f" {short(p.name)!r} has to cause {short(child.name)!r}"
|
|
519
|
+
for p in extra]
|
|
520
|
+
why = ("A gate source is an input to the CPD and still needs its own "
|
|
521
|
+
"edge: declaring a gate sets the weight that edge takes when "
|
|
522
|
+
"the gate fires, and drawing the edge stays explicit, because "
|
|
523
|
+
"a CPD is parameters and an edge is structure."
|
|
524
|
+
if any(id(p) in gate_sources for p in extra) else
|
|
525
|
+
"An input the CPD names has to be an actual cause of the node.")
|
|
526
|
+
fix = ("\n" + why + " Draw the missing edge first, with "
|
|
527
|
+
"`causes()` or `add_edge()`:\n" + "\n".join(lines))
|
|
505
528
|
raise ValueError(
|
|
506
529
|
f"the {type(cpd).__name__} for {short(child.name)!r} is over "
|
|
507
530
|
f"[{', '.join(short(p.name) for p in cpd.parents)}] but its inputs "
|
|
508
531
|
f"are [{', '.join(short(p.name) for p in parents)}]"
|
|
509
532
|
+ (f"; missing {missing}" if missing else "")
|
|
510
|
-
+ (f"; not an input: {extra}" if extra else "")
|
|
533
|
+
+ (f"; not an input: {[short(p.name) for p in extra]}" if extra else "")
|
|
511
534
|
+ ". Every input edge must appear, and only those."
|
|
535
|
+
+ fix
|
|
512
536
|
)
|
|
513
537
|
return cpd.reorder(parents).for_child(child)
|
|
@@ -138,7 +138,9 @@ def induce(graph, revealed):
|
|
|
138
138
|
METADATA IS NOT COPIED, and that is a guard rather than an omission.
|
|
139
139
|
`metadata` is where `q` lives and also where a generator records what it
|
|
140
140
|
knows, so copying it would leak ground truth into the very artifact the judge
|
|
141
|
-
reasons over.
|
|
141
|
+
reasons over. An EDGE'S `description` IS copied, and the two facts are one
|
|
142
|
+
decision: decoration a reader is meant to see has to be first-class, because
|
|
143
|
+
anything kept in `metadata` is removed exactly when it is wanted.
|
|
142
144
|
|
|
143
145
|
DEPTH ONE. An unrevealed parent contributes its `q` and nothing else -- no
|
|
144
146
|
CPD, no parents of its own, no virtual evidence. Walking further up is the
|
|
@@ -193,7 +195,14 @@ def induce(graph, revealed):
|
|
|
193
195
|
# claim a node that is not there.
|
|
194
196
|
for e in graph.edges():
|
|
195
197
|
if id(e.source) in revealed_ids and id(e.target) in revealed_ids:
|
|
196
|
-
out.add_edge(mirror[id(e.source)], mirror[id(e.target)], type(e))
|
|
198
|
+
drawn = out.add_edge(mirror[id(e.source)], mirror[id(e.target)], type(e))
|
|
199
|
+
# THE DESCRIPTION CROSSES AND THE METADATA DOES NOT, and that split is
|
|
200
|
+
# the whole reason an edge's description is first-class rather than a
|
|
201
|
+
# key inside `metadata`. The text is what a reader is MEANT to see;
|
|
202
|
+
# `metadata` is where an implicit prior and a generator's sampled
|
|
203
|
+
# world live, so it is stripped. Decoration kept in `metadata` would
|
|
204
|
+
# be removed exactly when it was wanted.
|
|
205
|
+
drawn.description = e.description
|
|
197
206
|
|
|
198
207
|
# The CPDs, after the edges, because a CPD is validated against its node's
|
|
199
208
|
# parents and they have to exist first.
|
|
@@ -217,7 +226,8 @@ def induce(graph, revealed):
|
|
|
217
226
|
copy = Evidence(mirror[id(v)], ev.label, ev.name, states=ev.states,
|
|
218
227
|
id=ev.id, description=ev.description)
|
|
219
228
|
out._insert(copy)
|
|
220
|
-
out.add_edge(mirror[id(v)], copy, type(graph.edge(v, ev)))
|
|
229
|
+
rider = out.add_edge(mirror[id(v)], copy, type(graph.edge(v, ev)))
|
|
230
|
+
rider.description = graph.edge(v, ev).description
|
|
221
231
|
out._cpds[id(copy)] = graph._cpd(ev).partially_revealed(
|
|
222
232
|
[mirror[id(v)]], unrevealed_q={}, child=copy)
|
|
223
233
|
mirror[id(ev)] = copy
|
|
@@ -28,7 +28,7 @@ import json
|
|
|
28
28
|
from .variable import short
|
|
29
29
|
|
|
30
30
|
FORMAT = "probability-flow/causal-graph"
|
|
31
|
-
VERSION = "1.4.
|
|
31
|
+
VERSION = "1.4.2"
|
|
32
32
|
|
|
33
33
|
#: Every key a document may carry, at each level. An unknown key is REJECTED.
|
|
34
34
|
#:
|
|
@@ -39,9 +39,17 @@ VERSION = "1.4.0"
|
|
|
39
39
|
#: producer's output surfaces on the first read instead of silently dropping
|
|
40
40
|
#: whatever it was meant to say.
|
|
41
41
|
#:
|
|
42
|
-
#: `metadata` is the single exception, at
|
|
43
|
-
#:
|
|
44
|
-
#: to extend.
|
|
42
|
+
#: `metadata` is the single exception, at ALL THREE levels: the document, a
|
|
43
|
+
#: variable and an edge. An explicit container whose contents this library never
|
|
44
|
+
#: inspects. Everything else strict, one place to extend.
|
|
45
|
+
#:
|
|
46
|
+
#: THERE WERE ONLY TWO LEVELS HERE UNTIL AN EDGE ATE A COLLABORATOR'S WORK. Edges
|
|
47
|
+
#: were checked for an unknown KIND and never for unknown KEYS, and the writer
|
|
48
|
+
#: rebuilt each one from a three-key literal, so an extra key was accepted on read
|
|
49
|
+
#: and dropped on write with nothing raised. A generator shipping worded edge
|
|
50
|
+
#: strengths lost every one of them silently. The comment above this one used to
|
|
51
|
+
#: say "at both levels" and meant the document and a variable, which named the
|
|
52
|
+
#: missing third level without noticing it.
|
|
45
53
|
TOP_LEVEL_KEYS = frozenset({
|
|
46
54
|
"format", "version", "name", "hypothesis", "produced_by", "metadata",
|
|
47
55
|
"engine_config", "variables", "edges", "cpds",
|
|
@@ -50,6 +58,9 @@ VARIABLE_KEYS = frozenset({
|
|
|
50
58
|
"id", "name", "states", "description", "observed", "definitional",
|
|
51
59
|
"evidence_for", "label", "metadata",
|
|
52
60
|
})
|
|
61
|
+
EDGE_KEYS = frozenset({
|
|
62
|
+
"source", "target", "kind", "description", "metadata",
|
|
63
|
+
})
|
|
53
64
|
|
|
54
65
|
|
|
55
66
|
#: Engine settings this version knows how to validate. Unknown keys inside
|
|
@@ -133,6 +144,17 @@ def _validate_engine_config(config) -> None:
|
|
|
133
144
|
)
|
|
134
145
|
|
|
135
146
|
|
|
147
|
+
def _decorate(edge, spec) -> None:
|
|
148
|
+
"""Carry an edge's decoration off its document spec.
|
|
149
|
+
|
|
150
|
+
One function for both routes, because there are two: an ordinary edge is
|
|
151
|
+
created here, and an edge into a virtual-evidence node was already created by
|
|
152
|
+
`add_evidence`. Those were decorated in two places until one of them was not.
|
|
153
|
+
"""
|
|
154
|
+
edge.description = spec.get("description")
|
|
155
|
+
edge.metadata = _copy(spec.get("metadata", {}))
|
|
156
|
+
|
|
157
|
+
|
|
136
158
|
def _reject_unknown(spec, known, where) -> None:
|
|
137
159
|
unknown = sorted(set(spec) - known)
|
|
138
160
|
if unknown:
|
|
@@ -349,7 +371,11 @@ def to_json(graph, *, indent: int = 2) -> str:
|
|
|
349
371
|
"variables": variables,
|
|
350
372
|
"edges": [
|
|
351
373
|
{"source": node_ref(e.source), "target": node_ref(e.target),
|
|
352
|
-
"kind": e.kind
|
|
374
|
+
"kind": e.kind,
|
|
375
|
+
# Omitted when empty, so an undecorated edge still emits the same
|
|
376
|
+
# three keys it always did and existing documents are unchanged.
|
|
377
|
+
**({"description": e.description} if e.description else {}),
|
|
378
|
+
**({"metadata": _copy(e.metadata)} if e.metadata else {})}
|
|
353
379
|
for e in _canonical_edges(graph)
|
|
354
380
|
],
|
|
355
381
|
"cpds": [
|
|
@@ -448,6 +474,10 @@ def from_json(text: str):
|
|
|
448
474
|
# variables. Definition-ness is derived from the edges, so the edge list
|
|
449
475
|
# has to be consulted before any node is created.
|
|
450
476
|
edge_specs = doc.get("edges", [])
|
|
477
|
+
for i, spec in enumerate(edge_specs):
|
|
478
|
+
_reject_unknown(spec, EDGE_KEYS,
|
|
479
|
+
f"edge {spec.get('source')!r} -> {spec.get('target')!r}"
|
|
480
|
+
if isinstance(spec, dict) else f"edge at index {i}")
|
|
451
481
|
definitional = {
|
|
452
482
|
e["target"] for e in edge_specs if e.get("kind") == "constitutive"
|
|
453
483
|
}
|
|
@@ -546,8 +576,14 @@ def from_json(text: str):
|
|
|
546
576
|
f"two edges both say {e['source']!r} -> {e['target']!r}"
|
|
547
577
|
)
|
|
548
578
|
seen_into_evidence.add(tgt.id)
|
|
579
|
+
# THE EDGE ALREADY EXISTS: `add_evidence` drew it in the pass above,
|
|
580
|
+
# so this one is not created here and used to `continue` before any
|
|
581
|
+
# decoration was read. On the file this was first tested against,
|
|
582
|
+
# that lost 19 of 43 edge descriptions, one per evidence channel,
|
|
583
|
+
# and lost them silently because the other 24 came through.
|
|
584
|
+
_decorate(g.edge(src, tgt), e)
|
|
549
585
|
continue
|
|
550
|
-
g.add_edge(src, tgt, kinds[kind])
|
|
586
|
+
_decorate(g.add_edge(src, tgt, kinds[kind]), e)
|
|
551
587
|
|
|
552
588
|
# Restore the document's own variable order. Evidence nodes go through
|
|
553
589
|
# add_evidence in a second pass, so they arrive last no matter where the
|
|
@@ -164,6 +164,16 @@ def _condition(cpd, assignment) -> str:
|
|
|
164
164
|
INK = "#1b1f24"
|
|
165
165
|
CAUSAL = "#46525f"
|
|
166
166
|
CONSTIT = "#44505c" # see `constitutive`: the DOUBLE LINE carries it now
|
|
167
|
+
#: Green is the one hue the drawing does NOT otherwise spend. The ramp runs
|
|
168
|
+
#: red-grey-blue and the gate is purple, so green says "a different kind of
|
|
169
|
+
#: edge" without colliding with anything that means a quantity. It was ruled
|
|
170
|
+
#: out for WEIGHTS because green reads as "good" rather than "true"; a
|
|
171
|
+
#: constitutive edge carries no valence to misread, so that objection does not
|
|
172
|
+
#: reach here.
|
|
173
|
+
CONSTIT_GREEN = "#1b7f4f"
|
|
174
|
+
#: Round dots, deliberately not the gate's long dash: both say "this line is
|
|
175
|
+
#: special", and they have to say different specials.
|
|
176
|
+
CONSTIT_DOTS = (0, (0.1, 1.9))
|
|
167
177
|
GATE = "#6b3fa0" # purple: the weight ramp took green->red
|
|
168
178
|
TRUE_C = "#3b4cc0" # coolwarm_r at +1: blue is "true", with less
|
|
169
179
|
FALSE_C = "#b40426" # valence attached to it than green had
|
|
@@ -303,7 +313,13 @@ class Style:
|
|
|
303
313
|
# -- 0.68 on the generator notebook's graph --
|
|
304
314
|
# and 37% of that figure was empty.
|
|
305
315
|
fontsize: float = 9.0
|
|
306
|
-
|
|
316
|
+
#: The SHAPE of a constitutive edge: how many strands and whether they are
|
|
317
|
+
#: dotted. "double" reads as "=", which is identity rather than influence.
|
|
318
|
+
constitutive: str = "double" # double | triple | dotted | double-dotted
|
|
319
|
+
# | arrow | diamond | brace
|
|
320
|
+
#: Its HUE, as a separate channel so the two can be combined: "neutral"
|
|
321
|
+
#: (slate, the shape carries everything) or "green", or any colour name.
|
|
322
|
+
constitutive_colour: str = "neutral"
|
|
307
323
|
gate_glyph: str = "switch" # "switch" (pole and two throws) | "valve" | "tee" | "tap"
|
|
308
324
|
switch_angle: float = 15.0 # degrees between the two throws
|
|
309
325
|
switch_arm: float = 0.35 # pole to contact, in INCHES -- a fixed size
|
|
@@ -879,6 +895,20 @@ def _clip(p, q, half):
|
|
|
879
895
|
return (p[0] + dx * t, p[1] + dy * t)
|
|
880
896
|
|
|
881
897
|
|
|
898
|
+
#: shape -> (strand offsets, dotted). One table so the drawing and the legend
|
|
899
|
+
#: cannot disagree about what a mode looks like, which they did once.
|
|
900
|
+
_STRANDS = {"double": ((-1, 1), False),
|
|
901
|
+
"triple": ((-1, 0, 1), False),
|
|
902
|
+
"dotted": ((0,), True),
|
|
903
|
+
"double-dotted": ((-1, 1), True)}
|
|
904
|
+
|
|
905
|
+
|
|
906
|
+
def _constit_colour(style):
|
|
907
|
+
"""The hue for a constitutive edge, kept separate from its shape."""
|
|
908
|
+
return {"neutral": CONSTIT, "green": CONSTIT_GREEN}.get(
|
|
909
|
+
style.constitutive_colour, style.constitutive_colour)
|
|
910
|
+
|
|
911
|
+
|
|
882
912
|
def _offset(path, d):
|
|
883
913
|
"""Shift a polyline sideways by `d` inches, along its own normal.
|
|
884
914
|
|
|
@@ -1480,7 +1510,8 @@ _ARGUMENTS = {
|
|
|
1480
1510
|
"ramp_floor": "ramp_floor", # 1.0 for the colormap untouched
|
|
1481
1511
|
"colours": "bar_colors",
|
|
1482
1512
|
"outcomes": "badge_outcome",
|
|
1483
|
-
"constitutive": "constitutive", #
|
|
1513
|
+
"constitutive": "constitutive", # shape: see Style.constitutive
|
|
1514
|
+
"constitutive_colour": "constitutive_colour", # "neutral" | "green" | any
|
|
1484
1515
|
}
|
|
1485
1516
|
_VALUES = {("idle_badge", "weights"): "number"}
|
|
1486
1517
|
|
|
@@ -1691,20 +1722,23 @@ def render(graph, observations=None, *, styles=None, style=None, ax=None,
|
|
|
1691
1722
|
segs.append((path[i], path[i + 1]))
|
|
1692
1723
|
a, b = path[-2], path[-1]
|
|
1693
1724
|
if kind == "constitutive":
|
|
1694
|
-
col = GHOST if ghosted else
|
|
1725
|
+
col = GHOST if ghosted else _constit_colour(style)
|
|
1695
1726
|
if style.constitutive == "arrow":
|
|
1696
1727
|
_draw_path(ax, path, col, 1.25, al)
|
|
1697
|
-
elif style.constitutive in
|
|
1698
|
-
#
|
|
1728
|
+
elif style.constitutive in _STRANDS:
|
|
1729
|
+
# FINE PARALLEL LINES, reading as "=" -- identity rather than
|
|
1699
1730
|
# influence, which is what constitution is. Blue used to carry
|
|
1700
|
-
# this and the weight ramp has taken blue, so the
|
|
1701
|
-
# it now
|
|
1702
|
-
|
|
1731
|
+
# this and the weight ramp has taken blue, so the shape carries
|
|
1732
|
+
# it now; hue is a second channel on top rather than a
|
|
1733
|
+
# replacement, because the two are independent questions.
|
|
1734
|
+
offs, dots = _STRANDS[style.constitutive]
|
|
1703
1735
|
for s in offs:
|
|
1704
1736
|
strand = _offset(path, 0.030 * s)
|
|
1705
1737
|
ax.plot([p[0] for p in strand], [p[1] for p in strand],
|
|
1706
|
-
color=col, linewidth=0.85
|
|
1707
|
-
solid_capstyle="round"
|
|
1738
|
+
color=col, linewidth=0.85 if len(offs) > 1 else 1.2,
|
|
1739
|
+
alpha=al, zorder=1.5, solid_capstyle="round",
|
|
1740
|
+
linestyle=CONSTIT_DOTS if dots else "solid",
|
|
1741
|
+
dash_capstyle="round")
|
|
1708
1742
|
ax.add_patch(FancyArrowPatch(path[-2], b, arrowstyle="-|>",
|
|
1709
1743
|
mutation_scale=10, color=col,
|
|
1710
1744
|
linewidth=0.9, alpha=al,
|
|
@@ -2106,17 +2140,20 @@ def _legend(ax, x, y, style, rows):
|
|
|
2106
2140
|
def _constit(cx, cy):
|
|
2107
2141
|
if style.constitutive == "arrow":
|
|
2108
2142
|
ax.add_patch(FancyArrowPatch((cx, cy), (cx + 0.42, cy), arrowstyle="-|>",
|
|
2109
|
-
mutation_scale=10, color=
|
|
2143
|
+
mutation_scale=10, color=_constit_colour(style),
|
|
2110
2144
|
linewidth=1.25, shrinkA=0, shrinkB=0))
|
|
2111
2145
|
return
|
|
2112
|
-
|
|
2113
|
-
|
|
2146
|
+
ccol = _constit_colour(style)
|
|
2147
|
+
if style.constitutive in _STRANDS:
|
|
2148
|
+
offs, dots = _STRANDS[style.constitutive]
|
|
2114
2149
|
for s in offs:
|
|
2115
2150
|
ax.plot([cx, cx + 0.34], [cy + 0.030 * s, cy + 0.030 * s],
|
|
2116
|
-
color=
|
|
2151
|
+
color=ccol, linewidth=0.85 if len(offs) > 1 else 1.2,
|
|
2152
|
+
linestyle=CONSTIT_DOTS if dots else "solid",
|
|
2153
|
+
dash_capstyle="round")
|
|
2117
2154
|
ax.add_patch(FancyArrowPatch((cx + 0.34, cy), (cx + 0.44, cy),
|
|
2118
2155
|
arrowstyle="-|>", mutation_scale=10,
|
|
2119
|
-
color=
|
|
2156
|
+
color=ccol, linewidth=0.9,
|
|
2120
2157
|
shrinkA=0, shrinkB=0))
|
|
2121
2158
|
return
|
|
2122
2159
|
ax.plot([cx, cx + 0.42], [cy, cy], color=CONSTIT, linewidth=1.15)
|
|
@@ -8,7 +8,7 @@ build-backend = "hatchling.build"
|
|
|
8
8
|
|
|
9
9
|
[project]
|
|
10
10
|
name = "probability-flow"
|
|
11
|
-
version = "1.4.
|
|
11
|
+
version = "1.4.2"
|
|
12
12
|
description = "Discrete causal graphs and exact inference."
|
|
13
13
|
readme = "README.md"
|
|
14
14
|
requires-python = ">=3.11"
|
|
@@ -41,10 +41,19 @@ dependencies = [] # the core is pure Python
|
|
|
41
41
|
[project.optional-dependencies]
|
|
42
42
|
viz = ["matplotlib"] # optional: render(); to_text() needs nothing
|
|
43
43
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
44
|
+
# NO PROJECT URLS, DELIBERATELY, AND THIS IS NOT AN OVERSIGHT.
|
|
45
|
+
#
|
|
46
|
+
# PyPI renders these in the page sidebar. All three used to point at the source
|
|
47
|
+
# repository, which is PRIVATE, so every one of them returned a genuine 404 to
|
|
48
|
+
# anybody who found the package and clicked. That shipped with 0.5.0 and was live
|
|
49
|
+
# until it was noticed. No link is better than a dead link, and there is no public
|
|
50
|
+
# issue tracker to offer while the repository is private, so the long description
|
|
51
|
+
# and `author_email` are the whole contact surface.
|
|
52
|
+
#
|
|
53
|
+
# Restore a Homepage here once the documentation site is published, and only
|
|
54
|
+
# then. A link to a page that does not exist yet is the same defect again.
|
|
55
|
+
# [project.urls]
|
|
56
|
+
# Homepage = "https://probabilityflow.com/..."
|
|
48
57
|
|
|
49
58
|
[tool.hatch.build.targets.wheel]
|
|
50
59
|
packages = ["probability_flow"]
|
|
@@ -61,6 +70,16 @@ include = ["/probability_flow", "/README.md", "/CHANGELOG.md", "/LICENSE",
|
|
|
61
70
|
[tool.pytest.ini_options]
|
|
62
71
|
pythonpath = ["."]
|
|
63
72
|
testpaths = ["tests"]
|
|
73
|
+
# `release` is DESELECTED BY DEFAULT, and that is the point rather than a
|
|
74
|
+
# convenience. A check that the tag matches HEAD is true only at the moment of
|
|
75
|
+
# release: between tagging and uploading, every ordinary commit moves HEAD past
|
|
76
|
+
# the tag, so running it in the normal suite would force a version bump for each
|
|
77
|
+
# commit, and a version bump regenerates every shipped document. It would have
|
|
78
|
+
# made the repository unworkable for exactly the window it was written to protect.
|
|
79
|
+
addopts = "-m 'not release'"
|
|
80
|
+
markers = [
|
|
81
|
+
"release: run at release time only. `uv run pytest -m release` is the gate.",
|
|
82
|
+
]
|
|
64
83
|
|
|
65
84
|
[tool.ruff]
|
|
66
85
|
line-length = 100
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|