probability-flow 1.4.1__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.1 → probability_flow-1.4.2}/CHANGELOG.md +22 -1
- {probability_flow-1.4.1 → probability_flow-1.4.2}/PKG-INFO +1 -1
- {probability_flow-1.4.1 → probability_flow-1.4.2}/probability_flow/__init__.py +1 -1
- {probability_flow-1.4.1 → probability_flow-1.4.2}/probability_flow/edges.py +18 -3
- {probability_flow-1.4.1 → probability_flow-1.4.2}/probability_flow/reveal.py +13 -3
- {probability_flow-1.4.1 → probability_flow-1.4.2}/probability_flow/serialize.py +42 -6
- {probability_flow-1.4.1 → probability_flow-1.4.2}/pyproject.toml +11 -1
- {probability_flow-1.4.1 → probability_flow-1.4.2}/.gitignore +0 -0
- {probability_flow-1.4.1 → probability_flow-1.4.2}/LICENSE +0 -0
- {probability_flow-1.4.1 → probability_flow-1.4.2}/README.md +0 -0
- {probability_flow-1.4.1 → probability_flow-1.4.2}/probability_flow/cpd.py +0 -0
- {probability_flow-1.4.1 → probability_flow-1.4.2}/probability_flow/decompose.py +0 -0
- {probability_flow-1.4.1 → probability_flow-1.4.2}/probability_flow/eliminate.py +0 -0
- {probability_flow-1.4.1 → probability_flow-1.4.2}/probability_flow/engines.py +0 -0
- {probability_flow-1.4.1 → probability_flow-1.4.2}/probability_flow/evidence.py +0 -0
- {probability_flow-1.4.1 → probability_flow-1.4.2}/probability_flow/factors.py +0 -0
- {probability_flow-1.4.1 → probability_flow-1.4.2}/probability_flow/generate.py +0 -0
- {probability_flow-1.4.1 → probability_flow-1.4.2}/probability_flow/graph.py +0 -0
- {probability_flow-1.4.1 → probability_flow-1.4.2}/probability_flow/independent.py +0 -0
- {probability_flow-1.4.1 → probability_flow-1.4.2}/probability_flow/inference.py +0 -0
- {probability_flow-1.4.1 → probability_flow-1.4.2}/probability_flow/junction.py +0 -0
- {probability_flow-1.4.1 → probability_flow-1.4.2}/probability_flow/logical.py +0 -0
- {probability_flow-1.4.1 → probability_flow-1.4.2}/probability_flow/noisy.py +0 -0
- {probability_flow-1.4.1 → probability_flow-1.4.2}/probability_flow/ordering.py +0 -0
- {probability_flow-1.4.1 → probability_flow-1.4.2}/probability_flow/py.typed +0 -0
- {probability_flow-1.4.1 → probability_flow-1.4.2}/probability_flow/query.py +0 -0
- {probability_flow-1.4.1 → probability_flow-1.4.2}/probability_flow/variable.py +0 -0
- {probability_flow-1.4.1 → probability_flow-1.4.2}/probability_flow/viz.py +0 -0
|
@@ -1,9 +1,30 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
##
|
|
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.
|
|
4
16
|
|
|
5
17
|
### Fixed
|
|
6
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
|
+
|
|
7
28
|
- **The PyPI page's three sidebar links were dead.** `Homepage`, `Repository` and
|
|
8
29
|
`Issues` all pointed at the source repository, which is private, so every one
|
|
9
30
|
returned a 404 to anyone who found the package and clicked. That shipped with
|
|
@@ -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):
|
|
@@ -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.1"
|
|
|
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
|
|
@@ -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"
|
|
@@ -70,6 +70,16 @@ include = ["/probability_flow", "/README.md", "/CHANGELOG.md", "/LICENSE",
|
|
|
70
70
|
[tool.pytest.ini_options]
|
|
71
71
|
pythonpath = ["."]
|
|
72
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
|
+
]
|
|
73
83
|
|
|
74
84
|
[tool.ruff]
|
|
75
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|