probability-flow 1.4.2__tar.gz → 1.4.3__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.2 → probability_flow-1.4.3}/.gitignore +7 -4
  2. {probability_flow-1.4.2 → probability_flow-1.4.3}/CHANGELOG.md +113 -1
  3. {probability_flow-1.4.2 → probability_flow-1.4.3}/PKG-INFO +9 -5
  4. {probability_flow-1.4.2 → probability_flow-1.4.3}/README.md +8 -4
  5. {probability_flow-1.4.2 → probability_flow-1.4.3}/probability_flow/__init__.py +3 -2
  6. {probability_flow-1.4.2 → probability_flow-1.4.3}/probability_flow/cpd.py +132 -8
  7. probability_flow-1.4.2/probability_flow/generate.py → probability_flow-1.4.3/probability_flow/generation.py +24 -16
  8. {probability_flow-1.4.2 → probability_flow-1.4.3}/probability_flow/graph.py +153 -0
  9. {probability_flow-1.4.2 → probability_flow-1.4.3}/probability_flow/independent.py +95 -4
  10. {probability_flow-1.4.2 → probability_flow-1.4.3}/probability_flow/logical.py +75 -5
  11. {probability_flow-1.4.2 → probability_flow-1.4.3}/probability_flow/noisy.py +34 -0
  12. {probability_flow-1.4.2 → probability_flow-1.4.3}/probability_flow/reveal.py +87 -38
  13. {probability_flow-1.4.2 → probability_flow-1.4.3}/probability_flow/serialize.py +20 -5
  14. {probability_flow-1.4.2 → probability_flow-1.4.3}/probability_flow/variable.py +22 -4
  15. {probability_flow-1.4.2 → probability_flow-1.4.3}/probability_flow/viz.py +109 -30
  16. {probability_flow-1.4.2 → probability_flow-1.4.3}/pyproject.toml +9 -3
  17. {probability_flow-1.4.2 → probability_flow-1.4.3}/LICENSE +0 -0
  18. {probability_flow-1.4.2 → probability_flow-1.4.3}/probability_flow/decompose.py +0 -0
  19. {probability_flow-1.4.2 → probability_flow-1.4.3}/probability_flow/edges.py +0 -0
  20. {probability_flow-1.4.2 → probability_flow-1.4.3}/probability_flow/eliminate.py +0 -0
  21. {probability_flow-1.4.2 → probability_flow-1.4.3}/probability_flow/engines.py +0 -0
  22. {probability_flow-1.4.2 → probability_flow-1.4.3}/probability_flow/evidence.py +0 -0
  23. {probability_flow-1.4.2 → probability_flow-1.4.3}/probability_flow/factors.py +0 -0
  24. {probability_flow-1.4.2 → probability_flow-1.4.3}/probability_flow/inference.py +0 -0
  25. {probability_flow-1.4.2 → probability_flow-1.4.3}/probability_flow/junction.py +0 -0
  26. {probability_flow-1.4.2 → probability_flow-1.4.3}/probability_flow/ordering.py +0 -0
  27. {probability_flow-1.4.2 → probability_flow-1.4.3}/probability_flow/py.typed +0 -0
  28. {probability_flow-1.4.2 → probability_flow-1.4.3}/probability_flow/query.py +0 -0
@@ -16,16 +16,19 @@ _previews/
16
16
  # kept ON DISK and out of the repo -- removal from disk is Alex's call.
17
17
  exam.py
18
18
  exam2.ipynb
19
- figures/
19
+ # ROOT-ANCHORED ON PURPOSE. An unanchored `figures/` matches a directory of that
20
+ # name at ANY depth, and it silently swallowed `docs/figures/`, which is
21
+ # COMMITTED output the site embeds. The generator for those lives in
22
+ # `illustrations/`.
23
+ /figures/
20
24
 
21
25
  # Written by notebook/tutorial.ipynb when it runs, as the save-and-load
22
26
  # section requires an actual file on disk. An artifact of executing the
23
27
  # notebook, not an input to it.
24
28
  notebook/sidewalk.json
25
29
 
26
- # Staged into docs/ at site-build time by hooks/stage_notebooks.py. The one copy
27
- # that is not generated lives in notebook/, beside the builder that makes it.
28
- docs/tutorial.ipynb
30
+ # Staged into docs/ at site-build time by hooks/stage_examples.py. The one copy
31
+ # that is not generated lives in examples/, beside the script that writes it.
29
32
  docs/examples/
30
33
 
31
34
  # mkdocs output
@@ -1,5 +1,117 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.4.3
4
+
5
+ ### Added
6
+
7
+ - **Structural editing.** `graph.remove_edge(cause, effect)` and
8
+ `graph.remove_variable(variable)`, plus `cpd.set_weight(parent, bits)` for
9
+ changing what an edge carries. Until now the library could only grow a graph,
10
+ so every consumer who needed to drop a node edited the serialised form and
11
+ read it back.
12
+
13
+ Removal **asks the CPD to drop the parent** rather than rewriting its
14
+ serialised form, because only the family knows its own shape. A caller
15
+ patching the document has to know that `weights` is a dict and `gates` is a
16
+ list and that a logical CPD keeps its inputs under `constituents` with no
17
+ `parents` key at all. One measured consequence of getting that wrong: an
18
+ orphaned gate re-creates the edge it names on the way back in, so the file
19
+ said two parents, the graph had three, every posterior was unchanged, and
20
+ nothing reported a problem.
21
+
22
+ Ids are never renumbered or reused. A gate goes when either end of it does,
23
+ with a warning. A family that cannot lose a parent without inventing
24
+ something has its CPD cleared, which makes `check()` and every query fail
25
+ until a new one is written.
26
+
27
+ - `IndependentEvidence.weights` is now read-only. Assigning into it changed
28
+ nothing and reported nothing, because `_log_w` is what every query reads.
29
+ Use `set_weight`.
30
+
31
+ ### Added
32
+
33
+ - **The documentation site is published**, at
34
+ <http://probabilityflow.com/docs/>, one directory per release with a version
35
+ selector and a banner on any version that is not the newest. It replaces a
36
+ hand-written page that described a version of the library which no longer
37
+ existed. Until Netlify issues a certificate for the domain, that link is
38
+ http and not https.
39
+ - **Figures.** The pages that describe a graph now draw one. `illustrations/build.py`
40
+ writes them from the library into `docs/figures/`, so a figure cannot depict a
41
+ graph the library would not produce, and three tests keep the embeds and the
42
+ files in step.
43
+ - **An API reference**, at `docs/api/` and on the documentation site. Grouped by
44
+ what you are doing rather than by which module a name lives in, with a section
45
+ for the things you receive rather than construct, because ten exported names
46
+ are returned to you and never called by anybody.
47
+
48
+ ### Changed
49
+
50
+ - **The API reference is five pages** rather than one, indented under one
51
+ section: graphs and CPDs, asking questions, saving and loading, drawing,
52
+ generating, and the reveal game.
53
+ - **The reveal game has its own page**, and the formalism page no longer
54
+ mentions it. It is an application built on the library rather than part of
55
+ the formalism, and the benchmark's own vocabulary has been taken off the site
56
+ entirely.
57
+ - **The formalism page says edges come in three kinds.** Gating used to be
58
+ described as "not a third kind" on the grounds that the schema writes it
59
+ `causal`. That is a fact about the schema. A gate is an edge with its own
60
+ rules, and the page now says so and draws one.
61
+ - **The tutorial is on Colab** rather than rendered as a static page, so a
62
+ reader can run it. `mkdocs-jupyter` is no longer a documentation dependency.
63
+
64
+ ### Fixed
65
+
66
+ - **A fully revealed CPD came back as a table, losing its structure.** Nothing is
67
+ hidden at a full reveal, so the restriction is the identity and flattening is
68
+ pure loss: it preserves every probability exactly while destroying the operator,
69
+ the constituent count and the per-input activations. On a real decorated graph
70
+ that turned three `logical` CPDs into tables with every posterior still correct.
71
+ A definitional node has no per-edge strength, so the operator and the count are
72
+ the whole content of a sentence about a constitutive edge, and a consumer that
73
+ lost them had to reach back into the source document for structure. `logical`
74
+ and the noisy families now stay in family when nothing is hidden.
75
+
76
+ A **partial** reveal still returns a table, and that is not an oversight: `and`
77
+ over three constituents with two shown and the third at `q = 0.5` is `0.5` on
78
+ the row where both hold, where an `and` over the two would be `1.0`. Tagging
79
+ that `logical` would misdescribe the numbers.
80
+
81
+ ## Unreleased
82
+
83
+ ### Fixed
84
+
85
+ - **An evidence node's `name` is free text.** The reader used to refuse any name
86
+ that was not exactly `"{parent}:{label}"`, so a document naming its
87
+ virtual-evidence nodes after the observation was rejected outright. The parent
88
+ is carried structurally in `evidence_for`, which is what consumers use, and
89
+ nothing parses the suffix back out of the name, so the rule enforced a format
90
+ on the one field a reader reads in order to encode something the document
91
+ already states. `add_evidence` still generates the suffixed form: what the
92
+ library produces and what the reader accepts are separate questions. The
93
+ integrity checks are untouched, so `evidence_for` must still name a real
94
+ variable, an evidence node must still have a CPD, and it is still at most one
95
+ per variable and never a constitutive target.
96
+
97
+ ### Changed
98
+
99
+ - **`reveal.induce` now preserves `metadata`, on nodes and on edges.** It used to
100
+ strip it. The guarantee that nothing in `metadata` reached a reader has been
101
+ given up deliberately, because decoration lives there: several wordings of one
102
+ edge at different levels of precision, with one promoted into `description`. A
103
+ strip forced a consumer to choose a wording before inducing, and carrying it across
104
+ lets them induce once and render the same subgraph more than one way.
105
+
106
+ **The boundary moves rather than disappearing.** A reader never saw the document
107
+ in the first place, only text produced from it, so what protects them is what a
108
+ verbaliser emits. That is a weaker promise in a more honest place.
109
+
110
+ Nothing inside `metadata` is validated, which is what makes it useful and what
111
+ makes it the wrong place for anything a reader must not see. No number changes:
112
+ an implicit prior is read off the source graph, so carrying it into the result
113
+ cannot move an answer, and this is asserted rather than assumed.
114
+
3
115
  ## 1.4.2 (2026-10-12)
4
116
 
5
117
  ### Added
@@ -17,7 +129,7 @@
17
129
  ### Fixed
18
130
 
19
131
  - **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
132
+ and each variable were checked against a key set, while edges were checked for an
21
133
  unknown `kind` and never for unknown keys, and the writer rebuilt each edge
22
134
  from a three-key literal. So an extra field survived a read, vanished on the
23
135
  next write, and raised nothing. A generator shipping worded edge strengths lost
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: probability-flow
3
- Version: 1.4.2
3
+ Version: 1.4.3
4
4
  Summary: Discrete causal graphs and exact inference.
5
5
  Author-email: Alex Roman <dr.alexroman@gmail.com>
6
6
  License-Expression: MIT
@@ -459,10 +459,14 @@ keys of `weights` plus any gate source, and a gate source joins the CPD's input
459
459
  list automatically at **weight `0.0` bits**, which is what "contributes no evidence
460
460
  of its own, gating only" means.
461
461
 
462
- **You still have to draw the edge yourself.** `gate_source.causes(child)` is
463
- required, and omitting it is a `ValueError` naming the two sets. The automatic part
464
- is the CPD's input list, not the graph's edges, and the sentence above used to
465
- read as though it were both.
462
+ **You do not have to draw the edge.** Declaring the gate is enough: naming the
463
+ source in a `Gate` is a statement that it bears on this node, so the edge it
464
+ implies is drawn for you. Drawing it by hand first also works and is not
465
+ duplicated.
466
+
467
+ The edge is there for the arithmetic rather than for the picture. A gate source
468
+ carrying no weight of its own gets no arrow in a rendering at all. It gets a
469
+ switch wired into the edge it modulates.
466
470
 
467
471
  **These are not `logical` with a leak.** A `logical` is deterministic over a
468
472
  predicate on a count, and these are probabilistic over per-input activations.
@@ -435,10 +435,14 @@ keys of `weights` plus any gate source, and a gate source joins the CPD's input
435
435
  list automatically at **weight `0.0` bits**, which is what "contributes no evidence
436
436
  of its own, gating only" means.
437
437
 
438
- **You still have to draw the edge yourself.** `gate_source.causes(child)` is
439
- required, and omitting it is a `ValueError` naming the two sets. The automatic part
440
- is the CPD's input list, not the graph's edges, and the sentence above used to
441
- read as though it were both.
438
+ **You do not have to draw the edge.** Declaring the gate is enough: naming the
439
+ source in a `Gate` is a statement that it bears on this node, so the edge it
440
+ implies is drawn for you. Drawing it by hand first also works and is not
441
+ duplicated.
442
+
443
+ The edge is there for the arithmetic rather than for the picture. A gate source
444
+ carrying no weight of its own gets no arrow in a rendering at all. It gets a
445
+ switch wired into the edge it modulates.
442
446
 
443
447
  **These are not `logical` with a leak.** A `logical` is deterministic over a
444
448
  predicate on a count, and these are probabilistic over per-input activations.
@@ -26,7 +26,7 @@ graph: being "the hypothesis" is a fact about the question, not about the causal
26
26
  structure.
27
27
  """
28
28
 
29
- from . import eliminate, engines, junction, logical, noisy, reveal
29
+ from . import eliminate, engines, generation, junction, logical, noisy, reveal
30
30
  from .cpd import CPD, CPT, Table
31
31
  from .edges import Causal, Constitutive, Edge
32
32
  from .evidence import Evidence
@@ -38,7 +38,7 @@ from .serialize import FORMAT, VERSION, from_json, to_json
38
38
  from .variable import BINARY, Definition, Variable
39
39
  from .viz import render, to_text
40
40
 
41
- __version__ = "1.4.2"
41
+ __version__ = "1.4.3"
42
42
 
43
43
  __all__ = [
44
44
  "__version__",
@@ -54,6 +54,7 @@ __all__ = [
54
54
  "Table",
55
55
  "logical",
56
56
  "noisy",
57
+ "generation",
57
58
  "eliminate",
58
59
  "junction",
59
60
  "engines",
@@ -127,6 +127,51 @@ class CPD(ABC):
127
127
  "what it says; it has to be written again"
128
128
  )
129
129
 
130
+ #: THE MIRROR OF `extensible`, and the same test read backwards: whether a
131
+ #: parent can be DROPPED from this CPD without inventing anything. An
132
+ #: additive form just loses a term. A table cannot: its rows are indexed by
133
+ #: the full assignment, so dropping a parent means choosing which slice of
134
+ #: the table survives, and nothing in the table says which.
135
+ #:
136
+ #: Where there is no answer the author clearly meant, the CPD is cleared
137
+ #: with a warning rather than silently reinterpreted, exactly as on the way
138
+ #: in. The downstream guard is the same one: a variable with no CPD fails
139
+ #: `check()` and every query, loudly.
140
+ reducible = False
141
+
142
+ def reduction_note(self, dropped) -> "str | None":
143
+ """What to warn about after dropping `dropped`, or None."""
144
+ return None
145
+
146
+ def reduction_reason(self) -> str:
147
+ """Why this CPD cannot lose a parent, for the warning."""
148
+ return (f"it is indexed by all of "
149
+ f"({', '.join(short(p.name) for p in self.parents) or 'nothing'}) "
150
+ "and nothing in it says which slice should survive")
151
+
152
+ def reduced_to(self, parents) -> "CPD":
153
+ """This CPD over `parents`, which drops one from its current set."""
154
+ raise ValueError(
155
+ f"{type(self).__name__} cannot drop a parent without changing what "
156
+ "it says; it has to be written again"
157
+ )
158
+
159
+ def _in_family_over(self, parents, order):
160
+ """This CPD over `parents`, in its own family, or None if it cannot be.
161
+
162
+ `order[i]` is the index in `self.parents` of the parent now at position
163
+ `i`, derived by STABLE ID rather than by identity, because the caller's
164
+ parents may be a different graph's objects with the same names. That is
165
+ why `reorder` cannot be used here: it matches by identity and raises
166
+ across graphs.
167
+
168
+ None means there is no in-family form and the caller should fall back to
169
+ a table. The base returns None; a family that can respin itself says so
170
+ by overriding. `Table` does not need to, because a table restricted to
171
+ all of its parents is a table either way.
172
+ """
173
+ return None
174
+
130
175
  def reorder(self, parents) -> "CPD":
131
176
  """Return this CPD with its conditioning set written in `parents` order.
132
177
 
@@ -219,6 +264,21 @@ class CPD(ABC):
219
264
  "count would silently restrict to the wrong states. See #22."
220
265
  )
221
266
 
267
+ # NOTHING HIDDEN MEANS NOTHING TO AVERAGE, so the restriction is the
268
+ # IDENTITY and flattening here is pure loss. It preserves every
269
+ # probability exactly while destroying the structure, which is the
270
+ # failure the format document already names about the emitter: "nothing
271
+ # checking numbers would notice". The same principle binds this method,
272
+ # and nobody connected the two until a consumer needed the operator.
273
+ #
274
+ # A full reveal is not an edge case, it is the state a reveal game ENDS
275
+ # in, and a consumer that needs a node's operator to put it into English
276
+ # needs it most there. Verified identical to the original to 1e-15.
277
+ if not hidden:
278
+ kept = self._in_family_over(list(shown), at)
279
+ if kept is not None:
280
+ return kept.for_child(target)
281
+
222
282
  rows = {}
223
283
  for assignment in _assignments(list(shown)):
224
284
  total = [0.0] * len(target.states)
@@ -226,14 +286,14 @@ class CPD(ABC):
226
286
  weight = 1.0
227
287
  for i, index in zip(hidden, combo):
228
288
  parent = self.parents[i]
229
- q = unrevealed_q[_stable_key(parent)]
230
- if len(parent.states) == 2:
231
- weight *= q if index == 1 else (1.0 - q)
232
- else:
233
- # The model prescribes q for a BINARY parent only; a
234
- # multi-state one is taken as uniform, which is a stand-in
235
- # and is recorded as an open gap in reveal/latex/main.tex.
236
- weight *= 1.0 / len(parent.states)
289
+ # ONE PATH FOR EVERY ARITY. A multi-state parent used to be
290
+ # taken as uniform here, which was a stand-in rather than a
291
+ # derivation. Its implicit prior is now a distribution over
292
+ # its states, exactly as a prior over a three-state variable
293
+ # is three numbers, and the binary case is the two-entry
294
+ # instance of the same thing.
295
+ weight *= as_implicit_distribution(
296
+ unrevealed_q[_stable_key(parent)], parent)[index]
237
297
  if weight == 0.0:
238
298
  continue
239
299
  full = [0] * len(self.parents)
@@ -262,6 +322,70 @@ class CPD(ABC):
262
322
  _keys_are_indices=True)
263
323
 
264
324
 
325
+ def as_implicit_distribution(value, parent):
326
+ """An implicit prior as a DISTRIBUTION over `parent`'s states.
327
+
328
+ `value` may be either form, because both are useful and one is ambiguous:
329
+
330
+ a number P(the parent is not in its FIRST state), which is what a
331
+ binary `q` has always meant, so `0.3` gives `(0.7, 0.3)`.
332
+ REFUSED for a multi-state parent, because there is no
333
+ honest way to spread one number over three states.
334
+ a sequence one entry per state, in `parent.states` order, summing to
335
+ one. This is the general form and the only one a
336
+ multi-state parent can use.
337
+
338
+ The vector is what a non-binary parent needs, exactly as a prior over a
339
+ three-state variable needs three numbers. Before it existed, a multi-state
340
+ unrevealed parent was taken as UNIFORM, which is a stand-in rather than a
341
+ derivation and was recorded as an open gap.
342
+
343
+ Validated here rather than where it is stored, because `metadata` is a
344
+ container this library does not inspect and this is the first thing that
345
+ looks at the value.
346
+ """
347
+ n = len(parent.states)
348
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
349
+ if n != 2:
350
+ raise ValueError(
351
+ f"{parent.name!r} has {n} states, so its implicit prior needs "
352
+ f"{n} numbers, one per state in {parent.states!r}, and a single "
353
+ f"number was given ({value!r}). One number means P(not the first "
354
+ "state), which says nothing about how the rest divides."
355
+ )
356
+ q = float(value)
357
+ if not 0.0 <= q <= 1.0:
358
+ raise ValueError(
359
+ f"{parent.name!r} has an implicit prior of {value!r}, which is "
360
+ "not a probability."
361
+ )
362
+ return (1.0 - q, q)
363
+
364
+ try:
365
+ dist = tuple(float(x) for x in value)
366
+ except (TypeError, ValueError):
367
+ raise ValueError(
368
+ f"{parent.name!r} has an implicit prior of {value!r}, which is "
369
+ "neither a number nor a sequence of them."
370
+ ) from None
371
+ if len(dist) != n:
372
+ raise ValueError(
373
+ f"{parent.name!r} has {n} states {parent.states!r} and its implicit "
374
+ f"prior gives {len(dist)} numbers. It needs one per state, in that "
375
+ "order, exactly as a prior does."
376
+ )
377
+ if any(x < 0.0 for x in dist):
378
+ raise ValueError(
379
+ f"{parent.name!r} has a negative entry in its implicit prior: {dist}"
380
+ )
381
+ if abs(sum(dist) - 1.0) > 1e-9:
382
+ raise ValueError(
383
+ f"{parent.name!r} has an implicit prior summing to {sum(dist)!r} "
384
+ "rather than 1. It is a distribution over the parent's states."
385
+ )
386
+ return dist
387
+
388
+
265
389
  def _stable_key(variable):
266
390
  """How a parent is matched ACROSS graphs: its stable id, falling back to
267
391
  object identity for a variable that is not in a graph yet."""
@@ -30,6 +30,7 @@ from .edges import Causal, Constitutive
30
30
  from .evidence import Evidence
31
31
  from .graph import CausalGraph
32
32
  from .independent import Gate, IndependentEvidence
33
+ from .variable import Definition, Variable
33
34
 
34
35
  __all__ = ["StructureSpec", "GraphSpec", "generate", "generate_batch",
35
36
  "check_reveal_legal", "GenerationError"]
@@ -104,8 +105,9 @@ class GraphSpec:
104
105
 
105
106
  #: Probability that an UNDETERMINED node with at least two parents is
106
107
  #: definitional. Only the undetermined are rolled: see `_assign_types`.
107
- #: (Alex has not settled this name.)
108
- definitional_prob: float = 0.0
108
+ #: (Alex has not settled this name; he asked on 2026-10-13 that it not be
109
+ #: renamed before the Friday demo, so the churn does not land near it.)
110
+ definitional_prob: float = 0.25
109
111
  #: Definitional nodes get their own CPD parameters, not the ordinary ones.
110
112
  definitional_leak_range: tuple[float, float] = (0.0, 0.02)
111
113
 
@@ -662,7 +664,7 @@ def _generator_fingerprint():
662
664
  """
663
665
  from . import __version__
664
666
 
665
- fingerprint = {"generator": "probability_flow.generate",
667
+ fingerprint = {"generator": "probability_flow.generation",
666
668
  "generator_version": __version__}
667
669
  try:
668
670
  import subprocess
@@ -702,9 +704,14 @@ def generate(spec: GraphSpec | None = None, seed: int = 0) -> CausalGraph:
702
704
  # assembly: now, and only now, can a Variable be created with its type
703
705
  graph = CausalGraph(f"generated/{seed}")
704
706
  var: dict = {}
705
- for name, tag in plan.items():
706
- var[name] = (graph.add_definition(name) if name in definitional
707
- else graph.add_variable(name))
707
+ # ids are n-based by convention (Alex, 2026-10-13). The library would assign
708
+ # `v0, v1, ...` on insert, so the id has to be supplied BEFORE the variable
709
+ # joins the graph -- afterwards it is read-only, deliberately, since the
710
+ # document format references it.
711
+ for index, (name, tag) in enumerate(plan.items()):
712
+ node = (Definition(name, id=f"n{index}") if name in definitional
713
+ else Variable(name, id=f"n{index}"))
714
+ var[name] = graph.add(node)
708
715
  hypothesis = var[spec.hypothesis_name]
709
716
  for source, target in edges:
710
717
  kind = Constitutive if target in definitional else Causal
@@ -749,7 +756,11 @@ def generate(spec: GraphSpec | None = None, seed: int = 0) -> CausalGraph:
749
756
  if node is hypothesis or isinstance(node, Evidence):
750
757
  continue
751
758
  p_if, p_if_not = _evidence_pair(rng, spec)
752
- observation = node.add_evidence("observation", p_if=p_if, p_if_not=p_if_not)
759
+ # `<claim id>-ve`, so one id convention runs through the whole document
760
+ # (Alex, 2026-10-13). The NAME is left as `add_evidence` builds it; only
761
+ # the id is in scope here.
762
+ observation = node.add_evidence("observation", p_if=p_if, p_if_not=p_if_not,
763
+ id=f"{node.id}-ve")
753
764
  # Virtual evidence DOES get a cluster tag, unlike implicit_prior: it is
754
765
  # revealed with the variable it is about, so it belongs to the same
755
766
  # cluster, and a consumer walking every variable should not meet a hole.
@@ -846,7 +857,7 @@ def check_reveal_legal(graph: CausalGraph) -> None:
846
857
  if leaked:
847
858
  raise GenerationError(
848
859
  f"the ground-truth world reached the document on {leaked}. It used to "
849
- "live in node metadata and survive only because the reveal game strips "
860
+ "live in node metadata, and the reveal game no longer strips "
850
861
  "metadata -- one undocumented line in another repo. It is now simply "
851
862
  "not written, so this is a generator bug rather than a leak"
852
863
  )
@@ -885,14 +896,11 @@ def leaks_ground_truth(doc: dict) -> list[str]:
885
896
  more, so this returns empty for anything this module produces. It stays
886
897
  because the day somebody puts it back is the day this fires.
887
898
 
888
- Two things in its old wording are no longer true and are worth correcting
889
- rather than deleting. It said the world "lives in per-node metadata", which
890
- stopped being so when the write was removed. And it said the strip that kept
891
- it from the judge was "one undocumented line in another repo", which was
892
- accurate while the revealed-subgraph builder lived in `eval`. That builder is
893
- retired: `reveal.induce` strips metadata here, says why in its docstring, and
894
- `test_induce_STRIPS_METADATA_because_it_is_where_ground_truth_lives` holds it
895
- in place.
899
+ THE STRIP IT USED TO RELY ON IS GONE. `reveal.induce` carried metadata from
900
+ 1.4.3 onward, so "it survives only because the reveal game strips metadata" is
901
+ no longer true and this check is the whole protection rather than the second
902
+ layer. That is the reason it stays: the write was removed, and this is what
903
+ fires if it returns.
896
904
  """
897
905
  return [v.get("name", v.get("id")) for v in doc.get("variables", [])
898
906
  if "ground_truth" in (v.get("metadata") or {})]
@@ -220,6 +220,139 @@ class CausalGraph:
220
220
  stacklevel=4,
221
221
  )
222
222
 
223
+ def remove_edge(self, cause: Variable, effect: Variable) -> None:
224
+ """Remove the edge `cause -> effect`, and the effect CPD's term for it.
225
+
226
+ THE CPD IS ASKED TO DROP THE PARENT, not rewritten from outside. Only
227
+ the family knows its own shape: an additive CPD loses a term and any
228
+ gate that touched either end, a conjunction loses a constituent, a noisy
229
+ family loses a factor, and a table cannot lose anything without somebody
230
+ choosing which slice survives, so it is cleared with a warning.
231
+
232
+ That division is the whole point. A caller editing the SERIALISED form
233
+ has to know that `weights` is a dict and `gates` is a list and that a
234
+ logical CPD keeps its inputs under `constituents` with no `parents` key
235
+ at all. Getting one of those wrong writes a document that still loads,
236
+ because the orphaned gate re-creates the edge it names, and the removal
237
+ then silently did not happen.
238
+ """
239
+ self._require(cause)
240
+ self._require(effect)
241
+ from .evidence import Evidence
242
+ if isinstance(effect, Evidence):
243
+ raise ValueError(
244
+ f"{short(effect.name)!r} is a virtual-evidence node and "
245
+ f"{short(cause.name)!r} is what it is evidence ABOUT, so this "
246
+ "edge is what makes it evidence at all. Remove the evidence "
247
+ f"node itself with remove_variable({short(effect.name)!r}), or "
248
+ f"remove {short(cause.name)!r} and it goes too."
249
+ )
250
+ if self._edges.get((id(cause), id(effect))) is None:
251
+ raise ValueError(
252
+ f"there is no edge {short(cause.name)!r} -> {short(effect.name)!r} "
253
+ "to remove. Edges are directed, so check the direction: "
254
+ f"{short(effect.name)!r} -> {short(cause.name)!r} "
255
+ + ("DOES exist." if self._edges.get((id(effect), id(cause)))
256
+ else "does not either.")
257
+ )
258
+ self._drop_edge(cause, effect)
259
+
260
+ def _drop_edge(self, cause: Variable, effect: Variable) -> None:
261
+ """Remove an edge without the Evidence guard.
262
+
263
+ Module-internal, and split out for the same reason `_insert` is split
264
+ from `add`: `remove_variable` has to take an evidence node's own edge
265
+ with it, and the public guard exists to stop a CALLER doing that on its
266
+ own and leaving evidence about nothing behind.
267
+ """
268
+ del self._edges[(id(cause), id(effect))]
269
+ self._parent_list[id(effect)] = [
270
+ p for p in self._parent_list[id(effect)] if p is not cause]
271
+ self._child_list[id(cause)] = [
272
+ c for c in self._child_list[id(cause)] if c is not effect]
273
+ self._reconcile_cpd_dropped(effect, cause)
274
+
275
+ def remove_variable(self, variable: Variable) -> None:
276
+ """Remove a variable, its virtual evidence, and every edge touching either.
277
+
278
+ IDS ARE NEVER REUSED OR RENUMBERED. The ids in a document are the keys
279
+ every external reference uses, so a removal that renumbered would be
280
+ schema-breaking rather than merely surprising. Removing `n3` leaves
281
+ `n4` as `n4`, and a later `add_variable` takes the next id that has
282
+ never been used rather than the hole.
283
+
284
+ Virtual evidence goes with the variable it is about. It models the
285
+ uncertainty of OBSERVING that variable, so keeping it afterwards would
286
+ leave a node that is evidence about nothing.
287
+ """
288
+ self._require(variable)
289
+ if variable is self._hypothesis:
290
+ raise ValueError(
291
+ f"{short(variable.name)!r} is this graph's hypothesis, so "
292
+ "removing it would leave the graph with nothing to be about. "
293
+ "Set `graph.hypothesis` to another variable first if that is "
294
+ "what you mean."
295
+ )
296
+ from .evidence import Evidence
297
+ doomed = [variable]
298
+ if not isinstance(variable, Evidence) and variable.has_evidence:
299
+ doomed.append(variable.evidence)
300
+ for victim in doomed:
301
+ # Copy both lists first: removing an edge mutates them.
302
+ for parent in list(self._parent_list[id(victim)]):
303
+ self._drop_edge(parent, victim)
304
+ for child in list(self._child_list[id(victim)]):
305
+ self._drop_edge(victim, child)
306
+ for victim in doomed:
307
+ self._variables.remove(victim)
308
+ del self._by_name[victim.name]
309
+ del self._by_id[victim.id]
310
+ self._cpds.pop(id(victim), None)
311
+ self._parent_list.pop(id(victim), None)
312
+ self._child_list.pop(id(victim), None)
313
+ self._position.pop(id(victim), None)
314
+ victim._graph = None
315
+ # POSITIONS ARE AN INTERNAL ORDERING, NOT THE SCHEMA IDS. Parent lists
316
+ # are sorted by position so that the same graph always serialises one
317
+ # way, so the index has to stay dense after a removal. `variable.id` is
318
+ # untouched by this.
319
+ for i, remaining in enumerate(self._variables):
320
+ self._position[id(remaining)] = i
321
+
322
+ def _reconcile_cpd_dropped(self, effect: Variable, dropped: Variable) -> None:
323
+ """The effect just lost a parent. Reduce its CPD, or clear it.
324
+
325
+ The mirror of `_reconcile_cpd`, and deliberately the same shape: reduce
326
+ where the reduction is the only thing the author could have meant, clear
327
+ where it is not. Clearing is safe for the same reason it is on the way
328
+ in, which is that a variable with no CPD fails `check()` and every query
329
+ rather than answering with something nobody wrote.
330
+ """
331
+ cpd = self._cpds.get(id(effect))
332
+ if cpd is None:
333
+ return
334
+ parents = tuple(self._parent_list[id(effect)])
335
+ if cpd.reducible:
336
+ self._cpds[id(effect)] = cpd.reduced_to(parents)
337
+ note = cpd.reduction_note(dropped)
338
+ if note:
339
+ warnings.warn(
340
+ f"{short(effect.name)!r} lost the parent "
341
+ f"{short(dropped.name)!r}, and its {type(cpd).__name__} "
342
+ f"{note}",
343
+ stacklevel=4,
344
+ )
345
+ return
346
+ del self._cpds[id(effect)]
347
+ warnings.warn(
348
+ f"{short(effect.name)!r} lost the parent {short(dropped.name)!r}, "
349
+ f"and its {type(cpd).__name__} was CLEARED: "
350
+ f"{cpd.reduction_reason()}. Set a new CPD for {short(effect.name)!r} "
351
+ f"over ({', '.join(short(p.name) for p in parents) or 'nothing'}); "
352
+ "until you do, every query on this graph will fail.",
353
+ stacklevel=4,
354
+ )
355
+
223
356
  @staticmethod
224
357
  def _check_kind(edge: Edge, effect: Variable) -> None:
225
358
  """The constitutive invariant, enforced as each edge arrives.
@@ -303,6 +436,26 @@ class CausalGraph:
303
436
  - a ready-made `CPD` (a compact parameterised form, say).
304
437
  """
305
438
  self._require(variable)
439
+
440
+ # A GATE NAMES ITS SOURCE, so the edge it implies is drawn here rather
441
+ # than left to the author.
442
+ #
443
+ # THIS IS NOT A PARAMETERS CALL INVENTING STRUCTURE, which is the
444
+ # objection it looks like. `Gate(u, modulates=a)` states that u bears on
445
+ # this node; the edge is that statement's mechanical consequence, not a
446
+ # relationship nobody mentioned. Structure before parameters still holds
447
+ # for everything a CPD does NOT name.
448
+ #
449
+ # Declaring a gate and stopping used to raise, and the refusal told the
450
+ # author to draw an edge the renderer then HIDES: a gate that carries no
451
+ # weight of its own gets no arrow to the child, it gets a switch wired
452
+ # into the edge it modulates. So the author was being asked to write a
453
+ # line whose only visible effect was to stop an error.
454
+ for gate in getattr(cpd_or_rows, "gates", ()) or ():
455
+ if not any(p is gate.source
456
+ for p in self._parent_list[id(variable)]):
457
+ self.add_edge(gate.source, variable)
458
+
306
459
  parents = tuple(self._parent_list[id(variable)])
307
460
  if isinstance(cpd_or_rows, CPT):
308
461
  cpd = cpd_or_rows.build(variable, parents)