Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,255 changes: 1,255 additions & 0 deletions .agents/project_specifications.md

Large diffs are not rendered by default.

37 changes: 37 additions & 0 deletions .github/workflows/engine.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
name: engine

on:
push:
paths:
- "packages/zeroforce/**"
- ".github/workflows/engine.yml"
pull_request:
paths:
- "packages/zeroforce/**"
- ".github/workflows/engine.yml"

defaults:
run:
working-directory: packages/zeroforce

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Install uv
uses: astral-sh/setup-uv@v5

- name: Sync (Python pinned by .python-version, deps from pyproject)
run: uv sync --extra test

# Guards a bad resolved (numba, numpy) pair at build time rather than at first JIT.
# See packages/zeroforce/README.md and spec §15.3/§15.4.
- name: import numba smoke test
run: uv run python -c "import numba, numpy; print('numba', numba.__version__, 'numpy', numpy.__version__)"

# Merge is blocked unless the smoke test and all GATING tests pass. The xfail
# closed-form tests (§4.6) may xpass or xfail without blocking.
- name: Run engine tests (gating)
run: uv run pytest -q -rxX
17 changes: 17 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Python
__pycache__/
*.py[cod]
.venv/
.pytest_cache/
.ruff_cache/
*.egg-info/
build/
dist/

# numba
__pycache__/
.numba_cache/

# env / secrets
.env
.env.local
1 change: 1 addition & 0 deletions packages/zeroforce/.python-version
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
3.12.7
102 changes: 102 additions & 0 deletions packages/zeroforce/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# zeroforce — RZF engine

Exact **Expected Propagation Time (EPT)** over weighted directed dependency graphs,
via Randomized Zero Forcing (RZF, arXiv:2602.16300). This package is the mathematical
core of the zeroforce product and is **built first**: a subtly wrong EPT is a plausible
number with no error signal, so math-correctness gates CI before anything is built on top.

## The RZF rule

At each round, every still-undisrupted ("white") node `v` flips to disrupted independently
with probability equal to the fraction of its incoming weight coming from already-disrupted
in-neighbors:

```
P(v flips at t+1) = ( Σ w(u → v) for u disrupted at t ) / ( Σ w(u → v) over all in-neighbors )
```

## Normalized-weight simplification (denominator = 1)

The §7.2 invariant requires every node's incoming weights to sum to `1.0` (source nodes,
which have no incoming edges, are exempt). With the invariant holding, the denominator above
is always `1.0`, so it drops out:

```
p_disrupt[v] = Σ w(u → v) for u disrupted # disrupted incoming weight, directly
```

This removes a division from the hot loop. **The invariant is asserted on the FULL input
graph at `compute()` entry — not on each induced subgraph** (see below).

## Scope: EPT is measured over R(v), the reachable set

EPT from seed `v` is the expected number of rounds until **every node reachable from `v`**
(`R(v)`, via positive-weight directed paths, including `v`) is disrupted. Measuring over all
of `V` would be infinite for any non-strongly-connected graph (nearly every supply chain).
The engine runs the DP on the induced subgraph `G[R(v)]` — correct, and a free perf win.
Nodes in `V \ R(v) \ {v}` are reported in `unreachable_from_here`, never as `∞`.

### Why the invariant is asserted on the full graph, not the subgraph

A node `v` may have suppliers that are **not** reachable from the seed. Those suppliers never
disrupt, so within `G[R(seed)]` node `v`'s in-subgraph incoming weights can sum to **less
than 1.0** — the deficit is permanently-white supplier mass. This is correct, not a bug:

- The row-stochastic invariant (`Σ incoming = 1.0`) holds on the **full graph** and is
asserted once at `compute()` entry (sources exempt).
- On a subgraph, `p_disrupt[v]` = disrupted incoming weight using the **full** edge weights.
The unreachable-supplier mass simply never enters the disrupted sum, so it correctly acts
as a constant white contribution. The denominator stays `1.0` (full), so the "denom = 1"
simplification is preserved.

Asserting row-stochastic on the subgraph would wrongly reject valid graphs (e.g. the
hand-computed oracle's seed-C case). See `tests/test_oracle_handcomputed.py`.

## The recurrence (self-loop fixed)

States are bitmasks over the nodes of `G[R(v)]`; a set bit = disrupted. Process states by
decreasing popcount. A round can flip **zero** white nodes, leaving the state unchanged, so
the recurrence must condition on "at least one flip occurred" — `expected_next` sums over
`next_mask ≠ mask` only, and divides by `(1 - p_stay)`:

```
p_disrupt[v] = Σ w(u → v) for u in disrupted(mask), for each white v
p_stay = Π (1 - p_disrupt[v]) over white v
ept[mask] = ( 1 + Σ_{next ≠ mask} P(next | a flip occurred) · ept[next] ) / (1 - p_stay)
ept[FULL] = 0
```

## Ranking

`impact_score = n_reachable / EPT`, sorted descending (higher = more dangerous).
`n_reachable = |R(v)| - 1` **excludes the seed itself**. Pure sinks (`n_reachable = 0`) get
`impact_score = None` and are ranked separately. `ept == 0` with `n_reachable > 0` is
structurally impossible and raises `EngineInvariantError`.

## Oracle strategy (the most load-bearing decision)

Tests must not lie. The gating oracles trust the (unrefereed preprint) paper **zero**:

1. **Hand-computed** 3-node graph, EPT derived from first principles in the test docstring.
2. **Monte Carlo differential** — an independent simulator vs the DP, agreeing to 3 decimals.
The RZF definition is implemented twice; agreement is real evidence.
3. **Engine parity** — pure-Python reference DP vs the numba JIT path, exact.

The §4.6 closed-form formulas (`path 2n−3`, `star`, `cycle n−k`, …) are **UNVERIFIED
transcriptions** and are marked `xfail(strict=False)`. The ONLY way to unblock one is to read
the paper body, confirm the formula, and cite the theorem number in the docstring — then drop
the marker. **Do not** "fix" a failing closed-form test by editing the expected value to match
the engine; that defeats the entire safety mechanism.

## Layout

```
zeroforce/
├── types.py # Pydantic Node, Edge, Graph, NodeAnalysis, EngineInvariantError
├── reachability.py # transitive closure, induced subgraph G[R(v)]
├── engine_py.py # pure-Python reference DP (correctness-first)
├── engine_numba.py # numba-JIT fast path, same recurrence
├── simulator.py # Monte Carlo RZF simulator (independent definition)
├── ranking.py # impact_score, percentiles, sink separation
└── api.py # public compute(graph) -> list[NodeAnalysis]
```
32 changes: 32 additions & 0 deletions packages/zeroforce/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
[project]
name = "zeroforce"
version = "0.0.1"
description = "Randomized Zero Forcing (RZF) engine — exact Expected Propagation Time over weighted directed dependency graphs."
readme = "README.md"
requires-python = ">=3.12,<3.13"
dependencies = [
"numba>=0.61,<0.64",
# widened from the v2.1 over-tight <2.2: numba 0.61 supports numpy<=2.1, but
# 0.62/0.63 support later numpy. The lockfile pins one compatible (numba, numpy)
# pair; CI runs an `import numba` smoke test against the resolved versions so a
# bad pairing fails at build time, not at first JIT. (Spec §15.3.)
"numpy>=1.26,<3.0",
"pydantic>=2.7,<3.0",
"networkx>=3.2,<4.0",
]

[project.optional-dependencies]
test = ["pytest>=8", "pytest-xdist", "hypothesis>=6"]

[tool.uv]
python-preference = "managed"

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.hatch.build.targets.wheel]
packages = ["zeroforce"]

[tool.pytest.ini_options]
testpaths = ["tests"]
27 changes: 27 additions & 0 deletions packages/zeroforce/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
"""Shared test helpers: random normalized weighted digraphs."""

from __future__ import annotations

import numpy as np

from zeroforce.reachability import WeightedDiGraph


def random_normalized_digraph(n: int, rng: np.random.Generator, extra_p: float = 0.4) -> WeightedDiGraph:
"""Random weighted digraph with node 0 reaching every node (backbone chain i-1 -> i),
plus random extra edges, with each non-source node's incoming weights normalized to 1.0.

Node 0 is a pure source (no incoming), so R(0) = all nodes — keeps the reachable set full
and the comparison meaningful. Some other nodes may also gain incoming edges.
"""
in_w = np.zeros((n, n), dtype=np.float64)
for v in range(1, n):
sources = {v - 1} # backbone guarantees reachability from 0
for u in range(n):
if u != v and rng.random() < extra_p:
sources.add(u)
raw = {u: rng.random() + 0.05 for u in sources}
total = sum(raw.values())
for u, w in raw.items():
in_w[v, u] = w / total
return WeightedDiGraph(node_ids=tuple(str(i) for i in range(n)), in_w=in_w)
78 changes: 78 additions & 0 deletions packages/zeroforce/tests/test_closed_forms.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
"""§4.6 closed-form EPT formulas — UNVERIFIED, xfail until paper body confirms each.

READ THIS BEFORE EDITING. These constants are transcribed from the spec table, which is
itself transcribed from an UNREFEREED preprint (arXiv:2602.16300). They are NOT trusted
oracles. Every test here is `xfail(strict=False)` ON PURPOSE.

To unblock one: read the paper body, confirm the formula line-by-line, cite the verifying
theorem number in the test docstring, THEN drop its xfail marker in a dedicated commit.

DO NOT make a failing test here pass by editing the expected value to match the engine.
That makes the test tautological against an unverified number and defeats the entire
safety mechanism. If you are tempted to do that: stop, and verify against the paper instead.

The GATING oracles (hand-computed, Monte Carlo differential, engine parity) are what prove
the engine correct; these do not gate CI.
"""

from __future__ import annotations

import math

import numpy as np
import pytest

from zeroforce.engine_py import compute_ept_from_seed
from zeroforce.reachability import WeightedDiGraph


def _ids(n):
return tuple(str(i) for i in range(n))


def _bidirected_path(n: int) -> WeightedDiGraph:
in_w = np.zeros((n, n), dtype=np.float64)
for i in range(n):
nbrs = [j for j in (i - 1, i + 1) if 0 <= j < n]
for j in nbrs:
in_w[i, j] = 1.0 / len(nbrs)
return WeightedDiGraph(node_ids=_ids(n), in_w=in_w)


def _bidirected_cycle(n: int) -> WeightedDiGraph:
in_w = np.zeros((n, n), dtype=np.float64)
for i in range(n):
for j in ((i - 1) % n, (i + 1) % n):
in_w[i, j] = 0.5
return WeightedDiGraph(node_ids=_ids(n), in_w=in_w)


def _star_from_leaf(n: int) -> WeightedDiGraph:
# node 0 = center, nodes 1..n-1 = leaves, bidirected. Seed a leaf (node 1).
in_w = np.zeros((n, n), dtype=np.float64)
for leaf in range(1, n):
in_w[leaf, 0] = 1.0 # leaf's only supplier is the center
for leaf in range(1, n):
in_w[0, leaf] = 1.0 / (n - 1) # center fed equally by all leaves
return WeightedDiGraph(node_ids=_ids(n), in_w=in_w)


@pytest.mark.xfail(strict=False, reason="§4.6 path 2n-3 UNVERIFIED against paper body")
@pytest.mark.parametrize("n", [3, 4, 5, 6])
def test_bidirected_path_endpoint_is_2n_minus_3(n):
ept = compute_ept_from_seed(_bidirected_path(n), seed_local=0)
assert math.isclose(ept, 2 * n - 3, abs_tol=1e-9)


@pytest.mark.xfail(strict=False, reason="§4.6 cycle n-k UNVERIFIED against paper body")
@pytest.mark.parametrize("n", [3, 4, 5])
def test_bidirected_cycle_single_seed_is_n_minus_1(n):
ept = compute_ept_from_seed(_bidirected_cycle(n), seed_local=0)
assert math.isclose(ept, n - 1, abs_tol=1e-9)


@pytest.mark.xfail(strict=False, reason="§4.6 star-from-leaf n UNVERIFIED against paper body")
@pytest.mark.parametrize("n", [3, 4, 5])
def test_star_from_leaf_is_n(n):
ept = compute_ept_from_seed(_star_from_leaf(n), seed_local=1)
assert math.isclose(ept, n, abs_tol=1e-9)
31 changes: 31 additions & 0 deletions packages/zeroforce/tests/test_differential.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
"""GATING — Monte Carlo differential: independent simulator vs DP, n<=6, ~3 decimals.

The strongest oracle: the RZF definition is implemented twice (DP subset enumeration in
engine_py, vectorized forward simulation in simulator). Agreement is evidence that trusts
no published closed form.
"""

from __future__ import annotations

import numpy as np
import pytest

from zeroforce.engine_py import compute_ept_from_seed
from zeroforce.simulator import simulate_ept

from conftest import random_normalized_digraph

SAMPLES = 200_000
TOL = 2e-2 # mean of 200k samples; comfortably above sampling noise for EPT at n<=6


@pytest.mark.parametrize("trial", range(8))
def test_dp_matches_monte_carlo(trial):
rng = np.random.default_rng(1000 + trial)
n = int(rng.integers(2, 7)) # 2..6
g = random_normalized_digraph(n, rng)

dp = compute_ept_from_seed(g, seed_local=0)
mc = simulate_ept(g, seed_local=0, samples=SAMPLES, rng=rng)

assert abs(dp - mc) < TOL, f"n={n} trial={trial}: dp={dp:.4f} mc={mc:.4f}"
34 changes: 34 additions & 0 deletions packages/zeroforce/tests/test_engine_parity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
"""GATING — engine parity: pure-Python reference DP vs numba JIT, identical graphs.

The numba path is an optimization, not a second definition. It must agree with the
reference DP exactly (to floating tolerance) on the same input, for every seed.
"""

from __future__ import annotations

import numpy as np
import pytest

from zeroforce.engine_numba import compute_ept_from_seed as ept_numba
from zeroforce.engine_py import compute_ept_from_seed as ept_py
from zeroforce.reachability import induced_subgraph, reachable_from

from conftest import random_normalized_digraph


@pytest.mark.parametrize("trial", range(12))
def test_py_and_numba_agree_for_every_seed(trial):
rng = np.random.default_rng(7000 + trial)
n = int(rng.integers(2, 11)) # 2..10
g = random_normalized_digraph(n, rng)

for seed in range(n):
# Engine contract: run on the induced subgraph G[R(seed)] (as api.compute does),
# so full coverage is the correct absorbing target and EPT is finite.
reachable = reachable_from(g, seed)
sub = induced_subgraph(g, reachable)
seed_local = sub.index_of(g.node_ids[seed])

a = ept_py(sub, seed_local)
b = ept_numba(sub, seed_local)
assert abs(a - b) <= 1e-10, f"n={n} seed={seed}: py={a!r} numba={b!r}"
Loading
Loading