Metadata-Version: 2.5
Name: probability-flow
Version: 1.4.0
Summary: Discrete causal graphs and exact inference.
Project-URL: Homepage, https://github.com/scalable-oversight-benchmarks/probability-flow
Project-URL: Repository, https://github.com/scalable-oversight-benchmarks/probability-flow
Project-URL: Issues, https://github.com/scalable-oversight-benchmarks/probability-flow/issues
Author-email: Alex Roman <dr.alexroman@gmail.com>
License-Expression: MIT
License-File: LICENSE
Keywords: bayesian-network,causal-graphs,causal-inference,inference,probabilistic-graphical-models
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: Science/Research
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
Classifier: Topic :: Scientific/Engineering :: Mathematics
Classifier: Typing :: Typed
Requires-Python: >=3.11
Provides-Extra: viz
Requires-Dist: matplotlib; extra == 'viz'
Description-Content-Type: text/markdown

# probability-flow

Discrete **causal graphs** and exact inference.

This is the `v1` rewrite. It shares a repository with the 0.x line but no code:
the ASPIC argumentation layer, the argument generator, the belief-propagation
solvers and the metrics package are all gone from this branch. The 0.x tree is
still on `main` if you want to refer back to it.

## The milestone

Define a causal graph, observe some variables, and ask for a posterior.

```python
from probability_flow import CausalGraph, CPT, render, to_text

g = CausalGraph("alarm")

burglary = g.add_variable("Burglary")
quake    = g.add_variable("Earthquake")
alarm    = g.add_variable("Alarm")

burglary.causes(alarm)               # an edge means cause -> effect
quake.causes(alarm)

burglary.set_prior(0.001)            # priors: uncaused variables only
quake.set_prior(0.002)

alarm.set_cpd(CPT()                  # P(Alarm | Burglary, Earthquake)
    .when({burglary: True,  quake: True },  0.950)
    .when({burglary: True,  quake: False},  0.940)
    .when({burglary: False, quake: True },  0.290)
    .otherwise(0.001))

burglary.posterior({alarm: True}).p_true    # 0.3736
```

A binary variable's states are Python's own `True` and `False`, and there is no
reason to spell them `"true"`/`"false"` when the booleans exist.

Several ways to write a CPD, all ending in the same table:

```python
# named rows: order-independent, and each one says what it means
alarm.set_cpd(CPT().when({burglary: True, quake: False}, 0.94).otherwise(0.001))

# dense table: name the causes, then index in that order
alarm.set_cpd(CPT(causes=[burglary, quake], table=[[0.001, 0.290],
                                                   [0.940, 0.950]]))

# positional: keys follow the order the causes were added
alarm.set_cpd({(True, True): 0.95, (True, False): 0.94,
               (False, True): 0.29, (False, False): 0.001})

# a single cause is fully specified by two numbers
rain.causes(wet, p_if=0.95, p_if_not=0.01)
```

The table form reads as `table[burglary][quake]`. Naming the causes is what makes
it safe: the order is yours, stated explicitly, and the table is permuted into
the graph's own parent order on the way in, so which order the edges happened to
be added in cannot change the answer. Nesting depth is fixed by the number of
causes, so a leaf may itself be a distribution (for a multi-state child) without
being mistaken for another level.

The last is only well defined while `rain` is `wet`'s only cause. With two
causes there is no combination rule until you give one, so it raises rather than
inventing one. `.otherwise()` fills every row you did not name, which is the
common shape: a few interesting rows and a default.

Structure has a graph-level spelling too, so `a.causes(b)` and
`g.add_edge(a, b)` are the same call. Numbers and questions do not: `set_prior`,
`set_cpd` and `posterior` live on the variable only, because there is one place
to look for each of them and a second spelling is a second thing to keep in
step.

**Looking a variable back up: `g["v0"]` takes the stable id**, and `g.by_name(...)`
takes the display name. The subscript uses the id because that is the key that
survives what a consumer does to a document. `name` is content a decorator
rewrites. Renaming is safe and moves the name index with it: `v.name = "..."` is
the supported spelling, ids never move, and a name that is already taken is
refused exactly as `add` refuses it.

`to_text(g)` prints the structure, every CPD row and the current beliefs, with no
dependencies. `render(g)` draws it with matplotlib, causes above effects and
observed variables highlighted. What every mark in a drawing means, and which
keyword changes it, is in `docs/rendering.md` in the source tree.

## Three decisions worth knowing

**The hypothesis is a question, not a node.** Nothing in a graph marks a variable
as the hypothesis, the target or the root. Which variable you are asking about is
an argument to `posterior()`. There is no node-type field, so there is nothing to
drift out of step with the structure, and `roots()` can mean exactly "parentless"
without competing for the word.

A graph may still record *which* question it was built to ask, as
`g.hypothesis`, and that survives a round trip. It is a pointer at the document's
purpose rather than a property of the node: the variable it names carries no
marking, behaves identically, and is serialised exactly as it would be if nothing
pointed at it. Inference never reads it.

**The causal order has three outcomes, not two.** `a.is_effect_of(b)` and
`a.is_cause_of(b)` ask whether a directed path runs between two variables, and
both are False when none does. Two co-effects of a common cause are genuinely
unordered. There is no third method for that case, because "unrelated" would read
as disconnected when the real state is an indeterminate *causal* relation. Ask
both predicates instead.

`<` and `>` are the same question, and they are **temporal**: `a > b` means `a` is
downstream of `b`. Not the arrow. `causes` already spells that and is the call
that creates the edge. An indeterminate pair **raises** rather than returning
False, which is what stops `sorted()` from quietly manufacturing a total order out
of a partial one. `graph.topological_order()` is the linear extension when a
consistent sweep is what you want.

**A prior is the zero-parent CPD.** There is no separate prior concept. Every
variable has exactly one CPD whose conditioning set is exactly its parents, so a
caused variable cannot also carry a prior, and the illegal state is unrepresentable
rather than validated against. `set_prior` is a convenience for the zero-parent
case and refuses a variable that has causes.

**Extend where the extension is the identity, clear where it is not.** Adding a
cause to a variable that already has a CPD used to raise, and raising left no way
back, since there is no `clear_cpd`, so the only recovery was rebuilding the
graph. Now it depends on the CPD, and the test is whether the extension changes
what the CPD says:

- an **additive** form takes a new term at **weight `0.0` bits**, which contributes nothing,
  so it is **extended** and every answer it gave is unchanged,
- a **table** would gain rows nobody wrote and a **definition** would gain a
  constituent that changes the proposition, so both are **cleared**, with a
  warning naming what was lost and what to write next.

Clearing is safe because the guard is downstream: a variable with no CPD fails
`check()` and every query, loudly. An unread warning becomes an error at the next
question, never a quietly wrong answer. *Structure before parameters* is still the
advice. It is now advice rather than a wall.

## Inference

Three engines, all exact, and you do not normally choose between them.
`posterior()` uses **variable elimination** and `marginals()` uses a **junction
tree**. The default is `auto` and it is the right answer unless you have a
particular reason otherwise.

```python
rain.posterior({wet: True})                      # auto: elimination
graph.marginals()                                # auto: a junction tree
rain.posterior({wet: True}, engine="enumeration")  # the reference, explicitly
```

**Enumeration** is the third, and it is never chosen for you. It sums the whole
joint, which is exponential and deliberately so: it is short enough to check by
eye, which is what makes it the reference the other two are tested against. It is
also the slowest and, measurably, the least accurate of the three, because it
accumulates the entire joint under one scale where the others rescale per factor.
Being obviously correct and being accurate are different properties.

**What a question costs is the largest clique, not the number of variables**, and
those come apart fast: a 60-variable chain is 2⁶⁰ joint states and a largest
clique of 4. `eliminate.cost(graph, query)` answers that before doing any work,
and a graph is refused when a clique is too wide rather than taking a very long
time. `engine_config` can carry an engine and a budget per document, and leaving
it empty is the normal case.

Nothing is special-cased in any of them. Evidence on a *descendant* of the
variable you are asking about works exactly like evidence on an ancestor. The
arithmetic never asks which way an edge points.

Every result carries `is_exact`. An approximate answer must never be mistaken for
ground truth downstream, so exactness travels with the number rather than being a
property of the library.

## Observation

A variable can be observed directly, or through an instrument you declare on it.

```python
ill.observe(True)                    # direct: the variable itself is known
ill.observe(None)                    # forget it again

ill.add_evidence("assay",            # declare an instrument...
    outcomes=("negative", "inconclusive", "positive"),
    table=[(0.90, 0.08, 0.02),       #   given Ill is False
           (0.05, 0.15, 0.80)])      #   given Ill is True

ill.evidence.observe("negative")     # ...and report what it said
ill.evidence.observe(None)           # or that nobody looked
```

**`observe` always observes its receiver.** `ill.observe(...)` is about `ill`,
`ill.evidence.observe(...)` is about the instrument. So the object you call it on
is what says *what* was observed, and nothing has to be inferred from context,
which matters, because observing `ill` and observing an instrument that reports on
`ill` give very different posteriors (1.0 against 0.269 for an LR-7 instrument).

**`x.evidence` is a reserved handle**, and a variable carries at most one, so it
means the same thing in every model. You never have to keep what `add_evidence`
returned. Declaring a second one raises, and `has_evidence` checks without raising,
and reaching for one that was never declared says so.

The reservation is a convenience, not a claim of exclusivity: **any effect of a
variable is evidence about it** when observed. Two independent witnesses are two
ordinary children, observed directly.

```python
second = g.add_variable("Bystander")
ill.causes(second, p_if=0.9, p_if_not=0.3)
second.observe(True)
```

Their likelihood ratios compose exactly as you would expect. `.evidence` is
just the one with the short name.

**Three states, and `None` is reserved.** An observation is an outcome or `None`
for "nobody looked". Outcomes can be anything: booleans, strings, numbers, with
exactly one exception: **no state may be `None`**, and `Variable` refuses it. That
is what makes "nobody looked" impossible to confuse with an outcome somebody
named, rather than merely unlikely to be. One sharp edge remains and
`is_observed` exists for it: `None` and `False` are both falsy, so
`if v.observed:` cannot tell "nobody looked" from "observed to be False".

**Declaring an instrument changes nothing until it is observed**, and that is
structural rather than incidental. An instrument is childless, so an unobserved
one marginalises to 1 and inference drops it from the joint entirely. Declaring a
hundred possibilities costs nothing and perturbs nothing. 14 real variables with
70 declared instruments enumerate 2¹⁴, not 2⁸⁴, and their marginals are still
available, recovered from the parent.

An instrument is Pearl's virtual evidence, and its two numbers give a genuine
likelihood ratio rather than an odds ratio: it has exactly one parent, so they
*are* the two conditionals. The ambiguity that makes "LR" a loaded word elsewhere
needs a second parent to appear. Accordingly `likelihood_ratio` raises on a
multi-outcome instrument, which has no single one.

An explicit `observations=` argument to `posterior` overrides what is recorded on
the graph for that one query without disturbing it, so what-ifs stay cheap,
including `{v: None}`, meaning "and nobody looked at this one".

The keyword is `observations` rather than the conventional `evidence` because
that is what the library calls the concept everywhere else: `observe(state)`,
`observed`, `is_observed`. **Observe is the verb, evidence is the noun for the
Pearl channel.** So `x.observe(True)` records one, `posterior(observations={x:
True})` supplies them for a query, and `x.evidence.observe(True)` observes the
virtual-evidence child. There is deliberately no `evidence=` alias.

## Definitional nodes

Some nodes are not *caused* by their parents. They **are** them. "Someone took
the key" does not happen because of "A took it", "B took it", "C took it". It is
another way of saying them.

```python
theft = g.add_definition("someone_took_it")
for s in (alice, bob, carol):
    s.constitutes(theft)

theft.set_cpd(logical.Exactly(1, over=[alice, bob, carol]))
```

**`And` and `Or` may be declared before their constituents**, since both mean the
same thing over any set and there is nothing to invent:

```python
theft.set_cpd(logical.Or())        # an OR of whatever constitutes it
alice.constitutes(theft)           # each one extends it
bob.constitutes(theft)
```

**Only `And` and `Or` extend**, and the reason is in the arithmetic: a `k` counts
against a particular number of inputs, so `Exactly(1)` is cleared with a warning
rather than silently re-read against a new denominator.

A definitional node's incoming edges are all `constitutive`, it carries no prior,
and a constitutive edge may point nowhere else. It carries no prior for the same
reason anything with parents does not: **a prior is the zero-parent CPD**, and a
definitional node has constituents by construction.

**Every operator is a predicate on the count of satisfied constituents**:
`logical.And`, `logical.Or`, `logical.AtLeast(k)`, `logical.Exactly(k)`, `logical.AtMost(k)`. That is one
observation rather than five: it is what makes the set extensible, the cost
polynomial rather than `2^D`, and the check for a vacuous `k` a single line.

**There is no `xor`. `logical.Exactly(1)` is what you want.** `xor` is `logical.Exactly(1)` only
at `n = 2`. Generalised past that it is *parity*, so over three suspects it
would call "all three did it" true. It is a special case of an operator that is
here, not one that is missing.

**Partial reveal: `partially_revealed` averages over what was not shown**, at each
unrevealed constituent's implicit prior, and hands back a table over the ones
that were. It is total: every operator partially reveals, including a count
predicate. `Exactly(2)` over three constituents and over two *are* different
claims, so rather than re-read `k` against a smaller denominator, the unshown
constituent is marginalised: with two of three shown true and the third at
`q = 0.5`, `Exactly(2)` comes back at `0.5` rather than `1.0`.

Both families keep the hidden term, and they differ in **where** it is averaged.
An unrevealed **cause** of an additive node is folded into `baseline` in
*log-odds*, which is exact rather than approximate: the exponent is linear in the
parents' states, so `E[f] = f(q)` by linearity of expectation. An unrevealed
**constituent** has no exponent to interpolate into, so it is averaged in
*probability space* instead. Which arithmetic applies is fixed by the shape of
the CPD rather than chosen.

A constituent is a variable plus the states that *satisfy* it, which covers
negation and multi-state membership with one mechanism:

```python
logical.And(over=[alice, (bob, False), (temp, {"hot", "very_hot"})])
```

`True` is the default on a binary variable and required on anything else. Order
is not an input. Every constituent names its variable, so it is permuted into
the graph's parent order for you.

### Why a class rather than a table

Because a table lets you write `1e-6` into a cell the definition makes
impossible, and that near-zero is a different object from a zero. With
`C = A AND B` at prior `p`, softening the impossible cells to `L` gives

```
P(A | C) = (p² + p(1-p)L) / (p² + (1-p²)L)
```

which depends on `L / p²` and nothing else, so `1e-6` is `0.999998` at
`p = 0.5` and `0.50` at `p = 0.001`. It is not a safe number or a dangerous one. Here you
do not author cells at all, so the zeros are exact by construction rather than by
validation.

`leak` is the declared version: `P(node | the definition is NOT satisfied)`, the
noisy-OR sense. It defaults to `0.0` and stays exactly representable, which
matters because the strength of a completed deduction caps at `log2(prior/leak)`
bits. A declared leak is fine because whoever wrote it can check it against that
ratio, and an inherited `1e-12` cannot be checked by anyone.

**Exceptions, meaning the definition holding while the conclusion fails, are
deliberately not a parameter.** Interpose a node: `D' = D AND NOT X`. The reason
is about what this is *for* rather than about parsimony. An anonymous exception
probability is a claim nobody can contest, where an exception node is a claim a
debater can attack.

## Interchange

`graph.to_json()` and `CausalGraph.from_json(text)`. The format is documented in
`docs/causal-graph-format.md` in the repository, with worked documents at
`examples/speeding-camera.json` and `examples/twelve-angry-men.json`. For the
short version of both the formalism and the format, read `docs/cheat-sheet.md`.

Two properties worth knowing here. The CPD field is a **tagged union**:
`table`, `logical`, `noisy_and`, `noisy_or` and `independent_evidence`, one
per concrete CPD class, and an unknown tag is rejected rather than guessed at. And **`"type": "table"` means authored as a table, never
compiled to one**: the writer never calls `as_table()`, and a CPD class with no
tag is refused outright rather than flattened, because flattening preserves every
probability exactly while destroying the structure.

## Independent evidence, and gating

```python
alarm.set_cpd(IndependentEvidence(
    baseline=0.001,
    weights={burglary: 9.876, quake: 8.179},      # bits
    gates=[Gate(grudge, modulates=burglary)],
))
```

Additive in log-odds, with one edge's weight selected by a gate. Weights are in
**bits**: how far the child's log-odds move when that parent goes false to true,
holding the others fixed. Zero contributes nothing, and negative is evidence
*against*.

```
logit P(child) = logit(baseline) + SUM_i s_i log LR_i
                                 + s_A log( gate ? LR'_A : LR_A )
```

**Hard gating is `weight_when_gated=0.0`.** The gated edge contributes nothing,
which is what removing a reason means. An undercutter does not make a conclusion
impossible. When this parameter held a ratio its neutral value was `1.0` and `0`
was refused, because `log 0` is `-inf` and a `-inf` meeting a `+inf` from another
edge is `nan`. In bits the parameter is already in log space, so the neutral
value *is* zero and the pathological one does not exist.

**A gate is a multiplexer, not an attenuator.** `weight_when_gated` *replaces*
the edge's weight rather than scaling it, so a larger magnitude **strengthens**
the edge and the opposite sign **reverses** it. Gating generalises undercutting
rather than only weakening it.

**One gate per EDGE, not per node.** `gates` takes a list, so "A supports H but
is undercut by U1, B supports H but is undercut by U2" is written directly. It
used to need an interposed node and no longer does. Several gates may share a
source, and an edge takes at most one, because combining two is a modelling decision
rather than an arithmetic one.

**Two obstructions that must compound go through an upstream ADDITIVE node, not an
OR-node.** An edge takes at most one gate, so compounding has to happen before the
gate, and the node that does it matters. Say a witness could not see because she
was not wearing her glasses *and* a train was passing: an OR-node saturates on
either one alone, so the glasses by themselves undercut as hard as both together,
which is not what either obstruction claims. An additive node accumulates them, so
each contributes and two contribute more than one. Gate on that node's posterior.

Reach for an OR-node only where either condition genuinely suffices on its own.

## Noisy conjunction and disjunction

`noisy.And` and `noisy.Or`: a conjunction and a disjunction that are allowed to
misfire.

```python
plan_works.set_cpd(noisy.And({permit: 0.95, inspection: 0.9, bond: 0.99}))

alarm.set_cpd(noisy.Or({burglary: 0.9, quake: 0.3}, leak=0.001))
```

An **activation** is `P(this input does its job | this input is satisfied)`, and
it is attached to a *named* input, which is what makes it a claim someone can
argue with. **The keys are the inputs**: there is no separate list and no
default, so every input carries a number. A key may also be a
`(variable, when)` pair, where `when` must be hashable: a tuple, not a set.

`IndependentEvidence` keeps its parent list implicit too: the inputs are the
keys of `weights` plus any gate source, and a gate source joins the CPD's input
list automatically at **weight `0.0` bits**, which is what "contributes no evidence
of its own, gating only" means.

**You still have to draw the edge yourself.** `gate_source.causes(child)` is
required, and omitting it is a `ValueError` naming the two sets. The automatic part
is the CPD's input list, not the graph's edges, and the sentence above used to
read as though it were both.

**These are not `logical` with a leak.** A `logical` is deterministic over a
predicate on a count, and these are probabilistic over per-input activations.
They agree in exactly one corner (every activation `1.0`, no leak) and differ
everywhere else.

**Necessity in `noisy.And` is hard at every activation**, so a revealed
unsatisfied conjunct refutes exactly, whatever is hidden. `noisy.Or` does *not*
mirror that: a satisfied input gives only a lower bound, because unseen inputs
could also be firing. **Noise costs the disjunction its exactness and costs the
conjunction nothing.**

`noisy.And` has no `leak`. The only leak a conjunction can carry without losing
necessity is a downward one, where the inputs hold and the conclusion fails anyway,
which is the anonymous exception parameter kept out of `logical`, for a reason
that applies here unchanged.

## The reveal game

Some consumers show a reader part of a graph and ask what that part warrants
believing. `probability_flow.reveal` is for them. One function is public:

```python
from probability_flow import reveal

revealed_graph = reveal.induce(g, revealed={claim, witness})

revealed_graph.by_id(claim.id).posterior()
revealed_graph.by_id(claim.id).posterior({revealed_graph.by_id(ev.id): True})
revealed_graph.marginals()
```

`revealed` going in is the set of nodes somebody has been shown, as variables or
as ids. What comes back is an ordinary `CausalGraph`, which is the whole point:
observations, un-observing with `{v: None}`, virtual evidence, all three engines
and the renderer work on it already, so there is no second API to learn and no
second implementation to keep in step.

**An unrevealed parent does not become a node.** It is folded into the child it
belongs to, so a hidden common cause carries no correlation between two revealed
children. That is deliberate rather than an approximation. A reader who was never
shown the hidden path has no way to know about it, so crediting them with the
correlation would measure the modeller's belief instead of theirs.

How the fold works depends on the CPD, and the two cases do not agree. An
additive node interpolates in log-odds, which is exact because the exponent is
linear in the parents' states. Everything else averages in probability space.
*Partial reveal* under **Definitional nodes** above has the detail.

**`metadata` is not copied.** That is where an unrevealed parent's assumed prior
lives, and where a generator records what it knows, so copying it would hand the
reader the answer.

## Not here yet

A scalable exact engine, and any CPD form that is neither additive, symmetric nor
a noisy CPD. An asymmetric multi-parent causal node still writes a full table.
Every compact form materialises to the same explicit rows, so each one is tested
against the table it claims to abbreviate.

One constraint on whatever fast engine lands next: **evidence has to be an axis
of it from the start.** A batched engine whose only per-row lever is "is this
variable present" can express "absent" and "present but unobserved", but not
"observed in this state", which is the one virtual evidence needs. Retrofitting
that onto a presence mask is the expensive version.

## Development

```bash
uv sync                  # installs the dev group: pytest, ruff, matplotlib
uv run pytest
uv run ruff check
```

No flags, and CI runs the same three commands. `dev` is a **dependency group**
rather than an extra precisely so that a bare `uv sync` picks it up. When it was
also an extra, a plain sync gave you pytest without ruff or matplotlib, so
`ruff check` failed and the two viz tests silently skipped.

Installing the library itself pulls in **nothing**: `dependencies = []`. The one
user-facing extra is `viz`, for `render()`. `to_text()` needs no dependencies.
