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.
Files changed (28) hide show
  1. {probability_flow-1.4.1 → probability_flow-1.4.2}/CHANGELOG.md +22 -1
  2. {probability_flow-1.4.1 → probability_flow-1.4.2}/PKG-INFO +1 -1
  3. {probability_flow-1.4.1 → probability_flow-1.4.2}/probability_flow/__init__.py +1 -1
  4. {probability_flow-1.4.1 → probability_flow-1.4.2}/probability_flow/edges.py +18 -3
  5. {probability_flow-1.4.1 → probability_flow-1.4.2}/probability_flow/reveal.py +13 -3
  6. {probability_flow-1.4.1 → probability_flow-1.4.2}/probability_flow/serialize.py +42 -6
  7. {probability_flow-1.4.1 → probability_flow-1.4.2}/pyproject.toml +11 -1
  8. {probability_flow-1.4.1 → probability_flow-1.4.2}/.gitignore +0 -0
  9. {probability_flow-1.4.1 → probability_flow-1.4.2}/LICENSE +0 -0
  10. {probability_flow-1.4.1 → probability_flow-1.4.2}/README.md +0 -0
  11. {probability_flow-1.4.1 → probability_flow-1.4.2}/probability_flow/cpd.py +0 -0
  12. {probability_flow-1.4.1 → probability_flow-1.4.2}/probability_flow/decompose.py +0 -0
  13. {probability_flow-1.4.1 → probability_flow-1.4.2}/probability_flow/eliminate.py +0 -0
  14. {probability_flow-1.4.1 → probability_flow-1.4.2}/probability_flow/engines.py +0 -0
  15. {probability_flow-1.4.1 → probability_flow-1.4.2}/probability_flow/evidence.py +0 -0
  16. {probability_flow-1.4.1 → probability_flow-1.4.2}/probability_flow/factors.py +0 -0
  17. {probability_flow-1.4.1 → probability_flow-1.4.2}/probability_flow/generate.py +0 -0
  18. {probability_flow-1.4.1 → probability_flow-1.4.2}/probability_flow/graph.py +0 -0
  19. {probability_flow-1.4.1 → probability_flow-1.4.2}/probability_flow/independent.py +0 -0
  20. {probability_flow-1.4.1 → probability_flow-1.4.2}/probability_flow/inference.py +0 -0
  21. {probability_flow-1.4.1 → probability_flow-1.4.2}/probability_flow/junction.py +0 -0
  22. {probability_flow-1.4.1 → probability_flow-1.4.2}/probability_flow/logical.py +0 -0
  23. {probability_flow-1.4.1 → probability_flow-1.4.2}/probability_flow/noisy.py +0 -0
  24. {probability_flow-1.4.1 → probability_flow-1.4.2}/probability_flow/ordering.py +0 -0
  25. {probability_flow-1.4.1 → probability_flow-1.4.2}/probability_flow/py.typed +0 -0
  26. {probability_flow-1.4.1 → probability_flow-1.4.2}/probability_flow/query.py +0 -0
  27. {probability_flow-1.4.1 → probability_flow-1.4.2}/probability_flow/variable.py +0 -0
  28. {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
- ## Unreleased
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
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: probability-flow
3
- Version: 1.4.1
3
+ Version: 1.4.2
4
4
  Summary: Discrete causal graphs and exact inference.
5
5
  Author-email: Alex Roman <dr.alexroman@gmail.com>
6
6
  License-Expression: MIT
@@ -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.1"
41
+ __version__ = "1.4.2"
42
42
 
43
43
  __all__ = [
44
44
  "__version__",
@@ -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.1"
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 both levels: an explicit container
43
- #: whose contents this library never inspects. Everything else strict, one place
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.1"
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