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.
Files changed (28) hide show
  1. {probability_flow-1.4.0 → probability_flow-1.4.2}/CHANGELOG.md +51 -0
  2. {probability_flow-1.4.0 → probability_flow-1.4.2}/PKG-INFO +17 -10
  3. {probability_flow-1.4.0 → probability_flow-1.4.2}/README.md +16 -6
  4. {probability_flow-1.4.0 → probability_flow-1.4.2}/probability_flow/__init__.py +1 -1
  5. {probability_flow-1.4.0 → probability_flow-1.4.2}/probability_flow/edges.py +18 -3
  6. {probability_flow-1.4.0 → probability_flow-1.4.2}/probability_flow/generate.py +14 -2
  7. {probability_flow-1.4.0 → probability_flow-1.4.2}/probability_flow/logical.py +26 -2
  8. {probability_flow-1.4.0 → probability_flow-1.4.2}/probability_flow/reveal.py +13 -3
  9. {probability_flow-1.4.0 → probability_flow-1.4.2}/probability_flow/serialize.py +42 -6
  10. {probability_flow-1.4.0 → probability_flow-1.4.2}/probability_flow/viz.py +52 -15
  11. {probability_flow-1.4.0 → probability_flow-1.4.2}/pyproject.toml +24 -5
  12. {probability_flow-1.4.0 → probability_flow-1.4.2}/.gitignore +0 -0
  13. {probability_flow-1.4.0 → probability_flow-1.4.2}/LICENSE +0 -0
  14. {probability_flow-1.4.0 → probability_flow-1.4.2}/probability_flow/cpd.py +0 -0
  15. {probability_flow-1.4.0 → probability_flow-1.4.2}/probability_flow/decompose.py +0 -0
  16. {probability_flow-1.4.0 → probability_flow-1.4.2}/probability_flow/eliminate.py +0 -0
  17. {probability_flow-1.4.0 → probability_flow-1.4.2}/probability_flow/engines.py +0 -0
  18. {probability_flow-1.4.0 → probability_flow-1.4.2}/probability_flow/evidence.py +0 -0
  19. {probability_flow-1.4.0 → probability_flow-1.4.2}/probability_flow/factors.py +0 -0
  20. {probability_flow-1.4.0 → probability_flow-1.4.2}/probability_flow/graph.py +0 -0
  21. {probability_flow-1.4.0 → probability_flow-1.4.2}/probability_flow/independent.py +0 -0
  22. {probability_flow-1.4.0 → probability_flow-1.4.2}/probability_flow/inference.py +0 -0
  23. {probability_flow-1.4.0 → probability_flow-1.4.2}/probability_flow/junction.py +0 -0
  24. {probability_flow-1.4.0 → probability_flow-1.4.2}/probability_flow/noisy.py +0 -0
  25. {probability_flow-1.4.0 → probability_flow-1.4.2}/probability_flow/ordering.py +0 -0
  26. {probability_flow-1.4.0 → probability_flow-1.4.2}/probability_flow/py.typed +0 -0
  27. {probability_flow-1.4.0 → probability_flow-1.4.2}/probability_flow/query.py +0 -0
  28. {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.0
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
- This is the `v1` rewrite. It shares a repository with the 0.x line but no code:
33
- the ASPIC argumentation layer, the argument generator, the belief-propagation
34
- solvers and the metrics package are all gone from this branch. The 0.x tree is
35
- still on `main` if you want to refer back to it.
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
- ## The milestone
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
- This is the `v1` rewrite. It shares a repository with the 0.x line but no code:
6
- the ASPIC argumentation layer, the argument generator, the belief-propagation
7
- solvers and the metrics package are all gone from this branch. The 0.x tree is
8
- still on `main` if you want to refer back to it.
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
- ## The milestone
20
+ ## Start here
11
21
 
12
22
  Define a causal graph, observe some variables, and ask for a posterior.
13
23
 
@@ -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.0"
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):
@@ -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(list(choices)) # a bare sequence is uniform
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 = [short(p.name) for p in cpd.parents if id(p) not in actual]
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.0"
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 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
@@ -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
- constitutive: str = "double" # "double" (=) | "triple" | "arrow" | "diamond" | "brace"
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", # "double" | "triple" | "arrow" | ...
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 CONSTIT
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 ("double", "triple"):
1698
- # TWO FINE LINES, reading as "=" -- identity rather than
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 SHAPE carries
1701
- # it now and no hue is spent.
1702
- offs = ((-1, 1) if style.constitutive == "double" else (-1, 0, 1))
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, alpha=al, zorder=1.5,
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=CONSTIT,
2143
+ mutation_scale=10, color=_constit_colour(style),
2110
2144
  linewidth=1.25, shrinkA=0, shrinkB=0))
2111
2145
  return
2112
- if style.constitutive in ("double", "triple"):
2113
- offs = (-1, 1) if style.constitutive == "double" else (-1, 0, 1)
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=CONSTIT, linewidth=0.85)
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=CONSTIT, linewidth=0.9,
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.0"
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
- [project.urls]
45
- Homepage = "https://github.com/scalable-oversight-benchmarks/probability-flow"
46
- Repository = "https://github.com/scalable-oversight-benchmarks/probability-flow"
47
- Issues = "https://github.com/scalable-oversight-benchmarks/probability-flow/issues"
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