probability-flow 1.4.2__tar.gz → 1.4.4__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.4}/.gitignore +7 -4
  2. {probability_flow-1.4.2 → probability_flow-1.4.4}/CHANGELOG.md +139 -1
  3. {probability_flow-1.4.2 → probability_flow-1.4.4}/PKG-INFO +9 -5
  4. {probability_flow-1.4.2 → probability_flow-1.4.4}/README.md +8 -4
  5. {probability_flow-1.4.2 → probability_flow-1.4.4}/probability_flow/__init__.py +3 -2
  6. {probability_flow-1.4.2 → probability_flow-1.4.4}/probability_flow/cpd.py +132 -8
  7. probability_flow-1.4.2/probability_flow/generate.py → probability_flow-1.4.4/probability_flow/generation.py +24 -16
  8. {probability_flow-1.4.2 → probability_flow-1.4.4}/probability_flow/graph.py +162 -1
  9. {probability_flow-1.4.2 → probability_flow-1.4.4}/probability_flow/independent.py +159 -9
  10. {probability_flow-1.4.2 → probability_flow-1.4.4}/probability_flow/logical.py +75 -5
  11. {probability_flow-1.4.2 → probability_flow-1.4.4}/probability_flow/noisy.py +34 -0
  12. {probability_flow-1.4.2 → probability_flow-1.4.4}/probability_flow/reveal.py +87 -38
  13. {probability_flow-1.4.2 → probability_flow-1.4.4}/probability_flow/serialize.py +20 -5
  14. {probability_flow-1.4.2 → probability_flow-1.4.4}/probability_flow/variable.py +52 -4
  15. {probability_flow-1.4.2 → probability_flow-1.4.4}/probability_flow/viz.py +109 -30
  16. {probability_flow-1.4.2 → probability_flow-1.4.4}/pyproject.toml +9 -3
  17. {probability_flow-1.4.2 → probability_flow-1.4.4}/LICENSE +0 -0
  18. {probability_flow-1.4.2 → probability_flow-1.4.4}/probability_flow/decompose.py +0 -0
  19. {probability_flow-1.4.2 → probability_flow-1.4.4}/probability_flow/edges.py +0 -0
  20. {probability_flow-1.4.2 → probability_flow-1.4.4}/probability_flow/eliminate.py +0 -0
  21. {probability_flow-1.4.2 → probability_flow-1.4.4}/probability_flow/engines.py +0 -0
  22. {probability_flow-1.4.2 → probability_flow-1.4.4}/probability_flow/evidence.py +0 -0
  23. {probability_flow-1.4.2 → probability_flow-1.4.4}/probability_flow/factors.py +0 -0
  24. {probability_flow-1.4.2 → probability_flow-1.4.4}/probability_flow/inference.py +0 -0
  25. {probability_flow-1.4.2 → probability_flow-1.4.4}/probability_flow/junction.py +0 -0
  26. {probability_flow-1.4.2 → probability_flow-1.4.4}/probability_flow/ordering.py +0 -0
  27. {probability_flow-1.4.2 → probability_flow-1.4.4}/probability_flow/py.typed +0 -0
  28. {probability_flow-1.4.2 → probability_flow-1.4.4}/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,143 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.4.4
4
+
5
+ ### Added
6
+
7
+ - **`variable.set_baseline(probability)`**, for changing P(a node) with every
8
+ parent absent after the CPD is written.
9
+
10
+ It takes a PROBABILITY, which is the unit `set_prior` takes and the unit the
11
+ file format stores. There is no relative form and no `bits=` flag: a
12
+ `shift_baseline(0.2)` beside a `set_baseline(0.2)` is two calls that read the
13
+ same and mean different things, since `0.2` is a plausible probability and a
14
+ plausible number of bits.
15
+
16
+ ### Fixed
17
+
18
+ - **A removed id could be handed out again.** Removing a variable from the
19
+ middle of a graph left a hole that the next auto-assigned id stepped past,
20
+ but removing the LAST variable simply freed its id, and the next variable
21
+ took it back. Anything still referring to the removed node then pointed at a
22
+ different proposition, silently. Ids are retired on removal now.
23
+
24
+ - **Three more silent no-ops in the additive family.** `cpd.baseline`,
25
+ `cpd.gates` and `Gate.weight_when_gated` all accepted an assignment, changed
26
+ nothing and reported nothing, because the value every query reads is a
27
+ derived copy. `baseline` was the worst: it read back as the number you had
28
+ assigned, so the object agreed with you while going on answering the old
29
+ question. All three raise now.
30
+
31
+ ## 1.4.3
32
+
33
+ ### Added
34
+
35
+ - **Structural editing.** `graph.remove_edge(cause, effect)` and
36
+ `graph.remove_variable(variable)`, plus `cpd.set_weight(parent, bits)` for
37
+ changing what an edge carries. Until now the library could only grow a graph,
38
+ so every consumer who needed to drop a node edited the serialised form and
39
+ read it back.
40
+
41
+ Removal **asks the CPD to drop the parent** rather than rewriting its
42
+ serialised form, because only the family knows its own shape. A caller
43
+ patching the document has to know that `weights` is a dict and `gates` is a
44
+ list and that a logical CPD keeps its inputs under `constituents` with no
45
+ `parents` key at all. One measured consequence of getting that wrong: an
46
+ orphaned gate re-creates the edge it names on the way back in, so the file
47
+ said two parents, the graph had three, every posterior was unchanged, and
48
+ nothing reported a problem.
49
+
50
+ Ids are never renumbered or reused. A gate goes when either end of it does,
51
+ with a warning. A family that cannot lose a parent without inventing
52
+ something has its CPD cleared, which makes `check()` and every query fail
53
+ until a new one is written.
54
+
55
+ - `IndependentEvidence.weights` is now read-only. Assigning into it changed
56
+ nothing and reported nothing, because `_log_w` is what every query reads.
57
+ Use `set_weight`.
58
+
59
+ ### Added
60
+
61
+ - **The documentation site is published**, at
62
+ <http://probabilityflow.com/docs/>, one directory per release with a version
63
+ selector and a banner on any version that is not the newest. It replaces a
64
+ hand-written page that described a version of the library which no longer
65
+ existed. Until Netlify issues a certificate for the domain, that link is
66
+ http and not https.
67
+ - **Figures.** The pages that describe a graph now draw one. `illustrations/build.py`
68
+ writes them from the library into `docs/figures/`, so a figure cannot depict a
69
+ graph the library would not produce, and three tests keep the embeds and the
70
+ files in step.
71
+ - **An API reference**, at `docs/api/` and on the documentation site. Grouped by
72
+ what you are doing rather than by which module a name lives in, with a section
73
+ for the things you receive rather than construct, because ten exported names
74
+ are returned to you and never called by anybody.
75
+
76
+ ### Changed
77
+
78
+ - **The API reference is five pages** rather than one, indented under one
79
+ section: graphs and CPDs, asking questions, saving and loading, drawing,
80
+ generating, and the reveal game.
81
+ - **The reveal game has its own page**, and the formalism page no longer
82
+ mentions it. It is an application built on the library rather than part of
83
+ the formalism, and the benchmark's own vocabulary has been taken off the site
84
+ entirely.
85
+ - **The formalism page says edges come in three kinds.** Gating used to be
86
+ described as "not a third kind" on the grounds that the schema writes it
87
+ `causal`. That is a fact about the schema. A gate is an edge with its own
88
+ rules, and the page now says so and draws one.
89
+ - **The tutorial is on Colab** rather than rendered as a static page, so a
90
+ reader can run it. `mkdocs-jupyter` is no longer a documentation dependency.
91
+
92
+ ### Fixed
93
+
94
+ - **A fully revealed CPD came back as a table, losing its structure.** Nothing is
95
+ hidden at a full reveal, so the restriction is the identity and flattening is
96
+ pure loss: it preserves every probability exactly while destroying the operator,
97
+ the constituent count and the per-input activations. On a real decorated graph
98
+ that turned three `logical` CPDs into tables with every posterior still correct.
99
+ A definitional node has no per-edge strength, so the operator and the count are
100
+ the whole content of a sentence about a constitutive edge, and a consumer that
101
+ lost them had to reach back into the source document for structure. `logical`
102
+ and the noisy families now stay in family when nothing is hidden.
103
+
104
+ A **partial** reveal still returns a table, and that is not an oversight: `and`
105
+ over three constituents with two shown and the third at `q = 0.5` is `0.5` on
106
+ the row where both hold, where an `and` over the two would be `1.0`. Tagging
107
+ that `logical` would misdescribe the numbers.
108
+
109
+ ### Fixed
110
+
111
+ - **An evidence node's `name` is free text.** The reader used to refuse any name
112
+ that was not exactly `"{parent}:{label}"`, so a document naming its
113
+ virtual-evidence nodes after the observation was rejected outright. The parent
114
+ is carried structurally in `evidence_for`, which is what consumers use, and
115
+ nothing parses the suffix back out of the name, so the rule enforced a format
116
+ on the one field a reader reads in order to encode something the document
117
+ already states. `add_evidence` still generates the suffixed form: what the
118
+ library produces and what the reader accepts are separate questions. The
119
+ integrity checks are untouched, so `evidence_for` must still name a real
120
+ variable, an evidence node must still have a CPD, and it is still at most one
121
+ per variable and never a constitutive target.
122
+
123
+ ### Changed
124
+
125
+ - **`reveal.induce` now preserves `metadata`, on nodes and on edges.** It used to
126
+ strip it. The guarantee that nothing in `metadata` reached a reader has been
127
+ given up deliberately, because decoration lives there: several wordings of one
128
+ edge at different levels of precision, with one promoted into `description`. A
129
+ strip forced a consumer to choose a wording before inducing, and carrying it across
130
+ lets them induce once and render the same subgraph more than one way.
131
+
132
+ **The boundary moves rather than disappearing.** A reader never saw the document
133
+ in the first place, only text produced from it, so what protects them is what a
134
+ verbaliser emits. That is a weaker promise in a more honest place.
135
+
136
+ Nothing inside `metadata` is validated, which is what makes it useful and what
137
+ makes it the wrong place for anything a reader must not see. No number changes:
138
+ an implicit prior is read off the source graph, so carrying it into the result
139
+ cannot move an answer, and this is asserted rather than assumed.
140
+
3
141
  ## 1.4.2 (2026-10-12)
4
142
 
5
143
  ### Added
@@ -17,7 +155,7 @@
17
155
  ### Fixed
18
156
 
19
157
  - **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
158
+ and each variable were checked against a key set, while edges were checked for an
21
159
  unknown `kind` and never for unknown keys, and the writer rebuilt each edge
22
160
  from a three-key literal. So an extra field survived a read, vanished on the
23
161
  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.4
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.4"
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 {})]