diff --git a/.agents/project_specifications.md b/.agents/project_specifications.md new file mode 100644 index 0000000..ce1adcf --- /dev/null +++ b/.agents/project_specifications.md @@ -0,0 +1,1255 @@ +# ZEROFORCE — Project Specifications + +**Supply Chain Disruption Intelligence** +**Randomized Zero Forcing × Gemini AI × Google Cloud** + +| | | +|---|---| +| **Product name** | zeroforce | +| **Repository** | `zeroforce/` | +| **Engine package** | `packages/zeroforce/` (the RZF core; shares the product name) | +| **Author** | Ali Malik — San Jose State University | +| **Spec version** | 2.2 (renames product Foresight → zeroforce; folds in 3 working-brief corrections over v2.1) | +| **Date** | May 22, 2026 | +| **Status** | Complete specification — production target. Build authorized, dev-first. | +| **Primary stack** | Python 3.12 · Gemini 2.5 Pro · Vertex AI · Cloud Run · Next.js 14 | +| **Dev stack** | Groq (gpt-oss-120b) · Tavily Search · SQLite · local Docker | + +> **What 2.1 changes vs the pasted 2.0.** This file folds in every decision reached during spec review. The five substantive corrections are: (1) Gemini schema+grounding incompatibility is confirmed by a live `400 INVALID_ARGUMENT` and §6.4 is rewritten as a two-pass design; (2) `/analyze` becomes async-by-default and node caps drop; (3) the DP recurrence is corrected for the self-loop double-count and simplified for normalized weights; (4) risk ranking moves from raw EPT to impact-weighted EPT; (5) "provider parity" splits into engine-parity (exact) and extraction-consistency (fuzzy). All five are detailed inline and summarized in §16. + +> **What 2.2 changes vs 2.1.** (a) Product renamed **Foresight → zeroforce**; the engine package keeps the name. (b) Three working-brief corrections: **numpy pin widened** to `>=1.26,<3.0` with a CI `import numba` smoke test instead of an over-tight `<2.2` (§15.3, R2-4); **async polling contract made explicit** — `status ∈ {pending|running|done|failed}`, result body only when `done`, plus a 5-minute hard *job* timeout distinct from the HTTP timeout (§9.2); **Gemini Pass B failure mode engineered** — 3× retry with temperature annealing → 0, then schema-less fallback parsed by the validator (§6.4). (c) The oracle strategy is promoted to a loud standalone callout (§11.1) because it is the project's single most load-bearing safety decision. + +--- + +## Table of Contents + +1. [Executive Summary](#1-executive-summary) +2. [Problem and Positioning](#2-problem-and-positioning) +3. [System Overview](#3-system-overview) +4. [The Mathematical Core — RZF](#4-the-mathematical-core--rzf) +5. [Technical Architecture](#5-technical-architecture) +6. [Provider Abstraction Layer](#6-provider-abstraction-layer) +7. [Data Model](#7-data-model) +8. [Prompt Specifications](#8-prompt-specifications) +9. [API Specification](#9-api-specification) +10. [Frontend Specification](#10-frontend-specification) +11. [Testing Strategy](#11-testing-strategy) +12. [Security and Compliance](#12-security-and-compliance) +13. [Observability and Cost](#13-observability-and-cost) +14. [Roadmap](#14-roadmap) +15. [Build Plan — Engine First](#15-build-plan--engine-first) +16. [Decision Log](#16-decision-log) +17. [Appendix](#17-appendix) + +--- + +## 1. Executive Summary + +zeroforce is a supply chain disruption intelligence platform. It answers a question no existing tool answers: if a specific supplier fails today, what is the mathematical expected time until the disruption cascades through the dependency network, and which single failure would collapse it fastest? + +The platform composes three layers: (1) an LLM extraction pipeline that builds a weighted directed dependency graph from natural language, public filings, and analyst reports; (2) a custom Randomized Zero Forcing (RZF) engine that computes exact Expected Propagation Time (EPT) from every node using Markov-chain dynamic programming; (3) an interactive visualization and reporting frontend with click-to-simulate cascade animation, what-if scenarios, and plain-English executive risk reports. + +RZF, introduced by Geneson, Hicks, Lichtenberg, Moon, and Robles in February 2026 (arXiv:2602.16300), is recipient-driven: each node's failure probability equals the fraction of its incoming supply that is already disrupted. This matches the structural reality of supply chains in a way that SIR/SEIR epidemic models and agent-based simulations cannot. + +> **Two-mode architecture.** **Production mode** uses Google Cloud (Gemini 2.5 Pro + Vertex AI grounding + Cloud Run + Firestore). **Dev mode** uses the same code paths against Groq (gpt-oss-120b) and Tavily Search, runs locally in Docker, requires no Google Cloud credentials. Mode selection is a single environment variable; all LLM and search calls go through a provider abstraction layer. + +> **Reference-paper caveat.** arXiv:2602.16300 is verified to exist, with authors who have a long zero-forcing track record, and its abstract matches the definitions used here. It is a **preprint, not peer-reviewed**. The §4.6 closed-form formulas are therefore treated as *unverified* until the paper body is read line-by-line; see §11.1 for how the test suite avoids trusting them prematurely. + +### 1.1 Differentiation at a glance + +| Capability | Scorecards (Interos/Resilinc) | SIR / academic | GNN approaches | zeroforce (RZF) | +|---|---|---|---|---| +| Real-time monitoring | ✓ Excellent | ✗ | Partial | ✗ Not focus | +| Propagation modeling | ✗ | ✓ | ✓ | **✓ Exact** | +| Directed graph support | ✗ Flat scores | Partial | ✓ | **✓ Core** | +| Training data requirement | Large signals corpus | Medium | Massive | **Graph only** | +| Explainable output | ✓ Score + reason | ✓ Equations | ✗ Black box | **✓ Formula** | +| Exact EPT metric | ✗ | ✗ Approximate | ✗ | **✓ Closed form** | + +--- + +## 2. Problem and Positioning + +### 2.1 The propagation gap + +Existing supply chain risk management (SCRM) tools — Interos, Resilinc, Everstream, Prewave — excel at one thing: monitoring individual supplier health scores in real time. They ingest financial signals, ESG compliance, geopolitical risk, and cyber vulnerabilities, and emit a risk score per supplier. + +They do not answer the propagation question: given that Supplier A fails, how quickly does that disruption reach Suppliers B, C, D? Which single failure causes the fastest full-network collapse? If hardening budget allows only one relationship, which one maximizes resilience? These are graph-dynamical questions, not scorecard questions. + +> **Positioning.** zeroforce is the propagation layer the SCRM market is missing. It does not displace scorecards — it sits downstream of them. Scorecards answer "who is risky." zeroforce answers "what happens next." + +### 2.2 Why current academic models fall short + +- **SIR/SEIR models** assume a uniform global infection rate across all nodes. Real supply chains are recipient-driven: a firm's vulnerability depends on its specific incoming dependencies, not a global constant. +- **Agent-based simulations** require enormous behavioral data to calibrate and produce results that are difficult to explain to a CFO or risk officer. +- **Graph neural networks** deliver predictions but no mechanism. They are black boxes when stakeholders need defensible reasoning. + +RZF resolves all three. It is recipient-driven by construction, requires only a weighted dependency graph as input, and produces exact, interpretable mathematical results. + +### 2.3 Market context + +The SCRM software market was approximately $8.1B in 2025, growing at 21% CAGR with a projected $56B market by 2035. Average annual loss from supply chain disruption for companies above $500M revenue is around $150M per company per year (2022 survey, 750 companies). zeroforce is positioned as a horizontal propagation layer addressable by every enterprise in this market and as an API platform for SCRM software vendors themselves. + +--- + +## 3. System Overview + +### 3.1 Three layers + +| Layer | Responsibility | Implementation | +|---|---|---| +| **Extraction** | Convert natural-language or document input into a validated weighted directed graph. | LLM provider (Gemini 2.5 Pro in prod via **two-pass** grounding+structure, gpt-oss-120b in dev) with web search grounding. | +| **Computation** | Compute exact EPT for every starting node using RZF Markov chain DP. | Custom Python engine (`packages/zeroforce/`). NumPy + numba JIT for hot path. Pure-Python reference for differential testing. networkx for graph utilities. | +| **Presentation** | Render interactive graph, cascade simulation, what-if comparisons, executive report. | Next.js 14 + react-force-graph-2d + Tailwind + shadcn/ui on the frontend. | + +### 3.2 Provider abstraction (the dev/prod hinge) + +All LLM and search calls go through a single provider abstraction. The application code never imports a vendor SDK directly. Switching mode is one environment variable. + +```python +# providers/base.py +class LLMProvider(Protocol): + async def generate_structured( + self, system: str, user: str, schema: type[BaseModel], temperature: float = 0.2 + ) -> BaseModel: ... + async def generate_text(self, system: str, user: str, temperature: float = 0.4) -> str: ... + +class SearchProvider(Protocol): + async def search(self, query: str, max_results: int = 5) -> list[SearchResult]: ... + +# providers/factory.py +def make_llm() -> LLMProvider: + mode = os.environ["ZEROFORCE_MODE"] # "prod" | "dev" + return GeminiProvider() if mode == "prod" else GroqProvider() + +def make_search() -> SearchProvider: + mode = os.environ["ZEROFORCE_MODE"] + return VertexGroundingProvider() if mode == "prod" else TavilyProvider() +``` + +### 3.3 End-to-end request lifecycle + +1. User submits free-form input (company name, supply chain description, or uploaded PDF) via the frontend. +2. API gateway authenticates request and forwards to `/api/v1/analyze`. +3. **Async by default.** Orchestrator persists a job record (`status: "processing"`), returns `{ id, status }` immediately, and runs the pipeline in the background. The frontend polls `/api/v1/analyses/{id}`. A synchronous `200` is available only as opt-in when `max_nodes ≤ 10 && wait=true` (see §9.2). +4. Extraction service runs the grounded extraction. **In prod this is two Gemini calls** (grounding pass → structuring pass; see §6.4), returning a structured graph JSON: nodes, directed edges, weights, confidence levels, and source citations. **In dev** it is one Tavily search call followed by one Groq structured call. +5. Validation service normalizes incoming edge weights to sum to 1.0 per node and emits a normalization report. Confidence-weighted edges below threshold are flagged but retained. +6. RZF engine receives the validated graph, computes a global reachability closure, and computes EPT for every starting node via Markov chain DP over the disrupted-subset state space of its **induced reachable subgraph** `G[R(v)]`. +7. Risk ranker computes `impact_score = n_reachable / EPT` for cascading nodes, sorts descending (higher = more dangerous), computes percentiles, and identifies the top-k critical nodes. Pure sinks (`n_reachable = 0`) are listed separately as non-cascading. +8. Reporting service issues Prompt 3 to the LLM provider, passing impact-ranked results, and receives a 3-paragraph executive risk report. +9. Result payload (graph, EPT/impact results, ranking, citations, report, validation notes, unreachable sets) is persisted to Firestore (prod) or local SQLite (dev) and the job record flips to `status: "complete"`. +10. Frontend renders the interactive force-directed graph, ranking table, executive report, and citation panel. Subsequent `/simulate` and `/whatif` calls reuse the cached graph. + +--- + +## 4. The Mathematical Core — RZF + +### 4.1 Definition + +Randomized Zero Forcing is a stochastic color-change process on a directed weighted graph G = (V, E, w). At each round, every still-undisrupted node W becomes disrupted independently with probability equal to the fraction of its incoming weight that comes from already-disrupted neighbors: + +> **The RZF rule** +> +> `P(W disrupted at round t+1) = Σ w(u → W) for u disrupted at time t / Σ w(u → W) over all in-neighbors u` + +Three structural properties make this the right model for supply chains: + +- **Recipient-driven.** Probability depends on the receiving node's own incoming structure, not a global rate. +- **Weighted and directional.** Edge weights encode share of inputs; arrows go from supplier to customer. +- **Exact.** EPT is computable in closed form on small graphs via Markov chain DP. No simulation noise; no training data beyond the graph. + +### 4.2 Expected Propagation Time (EPT) + +EPT from initial disrupted set S is the expected number of rounds until every **reachable** node is disrupted. + +``` +EPT(G, S) = E[T] where T = min { t : V_disrupted(t) = R(S) } +R(S) = set of nodes reachable from S via positive-weight directed paths (includes S) +``` + +> **Scope rule (locked).** EPT is measured over `R(v)`, the reachable set of the seed — **not** over all of V. Measuring over V would make EPT infinite for any graph that is not strongly connected, which is nearly every real supply chain. Nodes in `V \ R(v) \ {v}` are reported in the `unreachable_from_here` field, never as `∞`. The engine computes EPT on the induced subgraph `G[R(v)]`, which is both correct and a free performance win (smaller state space). + +### 4.3 Algorithm — Markov chain DP (corrected & simplified) + +States are bitmasks over the nodes of the **induced reachable subgraph** `G[R(v)]`. A mask represents the subset of disrupted nodes. Processing states by decreasing popcount, we compute `ept[mask]` = expected additional rounds from this state to full coverage of `R(v)`. + +> **Two corrections baked into this version.** +> +> 1. **Normalized-weight simplification.** Because of the hard invariant in §7.2 (incoming weights per node sum to 1.0), `total_in[v] = 1.0` for every node. The RZF denominator drops out: `p_disrupt[v]` equals the *disrupted incoming weight* directly. This removes a division from the hot loop — fewer FLOPs, one fewer place for a bug. The invariant is asserted at engine entry. +> 2. **Self-loop fix.** A round can leave the state unchanged (no white node flips). The recurrence must condition on "at least one flip occurred." `expected_next` sums over `next_mask ≠ mask` only. If the next-state enumerator ever includes `mask` itself, the recurrence double-counts and is wrong. + +The corrected recurrence: + +``` +p_disrupt[v] = Σ w(u → v) for u in disrupted(mask), for each white v # denom = 1, dropped +p_stay = Π (1 - p_disrupt[v]) over white v +ept[mask] = ( 1 + Σ_{next ≠ mask} P(next | flip occurred) · ept[next] ) / (1 - p_stay) +ept[FULL] = 0 +``` + +Reference pseudocode (pure-Python correctness-first form; numba mirrors it): + +```python +def compute_ept_from_seed(sub: WeightedDiGraph, seed_local: int) -> float: + n = sub.num_nodes # nodes of G[R(seed)] + in_w = sub.incoming_weight_matrix() # row-stochastic by §7.2 invariant + assert np.allclose(in_w.sum(axis=1), 1.0), "weight invariant violated" + + FULL = (1 << n) - 1 + ept = np.full(1 << n, np.inf) + ept[FULL] = 0.0 + + for pc in range(n - 1, -1, -1): + for mask in masks_with_popcount(pc, n): + white = [v for v in range(n) if not (mask >> v) & 1] + p = np.zeros(n) + for v in white: + p[v] = sum(in_w[v, u] for u in disrupted_nodes(mask)) # denom dropped + p_stay = float(np.prod([1.0 - p[v] for v in white])) + if p_stay >= 1.0: # absorbing non-full state => unreachable; guarded upstream + continue + expected_next = 0.0 + for nxt in next_states(mask, p): # MUST exclude mask itself + expected_next += transition_prob(mask, nxt, p) * ept[nxt] + ept[mask] = (1.0 + expected_next) / (1.0 - p_stay) + + return float(ept[1 << seed_local]) +``` + +### 4.4 Complexity and scaling + +| n (nodes) | States 2^n | Worst-case ops 3^n | Practical wall time | Notes | +|---|---|---|---|---| +| 10 | 1,024 | ~59k | < 50 ms | Trivial | +| 12 | 4,096 | ~531k | < 150 ms | **Default cap for sync** | +| 15 | 32,768 | ~14M | ~0.5 s | Comfortable async | +| 18 | 262,144 | ~387M | ~5–15 s | **Hard cap** | +| 20 | 1.05M | ~3.5B | ~30 s+ | Exceeds practical async budget | +| 25 | 33.5M | ~847B | > 30 min | Requires aggregation | + +> **Caps (locked).** Default `max_nodes = 12`. Hard cap `max_nodes = 18`. The n=20 case from v2.0 is dropped from the exact path because it does not fit the async budget reliably in Python+numba. Anything above 18 triggers aggregation (§4.5). Operating on the induced subgraph `G[R(v)]` often shrinks the effective state space below the nominal node count. + +A numba-JIT-compiled inner loop, bitmask operations, and structural pruning (skip states with unreachable suppliers) deliver an additional 10–50× over naive Python. + +### 4.5 Aggregation strategies for large graphs + +- **Spend-based truncation:** Retain the top-k suppliers by spend; aggregate the long tail into a single "Other suppliers" node with combined weight. +- **Sector-level rollup:** Use BEA 15-sector input-output tables directly. The original RZF paper validates this construction. +- **Hierarchical decomposition:** Detect strongly connected components and compute EPT on the condensation graph; within each SCC treat the subgraph independently. +- **Closed-form shortcuts:** Detect arborescences, stars, paths, and cycles in subgraphs and substitute published closed-form EPT formulas instead of running DP — **only after those formulas are verified against the paper body (§11.1).** + +### 4.6 Known closed-form EPT formulas — UNVERIFIED, do not encode as oracle yet + +> **Status flag (locked).** The constants below are transcribed from the v1/v2 spec table. They are **not** confirmed against the arXiv:2602.16300 paper body. They MUST NOT be used as test oracles until each is verified line-by-line and the verifying theorem number is cited. Tests for these are marked `@pytest.mark.xfail(strict=False)` until that happens. See §11.1. + +| Structure | EPT formula (claimed) | Verification status | +|---|---|---| +| Bidirected path P_n (from endpoint) | 2n − 3 | UNVERIFIED — claimed "verified n=3..9" | +| Bidirected cycle C_n with k blue | n − k | UNVERIFIED — claimed weight-independent | +| Star K_{1,n-1} from center | 1 | UNVERIFIED — claimed deterministic | +| Star K_{1,n-1} from leaf | n | UNVERIFIED | +| Arborescence from root | depth(T) | UNVERIFIED — claimed deterministic | +| Complete graph K_n | Θ(log n) | UNVERIFIED — asymptotic only | + +The **trusted** oracles instead are: (a) a hand-computed 3-node absorbing-leaf graph with the derivation in the test docstring, and (b) a Monte Carlo differential test (independent simulator vs DP). These trust the paper zero. See §11.1. + +--- + +## 5. Technical Architecture + +### 5.1 Service topology + +Six-service Python backend behind a Next.js frontend, deployed as containers. In production each backend service is its own Cloud Run service. In dev mode everything runs in a single docker-compose stack. + +| Service | Port | Responsibility | Stateless? | +|---|---|---|---| +| `gateway` | 8080 | Auth, rate limiting, request routing. | Yes | +| `extractor` | 8081 | LLM-driven graph extraction with grounding (two-pass in prod). | Yes | +| `validator` | 8082 | Weight normalization, schema checks, confidence audit. | Yes | +| `engine` | 8083 | RZF Markov chain DP. Numba-compiled hot path. | Yes | +| `reporter` | 8084 | Executive-report generation via LLM. | Yes | +| `orchestrator` | 8085 | Coordinates the pipeline. Owns Firestore/SQLite writes and job state. | Yes | + +### 5.2 Data flow + +``` +┌──────────┐ ┌──────────┐ ┌───────────┐ ┌───────────┐ +│ Frontend │──▶│ Gateway │──▶│Orchestrator│──▶│ Extractor │──┐ +└──────────┘ └──────────┘ └─────┬─────┘ └─────┬─────┘ │ + ▲ poll /analyses/{id} │ │ │ + │ │ ┌───────▼──────┐ │ + │ │ │ Grounding / │ │ + │ │ │ Search │ │ + │ │ │ (Gemini 2pass│ │ + │ │ │ / Tavily) │ │ + │ │ └──────────────┘ │ + │ │ ┌───────────┐ │ + │ ├────────▶│ Validator │◀──┘ + │ │ └─────┬─────┘ + │ │ ┌─────▼─────┐ + │ ├────────▶│ Engine │ + │ │ └─────┬─────┘ + │ │ ┌─────▼─────┐ + │ ├────────▶│ Reporter │ + │ │ └─────┬─────┘ + │ ┌─────▼──────┐ ┌─────▼─────┐ + └──────────────────────────│ Firestore/ │ │ Cache │ + │ SQLite │ │ Redis/dict│ + └────────────┘ └───────────┘ +``` + +### 5.3 Storage + +- **Firestore (prod) / SQLite (dev).** Persisted analyses keyed by content hash of the input. Re-analyze of the same input within TTL returns the cached graph immediately and only re-runs the RZF engine if parameters changed. Also holds job records (`processing` / `complete` / `failed`) for the async flow. +- **Redis (prod) / in-memory dict (dev).** Hot cache for the LLM extraction results (most expensive step). 24-hour TTL with explicit invalidation. +- **Cloud Storage (prod) / local FS (dev).** Uploaded PDF inputs and exported reports. +- **BEA bundle (static).** The BEA 15-sector input-output table ships as a versioned JSON file in the engine container (`data/bea-15-sector.json`). No external fetch at runtime. + +### 5.4 Deployment topology + +| Concern | Production (Google Cloud) | Dev (local) | +|---|---|---| +| Frontend hosting | Cloud Run (Next.js standalone build) | `next dev` on port 3000 | +| Backend services | Cloud Run, one service per microservice | docker-compose, six containers | +| LLM | Gemini 2.5 Pro via Vertex AI (two-pass) | Groq gpt-oss-120b (OpenAI-compatible) | +| Web grounding | Vertex AI Grounding with Google Search (inside Gemini) | Tavily Search API (search_depth=advanced) | +| Database | Firestore Native mode | SQLite file in mounted volume | +| Cache | Memorystore Redis | Redis container in compose | +| Object storage | Cloud Storage bucket | Bind-mounted `./data` directory | +| Auth | Firebase Auth + Identity-Aware Proxy | API key in `.env` (single dev user) | +| Observability | Cloud Trace + Cloud Logging + Cloud Monitoring | OpenTelemetry → console exporter | +| Secrets | Secret Manager | `.env.local` (gitignored) | + +--- + +## 6. Provider Abstraction Layer + +### 6.1 Why an abstraction at all + +Two reasons. First, dev velocity: every contributor can run the whole system without Google Cloud credentials, GCP quotas, or billing — Groq gives free-tier inference fast enough for development, Tavily covers grounded search with a free tier. Second, vendor risk: the production stack should not be a single-vendor monolith. The abstraction is thin enough that adding a third provider (Anthropic, OpenAI, Perplexity, Brave Search) takes under a day. + +### 6.2 LLM provider interface + +```python +from pydantic import BaseModel +from typing import Protocol, TypeVar + +T = TypeVar("T", bound=BaseModel) + +class LLMProvider(Protocol): + name: str # "gemini-2.5-pro" | "groq/gpt-oss-120b" + + async def generate_structured( + self, system: str, user: str, schema: type[T], + temperature: float = 0.2, max_output_tokens: int = 4096, + ) -> T: ... + + async def generate_text( + self, system: str, user: str, + temperature: float = 0.4, max_output_tokens: int = 1024, + ) -> str: ... + + async def health(self) -> bool: ... +``` + +### 6.3 Search provider interface + +```python +class SearchResult(BaseModel): + title: str + url: str + snippet: str + published_at: datetime | None = None + score: float | None = None + +class SearchProvider(Protocol): + name: str + async def search( + self, query: str, max_results: int = 5, + include_domains: list[str] | None = None, recency_days: int | None = None, + ) -> list[SearchResult]: ... + async def fetch(self, url: str) -> str: ... +``` + +### 6.4 Production implementation — Gemini TWO-PASS (rewritten) + +> **Confirmed incompatibility.** A single Vertex `generate_content` call with both `response_schema` (controlled generation) and the `google_search` tool returns: +> +> ``` +> 400 INVALID_ARGUMENT: controlled generation is not supported with google_search tool. +> ``` +> +> This is an explicit, documented incompatibility — not a transient quirk. The v2.0 §6.4 code (schema + tools in one call) **does not work** and is replaced by the two-pass design below. + +**Two-pass design:** + +- **Pass A — grounding.** Call Gemini with `tools=[GoogleSearch()]` and **no** `response_schema`. Returns prose plus grounding metadata (the cited URLs). This is where real web evidence enters. +- **Pass B — structuring.** Call Gemini with `response_schema=Graph` and **no** tools. The user content is the prose output of Pass A. Returns the typed `Graph`. + +Cost is ~2× extraction tokens. There is still **no separate search-provider call** in prod — grounding stays inside Gemini — but there are now **two Gemini calls**. (The v2.0 "no separate search call" phrasing was technically true but misleading about call count; corrected here honestly.) + +```python +# providers/gemini.py +from google import genai +from google.genai.types import GenerateContentConfig, Tool, GoogleSearch + +class GeminiProvider: + name = "gemini-2.5-pro" + + def __init__(self): + self.client = genai.Client( + vertexai=True, + project=os.environ["GCP_PROJECT"], + location=os.environ["GCP_REGION"], + ) + + async def _ground(self, system: str, user: str, temperature: float) -> tuple[str, list[dict]]: + """Pass A: grounded prose + citation metadata. NO schema.""" + cfg = GenerateContentConfig( + system_instruction=system, + temperature=temperature, + tools=[Tool(google_search=GoogleSearch())], + ) + resp = await self.client.aio.models.generate_content( + model="gemini-2.5-pro", contents=user, config=cfg + ) + citations = extract_grounding_metadata(resp) # cited URLs for the citations panel + return resp.text, citations + + async def generate_structured(self, system, user, schema, temperature=0.2, **_): + # Pass A — ground + prose, citations = await self._ground(system, user, temperature) + # Pass B — structure (NO tools, schema only) + cfg = GenerateContentConfig( + system_instruction=system, + temperature=temperature, + response_mime_type="application/json", + response_schema=schema, + ) + resp = await self.client.aio.models.generate_content( + model="gemini-2.5-pro", + contents=f"Grounded findings:\n{prose}\n\nReturn the structured schema.", + config=cfg, + ) + obj = schema.model_validate_json(resp.text) + attach_citations(obj, citations) # carry grounding URLs into provider_metadata + return obj +``` + +> **Pass B failure mode (locked).** Pass A (grounded prose) can succeed while Pass B (structuring) returns invalid JSON — leaving grounding metadata with no usable graph. Gemini's schema mode is not fully reliable on long structured outputs, so this is engineered, not hoped away: **Pass B retries up to 3× with temperature annealing toward 0** (same prose input each time). On persistent failure it **falls back to a schema-less call** and the resulting free-form JSON is parsed and schema-validated in the `validator` service. A graph that survives neither path surfaces as `502 llm_provider_error` with the Pass-A citations attached for debugging. + +`VertexGroundingProvider` exists only to expose the Pass-A grounding metadata (cited URLs) to the citations panel; it issues no independent search query. + +### 6.5 Dev implementations + +#### Groq via OpenAI-compatible endpoint + +```python +# providers/groq.py +from openai import AsyncOpenAI + +class GroqProvider: + name = "groq/gpt-oss-120b" + + def __init__(self): + self.client = AsyncOpenAI( + base_url="https://api.groq.com/openai/v1", + api_key=os.environ["GROQ_API_KEY"], + ) + + async def generate_structured(self, system, user, schema, temperature=0.2, **_): + resp = await self.client.chat.completions.create( + model="openai/gpt-oss-120b", + messages=[ + {"role": "system", "content": system}, + {"role": "user", "content": user}, + ], + temperature=temperature, + response_format={ + "type": "json_schema", + "json_schema": {"name": schema.__name__, "schema": schema.model_json_schema()}, + }, + ) + return schema.model_validate_json(resp.choices[0].message.content) +``` + +#### Tavily for search + +```python +# providers/tavily.py +from tavily import AsyncTavilyClient + +class TavilyProvider: + name = "tavily" + + def __init__(self): + self.client = AsyncTavilyClient(api_key=os.environ["TAVILY_API_KEY"]) + + async def search(self, query, max_results=5, include_domains=None, recency_days=None): + resp = await self.client.search( + query=query, search_depth="advanced", max_results=max_results, + include_domains=include_domains or [], days=recency_days, + include_answer=False, include_raw_content=False, + ) + return [ + SearchResult(title=r["title"], url=r["url"], snippet=r["content"], score=r.get("score")) + for r in resp["results"] + ] +``` + +In dev mode the extractor performs explicit Tavily search calls before invoking Groq, then passes search snippets into the user prompt as grounding context. This mirrors the prod two-pass pathway in shape (ground → structure) while using a different underlying mechanism. + +### 6.6 Mode-switching contract + +- **`ZEROFORCE_MODE=prod`** requires `GCP_PROJECT`, `GCP_REGION`, and Application Default Credentials. No `GROQ_API_KEY` or `TAVILY_API_KEY` needed. +- **`ZEROFORCE_MODE=dev`** requires `GROQ_API_KEY` and `TAVILY_API_KEY`. No GCP credentials needed. Firestore → SQLite, Redis local, object storage = local FS. +- **Identical I/O contract.** Every prompt, every schema, every API response shape is identical between modes. The only observable difference is source-attribution metadata and call count (prod = 2 Gemini calls, dev = 1 Tavily + 1 Groq). +- **CI runs both modes.** PRs trigger the full integration suite against dev providers (Groq + Tavily) and a smoke-test suite against prod providers using a fixed test project. + +--- + +## 7. Data Model + +### 7.1 Core schemas (Pydantic) + +```python +class Node(BaseModel): + id: str # canonical key, e.g. "TSMC" + label: str # display name + sector: str # NAICS-aligned sector + country: str | None = None # ISO-3166 alpha-2 if known + tier: int | None = None # supply chain tier, 0 = focal firm + metadata: dict = {} # free-form provider-specific fields + +class Edge(BaseModel): + source: str # supplier node id + target: str # customer node id + weight: float = Field(ge=0, le=1) + confidence: Literal["high", "medium", "low"] + citation: str | None # source document, e.g. "Apple 10-K 2024 p.14" + reasoning: str # one-sentence justification + asof: date | None # date of source document + +class Graph(BaseModel): + nodes: list[Node] + edges: list[Edge] + focal_node: str # the node the user asked about + extraction_model: str # provider+model that built this graph + extraction_timestamp: datetime + +class NodeAnalysis(BaseModel): + node_id: str + ept: float # over R(v), the reachable set + n_reachable: int # |R(v)| - 1, EXCLUDES self + reachable_set: frozenset[str] + unreachable_from_here: frozenset[str] # V \ R(v) \ {v} + impact_score: float | None # n_reachable / ept; None for pure sinks + rank: int | None # rank by impact_score desc; None for sinks + percentile: float | None # 0-100, higher = more dangerous; None for sinks + +class AnalysisResult(BaseModel): + id: str # content hash of input + graph: Graph + node_analyses: list[NodeAnalysis] + risk_ranking: list[str] # node ids by impact_score desc (cascading only) + non_cascading_nodes: list[str] # pure sinks, shown separately + executive_report: str + validation_notes: list[str] # normalization, missing-supplier flags + provider_metadata: dict # mode, models used, call counts + created_at: datetime +``` + +### 7.2 Weight normalization rule (non-negotiable invariant) + +> **Hard invariant.** For every node v in V, `Σ w(u → v)` over all incoming edges = 1.0 (within float epsilon). Enforced after extraction, before the engine runs. +> +> - If extractor returns weights summing to `t < 1.0`, scale every weight by `1/t` and emit: `"Weights for {node} normalized from {t:.2f} to 1.0 — minor suppliers may be missing."` +> - If `t > 1.05`, reject and re-run with a corrective prompt. +> - **Engine consequence.** With the invariant holding, `total_in[v] = 1.0` always, so the RZF denominator is 1 and `p_disrupt[v] = disrupted_incoming_weight` directly (§4.3). The engine asserts `np.allclose(in_w.sum(axis=1), 1.0)` at entry; a violation is an engine bug, not a recoverable value. +> - **Source nodes** (no incoming edges) are exempt: they have no incoming weight to normalize and can only be disrupted as a seed. + +### 7.3 Impact-score conventions (locked) + +```python +class EngineInvariantError(Exception): + """Raised when the engine produces a structurally impossible value.""" + +def impact_score(node: str, ept: float, n_reachable: int) -> float | None: + # n_reachable EXCLUDES self. + if n_reachable == 0: + return None # pure sink: cannot cascade; ranked separately + if ept == 0.0: + raise EngineInvariantError( # any nonempty cascade takes >= 1 round + f"node {node}: n_reachable={n_reachable} but EPT=0" + ) + return n_reachable / ept +``` + +- `n_reachable` **excludes the seed itself.** A node reaching only itself has `n_reachable = 0`. +- Pure sinks (`n_reachable = 0`) get `impact_score = None`, render as "—", sort to the bottom, and live in a separate "non-cascading nodes" UI section. +- `ept == 0` with `n_reachable > 0` is structurally impossible; if produced, the engine raises (it is a bug). +- Ranking is by `impact_score` descending. Raw EPT stays a visible column. This eliminates the v2.0 "leaf with EPT=1 tops the table" demo trap. + +### 7.4 Database schema + +Firestore collections (prod) mirror these document shapes; SQLite uses normalized tables with JSON columns for nested structures. + +| Collection / Table | Key | Contents | TTL | +|---|---|---|---| +| `analyses` | `content_hash(input)` | Full `AnalysisResult` document | 30 days (refresh on access) | +| `jobs` | `id` | Async job state: `processing`/`complete`/`failed`, error detail | 7 days | +| `extractions` | `content_hash(input) + model` | Cached graph from LLM extraction | 24 hours | +| `user_sessions` | `user_id` | Saved analyses, what-if scenarios | No expiry | +| `audit_log` | autoincrement | Every analysis request with provider+cost | 365 days | + +--- + +## 8. Prompt Specifications + +### 8.1 Prompt 1 — Graph extraction + +Used by the extractor. In prod this runs as the two-pass flow: Pass A attaches the Google Search tool (grounding), Pass B structures the prose into the `Graph` schema. In dev, Tavily results are pre-fetched and concatenated into the user prompt, then Groq structures in one call. + +``` +SYSTEM: +You are a supply chain analyst with expertise in corporate procurement +and SEC financial disclosures. Given a natural language description of +a company, product, or supply chain, you extract a structured weighted +directed dependency graph. + +You favor verifiable data from 10-K filings, supplier disclosures, and +analyst reports over general knowledge. You explicitly mark estimated +weights as such. You never invent citations. + +USER: +Build a supply chain dependency graph for: {user_input} + +Constraints: +1. Identify 8 to 12 key nodes (companies or sub-sectors). Never exceed 18. +2. For each supplier relationship A -> B, emit one directed edge with: + - weight: fraction of B's total inputs sourced from A (0..1) + - confidence: "high" (cited 10-K / disclosure), "medium" (analyst + report), "low" (industry knowledge) + - citation: source document + page if available + - reasoning: one sentence +3. Incoming weights per node must sum to ~1.0 across edges with weight + > 0.05. Smaller suppliers should be aggregated into "Other {sector} suppliers". +4. Use web grounding aggressively. Cite real sources before estimating. +5. Mark the focal node (the company or product the user asked about). +6. Return only the structured Graph schema. No prose. +``` + +> Node-count guidance changed from "8 to 20" to "8 to 12, never exceed 18" to match the engine caps in §4.4. + +### 8.2 Prompt 2 — Validation pass (optional, recommended in prod) + +``` +Review this extracted supply chain graph for accuracy and completeness: + +{graph_json} + +Check: +1. Does each node's incoming weight sum to ~1.0? List violations. +2. Are there obvious major suppliers missing (e.g. ASML for TSMC, + Foxconn for Apple iPhone assembly)? +3. Are any weights implausibly high or low given the industry? +4. Are confidence levels appropriately conservative? + +Return the corrected graph in the same schema plus a "validation_notes" +array describing every change you made and why. +``` + +Disabled by default in dev for speed; enabled in prod for accuracy. + +### 8.3 Prompt 3 — Executive risk report + +``` +SYSTEM: +You are a supply chain risk consultant writing for a CFO or chief +procurement officer. You translate mathematical results into specific, +actionable business recommendations. No math jargon. No generic advice. + +USER: +Supply chain analyzed: {focal_node_label} +Total nodes: {n_nodes} +RZF results (ranked by impact = reachable nodes / EPT; higher = more dangerous): +{impact_ranked_results_json} +Raw EPT is shown alongside impact for context (lower EPT = faster local cascade). + +Top three risk nodes with their incoming-edge structure: +{top_3_with_context} + +Write a 3-paragraph executive risk report. + +Paragraph 1 — Critical finding. Name the single most dangerous supplier +by impact score, quote its EPT and how many nodes it reaches, and explain +in plain English why this specific node is the network's structural weak +point. Reference its dependents. + +Paragraph 2 — Top three risk nodes ranked by impact. Specific numbers. +For each, state what business function it supports and the cascade +implication. + +Paragraph 3 — Two or three actionable recommendations. Quantify the EPT +improvement where possible (e.g. "diversifying TSMC to dual-source with +Samsung Foundry would raise network EPT from 1.3 to 2.8 rounds, a 115% +resilience improvement"). Estimate cost band where reasonable. +``` + +### 8.4 Prompt 4 — What-if scenario narration + +``` +Given this what-if modification: +- Original edge: {source} -> {target} weight {old_w} +- New edge weight: {new_w} +- EPT for {target} changed from {old_ept} to {new_ept} ({delta} rounds) +- Most affected downstream nodes: {affected} + +Write one paragraph explaining what real-world action this models +(diversification, vertical integration, supplier consolidation), and +whether the resulting resilience change is meaningful. +``` + +--- + +## 9. API Specification + +### 9.1 Endpoints + +| Method | Path | Purpose | Auth | +|---|---|---|---| +| POST | `/api/v1/analyze` | Full pipeline. **Async by default** (returns job id). | Required | +| POST | `/api/v1/simulate` | Run RZF from a specific node mask; return round-by-round cascade. | Required | +| POST | `/api/v1/whatif` | Recompute EPT after modifying one or more edge weights. | Required | +| GET | `/api/v1/sectors` | Return pre-computed BEA 15-sector graph with EPT values. | Optional | +| GET | `/api/v1/analyses/{id}` | Retrieve a persisted analysis or job status (poll target). | Required | +| POST | `/api/v1/upload` | Upload a PDF for extraction (10-K, procurement doc). | Required | +| GET | `/api/v1/health` | Liveness and provider connectivity check. | None | +| GET | `/api/v1/version` | Build version + provider mode + model identifiers. | None | + +### 9.2 `/api/v1/analyze` (async by default) + +#### Request + +```http +POST /api/v1/analyze +Authorization: Bearer +Content-Type: application/json + +{ + "input": "Apple semiconductor supply chain", + "mode": "company", // "company" | "sector" | "product" + "max_nodes": 12, // default 12, hard cap 18 + "confidence_threshold": "low", // include edges >= this confidence + "enable_validation_pass": true, // run Prompt 2; default true in prod + "use_cached": true, // hit extraction cache if available + "wait": false // sync 200 only if true AND max_nodes <= 10 +} +``` + +#### Response — async (default), 202 Accepted + +```json +{ "id": "a8f3...", "status": "pending", "poll": "/api/v1/analyses/a8f3..." } +``` + +**Polling contract (locked).** `GET /api/v1/analyses/{id}` returns a `status` field with exactly one of: + +| `status` | Meaning | Body shape | +|---|---|---| +| `pending` | Job accepted, not yet started. | `{ id, status }` only | +| `running` | Pipeline in progress. | `{ id, status, stage? }` — no result | +| `done` | Complete. | Full `AnalysisResult` (the §9.2 sync body) | +| `failed` | Terminal error. | `{ id, status, error: { code, message } }` | + +The result body is present **only when `status == "done"`**; the frontend branches on `status` and must not read result fields before then. (These four supersede the looser "processing/complete" wording used narratively in §3.3 / §7.4 — `pending+running` ≙ "processing", `done` ≙ "complete".) + +**Job timeout (locked, distinct from the HTTP timeout).** The background job has a **5-minute hard cap**, independent of any per-request HTTP timeout. `3^18` is genuinely long; rather than stall silently, a job exceeding the cap flips to `status: "failed"` with `error.code = "computation_timeout"` and a message directing the caller to aggregate (§4.5). The frontend polls until `done` or `failed`. + +#### Response — sync (opt-in), 200 OK + +Returned only when `wait=true && max_nodes <= 10`. Body is the full `AnalysisResult`: + +```json +{ + "id": "a8f3...", + "status": "complete", + "graph": { "nodes": [], "edges": [], "focal_node": "Apple" }, + "node_analyses": [ + { + "node_id": "TSMC", + "ept": 1.31, + "n_reachable": 13, + "impact_score": 9.92, + "rank": 1, + "percentile": 99.2, + "unreachable_from_here": [] + } + ], + "risk_ranking": ["TSMC", "Samsung", "Foxconn"], + "non_cascading_nodes": ["FinalAssembledProduct"], + "executive_report": "Critical finding: TSMC represents the single...", + "validation_notes": [ + "Weights for Apple normalized from 0.92 to 1.0", + "Confidence 'low' on 3 edges — see graph for details" + ], + "provider_metadata": { + "mode": "prod", + "llm_model": "gemini-2.5-pro", + "search_provider": "vertex-grounding", + "gemini_calls": 2, + "search_calls": 0, + "compute_ms": 487 + }, + "created_at": "2026-05-22T14:23:01Z" +} +``` + +#### Error responses + +| Status | Code | When | +|---|---|---| +| 400 | `invalid_input` | Input string empty or > 5000 chars | +| 400 | `unreachable_graph` | Graph has zero reachable nodes from focal — nothing to cascade | +| 400 | `too_many_nodes` | `max_nodes > 18` requested | +| 422 | `normalization_failed` | Incoming weights for some node sum to > 1.05 even after retry | +| 429 | `rate_limited` | Free tier quota exceeded | +| 502 | `llm_provider_error` | Underlying LLM call failed after 3 retries | +| 504 | `computation_timeout` | Engine exceeded budget (graph too large for exact) — async jobs flip to `failed` | + +> The 504 timeout cliff from v2.0 is largely removed by the async default; sync is only allowed for `max_nodes ≤ 10`, which is comfortably under the time budget. + +### 9.3 `/api/v1/simulate` + +```http +POST /api/v1/simulate +{ + "analysis_id": "a8f3...", + "starting_nodes": ["TSMC"], + "rounds": 10, + "samples": 1000 +} + +Response: +{ + "rounds": [ + { "round": 0, "disrupted": ["TSMC"], "node_probabilities": { "TSMC": 1.0, "Samsung": 0.0 } }, + { "round": 1, "node_probabilities": { "TSMC": 1.0, "Apple": 0.42 } } + ], + "expected_ept": 1.31, + "p95_rounds": 3, + "p99_rounds": 5 +} +``` + +### 9.4 `/api/v1/whatif` + +```http +POST /api/v1/whatif +{ + "analysis_id": "a8f3...", + "modifications": [ + { "edge": { "source": "TSMC", "target": "Apple" }, "new_weight": 0.20 }, + { "add_edge": { "source": "Samsung-Foundry", "target": "Apple", "weight": 0.22 } } + ] +} + +Response: +{ + "baseline_ept": { "Apple": 4.2, "TSMC": 1.31 }, + "modified_ept": { "Apple": 5.1, "TSMC": 2.84 }, + "delta": { "Apple": 0.9, "TSMC": 1.53 }, + "narration": "Adding Samsung as a second-source foundry reduces..." +} +``` + +> What-if modifications re-trigger normalization (§7.2) on any touched target node before re-running the engine, preserving the weight invariant. + +--- + +## 10. Frontend Specification + +### 10.1 Stack + +- Next.js 14 (App Router) with TypeScript strict mode. +- Tailwind CSS + shadcn/ui for the design system. +- `react-force-graph-2d` for the network visualization (canvas-based, hardware-accelerated, 100+ nodes). +- `zustand` for client state. TanStack Query for server state (including poll-until-complete on the async analyze job). +- Framer Motion for cascade animation transitions. +- Vercel AI SDK on the frontend for report streaming. + +### 10.2 Pages and routes + +| Route | Purpose | +|---|---| +| `/` | Hero landing page. Single input box. Three example chips. | +| `/analyze/[id]` | Three-panel results dashboard. Primary working surface. Polls job status while `processing`. | +| `/sectors` | Pre-computed BEA 15-sector view as a worked example. | +| `/history` | User's saved analyses with thumbnails. | +| `/docs/math` | Public-facing explanation of RZF and EPT with an interactive small-example walkthrough. | +| `/api-status` | Provider health, mode indicator, response time history. | + +### 10.3 Results dashboard layout + +The `/analyze/[id]` page is a three-panel layout for 1440px+ displays. Below 1280px it collapses to a tabbed single-column view. + +| Panel | Width | Contents | +|---|---|---| +| Left rail | 320px | Input recap, mode toggle, confidence filter, sector aggregation toggle, what-if scenario list. | +| Center stage | flex-1 | Force-directed risk map. Full viewport height. Hover, click, drag supported. | +| Right rail | 380px | Tabs: (1) Risk ranking table (impact-ranked, with raw EPT + n_reachable columns + non-cascading section), (2) Executive report, (3) Citations, (4) Validation notes. | + +### 10.4 Visual encoding + +- **Node color:** continuous red→yellow→green scale on **impact_score** (red = highest impact / most dangerous, green = lowest). Pure sinks render grey. Color stops anchored to graph-relative percentiles, not absolute values. +- **Node size:** proportional to `n_reachable` (downstream blast radius). +- **Node border:** solid for high-confidence weights on all incoming edges; dashed when any incoming edge is low-confidence. +- **Edge width:** proportional to weight. Thicker = stronger dependency. +- **Edge color:** blue (high confidence), amber (medium), grey (low). +- **Disrupted state:** pulsing red glow with concentric ripple. Round counter overlay in top-right. +- **Unreachable disclosure:** when a node is selected, nodes in its `unreachable_from_here` set are dimmed, with a tooltip: "This supplier's failure doesn't reach N nodes: …". + +### 10.5 Cascade animation + +When the user clicks a node, the system fetches `/api/v1/simulate` and animates the cascade. Timing: 800ms per round, slider 0.25× to 4×. Each round, nodes that transitioned to disrupted fade white→red over 600ms; still-white nodes pulse with opacity proportional to current disruption probability. + +A horizontal timeline below the graph shows rounds 0–N as a step indicator. Clicking a step seeks. A pause/play control overlays the graph. + +### 10.6 What-if mode + +Activated by a "What if?" button in the right rail. The user can drag edge weights with a numeric input or slider, add a new supplier from a typeahead, or click to delete an edge. A diff panel shows pending modifications. "Compute" submits to `/api/v1/whatif` and renders the modified graph side-by-side with the baseline, with a delta column on the ranking table. + +### 10.7 Accessibility + +- WCAG 2.1 AA target. Color is never the only encoding — node danger is also surfaced in the ranking table, tooltips, and the executive report. +- Keyboard navigation: tab cycles through nodes in impact rank order. Enter on a focused node triggers the cascade simulation. +- Screen-reader announcements for cascade steps via ARIA live regions. +- Prefers-reduced-motion disables cascade animation and shows a step-by-step static view. + +--- + +## 11. Testing Strategy + +### 11.1 Mathematical correctness — the non-negotiable layer (revised oracle strategy) + +> ## ⚠️ THE MOST LOAD-BEARING DECISION IN THE PROJECT +> +> The oracle strategy is what prevents the project's worst failure: **tests green, math wrong, a customer makes a procurement decision on a silently-bad EPT.** Read this before touching the engine or its tests. +> +> - The §4.6 closed-form formulas are **UNVERIFIED transcriptions** from an unrefereed preprint. They are marked `xfail(strict=False)` **on purpose**. +> - **The only way to unblock an xfail is to read the arXiv:2602.16300 paper body, confirm the formula line-by-line, and cite the verifying theorem number in the test docstring** — then drop the marker in a dedicated commit. +> - **Do NOT "fix" a failing closed-form test by re-transcribing the constant more carefully from the spec table.** That makes the test pass *tautologically against an unverified number* and defeats the entire safety mechanism. If you find yourself editing the expected value to match the engine, STOP. +> - The gating oracles (hand-computed graph, Monte Carlo differential, engine parity) trust the paper **zero** and are the real proof of correctness. They, not the closed forms, gate CI. + +The RZF engine's tests cannot be allowed to lie. The failure mode being designed against: encode a transcribed-wrong formula as the expected value, the test passes tautologically, the engine is silently wrong in production. The strategy below uses oracles that trust the paper as little as possible. + +**Oracle tiers:** + +| Tier | Oracle | Trusts paper? | Status | +|---|---|---|---| +| **Hard (lock now)** | Hand-computed 3-node absorbing-leaf graph; EPT derived from first principles in the test docstring. | No | Gating | +| **Independent (lock now)** | Monte Carlo differential: simulate RZF 100k times in pure Python for n≤6 random graphs, average rounds, compare to DP to 3 decimals. The engine's definition is implemented **twice** (DP + simulator); agreement is real evidence. | No | Gating | +| **Engine parity (lock now)** | Pure-Python reference DP vs numba JIT DP — exact agreement on identical graphs. | No | Gating | +| **Soft (xfail until verified)** | §4.6 closed forms (path 2n−3, star 1/n, cycle n−k, arborescence depth, K_n Θ(log n)). | Yes | `@pytest.mark.xfail(strict=False)` until paper body read and theorem # cited in docstring; then xfail dropped in a follow-up commit. | + +The Monte Carlo differential test is the strongest check: closed forms could be transcribed wrong in two places, but an independent simulator and the DP agreeing to 3 decimals is hard to fake. + +| Test family | Graph | Expected | Tolerance | Gating? | +|---|---|---|---|---| +| Hand-computed oracle | 3-node absorbing leaf | derived in docstring | < 1e-9 | Yes | +| Monte Carlo differential | random n≤6 | simulator mean | < 1e-2 (3 dp) | Yes | +| Engine parity | random n≤12 | py == numba | exact (1e-12) | Yes | +| Monotonicity | random + add seed | EPT decreases | sign check | Yes | +| Weight invariance | C_n random weights | EPT unchanged vs uniform | < 1e-9 | Yes | +| Invariant guards | pure sink / ept=0 case | None / raises | exact | Yes | +| Path endpoints | P_3..P_9 | 2n − 3 | < 1e-9 | xfail | +| Star center / leaf | K_{1,n-1} | 1 / n | exact | xfail | +| Cycle uniform | C_n, k blue | n − k | < 1e-9 | xfail | +| Arborescence | random rooted trees | depth | exact | xfail | +| Complete graph | K_n | Θ(log n) bounds | asymptotic | xfail | + +### 11.2 Provider tests — split into two suites (renamed) + +The v2.0 "provider parity" with a 15% EPT threshold is dropped: two LLMs extract different weights, and EPT is a nonlinear DP, so EPT will diverge far past 15%. Replaced by: + +**(a) Engine parity** — `tests/parity/test_engine_parity.py`. Same graph in → **identical** EPT out across both stacks. This is exact because the engine never touches the LLM. Fixed golden graphs: the BEA 15-sector graph + 5 hand-curated graphs. + +**(b) Extraction consistency** (renamed from "parity") — `tests/parity/test_extraction_consistency.py`. Same natural-language input → both stacks should agree on structure, not numbers: +- Node set Jaccard similarity ≥ 0.7. +- Top-3 risk nodes overlap ≥ 2. +- **No EPT numerical comparison.** EPT is a property of the engine on a fixed graph, not of extraction. + +### 11.3 Frontend tests + +- Vitest unit tests for utility code (color scales, layout helpers, time formatting). +- React Testing Library component tests for every panel. +- Playwright e2e: cold-start analysis (with async poll), cached re-analyze, cascade animation playback, what-if scenario, mobile collapsed layout. +- Visual regression via Chromatic on every PR. + +### 11.4 Load and chaos + +- k6 load test scenarios for `/analyze` at 10 RPS sustained, 100 RPS burst (against the async job path). +- Chaos test: kill the LLM provider mid-extraction; verify circuit breaker trips and the cached path serves stale results; verify the job flips to `failed` with a useful error. +- Cost regression: every analyze call emits LLM input/output token counts; CI fails if median tokens per analysis grows > 20% over a 7-day window. (Account for the 2× prod extraction tokens from the two-pass design when setting the baseline.) + +--- + +## 12. Security and Compliance + +### 12.1 Threat model + +| Threat | Surface | Mitigation | +|---|---|---| +| Prompt injection | User-supplied input fed to LLM | Structured-output mode (Pass B) + schema validation rejects anything not matching `Graph`. Untrusted text never executes tool calls; Pass A grounding output is treated as untrusted text fed into Pass B. | +| LLM cost exhaustion | Analyze endpoint | Per-IP and per-user quotas. Cap `max_nodes` (hard 18) and recursion depth. Hard token budget per request. | +| PII in uploads | `/upload` | Reject PDFs over 20MB. Scan for SSN/CCN patterns and redact before sending to LLM. Documented in privacy notice. | +| Credential exposure | Provider keys | Secret Manager (prod) and `.env.local` (dev). No keys in client bundles. Frontend never holds LLM keys — all LLM calls go through backend. | +| Citation forgery | LLM hallucinating sources | Cross-verify cited URLs via the search provider before storing. Mark unverified citations as "claimed by model" with visible badge. Pass A grounding metadata is the source of truth for prod citations. | +| Cross-tenant data leak | Firestore | Security rules enforce `userId` on every read. Analyses user-scoped by default; sharing requires explicit opt-in. | + +### 12.2 Compliance posture + +- All processing in single region per analysis (us-central1 default). Data residency configurable per workspace. +- No supply chain data is used to train upstream LLMs. Vertex AI configured with explicit no-training flags; Groq dev usage carries the same disclosure for development teams to acknowledge before key issuance. +- SOC 2 Type II is a Phase 2 goal. Pre-SOC 2 deployments restricted to non-production data and labeled as such in the UI. + +--- + +## 13. Observability and Cost + +### 13.1 What we measure + +- **Latency:** P50/P95/P99 for each service. Extraction dominates (now 2 Gemini calls in prod). +- **Token usage:** Per analysis, broken down by prompt (extraction Pass A / Pass B / validation / report). Logged with model identifier. +- **Cache hit rate:** Extraction cache hits vs misses. Direct lever on cost. +- **RZF compute time:** Per graph size. Validates scaling assumptions and the n≤18 cap. +- **Provider error rate:** LLM timeouts, search failures, schema validation failures. +- **Job lifecycle:** processing → complete/failed counts and durations. +- **User funnel:** input submitted → graph rendered → first node clicked → what-if executed → report read to bottom. + +### 13.2 Cost model (production target) + +| Item | Per-analysis cost | Notes | +|---|---|---| +| Gemini extraction Pass A (grounding) | ~$0.03 | Grounded prose, ~8k input + 1.5k output | +| Gemini extraction Pass B (structuring) | ~$0.02 | Re-feeds prose, structured output | +| Gemini validation pass | ~$0.02 | Re-feeds the graph; smaller output | +| Gemini executive report | ~$0.015 | 2k input + 500 output | +| Vertex grounding (search calls) | ~$0.01 | Bundled with Pass A in 2026 pricing | +| Compute (Cloud Run) | < $0.001 | Engine is CPU-bound and brief | +| Firestore + storage | < $0.001 | Negligible at this scale | +| **Total per fresh analysis** | **~$0.095** | Up from v2.0 ~$0.085 due to two-pass extraction. Cached re-analyze ≈ $0.015 (report only). | + +Dev mode cost is ~$0.005 per analysis (Groq gpt-oss-120b token prices) plus Tavily free-tier search. A developer running the full integration suite (~50 analyses) costs under $0.30. + +--- + +## 14. Roadmap + +### 14.1 Build phases + +| Phase | Duration | Deliverables | +|---|---|---| +| **Phase 0 — Foundation** | 2 weeks | Monorepo scaffold. Provider abstraction. **RZF engine (`packages/zeroforce/`) with full oracle test suite (hard + MC differential + engine parity) gating CI; closed-form tests present but xfail.** | +| **Phase 1 — Vertical slice** | 3 weeks | End-to-end `/analyze` (async) working in dev mode against Groq+Tavily. Basic graph render in Next.js. No animation, no what-if. | +| **Phase 2 — Production parity** | 2 weeks | Gemini two-pass + Vertex grounding providers. Production deploy on Cloud Run. Engine-parity + extraction-consistency suites passing on golden set. Verify §4.6 closed forms against paper, drop xfail. | +| **Phase 3 — Interactivity** | 3 weeks | Cascade animation, what-if mode, citations panel, executive report streaming. Mobile responsive. | +| **Phase 4 — Hardening** | 2 weeks | Load testing, chaos testing, security review, accessibility audit, observability dashboards. | +| **Phase 5 — Beta launch** | 2 weeks | Five design partners onboarded. Real supply chains validated. Iterate. | + +### 14.2 Post-launch product directions + +- **Continuous monitoring mode.** Ingest a webhook of supplier-status events from Interos/Resilinc/Everstream; continuously recompute EPT as upstream signals change. Push alerts when a top-3 risk node's status degrades. +- **Resilience optimizer.** Given a budget B to spend hardening relationships, solve the constrained maximization: pick edges to modify that maximize minimum EPT across the network. Extends the RZF throttling literature. +- **Temporal RZF.** Extend to time-varying graphs. Compute EPT under scheduled supplier transitions or rolling contracts. +- **Recovery modeling.** Allow disrupted nodes to recover (blue → white) with a recovery rate. Mathematically harder; practically critical. +- **Embedded API platform.** White-label the engine for existing SCRM vendors. +- **Industry-specific tuners.** Ship pre-tuned extractors for semiconductors, pharma, auto with sector defaults for missing weights. + +### 14.3 Open research questions + +- **Throttling under weighted RZF:** optimal initial set S minimizing `|S| + EPT(G, S)`. Open in the published paper. +- How does optimal hardening strategy change as edge weights become more/less concentrated? Unstudied. +- Sample complexity for learning weights from observed disruption histories — can we Bayesian-update LLM-extracted weights when historical events exist? + +--- + +## 15. Build Plan — Engine First + +### 15.1 Why engine first + +Of every component, the RZF engine is the only one where being wrong is **catastrophic and silent**: a subtly wrong EPT produces a plausible-looking number with no error signal, and a customer makes a procurement decision on it. Everything else (extraction, UI, providers) fails loudly and recovers. So the engine ships first, with math-correctness gating CI, before anything is built on top. + +### 15.2 `packages/zeroforce/` layout + +``` +packages/zeroforce/ +├── pyproject.toml +├── .python-version # 3.12.7 +├── README.md # the math, the recurrence, the invariant, the oracle strategy +├── zeroforce/ +│ ├── __init__.py +│ ├── types.py # Pydantic Node, Edge, Graph, NodeAnalysis, EngineInvariantError +│ ├── reachability.py # transitive closure, induced subgraph G[R(v)] extraction +│ ├── 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] +└── tests/ + ├── test_oracle_handcomputed.py # 3-node absorbing-leaf, derivation in docstring [GATING] + ├── test_differential.py # MC simulator vs DP, n<=6, 3 decimals [GATING] + ├── test_engine_parity.py # pure-Python vs numba, exact [GATING] + ├── test_properties.py # monotonicity, weight invariance on cycles [GATING] + ├── test_invariants.py # impact=None for sinks; raise on ept=0&reach>0 [GATING] + └── test_closed_forms.py # §4.6 table, xfail(strict=False) until verified +``` + +### 15.3 Dependency pins (locked) + +```toml +[project] +requires-python = ">=3.12,<3.13" + +[tool.uv] +python = "3.12.7" + +[project.dependencies] +numba = ">=0.61,<0.64" +numpy = ">=1.26,<3.0" # widened: numba 0.61 supports numpy<=2.1, but 0.62/0.63 + # support later numpy. A tight <2.2 would be wrong if uv + # resolves numba 0.63. The lockfile pins one compatible + # (numba, numpy) pair; CI runs an `import numba` smoke test + # against the resolved versions to catch a bad pairing at + # build time rather than at first JIT. (See §15.4.) +pydantic = ">=2.7,<3.0" +networkx = ">=3.2,<4.0" + +[project.optional-dependencies] +test = ["pytest>=8", "pytest-xdist", "hypothesis>=6"] +``` + +> **numpy pin rationale (locked, revised in v2.2).** numba 0.61 supports numpy ≤2.1.x; if uv resolved numpy 2.2+ against numba 0.61, `import numba` throws at runtime (not install) on first JIT. The v2.1 fix was an over-tight `numpy>=1.26,<2.2` — but that is *wrong* whenever uv resolves numba **0.63** (which supports later numpy), needlessly capping it. v2.2 resolution: **widen the range to `numpy>=1.26,<3.0` and let uv pick the compatible pair**, pinning it exactly in the lockfile, and add a CI **`import numba` smoke test** against the resolved versions so a bad (numba, numpy) pairing fails at build time, not at first JIT. The earlier alternative (pin `numba==0.61.x` exactly + tight numpy) was rejected as too rigid for a one-line range. + +### 15.4 CI gate + +`.github/workflows/engine.yml`: Python 3.12 → `uv sync` → **`python -c "import numba"` smoke test** (catches a bad resolved (numba, numpy) pair before any JIT, per §15.3) → `pytest`. Merge is blocked unless the smoke test and all GATING tests pass. `xfail(strict=False)` closed-form tests are allowed to xpass or xfail without blocking; when the paper body is verified, the xfail markers are removed in a dedicated commit and those tests become gating. + +### 15.5 Locked engine conventions (recap from review) + +1. **Recurrence:** simplified (normalized weights → denom=1), self-loop fixed (`next_states ≠ mask`). +2. **Reachability:** EPT scope = `R(v)`; engine runs on induced subgraph `G[R(v)]`; `unreachable_from_here` reported, never `∞`. +3. **Ranking:** by `impact_score = n_reachable / EPT` (descending); `n_reachable` excludes self; pure sinks → `None`, ranked separately; `ept==0 && n_reachable>0` raises `EngineInvariantError`. +4. **Oracles:** hand-computed + Monte Carlo differential + engine parity gate CI; §4.6 closed forms xfail until paper-verified. +5. **Caps:** default `max_nodes=12`, hard cap 18. +6. **Pins:** Python 3.12.7, numba ≥0.61<0.64, numpy ≥1.26<3.0 (lockfile pins exact pair; CI `import numba` smoke test guards the pairing). + +--- + +## 16. Decision Log + +Decisions reached during spec review, in resolution order. + +### Round 1 — architectural corrections + +| # | Decision | Rationale | +|---|---|---| +| R1-1 | Gemini schema+grounding is **incompatible**; use **two-pass** (ground → structure). | Confirmed live: `400 INVALID_ARGUMENT: controlled generation is not supported with google_search tool.` §6.4 rewritten. ~2× extraction tokens. | +| R1-2 | `/analyze` **async by default**; `max_nodes` default 12, hard cap 18; sync only when `max_nodes≤10 && wait=true`. | n=20 in <60s is unrealistic in Python+numba; removes the timeout cliff. | +| R1-3 | DP recurrence: **self-loop fix** (`next_states ≠ mask`) + **normalized-weight simplification** (denom=1). | v2.0 pseudocode double-counted the stay state and divided by a known-1 denominator. | +| R1-4 | Rank by **impact-weighted EPT** (`n_reachable / EPT`), not raw EPT. | Raw-EPT ranking puts a trivial leaf (EPT=1, one downstream) above a hub (EPT=2.5, 14 downstream). | +| R1-5 | Split provider tests: **engine parity** (exact) + **extraction consistency** (Jaccard≥0.7, top-3 overlap≥2, no EPT compare). | 15% EPT threshold across two LLM extractions is unrealistic; EPT is a nonlinear engine property, not an extraction property. | +| R1-6 | Name: **zeroforce** (product), `zeroforce/` repo, `packages/zeroforce/` engine pkg. | "zeroforce" is the customer-facing name; "zeroforce" stays as the engine nickname. | +| R1-7 | Build **dev-first** (Groq+Tavily+SQLite), Gemini in Phase 2. | No GCP billing friction; abstraction tested from day one; two-pass design doesn't block engine. | + +### Round 2 — engine details + +| # | Decision | Rationale | +|---|---|---| +| R2-1 | Closed-form §4.6 formulas are **UNVERIFIED**; do not use as oracle until paper body read and theorem # cited. Trusted oracles: hand-computed graph + Monte Carlo differential + engine parity. Closed-form tests `xfail(strict=False)` until verified. | A transcribed-wrong formula encoded as expected value passes tautologically and hides a silent engine bug. MC differential trusts the paper zero. | +| R2-2 | `n_reachable` **excludes self**; pure sinks → `impact_score=None`, ranked separately; `ept==0 && n_reachable>0` → raise `EngineInvariantError`. | Counting self inflates trivial graphs; any nonempty cascade takes ≥1 round, so ept=0 with downstream is impossible. | +| R2-3 | EPT scope = `R(v)`; unreachable nodes in `unreachable_from_here`, never `∞`; engine runs on induced subgraph `G[R(v)]`. | Measuring over V makes EPT infinite for any non-strongly-connected graph (i.e. nearly all). Also a perf win. | +| R2-4 | Python **3.12.7**; numba **≥0.61,<0.64**; numpy **≥1.26,<3.0** (v2.2: widened from the v2.1 over-tight `<2.2`). Lockfile pins the exact compatible pair; CI runs an `import numba` smoke test against the resolved versions. | 3.12 has a year of patches and universal dep support. A tight `<2.2` is wrong if uv resolves numba 0.63 (supports later numpy); widening + a build-time smoke test catches a bad (numba, numpy) pairing before first JIT instead of capping numba needlessly. | + +### Verification status of the reference paper + +arXiv:2602.16300 confirmed to exist (real authors with a zero-forcing track record; abstract matches the definitions used here; not retracted as of 2026-05-22). It remains a **preprint, not peer-reviewed**. The §4.6 constants are not yet verified against the paper body — see R2-1. + +--- + +## 17. Appendix + +### 17.1 Full repository layout + +``` +zeroforce/ +├── apps/ +│ ├── web/ # Next.js 14 frontend +│ └── docs/ # Public RZF explainer site +├── services/ +│ ├── gateway/ # FastAPI auth & routing +│ ├── extractor/ # Prompt 1 + grounding (two-pass in prod) +│ ├── validator/ # Weight normalization +│ ├── engine/ # RZF service wrapper around packages/zeroforce +│ ├── reporter/ # Prompt 3 streaming +│ └── orchestrator/ # Pipeline coordinator + async job state +├── packages/ +│ ├── zeroforce/ # RZF engine (see §15.2) — BUILT FIRST +│ ├── providers/ # LLM + search abstraction (Python) +│ ├── schemas/ # Pydantic models, shared with frontend via codegen +│ └── ui/ # shadcn-derived component library +├── infra/ +│ ├── terraform/ # GCP infrastructure as code +│ ├── docker-compose.dev.yml # Local dev stack +│ └── k8s/ # Future migration target +├── tests/ +│ ├── unit/ +│ ├── parity/ # engine parity + extraction consistency +│ ├── load/ # k6 scripts +│ └── golden/ # canonical supply chain inputs + golden graphs +├── data/ +│ └── bea-15-sector.json # BEA input-output bundle +├── .agents/ +│ └── project_specifications.md # THIS FILE +├── .github/workflows/ # CI: math tests (gating), parity, deploy +├── README.md +├── ARCHITECTURE.md +└── pyproject.toml # uv-managed monorepo Python deps +``` + +### 17.2 Glossary + +| Term | Definition | +|---|---| +| **RZF** | Randomized Zero Forcing — the stochastic spreading process this product implements (Geneson et al., 2026). | +| **EPT** | Expected Propagation Time — average rounds from initial disruption to full coverage of the reachable set `R(v)`. | +| **Edge weight w(A→B)** | Fraction of node B's total inputs sourced from A. Incoming weights per node sum to 1.0 (§7.2). | +| **impact_score** | `n_reachable / EPT`; the ranking metric. Higher = more dangerous. `None` for pure sinks. | +| **n_reachable** | `|R(v)| - 1` — count of nodes reachable from v, excluding v itself. | +| **R(v) / reachable set** | Nodes reachable from v via positive-weight directed paths (includes v). EPT is measured over this set. | +| **Throttling number th(G)** | `min over S of |S| + EPT(G,S)`. Optimal trade-off between monitoring breadth and acceptable propagation time. | +| **Focal node** | The node the user asked about — the analysis is centered on this firm/product/sector. | +| **SCRM** | Supply Chain Risk Management — the software category zeroforce operates in. | +| **Grounding** | LLM web-search augmentation. Prod: Vertex grounding inside Gemini Pass A. Dev: Tavily fed into the prompt. | +| **Two-pass extraction** | Prod workaround for the schema+grounding incompatibility: Pass A grounds (tools, no schema), Pass B structures (schema, no tools). | +| **Provider** | A pluggable LLM or search backend behind the abstraction (Gemini, Groq, Tavily, etc.). | +| **BEA** | US Bureau of Economic Analysis. Input-output tables provide validated sector-level edge weights. | +| **Markov chain DP** | The algorithm computing exact EPT — states are subsets of disrupted nodes, processed by popcount. | +| **MC differential test** | Monte Carlo simulator vs DP agreement check; the primary engine oracle that trusts no published formula. | +| **EngineInvariantError** | Raised when the engine produces a structurally impossible value (e.g. `ept=0` with reachable downstream). | + +### 17.3 Key references + +- Geneson, J., Hicks, I., Lichtenberg, N., Moon, A., Robles, N. "Randomized Zero Forcing." arXiv:2602.16300, February 2026. *Preprint, not peer-reviewed. §4.6 constants pending body verification.* +- Geneson, J., Hogben, L. "Propagation time for probabilistic zero forcing." arXiv:1812.10476, 2018. Foundational predecessor to RZF. +- Bureau of Economic Analysis. Input-Output Accounts Data (15-Sector Summary). bea.gov. +- Tavily Documentation. docs.tavily.com. +- Groq Console. console.groq.com/docs/models. +- Vertex AI Documentation. cloud.google.com/vertex-ai. +- Numba release notes (0.61 Jan 2025 — Python 3.13 support; 0.63 Dec 2025 — Python 3.14 support, current stable 0.63.1). + +--- + +**ZEROFORCE | Supply Chain Disruption Intelligence** +*Built on Randomized Zero Forcing (arXiv:2602.16300) × Gemini AI × Google Cloud* +*Spec v2.1 | Ali Malik | San Jose State University | May 22, 2026* diff --git a/.github/workflows/engine.yml b/.github/workflows/engine.yml new file mode 100644 index 0000000..6fb7169 --- /dev/null +++ b/.github/workflows/engine.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e99f050 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/packages/zeroforce/.python-version b/packages/zeroforce/.python-version new file mode 100644 index 0000000..56bb660 --- /dev/null +++ b/packages/zeroforce/.python-version @@ -0,0 +1 @@ +3.12.7 diff --git a/packages/zeroforce/README.md b/packages/zeroforce/README.md new file mode 100644 index 0000000..44479bc --- /dev/null +++ b/packages/zeroforce/README.md @@ -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] +``` diff --git a/packages/zeroforce/pyproject.toml b/packages/zeroforce/pyproject.toml new file mode 100644 index 0000000..6759e38 --- /dev/null +++ b/packages/zeroforce/pyproject.toml @@ -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"] diff --git a/packages/zeroforce/tests/conftest.py b/packages/zeroforce/tests/conftest.py new file mode 100644 index 0000000..908cef2 --- /dev/null +++ b/packages/zeroforce/tests/conftest.py @@ -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) diff --git a/packages/zeroforce/tests/test_closed_forms.py b/packages/zeroforce/tests/test_closed_forms.py new file mode 100644 index 0000000..7af7fa0 --- /dev/null +++ b/packages/zeroforce/tests/test_closed_forms.py @@ -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) diff --git a/packages/zeroforce/tests/test_differential.py b/packages/zeroforce/tests/test_differential.py new file mode 100644 index 0000000..0c7dc3e --- /dev/null +++ b/packages/zeroforce/tests/test_differential.py @@ -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}" diff --git a/packages/zeroforce/tests/test_engine_parity.py b/packages/zeroforce/tests/test_engine_parity.py new file mode 100644 index 0000000..d828f93 --- /dev/null +++ b/packages/zeroforce/tests/test_engine_parity.py @@ -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}" diff --git a/packages/zeroforce/tests/test_invariants.py b/packages/zeroforce/tests/test_invariants.py new file mode 100644 index 0000000..4642794 --- /dev/null +++ b/packages/zeroforce/tests/test_invariants.py @@ -0,0 +1,43 @@ +"""GATING — invariant guards (spec §7.2, §7.3).""" + +from __future__ import annotations + +import pytest + +from zeroforce.api import compute +from zeroforce.ranking import impact_score +from zeroforce.types import Edge, EngineInvariantError, Graph, Node + + +def test_pure_sink_impact_is_none(): + assert impact_score("X", ept=0.0, n_reachable=0) is None + + +def test_ept_zero_with_downstream_raises(): + with pytest.raises(EngineInvariantError): + impact_score("X", ept=0.0, n_reachable=3) + + +def _graph(edges: list[Edge]) -> Graph: + ids = {e.source for e in edges} | {e.target for e in edges} + return Graph( + nodes=[Node(id=i, label=i, sector="s") for i in sorted(ids)], + edges=edges, + focal_node=sorted(ids)[0], + extraction_model="test", + extraction_timestamp="2026-05-22T00:00:00Z", + ) + + +def test_compute_rejects_unnormalized_incoming_weights(): + # B's incoming sums to 0.92, not 1.0 -> normalization was not run upstream. + g = _graph([Edge(source="A", target="B", weight=0.92, confidence="high")]) + with pytest.raises(EngineInvariantError): + compute(g) + + +def test_compute_accepts_source_nodes_with_zero_incoming(): + # A is a source (no incoming, row sum 0); B's incoming = 1.0. Should not raise. + g = _graph([Edge(source="A", target="B", weight=1.0, confidence="high")]) + res = compute(g) + assert {a.node_id for a in res} == {"A", "B"} diff --git a/packages/zeroforce/tests/test_oracle_handcomputed.py b/packages/zeroforce/tests/test_oracle_handcomputed.py new file mode 100644 index 0000000..75594aa --- /dev/null +++ b/packages/zeroforce/tests/test_oracle_handcomputed.py @@ -0,0 +1,100 @@ +"""GATING oracle — hand-computed 3-node graph, EPT derived from first principles. + +This oracle trusts the arXiv:2602.16300 paper ZERO. Every expected value below is +derived by hand in this docstring from the RZF rule (spec §4.1), not transcribed from +the paper's closed-form table (§4.6, which is UNVERIFIED). + +THE GRAPH +--------- +Three nodes A, B, C. A is a pure source (no incoming edges; can only be disrupted as a +seed). Directed weighted edges (supplier -> customer), with each customer's incoming +weights summing to 1.0 per the §7.2 invariant: + + A --0.5--> B + A --1.0--> C + C --0.5--> B + +So B's incoming = {A:0.5, C:0.5} (sum 1.0); C's incoming = {A:1.0} (sum 1.0); A has none. + +RZF rule (normalized weights, so denominator = 1, §4.3): + P(white node v flips next round) = sum of w(u->v) over already-disrupted in-neighbors u. + +--- Seed A. R(A) = {A, B, C}; n_reachable = 2. --- +Round 0: disrupted = {A}. +Round 1: C sees disrupted incoming = w(A->C) = 1.0 -> flips with probability 1 (always). + B sees disrupted incoming = w(A->B) = 0.5 -> flips with probability 0.5. + * With prob 0.5: B flipped at round 1 too -> full coverage at T = 1. + * With prob 0.5: B still white. Round 2: B sees w(A->B)+w(C->B) = 0.5+0.5 = 1.0 + (C disrupted last round) -> flips with probability 1 -> full coverage at T = 2. +EPT(A) = 1*(0.5) + 2*(0.5) = 1.5. + +--- Seed C. R(C) = {C, B}; A is UNREACHABLE from C (edges only leave A). n_reachable = 1. --- +A never disrupts (not a seed, unreachable), so its 0.5 share into B is permanently-white +mass: B's disrupted incoming is at most w(C->B) = 0.5 every round. B therefore flips as a +geometric process with per-round success probability 0.5. +EPT(C) = E[geometric(0.5)] = 1 / 0.5 = 2.0. + (Note: the induced subgraph G[R(C)] = {C, B} has B's in-subgraph incoming summing to + only 0.5 — the missing 0.5 is A's unreachable mass. This is correct: the row-stochastic + invariant holds on the FULL graph, asserted at compute() entry, NOT on the subgraph.) +A is reported in C's `unreachable_from_here`, never as infinity (§4.2 scope rule). + +--- Seed B. B has no outgoing edges -> pure sink. R(B) = {B}; n_reachable = 0. --- +Nothing cascades. impact_score = None; B is a non-cascading node. +""" + +import math + +import pytest + +from zeroforce.api import compute +from zeroforce.types import Edge, Graph, Node + + +def _hand_graph() -> Graph: + nodes = [ + Node(id="A", label="A", sector="src"), + Node(id="B", label="B", sector="cust"), + Node(id="C", label="C", sector="mid"), + ] + edges = [ + Edge(source="A", target="B", weight=0.5, confidence="high", citation=None, reasoning="hand"), + Edge(source="A", target="C", weight=1.0, confidence="high", citation=None, reasoning="hand"), + Edge(source="C", target="B", weight=0.5, confidence="high", citation=None, reasoning="hand"), + ] + return Graph( + nodes=nodes, + edges=edges, + focal_node="A", + extraction_model="hand", + extraction_timestamp="2026-05-22T00:00:00Z", + ) + + +def _by_id(analyses): + return {a.node_id: a for a in analyses} + + +def test_seed_A_ept_is_1_point_5(): + res = _by_id(compute(_hand_graph())) + a = res["A"] + assert math.isclose(a.ept, 1.5, abs_tol=1e-9) + assert a.n_reachable == 2 + assert a.reachable_set == frozenset({"A", "B", "C"}) + assert a.unreachable_from_here == frozenset() + + +def test_seed_C_is_geometric_ept_2_with_unreachable_A(): + res = _by_id(compute(_hand_graph())) + c = res["C"] + assert math.isclose(c.ept, 2.0, abs_tol=1e-9) + assert c.n_reachable == 1 + assert c.reachable_set == frozenset({"C", "B"}) + assert c.unreachable_from_here == frozenset({"A"}) + + +def test_seed_B_is_a_pure_sink(): + res = _by_id(compute(_hand_graph())) + b = res["B"] + assert b.n_reachable == 0 + assert b.impact_score is None + assert b.reachable_set == frozenset({"B"}) diff --git a/packages/zeroforce/tests/test_properties.py b/packages/zeroforce/tests/test_properties.py new file mode 100644 index 0000000..c7727e2 --- /dev/null +++ b/packages/zeroforce/tests/test_properties.py @@ -0,0 +1,47 @@ +"""GATING — structural properties that are true by DEFINITION (no paper trusted). + +- A directed path with weight-1 edges cascades deterministically: each node flips exactly + the round after its unique predecessor, so EPT from the source endpoint = n - 1. Derived + here from first principles (this is NOT the §4.6 "2n-3" claim, which is for a *bidirected* + path and remains unverified). +- Monotonicity in the seed set: starting with MORE nodes already disrupted cannot increase + EPT. True by definition; a violation means the recurrence is wrong. +""" + +from __future__ import annotations + +import math + +import numpy as np +import pytest + +from zeroforce.engine_py import compute_ept_from_mask, compute_ept_from_seed +from zeroforce.reachability import WeightedDiGraph + +from conftest import random_normalized_digraph + + +def _directed_path(n: int) -> WeightedDiGraph: + in_w = np.zeros((n, n), dtype=np.float64) + for v in range(1, n): + in_w[v, v - 1] = 1.0 # single supplier, weight 1 + return WeightedDiGraph(node_ids=tuple(str(i) for i in range(n)), in_w=in_w) + + +@pytest.mark.parametrize("n", [2, 3, 5, 8]) +def test_directed_path_ept_equals_depth(n): + ept = compute_ept_from_seed(_directed_path(n), seed_local=0) + assert math.isclose(ept, n - 1, abs_tol=1e-9) + + +@pytest.mark.parametrize("trial", range(10)) +def test_adding_a_seed_does_not_increase_ept(trial): + rng = np.random.default_rng(5000 + trial) + n = int(rng.integers(3, 8)) + g = random_normalized_digraph(n, rng) + + base = compute_ept_from_mask(g, 1 << 0) # seed {0} + extra = int(rng.integers(1, n)) + bigger = compute_ept_from_mask(g, (1 << 0) | (1 << extra)) # seed {0, extra} + + assert bigger <= base + 1e-9, f"n={n}: ept({{0,{extra}}})={bigger} > ept({{0}})={base}" diff --git a/packages/zeroforce/uv.lock b/packages/zeroforce/uv.lock new file mode 100644 index 0000000..1f8f7e5 --- /dev/null +++ b/packages/zeroforce/uv.lock @@ -0,0 +1,268 @@ +version = 1 +revision = 3 +requires-python = "==3.12.*" + +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "execnet" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, +] + +[[package]] +name = "hypothesis" +version = "6.152.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3b/fb/a5651eb0cd03ecee644e8f4d8cd2027b40a08bf1488f46201e794aebe9b5/hypothesis-6.152.9.tar.gz", hash = "sha256:de4711d69ce3a18009047c3b44882810fd0c0348c1558e920a4b0d2c45f59e1e", size = 472009, upload-time = "2026-05-19T17:10:19.586Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/d9/40ef7ed22f0c45c2316467edb9afb8fc172cd089cb329c0ee6da6b74416c/hypothesis-6.152.9-py3-none-any.whl", hash = "sha256:9c4fdccb1eac0b12ec740c12290d0e6a0bea3526a3f0bf812b7643bb563c2d8b", size = 538148, upload-time = "2026-05-19T17:10:16.131Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "llvmlite" +version = "0.46.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/74/cd/08ae687ba099c7e3d21fe2ea536500563ef1943c5105bf6ab4ee3829f68e/llvmlite-0.46.0.tar.gz", hash = "sha256:227c9fd6d09dce2783c18b754b7cd9d9b3b3515210c46acc2d3c5badd9870ceb", size = 193456, upload-time = "2025-12-08T18:15:36.295Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2b/f8/4db016a5e547d4e054ff2f3b99203d63a497465f81ab78ec8eb2ff7b2304/llvmlite-0.46.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6b9588ad4c63b4f0175a3984b85494f0c927c6b001e3a246a3a7fb3920d9a137", size = 37232767, upload-time = "2025-12-08T18:15:00.737Z" }, + { url = "https://files.pythonhosted.org/packages/aa/85/4890a7c14b4fa54400945cb52ac3cd88545bbdb973c440f98ca41591cdc5/llvmlite-0.46.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3535bd2bb6a2d7ae4012681ac228e5132cdb75fefb1bcb24e33f2f3e0c865ed4", size = 56275176, upload-time = "2025-12-08T18:15:03.936Z" }, + { url = "https://files.pythonhosted.org/packages/6a/07/3d31d39c1a1a08cd5337e78299fca77e6aebc07c059fbd0033e3edfab45c/llvmlite-0.46.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4cbfd366e60ff87ea6cc62f50bc4cd800ebb13ed4c149466f50cf2163a473d1e", size = 55128630, upload-time = "2025-12-08T18:15:07.196Z" }, + { url = "https://files.pythonhosted.org/packages/2a/6b/d139535d7590a1bba1ceb68751bef22fadaa5b815bbdf0e858e3875726b2/llvmlite-0.46.0-cp312-cp312-win_amd64.whl", hash = "sha256:398b39db462c39563a97b912d4f2866cd37cba60537975a09679b28fbbc0fb38", size = 38138940, upload-time = "2025-12-08T18:15:10.162Z" }, +] + +[[package]] +name = "networkx" +version = "3.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" }, +] + +[[package]] +name = "numba" +version = "0.63.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "llvmlite" }, + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dc/60/0145d479b2209bd8fdae5f44201eceb8ce5a23e0ed54c71f57db24618665/numba-0.63.1.tar.gz", hash = "sha256:b320aa675d0e3b17b40364935ea52a7b1c670c9037c39cf92c49502a75902f4b", size = 2761666, upload-time = "2025-12-10T02:57:39.002Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/14/9c/c0974cd3d00ff70d30e8ff90522ba5fbb2bcee168a867d2321d8d0457676/numba-0.63.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2819cd52afa5d8d04e057bdfd54367575105f8829350d8fb5e4066fb7591cc71", size = 2680981, upload-time = "2025-12-10T02:57:17.579Z" }, + { url = "https://files.pythonhosted.org/packages/cb/70/ea2bc45205f206b7a24ee68a159f5097c9ca7e6466806e7c213587e0c2b1/numba-0.63.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5cfd45dbd3d409e713b1ccfdc2ee72ca82006860254429f4ef01867fdba5845f", size = 3801656, upload-time = "2025-12-10T02:57:19.106Z" }, + { url = "https://files.pythonhosted.org/packages/0d/82/4f4ba4fd0f99825cbf3cdefd682ca3678be1702b63362011de6e5f71f831/numba-0.63.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69a599df6976c03b7ecf15d05302696f79f7e6d10d620367407517943355bcb0", size = 3501857, upload-time = "2025-12-10T02:57:20.721Z" }, + { url = "https://files.pythonhosted.org/packages/af/fd/6540456efa90b5f6604a86ff50dabefb187e43557e9081adcad3be44f048/numba-0.63.1-cp312-cp312-win_amd64.whl", hash = "sha256:bbad8c63e4fc7eb3cdb2c2da52178e180419f7969f9a685f283b313a70b92af3", size = 2750282, upload-time = "2025-12-10T02:57:22.474Z" }, +] + +[[package]] +name = "numpy" +version = "2.3.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/76/65/21b3bc86aac7b8f2862db1e808f1ea22b028e30a225a34a5ede9bf8678f2/numpy-2.3.5.tar.gz", hash = "sha256:784db1dcdab56bf0517743e746dfb0f885fc68d948aba86eeec2cba234bdf1c0", size = 20584950, upload-time = "2025-11-16T22:52:42.067Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/37/e669fe6cbb2b96c62f6bbedc6a81c0f3b7362f6a59230b23caa673a85721/numpy-2.3.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:74ae7b798248fe62021dbf3c914245ad45d1a6b0cb4a29ecb4b31d0bfbc4cc3e", size = 16733873, upload-time = "2025-11-16T22:49:49.84Z" }, + { url = "https://files.pythonhosted.org/packages/c5/65/df0db6c097892c9380851ab9e44b52d4f7ba576b833996e0080181c0c439/numpy-2.3.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ee3888d9ff7c14604052b2ca5535a30216aa0a58e948cdd3eeb8d3415f638769", size = 12259838, upload-time = "2025-11-16T22:49:52.863Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e1/1ee06e70eb2136797abe847d386e7c0e830b67ad1d43f364dd04fa50d338/numpy-2.3.5-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:612a95a17655e213502f60cfb9bf9408efdc9eb1d5f50535cc6eb365d11b42b5", size = 5088378, upload-time = "2025-11-16T22:49:55.055Z" }, + { url = "https://files.pythonhosted.org/packages/6d/9c/1ca85fb86708724275103b81ec4cf1ac1d08f465368acfc8da7ab545bdae/numpy-2.3.5-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:3101e5177d114a593d79dd79658650fe28b5a0d8abeb8ce6f437c0e6df5be1a4", size = 6628559, upload-time = "2025-11-16T22:49:57.371Z" }, + { url = "https://files.pythonhosted.org/packages/74/78/fcd41e5a0ce4f3f7b003da85825acddae6d7ecb60cf25194741b036ca7d6/numpy-2.3.5-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b973c57ff8e184109db042c842423ff4f60446239bd585a5131cc47f06f789d", size = 14250702, upload-time = "2025-11-16T22:49:59.632Z" }, + { url = "https://files.pythonhosted.org/packages/b6/23/2a1b231b8ff672b4c450dac27164a8b2ca7d9b7144f9c02d2396518352eb/numpy-2.3.5-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0d8163f43acde9a73c2a33605353a4f1bc4798745a8b1d73183b28e5b435ae28", size = 16606086, upload-time = "2025-11-16T22:50:02.127Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c5/5ad26fbfbe2012e190cc7d5003e4d874b88bb18861d0829edc140a713021/numpy-2.3.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:51c1e14eb1e154ebd80e860722f9e6ed6ec89714ad2db2d3aa33c31d7c12179b", size = 16025985, upload-time = "2025-11-16T22:50:04.536Z" }, + { url = "https://files.pythonhosted.org/packages/d2/fa/dd48e225c46c819288148d9d060b047fd2a6fb1eb37eae25112ee4cb4453/numpy-2.3.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b46b4ec24f7293f23adcd2d146960559aaf8020213de8ad1909dba6c013bf89c", size = 18542976, upload-time = "2025-11-16T22:50:07.557Z" }, + { url = "https://files.pythonhosted.org/packages/05/79/ccbd23a75862d95af03d28b5c6901a1b7da4803181513d52f3b86ed9446e/numpy-2.3.5-cp312-cp312-win32.whl", hash = "sha256:3997b5b3c9a771e157f9aae01dd579ee35ad7109be18db0e85dbdbe1de06e952", size = 6285274, upload-time = "2025-11-16T22:50:10.746Z" }, + { url = "https://files.pythonhosted.org/packages/2d/57/8aeaf160312f7f489dea47ab61e430b5cb051f59a98ae68b7133ce8fa06a/numpy-2.3.5-cp312-cp312-win_amd64.whl", hash = "sha256:86945f2ee6d10cdfd67bcb4069c1662dd711f7e2a4343db5cecec06b87cf31aa", size = 12782922, upload-time = "2025-11-16T22:50:12.811Z" }, + { url = "https://files.pythonhosted.org/packages/78/a6/aae5cc2ca78c45e64b9ef22f089141d661516856cf7c8a54ba434576900d/numpy-2.3.5-cp312-cp312-win_arm64.whl", hash = "sha256:f28620fe26bee16243be2b7b874da327312240a7cdc38b769a697578d2100013", size = 10194667, upload-time = "2025-11-16T22:50:16.16Z" }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, +] + +[[package]] +name = "pytest-xdist" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "execnet" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, +] + +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "zeroforce" +version = "0.0.1" +source = { editable = "." } +dependencies = [ + { name = "networkx" }, + { name = "numba" }, + { name = "numpy" }, + { name = "pydantic" }, +] + +[package.optional-dependencies] +test = [ + { name = "hypothesis" }, + { name = "pytest" }, + { name = "pytest-xdist" }, +] + +[package.metadata] +requires-dist = [ + { name = "hypothesis", marker = "extra == 'test'", specifier = ">=6" }, + { name = "networkx", specifier = ">=3.2,<4.0" }, + { name = "numba", specifier = ">=0.61,<0.64" }, + { name = "numpy", specifier = ">=1.26,<3.0" }, + { name = "pydantic", specifier = ">=2.7,<3.0" }, + { name = "pytest", marker = "extra == 'test'", specifier = ">=8" }, + { name = "pytest-xdist", marker = "extra == 'test'" }, +] +provides-extras = ["test"] diff --git a/packages/zeroforce/zeroforce/__init__.py b/packages/zeroforce/zeroforce/__init__.py new file mode 100644 index 0000000..1a75478 --- /dev/null +++ b/packages/zeroforce/zeroforce/__init__.py @@ -0,0 +1 @@ +"""zeroforce — Randomized Zero Forcing engine.""" diff --git a/packages/zeroforce/zeroforce/api.py b/packages/zeroforce/zeroforce/api.py new file mode 100644 index 0000000..cf74cd0 --- /dev/null +++ b/packages/zeroforce/zeroforce/api.py @@ -0,0 +1,70 @@ +"""Public engine API: compute(graph) -> list[NodeAnalysis] (spec §15.2).""" + +from __future__ import annotations + +import numpy as np + +from .engine_py import compute_ept_from_seed +from .ranking import impact_score, rank_and_percentile +from .reachability import induced_subgraph, reachable_from, to_weighted_digraph +from .types import EngineInvariantError, Graph, NodeAnalysis + +_EPS = 1e-9 + + +def _assert_full_graph_invariant(in_w: np.ndarray, node_ids: tuple[str, ...]) -> None: + """§7.2 invariant: every node's incoming weights sum to 1.0, or 0.0 for source nodes. + + Asserted on the FULL graph (not subgraphs). A non-{0,1} row means normalization was + not run upstream — an engine-contract violation, not a recoverable value. + """ + row_sums = in_w.sum(axis=1) + for i, s in enumerate(row_sums): + if not (abs(s - 1.0) <= _EPS or abs(s) <= _EPS): + raise EngineInvariantError( + f"node {node_ids[i]}: incoming weights sum to {s:.4f}, " + "expected 1.0 (or 0.0 for a source). Normalize before the engine (§7.2)." + ) + + +def compute(graph: Graph) -> list[NodeAnalysis]: + g = to_weighted_digraph(graph) + _assert_full_graph_invariant(g.in_w, g.node_ids) + + all_ids = set(g.node_ids) + ept_by_id: dict[str, float] = {} + n_reach_by_id: dict[str, int] = {} + reach_by_id: dict[str, frozenset[str]] = {} + unreach_by_id: dict[str, frozenset[str]] = {} + + for v in range(g.num_nodes): + reachable = reachable_from(g, v) + sub = induced_subgraph(g, reachable) + seed_local = sub.index_of(g.node_ids[v]) + ept = compute_ept_from_seed(sub, seed_local) + + reachable_ids = frozenset(g.node_ids[i] for i in reachable) + nid = g.node_ids[v] + ept_by_id[nid] = ept + n_reach_by_id[nid] = len(reachable) - 1 # excludes self + reach_by_id[nid] = reachable_ids + unreach_by_id[nid] = frozenset(all_ids - reachable_ids - {nid}) + + scores = { + nid: impact_score(nid, ept_by_id[nid], n_reach_by_id[nid]) for nid in g.node_ids + } + ranks, percentiles = rank_and_percentile(scores) + + return [ + NodeAnalysis( + node_id=nid, + ept=ept_by_id[nid], + n_reachable=n_reach_by_id[nid], + reachable_set=reach_by_id[nid], + unreachable_from_here=unreach_by_id[nid], + impact_score=scores[nid], + rank=ranks.get(nid), + percentile=percentiles.get(nid), + ) + for nid in g.node_ids + ] diff --git a/packages/zeroforce/zeroforce/engine_numba.py b/packages/zeroforce/zeroforce/engine_numba.py new file mode 100644 index 0000000..962e8f6 --- /dev/null +++ b/packages/zeroforce/zeroforce/engine_numba.py @@ -0,0 +1,81 @@ +"""numba-JIT fast path for the RZF DP — same recurrence as engine_py, array-only. + +This is an optimization of engine_py._solve, not a second definition. The engine-parity +test (tests/test_engine_parity.py) asserts the two agree to 1e-10 on identical graphs. + +Implementation notes for nopython mode: +- No dicts/sets/generators. State is bitmask integers; per-node probabilities live in a + plain float array. White-node subsets are enumerated with the standard submask trick + `sub = (sub - 1) & whitemask`, skipping the empty subset (the "no flip" / stay case). +- Masks are processed by decreasing popcount so every superset (higher popcount, the + possible `next` states) is solved before the current mask. +""" + +from __future__ import annotations + +import numpy as np +from numba import njit + +from .reachability import WeightedDiGraph + + +@njit(cache=True) +def _popcount(x: int) -> int: + c = 0 + while x: + x &= x - 1 + c += 1 + return c + + +@njit(cache=True) +def _solve_numba(in_w: np.ndarray) -> np.ndarray: + n = in_w.shape[0] + ept = np.full(1 << n, np.inf) + if n == 1: + out = np.zeros(2) + return out + FULL = (1 << n) - 1 + ept[FULL] = 0.0 + + p = np.zeros(n) + for pc in range(n - 1, -1, -1): + for mask in range(1 << n): + if _popcount(mask) != pc: + continue + whitemask = (~mask) & FULL + p_stay = 1.0 + for v in range(n): + if (mask >> v) & 1: + continue + s = 0.0 + for u in range(n): + if (mask >> u) & 1: + s += in_w[v, u] + p[v] = s + p_stay *= 1.0 - s + if p_stay >= 1.0: + continue + expected_next = 0.0 + sub = whitemask + while sub > 0: + prob = 1.0 + for v in range(n): + if (whitemask >> v) & 1: + if (sub >> v) & 1: + prob *= p[v] + else: + prob *= 1.0 - p[v] + if prob > 0.0: + expected_next += prob * ept[mask | sub] + sub = (sub - 1) & whitemask + ept[mask] = (1.0 + expected_next) / (1.0 - p_stay) + return ept + + +def compute_ept_from_mask(sub: WeightedDiGraph, start_mask: int) -> float: + return float(_solve_numba(np.ascontiguousarray(sub.in_w))[start_mask]) + + +def compute_ept_from_seed(sub: WeightedDiGraph, seed_local: int) -> float: + return compute_ept_from_mask(sub, 1 << seed_local) diff --git a/packages/zeroforce/zeroforce/engine_py.py b/packages/zeroforce/zeroforce/engine_py.py new file mode 100644 index 0000000..53e3551 --- /dev/null +++ b/packages/zeroforce/zeroforce/engine_py.py @@ -0,0 +1,102 @@ +"""Pure-Python reference DP for RZF Expected Propagation Time (spec §4.3). + +Correctness-first. The numba path mirrors this exactly; engine parity tests assert +they agree to 1e-12. Recurrence (normalized weights, denom = 1, self-loop fixed): + + 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) · ept[next] ) / (1 - p_stay) + ept[FULL] = 0 + +P(next) here is the UNCONDITIONAL one-round transition probability; dividing the whole +sum once by (1 - p_stay) accounts for the "a flip occurred" conditioning. (The spec prose +phrased this as a conditional probability, but its pseudocode — and this code — use the +unconditional form with a single division, which is the algebraically correct version.) +""" + +from __future__ import annotations + +import numpy as np + +from .reachability import WeightedDiGraph + + +def compute_ept_from_seed(sub: WeightedDiGraph, seed_local: int) -> float: + """EPT from a single seed node over the induced subgraph `sub` (= G[R(seed)]).""" + return compute_ept_from_mask(sub, 1 << seed_local) + + +def compute_ept_from_mask(sub: WeightedDiGraph, start_mask: int) -> float: + """EPT from an arbitrary already-disrupted set `start_mask` over `sub`. + + `sub` should be the induced subgraph of the set reachable from the seed set, so that + full coverage (FULL) is the correct absorbing target. + """ + return float(_solve(sub)[start_mask]) + + +def _solve(sub: WeightedDiGraph) -> np.ndarray: + """ept[mask] for every mask: expected rounds from `mask` to full coverage of `sub`.""" + n = sub.num_nodes + in_w = sub.in_w + if n == 1: + return np.zeros(2, dtype=np.float64) # masks 0 and 1; seed reaches only itself + + FULL = (1 << n) - 1 + ept = np.full(1 << n, np.inf, dtype=np.float64) + ept[FULL] = 0.0 + + for pc in range(n - 1, -1, -1): + for mask in _masks_with_popcount(pc, n): + white = [v for v in range(n) if not (mask >> v) & 1] + # p_disrupt[v] = disrupted incoming weight (denom = 1 by full-graph invariant) + p = {} + for v in white: + s = 0.0 + for u in range(n): + if (mask >> u) & 1: + s += in_w[v, u] + p[v] = s + p_stay = 1.0 + for v in white: + p_stay *= 1.0 - p[v] + if p_stay >= 1.0: + # No white node can flip: absorbing non-full state. Unreachable from the + # seed (which reaches all of R(seed)); leave as inf, it is never consumed. + continue + expected_next = 0.0 + dead = False + for flip in _nonempty_subsets(white): + prob = 1.0 + flipset = set(flip) + for v in white: + prob *= p[v] if v in flipset else (1.0 - p[v]) + if prob == 0.0: + continue + nxt = mask + for v in flip: + nxt |= 1 << v + if not np.isfinite(ept[nxt]): + # Reaching a state that cannot itself reach FULL. This only happens for + # masks not reachable from any live seed; mark dead and skip the inf + # arithmetic so output stays pristine. (Live seed masks never hit this.) + dead = True + break + expected_next += prob * ept[nxt] + if dead: + continue + ept[mask] = (1.0 + expected_next) / (1.0 - p_stay) + + return ept + + +def _masks_with_popcount(pc: int, n: int): + for mask in range(1 << n): + if mask.bit_count() == pc: + yield mask + + +def _nonempty_subsets(items: list[int]): + k = len(items) + for bits in range(1, 1 << k): + yield [items[i] for i in range(k) if (bits >> i) & 1] diff --git a/packages/zeroforce/zeroforce/ranking.py b/packages/zeroforce/zeroforce/ranking.py new file mode 100644 index 0000000..c13a693 --- /dev/null +++ b/packages/zeroforce/zeroforce/ranking.py @@ -0,0 +1,34 @@ +"""Impact scoring, ranking, and sink separation (spec §7.3).""" + +from __future__ import annotations + +from .types import EngineInvariantError + + +def impact_score(node: str, ept: float, n_reachable: int) -> float | None: + """n_reachable EXCLUDES self. Pure sinks -> None. ept==0 with downstream -> raise.""" + if n_reachable == 0: + return None # pure sink: cannot cascade; ranked separately + if ept == 0.0: + raise EngineInvariantError( + f"node {node}: n_reachable={n_reachable} but EPT=0" + ) + return n_reachable / ept + + +def rank_and_percentile(scores: dict[str, float | None]) -> tuple[dict[str, int], dict[str, float]]: + """Rank cascading nodes by impact_score descending; pure sinks (None) are excluded. + + Returns (rank by node_id, percentile by node_id). Higher percentile = more dangerous. + """ + cascading = [(nid, s) for nid, s in scores.items() if s is not None] + cascading.sort(key=lambda kv: kv[1], reverse=True) + + ranks: dict[str, int] = {} + percentiles: dict[str, float] = {} + total = len(cascading) + for i, (nid, _score) in enumerate(cascading): + ranks[nid] = i + 1 + # higher score -> higher percentile; rank 1 of N -> 100 * (N-1)/N ... use position + percentiles[nid] = 100.0 * (total - 1 - i) / total if total > 1 else 100.0 + return ranks, percentiles diff --git a/packages/zeroforce/zeroforce/reachability.py b/packages/zeroforce/zeroforce/reachability.py new file mode 100644 index 0000000..995f3c0 --- /dev/null +++ b/packages/zeroforce/zeroforce/reachability.py @@ -0,0 +1,73 @@ +"""Reachability and induced-subgraph extraction (spec §4.2). + +EPT is measured over R(v) — the set of nodes reachable from v via positive-weight +directed paths, including v. The engine runs the DP on the induced subgraph G[R(v)]. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +import numpy as np + +from .types import Graph + + +@dataclass(frozen=True) +class WeightedDiGraph: + """A normalized weighted directed graph with integer-indexed nodes. + + `in_w[v, u]` is the weight of edge u -> v (the share of v's inputs sourced from u). + Rows are stochastic on the FULL graph (sources sum to 0); on an induced subgraph a + row may sum to < 1 because a supplier was unreachable (permanently-white mass). + """ + + node_ids: tuple[str, ...] + in_w: np.ndarray # shape (n, n), in_w[target, source] + + @property + def num_nodes(self) -> int: + return len(self.node_ids) + + def index_of(self, node_id: str) -> int: + return self.node_ids.index(node_id) + + +def to_weighted_digraph(graph: Graph) -> WeightedDiGraph: + ids = tuple(n.id for n in graph.nodes) + idx = {nid: i for i, nid in enumerate(ids)} + n = len(ids) + in_w = np.zeros((n, n), dtype=np.float64) + for e in graph.edges: + if e.weight <= 0: + continue + in_w[idx[e.target], idx[e.source]] += e.weight + return WeightedDiGraph(node_ids=ids, in_w=in_w) + + +def reachable_from(g: WeightedDiGraph, seed: int) -> list[int]: + """Indices reachable from `seed` via positive-weight directed paths (includes seed).""" + n = g.num_nodes + # successors: u -> v exists when in_w[v, u] > 0 + seen = {seed} + stack = [seed] + while stack: + u = stack.pop() + for v in range(n): + if g.in_w[v, u] > 0 and v not in seen: + seen.add(v) + stack.append(v) + return sorted(seen) + + +def induced_subgraph(g: WeightedDiGraph, nodes: list[int]) -> WeightedDiGraph: + """G[nodes]. Incoming weights are restricted to in-subgraph edges; rows may sum < 1 + (the deficit is unreachable-supplier mass that never disrupts).""" + sub_ids = tuple(g.node_ids[i] for i in nodes) + pos = {orig: k for k, orig in enumerate(nodes)} + m = len(nodes) + sub = np.zeros((m, m), dtype=np.float64) + for orig_t, kt in pos.items(): + for orig_s, ks in pos.items(): + sub[kt, ks] = g.in_w[orig_t, orig_s] + return WeightedDiGraph(node_ids=sub_ids, in_w=sub) diff --git a/packages/zeroforce/zeroforce/simulator.py b/packages/zeroforce/zeroforce/simulator.py new file mode 100644 index 0000000..e5547e3 --- /dev/null +++ b/packages/zeroforce/zeroforce/simulator.py @@ -0,0 +1,39 @@ +"""Monte Carlo RZF simulator — an INDEPENDENT implementation of the RZF definition. + +This is deliberately written in a different style from the DP (vectorized forward +simulation, no subset enumeration). The differential test asserts the simulator mean and +the DP agree to ~3 decimals: two independent implementations of the same definition +agreeing is real evidence of correctness, trusting no published closed form. +""" + +from __future__ import annotations + +import numpy as np + +from .reachability import WeightedDiGraph + +_SAFETY_CAP = 10_000 # rounds; a correct reachable graph terminates almost surely well below + + +def simulate_ept( + sub: WeightedDiGraph, seed_local: int, samples: int, rng: np.random.Generator +) -> float: + """Mean rounds until every node of `sub` (= G[R(seed)]) is disrupted, over `samples` runs.""" + n = sub.num_nodes + if n == 1: + return 0.0 + in_w = sub.in_w + total = 0 + for _ in range(samples): + disrupted = np.zeros(n, dtype=bool) + disrupted[seed_local] = True + rounds = 0 + while not disrupted.all(): + p = in_w[:, disrupted].sum(axis=1) # disrupted incoming weight per node + flips = (~disrupted) & (rng.random(n) < p) + disrupted |= flips + rounds += 1 + if rounds > _SAFETY_CAP: # pragma: no cover - guards a non-terminating bug + raise RuntimeError("simulation exceeded safety cap; graph may be malformed") + total += rounds + return total / samples diff --git a/packages/zeroforce/zeroforce/types.py b/packages/zeroforce/zeroforce/types.py new file mode 100644 index 0000000..4eacf71 --- /dev/null +++ b/packages/zeroforce/zeroforce/types.py @@ -0,0 +1,50 @@ +"""Core data models for the zeroforce engine (spec §7.1, §7.3).""" + +from __future__ import annotations + +from datetime import date, datetime +from typing import Literal + +from pydantic import BaseModel, Field + + +class EngineInvariantError(Exception): + """Raised when the engine produces or receives a structurally impossible value.""" + + +class Node(BaseModel): + id: str + label: str + sector: str + country: str | None = None + tier: int | None = None + metadata: dict = {} + + +class Edge(BaseModel): + source: str + target: str + weight: float = Field(ge=0, le=1) + confidence: Literal["high", "medium", "low"] + citation: str | None = None + reasoning: str = "" + asof: date | None = None + + +class Graph(BaseModel): + nodes: list[Node] + edges: list[Edge] + focal_node: str + extraction_model: str + extraction_timestamp: datetime + + +class NodeAnalysis(BaseModel): + node_id: str + ept: float + n_reachable: int + reachable_set: frozenset[str] + unreachable_from_here: frozenset[str] + impact_score: float | None = None + rank: int | None = None + percentile: float | None = None