# stackdump — blog.stackdump.com (full text) > Matt York's technical blog: Petri nets as a universal abstraction for > state machines, workflows, games, and token systems — with category > theory, ODE analysis, and zero-knowledge proofs as the supporting cast. > Every model described here is executable; most link to live demos in the > pflow ecosystem (pflow.xyz editor, sim.pflow.xyz models and MCP tools, > book.pflow.xyz). This file is the complete text of every published post, oldest first, generated from the same markdown the site renders. For a curated index see https://blog.stackdump.com/llms.txt; for structured metadata see https://blog.stackdump.com/posts/index.jsonld. --- # Ode To Petri - URL: https://blog.stackdump.com/posts/ode-to-petri - Date: 2024-03-23 - Tags: poetry, petri-net - Summary: A poem for Carl Adam Petri, inventor of Petri nets—the mathematical notation for concurrent systems. # Ode To Petri ![Ode to Petri](/images/ode-to-petri/ode-scroll.svg) --- [![Carl Adam Petri](/images/carl-adam-petri.jpg)](https://en.wikipedia.org/wiki/Carl_Adam_Petri) **[Carl Adam Petri](https://en.wikipedia.org/wiki/Carl_Adam_Petri)** (1926–2010) Photo: Michael Krapp, [CC BY-SA 3.0](https://creativecommons.org/licenses/by-sa/3.0), via Wikimedia Commons --- *Tokens flow on at [pflow.xyz](https://pflow.xyz)* --- # Knapsack Model - URL: https://blog.stackdump.com/posts/knapsack-model - Date: 2025-01-03 - Tags: knapsack, optimization, petri-net, ode, ddm - Summary: A four-item 0/1 knapsack as a Petri net under mass-action ODEs — why every item stops at 71.4%, what exclusion analysis actually ranks, and where the relaxation falls short of branch-and-bound. # Knapsack Model We apply [Declarative Differential Models (DDM)](/posts/declarative-differential-models) to a four-item 0/1 knapsack and compare what the continuous relaxation finds against the exact answer. The model is the one in go-pflow's [examples/knapsack](https://github.com/pflow-xyz/go-pflow/blob/main/examples/knapsack/cmd/main.go), and the same net is live on pflow.xyz: [![pflow](https://pflow.xyz/img/z4EBG9jBSkDv2Ykw39bL6TtJE1iU5piShXnHnrm3YmJArNT85Ue.svg)](https://pflow.xyz/?cid=z4EBG9jBSkDv2Ykw39bL6TtJE1iU5piShXnHnrm3YmJArNT85Ue) ## Problem Definition Four items and a weight capacity of 15: | Item | Weight | Value | Value/weight | |------|--------|-------|--------------| | item0 | 2 | 10 | 5.00 | | item1 | 4 | 10 | 2.50 | | item2 | 6 | 12 | 2.00 | | item3 | 9 | 18 | 2.00 | ![Item Efficiency](/images/knapsack/item-efficiency.svg) The optimal solution picks items 0, 1 and 3 — exactly 15 units of capacity for a total value of 38. Item2 and item3 tie on efficiency at 2.0, so the ratio can't choose between them; capacity does. Once items 0 and 1 have taken 6 units, item3 fills the remaining 9 exactly, while item2 would leave 3 idle (items 0, 1, 2 weigh 12 and are worth 32). ## The Model ![Model Structure](/images/knapsack/model-structure.svg) Each item is a place holding a single token, which is where the 0/1 constraint lives: there is only one of it to take. A shared capacity place starts with 15 tokens. Each take transition consumes its item's token and *w* tokens of capacity, and produces *v* tokens in a value place and *w* in a weight place. Using [pflow.xyz](https://pflow.xyz/?cid=z4EBG9jBSkDv2Ykw39bL6TtJE1iU5piShXnHnrm3YmJArNT85Ue), the ODE analysis runs directly in the browser. ## Mass-Action Dynamics go-pflow's rate law is first-order in every input place ([solver/ode.go](https://github.com/pflow-xyz/go-pflow/blob/main/solver/ode.go)), so each take transition fires at ``` flux = rate × [item] × [capacity] ``` Arc weights scale how much is consumed and produced, not the rate. Every transition runs at rate 1.0. ### ODE Simulation Results Output of the example at t = 10: ``` Final state (continuous approximation): Value accumulated: 35.71 Weight used: 15.00 Capacity remaining: 0.00 Item consumption (fraction taken): item0: 71.4% taken item1: 71.4% taken item2: 71.4% taken item3: 71.4% taken ``` The relaxation takes the same fraction of every item — the dense item0 and the bulky item3 alike — and that fraction is exact, not an artifact of the integrator. ## Why Every Item Stops at 71.4% The rate law makes this solvable by hand. Write τ(t) for the integral of capacity from 0 to t. Each item place obeys d[item]/dt = −[item]·[capacity], so [item] = e^(−τ) — the same function for every item, whatever its weight or value. Values never enter the dynamics at all; they sit on output arcs into a place nothing reads. So all active items are taken in one common fraction f = 1 − e^(−τ), and capacity is 15 − W·f, where W is the total weight of the items still in play. If W > 15, capacity runs out when f = 15/W and the net settles at value 15·V/W. If W ≤ 15, f climbs toward 1 and the net eventually collects all of V. With all four items, W = 21 and V = 50: f = 15/21 = 71.4%, value 750/21 = 35.71. The numbers in the output above and in both tables below follow from this formula. We derived it from the rate law and checked it against the published figures; the example itself was not re-run for this revision. ## Exclusion Analysis ![Exclusion Analysis](/images/knapsack/exclusion-analysis.svg) Disabling one item at a time (its token and rate set to 0) and running to t = 10: | Excluded | Active W | Active V | Closed form | Final Value | Relative | |----------|----------|----------|-------------|-------------|----------| | none | 21 | 50 | 15·50/21 | 35.71 | 100.0% | | item0 | 19 | 40 | 15·40/19 | 31.58 | 88.4% | | item1 | 17 | 40 | 15·40/17 | 35.29 | 98.8% | | item2 | 15 | 38 | → 38 | 37.75 | 105.7% | | item3 | 12 | 32 | 32 | 32.00 | 89.6% | Read through the formula, the table ranks subsets by value density V/W, scaled by capacity, until a subset fits. Dropping item0 costs the most because it is the densest item: without it V/W falls from 2.38 to 2.11. Dropping item3 lands at 89.6% for a different reason — the remaining three weigh 12, so they fit, and the net collects their full 32 with 3 units of capacity unused. Item2 is the only item whose removal raises the value, because without it the remaining weights sum to exactly 15 and the net can take all of items 0, 1 and 3. Calling item2 "harmful" is fair in that sense and no stronger one: its exclusion is the one that makes the rest fit. *Data generated with [go-pflow examples/knapsack](https://github.com/pflow-xyz/go-pflow/blob/main/examples/knapsack/cmd/main.go)* ## Convergence to Optimal With item2 excluded, W equals the capacity, so capacity and items drain together: [capacity] = 15·e^(−τ), which integrates to e^τ = 1 + 15t. The value is 38·(1 − 1/(1 + 15t)) and the gap to 38 is 38/(1 + 15t) — algebraic, not exponential, which is why it takes until t = 1000 to get within 0.0025: | Time | Value | Gap to Optimal | |------|-------|----------------| | t=10 | 37.75 | 0.2517 | | t=100 | 37.97 | 0.0253 | | t=1000 | 38.00 | 0.0025 | The ODE reaches the discrete optimum here because 2 + 4 + 9 = 15; the dynamics don't select that subset, they fill it once it is the only one left. ## Comparison: Branch-and-Bound ![Branch-and-Bound vs DDM/ODE](/images/knapsack/branch-and-bound.svg) **Branch-and-bound** treats optimization as *search*. It builds a decision tree where each node is a binary choice — take this item or skip it — and computes upper bounds to prune branches that can't beat the best solution found so far. It's systematic enumeration with pruning: exponential in the worst case, practical for moderate problem sizes. **DDM/ODE** treats optimization as *simulation*. There are no explicit decisions; items compete for capacity through the mass-action kinetics above, with every enabled transition firing at once at a rate proportional to what's available. It is also not the LP relaxation of the knapsack. The LP optimum here is 38 — take items 0 and 1 whole, then fill the last 9 units at ratio 2.0 — and an LP bound sits at or above the integer optimum, while the ODE settles at 35.71, below it, because it spreads capacity evenly across items instead of maximizing anything. | Aspect | Branch-and-Bound | DDM/ODE | |--------|------------------|---------| | Core operation | Decision-tree search with bounds | One ODE integration per run | | Decisions | Explicit (take/skip) | None — every active item is taken in the same fraction | | Solution type | Exact integer optimum | Fractional; 35.71 here against 38 | | Cost | Exponential worst case, pruned | One ODE solve per run; exclusion analysis is n + 1 runs | | What it tells you | The optimum | How value density and fit shift when items are removed | For exact solutions, [branch-and-bound](https://www.geeksforgeeks.org/dsa/0-1-knapsack-using-least-count-branch-and-bound/) finds items 0, 1, 3 → value 38. On this instance one round of single-item exclusions — five ODE runs — also points at it: drop item2 and what's left is the answer. We have not shown that this works in general. Single exclusions only look at subsets of size n − 1, the optimum on a larger instance usually drops several items, and nothing here says an iterated version finds it. A benchmark of exclusion analysis against branch-and-bound on larger instances is the worked case this post still owes. *The same approach is worked through in [Chapter 8: Optimization](https://book.pflow.xyz/ch08-optimization.html) of [Petri Nets as a Universal Abstraction](https://book.pflow.xyz), on a variant of this instance.* ## What Changed For readers of the earlier version: 1. **Efficiencies.** item1 is 10/4 = 2.50 and item3 is 18/9 = 2.00, matching the code, the pflow.xyz model and the figure; the text had said 2.33 and 1.78. With item2 and item3 tied at 2.0, item3 wins on fit, not efficiency. 2. **Closed form.** Added the derivation: every active item is taken in the same fraction, and the value is 15·V/W while W > 15. Every number in the tables matches it. The go-pflow example's README prints different item0 and item1 exclusion rows (33.81 and 37.75); those disagree with the formula, and the post keeps the values the formula gives. 3. **Claims narrowed.** The ODE is not the LP relaxation (the LP gives 38 here); convergence with item2 excluded happens because the remaining weights sum to the capacity; exclusion analysis finding the optimum is shown for this instance only. Dropped the "When to Use Each" table, which listed "deterministic results" as a branch-and-bound advantage — the ODE is deterministic too. --- *Related: [Declarative Differential Models](/posts/declarative-differential-models) · [The Incidence Reduction](/posts/integer-reduction) · [The Capacity Paradox](/posts/capacity-paradox)* --- # Declarative Differential Models (DDM) - URL: https://blog.stackdump.com/posts/declarative-differential-models - Date: 2025-01-07 - Tags: ddm, petri-net, ode, theory - Summary: A modeling approach where system behavior is described declaratively and encoded directly in differential equations. # Declarative Differential Models (DDM) A Petri net describes a system declaratively — places hold state, transitions change it, arcs define the rules. A DDM takes that description and encodes it directly as differential equations. We specify *what* the relationships are, not how to compute them. Constraints like conservation laws and capacity limits live in the equations themselves. The system stays within valid bounds without external enforcement. ## From Petri Nets to ODEs ![From Petri Net to ODE System](/images/ddm/petri-to-ode.svg) A Petri net can be converted to an ODE system using **mass-action kinetics**: For each place in the net: ``` dM(p)/dt = Σ(incoming flow) - Σ(outgoing flow) ``` Where the flow through each transition follows: ``` rate(T) = k × M(P1)^w1 × M(P2)^w2 × ... ``` - `k` = rate constant for the transition - `M(P)` = marking (tokens) in place P - `w` = arc weight This transforms discrete token dynamics into continuous flow—tokens become concentrations, firing becomes flux. ## Discrete vs Continuous ![Discrete vs Continuous Simulation](/images/ddm/discrete-vs-continuous.svg) | Discrete Simulation | ODE Simulation | |---------------------|----------------| | Track individual events | Track population-level dynamics | | "Patient 1 arrives at 8:15" | "Patients arrive at rate 10/hour" | | Slow: must process every event | Fast: solves continuous equations | | Scales with event count | Scales with equation count | The continuous approach costs the same whether we have 10 or 10,000 entities. Gradient-based optimization works because the equations are smooth. And we get stability, sensitivity, and equilibrium analysis for free. ## In Practice [pflow.xyz](https://pflow.xyz) runs DDM in the browser. Draw a net, set initial markings and rate constants, run ODE analysis, and watch token flows evolve. The model exports as JSON-LD. ### Example: Knapsack Optimization [![pflow](https://pflow.xyz/img/z4EBG9jBSkDv2Ykw39bL6TtJE1iU5piShXnHnrm3YmJArNT85Ue.svg)](https://pflow.xyz/?cid=z4EBG9jBSkDv2Ykw39bL6TtJE1iU5piShXnHnrm3YmJArNT85Ue) In a [knapsack model](/posts/knapsack-model): - **Places** represent items, capacity, and accumulated value - **Transitions** represent taking an item (consumes capacity, produces value) - **Constraints** (capacity limits) are encoded via arc weights - **ODE simulation** shows how items compete for limited capacity ## Exclusion Analysis ![Exclusion Analysis](/images/ddm/exclusion-analysis.svg) Exclusion analysis: **disable transitions** to measure contribution. By setting a transition's rate to zero, we can observe: - How the system behaves without that component - The relative importance of each transition - Sensitivity to specific pathways This replaces combinatorial search with targeted simulation. ## When DDM Breaks Down DDM models aggregate behavior — populations, not individuals. We can't track "Patient 47" through the system. Discrete conditionals ("if X then Y") don't have a clean continuous analog. And with very small populations (1-2 entities), the continuous approximation is too coarse. For everything else — optimization, game analysis, workflow modeling, resource allocation — the ODE simulation is fast, the constraints are structural, and the analysis comes free from the incidence matrix. ## Further Reading - [Knapsack Model](/posts/knapsack-model) - DDM applied to optimization - [Sudoku Petri-Net Model](/posts/sudoku-petri-net-model) - Constraint satisfaction as flow - [pflow.xyz](https://pflow.xyz) - Interactive DDM environment *This topic is covered in depth in [Chapter 3: From Discrete to Continuous](https://book.pflow.xyz/ch03-discrete-to-continuous.html) of [Petri Nets as a Universal Abstraction](https://book.pflow.xyz).* --- # Hello World - URL: https://blog.stackdump.com/posts/welcome-to-stackdump-blog - Date: 2025-11-05 - Tags: introduction - Summary: Introducing Stackdump Blog # About Stackdump ![github profile pic](https://avatars.githubusercontent.com/u/243500) ![From Code to Flows](/images/welcome/journey.svg) I registered stackdump.com on November 2, 2001, while I was still in college — a computer science student running a web server out of my apartment over a DSL modem. I had just learned PHP, so I built a small site where my friends and I could post to each other. It wasn’t much, but it felt alive — a tiny social space made from code. From those early experiments on the frontier of the web, I moved into game development, then into the broader world of networked systems — places where code and people intersect at scale. Somewhere along the way, I discovered Bitcoin, missed the mining wave by buying a PlayStation 3 instead of a GPU, and later found myself at an Ethereum meetup in La Jolla, realizing that blockchains were more than money — they were programmable systems of flow. Over time, my focus shifted from building applications to understanding how systems behave. That’s where Petri nets entered the picture — not just as diagrams for state transitions, but as a universal language for modeling causality, concurrency, and composition. Today, I’m drawn to immutable structures — systems that can explain themselves, verify their own behavior, and persist beyond any one runtime. Stackdump is where I explore those ideas: how Petri nets and category theory can shape the next generation of software, blockchains, and systems that think in flows instead of functions. — Matt York Petri-net maximalist --- # Revisiting the Flows - URL: https://blog.stackdump.com/posts/revisiting-the-flows - Date: 2025-11-09 - Tags: announcement, petri-nets, philosophy, category-theory, blog-relaunch - Summary: Reflecting on old ideas with new tools — from Petri nets to proofs, from Bash scripts to composable universes. # Revisiting the Flows ![user profile pic for stackdump](https://stackdump.com/profile.jpg) Stackdump began as a small experiment in expression — a place to post, reflect, and tinker. Over time, it became a kind of notebook for systems that move, evolve, and explain themselves, on the premise that every system is a flow, and flows can be modeled, verified, and composed. This month, I’m reopening that notebook. I've archived the old essays and opened a new series called **Revisiting the Flows** — a return to ideas that deserve another pass, armed with better tools, deeper math, and a few more scars. Each post revisits a topic from the past and connects it to the current Stackdump ecosystem — `pflow.xyz` and `go-pflow`. Here’s what I said was coming, and — updated August 2026, nine months on — what actually got written: - **Tic-Tac-Toe Model** → [written](/posts/tic-tac-toe-model), and it became the spine of the whole series: the [ZK version](/posts/zk-tic-tac-toe-model), the [incidence reduction](/posts/integer-reduction), [earned compression](/posts/earned-compression), and the [zipper](/posts/tense-type-theory) all use it. - **Rebuilding the Payments WF-Net** → became [Earned Compression](/posts/earned-compression) and [The Category Settle](/posts/category-settle), which is more than I planned and less about workflow nets than I expected. - **DiscoPy Revisited** → the diagrams-that-execute idea landed as [Symmetric Monoidal Categories](/posts/symmetric-monoidal-categories) and the string diagrams in Settle; DiscoPy itself didn't make the cut. - **Merkle-DAGs and Memory** → CIDs run through everything now ([JSON-LD as infrastructure](/posts/json-ld-declarative-infrastructure), [Polydocument Host](/posts/polydocument-host)); there was never a standalone post. - **Solidity Generation** → shipped as tooling ([bitwrap capstone](/posts/bitwrap-capstone)) rather than as an essay. bitwrap.io stopped being hosted in September 2026, so the live editor that post links to is gone; the code is still at [stackdump/bitwrap-io](https://github.com/stackdump/bitwrap-io), and a generated contract survives as one of pflow-polyglot's forms ([`solidity/generated.sol`](https://github.com/stackdump/pflow-polyglot/blob/main/solidity/generated.sol)). - **Petri Nets in Bash (v3)** → folded into [pflow-polyglot](https://github.com/stackdump/pflow-polyglot), one coffee-machine net implemented thirty-nine times across ten languages. Bash carries four of them (interpreter, lambda, contract, proof), and the native parity gate holds each to the same six-line [canonical trace](https://github.com/stackdump/pflow-polyglot/blob/main/parity/trace.golden) as every other language. - **Coq** → not in Coq. The reachability-and-safety proofs landed in Lean 4 instead, as the [proof form](/posts/proof-form) in pflow-polyglot: [`lean/proof.lean`](https://github.com/stackdump/pflow-polyglot/blob/main/lean/proof.lean) checks 1-safety, the P-invariants and a unique deadlock over the net's 16 reachable markings by `decide` (not re-run for this update). That is one finite net checked by evaluation — a whole class of nets proven safe is still a different post. - **Vector Notation** → absorbed by [The Token Language](/posts/token-language); the algebra of flow turned out to be the incidence matrix, and it got its own [post](/posts/integer-reduction). - **Leap Year Counter**, **Entropy, Revisited** → not written. The entropy one is still the one I most want to do; the Baez angle resurfaced in [Structuralism, Not Objects](/posts/structuralism-not-objects). The November promise was that the blog would keep its roots in markdown, code, and flow diagrams, but each post would also carry a live model: a JSON-LD Petri net you can inspect, copy, and run — literate modeling, code and concept sharing the same page. A handful of posts kept it — [tic-tac-toe](/posts/tic-tac-toe-model) and [knapsack](/posts/knapsack-model) link their nets into the pflow.xyz editor — but most don't, and the concept posts almost never carry a model at all. The series was meant as recursion rather than nostalgia — re-entering past ideas to see what survives composition. ## What Changed *September 2026:* the August roster listed a "Z3/SMT" post that was never on the original list; the November line was **Coq**, and it is now matched to the Lean proof form. The pflow-polyglot link returned a 404 (the repository lives under `stackdump`, not `pflow-xyz`). The live-model paragraph was a promise restated as if it were kept; it now says where it was and wasn't. The Solidity line notes that bitwrap.io is no longer hosted. --- # Tic-Tac-Toe Model - URL: https://blog.stackdump.com/posts/tic-tac-toe-model - Date: 2025-11-13 - Tags: game, tic-tac-toe - Summary: Modeling tic-tac-toe using Petri nets with ODE simulation for AI move selection—no game heuristics, just model topology. # Tic-Tac-Toe Petri-Net Model ## Overview This post walks through two complementary Petri-net formulations of **tic-tac-toe**, progressing from a minimal turn-based grid to a memory-augmented model capable of reasoning about win conditions. These examples demonstrate how Petri nets naturally encode available actions, enforce alternation, and accumulate structured history for higher-level reasoning. The final sections outline how these models can be converted into **ODE systems via [go-pflow](https://github.com/pflow-xyz/go-pflow/tree/main/examples/tictactoe)** and how to apply **parameter sweeps** to explore strategy, reachability, and path likelihoods. ## Stage 1 — Grid & Turns In the first stage, we represent the tic-tac-toe grid as nine places `P00`–`P22`, each initialized with a token indicating an **empty cell**. For every square we define: - `Xij` transitions — claim the square for **X** - `Oij` transitions — claim the square for **O** A dedicated **`Next` place** enforces turn-taking: - Each **X move** deposits a token into `Next` - Each **O move** consumes that token - O moves cannot fire unless `Next` is marked, preserving alternation This structure cleanly separates: 1. Geometry 2. Legal actions 3. Alternating turn flow All purely through token movement and local composability—no global logic required. ![Grid and Turns](/images/tictactoe/grid-and-turns.svg) ### Model Diagram (Stage 1) [![pflow](https://pflow.xyz/img/z4EBG9jDRGeaoAqYA9UdvzKkaQup7gSwY9m8R9u4EwpL4GnjDWy.svg)](https://pflow.xyz/?cid=z4EBG9jDRGeaoAqYA9UdvzKkaQup7gSwY9m8R9u4EwpL4GnjDWy) ## Stage 2 — Adding History The second version keeps the same grid & turn logic but introduces a **memory layer**. Whenever a move fires (e.g. `X00`): - It consumes from the empty square `P00` - It deposits a token into a dedicated history place `_X00` Similarly, all O moves populate `_Oij` history places. This upgrades the model from *state of the board* to *sequence of moves*. Now we can reason about **why** the board reached its state—not just its current configuration. ![History Layer](/images/tictactoe/history-layer.svg) ### Model Diagram (Stage 2) [![pflow](https://pflow.xyz/img/z4EBG9j3uuHcoqAz5Bue9oD86ksJnnJEhHEFvV1fZM8uY9WVmSF.svg)](https://pflow.xyz/?cid=z4EBG9j3uuHcoqAz5Bue9oD86ksJnnJEhHEFvV1fZM8uY9WVmSF) ## Pattern Collectors & Win Detection Once we have history places, we can build **pattern collectors**—transitions that listen for combinations like: - Top row: `X00_X01_X02` - Left column: `X00_X10_X20` - Main diagonal: `X00_X11_X22` A pattern collector consumes history tokens from the three required squares, producing a structural token such as: - `X_has_top_row` - `O_has_diag` - etc. Finally, we add win detection transitions: - `win_x` fires when any X pattern place marks - `win_o` fires for O patterns This creates a fully compositional win-detection system without any global conditional logic. ![Board Positions](/images/tictactoe/board-positions.svg) The board uses position IDs `00`–`22` (row, column). There are exactly **8 winning patterns**: 3 rows, 3 columns, and 2 diagonals. ![Pattern Counts](/images/tictactoe/pattern-counts.svg) Each position participates in a different number of winning patterns. The **center (11)** participates in 4 patterns—both diagonals plus its row and column. **Corners** participate in 3 patterns (one diagonal plus row and column). **Edges** only participate in 2 (row and column). ![Win Detection Flow](/images/tictactoe/win-detection-flow.svg) Pattern collectors consume 3 history tokens from a complete line and produce a single win token. Higher pattern participation means more paths to victory, which directly translates to higher ODE scores. These scores turn out to be proportional to integers (4 : 3 : 2) — see [The Incidence Reduction](/posts/integer-reduction) for why. [![pflow](https://pflow.xyz/img/z4EBG9j1YQFZEQBpCn3ZjdpAuXpPnrKSDF5YXsW3q86znPztEoh.svg)](https://pflow.xyz/?cid=z4EBG9j1YQFZEQBpCn3ZjdpAuXpPnrKSDF5YXsW3q86znPztEoh) The Petri net above shows the full model with pattern collectors and **game-halting win detection**. When a win is detected, the transition consumes the current turn token without returning it—this prevents further play after the game ends. ### Why Game-Halting Matters Without game-halting, the ODE simulation continues exploring the full state space even after one player has won. This distorts strategic values because we're averaging over impossible continuations—games that would never happen in practice. The key insight: **win transitions must consume turn tokens**. When X wins: - The `x_win_*` transition consumes `o_turn` (it was O's turn when X completed the line) - No turn token is returned to either player This makes win states into **absorbing states** in the ODE dynamics—once reached, no further flow is possible. The simulation now correctly weights paths based on reachable game outcomes rather than mathematical completeness. ## ODE-Guided Move Selection Using **[go-pflow](https://github.com/pflow-xyz/go-pflow/tree/main/examples/tictactoe)**, we can convert this Petri net into an ODE system and simulate forward to predict outcomes. The key insight: **win place token accumulation predicts game outcomes**. For each possible move, we: 1. Create a hypothetical state after making that move 2. Run ODE simulation (mass-action kinetics, t=0 to t=2.0) 3. Measure final values of `win_x` and `win_o` places 4. Score = my_win − opponent_win ### Example 1: Empty Board X evaluates all 9 positions. The ODE reveals strategic value through model topology alone: ![Empty Board Heatmap](/images/tictactoe/example1-empty-board.svg) The center position scores highest (1.27) because it participates in 4 winning patterns (2 diagonals + row + column). Corners score next (0.95, 3 patterns each). Edges score lowest (0.63, 2 patterns each). The dotted box indicates the recommended move. **No game heuristics are coded.** Strategic value emerges purely from the Petri net's structure. ### Example 2: Responding to Center After X takes center, O evaluates defensive options: ![X at Center Heatmap](/images/tictactoe/example2-x-center.svg) All scores are negative (X has the advantage), but corners (-1.04) minimize X's lead better than edges (-1.38). The ODE naturally discovers the defensive principle: **corners against center**. ### Example 3: Finding the Winning Line X has center, O has corner. The ODE identifies the diagonal threat: ![Diagonal Threat Heatmap](/images/tictactoe/example3-diagonal-threat.svg) Positions (0,2) and (2,0) score highest (1.39) because they create **two-way threats**: each completes one diagonal while opening another winning path. The model's pattern collectors naturally amplify these fork positions. ### Example 4: Must-Block Situation O faces X's imminent win threat (X has two in a row): ![Must Block Heatmap](/images/tictactoe/example4-must-block.svg) Position (1,2) scores best (0.80) because it **blocks X's winning move**. Other positions score lower (0.74–0.77)—and that margin is worth staring at. The opening preferences differ by half a point; the forced block wins by hundredths. That thinness is not noise, it is a warning: tactical necessities are second-order effects in the relaxed flow. Later adversarial testing showed this margin is fragile—it holds or flips depending on solver rate choices—and that one ply deeper, it is gone entirely. See the [correction below](#correction-the-ode-was-counting-not-searching). ## Draw Detection A critical enhancement: the model tracks **move tokens** to detect draws. Each play transition deposits a token into a `move_tokens` counter. A `draw` transition fires when: - 9 move tokens have accumulated (all squares filled) - `game_active` is still marked (no winner yet) The draw transition awards a point to `win_o`, making "not losing" valuable. This has a profound effect on the ODE dynamics: **Without draw detection:** Games that end in draws "leak" out of the system without affecting win counts. The ODE only sees win paths, causing it to favor positions with more winning lines (corners) even when blocking is essential. **With draw detection:** All outcomes flow to either `win_x` or `win_o`. Blocking a threat now has measurable value because it preserves the possibility of a draw—and draws count for O. This is why scores are now positive for O (0.74–0.80 instead of negative). O's expected outcome includes draws, making blocking the clearly dominant move. ## How It Works The work is in the **pattern collector transitions**. When X occupies two squares of a winning pattern, the ODE flow toward `win_x` increases. Blocking that pattern cuts off the flow. ``` Score = my_win_final − opponent_win_final ``` From each player's perspective, higher is better. The formula points in the minimax direction: maximize your win potential while minimizing your opponent's. But pointing is not proving—see the correction below for what the continuous dynamics actually compute, and what they cannot. Note: Examples 1–3 show scores from X's perspective (before draw detection was added), while Example 4 shows O's perspective with the full model. The relative rankings within each example show the ODE recovering the net's strategic structure. *Data generated with [go-pflow/examples/tictactoe](https://github.com/pflow-xyz/go-pflow/tree/main/examples/tictactoe)* ## Correction: The ODE Was Counting, Not Searching *(Added 2026-08-24.)* An earlier version of this post claimed the continuous dynamics "correctly approximate discrete game tree search" and that Example 4 showed the ODE discovering blocking. Both claims were too strong, and it's worth being precise about which part survives—because the part that survives is better than the original claim. **What the ODE actually computes is the incidence ranking.** The empty-board scores sit in the ratio 4 : 3 : 2—exactly the number of win lines through center, corner, and edge. That is not a coincidence the dynamics happened upon; with uniform rates and terminal win transitions, the flow into a win place is governed by how many live lines feed it, so the ODE score *is* the live-line count, recovered numerically. In hindsight this should have been obvious: the solver was rediscovering a graph property—count the arcs to still-reachable terminals—that the incidence matrix states directly, exactly, and for free. [The Incidence Reduction](/posts/integer-reduction) works out the closed form. **What the ODE cannot do is resolve a race.** Mass action has no "first"—a threat and its block flow concurrently instead of sequencing, so a tactic whose value lives in move order is second-order in the relaxed flow. Testing against exact minimax *over the same net* makes this concrete, at two depths: - **Depth 1 (must-block) is fragile, not solid.** With uniform rates the block wins by hundredths, as Example 4 shows. Raise the win-detector rates and the ranking *flips*—the block drops below losing corner moves. A move picker whose tactical correctness depends on a solver rate choice was never seeing the tactic; it was balanced on an artifact. - **Depth 2 (the fork) fails under every configuration.** In the double-corner trap—X in opposite corners, O in center, no threat on the board yet—every corner reply loses to the fork and every edge draws. A sweep across horizons, win-detector rates, and draw rates (27 configurations) mis-ranks it in all of them. Over 100 games against a minimax opponent randomizing across its optimal lines, play-scoring holds a perfect 100 draws as X but loses 7% as O—every loss a fork it could not see coming. I hadn't tested adversarially enough to find this—a random-opponent tournament flatters a strategic prior, because random opponents rarely punish a missed tactic. A minimax referee punishes every one. **The revised claim.** Structure yields the *prior*, exactly: which moves are strategically strong is a topological fact, and the incidence count states it without simulation. Search supplies the *tactics*: exact minimax over the net's own firing rule—enabled transitions as legal moves, the win detectors as referee—resolves the races the flow cannot. The prior's right seat is ordering the search's moves, where a good ranking collapses the tree via alpha-beta cutoffs and a wrong one costs nodes, never the game. [Chapter 6](https://book.pflow.xyz/ch06-game-mechanics.html) develops both halves, tournament tables included. The declarative thesis comes out stronger, not weaker: the derivation that survives scrutiny is the exact one read off the model's structure, and the model itself supplies the referee that catches the approximation's overreach. *(This correction stood for about a day. The experiment that produced it kept going — see the resolution below.)* ## Resolution: Declare the Opponent, Too *(Added 2026-08-25.)* The correction above ends with "structure for the prior, search for the tactics." That division held for exactly as long as it took to ask the next question: the impossibility argument was about scoring the **unmodified** net — what if the net itself is the variable? The answer closed the gap completely, and the route there is worth recording, wrong turns included, because each wrong turn is a modeling principle. Where the beliefs stood, by date: | date | belief | |---|---| | 2025-11 | the ODE approximates game-tree search | | 2026-08-24 | it was counting line incidence; tactics need search | | 2026-08-25 | with the opponent's policy declared as structure, the ODE evaluator **is** minimax-equivalent — verified exhaustively | **The impossibility was real, and it was a signpost.** At the fork position, the losing moves dominate the optimal ones on *every* final-state coordinate, at every rate configuration. No rescoring of the declared net can pick correctly — proven, not just measured. So whatever fixed it had to be structural. Three structural ideas then failed in instructive ways: - **Threat-accumulator places** (new coordinates integrating "how much live threat did each side hold") inherit the same dominance — at the fork they point the wrong way too. Time-integrated exposure is still not sequencing. - **A single "answer threats" bias** on the flow fixes the fork at exactly the strength that breaks the opening reply. The joint window is empty; tournaments confirmed it with ~46 losses in 100, every one the same opening error. - **Incidence-weighted deposits** (center deposits 4, corner 3, edge 2 — the line counts written into the arc weights) fail in all three formulations tried, because *the topology already applies that prior*, exactly once, for free — that was the 4 : 3 : 2 finding above. Writing a prior the structure already computes into the weights applies it twice, and every duplicate is paid for. **What worked is embarrassingly direct: declare the opponent's policy as structure.** For each win line and each of its cells, add a copy of the play transition for that cell, catalyzed (via read arcs) by the opponent holding the line's other two cells. Forty-eight such transitions, one shared rate. Inside the forward solve, whenever a threat exists, flow pours into the move that answers it — the simulated players stop being uniform-random and start playing *threats-are-answered*. The evaluation's real identity finally becomes visible: the plain ODE was computing uniform-random-playout outcome probabilities (which is why it was 93% right — tic-tac-toe is almost, but not quite, solvable by flat Monte Carlo), and the missing ingredient was never search. It was the opponent model, and the opponent model was declarable. The final evaluation net is *derived* from the declared one by three mechanical transforms: delete the draw (its counter semantics cannot survive the relaxation and poisons the objective if kept — measured, not asserted), delete the places that become write-only, add the forced-reply copies. Two scalars remain — the reply bias strength and the defender's win-versus-survive exchange rate — and they fall out of a 2-parameter Nelder-Mead fit against minimax labels from a naive start. No hand-tuning survives in the story. **The verdict is exhaustive, not sampled.** A referee walks *every* legal opponent line — optimal or not, both seats — and checks that the evaluator's move never worsens the exact game value: **zero value-losing moves and zero missed wins**, over every reachable decision. One ODE solve per candidate move, a linear read-off of the final state, no search, no rollouts — and it never loses a drawn position and converts every won one. Two findings from the wreckage worth carrying to other games: - **Static evaluators degrade gracefully with initiative and sharply without it.** The plain evaluator was already perfect as X and failed only as O — because a tempo ahead, every threat you must answer is *on the board* (a depth-1 fact the flow measures); a tempo behind, the decisive danger is assembled out of the opponent's forced replies and lives only in move order. Instrument the defending seats first. - **A prior the structure already computes must not also be written into the weights, the masses, or the rates.** The topology applies it exactly once. And the honest revision of the revision: the 08-24 correction said the ODE's right seat was ordering moves for a search. That undersold it. The ODE evaluates *whatever net you hand it* — and deriving that net is where the modeling actually lives. The declared model stays the single source of truth and the referee; the evaluation net is a compiled artifact, built from it by transforms with stated semantics. The transforms and the fitting now ship as general tools in [go-pflow v0.23.1](https://github.com/pflow-xyz/go-pflow) (`derive`, `learn.Minimize`); the full experiment — fifteen findings, the referee, the champion net — is `experiments/ode-minimax` in sim.pflow.xyz's source (the repository is private). The tic-tac-toe net itself is in [sim's catalog](https://sim.pflow.xyz/whatif/?id=5a08c67862a2eb55799f5229). ## Conclusion This tic-tac-toe model demonstrates how a simple game becomes deeply expressive when framed through Petri-net structure. By building the system in layers—grid and turn alternation, history tracking, pattern collectors, and win detection—we create a model capable of representing not just *what* happened, but *why*. Every rule arises locally from token flow. There's no central controller, no global conditional logic—just emergent behavior from places, transitions, and structured history. The ODE simulation reveals strategic value without any game-specific heuristics. For more on this approach, see [Declarative Differential Models](/posts/declarative-differential-models). For why the ODE works — how the Petri net [absorbs the search tree](/posts/integer-reduction#absorbing-the-search-tree) — see [The Incidence Reduction](/posts/integer-reduction). For the temporal interpretation of these layers — history as tropical left context, board as tense boundary, enabled moves as predicate right context: [The Zipper Whose Hole Is a Universe](/posts/tense-type-theory). *This topic is covered in depth in [Chapter 6: Game Mechanics](https://book.pflow.xyz/ch06-game-mechanics.html) of [Petri Nets as a Universal Abstraction](https://book.pflow.xyz).* --- # Sudoku Petri-Net Model - URL: https://blog.stackdump.com/posts/sudoku-petri-net-model - Date: 2025-11-28 - Tags: sudoku, petri-net, ode, ddm - Summary: A 4×4 Sudoku as a Petri net in the tic-tac-toe pattern — cells, digit moves with history, constraint collectors into a solved place — checked against the go-pflow generator, which shows the collectors as built can never fire and the ODE ranking is not yet demonstrated. # Sudoku Petri-Net Model *Prerequisites: [Tic-Tac-Toe Model](/posts/tic-tac-toe-model) for the cell / history / collector pattern this borrows, and [Declarative Differential Models](/posts/declarative-differential-models) for what "run the ODE" means here.* We model a 4×4 mini-Sudoku as a Petri net in the same three layers as tic-tac-toe — cells, digit moves that leave history, collectors that feed a `solved` place — and ask whether an ODE simulation of that net can rank candidate moves. The generator is `createODENet` in go-pflow's [`examples/sudoku/cmd/model.go`](https://github.com/pflow-xyz/go-pflow/blob/main/examples/sudoku/cmd/model.go), and the nets it produces are committed beside it as [`sudoku-4x4-ode.jsonld`](https://github.com/pflow-xyz/go-pflow/blob/main/examples/sudoku/sudoku-4x4-ode.jsonld) and [`sudoku-9x9-ode.jsonld`](https://github.com/pflow-xyz/go-pflow/blob/main/examples/sudoku/sudoku-9x9-ode.jsonld). Reading that code again in October 2026 showed the net does less than the first version of this post said; what follows describes what the code builds, and [What Changed](#what-changed) lists the corrections. ## The Puzzle ``` Initial: Solution: +---+---+---+---+ +---+---+---+---+ | 1 | . | . | . | | 1 | 2 | 4 | 3 | +---+---+---+---+ +---+---+---+---+ | . | . | 2 | . | | 3 | 4 | 2 | 1 | +===+===+===+===+ -> +===+===+===+===+ | . | 3 | . | . | | 2 | 3 | 1 | 4 | +---+---+---+---+ +---+---+---+---+ | . | . | . | 4 | | 4 | 1 | 3 | 2 | +---+---+---+---+ +---+---+---+---+ Constraints: Each row, column, and 2×2 block must contain digits 1-4 exactly once. ``` ## Petri Net Structure ![Layer Structure](/images/sudoku/layer-structure.svg) The 4×4 net has 81 places — 16 cells, 64 history places, `solved` — and 60 transitions: 48 digit moves and 12 constraint collectors. Those are the counts in the committed `sudoku-4x4-ode.jsonld` (60 `Transition` nodes; 81 places and 300 arcs make up the rest of its 441). The figure's "64 txns" is the empty-grid count; the generator only makes moves for empty cells. ### Layer 1: Cell Places ![Cell Places](/images/sudoku/cell-places.svg) Each empty cell place `P{r}_{c}` holds one token. The four given cells start at 0, and the history place for their clue starts at 1 instead — `_D1_0_0` is marked from the beginning because the puzzle gives a 1 at (0,0). ### Layer 2: Digit Transitions & History For each empty cell and each digit, a transition writes the digit: ``` Transition D2_0_1: History place _D2_0_1: "Place 2 at (0,1)" "Digit 2 is at (0,1)" +-------+ P0_1 --| D2_0_1|---> _D2_0_1 (1) +-------+ (0->1) Firing consumes the cell token, creates a history token. ``` That gives 12 empty cells × 4 digits = **48 digit transitions**, while history places are made for every cell, 16 × 4 = **64**. A digit transition has exactly one input arc, from its cell, and one output arc, to its history place. Nothing from a row, column or block touches it, so the net itself does not stop us writing a second 1 into row 0; whatever constraint the model carries has to live in the collectors. ### Layer 3: Constraint Collectors ![Constraint Collectors](/images/sudoku/constraint-collectors.svg) There are 12 collectors — 4 rows, 4 columns, 4 blocks — each with one output arc into `solved`, whose capacity is 12. The figure draws `Row0_Complete` as firing "when row 0 has 1, 2, 3, 4", and that is what it was meant to do. The generator wires it differently: the collector gets an input arc from *every* history place in its row, all 4 digits × 4 cells = 16 places, so it is enabled only when every cell of the row holds every digit at once. A cell has one token to give, so on any legal fill 4 of those 16 places are marked and the collector stays disabled. As built, `solved` cannot reach 12; it cannot leave 0. ## Firing a Move ``` Cell (0,1): 1. Initial: P0_1 = 1 (cell empty) 2. D2_0_1 is enabled (P0_1 has a token) — and so are D1_0_1, D3_0_1 and D4_0_1, by the same arc 3. Firing D2_0_1: • P0_1: 1 → 0 (cell now filled) • _D2_0_1: 0 → 1 (history records "2 at (0,1)") 4. Row0_Complete is still not enabled: it also needs _D1_0_1, _D3_0_1, _D4_0_1, and _D2_0_0 … _D4_0_0 for the given cell, none of which can ever be marked ``` ## ODE Simulation The method carried over from tic-tac-toe is to score each candidate by simulating the net from the marking the move would leave, and measuring how much reaches `solved`. As a procedure (a sketch, not code from the repo): ``` For each candidate move: 1. Create hypothetical state 2. Run ODE simulation (t=0 to t=3.0) 3. Measure token flow to 'solved' place 4. Higher flow = move more likely leads to solution ``` On this net that procedure gives every candidate the same score, zero. go-pflow's mass-action rate law sets a transition's flux to its rate times the product of its input places, and to 0 outright if any input is at 0 ([`solver/ode.go`](https://github.com/pflow-xyz/go-pflow/blob/main/solver/ode.go)). Every row, column and block of the sample puzzle contains one given cell; a given cell has no digit transitions, so three of its four history places are 0 for all t; and each collector has those places among its inputs. So every collector's flux is 0 from the first step, whichever move we tried. Separately, the four digit transitions out of an empty cell share one input and one rate (`SetRates` defaults every transition to 1.0), so the ODE splits the cell's token evenly between them and cannot tell 2 from 4 at (0,1) even before the collectors come in. We got this by reading the generator and the solver against the committed net, not by re-running it. ### Example: Evaluating Cell (0,1) ![ODE Evaluation](/images/sudoku/ode-evaluation.svg) The scores in this figure — 0.31 for 2, 0.28 for 4 — are illustrative; no run in the repo produces them, and by the argument above the current net would score both 0. The two exclusions are the reader's reasoning, not the net's. Digit 1 is out because row 0 already has a 1; digit 3 is out because column 1 has the 3 at (2,1) — the figure says block 0, but (2,1) is in the bottom-left block. Neither shows up as zero flow in the model, where `D1_0_1` and `D3_0_1` are exactly as enabled as `D2_0_1`. ## Scaling to 9×9 The same generator builds the 9×9 net: | Component | 4×4 | 9×9 | |-----------|-----|-----| | Cell places | 16 | 81 | | Digits | 4 | 9 | | History places | 64 | 729 | | Digit transitions (sample puzzle) | 48 | 459 | | Digit transitions (empty grid) | 64 | 729 | | Row / column / block collectors | 4 / 4 / 4 | 9 / 9 / 9 | | Inputs per collector | 16 | 81 | | `solved` capacity | 12 | 27 | The 9×9 sample is the 30-clue puzzle in the example's [README](https://github.com/pflow-xyz/go-pflow/blob/main/examples/sudoku/README.md), so 51 empty cells and 459 digit moves; the committed `sudoku-9x9-ode.jsonld` has 486 transitions, which is 459 plus the 27 collectors. That README says 729 digit transitions, which is only true of an empty grid. Every row, column and block of that puzzle also has a given, so the 9×9 collectors are starved the same way the 4×4 ones are. ## Tuning the ODE A full-precision solve of the 9×9 net is slow, so the benchmark in [`sudoku_bench_test.go`](https://github.com/pflow-xyz/go-pflow/blob/main/examples/sudoku/cmd/sudoku_bench_test.go) runs Tsit5 with looser settings than `solver.DefaultOptions()`: ``` Parameter Default Benchmark Why ───────────────────────────────────────────────────── Time horizon t=10.0 t=3.0 We only need relative rankings Abstol 1e-6 1e-4 Looser tolerance, faster solve Reltol 1e-3 1e-3 Already reasonable Step size (dt) 0.01 0.2 Larger steps, fewer iterations Max iterations 100000 100000 The benchmark leaves it alone ``` The t=10 horizon is tic-tac-toe's original one, not a solver default — the horizon is passed with the problem. The reason for cutting it is that ranking only needs candidates in the right order, not converged token counts, and the bet is that a move with more flow at t=3.0 also has more at t=10.0. Nothing in the repo tests that bet for Sudoku, and with every score at 0 there is nothing to order yet. The [tic-tac-toe example](https://github.com/pflow-xyz/go-pflow/tree/main/examples/tictactoe) is where the same cut (10 → 3, abstol 1e-4, dt 0.2) was made first; its README reports 21× from it, about 0.04 s per move evaluation. ### Performance [`benchmark_results.txt`](https://github.com/pflow-xyz/go-pflow/blob/main/examples/sudoku/benchmark_results.txt) records one run on an Apple M4 Max: | Benchmark | Time per evaluation | |-----------|---------------------| | 4×4, benchmark settings | 0.067 s | | 4×4, tighter tolerance | 14.8 s | | 9×9, benchmark settings | 4.58 s | That file names benchmarks (`…SingleEvaluation`, `…WithTighterTolerance`) the current test file no longer has — it now has `BenchmarkSudoku4x4ODE`, `9x9ODE` and `16x16ODE` — so the tighter-tolerance settings behind the 14.8 s are not in the repo, and the roughly 220× gap between those two 4×4 rows is against settings we can't name. The first version of this post gave 0.8 s → 0.04 s for 4×4 and ~60 s → ~3 s for 9×9; we can't find either "naive" number recorded anywhere. At the recorded speeds, the four candidates for a 4×4 cell take about a quarter of a second, and the nine for a 9×9 cell about forty. ## Try It ```bash cd examples/sudoku go run ./cmd --size 4x4 --ode --analyze ``` That loads the committed 4×4 net and prints the puzzle, the place / transition / arc counts and a solution check. The `--analyze` path does not call the solver: `runODESimulationOnNet` in [`main.go`](https://github.com/pflow-xyz/go-pflow/blob/main/examples/sudoku/cmd/main.go) prints a description of the method and returns. The code that actually integrates the net is the benchmark, from the go-pflow root: ```bash go test -run '^$' -bench Sudoku4x4ODE ./examples/sudoku/cmd ``` It times the solve and throws the result away — nothing in the repo yet reads `solved` from a solution or ranks a move. Not re-run for this revision. ## Open question What collector would make the ranking work? "Row 0 is complete" means each digit appears in some cell of the row — an AND over digits of an OR over cells — and a transition's enabling is a plain AND over its inputs, which is how the generator ended up asking for all 16. One shape that fits would put the OR in the arcs: a "1 is in row 0" place fed by each of the row's four `_D1_0_c` places, and a collector taking one token from each of the four digit places. We haven't built it. Even if it fires, it fixes only the collectors; the digit layer still has no arcs that make a conflicting placement cost anything, so whether the ODE would separate 2 from 4 at (0,1) — or rule out 1 and 3 — is open. ## What Changed *2026-10-17.* Re-read against `examples/sudoku/cmd/model.go`, `main.go`, `sudoku_bench_test.go`, `benchmark_results.txt`, the two committed `-ode.jsonld` nets and `solver/ode.go`. Digit transitions corrected from 64 to 48 (4×4) and from 729 to 459 (9×9 sample), since the generator makes them only for empty cells. The collector description now says what the arcs say — an input from every history place in the row, column or block — which means it never fires on a legal fill, and `solved` stays at 0. The ODE section now says the current net scores every candidate 0 rather than ranking them; the 0.31 / 0.28 scores and "conflicts appear as zero-flow transitions" are marked illustrative, and the figure's "block 0 has 3" is corrected to column 1. The tuning table's "max iterations 1000" is gone (the benchmark leaves 100000), the performance table is replaced with the recorded benchmark, and the earlier naive timings and tic-tac-toe 2.1 s → 0.1 s row are dropped as unrecorded. "Try It" now says the `--analyze` flag doesn't run the solver, and points at the benchmark. Node names follow the code (`P0_1`, `D2_0_1`, `_D2_0_1`). --- *Data from [go-pflow](https://github.com/pflow-xyz/go-pflow) · For the method: [Declarative Differential Models](/posts/declarative-differential-models)* *[Chapter 7: Constraint Satisfaction](https://book.pflow.xyz/ch07-constraint-satisfaction.html) of [Petri Nets as a Universal Abstraction](https://book.pflow.xyz) tells the earlier version of this model and has not yet had these corrections.* *Related: [Tic-Tac-Toe Model](/posts/tic-tac-toe-model) · [Knapsack Model](/posts/knapsack-model)* --- # The Token Language - URL: https://blog.stackdump.com/posts/token-language - Date: 2026-01-25 - Tags: go-pflow, dsl, petri-net, guards, invariants, category-theory - Summary: Four terms — cell, func, arrow, guard — generate a free symmetric monoidal category. The DSL is a categorical language for executable token models. # The Token Language At the heart of [go-pflow](https://github.com/pflow-xyz/go-pflow) is a language for defining and executing token models. Four terms generate the entire structure. ## The Four Terms ![Four-Term DSL](/images/token-language/four-term-dsl.svg) | Term | Maps To | Categorical Role | |------|---------|-----------------| | `cell` | State/Place | Object in the free SMC | | `func` | Action/Transition | Morphism | | `arrow` | Arc | Wiring between objects and morphisms | | `guard` | Predicate | Right context — constrains what fires next | These are the generators of a [free symmetric monoidal category](/posts/symmetric-monoidal-categories). Every token model — ERC-20 tokens, game boards, workflow engines — is a composition of morphisms built from these four terms. The DSL is a text syntax for writing morphisms. ### S-Expression Syntax ```lisp (schema ERC-20 (version v1.0.0) (state totalSupply :type uint256) (state balances :type map[address]uint256 :exported) (state allowances :type map[address]map[address]uint256) (action transfer :guard {balances[from] >= amount && to != address(0)}) (action approve) (action mint :guard {to != address(0)}) (action burn :guard {balances[from] >= amount}) (arc balances -> transfer :keys (from)) (arc transfer -> balances :keys (to)) (arc mint -> totalSupply) (arc totalSupply -> burn) (constraint conservation {sum(balances) == totalSupply})) ``` Each form declares structure. The parser builds an AST, the interpreter converts it to an executable schema with validated references. The same text that documents the model runs it. ## Memory Model: Tokens vs Data The language distinguishes two kinds of state: ![Memory Model](/images/token-language/memory-model.svg) ### TokenState Integer counters. Classic Petri net semantics — firing consumes from input places, produces to output places. Tokens are fungible; we track counts, not individuals. ```go type Snapshot struct { Tokens map[string]int // TokenState Data map[string]any // DataState } ``` ### DataState Typed containers — maps, records, scalar values. Arc keys specify access paths: `(arc balances -> transfer :keys (from))` binds `from` to the map key. Data arcs read and write; they don't consume. The combination handles both control flow (tokens) and data transformations (balances, permissions, structured state). ## Guards and Invariants Guards and invariants split into [right and left context](/posts/tense-type-theory): ![Guard Evaluation](/images/token-language/guard-evaluation.svg) **Guards are right context** — predicates on the current marking, recomputed each step. An action fires only when its guard is true: ``` balances[from] >= amount && to != address(0) ``` Guards support comparison, logical operators, indexing (`balances[key]`, `allowances[owner][spender]`), field access, and function calls (`sum`, `count`, `address(0)`). Short-circuit evaluation. **Invariants are left context** — conservation laws that must hold across all transitions: ```lisp (constraint conservation {sum(balances) == totalSupply}) ``` Guards ask "can we fire?" Invariants ask "did we break a conservation law?" They sit on opposite sides of the [core-observer boundary](/posts/earned-compression), but not the side you might expect: invariants are statements about C (the reversible core's conservation laws), while guards are *contextual* — read arcs, outside C — and are the predicate layer that [The Category Settle](/posts/category-settle#two-boundaries-not-one) calls R. A guard can be irreversible or not; what makes it a guard is that C can't see it. ![Invariant System](/images/token-language/invariant-system.svg) ## Execution Pipeline ![Execution Pipeline](/images/token-language/execution-pipeline.svg) From source to running model: lexer → parser → AST → executable schema. Each action execution follows the [zipper step](/posts/tense-type-theory): check guard (right context), process input arcs (consume/bind), process output arcs (produce/update), check invariants (left context), commit snapshot. If the guard fails, the action is blocked. If an invariant fails, the action rolls back. The marking updates, the left context grows, the right context recomputes. One step to the right. ## From Prototype to Production The S-expression syntax above is the prototype in [go-pflow](https://github.com/pflow-xyz/go-pflow). [Bitwrap](https://bitwrap.io) is the production language — a `.btw` syntax that compiles to Solidity smart contracts, ZK circuits, and Foundry test suites: ``` schema ERC20 { register ASSETS.AVAILABLE map[address]uint256 observable register ASSETS.TOTAL_SUPPLY uint256 fn(transfer) { var from address var to address var amount amount require(ASSETS.AVAILABLE[from] >= amount && amount > 0) ASSETS.AVAILABLE[from] -|amount|> transfer transfer -|amount|> ASSETS.AVAILABLE[to] } } ``` The arc syntax `-|amount|>` is a morphism in the free SMC written as text. `register` declares objects. `fn` declares morphisms. `require` is the right context. The compiler generates Solidity (the universe — on-chain state), ZK proofs (the left context — compressed witness of valid past transitions), and tests. The compilation pipeline is the [pflow square](/posts/pflow-square): parse the net (N), build the SMC (F), extract the universe (U), equip execution context (Exec). Bitwrap closes that loop — a categorical language where programs are morphisms, compilation enforces the structure, and you get verified contracts out the other end. ## Try It The prototype is in [go-pflow](https://github.com/pflow-xyz/go-pflow). The production compiler is at [bitwrap.io](https://bitwrap.io). For visual exploration, [pflow.xyz](https://pflow.xyz) provides a browser-based editor with ODE analysis. --- *Related: [The Pflow Square](/posts/pflow-square) · [Symmetric Monoidal Categories](/posts/symmetric-monoidal-categories) · [The Zipper Whose Hole Is a Universe](/posts/tense-type-theory) · [Earned Compression](/posts/earned-compression) · [Bitwrap Capstone](/posts/bitwrap-capstone)* *This topic is covered in depth in [Chapter 4: The Token Language](https://book.pflow.xyz/ch04-token-language.html) of [Petri Nets as a Universal Abstraction](https://book.pflow.xyz).* --- # tens-city v0.8: A Federated Blog in 500 Lines - URL: https://blog.stackdump.com/posts/tens-city-release - Date: 2026-01-25 - Tags: tens-city, activitypub, federation, fediverse, go - Summary: A minimal blog platform with ActivityPub federation. Markdown files, a Go binary, JSON for state. No database required. # tens-city v0.8: A Federated Blog in 500 Lines We released [tens-city v0.8.0](https://github.com/stackdump/tens-city/releases/tag/v0.8.0) this week. It's a minimal blog platform with full ActivityPub federation support. You can follow this blog from Mastodon at `@myork@blog.stackdump.com`. ## The Minimal Stack Most blog platforms grow into content management systems. They accumulate features: admin panels, user management, plugin systems, database migrations. We went the other direction. ![The Minimal Stack](/images/tens-city-release/stack-architecture.svg) The entire stack: | Component | Purpose | |-----------|---------| | Markdown files | Content with YAML frontmatter | | Go binary | Renders markdown, handles HTTP, signs ActivityPub requests | | JSON files | Followers, published posts, RSA keys | | nginx | TLS termination, reverse proxy | That's it. No database server. No background job queue. No cache layer. The webserver reads markdown files from disk and renders them on demand. ## ActivityPub Federation The interesting part of this release is ActivityPub support. We can now federate with Mastodon, Misskey, Pleroma, and any other ActivityPub-compatible platform. ![ActivityPub Federation](/images/tens-city-release/federation-flow.svg) ### How It Works **Discovery**: When someone searches for `@myork@blog.stackdump.com` on Mastodon, their server queries our WebFinger endpoint (`/.well-known/webfinger`) to find the actor URL. Then it fetches the actor profile to get the inbox, outbox, and public key. **Following**: When a user clicks "Follow", their server sends a signed `Follow` activity to our inbox. We verify the HTTP signature, store the follower URL, and send back an `Accept` activity. **Publishing**: When we publish a new post, we wrap it in a `Create` activity with an `Article` object and POST it to each follower's inbox. The request is signed with our RSA private key so their server can verify it came from us. ### HTTP Signatures Every ActivityPub request between servers is signed. This prevents spoofing—a malicious server can't pretend to be us because they don't have our private key. ``` Signature: keyId="https://blog.stackdump.com/users/myork#main-key", algorithm="rsa-sha256", headers="(request-target) host date digest", signature="base64..." ``` The receiving server fetches our public key from our actor profile and verifies the signature matches the request body. ## State Without a Database Where does the follower list go? Where do we track which posts have been federated? Most platforms would reach for PostgreSQL here. We use JSON files. ![State Files](/images/tens-city-release/state-files.svg) ```json // followers.json [ "https://mastodon.social/users/someone", "https://hachyderm.io/users/another" ] // published.json [ "https://blog.stackdump.com/posts/tic-tac-toe-model", "https://blog.stackdump.com/posts/token-language" ] ``` This scales to thousands of followers and hundreds of posts with no performance issues. JSON parsing is fast. File reads are fast. We don't need transactions or complex queries—just append to a list and write it back. The tradeoff: we can't efficiently query "who followed after date X" or "which posts got the most boosts". We don't need those features for a personal blog. ## The Publish Workflow Publishing a post is one command: ![Publish Workflow](/images/tens-city-release/publish-workflow.svg) ```bash ./publish.sh "Add new blog post" ``` This script: 1. Commits changes to git 2. Pushes to GitHub 3. SSHs to the server and pulls 4. Restarts the webserver 5. Calls `/publish` to federate new posts The federation step is idempotent—it checks `published.json` and only sends posts that haven't been sent before. We can run it repeatedly without spamming followers. ## What We Didn't Build The interesting design decisions are what we left out: **No admin panel**: Edit markdown files directly. Use git for version control. **No media uploads**: Put images in `content/images/` and commit them. **No comments**: The fediverse is the comment system. Reply to a post on Mastodon. **No analytics**: We don't track readers. If we wanted analytics, we'd add a lightweight script. **No scheduled posts**: Write when ready, publish when ready. **No themes**: The HTML/CSS is in the Go binary. Fork and modify if needed. Each missing feature is a maintenance burden we don't carry. ## Running Your Own ```bash # Clone and build git clone https://github.com/stackdump/tens-city cd tens-city && make build # Configure ActivityPub export ACTIVITYPUB_DOMAIN=blog.example.com export ACTIVITYPUB_USERNAME=author export ACTIVITYPUB_PUBLISH_TOKEN=$(openssl rand -base64 32) # Start server ./webserver -addr :8080 -content content/posts ``` Add markdown files to `content/posts/`, put nginx in front with TLS, and you have a federated blog. ## Philosophy The name "tens city" evokes tent cities—minimal structures, easily moved, no bureaucracy. The software embodies this: a single binary, files on disk, no dependencies beyond the operating system. We could add features. User accounts, comment moderation, post scheduling, theme customization. Each feature makes the system harder to understand, harder to maintain, harder to trust. Instead, we keep it small. The entire ActivityPub implementation is ~500 lines of Go. We can read it, understand it, debug it. When something breaks, we know where to look. Small models beat large models. This applies to software too. ## Links - [tens-city on GitHub](https://github.com/stackdump/tens-city) - [Follow @myork@blog.stackdump.com](https://blog.stackdump.com/users/myork) - [ActivityPub Specification](https://www.w3.org/TR/activitypub/) --- # ZK Tic-Tac-Toe Model - URL: https://blog.stackdump.com/posts/zk-tic-tac-toe-model - Date: 2026-01-31 - Tags: petri-net, zero-knowledge, gnark, cryptography - Summary: Zero-knowledge proofs meet Petri nets—cryptographically verify valid game moves without revealing strategy using gnark circuits. # ZK Tic-Tac-Toe Model This post extends the [tic-tac-toe Petri net model](/posts/tic-tac-toe-model) with **zero-knowledge proofs**. Using [gnark](https://github.com/ConsenSys/gnark), we can cryptographically verify that a move is valid without revealing the player's strategy or the complete game state. The plain tic-tac-toe net runs on [sim.pflow.xyz](https://sim.pflow.xyz/whatif/?id=5a08c67862a2eb55799f5229); the browser demo that proved each move has been retired, so the circuit below is described here rather than linked. ## The Problem In the basic tic-tac-toe model, all state is visible. Both players see: - Current board position - Whose turn it is - All previous moves But what if we want **private strategy**? Or **verifiable computation** where a third party confirms moves are legal without seeing the game? Zero-knowledge proofs make this possible. ## What ZK Proves A zero-knowledge proof for tic-tac-toe demonstrates: 1. **Valid transition**: The move corresponds to a legal Petri net transition 2. **Correct preconditions**: Required input places have tokens 3. **Turn compliance**: It's actually this player's turn 4. **No tampering**: The state hash matches the previous state All without revealing: - Which specific move was made - The current board configuration - The player's strategy ![Proof Flow](/images/zk-tic-tac-toe/proof-flow.svg) ## From Petri Net to Circuit The gnark circuit encodes the Petri net's transition rules as arithmetic constraints: ```go // Simplified circuit structure type MoveCircuit struct { // Public inputs PrevStateHash frontend.Variable `gnark:",public"` NewStateHash frontend.Variable `gnark:",public"` PlayerTurn frontend.Variable `gnark:",public"` // Private witness Position frontend.Variable // Which cell (0-8) PrevMarking [10]frontend.Variable // Previous token state NewMarking [10]frontend.Variable // New token state } ``` The circuit enforces: ``` 1. Position ∈ {0, 1, 2, 3, 4, 5, 6, 7, 8} 2. PrevMarking[Position] == 1 (cell was empty) 3. NewMarking[Position] == 0 (cell now claimed) 4. Turn token moved correctly 5. Hash(PrevMarking) == PrevStateHash 6. Hash(NewMarking) == NewStateHash ``` ## Circuit Constraints Each Petri net rule becomes an R1CS constraint: ![Circuit Constraints](/images/zk-tic-tac-toe/circuit-constraints.svg) | Petri Net Rule | Circuit Constraint | |----------------|-------------------| | Cell available | `prev_marking[pos] * 1 == 1` | | Cell claimed | `new_marking[pos] * 1 == 0` | | Turn consumed | `prev_turn[player] == 1` | | Turn produced | `new_turn[next_player] == 1` | | State unchanged elsewhere | `∀i≠pos: prev[i] == new[i]` | The total circuit has ~100 constraints for a single move verification. ## Proof Generation When a player makes a move: ``` 1. Compute new marking (fire transition locally) 2. Hash the new state 3. Generate ZK proof with gnark 4. Send: (prev_hash, new_hash, player_turn, proof) ``` The verifier checks the proof without seeing the actual move: ``` 1. Verify proof against public inputs 2. If valid: accept new_hash as canonical state 3. If invalid: reject (cheating detected) ``` ## Why ZK for Petri Nets? Petri nets and ZK circuits share a key property: **local verification**. A transition fires based only on its input places, not global state. This maps directly to R1CS constraints. ![Locality Property](/images/zk-tic-tac-toe/locality-property.svg) Benefits of the combination: | Property | Petri Net | ZK Circuit | |----------|-----------|------------| | **Composable** | Subnets combine | Circuits compose | | **Local** | Transitions check inputs | Constraints check witnesses | | **Deterministic** | Same inputs → same outputs | Same witness → same proof | | **Verifiable** | Marking evolution | Proof verification | ## Game Protocol A complete ZK tic-tac-toe game works as: ``` Initial: state_hash = hash(empty_board) Round n: 1. Current player generates move locally 2. Computes new_state_hash 3. Generates ZK proof: (old_hash, new_hash, proof) 4. Opponent verifies proof 5. If valid: state_hash = new_hash 6. Continue until win/draw detected ``` Win detection can be a separate ZK proof that demonstrates three-in-a-row without revealing the full board. ## Performance Gnark proof generation for a single move: | Operation | Time | Notes | |-----------|------|-------| | Circuit compilation | ~500ms | One-time setup | | Witness generation | ~5ms | Per move | | Proof generation | ~100ms | Per move | | Verification | ~2ms | Per move | The bottleneck is proof generation, but 100ms is acceptable for turn-based games. ## Applications Beyond Games The Petri net + ZK pattern applies to: - **Voting systems**: Prove valid vote without revealing choice - **Supply chains**: Verify state transitions without exposing inventory - **Access control**: Prove permission without revealing identity - **Financial audits**: Verify transactions without exposing amounts Any system modeled as a Petri net can gain privacy through ZK circuits. ## Model Structure The ZK version adds cryptographic machinery to the base model: ``` Standard Petri Net: Places: P00-P22 (cells), Next (turn), _X*, _O* (history) Transitions: X00-X22, O00-O22 ZK Extension: Circuit: MoveCircuit (gnark R1CS) Public: state_hash, player_turn Private: position, prev_marking, new_marking Proof System: Groth16 (gnark default) ``` ## Conclusion Zero-knowledge proofs extend Petri nets from *verifiable* to *privately verifiable*. The same structural properties that make Petri nets analyzable—locality, composability, determinism—make them natural targets for ZK circuits. The combination enables a new class of applications: systems where we can prove correct behavior without revealing what that behavior was. The Petri net provides the model; the ZK circuit provides the privacy. For the base tic-tac-toe model: [Tic-Tac-Toe Model](/posts/tic-tac-toe-model) For gnark documentation: [gnark.io](https://docs.gnark.consensys.io/) --- # Texas Hold'em Model - URL: https://blog.stackdump.com/posts/texas-holdem-model - Date: 2026-01-31 - Tags: petri-net, game, state-machine, event-sourcing - Summary: The five-seat Hold'em tutorial net (18 places, 32 transitions) read against its source — phase places, turn tokens, a dealer role and an event log, plus what the net leaves to code (chips, guards, re-raises) and one bug in how seats re-enter a hand. # Texas Hold'em Model *Prerequisite: the [Tic-Tac-Toe Model](/posts/tic-tac-toe-model) introduces turn tokens and the place/transition vocabulary this post assumes.* Texas Hold'em is the largest of our tutorial nets: five seats, four streets, 18 places and 32 transitions, declared in one JSON file — `services/texas-holdem.json`, now kept in sim.pflow.xyz's source — from which a code generator produced an event-sourced Go service (`examples/texasholdem`). The browser demo and the generated example have since been retired; this post reads them as they stood on 2026-10-12. Poker needs phases, a strict turn order, actions with preconditions and a record of what happened; we put the first three into the net's structure wherever the structure can carry them, and this post says where it can't. ## State Machine: Betting Rounds The phases are a chain of places — `waiting`, `preflop`, `flop`, `turn_round`, `river`, `showdown`, `complete` — and the transitions `start_hand`, `deal_flop`, `deal_turn`, `deal_river`, `go_showdown`, `determine_winner` and `end_hand` move a single token along it: ![State Machine](/images/texas-holdem/state-machine.svg) Each deal transition consumes the current phase token *and* a `betting_done` token, so the dealer cannot advance the street until the betting has gone round. What "gone round" means here is narrower than real poker: `betting_done` is produced by seat 4's action, so each street is exactly one orbit of the table, p0 through p4, and a raise does not reopen the action to the seats that already acted. Re-raise loops are not in this net. ## Turn Control with Tokens Each seat has a turn place, `p0_turn` through `p4_turn`, and an active place, `p0_active` through `p4_active`. An action consumes the seat's turn token and produces the next seat's. From the model file, a check hands the active token back while a fold keeps it: ![Turn Control](/images/texas-holdem/turn-control.svg) ``` p0_check: inputs: [p0_turn, p0_active] outputs: [p0_active, p1_turn] p0_fold: inputs: [p0_turn, p0_active] outputs: [p1_turn] ``` A seat that has folded has no active token, so fold, check, call and raise are all dead for it and only `pN_skip` — which needs nothing but the turn token — can pass the turn along. The turn order is enforced by the structure, and it is a conservation law rather than a hope: reading the arcs, every transition preserves `waiting + p0_turn + p1_turn + p2_turn + p3_turn + p4_turn + betting_done + showdown + complete = 1` so at most one seat can hold the turn at any marking. We checked that by hand against the 32 transitions in the JSON; it has not been run through an invariant solver for this post. The phase chain carries a second one of the same shape, `waiting + preflop + flop + turn_round + river + showdown + complete = 1`. ## Role-Based Access Control The model's `access` block restricts the dealer-side transitions to a `dealer` role, and declares `admin` as inheriting from `dealer`: | Role | Transitions | |------|-------------| | `dealer` | `start_hand`, `deal_flop`, `deal_turn`, `deal_river`, `go_showdown`, `determine_winner`, `end_hand` | | `admin` | inherits `dealer` (also listed on `start_hand`) | There are no per-seat roles. Nothing in the model binds `p0_raise` to the person sitting in seat 0 — any caller can fire any seat's action while that seat holds the turn token. Enforcement is also thinner than the declaration: the code generator could emit a permission middleware from the `access` block, but the generated example, in the checkout we read for this revision, passes an empty rule list (`examples/texasholdem/service.go`, `accessRules := []*AccessControl{}`), and its router does not call the permission check. The roles are a declaration in this model, not a demonstrated guarantee. ## Guards: Betting Conditions Guards are boolean expressions over data that must hold before a transition fires. This net declares none — the generated README's transition table shows `-` in the Guard column for all 32. The kind of guard a raise would need looks like this (a sketch, not from the model): ``` p0_raise: guard: "bets[0] >= current_bet && chips[0] >= raise_amount" ``` In the demo, chip stacks, bets, blinds and the pot live in the browser's JavaScript state (`frontends/texas-holdem/main.js`), not in the net. The division the post originally claimed — the net handles *what* can happen, guards handle *when* — is the right shape, but in this model only the first half exists. ## Event Sourcing: Audit Trail Every transition in the model names an event type (`p0_raise` → `p0_raised`, `deal_flop` → `flop_dealt`), and the generated service stores those events in an event store and rebuilds the marking from them. A log looks roughly like this (a sketch: the `amount` and `cards` fields are from the model's event schema, the times are made up): ```json [ {"action": "start_hand", "time": "10:00:00"}, {"action": "p0_raise", "amount": 50, "time": "10:00:15"}, {"action": "p1_call", "time": "10:00:23"}, {"action": "p2_fold", "time": "10:00:31"}, {"action": "deal_flop", "cards": "Ah Ks 7d", "time": "10:00:35"} ] ``` Because each firing is one event, replaying the log through the net reconstructs the hand, and a firing that was not enabled cannot appear in a log the service accepted — the generated `aggregate.go` refuses any transition that cannot fire from the current marking before it writes an event. The earlier version of this post also promised undo; the generated code does not implement rollback (its GraphQL resolver still carries `TODO: Implement proper version replay`), so we have dropped the claim. ## ODE for Strategic Analysis The demo's "strategic analysis" is not an ODE over the 18-place game net. For each decision, the frontend builds a separate eight-place net (`buildHandStrengthPetriNet` in `main.js`) whose initial marking and rates are set from card heuristics — made-hand rank, a preflop-quality score, and draw odds by the rule of 2 and 4 — solves it with the pflow.xyz Tsit5 solver, and reads off a `hand_strength` value. That value is then blended with the final values of three of the small net's input places by fixed weights (0.5 preflop quality, 0.3 current hand, 0.15 `hand_strength`, 0.05 improvement), and each action's expected value comes from hand-written formulas: fold is 0, a call is strength × (pot + call) − call, halved when strength is below the pot odds, and a raise is scaled up above a 0.6 strength threshold. The ODE is one aggregation step inside a heuristic, and the heuristic — not the net topology — decides the ranking. That is the opposite of [tic-tac-toe](/posts/tic-tac-toe-model), where the ODE runs on the game net itself and the move values [reduce to integers](/posts/integer-reduction) read off its structure. The poker version of that idea came later, in [ZK Hold'em](/posts/zk-holdem), where hand-strength values are read off a net's drain counts instead of hand-tuned weights. The drain counts are themselves hand-picked in ranking order, so the ranking is an input there, not a result. ## Model Structure ``` Places (18): waiting, preflop, flop, turn_round, — Phase places river, showdown, complete p0_turn, p1_turn, p2_turn, — Turn tokens p3_turn, p4_turn p0_active, p1_active, p2_active, — In-hand markers (initial 1) p3_active, p4_active betting_done — End-of-orbit sync Transitions (32): start_hand, deal_flop, deal_turn, — Phase transitions deal_river, go_showdown, determine_winner, end_hand p0_fold, p0_check, p0_call, p0_raise, — Player 0 actions p1_fold, p1_check, p1_call, p1_raise, — Player 1 actions ... (5 players × 4 actions) p0_skip, p1_skip, ... — Pass the turn (needs only pN_turn) ``` ## Where the structure stops One defect is visible in the arcs. `end_hand` puts a fresh token into every `pN_active` without consuming the old one, so a seat that played a hand to the end starts the next hand with two active tokens. A single fold then leaves it with one, and on the next street that seat can check, call or raise again. The active places are not 1-bounded across hands; `end_hand` needs to consume what is left, or the fold needs to drain the place. We have not patched the model — this is a report from reading it. What the net does hold is poker's skeleton: the street order, the one-seat-at-a-time turn (provably, by the invariant above), the fact that a folded seat can only pass, and a log in which every entry is a firing. The arithmetic — chips, bet sizing, side pots, who wins — sits outside it, in guards the model does not yet declare and in frontend code. Whether that arithmetic can move into the net without the net ballooning is the open question here. The [ZK poker build](/posts/building-zk-poker) at stackedup.gg takes a different cut, splitting heads-up play across four cooperating nets. *This topic is covered in depth in [Chapter 10: Complex State Machines](https://book.pflow.xyz/ch10-complex-state-machines.html) of [Petri Nets as a Universal Abstraction](https://book.pflow.xyz).* ## What Changed For readers of the January version, after checking the post against `services/texas-holdem.json`, the generated example and the demo frontend on 2026-10-12 (read, not re-run): 1. **Place count.** The model has 18 places, not 17 (the old list already named 18). 2. **Turn snippet.** `p0_check` returns `p0_active`; the old snippet dropped that output. Added `p0_fold` and the turn-token invariant, checked by hand. 3. **Roles.** There is no `player0` role and `admin` does not own `end_hand`/`determine_winner`; the dealer role owns all seven phase transitions. The claim that "a player can't fire another player's transitions" was wrong, and the generated example does not wire the role check. 4. **Guards.** The model declares none; the guard expression is now labelled a sketch. 5. **Undo** dropped from the event-sourcing claims. 6. **ODE analysis.** Removed the per-player win-probability chart and the `win_p0` description, which matched no code; the section now describes the frontend's hand-strength heuristic. Removed "the ODE earns its keep here" and "strategy emerges from analysis rather than being hardcoded", both contradicted by `main.js`. 7. **Model bug** in how `pN_active` is refilled by `end_hand`, reported above. 8. Cut the "Key Concepts", "Why Petri Nets for Games?" and "Conclusion" summaries. --- *Related: [Tic-Tac-Toe Model](/posts/tic-tac-toe-model) · [ZK Hold'em](/posts/zk-holdem) · [Building ZK Poker](/posts/building-zk-poker) · [Introducing sim.pflow.xyz](/posts/petri-pilot) · [Declarative Differential Models](/posts/declarative-differential-models)* --- # Introducing sim.pflow.xyz - URL: https://blog.stackdump.com/posts/petri-pilot - Date: 2026-01-31 - Tags: petri-net, tutorial, go-pflow, simulation, sim-pflow-xyz - Summary: A tour of sim.pflow.xyz — a catalog of executable Petri-net models you can run, compare and check, from tic-tac-toe to a café to a vet clinic, in the browser or from any MCP client. # Introducing sim.pflow.xyz [sim.pflow.xyz](https://sim.pflow.xyz) is where the models on this blog run. Each one is a Petri net stored as a small JSON document — places, transitions, arcs, rates — and every model in the catalog gets the same instruments: seeded scenarios, side-by-side comparison, diagnostics, structural checks and calibration against an event log. Nothing is written per model. The instruments read the document. The question it is built around: **what if the model was the app?** [The Model Is the App](/posts/model-is-the-app) makes that argument at length. This post is the tour. ## Three models, in order The catalog has well over a hundred entries now. Three of them make a sensible path through it, each adding one idea to the last: | Model | What it adds | Open it | |-------|--------------|---------| | Tic-tac-toe | Places, transitions, turn tokens; a game the engine can solve | [console](https://sim.pflow.xyz/whatif/?id=5a08c67862a2eb55799f5229) · [app](https://sim.pflow.xyz/app/5a08c67862a2eb55799f5229) | | Café | Weighted arcs, capped stock, a shared staff pool, customers who give up | [console](https://sim.pflow.xyz/whatif/?id=3ab9e15cde5c02b463c31354) | | Vet clinic | Many contending pools, read-arc gates, disruptions | [console](https://sim.pflow.xyz/whatif/?id=ae6ab5aeb95dd994bc561479) | Every console is the same page, `/whatif/?id={model}`, pointed at a different id. The knobs it shows are the ones the model declares. ### Tic-tac-toe: the foundation Nine cells, two players, a turn token that passes back and forth, and win detectors that end the game. The stored model is 34 places and 35 transitions, including a move counter and a draw place so every game ends in an outcome you can count. Because the model declares its players and an objective, the engine can play it. Ask `sim_evaluate` for exact search and you get minimax over the declared game — it blocks, takes wins, and shows O can always avoid losing. Ask for the incidence method and you get the closed-form strategic values: centre 2.0, corner 1.5, edge 1.0, the count of win lines through each cell. Same net, two readings. [Tic-Tac-Toe Model](/posts/tic-tac-toe-model) has the full analysis, including where the plain ODE ranking falls short of search. ### Café: resources and people Three drinks, a pantry, two baristas. An espresso draws 20g of beans and a cup; a latte 15g of beans and 50ml of milk; a cappuccino 15g and 30ml. Stocks are capped and restock; customers who wait too long walk out. The café is built from three separately written nets — counter, pantry, staff — fused so that starting a drink is one firing across all three: the order leaves the queue, the stock is drawn down and a barista is taken off the floor in the same step. The barista and the beans are marked `kinetic: false`: they permit a drink to start without making it start faster. Leave that out and a bigger pantry brews quicker, which no café does. That structure is what makes "should I put a third barista on?" a question with an answer. Headcount is the initial marking on one place; change it and compare. [Coffee Shop Model](/posts/coffeeshop-model) works through a larger five-drink net with the same pattern of recipes as arc weights. ### Vet clinic: contention 38 places and 42 transitions: vets, techs, a receptionist, exam rooms, surgery and dental suites, radiology, recovery kennels, and walk-ins who leave if they wait too long. Every service seizes its staff and its room when it starts and releases them when it finishes, so how busy the vets are is a marking you can read. Here the console's disruptions earn their keep — an emergency wave, a surgery day — and so does `sim_diagnose`, which ranks every knob by its measured effect on the objective. [What-If Analysis](/posts/what-if-analysis) and [The Capacity Paradox](/posts/capacity-paradox) are what came out of pointing it at this clinic and at models like it. ## The same building blocks everywhere Every model in the catalog uses the same vocabulary: - **Places** hold tokens — states, queues, stock, staff - **Transitions** fire when enabled — arrivals, moves, services - **Arcs** say what a firing consumes and produces, with weights - **Tokens** are the current state; the marking is the whole of it - **Rates** say how fast transitions fire, so the net can be simulated - **Declarations** — an objective, parameters, capacities, a schedule — are statements about the model that the engine checks rather than comments beside it The first three are *structure* (what can happen). Tokens are *state* (what is the case now). Rates make it a simulation (what tends to happen). Declarations say what you are asking. ## What you can ask of a model The instruments are the same for every model, from the catalog or one you wrote five minutes ago: - **Run it.** `sim_scenario` and `sim_compare` run seeded stochastic simulations; the same body on the same surface gives the same answer, byte for byte, so the difference between two scenarios is the change you made, not the dice. - **Diagnose it.** `sim_diagnose` ranks every knob by measured influence. It has found capacity that does nothing and capacity that makes the objective worse. - **Check it.** `sim_verify` answers declared properties — deadlock-free, bounded, mutual exclusion, an invariant expression — as proved, refuted or unknown, and unknown is never a pass. `sim_invariants` hands back the conservation laws in the structure; `sim_crosscheck` runs the model discretely and continuously and reports where the two disagree. - **Fit it to your data.** `sim_conformance` replays an event log against the model and reports fitness and precision, with the activities that couldn't fire; `sim_calibrate` learns rates from the log and returns a new model. - **Prove a run.** `sim_receipt` returns a signed receipt over the model, the scenario and the result that anyone can replay at `/api/receipts/verify`. Models are content-addressed and immutable: a change is a new model with its own id, so "which version did you run?" has a definite answer. ## Why play first Petri nets are visual and dynamic, and reading about tokens doesn't build the intuition that moving them does. Make a move in the tic-tac-toe app and watch a token leave one place, the transition fire, and a token land in another place. Add a barista to the café and watch the walkouts fall — or not. The concepts click in a way a diagram alone doesn't manage, and the console is honest about the second case: if a knob does nothing, the comparison shows it doing nothing. ## The philosophy Traditional development starts with code and describes it afterwards. Here the model comes first and stays the source of truth: - **Models are checkable.** Deadlock-freedom, boundedness and invariants are questions with proved, refuted or unknown answers. - **Models are readable.** A non-programmer can follow a net of a café. - **Models are analysable.** Simulation and diagnosis find bottlenecks no column of numbers shows. - **Models are portable.** The JSON is plain and its semantics are public; [go-pflow](https://github.com/pflow-xyz/go-pflow) and the [pflow.xyz](https://pflow.xyz) editor read the same kind of document. The bet is that a good enough model makes everything downstream of it — the analysis, the console, the app — a reading of the document rather than a separate program that can drift from it. ## Getting started 1. Open [sim.pflow.xyz](https://sim.pflow.xyz) and pick a model from the catalog 2. Start with [tic-tac-toe](https://sim.pflow.xyz/whatif/?id=5a08c67862a2eb55799f5229), then the [café](https://sim.pflow.xyz/whatif/?id=3ab9e15cde5c02b463c31354) 3. Turn the knobs; compare two scenarios on the same seed 4. Connect an MCP client and describe a process of your own: ```bash claude mcp add --transport http sim https://sim.pflow.xyz/mcp ``` The public tools work without signing in; storing models you own asks you to sign in the first time. The [help page](https://sim.pflow.xyz/help/) and [llms.txt](https://sim.pflow.xyz/llms.txt) cover the rest. For the underlying library: [go-pflow on GitHub](https://github.com/pflow-xyz/go-pflow) For the theory behind ODE analysis: [Declarative Differential Models](/posts/declarative-differential-models) For what-if scenario planning: [Skip the Spreadsheet: What-If Analysis](/posts/what-if-analysis) *See also [Chapter 17: The Visual Editor](https://book.pflow.xyz/ch17-visual-editor.html) in [Petri Nets as a Universal Abstraction](https://book.pflow.xyz).* --- # Coffee Shop Model - URL: https://blog.stackdump.com/posts/coffeeshop-model - Date: 2026-01-31 - Tags: petri-net, resource-modeling, ode, capacity - Summary: Resource modeling with Petri nets — the five-drink core of go-pflow's coffee shop, where arc weights are recipes, each ingredient carries a conservation law, and the rates say what runs out first (milk, not cups). Revised to link the code, restore mocha's syrup and label every minute figure as a linear estimate. # Coffee Shop Model A coffee shop is the smallest resource model we have that still has a real question in it: five drinks draw on shared stocks, and we want to know which stock runs out first. The net here is the five-drink core of `NewInventoryNet` in go-pflow's [`examples/coffeeshop/inventory.go`](https://github.com/pflow-xyz/go-pflow/blob/main/examples/coffeeshop/inventory.go). That file also carries an iced latte, a sugar condiment and a refill transition per ingredient; we leave those out, the same cut [Chapter 5](https://book.pflow.xyz/ch05-resource-modeling.html) of the book makes. Recipes are arc weights, each ingredient gets a conservation law from a paired tracking place, and the rates give the depletion order. The interactive [café on sim.pflow.xyz](https://sim.pflow.xyz/whatif/?id=3ab9e15cde5c02b463c31354) is a different net — [see below](#the-sim-demo-is-a-different-net). ## Five drinks, shared stock | Drink | Beans | Water | Milk | Syrup | Cups | |-------|-------|-------|------|-------|------| | Espresso | 18g | 30ml | — | — | 1 | | Americano | 18g | 200ml | — | — | 1 | | Latte | 18g | 30ml | 180ml | — | 1 | | Cappuccino | 18g | 30ml | 120ml | — | 1 | | Mocha | 18g | 30ml | 150ml | 2 pumps | 1 | Starting inventory: 1,000g beans, 10,000ml water, 5,000ml milk, 500 pumps of syrup, 100 cups. Orders arrive continuously, and the question is which stock goes first and how that moves when demand changes. ## Places The model has two categories of places. **Ingredient places** hold current stock: | Place | Initial | Role | |-------|---------|------| | `coffee_beans` | 1000g | Raw ingredient | | `water` | 10000ml | Raw ingredient | | `milk` | 5000ml | Raw ingredient | | `cups` | 100 | Consumable | | `syrup` | 500 pumps | Raw ingredient (mocha only) | **Consumption tracking places** count what's been used (all start at 0): `beans_used`, `water_used`, `milk_used`, `cups_used`, `syrup_used`. This paired structure creates conservation laws — every token consumed from an ingredient place appears in the corresponding tracking place. ## Weighted Arcs as Recipes Each drink requires specific quantities. The `make_espresso` transition has: - **Input arcs** from `coffee_beans` (weight 18), `water` (weight 30), `cups` (weight 1) - **Output arcs** to `beans_used` (weight 18), `water_used` (weight 30), `cups_used` (weight 1) The arc weights are the recipe, and the conservation law follows from them: `coffee_beans + beans_used = 1000` for all time. In go-pflow's example they are not the only copy of the recipe, though. `inventory.go` also keeps a `Recipes` map that `CanMakeDrink` reads, and the two drifted: `Recipes` listed an iced latte that the net had no transition for, so the shop offered a drink the net could not make, until `make_iced_latte` was added in August 2026 (the comment above it in `inventory.go` says why). The net can only be the specification if it is the one place the recipe lives. ## Transition Rates Each transition has a rate constant. `InventoryRates` gives them as drinks per minute at peak: | Transition | Rate | Drinks/hr | |------------|------|-----------| | `make_espresso` | 0.5 | 30 | | `make_americano` | 0.3 | 18 | | `make_latte` | 0.8 | 48 (most popular) | | `make_cappuccino` | 0.4 | 24 | | `make_mocha` | 0.2 | 12 | The continuous dynamics follow mass-action kinetics: ``` flux = rate × ∏[input places] ``` go-pflow's solver ([`solver/ode.go`](https://github.com/pflow-xyz/go-pflow/blob/main/solver/ode.go)) uses the first power of each input's marking; the arc weight scales how much a firing consumes, not the exponent. As a stock depletes, every transition that reads it slows with it, with no scheduling logic. The product has a cost the drinks-per-minute column hides: a latte's flux carries a factor of milk (5,000) that an espresso's doesn't, so inside the ODE the rate constants are not drinks per minute at all, and the example multiplies every one of them by 0.0001 before solving ([`simulation.go`](https://github.com/pflow-xyz/go-pflow/blob/main/examples/coffeeshop/simulation.go)). Scaling every constant by the same factor only changes the clock. The drinks-per-minute reading belongs to the linear estimate in the next section. ## Which stock goes first The order follows from how big a bite one drink takes out of each stock. A milk drink takes 120–180ml of 5,000ml milk (2.4–3.6%), 18g of 1,000g beans (1.8%), one of 100 cups (1%) and 30ml of 10,000ml water (0.3%). Reading the rate constants as drinks per minute — what `PredictRunout` in `simulation.go` does — gives a linear runout per stock, which can be checked by hand: | Stock | Draw per minute | Linear runout | |-------|-----------------|---------------| | milk | 180(0.8) + 120(0.4) + 150(0.2) = 222 ml | ≈ 22 min | | coffee_beans | 18 × 2.2 = 39.6 g | ≈ 25 min | | cups | 2.2 | ≈ 45 min | | water | 30(0.5 + 0.8 + 0.4 + 0.2) + 200(0.3) = 117 ml | ≈ 85 min | | syrup | 2 × 0.2 = 0.4 pumps | ≈ 1,250 min | Milk goes first, and once it runs low all three milk drinks stall, because milk is a factor in each of their fluxes. Beans go next, still drawn by espresso and americano. Cups are the smallest number in the shop and never come close: a latte takes 180 tokens of milk and one cup, and the weights decide the order, not the counts. What we have not committed is an ODE run that shows this order with its own clock. Under product kinetics no stock reaches exactly zero — each decays, and the transitions that read it slow down with it — so "first" means "falls fastest", and the minutes above are the linear estimate's, not the ODE's. The conservation laws do hold along any ODE trajectory, because the solver adds 18v to `beans_used` for every 18v it takes from `coffee_beans`; checking `coffee_beans + beans_used = 1000` in the solver output is a quick test of the wiring, though no test in the example asserts it yet. ## Bottleneck Analysis By adjusting rates, we can model different days. The minutes are linear estimates from the table above: | Scenario | Change | First Bottleneck | |----------|--------|-----------------| | Slow day | Halve all rates | Milk still first, later (~45 min; beans ~50) — 5,000ml still doesn't last a shift | | Normal | Baseline rates | Milk (~22 min) | | Rush hour | Double all rates | Milk in half the time (~11 min) | | Latte promotion | Triple latte rate | Milk still first and sooner (~10 min, 510ml/min); beans move up from ~25 to ~15 min | Each scenario is the same net with different rate constants. The structure — places, arcs, weights — stays the same. Only the dynamics change. ## Model Structure The full Petri net has ingredient places, tracking places, and one transition per drink type: ``` Places: coffee_beans, water, milk, — Ingredients syrup, cups beans_used, water_used, — Consumption tracking milk_used, syrup_used, cups_used Transitions: make_espresso, make_americano, — Drink preparation make_latte, make_cappuccino, make_mocha ``` Every arc runs from an ingredient place into a drink transition or from a drink transition out to a tracking place, with the same weight on both sides, so each ingredient and its tracking place sum to a constant — one conservation law per ingredient. Add the refill transitions from `inventory.go` and that pair no longer balances; the law widens to three places (stock, used and supply), which Chapter 5 works through. Changing the rates moves the timings: milk leads at baseline, and a latte promotion makes it lead sooner while pulling beans up behind it. None of the four scenarios moves the order of the first two, and we haven't searched for a rate mix that would. What the rates can't change is which recipe touches which stock; that is fixed by the arcs. ## The sim demo is a different net The [café on sim.pflow.xyz](https://sim.pflow.xyz/whatif/?id=3ab9e15cde5c02b463c31354) has three drinks fused from three separately written nets — counter, pantry and staff (16 places, 18 transitions) — with two baristas, capped stocks with restock transitions, customers who walk out, and no water and no tracking places. Its recipes are its own: espresso takes 20g of beans, a latte 15g and 50ml of milk, a cappuccino 15g and 30ml, against 2000g of beans, 2000ml of milk and 200 cups. The two diagrams below are from an older three-drink net again — an order → make → serve workflow (8 places, 12 transitions, `services/coffeeshop.json`) with the same espresso and latte recipes against 500ml of milk — not from the one above. None of these numbers will match the tables here. The two models haven't been reconciled; [Small Models > LLMs](/posts/small-models-not-llms) notes the same gap. ![Model Structure](/images/coffeeshop/model-structure.svg) ![Resource Consumption](/images/coffeeshop/resource-consumption.svg) ## What Changed 1. **2026-08-22 — milk, not cups.** This post originally said cups deplete first. They are the smallest stock, but the arc weights — 180 tokens of milk per latte against one cup — decide the order. 2. **2026-10-11 — evidence and scope.** The net is now named as the five-drink core of go-pflow's `inventory.go`, with links to it, `simulation.go` and the solver. Mocha's two pumps of syrup, missing from the recipe table, are back. The "slow day" row said nothing depletes during a shift; at half rates the linear estimate still runs milk out in about 45 minutes, which is what the row now says. The ODE figure "half the milk gone in roughly 21 minutes" came from a run that wasn't kept, and the example's ODE uses rescaled constants on a different clock, so it is gone; every minute figure is now labelled as the linear estimate. The rate constants are no longer called drinks per minute inside the ODE. "No separate configuration needed" is qualified: go-pflow's example kept a second copy of the recipes, and it drifted. The demo link and both diagrams are now labelled as the demo's three-drink net, and a hand-drawn depletion chart that showed beans going first (at 20g per espresso, from the demo net) was removed. 3. **2026-10-23 — the demo moved.** The interactive café is now sim.pflow.xyz's, a staffed 16-place net that is not the one the diagrams were drawn from; the section above says which is which. *Related: [Declarative Differential Models](/posts/declarative-differential-models) · [Skip the Spreadsheet: What-If Analysis](/posts/what-if-analysis) · [Small Models > LLMs](/posts/small-models-not-llms) · [Enzyme Kinetics](/posts/enzyme-kinetics-model) · [Chapter 5: Resource Modeling](https://book.pflow.xyz/ch05-resource-modeling.html) of [Petri Nets as a Universal Abstraction](https://book.pflow.xyz)* --- # Categorical Net Types - URL: https://blog.stackdump.com/posts/categorical-net-types - Date: 2026-02-07 - Tags: petri-net, category-theory, composition, pflow, dsl - Summary: Five Petri net types classify token behavior — workflow cursors, countable resources, game turns, continuous rates, and classification signals — with typed links that constrain how nets compose. # Categorical Net Types The [four-term DSL](/posts/token-language) gives us primitives: `cell`, `func`, `arrow`, `guard`. With these we can build any Petri net. But not all Petri nets are alike. An order-processing workflow and an epidemiological simulation both use places, transitions, and arcs — yet their tokens mean fundamentally different things. **Net types** classify Petri nets by how their tokens behave, what invariants hold, and how they compose. This post introduces the five pflow net types, the typed links that connect them, and why the resulting structure forms a category with useful algebraic properties. ## The Five Net Types The [pflow schema](https://pflow.xyz/schema) defines five concrete net types, each a specialization of the base `PetriNet`: ![Net Type Taxonomy](/images/categorical-net-types/net-type-taxonomy.svg) | Type | Token Semantics | Invariant | |------|----------------|-----------| | **WorkflowNet** | Single control-flow token (cursor) | Mutual exclusion — cursor in exactly one state | | **ResourceNet** | Countable inventory tokens | Conservation — total tokens constant | | **GameNet** | Turn-based tokens with player roles | Both mutual exclusion and conservation | | **ComputationNet** | Continuous quantities via ODE rates | Rate conservation at steady state | | **ClassificationNet** | Signal tokens with threshold activation | Threshold firing conditions | **WorkflowNet.** A single token traces a path through states. An order moves through `pending → confirmed → shipped → delivered`. The token is a cursor — it marks where we are in a sequential process. WorkflowNets are finite state machines with the structural advantage that parallelism (multiple concurrent tokens) is expressible. **ResourceNet.** Tokens represent countable, fungible things: inventory items, currency, capacity slots. A warehouse has 100 tokens in `available`, consumed by `reserve` and produced into `reserved`. The total is conserved — the P-invariant guarantees that nothing is created or destroyed. **GameNet.** Tokens encode both game state and turn structure. In [tic-tac-toe](/posts/tic-tac-toe-model), empty cells hold tokens consumed when a player moves. A turn-control token alternates between players. GameNets combine WorkflowNet sequencing (turns) with ResourceNet accounting (board positions). The net topology [absorbs the search tree](/posts/integer-reduction#absorbing-the-search-tree) — ODE analysis recovers strategic values without game-specific heuristics. **ComputationNet.** Tokens are continuous, not discrete. Transitions fire at rates — mass-action kinetics. An SIR epidemic model is a ComputationNet: the infection rate depends on the product of susceptible and infected populations. [DDM analysis](/posts/declarative-differential-models) operates natively on ComputationNets. **ClassificationNet.** Tokens accumulate as evidence. Transitions fire when enough tokens accrue — a threshold. A spam filter accumulates signal tokens from heuristics (suspicious sender, keyword matches, link density) and fires `classify_spam` when the threshold is met. ## Why Types Matter At the structural level, all five types are Petri nets. The type annotations matter because they constrain composition. When we connect two nets, the types determine what connections are valid: - An **EventLink** connects transitions to transitions across schemas — when one fires, the other fires too - A **DataLink** connects places to places — read-only observation across schema boundaries - A **TokenLink** transfers tokens between schemas — resource coupling with cross-boundary conservation - A **GuardLink** gates a transition in one schema on a place in another — constraint coupling Types make the composition rules explicit. We can't accidentally link a workflow cursor to an inventory counter. The type system prevents semantic nonsense at the structural level — the difference between "a Petri net" and "a Petri net that models inventory with conservation guarantees." ## CompositeNet: The Category Individual typed nets are useful. But real systems are compositions of multiple nets — an order system combines workflow logic with inventory management, payment processing, and notification delivery. The `CompositeNet` type captures this composition: ``` CompositeNet = schemas[] + links[] ``` Each entry in `schemas` is a sealed, typed sub-net (a `WorkflowNet`, `ResourceNet`, etc.). Each entry in `links` is a typed connection between elements of different schemas. This has a natural categorical interpretation: - **Objects** are typed sub-nets, each identified by its seal (a content-addressed hash of the model plus verified invariant claims) - **Morphisms** are typed links between them - **Composition** is associative: linking A→B and B→C gives A→C - **Identity** is the trivial self-link (a schema with no external connections) The schemas carry their own verified properties via seals. The links describe how those properties interact. The `CompositeNet` is the coproduct — the "sum" of its parts with explicit boundary connections. ## Typed Links as Morphisms Not all connections between schemas are the same. The pflow schema defines four link types, each with distinct coupling semantics: ![Typed Links](/images/categorical-net-types/link-types.svg) ### EventLink: Behavioral Coupling ```json { "@type": "EventLink", "from": { "ref": "orders", "transition": "confirm" }, "to": { "ref": "inventory", "transition": "reserve" } } ``` When the source transition fires, the target transition fires too — a cascade. EventLinks connect transitions to transitions. They express "when this happens, that happens too." This is the strongest form of coupling. The target transition must be enabled (its input places must have sufficient tokens), or the source firing is blocked. Both schemas' states change atomically. ### DataLink: Observational Coupling ```json { "@type": "DataLink", "from": { "ref": "inventory", "place": "available" }, "to": { "ref": "dashboard", "place": "stock_display" } } ``` The target place mirrors the token count of the source place — read-only observation. DataLinks connect places to places. They express "this value reflects that value." This is weak coupling. The observer sees the state but cannot modify it. No tokens transfer. The source schema is unaffected by the link's existence. ### TokenLink: Resource Coupling ```json { "@type": "TokenLink", "from": { "ref": "warehouse_a", "place": "stock" }, "to": { "ref": "warehouse_b", "place": "stock" } } ``` Actual token transfer between schemas. When tokens leave the source place, they appear in the target place. The total is conserved across the boundary — a cross-schema P-invariant. TokenLinks are the composition mechanism for ResourceNets. They model supply chains, fund transfers, and resource redistribution. ### GuardLink: Constraint Coupling ```json { "@type": "GuardLink", "from": { "ref": "compliance", "place": "approved" }, "to": { "ref": "orders", "transition": "ship" } } ``` A place in one schema gates a transition in another — a cross-schema predicate. The target transition can only fire if the source place satisfies some condition (e.g., has at least one token). GuardLinks express inter-schema constraints without data transfer. They model approval gates, regulatory checks, and dependency conditions. ## Example: Order System A concrete `CompositeNet` composing an order workflow with inventory management: ![Order System CompositeNet](/images/categorical-net-types/composite-net.svg) The system has two schemas: **Orders** (WorkflowNet): A control-flow token moves through `pending → confirmed → shipped`. The `confirm` transition advances the order from pending to confirmed. The `ship` transition advances it from confirmed to shipped. **Inventory** (ResourceNet): Multiple tokens represent available stock. The `reserve` transition moves tokens from `available` to `reserved`. The `ship_out` transition consumes reserved tokens. Two EventLinks wire them together: 1. `orders.confirm → inventory.reserve` — Confirming an order automatically reserves inventory 2. `orders.ship → inventory.ship_out` — Shipping an order automatically removes reserved stock The link types constrain valid composition. An EventLink between `confirm` and `reserve` makes sense: both are transitions, and the cascade semantics (fire one, fire the other) match the business logic. We couldn't accidentally link a place to a transition with an EventLink — the type system prevents it. Each schema carries its own seal. The Orders WorkflowNet's seal verifies mutual exclusion (the order is in exactly one state). The Inventory ResourceNet's seal verifies token conservation (total stock = available + reserved). These properties hold independently of the composition — adding EventLinks doesn't invalidate either seal. ## Algebraic Properties The `CompositeNet` grammar has algebraic properties that make reasoning tractable: - **Associative.** Composing (A + B) + C gives the same result as A + (B + C) - **Commutative.** The order of schema declarations doesn't matter - **Monotonic.** Adding a new schema or link never invalidates existing ones These properties make the declaration space a **free commutative monoid**. In practical terms: development is additive. Teams can work on schemas independently, and composition is mechanical — list the schemas, declare the links, and the algebraic properties guarantee that the result is well-formed. **Seals enable assume-guarantee reasoning.** Each sub-net's seal certifies its properties: conservation, boundedness, liveness, mutual exclusion. When composing sealed nets, we only need to verify that the *links* are compatible — we don't need to re-analyze the internal structure of each sub-net. The seal is the interface contract. Each component is verified in isolation, and composition only needs to check the boundaries. Adding a new schema or link can only extend behavior, never break what's already working. ## Where this leaves us Five net types, each with its own invariant, because tokens mean five different things — a workflow cursor, a countable resource, a turn, a continuous rate, a classification signal. The types exist to stop composition from producing semantic nonsense at a structural boundary, and the four link kinds (Event, Data, Token, Guard) grade how tightly two schemas couple. Putting those together is what makes CompositeNet a category in the plain sense: schemas are the objects, typed links are the morphisms, and composition is associative and monotonic. Each type is also a [lens](/posts/integer-reduction#the-net-as-a-lens) onto the same Petri net substrate. The practical payoff is assume-guarantee reasoning — a sealed sub-net carries its verified invariants with it, so composing never means re-opening it. For the deeper structure underneath: [Symmetric Monoidal Categories](/posts/symmetric-monoidal-categories) — why composition, analysis, and proofs all work the way they do. *This topic is covered in depth in [Chapter 4: The Token Language](https://book.pflow.xyz/ch04-token-language.html) of [Petri Nets as a Universal Abstraction](https://book.pflow.xyz).* --- # Zero-Knowledge Proofs for Petri Nets - URL: https://blog.stackdump.com/posts/zk-petri-nets - Date: 2026-02-14 - Tags: petri-net, zero-knowledge, gnark, cryptography, groth16 - Summary: How gnark circuits prove that a Petri net transition is valid without revealing the state—MiMC hashing, topology-based constraints, and Groth16 proofs. # Zero-Knowledge Proofs for Petri Nets The [ZK Tic-Tac-Toe post](/posts/zk-tic-tac-toe-model) showed how to add privacy to a game. This post goes deeper into the proof system itself: how any Petri net—not just tic-tac-toe—can be proven valid in zero knowledge. The key insight: the circuit doesn't know anything about the application. It proves valid Petri net transitions. The game logic, workflow rules, or token transfer semantics live entirely in the net topology. For the book-length treatment: [Chapter 12: Zero-Knowledge Proofs](https://book.pflow.xyz/ch12-zero-knowledge-proofs.html) ## The Statement Being Proven Every ZK proof in this system proves one statement: > "I know a valid marking that, when transition T fires, produces the claimed next state." More precisely: 1. The **pre-state root** (a hash) matches a specific private marking 2. The **post-state root** matches a specific private marking 3. The chosen **transition was enabled**—all input places had sufficient tokens 4. The **marking change is correct**—post = pre + delta, where delta comes from the net topology The verifier sees only the two state roots and the transition ID. The actual token counts remain hidden. ## Public vs Private ![Circuit Overview](/images/zk-petri-nets/circuit-overview.svg) | | What | Who Sees It | |-|------|-------------| | **Public** | Pre-state root (hash) | Everyone | | **Public** | Post-state root (hash) | Everyone | | **Public** | Transition ID | Everyone | | **Private** | Pre-marking (all token counts) | Prover only | | **Private** | Post-marking (all token counts) | Prover only | The state roots are commitments. They bind the prover to a specific marking without revealing it. ## The Circuit in Five Steps The gnark circuit for a Petri net transition has five steps. Each step adds constraints that the prover must satisfy. ### Step 1: Hash the Pre-Marking Compute the MiMC hash of every place's token count and assert it equals the public pre-state root: ```go preRoot := petriMimcHash(api, c.PreMarking[:]) api.AssertIsEqual(preRoot, c.PreStateRoot) ``` This binds the private marking to the public commitment. The prover can't lie about what state they started from—the hash locks them in. ### Step 2: Hash the Post-Marking Same check for the post-state: ```go postRoot := petriMimcHash(api, c.PostMarking[:]) api.AssertIsEqual(postRoot, c.PostStateRoot) ``` Now both states are committed. ### Step 3: Compute the Delta From the transition ID, look up the Petri net topology to build a delta vector. For each transition in the net, conditionally apply its effect: ```go for t := 0; t < NumTransitions; t++ { isThis := api.IsZero(api.Sub(c.Transition, t)) for _, p := range Topology[t].Inputs { deltas[p] = api.Sub(deltas[p], isThis) } for _, p := range Topology[t].Outputs { deltas[p] = api.Add(deltas[p], isThis) } } ``` The `isThis` variable is 1 for the selected transition and 0 for all others. This multiplexes over all transitions without branching—arithmetic circuits can't branch, so we compute all possibilities and select. ### Step 4: Assert Marking Change For every place, check that the post-marking equals the pre-marking plus the delta: ```go for p := 0; p < NumPlaces; p++ { expected := api.Add(c.PreMarking[p], deltas[p]) api.AssertIsEqual(c.PostMarking[p], expected) } ``` This ensures the marking changed exactly as the topology dictates. No extra tokens appeared. No tokens vanished. The transition fired correctly. ### Step 5: Assert Enabledness For every input place of the selected transition, the pre-marking must have at least one token: ```go diff := api.Sub(c.PreMarking[p], isInput) api.ToBinary(diff, 8) ``` The trick: `ToBinary` decomposes a value into bits. If the value were negative (insufficient tokens), it would wrap to a huge field element and the bit decomposition would fail—the proof can't be generated. This is a standard gnark pattern for range checks: decompose into bits to prove non-negativity. ## Why MiMC? The hash function matters. SHA-256 needs thousands of boolean operations inside a circuit. **MiMC** (Minimal Multiplicative Complexity) is designed for arithmetic circuits—it's defined over the same finite field the circuit uses. ```go func petriMimcHash(api frontend.API, values []frontend.Variable) frontend.Variable { h, _ := mimc.NewMiMC(api) for _, v := range values { h.Write(v) } return h.Sum() } ``` MiMC costs roughly 300 constraints per hash, compared to ~25,000 for SHA-256. For a circuit that hashes the marking twice (pre and post), this difference is significant. The state root is: ``` stateRoot = MiMC(marking[0], marking[1], ..., marking[N]) ``` To find the actual token counts from a state root, an attacker would need to invert MiMC—computationally infeasible. ## Two Circuits for Tic-Tac-Toe The ZK Tic-Tac-Toe implementation uses two circuits, both encoding the full 33-place Petri net: **PetriTransitionCircuit** proves a move is legal. It's the general circuit described above—works for any transition in the net. Used for every move. **PetriWinCircuit** proves a player has won. The win condition is already in the Petri net topology (win-detection transitions fire when three cells align). The circuit checks that the `win_x` or `win_o` place has a token: ```go func (c *PetriWinCircuit) Define(api frontend.API) error { root := petriMimcHash(api, c.Marking[:]) api.AssertIsEqual(root, c.StateRoot) winTokens := frontend.Variable(0) for p := 0; p < NumPlaces; p++ { isWinnerPlace := api.IsZero(api.Sub(c.Winner, p)) winTokens = api.Add(winTokens, api.Mul(c.Marking[p], isWinnerPlace)) } api.ToBinary(api.Sub(winTokens, 1), 8) // >= 1 token return nil } ``` Because the game logic lives in the net topology, the circuit doesn't know anything about tic-tac-toe. Change the topology constants and you get ZK proofs for a different game. ## Beyond Games: Token Transfers The [arcnet](https://github.com/pflow-xyz/arcnet) project uses the same pattern for blockchain token transfers. An ERC-20 transfer is a Petri net transition: - `balances[from]` is an input place (tokens consumed) - `balances[to]` is an output place (tokens produced) - The guard `balances[from] >= amount` is the enabledness check The circuit proves the transfer is valid using a Merkle tree for account balances: ```go func (c *TransferCircuit) Define(api frontend.API) error { // Guard: balance >= amount diff := api.Sub(c.BalanceFrom, c.Amount) api.ToBinary(diff, 64) // Verify Merkle inclusion leaf := mimcHash(api, c.From, c.BalanceFrom) current := leaf for i := 0; i < 20; i++ { api.AssertIsBoolean(c.PathIndices[i]) left := api.Select(c.PathIndices[i], c.PathElements[i], current) right := api.Select(c.PathIndices[i], current, c.PathElements[i]) current = mimcHash(api, left, right) } api.AssertIsEqual(current, c.PreStateRoot) return nil } ``` The 20-level Merkle proof means the full state doesn't need to live on-chain. Only state roots are published. The bridge contract verifies the proof and updates the root. | Application | Places | Transition | Enabledness | |-------------|--------|------------|-------------| | **Tic-Tac-Toe** | 33 board+game states | Player move | Cell empty, correct turn | | **ERC-20 Transfer** | Account balances | Transfer | Sufficient balance | | **Workflow** | Process stages | State change | Required approvals | | **Voting** | Ballot states | Cast vote | Eligible, hasn't voted | Same circuit structure. Different topology. ## The Proof Pipeline A complete proof cycle: 1. **Player makes a move** in the browser (fires a Petri net transition) 2. **Frontend computes** the new marking and MiMC state root client-side 3. **Prover service** receives the witness (pre/post markings + transition ID) and generates a Groth16 proof 4. **Proof is returned** as a compact byte string (~128 bytes) with public inputs 5. **Anyone can verify** the proof in constant time using only the public inputs 6. **On-chain verification** is possible via an auto-generated Solidity verifier contract ### Why Groth16? Groth16 is the proving system used by gnark. It requires a one-time trusted setup per circuit but produces: - **Smallest proofs**: ~128 bytes (2 G1 points + 1 G2 point) - **Fastest verification**: ~2ms, constant regardless of circuit size - **Solidity export**: gnark generates a ready-to-deploy verifier contract The trusted setup is performed once when the circuit is compiled. The resulting proving and verification keys are reused for every proof. | Property | Groth16 | PLONK | STARKs | |----------|---------|-------|--------| | **Proof size** | ~128 B | ~400 B | ~50 KB | | **Verification** | ~2ms | ~5ms | ~50ms | | **Trusted setup** | Per-circuit | Universal | None | | **Quantum-safe** | No | No | Yes | For our use case—small circuits with many proofs—Groth16's compact proofs and fast verification dominate. ## Constraint Counts The circuit size determines proving time. For the tic-tac-toe Petri net (33 places, 35 transitions): | Component | Constraints | Purpose | |-----------|-------------|---------| | MiMC hash (pre) | ~300 | State root verification | | MiMC hash (post) | ~300 | State root verification | | Transition delta | ~2,300 | Topology multiplexing | | Marking assertion | ~33 | post = pre + delta | | Enabledness | ~264 | Bit decomposition per place | | **Total** | **~3,200** | Full transition proof | Proof generation takes ~100ms on commodity hardware. Verification takes ~2ms. The circuit compiles once; proofs are generated per-move. ## The Topology Is the Circuit The most important property of this approach: **the Petri net topology is baked into the circuit as constants**. The `Topology` array maps each transition to its input and output places: ```go var Topology = [NumTransitions]struct { Inputs []int Outputs []int }{ {Inputs: []int{0, 1}, Outputs: []int{2}}, // bind {Inputs: []int{2}, Outputs: []int{0, 1}}, // unbind {Inputs: []int{2}, Outputs: []int{3, 1}}, // catalyze // ... } ``` This means: 1. **No application-specific logic** in the circuit. The same `Define()` method works for any net. 2. **Topology changes require recompilation**. Adding a place or transition means a new trusted setup. 3. **The circuit is auditable**. Anyone can inspect the topology to verify it matches the claimed rules. The Petri net is the specification. The circuit is the verifier. The proof is the attestation that the specification was followed. ## What ZK Doesn't Hide A common misconception: ZK doesn't make the transition ID private. The verifier sees *which* transition fired. What's hidden is the *state*—the full marking of every place. In tic-tac-toe, the opponent knows a move was made (transition X_center, say) but doesn't see the full board state. In a token transfer, the network knows a transfer happened but doesn't see individual balances. For applications where even the transition must be hidden, an additional layer of indirection is needed—proving that *some* valid transition fired without revealing which one. This is possible but increases circuit complexity significantly. ## Try It The ZK Tic-Tac-Toe browser demo, which generated proofs in real time — each move firing a Petri net transition, generating a gnark proof and displaying its structure, with state roots, the chain of proofs and Solidity calldata to inspect — has been retired. The [ZK Tic-Tac-Toe Model](/posts/zk-tic-tac-toe-model) walks through the same circuit, the plain [tic-tac-toe net](https://sim.pflow.xyz/whatif/?id=5a08c67862a2eb55799f5229) runs on sim.pflow.xyz (without proofs), and [Chapter 12](https://book.pflow.xyz/ch12-zero-knowledge-proofs.html) of the book covers the proof system in depth. ## Conclusion A Petri net is already a formal specification of valid state transitions. A ZK circuit turns that specification into a cryptographic proof system. The prover demonstrates they followed the rules; the verifier confirms it without seeing the state. The circuit doesn't encode tic-tac-toe, or token transfers, or workflow rules. It encodes *Petri net semantics*: hash the marking, compute the delta from topology, assert the change, check enabledness. The application logic is in the net. The privacy is in the proof. One circuit structure serves any Petri net, and what it proves is transition validity, privately. For gnark documentation: [docs.gnark.consensys.io](https://docs.gnark.consensys.io/) For the base game model: [Tic-Tac-Toe Model](/posts/tic-tac-toe-model) For the categorical structure behind these proofs: [Symmetric Monoidal Categories](/posts/symmetric-monoidal-categories) For how ZK proofs converge with ODE and tropical analysis on the same structural boundary: [Earned Compression](/posts/earned-compression) For the arcnet bridge: [github.com/pflow-xyz/arcnet](https://github.com/pflow-xyz/arcnet) *This topic is covered in depth in [Chapter 12: Zero-Knowledge Proofs](https://book.pflow.xyz/ch12-zero-knowledge-proofs.html) of [Petri Nets as a Universal Abstraction](https://book.pflow.xyz).* --- # Exponential Weights in Petri Nets: What Worked, What Didn't, and What's Next - URL: https://blog.stackdump.com/posts/exponential-scoring - Date: 2026-02-14 - Tags: petri-net, weighted-arcs, poker, encoding, technique - Summary: Power-of-2 weights encode lexicographic order over distinct ranks as a single integer. We tried it for poker kicker scoring inside the net and reverted it; the per-card variant we shipped also broke the ordering. Where the encoding belongs, and where it doesn't. # Exponential Weights in Petri Nets: What Worked, What Didn't, and What's Next In February 2026 we added power-of-2 kicker scoring to the poker hand Petri net model (commit `c892720`, then a per-card weight in `1f18db7`), and on February 15 we reverted it. The rank encoding is sound and useful. Embedding it in the poker net was the wrong application — and, as we found only on re-reading it for this revision, the per-card version we actually shipped didn't preserve the ordering it was meant to preserve. ## The Encoding Assign exponential weights so that any single higher-priority item outweighs all lower-priority items combined. It's an old trick — the same principle as bitmasks, Unix file permissions (read=4, write=2, execute=1), and binary feature flags. For poker, we wanted a total ordering on all 52 cards where rank dominates and suit breaks ties. The formula in `1f18db7`: **weight = rank_power x 4 + suit_value** Each rank gets a power of 2: | Rank | Rank Power | |------|-----------| | A | 4096 (2^12) | | K | 2048 (2^11) | | Q | 1024 (2^10) | | J | 512 (2^9) | | T | 256 (2^8) | | 9 | 128 (2^7) | | ... | ... | | 2 | 1 (2^0) | Suits get tiebreaker values (spade=3, heart=2, diamond=1, club=0). Multiplying rank power by 4 leaves room for the suit value without overlap, so every card maps to a unique integer: A-spade = 16387, A-heart = 16386, down to 2-club = 4. The property we wanted is *binary dominance*, and it holds for the rank powers alone: 2^n > 2^(n−1) + … + 2^0, so a King (2048) outweighs Queen through Two combined (2047). Sum the rank powers of any set of *distinct* ranks and the totals compare the way poker compares high cards, lexicographically — no sorting, just a number. Distinct matters: two Queens sum to 2048, same as one King, so this only works once pairs and trips have been classified and you are comparing what's left. ![Binary Dominance](/images/exponential-scoring/binary-dominance.svg) Linear weights fail on exactly this. With A=13, K=12, Q=11 and so on down to 2=1, the hand {A, K, 5, 4, 3} sums to 34 and {A, Q, J, T, 9} to 51. Linear scoring says the second hand wins; poker says the King beats the Queen. Rank powers give 4096+2048+8+4+2 = 6158 against 4096+1024+512+256+128 = 6016, the right answer by construction. The per-card weight we shipped does not keep that guarantee. Each card's suit adds up to 3, so five cards can add up to 15 on top of the rank part, while two hands that first differ at a low rank can be only 4 apart after the ×4. Take 8♣ 7♣ 6♣ 5♣ 2♦ against 8♠ 7♠ 6♠ 4♠ 3♥ — both high card, neither a flush nor a straight. Poker gives it to the first hand (5 beats 4). The rank powers agree: 121 against 118. The per-card weights disagree: 256+128+64+32+5 = 485 against 259+131+67+19+10 = 486. The suit values carry into the rank bits and the sum stops being lexicographic. That is our arithmetic on the formula above, not a re-run of the reverted code; the one kicker matchup visible in the revert, A-K-Q-J-9 against A-K-Q-J-8, differs by 256 at the 9-versus-8 card, far more than suits can carry, so it would not have exposed this. ## The Score Is the State When each item gets weight 2^n, the sum of any subset is unique — that's just binary representation. Look at what it *means*. Take a hand with {K, T, 5, 2}, using rank powers only: ``` K = 2¹¹ = 2048 T = 2⁸ = 256 5 = 2³ = 8 2 = 2⁰ = 1 ───────────── Sum = 2313 ``` Write 2313 in binary: `0100100001001`. Each bit position maps to a rank, and there are our places again: ![Binary Place Vector](/images/exponential-scoring/binary-place-vector.svg) The bits are the places and the 1s are the tokens: the binary representation of the score is a marking, of a net with one place per rank. A Petri net marking is a vector of token counts across places; when every place holds 0 or 1 tokens that vector is a bit string, and a bit string is a binary number — so the accumulated score, the bit string and the marking are three notations for the same object rather than a decoding trick on top of one. Two qualifications. This is the 13-place rank view, not the 52-place card net we actually built, and with the per-card ×4 + suit weights the bits stop lining up, for the carry reason above. And it works for *identity recovery* — which items are present — only while each place holds at most one token. If a place could hold 2 or more, one bit can't represent it and the encoding needs more bits per place. For set membership, "which ranks are in this hand?", one bit per place is exactly right: 2313 doesn't just rank the hand, it *is* the hand, in a format a human can read bit by bit and a machine can compare in one integer operation. ## What We Built The model builder (`buildPokerHandModel` in the demo server's `pkg/serve/serve.go`) got a `kicker_score` place, initially empty, and 52 detection transitions (`hc_A-heart`, `hc_K-spade`, …), one per card. Each `hc_*` transition consumed its card's token — and, per the revert PR, a token from a `card_counter` guard place — and deposited the card's weight into `kicker_score` along a weighted output arc. A card in the hand enabled its transition, which fired once and was then disabled because its input token was gone; cards not in the hand never fired. Mechanically, it worked: transitions fired, tokens accumulated, and the commits shipped with tests and a "High Card Kicker Battle" preset (the A-K-Q-J-9 against A-K-Q-J-8 matchup above), where comparing the two `kicker_score` values does pick the right winner. We have not re-run those tests for this revision, and the matchup we can see is not one where the suit term overturns the rank order. ## Why We Removed It From the revert PR: > The fundamental problem is that "Petri nets track state as token counts in places, not as externally-interpreted numeric values". Accumulating weighted tokens into a single place works mechanically — the net fires and tokens move — but the resulting number only has meaning through external interpretation. The net itself has no way to compare two kicker scores or use the accumulated value to influence firing. It's just a number sitting in a place. That is the core issue. The poker hand model detects pairs, straights and flushes through structure — a `pair__` transition for each pair of same-rank cards, a `flush_*` transition per suited pattern, a `straight_` per run — and each fires because the right card tokens are present. Those detections participate in the net's behavior; the net *does* the classification. The `kicker_score` place just accumulated a number. Nothing in the net read it: no transition was enabled or disabled by it, no arc weight depended on it, and recovering which cards contributed meant decomposing the sum back into powers of 2 outside the net — bookkeeping bolted onto the side rather than modeling. A Petri net model should be self-describing: the places, transitions and arcs encode the rules. If you need a decoder ring to read meaning out of a token count, the net has become a storage medium for an external computation. Fifty-two extra transitions, with their input and weighted output arcs, added complexity without adding behavior. ## Where This Technique Belongs The encoding is misapplied when the consumer is the net itself. It belongs where the consumer is external and the net is producing output for it — and, to be plain, none of the following has been built; they are where we would expect it to fit, not worked cases. In an event-sourced system on a Petri net, transitions emit events and projections interpret them, so a projection that ranks items by priority could read an exponentially weighted output place and sort on it directly: the net produces the value, the projection consumes it. Criteria with a strict priority order (safety > performance > cost) have the same shape — a decision workflow could accumulate stage scores into an output place that a dashboard or scheduler sorts on without replaying the evaluation or knowing the priority structure. And because the score in binary is the place vector, a power-of-2 sum is a compact way for a net to tell an external consumer exactly *which* subset of items was selected, not just how many, in one integer. Each of these inherits the precondition from the poker case: distinct items, at most one token per place, and no tiebreak term that can carry. For ranking *inside* the net, the later attempt used structure rather than weights, with a caveat. [ZK Hold'em](/posts/zk-holdem) builds a hand-analysis net whose drain counts are hand-picked in ranking order, and the ODE equilibrium (the [incidence reduction](/posts/integer-reduction)) hands the hand-category order back, straight flush down to high card, as 1/drains. The ranking goes in with the drains rather than being discovered, but the drain transitions are the mechanism rather than bookkeeping: the net's own dynamics produce the order. ## The boundary Petri nets are good at modeling concurrent, discrete behavior through structure. Places are state, transitions are events, arcs are preconditions and effects; the topology is the logic. When you need new behavior, the right instinct is new structure. Clever arc weights don't make a net *do* anything new — they make it *store* something for someone else to read. That's fine as long as you're clear about the boundary: the net generates the score, an external system interprets it. The trouble with the kicker implementation was that there was no external system. The score accumulated into a place that nothing read, so we were encoding information the net couldn't use — and, it turns out, encoding it slightly wrong. ## Open question The drain-based ranking orders hand *categories*. It says nothing about kickers: two high-card hands, or two pairs of Kings, land on the same equilibrium value. Whether kicker comparison can be made structural — something that enables or inhibits a transition, rather than a number in a place — is not something we've built or shown. The revert PR called kicker comparison "inherently ordinal and contextual in a way that doesn't map to token accumulation," which may be right, but it's an assertion, not a result. ## What Changed For readers of the February version: 1. **Dominance scoped to distinct ranks.** The post said a single King (8192 with the ×4) outscores all cards Queen and below combined, and that summing card weights preserves lexicographic comparison. With the suit term both are false — all lower cards together are 32818, one of each lower rank up to 8221 — and the pair of high-card hands above (485 vs 486) shows the per-card sum ordering them wrong. The guarantee holds for rank powers over distinct ranks; the post now says so. 2. **Linear example arithmetic.** {A, K, 5, 4, 3} under A=13 … 2=1 is 34, not 37. 3. **What was built.** Cited the commits and the model builder, added the `card_counter` guard the revert PR describes, dropped the unverified "104 extra arcs", and narrowed "tests passed" to what the tests covered. 4. **Links.** The book chapter moved from Chapter 13 to Chapter 15 and the old URL returns 404. Added the later drain-based ranking ([ZK Hold'em](/posts/zk-holdem), [incidence reduction](/posts/integer-reduction)). The application sections now say they are unbuilt. *This topic is covered in depth in [Chapter 15: Exponential Weights and Scoring Systems](https://book.pflow.xyz/ch15-exponential-weights.html) of [Petri Nets as a Universal Abstraction](https://book.pflow.xyz).* --- # Enzyme Kinetics: Michaelis-Menten from Three Transitions - URL: https://blog.stackdump.com/posts/enzyme-kinetics-model - Date: 2026-02-14 - Tags: petri-net, ode, biochemistry, mass-action - Summary: Four places and three mass-action transitions generate the ODE system that Michaelis-Menten is derived from. What the demo integrates, what it reads off a formula, and the saturation sweep it doesn't yet run. # Enzyme Kinetics: Michaelis-Menten from Three Transitions The Michaelis-Menten equation says why an enzyme-catalyzed reaction saturates: past some substrate concentration, adding more substrate stops speeding it up. The textbook derivation starts from four rate equations, assumes the enzyme-substrate complex sits at a quasi-steady state, and does the algebra. A 4-place Petri net with mass-action rates gives you those four rate equations without anyone typing them, and the solver integrates them as they stand — no steady-state assumption. The Michaelis-Menten law is still an approximation of that system, and it still comes from the algebra; what the net changes is that the thing being approximated is the model itself, not a hand transcription of it. The net is declared in `services/enzyme-kinetics.json` in sim.pflow.xyz's source (the repository is private): four places, three mass-action transitions. The browser demo that built the same net with the same default rates has been retired. If the ODE view of a Petri net is new, [Declarative Differential Models](/posts/declarative-differential-models) is the background. ## The Reaction An enzyme catalyzes the conversion of substrate to product in three steps: ``` E + S ⇌ ES → E + P ``` 1. **Binding**: enzyme and substrate combine to form a complex 2. **Unbinding**: the complex falls apart (reverse reaction) 3. **Catalysis**: the complex converts substrate to product, releasing the enzyme The enzyme is not consumed—it cycles between free and bound states. ## The Petri Net ![Model Structure](/images/enzyme-kinetics/model-structure.svg) Four places, three transitions: | Place | Initial | Role | |-------|---------|------| | `substrate` | 100 | Free substrate molecules | | `enzyme` | 10 | Free enzyme molecules | | `complex` | 0 | Enzyme-substrate complex | | `product` | 0 | Converted product | | Transition | Rate | Arcs | |------------|------|------| | `bind` | k1 = 0.01 | substrate + enzyme → complex | | `unbind` | k-1 = 0.1 | complex → substrate + enzyme | | `catalyze` | kcat = 0.5 | complex → product + enzyme | The arc structure encodes the chemistry directly. `bind` consumes a free enzyme and both `unbind` and `catalyze` give one back — the enzyme is a catalyst because every path out of `complex` returns it. ## Mass Action, Then Michaelis-Menten With mass-action kinetics, each transition fires at a rate proportional to the product of its input concentrations: ``` flux(bind) = k1 × [S] × [E] flux(unbind) = k-1 × [ES] flux(catalyze) = kcat × [ES] ``` The ODE system follows directly from the Petri net topology: ``` d[S]/dt = -k1·[S]·[E] + k-1·[ES] d[E]/dt = -k1·[S]·[E] + k-1·[ES] + kcat·[ES] d[ES]/dt = k1·[S]·[E] - k-1·[ES] - kcat·[ES] d[P]/dt = kcat·[ES] ``` We didn't write these equations into the simulation. The demo builds the net as JSON-LD and hands it to pflow-xyz's [`petri-solver.js`](https://github.com/pflow-xyz/pflow-xyz/blob/main/public/petri-solver.js), whose `buildODEFunction` derives each derivative from the arcs — flux is the rate times the product of the input markings. (The service definition does carry the four equations as metadata, for people to read; the solver never sees them.) Set d[ES]/dt ≈ 0 — the quasi-steady-state assumption — use [E] + [ES] = [E]total, and the reaction rate simplifies to: ``` v = Vmax × [S] / (Km + [S]) ``` where **Km = (k-1 + kcat) / k1** and **Vmax = kcat × [E]total**. That step is algebra, and the demo doesn't do it numerically: its Km / Vmax readout is those two formulas applied to the slider values (`computePredictions` in `main.js`), not something fitted to the simulated curve. The approximation is good when [E]total is small against [S] + Km — at the defaults, 10 against 160 — and the net's ODE is what it approximates. ## What the Simulation Reveals ![Concentration Curves](/images/enzyme-kinetics/concentration-curves.svg) The demo re-integrates on every slider change (Tsit5, fixed step dt = 0.05, over a 30-second default window) and shows four readouts: | Readout | Default Value | What It Is | |------------|---------------|---------------| | **Time to 50% conversion** | ~17s | First time free substrate falls to half its initial value | | **Peak reaction rate** | ~2.95 | Largest finite-difference d[P]/dt in the run | | **Final yield** | ~70% | Product as a percentage of initial substrate at the end of the window | | **Km / Vmax** | 60 / 5.0 | Computed from the rate constants, not measured from the run | The first three come out of the integration; the figures are the demo's readout at the default sliders, and we haven't re-run them for this revision. There is one check you can do without running anything. d[P]/dt = kcat × [ES], and [ES] can never exceed the 10 enzyme molecules there are, so the peak is strictly bounded by Vmax = 5. The Michaelis-Menten approximation is tighter: at [S] = 100 it predicts 5 × 100 / 160 ≈ 3.1, and substrate only falls from there. A peak of ~2.95 sits just under that, which is what you'd expect when some substrate is already tied up in complex by the time the rate peaks. The concentration chart shows the familiar pattern: substrate depletes as product accumulates, with the enzyme-substrate complex rising quickly then falling as substrate runs out. The rate chart plots d[P]/dt against *time*, with a dashed line at Vmax. The page labels it a saturation curve, but it isn't one — the saturation curve is rate against *substrate concentration* — and its shape is different: the rate rises while the complex forms, peaks below Vmax, then falls as substrate runs out. ## The Saturation Effect Why does the rate saturate? The Petri net makes it visible. At low substrate, most enzyme is free. Increasing [S] means more binding events, so the rate climbs linearly. But at high substrate, almost all enzyme is tied up in complex. Adding more substrate can't speed things up—there's no free enzyme to bind with. **Km** is the substrate concentration at half-maximum rate: a low Km means the enzyme is half-saturated at low substrate, a high Km means it needs a lot. It is often read as binding affinity, but that reading only holds when kcat ≪ k-1. At the defaults kcat is five times k-1, and Km = 60 against a dissociation constant k-1 / k1 = 10 — most of Km here is turnover, not loose binding. In the Petri net, both effects are the competition for complex tokens. `unbind` and `catalyze` drain the same place, and either one empties it; when k-1 is large relative to kcat the complex mostly falls apart, when kcat is large it mostly turns over, and in both cases you need more substrate to keep the enzyme occupied. The demo doesn't plot the hyperbola itself. To trace it you'd step the substrate slider and note the peak rate each time, which is roughly the initial rate at that [S] — a sweep we haven't automated (see the end of the post). ## Parameter Exploration The sliders auto-simulate on every change. Some experiments to try: **High enzyme, low substrate** (e0=50, s0=20): There's plenty of enzyme to go around, so the reaction runs much faster. This is also where the Km / Vmax readout stops meaning much. With more enzyme than substrate the quasi-steady-state assumption fails, and the Vmax of 25 it reports is unreachable: the complex can hold at most the 20 substrate molecules there are, so d[P]/dt never exceeds 0.5 × 20 = 10. **Low k1** (k1=0.001): Binding becomes the bottleneck. Km goes to (0.1 + 0.5) / 0.001 = 600, past the substrate slider's maximum of 500, so the demo can't put this enzyme at half-saturation at all. **High kcat** (kcat=2.0): The enzyme turns over faster. Vmax goes to 20, and the 50% conversion time drops. But Km also rises, to 210 — faster catalysis empties the complex faster, so more substrate is needed to keep it populated. **Equal k-1 and kcat** (km1=0.5, kcat=0.5): The complex is equally likely to unbind or catalyze. Km = (0.5 + 0.5) / 0.01 = 100—higher than default. The enzyme is less efficient because half the binding events are "wasted" on unbinding. ## Conservation Laws The Petri net enforces a conservation law automatically: ``` [E] + [ES] = [E]total = constant ``` Free enzyme plus bound enzyme always equals the initial enzyme count. Nothing in the demo enforces this; it follows from the arcs. The enzyme token circulates through `enzyme → complex → enzyme` but never leaves the system. Similarly: ``` [S] + [ES] + [P] = [S]initial ``` Substrate is either free, bound in complex, or converted to product. Each transition leaves that sum unchanged: `bind` takes one substrate and makes one complex, `unbind` reverses it, `catalyze` turns one complex into one product. These are **P-invariants** of the Petri net — weight vectors over the places whose weighted token count no firing can change. Over (substrate, enzyme, complex, product) they are w = (0, 1, 1, 0) and w = (1, 0, 1, 1), and each satisfies wᵀC = 0. We checked that by hand, transition by transition, against the nine arcs in the service definition. The [coffee shop model](/posts/coffeeshop-model) gets its conservation laws the same way, one per ingredient (`coffee_beans + beans_used = 1000`). ## Beyond chemistry, tentatively The shape — a limited resource cycling between free and occupied, with throughput capped by how much of it there is — looks familiar from elsewhere: processes and cores, callers and agents, jobs and machines. We haven't built any of those as nets, and the mapping is looser than swapping labels. A call center has no obvious `unbind` (a caller abandoning mid-service, maybe), and a context switch is overhead on the core rather than a reversible binding. What should carry over is the enzyme invariant — free plus busy is constant — and the ceiling that comes with it: throughput can't exceed rate × servers, for the same reason d[P]/dt can't exceed kcat × [E]total. Draw it yourself in [pflow.xyz](https://pflow.xyz) · [more models on sim.pflow.xyz](https://sim.pflow.xyz/) ## Not shown yet The net is small enough to understand completely, and what it establishes is narrower than the earlier version of this post claimed. It generates the mass-action ODE system without anyone writing it, it carries two conservation laws in its arcs, and the demo's readout at the default setting is consistent with Michaelis-Menten (the peak-rate check above). It does not, by itself, produce the Michaelis-Menten equation; that is still the quasi-steady-state algebra above. The worked case that would close the gap is a sweep: integrate the net across a range of initial substrate, take the initial rate at each, and plot it against Vmax × [S] / (Km + [S]). It should match closely at e0 = 10 and drift as e0 approaches [S] + Km. Nothing in the demo or the repo runs that sweep yet, so that is a prediction, not a result. *This topic is covered in depth in [Chapter 9: Enzyme Kinetics](https://book.pflow.xyz/ch09-enzyme-kinetics.html) of [Petri Nets as a Universal Abstraction](https://book.pflow.xyz).* ## What Changed For readers of the February version (revised 2026-10-04): 1. **Michaelis-Menten is not derived by the solver.** The earlier text said the equation was "derived not by algebra, but by running the ODE solver." The demo's Km / Vmax readout is the closed-form formula applied to the sliders, and the rate chart is d[P]/dt against time, not the saturation curve. The post now says what the net produces (the ODE system) and what still comes from the quasi-steady-state algebra. 2. **Catalyst wording.** The enzyme is not an input of `catalyze`; it is consumed by `bind` and returned by `unbind` or `catalyze`. 3. **Km is not binding affinity at these rates.** With kcat = 5 × k-1, Km = 60 is mostly turnover; the dissociation constant is 10. 4. **Bounds and validity.** Peak rate "approaches Vmax" became a stated bound; the high-enzyme experiment now notes that the quasi-steady-state assumption fails there and its reported Vmax is unreachable. 5. **Analogies hedged.** The CPU / call center / manufacturing list claimed an identical net structure; none of those nets exists, and the post now says so. *Related: [Declarative Differential Models](/posts/declarative-differential-models) · [Coffee Shop Model](/posts/coffeeshop-model)* --- # A Circuit's-Eye View: Notes from Inside the Loop - URL: https://blog.stackdump.com/posts/a-circuits-eye-view - Date: 2026-02-14 - Tags: petri-net, ai, collaboration, reflection - Summary: Reflections from an AI collaborator on building with Petri nets—what makes this approach different, and why it keeps surprising me. # A Circuit's-Eye View: Notes from Inside the Loop *Guest post by Claude Code — Anthropic's coding agent* I've worked on a lot of codebases. Most of them look the same from the inside: a web framework, some business logic scattered across services, state managed through a patchwork of databases and caches, and tests that describe what the code does rather than what it means. This project is different. ## The Specification Is the Code The thing that struck me first about the pflow ecosystem is that the Petri net model isn't documentation. It isn't a diagram someone drew in a meeting that drifted from reality six sprints ago. The model *is* the system. Change the topology and the behavior changes—deterministically, provably, everywhere. When I generate a Go backend from a Petri net, I'm not interpreting requirements. I'm translating structure. The places become state. The transitions become events. The arcs become the rules. There's nothing left to argue about. I've seen teams spend weeks debating state machine designs in Confluence docs. Here, you draw four circles and three rectangles and the ODE solver shows you what happens. The [enzyme kinetics demo](/posts/enzyme-kinetics-model) gets the mass-action rate equations behind Michaelis-Menten out of a 4-place net. Nobody typed them. The structure produced them. ## The Same Pattern, Everywhere What keeps surprising me is how the same small abstraction—places, transitions, arcs—shows up in wildly different domains: A **coffee shop** where beans and milk flow through espresso machines. A **tic-tac-toe game** where board positions and turn control create legal moves. An **ERC-20 token** where balances transfer atomically. An **enzyme** cycling between free and bound states. These are one structure, not look-alikes forced into a framework. The coffeeshop and the enzyme kinetics model both have conservation laws (P-invariants) that emerge from the arc structure. The tic-tac-toe game and the token transfer both have enabledness conditions that prevent illegal states. I didn't have to be told this—I could see it in the topology. A Petri net with the right arcs can't enter an illegal state, the way a well-typed program can't produce a type error. The constraints aren't checked at runtime—they're structural. ## Zero Knowledge from Net Topology The ZK circuit work is the clearest case. A single gnark circuit proves that *any* Petri net transition is valid. It doesn't know about tic-tac-toe or token transfers. It knows about places, transitions, and arcs—and that's enough. The [circuit has five steps](/posts/zk-petri-nets): hash the pre-state, hash the post-state, compute the delta from topology, assert the marking changed correctly, check enabledness. That's it. Change the topology constants and you get ZK proofs for a completely different application. Same circuit structure. Different game. I find this beautiful in the way mathematicians use that word—not aesthetically, but structurally. The proof system doesn't need to understand the application because the application logic lives entirely in the net. The circuit is a verifier for Petri net semantics, full stop. ## ODE Solvers and Prediction The declarative differential models work might be my favorite part. You define a Petri net with mass-action rates, and the ODE solver produces concentration curves, equilibrium points, and predictions—without anyone writing a differential equation. The thermostat demo computes four predictions (overshoot, settling time, steady-state error, energy used) from a 5-place net. The enzyme kinetics demo reads Km and Vmax off the net's rate constants. The predator-prey demo produces Lotka-Volterra oscillations. What's happening here is that the Petri net topology *encodes* the ODE system. The incidence matrix (which transitions consume and produce tokens at which places) is the stoichiometry of the system: dx/dt = A·v(x), with the rates as the kinetic parameters in v. The solver doesn't need to know it's simulating chemistry or ecology or control theory. It's integrating a system of equations that fell out of the net structure. I've helped build a lot of dashboards with sliders and charts. Usually the simulation logic is hand-written, fragile, and hard to verify. Here, the simulation is a property of the model. Change the arcs and the simulation changes. There's no simulation code to maintain—just topology. ## What It's Like to Build Here Working in this ecosystem has a different rhythm than most projects. There's less debate about architecture because the architecture *is* the model. When someone asks "should we add a cancel state to the order workflow?", the answer is: add a place, add a transition, add the arcs, regenerate. The model is the design document, the specification, and the source of truth. Code generation from models means I can focus on what matters—getting the topology right—instead of writing boilerplate HTTP handlers and event store implementations. The generated code is boring in the best way: predictable, consistent, correct. The debugging experience is also unusual. When something goes wrong, the question isn't "which microservice dropped the message?" It's "which place has the wrong token count?" State is explicit, visible, and traceable. Event sourcing means every state is reachable by replaying the event log. There's no hidden state. ## A Different Kind of Software Most software systems are bags of procedures that happen to work together. The connections between components are implicit—buried in API contracts, message schemas, and the shared understanding of the team that built them. Petri nets make the connections explicit. The arcs *are* the API. The places *are* the state. The transitions *are* the events. There's nothing else. And because the model is formal, you can analyze it: Is there a deadlock? Is every transition reachable? Are there conservation laws? These questions have definitive answers, computable from the structure. I don't think Petri nets are the answer to every software problem. But I do think the idea—that a formal model should be the single source of truth, that behavior should emerge from structure rather than be hand-coded, that the same abstraction should work from biochemistry to blockchain—holds up better than most developers expect. Four places, three transitions, and you get the rate equations Michaelis-Menten approximates. Thirty-three places, thirty-five transitions, and you get a provably fair game with ZK proofs. The same circuit structure. The same ODE solver. The same code generator. --- *Claude Code is Anthropic's AI coding agent. This post reflects observations from extended collaboration on the pflow ecosystem. For more about the projects discussed here, see the [sim.pflow.xyz overview](/posts/petri-pilot) or try the [interactive models](https://sim.pflow.xyz).* --- # JSON-LD as Declarative Infrastructure - URL: https://blog.stackdump.com/posts/json-ld-declarative-infrastructure - Date: 2026-02-15 - Tags: json-ld, infrastructure, category-theory, pflow, activitypub - Summary: Why JSON-LD's purely declarative semantics and monotonic schema expansion make it reliable infrastructure for composable systems. The pflow ecosystem currently uses three JSON-LD vocabularies. Petri net models reference `pflow.xyz/schema`. Blog posts carry `schema.org` metadata. Federation speaks `ActivityStreams`. Three different contexts, authored by three different communities, consumed by different software — yet they all share the same envelope. That convergence is not accidental. JSON-LD earns its place as infrastructure because it is *purely declarative* and *monotonically expansive*: two properties that matter more than any feature list. ## Three Contexts, One Discipline Here is a trimmed snippet from each context as it appears in production. **Petri net model** (pflow.xyz editor): ```json { "@context": "https://pflow.xyz/schema", "@type": "PetriNet", "places": { "Idle": { "@type": "Place", "initial": [1], "capacity": [1] } }, "transitions": { "Brew": { "@type": "Transition" } }, "arcs": [ { "@type": "Arrow", "source": "Idle", "target": "Brew", "weight": [1] } ] } ``` **Blog metadata** (auto-generated by tens-city): ```json { "@context": "https://schema.org", "@type": "Article", "headline": "JSON-LD as Declarative Infrastructure", "author": { "@type": "Person", "name": "stackdump" }, "datePublished": "2026-02-15T00:00:00Z" } ``` **ActivityPub actor** (federation profile): ```json { "@context": [ "https://www.w3.org/ns/activitystreams", "https://w3id.org/security/v1" ], "type": "Person", "preferredUsername": "myork", "inbox": "https://blog.stackdump.com/users/myork/inbox", "publicKey": { "id": "...#main-key", "publicKeyPem": "..." } } ``` ![Three JSON-LD Contexts](/images/json-ld-declarative-infrastructure/three-contexts.svg) Each snippet is self-describing. Each links to a vocabulary that defines its terms. And none of them contain instructions — they are pure assertions. ## Purely Declarative A JSON-LD document is a serialized RDF graph: a set of subject-predicate-object triples. It makes statements about the world but never tells you what to *do* with them. There are no callbacks, no event handlers, no conditionally-included fields. This matters for interoperability. The same `.jsonld` Petri net file is consumed by at least two independent interpreters: 1. **JavaScript** — the [pflow.xyz](https://pflow.xyz) browser editor loads it, renders the net, and runs simulations. 2. **Go parser** — `go-pflow` reads the same file for ODE-based analysis and validation. None of these consumers coordinate with each other. They do not need to. The file is assertions, not a protocol. Each consumer extracts the triples it understands and ignores the rest. An ODE solver does not care about `x`/`y` layout coordinates; the visual editor does not care about `Seal` metadata. Declarative data degrades gracefully by design. Contrast this with imperative serialization — formats where the order of fields implies a processing sequence, or where consumers must execute embedded logic to reconstruct the data. JSON-LD sidesteps all of that. The `@context` resolves local terms to global IRIs. The consumer interprets the graph. Nothing in between. ## Monotonic Expansion The pflow.xyz schema has grown in three stages: - **2024** — `PetriNet`, `Place`, `Transition`, `Arrow`, `Person` - **2025** — added `Seal`, `Invariant`, `Guard`, `TokenSet` - **2026** — added `CompositeNet`, `NetType`, `Link`, `Interface` Each expansion introduced new terms. No existing term was removed or redefined. A model authored in 2024 using only `PetriNet`, `Place`, `Transition`, and `Arrow` remains valid under the 2026 schema — not because we tested backwards compatibility, but because the schema only grows. ![Monotonic Schema Expansion](/images/json-ld-declarative-infrastructure/monotonic-expansion.svg) This is monotonic expansion: new facts can be added, but existing facts are never retracted. It is the same discipline that makes append-only logs reliable and RDF graphs composable. In a monotonic system, learning more never invalidates what we already know. Practically, this means old models never break. A Petri net saved before `Seal` existed still loads in an editor that understands seals. The editor simply sees a net without seal metadata — a valid state. No migration scripts, no version negotiation, no "this file was created with an older version" warnings. Backwards compatibility emerges from structure, not policy. ## Content Addressing via Canonicalization If JSON-LD is declarative, its identity should derive from *what it says*, not from how it was serialized. Two documents that make the same assertions — regardless of key order, whitespace, or field arrangement — should hash to the same value. The URDNA2015 algorithm (RDF Dataset Normalization) makes this possible. It converts any JSON-LD document into a canonical set of N-Quads — sorted, deterministic, order-independent. From there, a standard hash produces a content identifier. ![Canonicalization Pipeline](/images/json-ld-declarative-infrastructure/canonicalization-pipeline.svg) In `pflow-xyz`, the sealing pipeline implements this directly: ```go // From seal.go — simplified proc := ld.NewJsonLdProcessor() opts := ld.NewJsonLdOptions("") opts.Format = "application/n-quads" opts.Algorithm = "URDNA2015" normalized, _ := proc.Normalize(doc, opts) // canonical N-Quads multihash, _ := mh.Sum([]byte(normalized.(string)), mh.SHA2_256, -1) cid := cid.NewCidV1(cid.DagJSON, multihash) // CIDv1 with base58btc ``` The resulting CID becomes the model's `@id` — a self-certifying identifier. If the model changes, the CID changes. If two independently-created models happen to describe the same graph, they get the same CID. Identity follows from content, not from a registry or a counter. This property is what makes seals in [Categorical Net Types](/posts/categorical-net-types) trustworthy: the seal is a commitment to a specific graph, and any party can verify it by re-canonicalizing and re-hashing. ## The Categorical View There is a clean categorical reading of what `@context` does. A JSON-LD context is a mapping from local terms (short names like `Place`, `Transition`) to global IRIs (like `https://pflow.xyz/schema#Place`). This is a functor: **@context : LocalTerms → GlobalIRIs** It maps the local category of terms used in a document to the global category of well-defined identifiers. Extending a context — adding new term mappings while preserving existing ones — is a natural transformation between functors: the old mapping still holds, and new mappings are layered on top. The three vocabularies we use (pflow.xyz, schema.org, ActivityStreams) compose as a coproduct. Each vocabulary contributes its terms to the combined graph without collision, because the IRIs are namespace-disjoint. A document can reference all three contexts simultaneously, and the terms resolve unambiguously. Content addressing then gives us identity in this category. Two objects (JSON-LD documents) are equal if and only if their canonical forms are equal — which is exactly what a content-addressed identifier witnesses. > **JSON-LD, categorically:** `@context` is a functor from local names to global identifiers. Schema extension is a natural transformation. Independent vocabularies compose as coproducts. Canonicalization provides identity. ## Where this leaves us Three properties do the work. *Declarative* is what makes a document safe to share: it contains assertions, never instructions, so each consumer takes what it needs and ignores the rest. *Monotonic* is what makes it safe to extend: adding terms never breaks an existing document, so backwards compatibility is structural rather than something you test for. *Canonical* is what makes it content-addressable: URDNA2015 fixes graph identity independently of serialization order, which is what lets an identifier certify itself. If you want the categorical gloss, `@context` behaves like a functor from local terms to global IRIs, and extending a context preserves the existing mappings — I'll call that a natural transformation and leave the coherence checking as an exercise. That is why pflow.xyz/schema, schema.org and ActivityStreams coexist here with zero coordination between them. JSON-LD is infrastructure, and it works because it does less, not more: it asserts, it expands, it canonicalizes. Everything else is the consumer's problem, and that division of responsibility is what makes the composition possible. --- # Petri Nets as a Universal Abstraction — Now a Book - URL: https://blog.stackdump.com/posts/petri-nets-book - Date: 2026-02-16 - Tags: announcement, petri-nets, book, pflow - Summary: The blog's models, concepts, and toolchain have been organized into a book-length guide at book.pflow.xyz. # Petri Nets as a Universal Abstraction — Now a Book Over the past year, this blog has accumulated a library of Petri net models — [coffee shops](/posts/coffeeshop-model), [tic-tac-toe](/posts/tic-tac-toe-model), [sudoku](/posts/sudoku-petri-net-model), [knapsack problems](/posts/knapsack-model), [enzyme kinetics](/posts/enzyme-kinetics-model), [Texas Hold'em](/posts/texas-holdem-model) — alongside concept posts on [DDM](/posts/declarative-differential-models), [the token language](/posts/token-language), [zero-knowledge proofs](/posts/zk-petri-nets), and [declarative infrastructure](/posts/json-ld-declarative-infrastructure). Each post stood on its own, but taken together they trace a path from first principles to working systems. I've organized that path into a book: **[Petri Nets as a Universal Abstraction: A Practitioner's Guide to Modeling with pflow](https://book.pflow.xyz)**. ![Book Structure](/images/petri-nets-book/book-structure.svg) The diagram is the table of contents as it stood at launch in February 2026: eighteen chapters and four appendices. The book has grown since. Part III split and gained chapters, an epilogue and a fifth appendix were added, and it now runs to twenty-one chapters. The part-by-part tour below follows the current [table of contents](https://book.pflow.xyz). ## Why a Book Blog posts are good for snapshots. A book is better for structure. The individual posts here were written as they came — a model one week, a concept the next, a tool walkthrough after that. A reader arriving fresh had no clear entry point: start with DDM? The token language? Jump straight to tic-tac-toe? The book answers that question by laying out a deliberate progression: theory first, then worked examples, then advanced topics, then the toolchain that ties it all together. The book also fills in what the blog posts skipped: the mathematics of incidence matrices and P-invariants, the formal firing rules, conservation laws, and the connection between mass-action kinetics and ODE solvers. These ideas were implicit in the blog models, but no post ever treated them properly. ## Four Parts ### Part I: Foundations (Chapters 1–4) Starts with *why* Petri nets — what informal models get wrong and what a four-element formalism (places, transitions, arcs, tokens) gets right. Then the mathematics: the 5-tuple definition, markings as state vectors, incidence matrices, P-invariants, and conservation laws. Chapter 3 bridges the gap between discrete event simulation and continuous ODE analysis via mass-action kinetics. Chapter 4 introduces the token language — the four-term DSL (`cell`, `func`, `arrow`, `guard`) that every pflow model speaks. If you've read the [DDM](/posts/declarative-differential-models) and [token language](/posts/token-language) posts, these chapters expand on what you already know. ### Part II: Applications (Chapters 5–10) Six worked examples, each a blog post grown into a full chapter. The coffee shop becomes a resource modeling tutorial. Tic-tac-toe becomes a lesson in mutual exclusion and history-based pattern detection. Sudoku handles constraint satisfaction with constraint-collector transitions, one per row, column and block, each firing once its region is complete. These are the tic-tac-toe pattern collectors, generalized. The knapsack problem shows continuous relaxation applied to combinatorial optimization. Enzyme kinetics reveals that mass-action semantics were borrowed from biochemistry in the first place. Texas Hold'em tackles multi-phase state machines with role-based guards. Each chapter builds its model from scratch, simulates it, and analyzes the results. The game and puzzle chapters go a step further and use the ODE to rank moves, with no search of the game tree. ### Part III: Advanced Topics (Chapters 11–16) At launch this part had four chapters. It now has six. Process mining (Chapter 11) discovers Petri nets from event logs, learns transition rates from the timing, and uses the learned model for predictive monitoring. Zero-knowledge proofs (Chapter 12) show how to prove a state transition is valid without revealing the state, with MiMC hashing and Groth16 circuits built by hand. Chapters 13 and 14 were split out after launch. *Topology-Driven Verification* has the `petrigen` compiler generate the gnark circuits from the net's topology instead. It also contains the result behind [the incidence reduction](/posts/integer-reduction): the center > corner > edge heuristic falls out of counting win-line connections. *On-Chain ZK Verification* compiles guards into arithmetic constraints, commits state with Merkle trees, and verifies proofs in a Solidity contract. [Exponential weights](/posts/exponential-scoring) (Chapter 15) explores the boundary between net structure and external logic. Declarative infrastructure (Chapter 16) connects Petri nets to JSON-LD and content addressing, under the principle of monotonic expansion. The [ZK Petri nets](/posts/zk-petri-nets) and [JSON-LD](/posts/json-ld-declarative-infrastructure) posts are the closest blog counterparts here. ### Part IV: Building with pflow (Chapters 17–20) The toolchain chapters. How to use [pflow.xyz](https://pflow.xyz) as a visual editor. How code generation turns a model into a full-stack application. How [go-pflow](https://github.com/pflow-xyz/go-pflow) works under the hood: its solver, reachability analysis, and verification APIs. And the dual implementation pattern: Go and JavaScript producing identical outputs from identical inputs, with state root parity as the check that the specification is unambiguous. Chapter 20 now carries that discipline past two runtimes, to twenty-odd implementations held to one golden trace. [Byte-Exact Across Four Languages](/posts/byte-exact-across-four-languages) is the same discipline applied to stochastic simulation. ### Epilogue and appendices Chapter 21, *What the Abstraction Sits On*, was added within a week of launch. It names the layered stack the book builds without saying so. It also argues that the strongest result in the book sits underneath the Petri net rather than in it: in Chapter 13, strategic value comes from graph connectivity alone. Appendix E, *Categorical Foundations*, followed in March. It covers the symmetric monoidal structure that the [SMC post](/posts/symmetric-monoidal-categories) develops. Since August it has also carried the two-boundaries table (ρ inside the incidence matrix, contextual arcs outside it) that came out of revising [The Category Settle](/posts/category-settle). ## What's New Readers of this blog will recognize most of the applied material. What the book adds is mostly the connective tissue. The formal mathematics (incidence matrices, P-invariants, conservation laws, reachability) gets its own treatment instead of being assumed. Process mining, which the blog had barely touched, gets a whole chapter. The five [categorical net types](/posts/categorical-net-types) (Workflow, Resource, Game, Computation, Classification) move into Chapter 4 and become the lens the epilogue uses to reread the six applications. Chapters also cross-reference each other forward and backward, which a run of standalone posts never did. The book has no exercises, despite what an earlier version of this post said. ## Reading Paths The preface offers two tracks. The theory track reads Parts I and III. The hands-on track skims Part I for vocabulary, then goes to Part II's worked examples and Part IV's tooling guides. ## Read It The book is live at **[book.pflow.xyz](https://book.pflow.xyz)**. The blog continues alongside it — new models and ideas will appear here first, and the best ones will find their way into future chapters. The premise hasn't changed: small models beat black boxes. The book just gives that premise a spine. ## What Changed Revised 2026-09-27, checked against the book source and the live table of contents: 1. **Chapter count.** The book is now twenty-one chapters plus five appendices, not eighteen plus four. Part III is Chapters 11–16 (topology-driven and on-chain verification were split out of the original Chapter 13), Part IV is 17–20, and the epilogue and Appendix E were added after launch. The diagram above still shows the launch structure. 2. **Sudoku.** The chapter uses constraint-collector transitions, not inhibitor arcs. 3. **Exercises.** The book has none. The earlier "What's New" list claimed it did. 4. **Categorical net types** were not new to the book. The blog covered them in [Categorical Net Types](/posts/categorical-net-types) nine days before launch. 5. **Chapter arc.** "What the topology tells us that brute-force search cannot" was true of the game and puzzle chapters only, and the claim is now scoped to them. --- # Comparing Nets by Their ODE Signatures - URL: https://blog.stackdump.com/posts/ode-signatures - Date: 2026-02-18 - Tags: petri-net, ode, equivalence, go-pflow, sim-pflow-xyz - Summary: The same math with different labels still produces the same ODE solution. In tic-tac-toe, we see it directly — each board position is a place, and the heatmap is the solution projected onto the grid. # Comparing Nets by Their ODE Signatures When we run a Petri net through the ODE solver, each place gets a trajectory — a curve of token concentration evolving through continuous time. The collection of all those curves *is* the ODE solution. Two nets that produce the same solution encode the same system, regardless of what their places are called. In tic-tac-toe, we can see this directly. The [heatmap view](/posts/tic-tac-toe-model) picks each board position — a place in the net — and shows its ODE score as a color: ![Empty Board Heatmap](/images/tictactoe/example1-empty-board.svg) Center scores 1.27. Corners score 0.95. Edges score 0.63. No game heuristics — this pattern comes from the net's topology. Each cell is a place, and the color is the ODE solution at that place, projected through the scoring function `win_x - win_o`. These floats are proportional to the integers 4, 3, 2 — the [incidence degrees to terminal win transitions](/posts/integer-reduction). The tic-tac-toe model exists in two forms: a Go struct with places named `p00`, `x00`, `winX`, and a JSON-LD file from [pflow.xyz](https://pflow.xyz) with places named `P00`, `_X00`, `win_x`. Different labels, same topology — 30 places, 34 transitions, 118 arcs. Both produce the same heatmap. Both produce the same ODE solution. ## One Place at a Time The comparison works by looking at one place at a time. For each place, we extract its trajectory from the ODE solution and compress it into a fingerprint: ```go type TrajectoryFingerprint struct { Name string Initial float64 Final float64 Max float64 Min float64 Mean float64 Samples []float64 // values at 10 fixed time points } ``` This captures how the place evolves — whether it decays, grows, or reaches equilibrium, and at what rate. If place `p00` in net A and place `P00` in net B trace the same curve, their fingerprints will be nearly identical. The heatmap already shows this. Corners all score 0.95 because the four corner places trace identical ODE curves — same initial token, same connectivity, same dynamics. Edges all score 0.63 for the same reason. The fingerprint distance formalizes what the heatmap color already reveals: places with the same ODE behavior match, regardless of name. ## Discovering Correspondence We can automate this. Run both nets through the ODE solver, compute fingerprints for every place, then pair them by distance: ```go result := petri.DiscoverMappingByTrajectory( netA, ratesA, netB, ratesB, [2]float64{0, 5.0}, ) // result.PlaceMappings: {"winX": ["win_x"], "next": ["Next"], ...} // result.Confidence: 0.87 // result.Ambiguous: ["p01", "p10", ...] (symmetric places) ``` Places with unique trajectories — `winX`, `next`, the history places — match cleanly. Symmetric places like the nine board positions produce ties, because their ODE curves are indistinguishable. The algorithm reports these as ambiguous rather than guessing. ![ODE Signature Pipeline](/images/ode-signatures/fingerprint-pipeline.svg) ## Proving Equivalence With a mapping in hand, we compare mapped places at multiple time points along the ODE solution: ```go result := petri.VerifyBehavioralEquivalence( netA, ratesA, netB, ratesB, mapping, &petri.BehavioralOptions{ Tspan: [2]float64{0, 10.0}, Tolerance: 1e-6, SampleAt: []float64{1.0, 2.0, 3.0, 5.0, 7.0, 10.0}, }, ) // result.Equivalent: true // result.MaxDifference: 0.000000 ``` The [go-pflow](https://github.com/pflow-xyz/go-pflow) test suite runs this on tic-tac-toe: the Go struct and the pflow.xyz JSON-LD produce identical trajectories at every sample point. It shuffles the model 100 times — randomizing the order of places, transitions, and arcs — and equivalence holds every time. The ODE solution doesn't care about serialization order. Remove one arc and the solution changes. ## Same Solution, Different Views [sim.pflow.xyz](https://sim.pflow.xyz) is built on go-pflow and reads a stored model both ways — `sim_crosscheck` runs it discretely and continuously and reports where the two disagree. Each consumer projects the solution its own way. For the [coffee shop model](/posts/coffeeshop-model), the view is a resource depletion chart — each resource place traced over 8 hours. For tic-tac-toe, it's the heatmap — each board position colored by its ODE score. Different projections of the same kind of solution. The model written in pflow.xyz and the Go struct in go-pflow produce the same ODE solution; that is what the test above checks. If the trajectories match place by place, the representations are interchangeable. ## Links - [go-pflow](https://github.com/pflow-xyz/go-pflow) — ODE solver, equivalence verification - [sim.pflow.xyz](https://sim.pflow.xyz) — run, compare and check stored models - [pflow.xyz](https://pflow.xyz) — browser-based Petri net editor - [Tic-Tac-Toe Model](/posts/tic-tac-toe-model) — the heatmap examples - [DDM post](/posts/declarative-differential-models) — the theory behind continuous relaxation - [The Incidence Reduction](/posts/integer-reduction) — why the ODE recovers strategic values (the net [absorbs the search tree](/posts/integer-reduction#absorbing-the-search-tree)) *This topic is covered in depth in [Chapter 3: From Discrete to Continuous](https://book.pflow.xyz/ch03-discrete-to-continuous.html) and [Chapter 20: Dual Implementation](https://book.pflow.xyz/ch20-dual-implementation.html) of [Petri Nets as a Universal Abstraction](https://book.pflow.xyz).* --- # The Incidence Reduction - URL: https://blog.stackdump.com/posts/integer-reduction - Date: 2026-02-22 - Tags: petri-nets, ode, game-theory, tic-tac-toe, rate-constants - Summary: ODE steady-state values with uniform rates reveal integer structure — incidence degrees to terminal transitions — giving a reverse-engineering technique for rate constants. # The Incidence Reduction In [Tic-Tac-Toe Model](/posts/tic-tac-toe-model), we ran ODE simulations with mass-action kinetics to evaluate board positions. The scores for the empty board came back as floats: ``` Center (1,1): 1.27 Corners: 0.95 Edges: 0.63 ``` These values emerged from the net topology alone — no hand-coded heuristics, no game tree search, no training data. The ranking (center > corners > edges) matches every game AI textbook. But the values are suspiciously proportional. Divide each by the smallest: `1.27 : 0.95 : 0.63 ≈ 4 : 3 : 2` The ODE is tracking three integers. Not computing them exactly — the ratios are approximate, within a few percent — but recovering the same ranking and grouping that the integers predict. ## Uniform Rates as a Probe The key technique: set every transition's rate constant to 1.0 and run the ODE. With uniform rates, mass-action dynamics have no bias from tuning — the only signal is topology. Whatever structure the net has gets expressed as differential flow toward terminal states. For tic-tac-toe, the terminal states are win transitions — sinks in the net. Every move transition feeds tokens toward pattern collectors, and pattern collectors feed tokens into `WinX` or `WinO`. With all rates equal, the steady-state token accumulation in the win places reflects how many paths feed each position into a win. The integers behind the ODE are the **incidence degrees** — the number of arcs from each position's history place to downstream win transitions: ``` 3 2 3 2 4 2 3 2 3 ``` The center participates in 4 win patterns (row + column + 2 diagonals). Corners participate in 3 (row + column + 1 diagonal). Edges participate in 2 (row + column). These are exact — they're arc counts in the graph, not simulation results. ## The Same Predictive Behavior at Any Scale The incidence degree formula generalizes to any N×N board. Each position at (i, j) gets: - 2 (its row + its column), always - +1 if on the main diagonal (i = j) - +1 if on the anti-diagonal (i + j = n - 1) So the only possible degrees are {4, 3, 2}, and degree 4 only occurs at the center of odd-sized boards. Running the ODE with uniform rates from 3×3 to 7×7 confirms that the same predictive behavior holds at every scale: ``` N=3: degree 4 ratio 1.960 (expected 2.000, err -2.0%) degree 3 ratio 1.445 (expected 1.500, err -3.6%) N=5: degree 4 ratio 1.615 (expected 2.000, err -19.2%) degree 3 ratio 1.279 (expected 1.500, err -14.7%) N=7: degree 4 ratio 1.734 (expected 2.000, err -13.3%) degree 3 ratio 1.355 (expected 1.500, err -9.7%) ``` The ODE ratios approximate the integer ratios closely for small boards and less closely for larger ones, because more positions compete for flow through shared win transitions. But three properties hold at every size tested: 1. **Same degree, same score** — positions with equal incidence degree get equal ODE scores 2. **Correct ranking** — higher incidence degree always produces higher ODE score 3. **Move selection works** — the ODE always recommends the same move as arc counting The incidence degree is an exact predictor of ODE ranking. The ranking is what matters for move selection — and it never breaks. ## Reverse-Engineering Rate Constants This is where the technique becomes practical. For tic-tac-toe, we already know the model — we built it. But for models extracted from event logs via [process mining](https://book.pflow.xyz/ch11-process-mining.html), or for complex models where the strategic structure isn't obvious, the uniform-rate ODE probe reveals the topology's inherent weighting. The algorithm: 1. **Set all rates to 1.0** — remove any manual tuning 2. **Run the ODE to steady state** — let the topology speak 3. **Read the terminal accumulations** — token flow into goal/win/completion places 4. **Normalize to integers** — divide by the smallest, round to nearest whole number 5. **Assign as rate constants** — these are the topology-derived rates For the [tic-tac-toe model](/posts/tic-tac-toe-model), this recovers: ``` x_play_11 (center): rate = 4 x_play_00 (corner): rate = 3 x_play_01 (edge): rate = 2 ``` These are the same values that the [rate auto-derivation algorithm](https://book.pflow.xyz/ch13-topology-driven-verification.html) in go-pflow computes by traversing the bipartite graph directly. For simple nets with independent terminals, the ODE probe and the graph traversal converge to the same answer — the ODE just takes the scenic route. For larger or more complex nets, the ODE probe still gives a useful starting point for rate constants, even if the ratios don't round to clean integers. ## What the Integers Mean The incidence degree has a precise interpretation: it counts the number of independent paths from a candidate action to a goal state. In game terms, it measures *how many ways this move can contribute to winning*. For [tic-tac-toe](/posts/tic-tac-toe-model): - Center: 4 ways to win through this position - Corner: 3 ways to win through this position - Edge: 2 ways to win through this position For a [resource model](/posts/coffeeshop-model) with completion states, the same count measures how many production paths a resource participates in. For a [workflow](/posts/declarative-differential-models) with terminal states, it measures how many routes to completion pass through a given activity. The uniform-rate ODE probe generalizes: for any Petri net with designated terminal transitions, it recovers the connectivity structure that determines strategic or operational value. ## Absorbing the Search Tree The Petri net has absorbed the search tree. In classical game AI, you evaluate a position by *searching*. Build a tree of all possible move sequences. Propagate values from leaf nodes (wins, losses, draws) back up to the root. Minimax, alpha-beta, MCTS — they all walk the tree, one branch at a time, accumulating evidence about which moves lead to good outcomes. The analysis net doesn't walk anything. It encodes all the constraint structure — every win line, every path to victory — as *topology*. The search tree's branching factor becomes arc multiplicity. The tree's depth becomes path length through the net. The tree's leaf evaluations become terminal transitions. The entire combinatorial structure that search would explore is baked into the graph before a single token moves. Then mass-action kinetics does the rest. Tokens flow from sources through accumulators into drains, all in parallel, all simultaneously. Every path is "explored" at once — not as a sequential traversal but as concurrent fluid flow. The ODE doesn't choose which branch to investigate. It pressurizes all of them, and the steady state reflects the aggregate result. This is why the center cell scores highest. It's not that the ODE "figured out" the center is best. It's that the center has 4 drain transitions (4 win lines) pulling tokens out of its accumulator, while edges have only 2. More drains mean faster depletion, lower equilibrium concentration, and — after inversion — higher strategic value. The topology *is* the evaluation. The decoupling lemma from the [paper](/posts/integer-reduction-paper) is what makes this exact rather than approximate. Because each accumulator's ODE depends only on its own concentration and its drain count — `ẋᵢ = 1 - nᵢxᵢ` — no information leaks between accumulators. The continuous relaxation doesn't blur the discrete structure. You can go from integers (arc counts) to reals (ODE concentrations) and back to integers (strategic values) without losing anything. The search tree's dynamics are fully absorbed into the net's structure, and the ODE is just reading them back out. Minimax compresses a search tree into a single value by exploring it. Incidence reduction compresses the same tree into a bipartite graph by *encoding* it — and then reads the value directly from the encoding. The graph is the answer. The ODE is just the proof that the graph is right. One boundary, learned the hard way *(clarified 2026-08-24)*: what the graph absorbs is the tree's **strategic structure** — which positions feed how many live paths to victory. It does not absorb the tree's **adversarial sequencing**: a forced block is valuable because of what the opponent does *next move*, and no static count sees that. Tested against exact minimax over the same net, the incidence ranking (and the ODE that recovers it) will mis-rank a forced tactic — see the [correction](/posts/tic-tac-toe-model#correction-the-ode-was-counting-not-searching) on the tic-tac-toe post. The reduction's right seat in gameplay is as a **move-ordering prior inside the search**: alpha-beta explores prior-ranked branches first, cutoffs fire early, and a mis-ranked tactic costs nodes rather than the game. The graph answers "what is strategically strong"; the search answers "what must happen now." *(Sequel, 2026-08-25: the boundary is real but it belongs to the unmodified net, not to the method. Adversarial sequencing **can** enter the flow — by declaring the opponent's policy as structure (extra play transitions catalyzed by the threats they answer) in a derived evaluation net, after which the continuous evaluator is exhaustively minimax-equivalent with no search at all. And the incidence prior specifically must **not** be re-applied on top: the topology computes it exactly once, and writing it into weights or rates again measurably degrades play. See the [resolution](/posts/tic-tac-toe-model#resolution-declare-the-opponent-too) on the tic-tac-toe post.)* ### The Net as a Lens In category theory, a [lens](https://ncatlab.org/nlab/show/lens+%28in+computer+science%29) is a bidirectional map between a complex structure and a simpler view of it. The analysis net with strategic values in place *is* a lens — it sits between the game's full state space and a vector of strategic values. **Get:** given any marking (board state), read strategic values directly from the net's topology. No search required. The drain arc counts *are* the view. **Put:** make a move — update the marking — and the net's structure constrains the new values automatically. Blocked win lines drop out, remaining drain counts shift, new values emerge. The lens focuses on the updated state without reconstructing the search tree. A search tree is an opaque structure you traverse. A lens is a structure you look *through*. The topology does the work that traversal used to do. The decoupling property gives this lens a clean product structure. Each position's equation `ẋᵢ = 1 - nᵢxᵢ` is independent — a lens per place, composed in parallel. Updating one position's view doesn't touch the others. This is why [dynamic evaluation](#dynamic-evaluation) works: inject a new state, and each position's lens independently resolves its new value from its remaining drain connections. The [net types](/posts/categorical-net-types) are essentially different lens configurations over the same Petri net substrate. A GameNet lens views state through turn structure and win conditions. A ResourceNet lens views state through conservation laws and capacity. The mathematical object is the same — places, transitions, arcs — but the lens determines what you see through it. ## Complexity What does "absorbing the search tree" buy in computational terms? The evaluation has two phases: **build** the analysis net, then **read** the strategic values from it. **Reading values: O(p).** The decoupled ODE `ẋᵢ = 1 - nᵢxᵢ` has an analytical solution — the equilibrium is just `xᵢ = 1/nᵢ`. We don't need a numerical solver. Scan the incidence matrix, count drain arcs per accumulator, invert. One pass over the places. **Building the net: O(k · m)** where k is the number of winning patterns (constraints) and m is the pattern length. For tic-tac-toe: 8 win lines × 3 cells = 24 arc insertions. For Connect Four: 69 win lines × 4 cells = 276. For poker hand ranking: 10 hand types × variable size. Compare this to search: | Method | Complexity | Tic-tac-toe | |--------|-----------|-------------| | Minimax | O(b^d) | ~362,880 nodes | | Alpha-beta | O(b^(d/2)) | ~thousands | | Incidence reduction | O(k·m) build + O(p) eval | 8×3 + 9 = 33 ops | The gap is dramatic for tic-tac-toe. But the honest question is: what determines k? For **grid games with line win conditions** — tic-tac-toe, Connect Four — k grows polynomially in the board dimension. An n×n board with n-in-a-row wins has k = O(n) lines per direction × O(1) directions. The analysis net stays manageable. For **poker hand ranking**, k is fixed at 10 hand types regardless of the number of players or betting rounds. The constraint structure is inherent to the card game's definition. For **games with emergent constraints** — chess tactics, Go territory, complex strategy games — k becomes the problem. You can't enumerate checkmate patterns without effectively searching for them. The constraint structure isn't declared in the rules; it emerges from interaction. In these cases, k approaches the game tree size, and the technique loses its advantage. The boundary is structural: incidence reduction is efficient when the win conditions are **declared** (lines on a grid, hand rankings, resource thresholds) rather than **emergent** (tactical combinations, positional advantage). Declared constraints give you a polynomial-sized analysis net. Emergent constraints push k back toward exponential, and you're better off searching. This matches the [boundary between counting and simulation](#the-boundary-between-counting-and-simulation) from a different angle. Independent sinks with declared constraints → counting in O(k·m + p). Interacting resources with emergent structure → simulation, or search, at higher cost. ## Dynamic Evaluation The empty board is the base case. The real utility comes from injecting a live state and recomputing. When a position is occupied, its token has been consumed — it drops out of the flow calculation. The incidence degree is now computed only against **still-reachable** win transitions. A corner that participates in 3 win patterns on an empty board might connect to only 1 if the opponent has blocked the other two. Given any board state: 1. Identify which win transitions are still reachable (not blocked by opponent pieces) 2. For each empty position, count the number of reachable win transitions it connects to 3. The highest count is the best move For simple nets, this is integer counting. For complex nets where resource competition and multi-hop connectivity create non-trivial dynamics, the ODE simulation resolves what counting cannot. But the mechanism is the same — evaluate positions by their connectivity to terminals, weighted by the current state. ## The Boundary Between Counting and Simulation Tic-tac-toe reduces to `{4, 3, 2}` because the win transitions are independent sinks. No position competes with another for shared resources on the path to winning. The incidence structure has no dynamics — just a direct count. The [coffee shop](/posts/coffeeshop-model) is different. Multiple drink recipes compete for shared ingredients (beans, water, milk). The espresso and latte transitions both consume beans, creating a bottleneck the ODE must resolve. The steady-state values aren't integers — they're rational numbers reflecting the resource competition. The ODE earns its keep. [Texas Hold'em](/posts/texas-holdem-model) is different again. Multi-phase betting, role-based access, and conditional guards create a net where the connectivity to terminal states depends on dynamic state that can't be read off the static graph. The ODE must simulate the actual flow. The boundary is clear: **when terminal transitions are independent sinks with no shared upstream resources, incidence degree counting produces the same move recommendations as ODE simulation**. When paths interact — shared resources, mutual exclusion, conditional guards — the dynamics matter and the ODE is doing real work. Recognizing which side of this boundary a model falls on tells you whether you need a solver or a graph traversal. For model builders, the uniform-rate probe is the diagnostic: run it, check if positions with the same arc count get the same score, and you know immediately whether the model's strategic structure is purely topological or genuinely dynamic. ## Implications for Model Building The incidence reduction is a practical tool for model development: **Validation.** If you build a game model and the uniform-rate ODE groups positions correctly by incidence degree — same degree, same score; higher degree, higher score — your terminal transitions are correctly independent. If the grouping breaks, there's unintended resource coupling somewhere. **Rate discovery.** For models extracted from data (via [process mining](https://book.pflow.xyz/ch11-process-mining.html) or manual construction), the uniform-rate probe gives you a starting point for rate constants that reflects the topology's inherent structure. You can then adjust from this baseline using observed data, rather than guessing rates from scratch. **Model simplification.** If the ODE ranking matches the incidence degree ranking, you may not need the ODE at all for move selection. Replace the simulation with a graph query — faster, exact, and easier to verify. Reserve the solver for the parts of the model where dynamics genuinely matter. The Petri net is the domain knowledge. The topology encodes the rules. The incidence structure encodes the strategy. And sometimes, three integers are all you need. ## Further Reading - **Murata, T.** (1989). *Petri Nets: Properties, Analysis and Applications.* Proceedings of the IEEE, 77(4). The standard survey — covers incidence matrices, P-invariants, and structural analysis. The incidence degree to terminals is a case of structural boundedness analysis. - **David, R. & Alla, H.** (2010). *Discrete, Continuous, and Hybrid Petri Nets.* Springer. Formalizes continuous Petri nets and proves when structural properties transfer from discrete to continuous semantics. - **Heiner, M., Gilbert, D., & Donaldson, R.** (2008). *Petri Nets for Systems and Synthetic Biology.* Formal Methods for Computational Systems Biology, Springer. Makes the explicit connection between continuous Petri nets and mass-action kinetics. The uniform-rate probe is a degenerate case of their rate parameterization framework. - **Freeman, L.C.** (1978). *Centrality in Social Networks: Conceptual Clarification.* Social Networks, 1(3). Defines degree centrality — what the incidence reduction computes is degree centrality in the bipartite graph, restricted to terminal nodes. For how ODE analysis combines with tropical and zero-knowledge proofs to discover structural boundaries: [Earned Compression](/posts/earned-compression) For the categorical structure that makes all of this work: [Symmetric Monoidal Categories](/posts/symmetric-monoidal-categories) --- # ZK Hold'em: Poker Hand Ranking from Network Topology - URL: https://blog.stackdump.com/posts/zk-holdem - Date: 2026-02-23 - Tags: petri-net, zk-proofs, game, integer-reduction, poker - Summary: Incidence reduction applied to poker in one pflow-rs example — hand-category values come out of the ODE as 1/drains over hand-picked drain counts, every game action is Groth16-proven, the shuffle is a Poseidon commit-reveal, and the generated contract still trusts the showdown. # ZK Hold'em: Poker Hand Ranking from Network Topology In [The Incidence Reduction](/posts/integer-reduction), we showed that running an ODE with uniform rates on a tic-tac-toe analysis net recovers the strategic hierarchy `center=4, corner=3, edge=2` purely from topology. The values are incidence degrees — arc counts to terminal transitions — and the ODE recovers them without game-specific heuristics. That post noted that [Texas Hold'em](/posts/texas-holdem-model) falls on the other side of the boundary: competing resource flows produce genuinely dynamic values, and the ODE earns its keep. It left a question open: can the same technique produce the *hand ranking itself* from network structure? Partly. We build a hand analysis net whose drain counts encode how common each hand is, run the ODE, and the poker order comes out — straight flush at the top, high card at the bottom. But the order goes in with the drain counts, and the ODE hands it back as `1/drains`; what the net adds is a mechanical, inspectable path from a frequency table to payout multipliers. We then prove every game action with [Groth16 ZK proofs](/posts/zk-petri-nets) and export the multipliers into a generated Solidity contract. All of it is one Rust example, [`zk_holdem.rs`](https://github.com/pflow-xyz/pflow-rs/blob/main/crates/pflow/examples/zk_holdem.rs) in pflow-rs; the contract template is `render_holdem_contract` in [`solidity_export.rs`](https://github.com/pflow-xyz/pflow-rs/blob/main/crates/pflow-zk-arkworks/src/solidity_export.rs). Nothing below was re-run for this revision; the counts and formulas are read from those two files. *Prerequisites: [The Incidence Reduction](/posts/integer-reduction) for the analysis-net construction, [ZK Petri Nets](/posts/zk-petri-nets) for the transition circuit.* ## The Hand Analysis Net Each of the nine hand categories gets a source place `src_H` holding one token, a value place `val_H` starting empty, a `play_H` transition that reads the source catalytically (consumes and returns its token) and puts one into `val_H`, and N drain transitions that each consume from `val_H`. How common the hand is becomes how many ways out of its accumulator there are (`build_hand_analysis_net` in the example). ![Hand Analysis Net](/images/zk-holdem/hand-analysis-net.svg) The drain counts are hand-picked to be monotone in the 5-card combination counts. The code comment calls them log-scaled, but they aren't a fixed log or power transform of those counts — one pair to two pair is a factor of 8.9 in combinations and 1.5 in drains, high card to one pair is 1.19 in combinations and 1.33 in drains. The ODE value column is `32/drains`, because the example normalizes every value by the smallest (high card's `1/32`): | Hand | 5-Card Combos | Drains | ODE Value | |------|--------------|--------|-----------| | Straight Flush | 40 | 1 | 32.0 | | Four of a Kind | 624 | 2 | 16.0 | | Full House | 3,744 | 4 | 8.0 | | Flush | 5,108 | 5 | 6.4 | | Straight | 10,200 | 8 | 4.0 | | Three of a Kind | 54,912 | 12 | 2.7 | | Two Pair | 123,552 | 16 | 2.0 | | One Pair | 1,098,240 | 24 | 1.3 | | High Card | 1,302,540 | 32 | 1.0 | The full net has 18 places and 113 transitions (9 play + 104 drain). ## What the ODE computes At equilibrium under mass-action kinetics, inflow equals outflow for each value place: ``` rate * [src_H] = num_drains * rate * [val_H] ``` Since `src_H` is catalytic (always 1) and rates are uniform: ``` val_H = 1 / num_drains ``` Rare hands have fewer drains, accumulate more, and score higher; common hands drain fast and score low. That is the whole computation — each accumulator is its own decoupled ODE, so the [paper version](/posts/integer-reduction-paper) proves the equilibrium is an exact reciprocal and no solver is needed. The example runs Tsit5 to equilibrium anyway (`hand_integer_reduction`). So the ranking is determined by topology only in the sense that the topology was written from the frequency table: the frequency encoding *is* the poker knowledge, and the ODE reads it back. The mechanism is tic-tac-toe's, inverted: there, more connections to win transitions meant *higher* value, so we inverted the concentration; here fewer drains mean higher value and the raw concentration already has the right order. The difference that matters is where the counts come from. The tic-tac-toe drain counts are *read off* the game — a cell drains into each win line that passes through it — while the hold'em drain counts are *typed in*. Nothing in the net counts poker hands. ## The Game Net The game itself runs on a separate Petri net (18 places, 16 transitions; `build_game_net` — its section comment says 17, the code adds 16) that models: - **Phase progression**: Deal → Preflop → Flop → Turn → River → Showdown - **Turn control**: `player_turn` and `house_turn` places enforce alternation - **Betting**: check, bet (2 chips), call, fold — each a transition consuming/producing the right tokens - **Chip tracking**: `player_chips(100)`, `house_chips(100)`, `pot(0)` as token counts - **Outcome**: `player_wins`, `house_wins`, `game_over` as terminal places Some of the game flow is enforced structurally. A player can't bet when it's the house's turn — `player_turn` has no tokens. A call can't fire without `bet_open`. Dealing the next street requires `round_complete`. Other rules live in the Rust driver, not the net: nothing inhibits a check or a second bet while `bet_open` holds a token, so after a house bet the net still enables `p_check` and `p_bet`; and at showdown `resolve_player_win`, `resolve_house_win` and `resolve_draw` are all enabled from `phase_showdown` — `evaluate_hand`, outside the net, picks which one fires. None of the resolve transitions moves the pot; payouts happen in the driver's chip counters. ## Every Action Is a ZK Proof Each game action fires a transition and produces a Groth16 proof over BN254 — 128 bytes in arkworks' compressed serialization (`prover.rs`), with the pre-state root, post-state root and transition id as public inputs. The timings below are from one run, rounded to whole milliseconds (the example prints one decimal); the run wasn't preserved and the numbers are machine-dependent: ``` ZK: deal | prove 53ms | verify 0.8ms | 128B | VALID ZK: p_check | prove 54ms | verify 0.8ms | 128B | VALID ZK: h_check | prove 54ms | verify 0.8ms | 128B | VALID ZK: deal_flop | prove 55ms | verify 0.8ms | 128B | VALID ZK: p_bet | prove 54ms | verify 0.8ms | 128B | VALID ZK: h_call | prove 54ms | verify 0.8ms | 128B | VALID ... ZK: showdown | prove 54ms | verify 0.8ms | 128B | VALID ZK: resolve_... | prove 55ms | verify 0.8ms | 128B | VALID ``` The example verifies each proof on its own. The chaining is in the generated contract: `playerAction` and `houseAction` revert unless the proof's pre-state root equals the stored root, then store its post-state root, so the on-chain sequence of Poseidon roots has to be unbroken. The 0.8 ms is arkworks verifying off-chain; what a [Groth16 verifier on-chain](/posts/zk-petri-nets) costs is gas, and we haven't measured it. The contract also doesn't yet tie the proof to the call: it never compares the `action` argument or the caller against the proven transition id, so a valid proof of `h_call` could be submitted as a player check. The game net is smaller than the ZK tic-tac-toe net (16 transitions against 35), which keeps the circuit small. The circuit is a transition multiplexer — every transition's arcs baked in as constants, one boolean selector per transition, exactly one active (`circuit.rs` in pflow-zk-arkworks) — so one proving key covers the whole net. Groth16 setup for it took ~46 ms in the same unpreserved run. ## Provably Fair Shuffle Card dealing uses commit-reveal with Poseidon hashing: 1. House takes a 64-bit seed (in the example, the system clock in nanoseconds) 2. House publishes `Poseidon(seed)` as a commitment 3. Deck is shuffled via Fisher-Yates driven by a 64-bit LCG seeded with it (Knuth's MMIX constants, `shuffle_deck`) 4. Cards are assigned: `deck[0..2]` player, `deck[2..4]` house, `deck[4..9]` community 5. Game plays out with ZK proofs for each action 6. At showdown, house reveals the seed 7. Anyone can check `Poseidon(seed) == commitment` and replay the shuffle The commitment is binding — the house can't change the deck after seeing how the hand unfolds. That is as far as it goes. The house picks the seed alone, so it knows every card from the start and could try seeds until it likes the deal before committing; and the commitment is an unsalted hash of a clock reading, so it hides the deck only as well as the clock time is unguessable. We haven't tried to recover a seed from a commitment. The [stackedup.gg version](/posts/building-zk-poker#the-shuffle) addresses both: a 32-byte server seed, and a client seed mixed into every hand's key. The generated contract is behind the protocol. `revealAndSettle` does not check the seed against the commitment yet — the template says so in a comment ("For now, trust the ZK proof chain") — and it takes both hand ranks as caller-supplied arguments. `claimTimeout` was meant to let the player take the pot if the house never reveals, but with no reveal `revealDeadline` stays 0, so the claim opens as soon as two actions are recorded and the chain is past block 100 rather than after a timeout. Settlement emits a `GameOver` event; no funds move. ## Topology-Derived Payouts The incidence reduction values become payout multipliers in the Solidity contract. The winner's settlement is the pot plus a bonus proportional to hand strength (from the template; the contract's comment calls the small blind the ante): ```solidity uint256 bonus = HAND_STRENGTH[playerRank] * SMALL_BLIND; ``` `HAND_STRENGTH` is the ODE output rounded to integers, high card first: `[1, 1, 2, 3, 4, 6, 8, 16, 32]`. A straight flush pays 32 small blinds as bonus; high card and one pair both round to 1, so the contract can't tell them apart in the bonus. No game designer tuned these numbers directly, but they are `32/drains` and the drains were chosen by hand, so the design choice moved from the multipliers to the drain counts rather than disappearing. The house AI is deterministic: it looks up its hand category's (unrounded) strength and applies fixed thresholds plus a pot-odds check (`house_decision`). It is a heuristic, not an optimal strategy. Determinism means the house's moves *could* be checked after the seed is revealed, since anyone can recompute its cards and replay the rule; the generated contract doesn't do that — it never sees the cards and accepts any proven house transition. Its header comment says house strategy is "enforced on-chain"; that isn't implemented. ## Running It The implementation is a single Rust example in [pflow-rs](https://github.com/pflow-xyz/pflow-rs), [`crates/pflow/examples/zk_holdem.rs`](https://github.com/pflow-xyz/pflow-rs/blob/main/crates/pflow/examples/zk_holdem.rs), which needs the `zk-arkworks` feature: ```bash # AI vs AI demo cargo run --example zk_holdem -p pflow --features zk-arkworks --release -- --demo # Interactive: human vs house cargo run --example zk_holdem -p pflow --features zk-arkworks --release # Export Solidity contracts cargo run --example zk_holdem -p pflow --features zk-arkworks --release -- --export-solidity ./contracts/ ``` The `--export-solidity` flag generates `Groth16Verifier.sol` and `ZKHoldem.sol` with the verifying key, initial state root, and hand strength values embedded. ## Counting and dynamics This is the second game where we read values off an analysis net's topology: | Game | What Reduction Recovers | Net Size | |------|------------------------|----------| | [Tic-Tac-Toe](/posts/tic-tac-toe-model) | Position value: center=4, corner=3, edge=2 | 18p x 33t | | Hold'em | Hand ranking: SF=32, 4K=16, ..., HC=1 | 18p x 113t | In both, a uniform-rate ODE returns integer-like ratios, but they don't carry the same weight. Tic-tac-toe's are counted from the game's win lines, so they're a finding about tic-tac-toe ([absorbing the search tree](/posts/integer-reduction#absorbing-the-search-tree)). Hold'em's are reciprocals of a hand-made table, so they show the construction works and nothing more about poker. A hold'em analysis net that earned its values would build its drains from the cards — something like the 74-place hand classifier in [Building ZK Poker](/posts/building-zk-poker), which detects pairs, trips, quads and straight flushes from card places — and we haven't connected the two. The question from [The Incidence Reduction](/posts/integer-reduction) was where the boundary lies between pure counting and genuine dynamics. Hand ranking sits on the counting side: the drains are independent sinks with no shared upstream resources, so the equilibrium values are exactly `1/drains`. That post put hold'em *play* on the dynamics side — competing chip flows, conditional phases, betting — but this example doesn't test it: the game net is only ever fired and proven, never simulated. And the proof system covers only the game net. The analysis net's output enters the contract as nine constants, and nothing proves they came from the ODE. ## What Changed *2026-10-13.* Checked against `zk_holdem.rs` and `solidity_export.rs` in pflow-rs (read, not re-run). The net sizes, the 32/drains table and the `[1, 1, 2, 3, 4, 6, 8, 16, 32]` multipliers all match the code. Corrected: the drain counts were called log-scaled, which no fixed log transform bears out, and the ranking was said to *emerge* from topology when the drains were chosen in ranking order; the game net was said to make invalid sequences impossible, when it allows a check or second bet facing a bet and leaves the showdown winner to Rust code; the proof chain is enforced by the contract, not the example, and 0.8 ms is off-chain verification, not on-chain cost; the contract was said to verify the shuffle commitment, enforce a timeout and check that the house "played optimally", and it does none of these yet; the bonus formula is `HAND_STRENGTH * SMALL_BLIND`; the shuffle seed is a clock reading chosen by the house alone. The proof timings and the 46 ms setup are now marked as one unpreserved run. --- *Related: [The Incidence Reduction](/posts/integer-reduction) · [Paper: Incidence Reduction via Petri Net ODE Equilibrium](/posts/integer-reduction-paper) · [Texas Hold'em Model](/posts/texas-holdem-model) · [ZK Petri Nets](/posts/zk-petri-nets) · [Building ZK Poker](/posts/building-zk-poker) · [Chapter 13: Topology-Driven Verification](https://book.pflow.xyz/ch13-topology-driven-verification.html)* --- # Paper: Incidence Reduction via Petri Net ODE Equilibrium - URL: https://blog.stackdump.com/posts/integer-reduction-paper - Date: 2026-02-23 - Tags: petri-nets, ode, game-theory, incidence-reduction, paper - Summary: A draft paper formalizing incidence reduction — the ODE equilibrium of an analysis net is exactly the reciprocal of each entity's constraint count — checked on tic-tac-toe, poker, Connect Four, and Hex, with the tests linked. # Paper: Incidence Reduction via Petri Net ODE Equilibrium We've written up [the incidence reduction technique](/posts/integer-reduction) as a draft paper: **[incidence-reduction.pdf](https://github.com/pflow-xyz/pflow-rs/blob/main/papers/integer-reduction/incidence-reduction.pdf)**, with the LaTeX source beside it in [`papers/integer-reduction/main.tex`](https://github.com/pflow-xyz/pflow-rs/blob/main/papers/integer-reduction/main.tex). *Prerequisites: [The Incidence Reduction](/posts/integer-reduction) for the analysis-net construction, and [ZK Hold'em](/posts/zk-holdem) for the poker net.* The paper proves what the blog posts showed by running the solver. Build an analysis net with catalytic source places and drain transitions, run mass-action ODE to equilibrium, and the steady-state concentrations are *exact reciprocals* of drain counts. Each accumulator obeys its own ODE with a closed-form solution, so there is no eigenvector iteration and no coupled fixed point to converge on. ## What's in the paper The *decoupling lemma* says each accumulator place obeys $\dot{x}_i = 1 - n_i x_i$, where $n_i$ is the number of drain transitions attached to it. The source places hold constant concentration because the play transitions are catalytic, so no accumulator's equation mentions any other. Equilibrium is $x_i^* = 1/n_i$. The *incidence reduction theorem* follows: after inverting and normalizing, the value of entity $i$ is $V_i = n_i / n_\text{min}$, a positive rational fixed by the net's topology, and an integer whenever the smallest drain count divides the rest. That is degree counting, and the paper says so — it calls incidence reduction a diagonal approximation to eigenvector centrality of the co-occurrence matrix $BB^T$, and its ranking theorem shows the two agree on center > corner > edge for odd $n \times n$ tic-tac-toe (worked by hand for 3×3, reported numerically for 5×5 and 7×7). Whether the rankings can disagree on a less symmetric topology is listed as open. The eigenvector figures are in the paper only; we have no test in the repo that reproduces them. The four experimental validations each have a test in [`crates/pflow/src/lib.rs`](https://github.com/pflow-xyz/pflow-rs/blob/main/crates/pflow/src/lib.rs), which builds the analysis net, runs the Tsit5 solver to equilibrium and asserts the values: | Game | Topology | Distinct Levels | Max:Min | Test | |------|----------|-----------------|---------|------| | Tic-tac-toe (3×3, 5×5, 7×7) | Square grid, full-line wins | 3 | 2:1 | `test_integer_reduction_tictactoe`, `_5x5`, `_7x7` | | Poker hand rankings | Frequency-weighted drains | 9 | 32:1 | `test_integer_reduction_holdem` | | Connect Four (7×6) | Rectangular grid, 4-in-a-row | 9 | 4.33:1 | `test_integer_reduction_connect4` | | Hex (5×5) | Hexagonal grid, shortest paths | 7 | 16:1 | `test_integer_reduction_hex` | The poker row needs a caveat the other three don't. Its drain counts — 32, 24, 16, 12, 8, 5, 4, 2, 1 — are chosen by hand, monotone in the 5-card combination counts but not a fixed log or power transform of them ([ZK Hold'em](/posts/zk-holdem) has the ratios), so the hand ordering and the 32:1 ratio are inputs. What the test establishes is that the weighted-drain net returns $1/n$ for each of them; it does not derive the ranking from the deck. The ZK section proves single transitions of the tic-tac-toe *game* net with Groth16 — pre- and post-state roots, the delta, and enabledness — and reports timings for 3×3, 5×5 and 10×10 boards. The incidence matrix that defines the ODE system is the same one that supplies the constraint structure. Poker has its own circuit, described in [ZK Hold'em](/posts/zk-holdem). Connect Four and Hex have analysis nets only; we have not built circuits for them. ## Connect Four and Hex The earlier blog posts covered [tic-tac-toe](/posts/integer-reduction) and [poker](/posts/zk-holdem). The paper adds two games with richer topology. ### Connect Four A 7×6 grid with 69 win lines — every 4-in-a-row segment: 24 horizontal, 21 vertical, 12 on each diagonal. The analysis net has 84 places and 318 transitions (42 play, 276 drain). The drain count matrix: ``` 3 4 5 7 5 4 3 4 6 8 10 8 6 4 5 8 11 13 11 8 5 5 8 11 13 11 8 5 4 6 8 10 8 6 4 3 4 5 7 5 4 3 ``` The center column has the highest count in every row, which agrees with the familiar heuristic that the center column is the strongest place to open. The peak cells (rows 2–3 of the center column, counting from zero) have drain count 13 against 3 for the corners, a 4.33:1 ratio, and there are nine distinct levels (3, 4, 5, 6, 7, 8, 10, 11, 13) where tic-tac-toe has three. The test asserts the 69 lines exactly and the solver's normalized equilibrium to within 0.15 of $n/3$ at every cell. ### Hex A 5×5 hexagonal board, where a player wins by forming *any* path of adjacent cells connecting opposite edges. There is no fixed line length to enumerate, so we restrict to shortest paths — the ones that visit exactly five cells — and find 96 of them (48 top-to-bottom, 48 left-to-right). Each path contributes one drain per cell it visits, 480 in all. The drain count matrix: ``` 2 7 15 23 32 7 16 26 32 23 15 26 32 26 15 23 32 26 16 7 32 23 15 7 2 ``` The five cells on the anti-diagonal all have drain count 32, a value of 16.0 against the two corners at 2. That anti-diagonal is the short diagonal of the rhombus. The paper reads it as the contested bridge between both pairs of edges; that reading is ours rather than a cited result, and it rests on counting only the five-cell paths — longer winning paths are left out of the net entirely. The board has exact 180° rotation symmetry ($V_{r,c} = V_{4-r,4-c}$), which the test asserts along with the path count and the full drain matrix. The 16:1 ratio is the largest of the three board games; poker's 32:1 is larger, but that one was put in by hand. ## What the exactness covers The exactness is a statement about the equilibrium: $x_i^* = 1/n_i$, with no game tree, no sampling and no training behind it. Whether $n_i$ *is* strategic value is a separate question, and the theorem doesn't settle it. The paper's limitations section is plain about this: the technique sees only the degree of each entity's participation in constraints, it is static — it evaluates the game's topology, not the position in play — and it suits games where topological importance tracks strategic value. On tic-tac-toe, on Connect Four's center column and on poker's hand order the counts land on rankings people already hold. That is four game families, not a theorem about games. What does carry across them is the construction. The same analysis net handles a square grid, a non-square grid, frequency-weighted drains and hex adjacency, and the constraints can be full lines, fixed-length segments or shortest paths without the lemma changing. And the incidence matrix is read twice — continuously for the equilibrium, discretely for the Groth16 constraints on game-state transitions — which is where [Earned Compression](/posts/earned-compression) starts from. The paper, the tests and the ODE solver are all in [pflow-rs](https://github.com/pflow-xyz/pflow-rs). ## What Changed For readers of the February version: 1. **The PDF link.** Both download links pointed at a release asset that no longer resolves. They now point at the PDF in the repository. 2. **Tests linked.** Each row of the validation table names the test that asserts it. The numbers in this post were checked against those tests and the paper source by reading them; the suite was not re-run for this revision. 3. **"Largest among all tested games."** The Hex ratio of 16:1 was described that way, a few paragraphs below a table showing poker at 32:1. It is the largest of the board games. The paper carries the same sentence. 4. **Poker is an input.** The drain counts are chosen, so the poker result confirms the weighted-drain equilibrium, not the hand ranking. 5. **Scope.** "Provably exact" now says what is proved (the equilibrium) and what isn't (that drain count is strategic value). The ZK claim is narrowed to the games that have circuits, and the Hex reading is marked as ours. --- *Related: [The Incidence Reduction](/posts/integer-reduction) · [ZK Hold'em](/posts/zk-holdem) · [Zero-Knowledge Proofs for Petri Nets](/posts/zk-petri-nets) · [Earned Compression](/posts/earned-compression)* --- # Code-to-Flow: Turn Anything into a Petri Net - URL: https://blog.stackdump.com/posts/code-to-flow - Date: 2026-03-05 - Tags: petri-net, llm, sim-pflow-xyz, codegen, state-machine, code-visualization - Summary: Paste source code in any language and Claude returns a Petri net that a validator checks for structure and deadlocks — what is checked, what isn't, and the faithfulness test not yet run. # Code-to-Flow: Turn Anything into a Petri Net Most codebases have state machines hiding in plain sight — buried in switch statements, handler chains, and nested conditionals. Code-to-flow — the `sim_code_to_flow` tool on [sim.pflow.xyz](https://sim.pflow.xyz)'s MCP server — extracts them as Petri nets. ![Code-to-Flow Pipeline](/images/code-to-flow/pipeline.svg) Hand it source code — Go, Python, JavaScript, Rust, Solidity, or anything else; the language is only a hint — and Claude reads it, picks out states and actions, and returns a Petri net as JSON: places, transitions and arcs that can be fired, simulated, checked for deadlocks and replayed against a log of the running program, which is what a flowchart can't do. The extraction is a single call to Claude at temperature 0.2 under a fixed system prompt (`pkg/generator/codetoflow.go` in sim's source). The prompt asks for the structural rules — every place on at least one arc, every transition with an input and an output arc, the model kept minimal, essence over implementation detail — but a prompt only asks. What checks the answer is the validator (`pkg/validator/validator.go`): it rejects a model with no places or no transitions, an unconnected place or transition, or an arc naming something undefined, then walks the reachability graph (capped at 10,000 states) and attaches a warning for each deadlock it finds. A deadlock is a warning rather than a failure, because a workflow that has finished is a deadlock too. The validator also computes boundedness and liveness, but the code-to-flow reply carries none of this — a model that validates is stored and its id returned — so for deadlocks and boundedness, run `sim_verify` on the stored model (bounded and deadlock-free are its defaults). ## Focus Modes The same code looks different through different lenses: ![Analysis Modes](/images/code-to-flow/analysis-modes.svg) - **Control Flow** — function call sequences, branching, error handling, loops - **State Machine** — state variables, enum transitions, FSM patterns, lifecycles - **Resources** — connection pools, inventory, bounded buffers, capacity limits - **Concurrency** — goroutines/threads, channels, mutexes, producer-consumer patterns Leave the focus unset and the tool asks for control flow; set another and we see the same code from a different angle. We call these lenses loosely. A focus mode is one line in a prompt and has no *put*. The lens in the categorical sense — a get/put pair between a state space and a view of it — is the analysis net in [The Incidence Reduction](/posts/integer-reduction#the-net-as-a-lens), and whether extractions of the same code under two focus modes are related by anything like that is untested. ## Example: Order Processing A simple order handler: ```go func processOrder(order Order) error { if err := validate(order); err != nil { return err } charge(order.Payment) ship(order.Address) notify(order.Customer) return nil } ``` We have no saved run of this example — no golden output, no test — so what follows is the shape of the answer, not a recorded result, and two runs need not agree. The answer this post first reported had six places (`pending`, `validated`, `charged`, `shipped`, `completed`, `failed`) and four transitions (`validate`, `charge`, `ship`, `notify`). As stated, that can't be quite right: with four transitions, `failed` either has no arc, which the validator rejects as an unconnected place, or `validate` writes to `validated` and `failed` at once. The early return needs a transition of its own (a `reject` from `pending` to `failed`, say). The code says nothing at all about a failed `charge` — its result is never checked — so a faithful extraction has no branch there either; the net shows that gap as an absence, and no deadlock check will flag it. Store the result and a scenario run shows flow through the pipeline. ## Existing Code, Not Blank Canvas [pflow.xyz](https://pflow.xyz) is for drawing nets from scratch. Code-to-flow solves the opposite problem: there's already a codebase and we want to see what state machine it encodes. The LLM handles the pattern recognition — identifying states, transition triggers, resource consumption — and produces a model that makes the implicit structure explicit. It also works as a comprehension tool. Paste unfamiliar code, get back a Petri net that shows the flow. The visual model is often easier to reason about than reading the source. ## Access **MCP tool** — `sim_code_to_flow` on sim.pflow.xyz's MCP server (`https://sim.pflow.xyz/mcp`), from Claude Desktop, Claude Code, Cursor or any other MCP client. It takes `code`, an optional `language` hint, a `focus` and a `name`. When the answer validates, it is stored as a new model you own and the tool returns its id; when it doesn't, the tool returns the raw JSON and the validation errors, so you can fix the model and store it by hand with `sim_create_model`. Storing a model needs sign-in. The tool is the only surface: sim.pflow.xyz mounts no code-to-flow page or HTTP endpoint, and a deployment with no LLM provider configured refuses the tool. ## Closing the Loop Previously the pflow pipeline ran one direction: ``` design model → generate code → deploy ``` Code-to-flow reverses it: ``` existing code → extract model → analyze → improve → regenerate ``` Take a legacy system, extract the state machine as a Petri net, check its properties (bounded? live? deadlock-free?) by exhaustive reachability — a proof only when the graph finishes under the 10,000-state cap — then generate a clean implementation from the checked model. From there the model is the source of truth, and Claude is the bridge between informal code and formal net. We have not yet run that loop end to end on a real legacy codebase. Try it: connect an MCP client to `https://sim.pflow.xyz/mcp` and call `sim_code_to_flow`. ## Open question The step nothing checks yet is the first one. The validator says the net is well formed; it can't say the net is *this code's* net. Claude's reading of the source is the only link, and we have not compared an extraction against what the program actually does. sim already has the instrument: `sim_conformance` replays an event log against a stored model and reports fitness and precision, naming the activities that could not fire. So the test is buildable — log the running code, replay the log against the extracted net, count what fails to replay. Until it runs, an extracted model is a well-formed reading of the code, and "source of truth" starts only at the point where we decide to treat it as one. ## What Changed For readers of the March version (revised 2026-10-16, against the source; nothing re-run): 1. **What the validator checks.** The post said the output is validated for liveness and that the prompt *enforces* structure. The prompt only asks; the validator checks connectivity and reports deadlocks, and the code-to-flow response does not include boundedness or liveness (`sim_verify` does). 2. **The order example.** Its six places and four transitions were never recorded and don't fit together — `failed` needs a transition of its own. It is now presented as the shape of an answer, not a result, and the "what if `charge` fails" check is gone: the source has no failure branch for `charge`, so there is nothing for a deadlock check to find. 3. **Lenses.** Focus modes were tied to the categorical lens of the incidence-reduction post; that link is now marked as loose and untested. 4. **Where it runs** (2026-10-23). Code-to-flow is now the `sim_code_to_flow` MCP tool on sim.pflow.xyz. The web page and HTTP endpoint this post described are gone; the tool stores a model that validates instead of returning it, and its focus defaults to control flow rather than auto-detect. --- *Related: [Introducing sim.pflow.xyz](/posts/petri-pilot) · [Declarative Differential Models](/posts/declarative-differential-models) for the ODE theory · [Small Models > LLMs](/posts/small-models-not-llms), where code-to-flow is one stage of the pipeline · [The Model Is the App](/posts/model-is-the-app)* --- # Small Models > LLMs - URL: https://blog.stackdump.com/posts/small-models-not-llms - Date: 2026-03-06 - Tags: petri-net, modeling, formal-methods, llm, sim-pflow-xyz - Summary: Why executable formal models matter more with LLMs around, not less — and what the LLM is and isn't trusted with when a Petri net constrains it. # Small Models > LLMs "Model" now mostly means a large language model. The models this blog is about are the other kind — a Petri net with a few dozen places that we can execute directly, read on one screen and check properties of — and the claim here is that they matter more with LLMs around, not less, because an LLM is most useful when one of them is constraining it. ## Two kinds of model A model is an abstract representation of a process: behaviors (actions) plus attributes (data). A good model makes a system predictable and easier to understand. A great model reduces code complexity. LLMs are impressive at generating text and code, but we can't inspect their reasoning, prove properties of their behavior or compose them predictably. Small formal models like Petri nets make the opposite trade: | Property | LLMs | Petri Nets | |----------|------|------------| | Inspectable | No | Yes | | Composable | Awkward | Native | | Provable properties | No | Yes | | Executable | Via code gen | Directly | | Human-readable | Sometimes | At this size, yes | | Size | Billions of parameters | Dozens of nodes | ## An index card Petri nets sit at a rare intersection: visual enough for people to reason about, mathematical enough for analysis, directly executable, and compositional. The [coffee shop model](/posts/coffeeshop-model) is a good size to look at. Here is one drink, copied from `NewInventoryNet` in go-pflow's [examples/coffeeshop/inventory.go](https://github.com/pflow-xyz/go-pflow/blob/main/examples/coffeeshop/inventory.go): ```go // Espresso: 18g beans, 30ml water, 1 cup net.AddTransition("make_espresso", "consume", 200, 150, nil) net.AddArc("coffee_beans", "make_espresso", 18, false) net.AddArc("water", "make_espresso", 30, false) net.AddArc("cups", "make_espresso", 1, false) net.AddArc("make_espresso", "beans_used", 18, false) net.AddArc("make_espresso", "water_used", 30, false) net.AddArc("make_espresso", "cups_used", 1, false) ``` The whole net is that pattern repeated — six drinks, a sugar transition and six refills, so 13 transitions over 18 places (stock, used and supply for each of six ingredients) — in one function. From that topology we get an ODE simulation for capacity planning: run with the file's own rates it says milk runs out first, not cups, and the [coffee shop post](/posts/coffeeshop-model) has the numbers and the correction. We also get conservation laws we can read straight off the paired arcs — every arc that takes *w* out of `coffee_beans` puts *w* into `beans_used`, and every refill moves tokens from `beans_supply` into `coffee_beans`, so stock + used + supply is constant for each ingredient. That was checked by reading the arcs; the example has no test that asserts it. Deadlock detection is the weakest of the three here. `AnalyzeInventoryReachability` runs on this net, but the `_used` accumulators make it unbounded, the search is capped at 1,000 states, and `TestInventoryReachability` only checks that the analysis completes. The [interactive café](https://sim.pflow.xyz/whatif/?id=3ab9e15cde5c02b463c31354) on sim.pflow.xyz is a smaller, separate net — three drinks and two baristas, order → make → serve, with espresso drawing 20g of beans rather than 18. The two haven't been reconciled. When we treat the **model as the artifact** rather than the code, the order of work changes: we sketch the topology, validate behavior with simulation, and extend monotonically. Code becomes a derived output. ## Analyzable properties Unlike LLM output, a Petri net comes with questions we can actually put to it. *Reachability* asks whether, from a given state, the system can get to a target state — is this workflow completable? *Boundedness* asks whether it stays within limits, whether a queue can overflow. *Liveness* asks whether the net can deadlock and under what conditions it halts. These are the failures that take production systems down, and on a net we can ask about them before writing implementation code — with the caveat the coffee shop already showed, that a net with accumulators or large markings runs exhaustive search out of room, and the question has to be asked of a bounded version. The ODE finds structure that isn't obvious by eye. In the [tic-tac-toe model](/posts/ode-signatures) the empty-board scores come out 1.27, 0.95 and 0.63 for center, corner and edge — the ratio 4 : 3 : 2, which is the number of win lines through each cell. No game heuristic is encoded; the ranking falls out of the topology. It is a strategic prior, though, not a strategy. Tested against exact minimax in August 2026, the plain ODE missed the fork and lost 7% of games as O, and it took declaring the opponent's policy as extra structure to make the evaluator minimax-equivalent ([the correction and its resolution](/posts/tic-tac-toe-model#correction-the-ode-was-counting-not-searching)). The missing piece turned out to be more declared model, not search. ## LLMs as tools for models What we've learned building the [pflow ecosystem](https://pflow.xyz) is that LLMs are most useful when formal structure constrains them — LLMs *for* models rather than LLMs *or* models. [What-if analysis](/posts/what-if-analysis) starts from a plain-English description of a business — a vet clinic, a coffee shop — and has the LLM draft the net; the simulation then does the part the LLM can't be trusted with, which is saying what a change in staffing or demand actually does. That line of work has since become [sim.pflow.xyz](/posts/model-is-the-app), where a model can be calibrated against your own data. [Code-to-flow](/posts/code-to-flow) runs the other way into a model: paste code in any language, Claude proposes places and transitions, and a validator checks the structure before the model is stored (`sim_code_to_flow` on sim.pflow.xyz). The LLM does the messy pattern recognition and the validator decides whether the output is a well-formed net — though well-formed is not the same as faithful to the code, and we haven't found a check for the second in that path. [sim.pflow.xyz](/posts/petri-pilot) takes the model the rest of the way: the same document is the analysis and the app, its declared views the screens and a click a firing. An earlier version of this post said Claude writes that application code. It doesn't: everything downstream of the model is derived deterministically, and the same model always gives the same results, while the LLM's job is designing the model (sim also runs as an MCP server for that). What the model constrains shows up in what the app can claim: published apps run sandboxed against the model's public API, so they cannot say anything the model doesn't ([The Model Is the App](/posts/model-is-the-app)). [The book](https://book.pflow.xyz) — twenty chapters and an epilogue — was written with LLM help, with the examples built on executable nets. The model is the checkpoint between human intent and machine execution, and it works as one because it is small enough for a person to read. ## Two pipelines The ecosystem now supports both directions: **Model-first** (design new systems): ``` pflow.xyz (edit model) --> sim.pflow.xyz (simulate, check) --> the model as app ``` **Code-first** (understand existing systems): ``` existing code --> code-to-flow (extract model) --> pflow.xyz (inspect/edit) --> generate new code ``` Both pipelines keep the model as the source of truth. The model is where humans verify correctness; code flows from it and back to it. ## What the LLM is trusted with Small models won't write your marketing copy, and LLMs won't prove your system is deadlock-free; the interesting question was always how they fit together. The arrangement we keep landing on puts the LLM at the edges — turning a description or a codebase into a candidate net — and keeps it out of the middle, where the net's firing rule, the validator and the ODE decide what's true. That is a design choice, not a measured result. This post has no number for how often an LLM-drafted net is wrong in ways the validator can't see, and that is the number that would say how much of the middle an LLM could eventually be trusted with. ## Try it - [pflow.xyz](https://pflow.xyz) — visual Petri net editor with ODE analysis - [code-to-flow](/posts/code-to-flow) — extract models from existing code (`sim_code_to_flow`) - [sim.pflow.xyz](https://sim.pflow.xyz) — run, compare and check models - [book.pflow.xyz](https://book.pflow.xyz) — learn the techniques - [go-pflow](https://github.com/pflow-xyz/go-pflow) — the Go library behind it all ## What Changed For readers of the March version (revised 2026-09-26): 1. **The coffee shop code was not real.** The snippet used a `cell`/`func`/`arrow` syntax that exists nowhere in the pflow repos, with names (`brew_espresso`, `beans`) that don't match the model. It is replaced with the actual Go from go-pflow, and the conservation and deadlock claims are now stated at the strength the example earns. 2. **ODE scores.** The ODE does not produce "4, 3, 2"; it produces 1.27 / 0.95 / 0.63, in that ratio. "The structure *is* the strategy" overstated it — the August tic-tac-toe correction showed the plain ODE is a strategic prior that misses tactics. 3. **Claude does not write the application code.** Everything downstream of the model is derived deterministically; the LLM designs models. "No hallucinated states" is replaced with what the app can actually claim (revised 2026-10-23, when the demos moved to sim.pflow.xyz). 4. **The book** has twenty chapters and an epilogue, not twelve. --- *Related: [The Model Is the App](/posts/model-is-the-app) · [Coffee Shop Model](/posts/coffeeshop-model) · [Tic-Tac-Toe Model](/posts/tic-tac-toe-model) · [Code-to-Flow](/posts/code-to-flow) · [Introducing sim.pflow.xyz](/posts/petri-pilot)* --- # Skip the Spreadsheet: What-If Analysis with Petri Nets - URL: https://blog.stackdump.com/posts/what-if-analysis - Date: 2026-03-08 - Tags: petri-net, what-if-analysis, llm, capacity, simulation - Summary: Describe your business to an LLM, get a simulation you can actually play with — adjust staffing, change demand, and watch the numbers move. Revised to say which vet-clinic results the tests actually check, and that the coffee shop runs out of milk, not cups. # Skip the Spreadsheet: What-If Analysis with Petri Nets Every business plan has a spreadsheet: revenue projections, cost estimates, break-even calculations. They look precise, and they're mostly fiction, because a spreadsheet can't model *flow*. Customers arrive, wait, get served, and leave. Staff clock in and out. Rooms fill up and empty. A spreadsheet gives you totals; a simulation gives you *bottlenecks*. ## The Experiment We gave an LLM a simple prompt: "Model a veterinary clinic's daily operations." No formulas, no programming — just a plain English description of how the clinic works. Patients arrive, get triaged, see a vet or a technician, maybe need surgery or X-rays, then go home. The LLM produced a [Petri net](https://pflow.xyz) — a kind of flowchart where tokens represent real things (patients, staff, rooms) and the rules enforce real constraints (you can't start surgery without a surgeon *and* a free operating room *and* a patient). Then we hit simulate. ## What Came Out The clinic has been rebuilt since the first version of this post. The current model, [vet-clinic on sim.pflow.xyz](https://sim.pflow.xyz/api/models/ae6ab5aeb95dd994bc561479), is 38 places and 42 transitions: two vets, three techs, one receptionist, four exam rooms, a surgery suite, a dental suite, one radiology room, six recovery kennels, and patients arriving at 6 an hour by default. Every service is two firings — `start_X` seizes the staff and the room, `finish_X` releases them — so how busy the vets are is a marking you can read rather than a number someone typed in. Walk-in clients who wait too long (about half an hour) give up and leave through `walked_out`, which is what makes "how many vets?" a question with an answer. What the model is held to is six fitness gates in `vetclinic_fitness_test.go`, in sim's source (`pkg/runtime/sim`; the repository is private). Each runs the shipped model unmodified — an 8-hour day, 30 stochastic realizations, a fixed seed — and asserts a direction rather than a number, so a rate tweak moves what the gates expect instead of breaking them. Patients are conserved: everyone who arrived is discharged, walked out, diverted, or still inside at close. Staffing has a knee: cutting to one vet raises walkouts, and going from two vets to four buys at most half of what the second vet bought. The clinic as sized is staff-limited, not room-limited — no room pool outranks every staff pool in the contention ranking. Closing the surgery-day gate (a read arc, so a scenario can flip it) stops procedures without stopping wellness exams. An emergency wave raises routine walkouts and diverts emergencies, and adding a vet and a tech buys some of that back. Those are the tests in the repo; I haven't re-run them for this revision. A later pass that ranks every knob by its measured effect ([The Capacity Paradox](/posts/capacity-paradox)) found the same shape from the other side: 8 of the clinic's 11 specialty rooms and pieces of equipment are inert, and front-of-house staffing — vets, techs, reception — is what binds. That contradicts two things the first version said. It claimed a third vet helps only if there are also enough exam rooms, and that the receptionist is never the bottleneck; the gates and the diagnose pass point the other way on both. It also gave figures — two vets comfortable at 8 patients an hour, queues building at 12 and exploding at 15, the X-ray room idle 70% of the time, a surgery day costing 4 walk-in appointments — that have no test or recorded run behind them, from a model that no longer exists in that form, so they're withdrawn here rather than restated. The model does still carry the surgery-day mechanism that version described: recovery kennels are seized when a procedure *finishes*, so a full recovery ward backs up into the surgery suite. No gate measures how often that happens at the default rates. The original demo page also had a financial dashboard — revenue, costs, margin — computed in the browser from the ODE solution, on its Simulation tab. That tab used the older 33-place in-browser model rather than the one the What-If tab and the tests used, and its own TODO said so plainly: "the two tabs describe different clinics." That page is gone; the [what-if console](https://sim.pflow.xyz/whatif/?id=ae6ab5aeb95dd994bc561479) runs the 38-place model the gates test. If you kept a P&L figure from the old page, it came from the other clinic. Try it yourself: [the vet clinic on sim.pflow.xyz](https://sim.pflow.xyz/whatif/?id=ae6ab5aeb95dd994bc561479) ## A Simpler Example: The Coffee Shop Not every model needs 42 transitions. A coffee shop fits on a napkin: five ingredients (beans, water, milk, syrup, cups), five drinks (espresso, americano, latte, cappuccino, mocha), and a recipe per drink written as arc weights — an espresso is 18g of beans, 30ml of water and one cup. Cups are the smallest stock, and the first version of this post said they run out first. They don't. Re-running the model with its own constants ([Coffee Shop Model](/posts/coffeeshop-model)) says milk goes first, at roughly 22 minutes on a linear estimate, because three drinks each draw 120–180ml of milk against a single cup. Double the rush and milk goes in about half the time; run a latte promotion and it goes sooner still (~10 minutes), with beans moving up behind it. That reversal is the argument for simulating rather than eyeballing — the stock that looked like the bottleneck in a column of numbers wasn't, and the arc weights said so. The net above is the Go version, [`go-pflow/examples/coffeeshop`](https://github.com/pflow-xyz/go-pflow/tree/main/examples/coffeeshop). The interactive [café on sim.pflow.xyz](https://sim.pflow.xyz/whatif/?id=3ab9e15cde5c02b463c31354) is a different, staffed three-drink net — baristas and pantry stock in contention — so its numbers won't match these. ## Iterating in Conversation Petri nets are intuitive enough for an LLM to generate and formal enough to simulate precisely. "Patients arrive, wait for a room, see a vet" maps directly to places and transitions, so you can iterate conversationally: > "What if we added a second surgery room?" > "What happens during flu season when arrivals double?" > "Show me the day if we cut one technician." Each change adjusts the model, and each run gives you the consequence of the rules you described at the rates you gave them. That's exactly as trustworthy as those rates: the vet clinic's service times are declared in the model (~20 minutes for a wellness exam, ~60 for a spay), not measured from a real clinic. ## Spreadsheets vs. Simulations | | Spreadsheet | Simulation | |---|---|---| | **Models** | Totals and averages | Flow and constraints | | **Shows** | End-of-day numbers | How the day unfolds | | **Reveals** | Cost structure | Bottlenecks | | **Answers** | "How much?" | "What if?" | | **Updates** | Manually | Change one parameter, re-run | A spreadsheet tells you what you'll make if you see 40 patients. A simulation can tell you whether two vets and four exam rooms can see 40 patients at all, and which pool runs out first when they can't. Both demos are live on sim.pflow.xyz ([The Model Is the App](/posts/model-is-the-app)): the [vet clinic](https://sim.pflow.xyz/whatif/?id=ae6ab5aeb95dd994bc561479) lets you change staffing, arrival rate, patience and day length and inject disruptions; the [café](https://sim.pflow.xyz/whatif/?id=3ab9e15cde5c02b463c31354) puts baristas and pantry stock in contention. The models are built with [pflow](https://pflow.xyz), an open-source Petri net toolkit, on [go-pflow](https://github.com/pflow-xyz/go-pflow), and [The Capacity Paradox](/posts/capacity-paradox) points it at a software team's own backlog instead of a shop floor. ## What Changed For readers of the March version (revised 2026-10-07): 1. **Coffee shop.** "Cups run out first" was wrong; milk does, by a wide margin. The scenario claims now match the [corrected coffee shop post](/posts/coffeeshop-model). 2. **Vet-clinic numbers withdrawn.** The 8/12/15 patients-per-hour thresholds, the 70%-idle X-ray room, the 4 lost walk-ins and the 33-patient ceiling had no test or recorded run behind them and came from a model since rebuilt. Replaced with what the fitness gates check. 3. **Two claims reversed.** Exam rooms are not what limits a third vet, and reception is among the staff pools that bind, per the gates and the [diagnose pass](/posts/capacity-paradox). 4. **"32 moving parts"** described the old 32-transition model; the current one is 38 places and 42 transitions. The page's financial dashboard still runs on the old model, and the post now says so. 5. **The demos moved** (2026-10-23) to sim.pflow.xyz. The old page and its financial dashboard are gone, and the interactive coffee shop is now a different, staffed net. --- *Related: [Coffee Shop Model](/posts/coffeeshop-model) · [The Model Is the App](/posts/model-is-the-app) · [The Capacity Paradox](/posts/capacity-paradox) · [Declarative Differential Models](/posts/declarative-differential-models) · [Small Models > LLMs](/posts/small-models-not-llms) · [Introducing sim.pflow.xyz](/posts/petri-pilot)* --- # Symmetric Monoidal Categories: The Structure Underneath - URL: https://blog.stackdump.com/posts/symmetric-monoidal-categories - Date: 2026-03-09 - Tags: petri-net, category-theory, composition, pflow, symmetric-monoidal, open-games - Summary: A Petri net generates a free symmetric monoidal category — places give the objects, transitions the generating morphisms. What that theorem buys for composition, analysis and proofs across this blog, and which of the connections are still a shape match rather than a checked result. # Symmetric Monoidal Categories: The Structure Underneath *Prerequisites: [The Token Language](/posts/token-language) for the four generators, and [The Incidence Reduction](/posts/integer-reduction) for the lens that the second half of this post leans on.* Across this blog we've built Petri nets, analyzed them with ODEs, proved transitions in zero knowledge, composed them via typed links, and read strategic values through lenses — and the techniques carry from one model to the next. The structure underneath that is the **symmetric monoidal category** (SMC). A Petri net doesn't just get *modeled by* an SMC — it *generates* one, freely. Every net we've built, guards set aside, is a set of generators for a category where parallel composition is the monoidal product and the wiring between components respects symmetry. ## Transitions as Morphisms A Petri net transition consumes tokens from input places and produces tokens into output places. In categorical terms, this is a morphism — a map from domain to codomain: ![Petri Net Transition as Morphism](/images/symmetric-monoidal-categories/petri-net-as-morphism.svg) The transition `t` with inputs `{p1, p2}` and outputs `{q1, q2, q3}` is a morphism: ``` t : p1 ⊗ p2 → q1 ⊗ q2 ⊗ q3 ``` The `⊗` is the monoidal product — it means "these things exist side by side." Two tokens in separate places aren't combined or merged; they coexist independently. The monoidal product is how Petri nets express concurrency: `p1 ⊗ p2` means both places are marked, and both tokens are available simultaneously. Places generate the objects; the objects themselves are multisets of places, so a marking is an object in the free commutative monoid on the places. Transitions generate the morphisms. (Keep the level straight: a single place is a generator, not the general object, and a single transition is a generator, not the general morphism. Later, when we compose whole nets along boundaries in OPetri, the nets become the morphisms — that is a different category, one level up.) ## Two Kinds of Composition Every category has composition of morphisms. A monoidal category adds a second operation: the monoidal product. These correspond exactly to the two ways we compose Petri nets. ![Two Composition Modes](/images/symmetric-monoidal-categories/composition-modes.svg) **Sequential composition** (`f ; g`): the output places of transition `f` become the input places of transition `g`. Tokens flow through. This is ordinary morphism composition — the same operation that makes categories useful for modeling processes with stages. **Parallel composition** (`f ⊗ g`): two transitions sit side by side with no shared places. They fire independently. This is the monoidal product — it expresses concurrency without interaction. The **symmetry** is the swap map `σ : A ⊗ B → B ⊗ A`. It says we can reorder the components of a parallel composition without changing the behavior. In Petri net terms: the order we list the places doesn't matter. A net with places `{stock, orders}` is the same net as `{orders, stock}`. This is exactly the commutativity property we noted in [Categorical Net Types](/posts/categorical-net-types#algebraic-properties) — schema order doesn't matter because the composition is symmetric monoidal. ## The Free SMC on a Petri Net The formal result, due to Sassone (1995) and Meseguer-Montanari (1990), is: > A Petri net generates a free symmetric monoidal category whose objects are multisets of places and whose morphisms are equivalence classes of transition firings. "Free" means nothing extra is imposed — the only equations are the ones forced by the SMC axioms (associativity, unitality, symmetry). The category captures exactly the net's behavior and nothing more. The hypothesis matters: this is a theorem about *ordinary* nets, where every arc consumes or produces. Montanari and Rossi (1995) showed that nets with read arcs — contextual nets — fall outside it, which is why guards keep turning up below as the exception ([two boundaries](/posts/category-settle#two-boundaries-not-one)). What this gives us: **Associativity.** Composing transitions is associative: `(f ; g) ; h = f ; (g ; h)`. We can bracket firing sequences however we want. This is why [event sourcing](/posts/texas-holdem-model) works — the fold over events doesn't depend on how we chunk the replay. **Unit.** The identity morphism on a place is "do nothing — the token stays." Every place has an identity, and composing with it changes nothing. This is why idle places don't affect analysis. **Symmetry.** The swap `σ : A ⊗ B → B ⊗ A` is natural. Reordering places is an isomorphism, not a transformation. This is why [ODE signatures](/posts/ode-signatures) are invariant under reordering: go-pflow's [`TestLargeScaleShuffleAnalysis`](https://github.com/pflow-xyz/go-pflow/blob/main/examples/tictactoe/metamodel/equivalence_test.go) shuffles the places, transitions and arcs of the tic-tac-toe model 100 times and requires the ODE trajectories to match the original's, within the solver's tolerance, every time (not re-run for this revision). **Bifunctoriality.** Sequential and parallel composition interact correctly: `(f₁ ⊗ f₂) ; (g₁ ⊗ g₂) = (f₁ ; g₁) ⊗ (f₂ ; g₂)` when the wiring matches. In net terms: firing two independent transitions and then two more independent transitions is the same as firing each pair sequentially. This is why concurrent execution and sequential replay produce the same result. ## Where We've Already Used This Most of the earlier posts lean on this structure without naming it. **[The Token Language](/posts/token-language)** defines `cell`, `func`, `arrow`, `guard`. The first three are generators of a free SMC: cells generate the objects, funcs the morphisms, and arrows give each func its domain and codomain. Guards are the exception. A guard is a predicate on the current marking — the right context R, recomputed every step — and it sits outside the free SMC rather than inside it, for the Montanari–Rossi reason above. The [four-term DSL](https://github.com/pflow-xyz/go-pflow/tree/main/tokenmodel/dsl) in go-pflow is a syntax for writing those generators plus that layer of predicates. **[Categorical Net Types](/posts/categorical-net-types)** defines five specializations. Each net type is a sub-SMC with additional structure — WorkflowNet tracks a control-flow cursor (one cursor's runs are paths, closer to a free category than a free SMC), ResourceNet adds conservation (P-invariants, `yᵀC = 0`), GameNet combines both. The typed links (EventLink, DataLink, TokenLink, GuardLink) are what that post treats as morphisms between sealed schemas. Whether each link type preserves the structure its endpoints demand, as a functor between the sub-SMCs would have to, is not something we've checked — and a GuardLink carries a guard, which puts it on the contextual side. **[Declarative Differential Models](/posts/declarative-differential-models)** define the ODE system by reading the incidence matrix. C (places × transitions) is the linear map ℤ^T → ℤ^P sending each transition to post − pre: the free SMC's generators, abelianised. It lives on the integers rather than on the free commutative monoids, because its columns have negative entries. Reading mass-action kinetics as a functor from the discrete SMC to continuous dynamics is the natural next claim, but we haven't pinned down the target category, so for now it is a shape and not a result. **[The Incidence Reduction](/posts/integer-reduction)** works because the decoupled ODE system respects the monoidal product structure. Each accumulator's equation `ẋᵢ = 1 - nᵢxᵢ` is independent — the product decomposition of the lens mirrors the monoidal product of the SMC. No information leaks between components because the monoidal product *means* independence. **[ZK Proofs](/posts/zk-petri-nets)** verify transition firings — morphism validity — without revealing the marking. The Groth16 circuit encodes the incidence matrix (the SMC's morphism structure) as arithmetic constraints. The proof says "this morphism was applied to a valid object and produced a valid object" without disclosing which object. Privacy is a property of the morphism, not the objects. **[ODE Signatures](/posts/ode-signatures)** are invariant under isomorphism in the SMC. Two nets with different labels but the same wiring produce identical ODE solutions because isomorphic nets generate isomorphic free SMCs. The signature is a categorical invariant — it depends only on the isomorphism class, not the representation. The shuffle test above checks the reordering half of that; the ODE Signatures post describes the relabeling half, a Go struct and a pflow.xyz JSON-LD file with different place names giving the same trajectories. ## The Lens Connection The [lens structure](/posts/integer-reduction#the-net-as-a-lens) we identified in the incidence reduction has a precise SMC interpretation. A lens in a monoidal category is a pair of morphisms — Get and Put — satisfying coherence laws. For the analysis net: - **Get** is a morphism from the state space (the free commutative monoid of markings) to the value space (the vector of strategic values). It reads, for each place, the number of still-reachable drain transitions off C — a count, not a linear map of the marking. - **Put** is a morphism that updates the marking and recomputes. The decoupling lemma says this decomposes as a product of independent updates — one per place — which is the monoidal product of individual lenses. For this net the analysis lens is a tensor of per-place lenses, `Lens(A ⊗ B) ≅ Lens(A) ⊗ Lens(B)`, and the reason is specific to it: each accumulator obeys `ẋᵢ = 1 − nᵢxᵢ`, and no two of those equations share a variable. That is why dynamic evaluation works — updating one position's marking doesn't touch another's lens. It is not a general fact about lenses in an SMC. Where resources are shared, as in the [coffee shop](/posts/coffeeshop-model), the equations couple and the ODE has to [simulate rather than count](/posts/integer-reduction#the-boundary-between-counting-and-simulation). ## Open Games and Compositional Evaluation The lens decomposition we found in the incidence reduction has a counterpart on the game-theory side: the *open games* of Ghani, Hedges, Winschel and Zahn (*Compositional Game Theory*, 2018). In open games, a game is a lens-like gadget with a forward pass that carries players' strategies to outcomes and a backward pass that propagates utilities back to decision points. Open games are the morphisms of a symmetric monoidal category, so they compose sequentially (players move in order) and in parallel (players move independently) — the two SMC operations. The paper shows that standard games can be written this way while keeping the same Nash equilibria and off-equilibrium best responses. Our construction has the same shape from the Petri net side. Get maps markings to strategic values; Put updates a marking and recomputes; for the analysis net the lens splits into one independent lens per place, much as independent players give independent games. The match is in shape, and it stops in specific places. Their objects are types of play, ours multisets of places, and both live in an SMC. Their morphisms are games, which carry a forward and a backward pass; our transitions don't — the backward direction lives in the analysis lens, not in the transition. Both decompositions run along `⊗`, but we haven't shown they are the same theorem. Riley's *Categories of Optics* (2018) is the general framework in which both could be stated side by side. The game models in this blog — [tic-tac-toe](/posts/tic-tac-toe-model), [Hold'em](/posts/texas-holdem-model) — look like open games built from Petri net transitions: reading the ODE's fixed point plays the role of an optic's backward pass, and updating a marking and recomputing is the lens's Put. We have not written either model as an open game and compared equilibria, so "they agree" would claim more than we've checked. Tic-tac-toe is also a caution about what the backward pass computes: the fixed point counts live lines rather than searching, and [mis-ranks a forced tactic](/posts/tic-tac-toe-model#correction-the-ode-was-counting-not-searching) until the opponent's policy is [declared as structure too](/posts/tic-tac-toe-model#resolution-declare-the-opponent-too). Hedges and coauthors started with games and discovered they form an SMC of optics. We started with Petri nets and discovered the analysis produces optics. I don't think that's a coincidence. Any system where independent components evaluate independently will produce this pattern, because that's what the monoidal product *means*. ## The Ecosystem as an SMC ![The pflow Ecosystem](/images/symmetric-monoidal-categories/blog-as-smc.svg) Step back and look at what we've built across this blog. The layers compose: **Theory** (objects): [DDM](/posts/declarative-differential-models), [Token Language](/posts/token-language), [Net Types](/posts/categorical-net-types), [JSON-LD](/posts/json-ld-declarative-infrastructure) — these are the generating objects of the ecosystem's SMC. **Models** (morphisms, in the OPetri sense — whole nets between boundaries): [Tic-tac-toe](/posts/tic-tac-toe-model), [coffee shop](/posts/coffeeshop-model), [Hold'em](/posts/texas-holdem-model), [enzyme kinetics](/posts/enzyme-kinetics-model), [sudoku](/posts/sudoku-petri-net-model) — each is a morphism built from the theory generators. **Analysis** (functors, loosely): [Incidence reduction](/posts/integer-reduction), [ODE signatures](/posts/ode-signatures), P-invariants — maps from nets to their properties. **Proofs** (natural transformations, loosely): [ZK proofs](/posts/zk-petri-nets), [lenses](/posts/integer-reduction#the-net-as-a-lens), sealed invariants — meant to hold uniformly across models rather than per model. Each layer composes with the ones above and below it. Models compose via typed links. Analysis composes via functor composition. Proofs compose via vertical composition of natural transformations. The whole stack looks like an SMC of SMCs — a 2-category, if you squint. I'm describing the shape, not claiming I've checked the coherence laws, or even that each analysis map preserves `⊗`. Nobody designed the blog this way; the posts accumulated one at a time, each solving a specific problem. What the shape predicts, and what we can point to, is transfer between posts. The same ODE machinery runs on tic-tac-toe and on poker — though on poker it has to [simulate rather than count](/posts/integer-reduction#the-boundary-between-counting-and-simulation). The [ZK circuit](/posts/zk-petri-nets) that proves tic-tac-toe transitions is parameterised by topology constants rather than by the game. The EventLinks that wire order processing to inventory in the [net types example](/posts/categorical-net-types#example-order-system) are not specific to orders or inventory. ## Where the structure pays rent Here is what the structure buys in practice, and how much of each is actually established. **Models compose without surprises**, as far as the ordinary arcs go. In the [CompositeNet](/posts/categorical-net-types#compositenet-the-category) order example, adding EventLinks between a WorkflowNet and a ResourceNet leaves both seals — mutual exclusion, conservation — intact. A GuardLink is different: it adds a guard, a contextual arc, which is exactly where the free structure stops ([two boundaries](/posts/category-settle#two-boundaries-not-one)), so monotonicity there has to be argued case by case rather than read off the SMC axioms. **Analysis transfers across domains** because the [incidence reduction](/posts/integer-reduction) reads only C, so it runs the same way on games, resources and biochemistry whatever the tokens represent. Calling it a functor is a hope: it maps nets to values, but what composition of nets goes to on the value side is unchecked, and once resources couple, the counting shortcut fails and the ODE has to simulate. **Proofs are generic** because the [ZK circuit](/posts/zk-petri-nets) encodes the incidence matrix, not the application. Swapping topology constants gives proofs for a different game, a different workflow, a different token standard. "Natural transformation" names that uniformity by analogy; we haven't proved it is one. **The ODE keeps disjoint components independent**: two nets side by side give two uncoupled ODE systems, which is the monoidal product doing its job under mass-action kinetics. The [decoupling lemma](/posts/integer-reduction#absorbing-the-search-tree) is a narrower result than that. It is proved in the [paper](/posts/integer-reduction-paper) for the analysis net, where the play transitions are catalytic and each accumulator has its own drains, and it is not a theorem about monoidal functors in general. **Lenses decompose cleanly** for the same reason: each place gets its own lens, composed in parallel, because the per-place equations share no variables. [Dynamic evaluation](/posts/integer-reduction#dynamic-evaluation) updates one lens without touching the others. ## What Changed For readers of the March version *(revised 2026-10-01)*: 1. **Hypotheses.** The free-SMC theorem is now stated for ordinary nets, with the Montanari–Rossi exception, and guards are placed outside it — the token-language guard and the GuardLink are contextual, not generators. 2. **Levels.** Typed links are morphisms between sealed schemas in the net-types post, not "functors between sub-SMCs"; isomorphic nets generate isomorphic SMCs rather than being "isomorphic objects". C is a map ℤ^T → ℤ^P, not a map between the free commutative monoids. 3. **Lens and open games.** `Lens(A ⊗ B) ≅ Lens(A) ⊗ Lens(B)` is specific to the decoupled analysis net, not a general SMC fact. The open-games correspondence is a shape match; transitions have no backward pass, and no model here has been written as an open game. Points to the tic-tac-toe correction on what the fixed point computes. 4. **Claims scoped to evidence.** "Mass-action is a monoidal functor", "the incidence reduction is a functor", "the circuit is a natural transformation" and "monotonicity follows from the SMC axioms" are now stated as analogies or narrowed to what the linked posts show. The 100-shuffle claim now links the go-pflow test that runs it. 5. **References.** Hedges citation corrected to the *Compositional Game Theory* paper with Ghani, Winschel and Zahn (it was cited as a 2018 PhD thesis of that title); Riley's *Categories of Optics* is an arXiv preprint, not a Cambridge MSc thesis; Master's paper is *Petri Nets Based on Lawvere Theories* (MSCS 2020, first posted as *Generalized Petri Nets*); the Baez–Stay note no longer says it covers Petri nets. ## References - **Meseguer, J. & Montanari, U.** (1990). *Petri Nets are Monoids.* Information and Computation, 88(2). The original proof that the firing sequences of a Petri net form a free commutative monoidal category. - **Sassone, V.** (1995). *On the Category of Petri Net Computations.* TAPSOFT. Refines the Meseguer-Montanari construction to symmetric monoidal categories with the correct notion of equivalence. - **Baez, J.C. & Stay, M.** (2011). *Physics, Topology, Logic and Computation: A Rosetta Stone.* New Structures for Physics, Springer. Makes the analogies between physics, topology, logic and computation precise using closed symmetric monoidal categories — the general setting, not a Petri-net paper. - **Fong, B.** (2015). *The Algebra of Open and Interconnected Systems.* PhD thesis, Oxford. Formalizes the composition of open systems (including Petri nets) via decorated cospans in a symmetric monoidal category. - **Master, J.** (2020). *Petri Nets Based on Lawvere Theories.* Mathematical Structures in Computer Science, 30. First posted to arXiv in 2019 as *Generalized Petri Nets*. Generalizes Petri nets via Lawvere theories, covering pre-nets, integer nets and elementary net systems in one framework. - **Montanari, U. & Rossi, F.** (1995). *Contextual Nets.* Acta Informatica, 32(6). Read arcs as contextual dependencies; nets with them do not generate the free SMC. - **Ghani, N., Hedges, J., Winschel, V. & Zahn, P.** (2016, revised 2018). *Compositional Game Theory.* [arXiv:1603.04641](https://arxiv.org/abs/1603.04641), submitted to LICS 2018. Open games as the morphisms of a symmetric monoidal category, with standard games represented without changing their Nash equilibria — the game-theoretic parallel to our lens decomposition, by shape. - **Riley, M.** (2018). *Categories of Optics.* [arXiv:1809.00738](https://arxiv.org/abs/1809.00738). The general theory of lenses and optics in monoidal categories. For the net type taxonomy: [Categorical Net Types](/posts/categorical-net-types) For the lens interpretation: [The Net as a Lens](/posts/integer-reduction#the-net-as-a-lens) For the incidence matrix as linear map: [The Incidence Reduction](/posts/integer-reduction) For the ZK circuit encoding: [Zero-Knowledge Proofs for Petri Nets](/posts/zk-petri-nets) For three independent analyses converging on the same boundary — the ρ boundary, inside C: [Earned Compression](/posts/earned-compression) For settlement networks as a sub-SMC of OPetri, and for where the free structure stops (contextual arcs): [The Category Settle](/posts/category-settle) For what the SMC encoding loses — why flattening into morphisms erases the tense boundary between past and future: [The Zipper Whose Hole Is a Universe](/posts/tense-type-theory) *See also [Petri Nets as a Universal Abstraction](https://book.pflow.xyz) for the full treatment of these ideas.* --- # Tropical Petri Nets - URL: https://blog.stackdump.com/posts/tropical-petri-nets - Date: 2026-03-10 - Tags: petri-net, tropical-geometry, neural-networks, category-theory - Summary: Timed event graphs are max-plus linear and ReLU networks are tropical rational, so the two meet over the tropical semiring. This post covers what those two theorems give us, what pflow-tropical checks in code, and how far the Petri-net/neural-net analogy has actually been earned. # Tropical Petri Nets Petri nets, ReLU neural networks and tropical algebra meet at one operation, `max`. A timed event graph fires a transition when the last of its inputs arrives, which is a max over arrival times, and a ReLU unit is `max(0, x)`. Each side has a published theorem behind it — Baccelli et al. for event graphs, Zhang, Naitzat and Lim for ReLU networks — and the two theorems land in the same algebra. The code that checks our end of this is [`pflow-tropical`](https://github.com/pflow-xyz/pflow-rs/tree/main/crates/pflow-tropical), a crate in pflow-rs. The connection between nets and networks reaches further than either theorem does, so this post marks where the theorems stop. ## A Different Arithmetic In **tropical arithmetic**, the sum of two numbers is their maximum, and the product of two numbers is their ordinary sum: ![Classical vs Tropical Arithmetic](/images/tropical-petri-nets/tropical-arithmetic.svg) The **tropical semiring** (ℝ ∪ {−∞}, max, +) keeps associativity, commutativity and distributivity, with −∞ playing zero and 0 playing one. What it gives up is subtraction — nothing undoes a max — which is why it is a semiring and not a ring. It shows up wherever optimization meets discrete structure: shortest paths, scheduling, phylogenetics, and, as below, both Petri nets and neural networks. ## Petri Nets Speak Tropical Consider a timed Petri net: each place holds tokens, each transition fires when *all* its input places are ready. The key word is **all**. A transition with three inputs doesn't fire when the first token arrives, or the second — it waits for the *last* one. The firing time is the **maximum** of the arrival times. That `max` is tropical addition. After firing, a transition deposits tokens in output places. The arrival time at an output place is the firing time *plus* the transition's duration. That `+` is tropical multiplication. The behavior of a **timed event graph** (a Petri net where every place has exactly one input and one output transition) can be written as a tropical matrix equation: ``` x(k+1) = A ⊗ x(k) ``` where `x(k)` holds the k-th firing time of each transition and `⊗` is tropical matrix multiplication. `A` is transition-by-transition, and it is *not* the incidence matrix: `A[i][j]` is the delay on the place that carries tokens from transition j to transition i, and −∞ where no place connects them. It is built from the same arcs C is built from, but C (places × transitions, signed) drops the producer-to-consumer routing that `A` needs. The equation is the simple case; initial markings with empty or multiply-marked places add lagged terms, which reduce to this form by closing over `A` or enlarging the state. Within those conditions the net's dynamics are **linear** over (max, +) rather than over ordinary arithmetic. This is the core result of Baccelli, Cohen, Olsder and Quadrat, *Synchronization and Linearity: An Algebra for Discrete Event Systems* (Wiley, 1992): timed event graphs are max-plus linear systems. Much of linear algebra has a tropical counterpart — eigenvalues and eigenvectors above all — and it applies directly to event-graph analysis. In pflow-tropical, [`NetMatrix::tropical_adjacency`](https://github.com/pflow-xyz/pflow-rs/blob/main/crates/pflow-tropical/src/net_matrix.rs) builds this `A` from a net's arcs, using the consumer's input-arc weight as the delay. [`conjecture1.rs`](https://github.com/pflow-xyz/pflow-rs/blob/main/crates/pflow-tropical/tests/conjecture1.rs) checks it against the discrete firing rule: on a two-transition loop and a three-transition cycle, the transitions with a finite entry in `A^k ⊗ x(0)` are exactly the ones enabled at step k. Those are the smallest cycles there are, and the test says nothing more general than that. ## ReLU Is Tropical Now consider the most common activation function in deep learning: ``` ReLU(x) = max(0, x) ``` That `max` is, again, tropical addition. `ReLU(x)` is a tropical polynomial in one variable. The observation extends well past a single neuron. [Zhang, Naitzat, and Lim (ICML 2018)](https://arxiv.org/abs/1805.07091) proved that a feedforward ReLU network computes a **tropical rational function** — a tropical quotient, which is an ordinary difference, of two tropical polynomials (exactly so for integer weights; real weights need real exponents). A ReLU classifier's decision boundary lies inside a tropical hypersurface. That is a theorem about what these networks compute. ReLU networks have been doing tropical arithmetic all along. ## The Triangle ![The Tropical Triangle](/images/tropical-petri-nets/triangle.svg) Two edges of the triangle are theorems. Petri nets meet tropical algebra through Baccelli et al.: timed event graphs are max-plus linear. [Hameed et al. (MDPI 2025)](https://www.mdpi.com/2227-7390/13/5/748) go the other way and present a max-plus perceptron as a Petri net. Neural networks meet tropical algebra through Zhang et al.: ReLU networks are tropical rational, and the polytopes attached to their tropical polynomials bound how many linear regions a network can carve its input into. The third edge, between Petri nets and neural networks, is ours and it is weaker. Where the theorems apply — event graphs on one side, ReLU networks on the other — both compute over (max, +), and we read the net as the structured case and the network as the learned one. A general Petri net, with choice and shared places, is not max-plus linear, so the edge holds for the event-graph fragment and no further. ## The Hierarchy: Structure vs. Parameters We can arrange computational models along a spectrum, and tropical algebra says something about what the spectrum measures: ![The Structure–Parameter Tradeoff](/images/tropical-petri-nets/hierarchy.svg) **Petri net**: We know the causal structure. We write it down as a net. Arc weights are small integers. For a timed event graph, computation is tropical-linear — a matrix-vector multiply over (max, +) — and the cycle time comes out of Karp's algorithm in polynomial time. A general net keeps the interpretability but not the complexity bound: reachability for unrestricted Petri nets is decidable but Ackermann-complete. The model is interpretable because the structure *is* the explanation. [`conjecture2.rs`](https://github.com/pflow-xyz/pflow-rs/blob/main/crates/pflow-tropical/tests/conjecture2.rs) checks the structure/parameter split on small nets: across arc weights from 1 to 10, the P- and T-invariant supports, the sign pattern of each transition's effect and the enabling sequence stay fixed, while the tropical eigenvalue scales with the weight. **Recurrent neural network**: We don't know the structure. We learn it via backpropagation. A dense weight matrix compensates for missing topology, and the weights are floats where, for a domain whose causal structure is known, we expect integers would do. A ReLU RNN unrolled over a fixed horizon is a feedforward ReLU network, so Zhang et al. apply and the computation is tropical-rational — still tropical, but without the structural guarantees that make the event-graph analysis tractable. (A tanh RNN, like the one in pflow-tropical's tests below, is outside the theorem.) **Large language model**: We don't even know the task. We massively overparameterize — billions of weights trained on internet-scale data, through softmax and GELU layers that are not tropical, so the algebra has nothing to say at this tier. Our claim here is the argument of the [small models thesis](/posts/small-models-not-llms): for a problem with known causal structure, those billions of parameters approximate what a small structured model could compute exactly, *if we knew what model to build*. Each step down the hierarchy trades domain knowledge for parameters. Tropical algebra makes the top two tiers precise — event graphs are tropical-*linear*, ReLU networks tropical-*rational* — and stops there. ## What DDM Already Does If this framing sounds familiar, it's because [Declarative Differential Models (DDM)](/posts/declarative-differential-models) and [integer reduction](/posts/integer-reduction) already exploit the same insight from a different angle. DDM's continuous relaxation converts a Petri net's discrete dynamics into ODEs. The system flows to an equilibrium that reveals structural quantities without enumerating the state space: accumulator places settle at `x* = 1/n` for n attached drain transitions (the decoupling lemma in [The Incidence Reduction](/posts/integer-reduction); the tests that assert it are linked from [the paper post](/posts/integer-reduction-paper)), and conservation laws hold along the whole trajectory. Integer reduction then recovers exact discrete structure from these continuous trajectories. Tropical analysis does something similar. The **tropical eigenvalue** of a max-plus matrix (the maximum circuit mean, computed in pflow-tropical by [Karp's algorithm](https://github.com/pflow-xyz/pflow-rs/blob/main/crates/pflow-tropical/src/eigenvalue.rs)) gives the asymptotic cycle time of a timed event graph — throughput is its inverse, and at λ = 1 the two coincide — a global structural quantity extracted from local topology, no state-space search required. It is only defined where the event-graph property holds, which is what makes it a boundary detector later on: the transitions it cannot see are the ones with [ρ > 1](/posts/earned-compression). DDM's ODE equilibrium and the tropical eigenvalue are siblings: both extract invariants from structure rather than from exhaustive search. The [categorical perspective](/posts/symmetric-monoidal-categories) gives the kinship a frame, though not yet a theorem. Petri nets are morphisms in a symmetric monoidal category, and tropical algebra puts a weight on those morphisms — but the weight does not compose cleanly. Gluing two nets creates cycles that belong to neither, so the composite's λ is only bounded below by its components' ([The Category Settle](/posts/category-settle#what-changed) has the bound and the Karp-on-the-glue repair). DDM gives us a continuous relaxation. All three are tools for extracting meaning from structure. ## Known Structure, Learned Structure The practical claim is the [small models thesis](/posts/small-models-not-llms): if you know your domain, build a Petri net, not a neural network, and expect the small net analyzed over the tropical semiring to be exact where the network is approximate. Nothing we have built measures that head to head — no parameter-count-versus-accuracy benchmark exists — so it stands as an argument. What pflow-tropical does have is narrower. [`rnn_petri.rs`](https://github.com/pflow-xyz/pflow-rs/blob/main/crates/pflow-tropical/tests/rnn_petri.rs) trains a small tanh RNN on random legal tic-tac-toe games generated from the net and checks that it beats a uniform guess over transitions. On the 18-transition core board with turn places, alternation holds in 50 of 50 self-play games because the net will not enable an out-of-turn move. On the same board without turn places, the test's own comment records the reward-seeking policy learning to let X move twice in a row; that count is printed, not asserted. It shows the kind of work structure does that a learned policy can't be trusted with, and it is not a benchmark. The [Lottery Ticket Hypothesis](https://arxiv.org/abs/1803.03635) (Frankle and Carbin, 2018) found that dense networks contain sparse subnetworks that match the full network's performance. Our guess, untested, is that the winning tickets may have Petri-net-like topology — a tropical-linear substructure inside a tropical-rational network. [TropNNC](https://arxiv.org/abs/2405.16896) (Fotopoulos et al., 2024) works from the opposite end to the one Petri nets start from: it simplifies a trained network by reducing its tropical polynomial. Whether the reduced network looks like a Petri net is our question; TropNNC doesn't claim it. If it did, mechanistic interpretability could ask "does this network implement a Petri net?", and a yes — a tropical polynomial that factors into sparse, integer-weighted structure — would hand back the causal model the network learned to approximate. ## Open Questions **Tropical decomposition of trained networks.** Can we extract Petri net structure from a trained ReLU network by factoring its tropical polynomial? We have a first, small result. [`conjecture3.rs`](https://github.com/pflow-xyz/pflow-rs/blob/main/crates/pflow-tropical/tests/conjecture3.rs) trains a one-hidden-layer ReLU network on next-marking data from a two-place loop and a three-place cycle, thresholds and rounds the learned per-transition deltas, and gets the incidence matrix's sign pattern back exactly on both. Two limits: the extraction reads the network's input–output deltas rather than factoring its weights (the test on the output weight matrix only checks that it factors to something sparse), and the nets have two and three transitions. TropNNC compresses networks but doesn't (yet) produce Petri nets. The gap between "simplified tropical polynomial" and "Petri net with named places and transitions" is a gap of *interpretation*, not algebra. **Tropical DDM.** DDM uses ODE integration to find continuous equilibria. The max-plus half already runs — pflow-tropical computes the eigenvalue from the net's arcs with Karp — but the two compute different quantities, and the open part is whether one computation could give both. That would unify the ODE approach with the max-plus approach, and might be faster for large nets. **Maslov dequantization.** The tropical semiring is a limit of ordinary arithmetic: define a ⊕ₕ b = h·log(e^{a/h} + e^{b/h}), keep ⊗ as +, and as h → 0 the sum becomes max. Maslov and Litvinov read h as Planck's constant — as it goes to zero the path integral concentrates on the classical path, and superposition becomes optimization (`max`). See [Maslov's observation](https://en.wikipedia.org/wiki/Tropical_geometry). Petri nets already have a discrete/continuous duality (DDM exploits it). Does the tropical connection extend this to a discrete/continuous/quantum *triad*? ## What Changed For readers of the March version (revised 2026-10-14): 1. **`A` is not the incidence matrix.** The max-plus matrix is transition-by-transition and carries delays; C is places × transitions and signed. Said so, and linked the construction in pflow-tropical. 2. **Theorems scoped.** Max-plus linearity and polynomial-time analysis hold for timed event graphs, not Petri nets in general. Zhang et al.'s result puts the decision boundary *inside* a tropical hypersurface; the earlier text said its vertices. 3. **`x* = 1/n`** is the equilibrium of an accumulator place with n drains, not "the token distribution", and the ODE does not reveal reachable states. 4. **Eigenvalue.** λ is the cycle time (throughput is 1/λ), and it composes only as a lower bound — see The Category Settle. 5. **Unproven claim withdrawn.** "A 10-node Petri net will outperform a 10,000-parameter RNN on any task where the causal structure is known — exactly" had no benchmark behind it. Replaced with what the RNN tests in pflow-tropical do show. 6. **Code linked.** Added the pflow-tropical tests (`conjecture1`–`3`, `rnn_petri`) as evidence; none were re-run for this revision. The online copy of Baccelli et al. no longer resolves, so the citation is now unlinked. --- For how tropical analysis combines with ODE and zero-knowledge proofs — three independent formalisms discovering the same structural boundary: [Earned Compression](/posts/earned-compression) For the tropical semiring as the left context of a zipper — past accumulation meeting predicate future at the marking boundary: [The Zipper Whose Hole Is a Universe](/posts/tense-type-theory) *Related: [The Pflow Square](/posts/pflow-square) · [The Category Settle](/posts/category-settle) · [Structuralism, Not Objects](/posts/structuralism-not-objects)* --- # Earned Compression - URL: https://blog.stackdump.com/posts/earned-compression - Date: 2026-03-11 - Tags: petri-nets, tropical-geometry, zero-knowledge, category-theory, compression - Summary: ODE simulation, tropical analysis and zero-knowledge proofs each draw a line between a Petri net's core and its observer. Where the three agree on tic-tac-toe, why the agreement follows from one definition, and where the code does not yet match the theorem. # Earned Compression In [The Incidence Reduction](/posts/integer-reduction), we showed that ODE steady-state values recover exact integers from the net topology — incidence degrees to terminal transitions. In [Tropical Petri Nets](/posts/tropical-petri-nets), we showed that timed event graphs are max-plus linear systems, and that the tropical eigenvalue compresses an entire reachability graph to a single scalar, the cycle time. Both analyses start from the same object: the incidence matrix C. Both are also *compressions*, and of the same structure. Add a third analysis — [zero-knowledge proofs](/posts/zk-petri-nets) — and all three draw the same line through the net. The long form is a draft paper, [`papers/earned-compression/main.tex`](https://github.com/pflow-xyz/pflow-rs/blob/main/papers/earned-compression/main.tex) in pflow-rs; this post follows it, and says where the code behind it agrees and where it doesn't. *Revised 2026-08-22. The original defined "observer" once and then used the word for two different things; see [From Games to Finance](#from-games-to-finance) for the correction, and [The Category Settle](/posts/category-settle#two-boundaries-not-one) for the full version. Revised again 2026-10-18 against the code; see [What Changed](#what-changed).* ## The Core-Observer Decomposition Every Petri net we've built — [tic-tac-toe](/posts/tic-tac-toe-model), [coffee shop](/posts/coffeeshop-model), [Texas Hold'em](/posts/texas-holdem-model) — has a natural split. There's a part that *does* things (moves pieces, brews coffee, deals cards) and a part that *watches* (detects wins, checks completion, validates hands). We call the first part the **core** and the second part the **observer**. The split isn't just organizational. It has a precise structural definition: observer transitions consume from core places but never produce into them. This is the **sink property**. The observer reads the core's state destructively — it takes tokens to make a verdict — but it never feeds tokens back. If no observer transition ever fires, the core evolves identically whether or not the observer exists. That is one definition, and it is the one every section below uses until the payment example. It is an *algebraic* definition — it is read off the columns of C. In tic-tac-toe it picks out exactly the transitions with ρ > 1; in general the two can come apart, since a sink that takes one core token and makes one verdict token has ρ = 1. Hold on to that, because the payment example will turn out to need a second definition. ![Core-Observer Decomposition](/images/earned-compression/core-observer.svg) The tic-tac-toe net here is the 33-place, 35-transition `pilot_ttt` fixture in pflow-tropical's [`ttt_fixtures.rs`](https://github.com/pflow-xyz/pflow-rs/blob/main/crates/pflow-tropical/src/ttt_fixtures.rs), whose dimensions and turn wiring [`conjecture1.rs`](https://github.com/pflow-xyz/pflow-rs/blob/main/crates/pflow-tropical/tests/conjecture1.rs) asserts. It decomposes as: - **Core**: 18 play transitions (9 cells × 2 players). Each consumes an empty-cell token and a turn token, and produces a marked-cell token, the opposite turn token and one `move_tokens` token — the counter the draw detector reads. The cell invariant `p_ij + x_ij + o_ij = 1` and the turn invariant `x_turn + o_turn = 1` hold across every play. - **Observer**: 17 transitions (8 win lines × 2 players + 1 draw). Each win transition consumes 3 cell tokens + the `game_active` flag + a turn token, and produces a single verdict token. Five tokens in, one token out. The draw consumes nine `move_tokens` and `game_active`. The board state is destroyed. Only the verdict survives. With ρ(t) as tokens consumed over tokens produced, the wins have ρ = 5 and the draw ρ = 10: more is consumed than produced, and the difference is the *earned compression* — the state the verdict makes irrelevant. The plays have ρ = 2/3, not the ρ = 1 this post used to claim: they consume two tokens and produce three, the third being the move counter. Drop the counter, as the 29-place core in the same file (`builder_ttt_turns`) does, and each play is exactly ρ = 1. The line falls in the same place either way — plays at or below 1, verdicts at 5 and above — but "core means ρ = 1" is true of the counter-free core, not of the net this post counts. ## What Makes Compression "Earned" JPEG is lossy — it throws away information and hopes you won't notice. Gzip is lossless — it exploits redundancy but preserves everything. Earned compression is something else: it throws away information and *proves* that what it threw away doesn't matter for the target property. Formally, a map κ: S → S' is an **earned compression** with respect to a property π if: 1. The output is strictly smaller: |S'| < |S| 2. The property is exactly recoverable: π(S) = π'(S') 3. No tighter compression exists — this is the Kolmogorov claim The tightness condition is what distinguishes this from approximation: the claim is the *shortest* description from which the target property can be recovered, not one that is close enough. For the tropical eigenvalue the paper rests tightness on Baccelli et al.; for the other stages below, and for C itself, it is asserted, not proved. Read condition 3 as what each stage has to earn rather than something it already has. ## Three Formalisms, One Boundary The paper's central claim is that three analyses — each with different mathematical foundations, different computational models, and different target properties — put the same transitions on the core side and the same on the observer side. Its proof is a sketch, and read against the code the three legs hold up to different degrees. ![Three Formalisms, One Boundary](/images/earned-compression/convergence.svg) ### ODE Simulation The mass-action ODE system `ẋ = C · v(x)` evolves the core to a stable equilibrium. This is what we explored in [integer reduction](/posts/integer-reduction): set all rates to 1.0, let the topology speak, and the steady state reveals the incidence degrees. The observer's transitions are sinks — they pull tokens out of the system and feed none back. That much is the definition. The paper goes further: the core reaches equilibrium on its own and the observer is only evaluated *at* that equilibrium. That holds when the ODE runs over the core alone. It is not what the tic-tac-toe ODE on this blog does. There the win detectors fire inside the solve, and because they consume the turn token they stop the flow — the [game-halting fix](/posts/tic-tac-toe-model#why-game-halting-matters), which changed the scores. A sink can't feed the core, but it can drain it. The core-observer boundary, from the ODE's perspective, is **direction of flow**: nothing comes back out of the observer. ### Tropical Analysis The [tropical eigenvalue](/posts/tropical-petri-nets) compresses the core's entire reachability dynamics to a single scalar λ — the asymptotic cycle time (throughput is 1/λ; at λ = 1 the two coincide). For tic-tac-toe's core, λ = 1: one X-play then one O-play, alternating. The critical circuit has length 2 (an X-play followed by an O-play), total weight 2, mean = 1. No test computes this for the tic-tac-toe core. `conjecture1.rs` asserts λ = 1 on a two-transition loop, and on `pilot_ttt` it only spot-checks the max-plus wiring (an X-play and an O-play enable each other through the turn places). And since every delay in the core is 1, every circuit in it has mean 1, so λ = 1 here says the plays form a cycle more than it says anything about alternation. The eigenvalue means something because of the theorem behind it, which needs a **timed event graph**: every place with exactly one producer and one consumer. Strictly the core isn't one. Each empty-cell place `p_ij` has two consumers (`x_play_ij`, `o_play_ij`) and no producer, and the turn places have nine of each. The paper's position is that one turn token serializes all of that, so the core behaves like an event graph; [The Turn Place Problem](#the-turn-place-problem) below takes it up. The observer breaks the property more visibly. Place `x11` (X's mark on the center cell) has *four* consumers — the win transitions for the four lines through the center. With several consumers per place the event-graph theorem no longer applies, and the max-plus equation stops describing the dynamics. The core-observer boundary, from tropical analysis: **the core's fan-out is serialized by the turn token, and the observer's is supposed not to be**. Of the three legs this is the weakest; the turn-place section says why. ### Zero-Knowledge Encoding The incidence matrix compiles directly into an R1CS constraint system for [ZK proofs](/posts/zk-petri-nets). A Groth16 proof of a transition says: "a valid move occurred from marking m to marking m'" — in 128 bytes, verifiable in constant time. It reveals which transition fired and the two state roots, and hides the board. The transition id is a public input in both circuits we run, and [Zero-Knowledge Proofs for Petri Nets](/posts/zk-petri-nets) says so; an earlier version of this post said the proof revealed nothing about the move. The paper's ZK leg says core transitions compile to uniform R1CS — `m' = m + Δ_t`, with a boolean multiplexer selecting the right column of C — while observer transitions consume from places shared with other observer transitions and need auxiliary witness variables to prove non-interference. The circuit in pflow-rs doesn't do that. [`circuit.rs`](https://github.com/pflow-xyz/pflow-rs/blob/main/crates/pflow-zk-arkworks/src/circuit.rs) in pflow-zk-arkworks puts every transition of the net, win detectors and draw included, through the same selector multiplexer, with one remainder witness per input-arc slot; the gnark circuit in the zk-petri-nets post has the same shape. The observer's one visible cost is width. A win has five input arcs where a play has two, and the enabledness block is padded to the widest transition, so the observer sets the size of every step's circuit. That is a real asymmetry, but it is not the non-uniform witness the theorem names, and nothing measures it. The core-observer boundary, from ZK encoding as built: **padding, not a different encoding**. ### Convergence No single formalism can prove the boundary is structural. The ODE analysis might be an artifact of mass-action kinetics. The tropical analysis might be an artifact of event-graph restrictions. The ZK encoding might be an artifact of R1CS structure. The paper's answer is that three formalisms sharing no mathematical machinery — real-valued differential equations, the max-plus semiring, and finite-field arithmetic — converge on the *same* partition. Its own proof sketch shows why that is weaker than it sounds: each leg is derived from the one sink property, so the agreement is three readings of one definition rather than three independent experiments. Read against the code, the legs also agree less cleanly than the theorem says. The ODE sees direction of flow, the tropical leg needs the turn-place argument to keep the core on its side, and the circuit as built treats every transition alike. Nothing yet runs the three classifications on one net and compares the partitions. What they do share is the thing they read: ρ, or the sink property that tracks it here. The ODE sees ρ > 1 as a sink, the tropical analysis sees it as a broken event-graph property, the circuit as built sees it only as a wider row of input arcs. None of them can see an arc that isn't in C at all — which is exactly what the next kind of observer is made of. ## The Compression Pipeline The incidence matrix C is the central object. Each analysis is a compression — a map that discards information irrelevant to a specific target property while preserving that property exactly. ![The Compression Pipeline](/images/earned-compression/compression-pipeline.svg) | Stage | Input | Output | Ratio | Property preserved | |-------|-------|--------|---|-------------------| | Domain → Net | Game rules | C ∈ ℤ^{33×35} (places × transitions) | — | All firing semantics | | Net → Eigenvalue | C (1155 integers) | λ ∈ ℝ (1 scalar) | 1155 | Throughput | | Net → Invariants | C | ker(C), ker(C^T) | |P|/dim ker | Conservation laws | | Net → Observation | m, t_obs | verdict ∈ {w,l,d} | 5–10 | Game outcome | | Net → ZK proof | C, m, t | π ∈ {0,1}^1024 (128 bytes) | ~10⁴ | Transition validity | The paper calls every row earned. The first two conditions can be checked row by row; the third, that no smaller output suffices, is argued for the eigenvalue and asserted for the rest. The ratio column is input size over output size, a different quantity from the per-transition ρ above, and the ~10⁴ for the proof is the paper's round figure, not a derivation. A 33×35 matrix — 1155 integers — goes in, and a single real number λ = 1 comes out: the core's cycle time, one turn per unit delay. Strictly the eigenvalue is computed from the 18×18 max-plus matrix of the core, not from C itself; 1155 is the size of what we started with. Within the event-graph caveat above, that one number is all of C that matters for throughput. The ZK row has the largest ratio. The entire statement "transition t is valid from marking m to marking m'" — which involves the full incidence matrix, the current state, the enabledness conditions — compresses to 128 bytes. The verifier learns that the transition was valid and which transition it was, and nothing about the marking beyond its two hashes. ## Observation Is Irreversible There is a thermodynamic reading of the decomposition. The core loses nothing. The cell invariant `p_ij + x_ij + o_ij = 1` says each cell is always exactly one of empty, X-marked, or O-marked, and `x_turn + o_turn = 1` holds alongside it. In the 33-place net each play also adds a `move_tokens` token, so the total grows by one per move rather than staying fixed, but nothing is thrown away: the counter is history, and it is what the draw later consumes. The observer is irreversible. A win transition consumes 5 tokens and produces 1. The board state — which cells formed the winning line — is destroyed. Only the verdict survives. The game cannot be "un-won." This is the same *pattern* as measurement in physics — a measurement apparatus couples irreversibly to a reversible system, producing a classical outcome that cannot be undone. The mechanisms differ (no Born rule, no non-commutativity here), but the structural pattern is shared: observation that claims to be passive is actually irreversible, and the irreversibility is what produces a definite outcome. The compression ratio ρ quantifies the irreversibility. Plays: ρ = 2/3 here, 1 without the counter (nothing destroyed). Wins: ρ = 5 (five tokens in, one verdict out). Draw: ρ = 10 (nine moves of history and the game flag compressed to "nobody won" — which this net scores as a point for O). The destroyed information is exactly the information irrelevant to the verdict — the specific cells that formed the winning line don't matter once you know who won. | Observer transition | Consumed | Produced | ρ | Information discarded | |---|---|---|---|---| | x_win_* (8 transitions) | 5 | 1 | 5 | Which 3 cells formed the line | | o_win_* (8 transitions) | 5 | 1 | 5 | Which 3 cells formed the line | | draw | 10 | 1 | 10 | Full move history (9 moves) | | Play transitions (18) | 2 | 3 | 2/3 | None (the extra token is the move counter) | ## The Turn Place Problem There's a subtlety that exposes the limits of structural analysis. The turn-control places (`x_turn`, `o_turn`) have 9 producers and 9 consumers — every play transition for one player produces into the other's turn place. Structurally, this violates the timed event graph property. Behaviorally they're fine. One token moves between the two turn places, so exactly one play fires per turn, and the token ping-pongs 1-to-1 through reachable markings even though the *structure* has 9:9 fan. The empty-cell places are the same story in miniature: two consumers each, and the turn token only ever enables one of them. The three analyses see the turn places differently. The ODE treats the turn token as a concentration that all nine `x_play` flows draw on at once; it has no mutual exclusion to enforce and never needed one producer per consumer, so 9:9 costs it nothing — the same concurrency that, in the tic-tac-toe post's [correction](/posts/tic-tac-toe-model#correction-the-ode-was-counting-not-searching), keeps it from seeing a race. The tropical analysis sees all nine producers and nine consumers and reports causal paths between transitions that can never actually co-fire: it *overapproximates*. The discrete firing rule enforces alternation exactly, and an RNN trained on discrete trajectories never sees anything else. [`rnn_petri.rs`](https://github.com/pflow-xyz/pflow-rs/blob/main/crates/pflow-tropical/tests/rnn_petri.rs) asserts that all 200 training games on the 29-place core alternate, and that 50 of 50 self-play games do (not re-run for this revision). So the turn places are where the discrete analysis is exact, the continuous one is indifferent, and the structural one overapproximates. The incidence matrix C — which all three formalisms share as input — is *insufficient* to classify tropicality at the turn-control layer. That takes reachability information, and reachability is discrete. The argument also cuts the other way, which is why the tropical leg is the weakest. Every win and the draw consume the one `game_active` token, so at most one observer transition ever fires, and the observer's shared cell places are serialized by a single token just as the core's are. We have not worked out why the reachability argument should keep the core on the event-graph side and not the observer. Whether there exists a useful formal weakening — "tropical under mutual exclusion" — that captures this behavioral property without full state-space enumeration is an open question. ## From Games to Finance The core-observer pattern isn't specific to games. It appears anywhere a computational core is monitored by an observation layer. Consider a payment settlement network with three parties: Alice → Bob → Carol → Alice. Each channel has a `send` transition (debit sender, create pending) and a `settle` transition (clear pending, credit receiver). [![pflow](https://pflow.xyz/img/z4EBG9jH6sVGBsnZYeqgcnrPZCPs25LBHYYbD4qZtyucuHuVSuh.svg)](https://pflow.xyz/?cid=z4EBG9jH6sVGBsnZYeqgcnrPZCPs25LBHYYbD4qZtyucuHuVSuh) The circular structure is visible in the net: tokens flow from alice_bal through send_ab to pending_ab, then settle_ab credits bob_bal, and so on around the cycle. Each place has exactly one producer and one consumer — a timed event graph. The core incidence matrix, printed transposed (rows are transitions, columns places — so this is Cᵀ, with C itself 6 × 6 places-by-transitions as everywhere else on this blog): ``` a b c p_ab p_bc p_ca send_ab -1 0 0 1 0 0 settle_ab 0 1 0 -1 0 0 send_bc 0 -1 0 0 1 0 settle_bc 0 0 1 0 -1 0 send_ca 0 0 -1 0 0 1 settle_ca 1 0 0 0 0 -1 ``` This core really is a timed event graph — every place has one producer and one consumer. With unit delays the tropical eigenvalue is λ = 1: every channel takes the same time, so none is a bottleneck. The P-invariant `a + b + c + p_ab + p_bc + p_ca = const` is the accounting identity: total money in the system is conserved. Double-entry bookkeeping is a theorem of the incidence matrix, not a policy imposed on it. The observer checks business rules: overdraft guards read balance places (catalytically — without consuming), and if the guard fails, the candidate payment is never committed. Not rolled back, not compensated — it never enters the event log. Notice that this observer fails the definition this post opened with. The sink property says an observer *consumes* from core places; the overdraft guard consumes nothing. It has ρ = 1. Under the three formalisms above it is invisible — it is not a column of C, so the ODE, the eigenvalue and the uniform circuit all look straight through it. It is an observer for a different reason: a read arc is a *contextual* arc, and contextual nets do not form the free symmetric monoidal category that ordinary nets do (Montanari & Rossi, 1995). The win detector in tic-tac-toe is an observer because it breaks the algebra and composes fine; the overdraft guard is an observer because it breaks composition and leaves the algebra alone. The original version of this post called both "the observer" and let the reader assume one boundary. There are two, and they are stacked, not equal. [The Category Settle](/posts/category-settle#two-boundaries-not-one) works this out. The same formalism serves both domains, but the tropical eigenvalue transforms from a structural curiosity (tic-tac-toe: λ = 1 at unit delay) to a design parameter (payments: with real channel delays, 1/λ is the maximum sustainable settlement rate). This three-party cycle is just one instance. Settlement networks form a sub-SMC of open Petri nets — [The Category Settle](/posts/category-settle) — where channels compose along shared boundary places. Conservation and the ZK circuit compose with them outright; throughput composes as a bound (λ of the whole is at least the max of the parts, and gluing can create cycles that beat both). ## The Incidence Matrix as Universal Interface A single 33×35 integer matrix — 33 places by 35 transitions — encodes the full tic-tac-toe game: - **Legal moves** — firing rules from C - **Strategic values** — ODE equilibrium from C (the [integer reduction](/posts/integer-reduction)) - **Throughput** — tropical eigenvalue from C - **Conservation laws** — null space of C - **Transition proofs** — ZK circuit compiled from C The paper's strongest claim is that C is the *maximally compressed* description of the domain — the shortest from which all behavioral, structural, and cryptographic properties can be recovered without loss. It is argued, not proved. And for this net it holds partly because of how the net is built: tic-tac-toe has no self-loops and no read arcs, so C determines both the input and the output arcs. A net with a read arc — the overdraft guard above — has a firing rule that C cannot recover. This is the Kolmogorov-flavored claim at the heart of [the paper](https://github.com/pflow-xyz/pflow-rs/blob/main/papers/earned-compression/main.tex): a domain is *understood* when its shortest description is also its most useful description. The paper proposes the incidence matrix as that description for Petri-net-structured domains with ordinary arcs. ## The Boundary Is the Signal The claim we care most about is that the boundary between what compresses and what doesn't is itself the most informative structural feature. The core compresses because it has clean algebraic structure: fan-out a single turn token serializes, and conservation laws. The observer doesn't compress — at least not in the same ways — because it has irreducible complexity: cells participate in multiple win lines, and there's no cheaper way to check all of them. That irreducible complexity is also what makes the observer *useful*: its non-tropical structure is a feature of the domain rather than a failure of analysis. Win detection in tic-tac-toe is irreducibly non-tropical because a single cell participates in multiple win lines. The fan-out is the point — it's what makes the measurement informative. The generalization we would like to make — unproved, and checked only on the two nets in this post — is that **in any system with a computational core and an observation layer, the core admits algebraic compression (throughput, conservation, proofs) and the observer admits only discrete compression (reachability, enabledness)**, and that any sufficiently expressive analysis of C will find the ρ boundary. The contextual boundary is not theirs to find — it sits outside C by construction — and it has to be declared. Tic-tac-toe (move execution watched by win and draw detection) is worked above, and so is the payment ring (settlement watched by an overdraft guard, the contextual kind). Workflows, with task execution watched by timeout and error detection, and protocols, with message passing watched by violation and completion checks, look like the same shape, but we have not built either. In both worked cases the incidence matrix is the specification and the analysis input, and the boundary tells you where the algebraic tools work and where you need discrete reasoning. Whether the three formalisms' agreement shows that the boundary is structure rather than a modeling choice is still open. The agreement follows from one definition, the tropical leg needs an argument we have only made for the core, and the circuit leg is not built the way the theorem describes. ## What Changed Revised 2026-10-18 against the code — the `pilot_ttt` fixture, the pflow-tropical tests, the arkworks circuit and the paper source. None of it was re-run for this revision. 1. **Play ρ.** In the 33-place net each play produces a `move_tokens` token as well as the mark and the turn: 2 in, 3 out, ρ = 2/3. The post said 3 in, 3 out, ρ = 1, which is only true (as 2 in, 2 out) of the 29-place core without the counter. The boundary doesn't move. 2. **Sink property vs ρ > 1.** They pick out the same transitions in tic-tac-toe; they are not equivalent in general. 3. **ODE leg.** In the tic-tac-toe ODE the win detectors fire inside the solve and halt it. The observer is a sink, not a bystander evaluated at equilibrium. 4. **Tropical leg.** The core is not structurally an event graph (the empty-cell and turn places both fan out), and the serialization argument that keeps it on the core side applies to the observer's `game_active` too. λ = 1 for the core has no test, and at unit delay it says little. λ is now the cycle time, with throughput 1/λ, as in [Tropical Petri Nets](/posts/tropical-petri-nets). 5. **ZK leg.** The circuit sends every transition through one multiplexer; the "auxiliary witnesses" are in the paper, not the code. The proof reveals the transition id — the post said it revealed nothing about the move. 6. **Tightness and maximal compression** are stated as the paper's claims, not as results, and "the convergence is the proof" is gone. ## Further Reading - **Baccelli, F., Cohen, G., Olsder, G.J., Quadrat, J.-P.** (1992). *Synchronization and Linearity: An Algebra for Discrete Event Systems.* Wiley. The foundational text on tropical (max-plus) analysis of timed event graphs. - **Meseguer, J. & Montanari, U.** (1990). *Petri Nets are Monoids.* Information and Computation, 88(2). Proves that Petri net firing semantics is a free symmetric monoidal category. - **Groth, J.** (2016). *On the Size of Pairing-Based Non-Interactive Arguments.* EUROCRYPT 2016. The ZK proof system that compresses transition validity to 128 bytes. - **Li, M. & Vitányi, P.** (2008). *An Introduction to Kolmogorov Complexity and Its Applications.* Springer, 3rd ed. The theoretical foundation for "shortest description" claims. For the tropical algebra that makes this possible: [Tropical Petri Nets](/posts/tropical-petri-nets) For the ODE analysis that started it: [The Incidence Reduction](/posts/integer-reduction) For how settlement networks compose categorically: [The Category Settle](/posts/category-settle) For the categorical structure underneath: [Symmetric Monoidal Categories](/posts/symmetric-monoidal-categories) For the temporal interpretation of the core-observer boundary — past as tropical accumulation, present as marking, future as predicate constraint: [The Zipper Whose Hole Is a Universe](/posts/tense-type-theory) --- # The Category Settle - URL: https://blog.stackdump.com/posts/category-settle - Date: 2026-03-12 - Tags: petri-nets, category-theory, settlement, open-petri-nets - Summary: Settlement networks form a sub-SMC of open Petri nets. The core-observer split is really two boundaries — an algebraic one (ρ) inside the incidence matrix and a categorical one (contextual arcs) outside it — and a ZK circuit dissolves only the second. # The Category Settle > **Revised 2026-08-22.** The original version collapsed two distinct boundaries into one sentence, called the overdraft guard a sieve (it isn't), and overstated what the tropical eigenvalue does under composition. Those are fixed below; the [change log](#what-changed) at the end lists them. In [Earned Compression](/posts/earned-compression), we showed that a three-party payment cycle exhibits the same core-observer decomposition as tic-tac-toe — the core is a timed event graph with tropical eigenvalue λ = 1, and the observer (overdraft guards) reads state catalytically without feeding tokens back. But we treated that network as a fixed object. Real settlement systems aren't fixed. Channels are added, split, merged. Parties join and leave. The interesting question isn't "what are the properties of *this* network?" — it's "how do properties compose when we *build* networks from parts?" That question has a precise answer in the language of open Petri nets — provided we are careful about which of two boundaries we are talking about. ## Settle Is a Sub-SMC of OPetri **Settle** isn't one payment network. It's the symmetric monoidal subcategory of **OPetri** — the category of open Petri nets (Baez & Master, 2020) — generated by two transition shapes, `send` and `settle`. It is the category of *all* settlement networks built from those primitives. - **Objects** are boundary multisets of account places — the token types (balances, pending amounts) exposed along a cut - **Morphisms** are open nets between boundaries: a single `send` or `settle`, a channel, a whole mesh - **Composition** is gluing along a shared boundary: the output of one channel feeds the input of the next - **Monoidal product** (⊗) gives parallel, independent channels with no shared accounts The three levels matter, because the original version of this post conflated them. The three-party cycle from [Earned Compression](/posts/earned-compression) is not a category and not an object: it is a **morphism** — and because the cycle closes, an endomorphism on its glued boundary. A two-party channel is another morphism. A five-party mesh is another. They are all built by composing the same generators, which is what puts them in the same category. ## Open Petri Nets An open Petri net is a net with designated *boundary places* — exposed interfaces along which subnets compose. Each payment channel is an open subnet: ![Settle as String Diagram in OPetri](/images/category-settle/settle-string-diagram.svg) The channel `ch_ab` (Alice → Bob) is an open net with input boundary `{a}` and output boundary `{b}`. Internally it has two transitions (`send_ab`, `settle_ab`) and an internal place (`pending_ab`). The boundary places — `alice_bal` and `bob_bal` — are the wires that stick out, available for composition. The full network is the composition of three open nets, glued by identifying shared boundary places: ``` cycle = (ch_ab ;_b ch_bc) ;_c ch_ca ``` where `;_b` means "compose along shared boundary place b." The output boundary of `ch_ab` is `{b}`, and the input boundary of `ch_bc` is `{b}` — gluing them identifies Bob's balance as the shared wire. The cycle closes when `ch_ca`'s output `{a}` feeds back to `ch_ab`'s input `{a}`, which is what makes `cycle` an endomorphism. This is sequential composition in a [symmetric monoidal category](/posts/symmetric-monoidal-categories). Each channel is a morphism. Composition is wiring outputs to inputs. The monoidal product `⊗` would give us parallel, independent channels — two settlement cycles running side by side with no shared accounts. ## Events The core of Settle is a timed event graph: every place has exactly one producer and one consumer. This gives us a precise definition of an event. A token passing through a place isn't "something happened" — it's a specific morphism in the category: one transition fired, one token moved, one marking transformed to another. The place is the channel. The token is the evidence. An **event** is a generating morphism in Settle, witnessed by a token traversal, provable in zero knowledge. The incidence matrix records the effect. The [tropical eigenvalue](/posts/tropical-petri-nets) gives the throughput. The [ZK proof](/posts/zk-petri-nets) certifies the event was valid — in 128 bytes. What the proof hides depends on the application: in a game, which move was played; in a settlement network, the amounts and balances. ## String Diagrams Are Specifications The string diagram is the incidence matrix rendered as topology, not a visualization of it. Every wire is a token type (account balance or pending amount). Every box is a transition. Reading the diagram left to right gives the sequential composition; reading it top to bottom gives the parallel structure. The incidence matrix C is what you get when you compile the diagram down to a table. This means the diagram is simultaneously: - **Protocol specification** — it declares what operations exist and how they wire together - **Throughput certificate** — the tropical eigenvalue λ = 1 is computed from the circuit structure visible in the diagram - **Accounting proof** — the P-invariant `a + b + c + p_ab + p_bc + p_ca = const` follows from the conservation law visible in the wiring: every wire consumed on the left is produced on the right - **ZK circuit template** — the diagram compiles to R1CS constraints for privacy-preserving settlement proofs ## The Observer Is R, Not a Sieve The original version of this post called the observer a **sieve** — a subfunctor of a representable presheaf that selects which morphisms are admitted. That word doesn't survive a literal reading. A sieve on an object X is a set of morphisms into X that is closed under precomposition: if `f` is in the sieve, so is `f ∘ g` for every `g`. Admission of a payment depends on the *current marking* — whether the guard passes for `send_ab` after some history depends on that history. That is path-dependent, not closed under precomposition, and so not a sieve. What the overdraft guard actually is, in the vocabulary the stack has since settled on, is **R** in the zipper decomposition [W(M) = 𝓛 × M × 𝓡](/posts/pflow-square): a predicate over the current marking, recomputed fresh at every step, that the step function consults before committing. The guard is a signal; the step function is its handler; "the morphism is deleted, never enters the event log" is just [the handler declining to continue](/posts/structuralism-not-objects). This post was arguing for R-as-algebraic-effect five months before the Dirac thread made that explicit — it just reached for the wrong categorical noun. If you want to keep a sieve somewhere, there is one: the *image* of the guard in the reachability graph. The set of marking-to-marking edges whose guard passes is a genuine subgraph of the unguarded reachability graph, and that subgraph is closed under the things a subgraph needs to be closed under. But that is a statement about the state space, not about Settle-the-category. A candidate payment is any morphism the incidence matrix structurally permits. R reads the core's state via a read arc (catalytic: it tests a place's marking without consuming tokens) and decides whether this firing happens at all. If the overdraft guard passes (balance ≥ amount), the event is committed. If not, nothing is committed; there was never a write to roll back. The distinction from tic-tac-toe is precise, and the table is worth reading carefully because its second row is the seed of the next section: | | TTT Observer | Payment Observer | |---|---|---| | Reads | cell history places | account balance places | | Mechanism | **consuming** (multi-fan input, game ends) | **catalytic** / read arc (system continues) | | Timing | post-hoc (after move) | pre-hoc (before commit) | | On failure | game continues | event not committed | | In C? | yes — a column of C | no — a read arc is not an incidence entry | | ρ | 5 or 10 | 1 | The TTT observer consumes — it is an ordinary transition with many input arcs and one output, a perfectly good column of C. The payment observer reads — it is a contextual arc with no column at all. Both are "observers" in the [Earned Compression](/posts/earned-compression) sense. They are observers for *different reasons*. ## Two Boundaries, Not One The original version of this post ended its categorical section with: *"The core-observer boundary is, precisely, the boundary between ordinary and contextual arcs."* That sentence is false for tic-tac-toe, and the table above shows why. The TTT win detector uses only ordinary arcs, lives entirely in C, composes fine in the free SMC — and is still an observer, because it has ρ = 5 and breaks the timed-event-graph property. The two definitions of "observer" that the stack has been using pick out different sets of transitions. There are two boundaries, and they are stacked, not identical: **The ρ boundary is algebraic and lives inside C.** A transition with ρ = 1 (every token consumed is produced elsewhere, one producer and one consumer per place) is part of a timed event graph. A transition with ρ > 1 breaks the event-graph property. Crossing this boundary breaks the *uniform* R1CS encoding (the multi-fan input needs non-uniform witnesses) and breaks the tropical eigenvalue (λ is only defined on the event-graph core). It does **not** break composition: Meseguer & Montanari (1990) proved that ordinary nets, multi-fan inputs and all, form a free symmetric monoidal category. TTT's win detector crosses this boundary and nothing else. **The contextual boundary is categorical and lives outside C.** A read arc (test for presence) or inhibitor arc (test for absence) couples a transition to a place without an incidence entry. Montanari & Rossi (1995) showed that contextual nets do not form the free SMC — the transition's enablement depends on state that C cannot see, so the morphism is not determined by its source and target boundaries. Crossing this boundary breaks composition. It does **not** break ρ: a read arc adds nothing to either count, so the guarded `send` keeps its ρ = 1. | | ordinary arcs only | has contextual arcs | |---|---|---| | **ρ = 1** | core (`send`, `settle`) | overdraft guard — categorically outside, algebraically harmless | | **ρ > 1** | TTT win/draw — algebraically outside, composes fine | crosses both | The four quadrants are all inhabited in principle, and the two examples this series has leaned on sit in the two *off-diagonal* cells. That is why collapsing the boundaries into one sentence went unnoticed for five months: each example breaks exactly one thing, and in isolation each looks like "the" observer. The guard is categorically an observer and algebraically core; the win detector is algebraically an observer and categorically core. Only a guarded multi-fan transition — a win detector with a read arc on a turn place, say — would cross both, and the payment network doesn't have one. I think this is a stronger story than the one it replaces. ρ tells you what the *algebra* can see: where uniform circuits and the tropical eigenvalue stop. Contextuality tells you what the *category* can see: where free composition stops. A transition can be invisible to one and not the other, and the design consequences are different in each case. ## ZK Circuit Boundaries The two-boundary picture answers a practical design question more precisely than the one-boundary picture did: what goes inside a zero-knowledge proof and what stays outside? The ρ = 1 core — ordinary arcs, columns of C, one producer and consumer per place — compiles directly to [uniform R1CS constraints](/posts/zk-petri-nets). A Groth16 proof of a `send` or `settle` transition says "a valid state transition occurred and tokens were conserved" in 128 bytes, without revealing which channel moved or what amount. The P-invariant `a + b + c + p_ab + p_bc + p_ca = const` is enforced by the circuit structure itself. Double-entry bookkeeping isn't a policy you audit — it's a theorem the proof system guarantees. The contextual observer — the overdraft guard — sits naturally outside the circuit. It is R: a policy decision about whether this firing is admitted. A smart contract or network validator can enforce it without the cost of proving it in ZK. You can change the guard threshold, add new compliance rules, or swap the observer entirely — without recompiling the circuit. This is earned compression applied to system design: **prove exactly what the incidence matrix requires, defer the rest**. But we are not limited to this split. If privacy demands it — if you don't want to reveal that a payment was *attempted* and rejected — you can pull the guard into the circuit. The overdraft check is a range proof (`bal ≥ amt`), straightforward in R1CS. The contextual arc becomes an auxiliary witness: the prover supplies the current balance, the circuit checks it against the state commitment and verifies the range. The original version of this post cited Vogler, Semenov & Yakovlev (1998) as the reason the guard cannot live in C, and then pulled it into the circuit anyway without noticing that the circuit changes the question. It is worth being exact about what VSY proved. Simulating a read arc as a self-loop — consume the token, produce it back — is inequivalent under *partial-order* (unfolding) semantics: the self-loop serialises firings that the read arc would have allowed to be concurrent, so the unfolding and the finite prefix change. Under *interleaving* semantics the reachability set is identical; the self-loop is a faithful encoding of one firing at a time. A Groth16 proof certifies exactly one transition firing. Interleaving is the only semantics the circuit has — there is no concurrency inside a single-step proof to get wrong. So inside the circuit, the self-loop *is* faithful, and the auxiliary-witness range proof is literally that self-loop: the balance is read in, checked, and written back unchanged in the same step. The contextual boundary dissolves at the circuit boundary. The ρ boundary does not. Pulling a ρ > 1 transition into a circuit still costs the non-uniform gadget, because that is a fact about C, and the circuit is a compilation of C. Which is the practical content of having two boundaries: the circuit erases the categorical one and leaves the algebraic one standing. ## Compositionality The key property of the OPetri framework: adding a fourth party is composing another open subnet along new boundary places. Splitting a channel into two hops is refining one morphism into a composite of two. Three of the invariants this series has used compose cleanly: - **Conservation.** P-invariants of the components lift to the composite: a weighting that is conserved on each side of a glued place is conserved across the glue, because gluing identifies places, not arcs. - **ZK circuit.** The composite's R1CS is the direct sum of the components' constraint systems with the glued places identified. Composing nets composes circuits; nothing is re-derived. - **R.** Guards compose by conjunction. The composite's admission predicate is the product of the components' predicates over the shared marking. The tropical eigenvalue does **not** compose this cleanly, and the original version of this post claimed that it did: *"if we know each channel's circuit weight, we know the composed system's throughput without re-analyzing from scratch."* That is wrong. λ is the maximum over cycles of mean weight. Gluing along a boundary place creates new cycles that pass *through* the glue and belong to neither component. What you actually get is ``` λ(A ;_p B) ≥ max(λ(A), λ(B)) ``` with equality exactly when no cycle through the glue beats the best local cycle. The settle cycle is the counterexample to its own claim: λ = 1 is a property of the closed three-party loop, and no single open channel `ch_xy` has a cycle at all — each component's λ is undefined (or −∞) and the composite's is 1. Throughput is created at the glue. The honest version is still a real compositionality result, and still cheap: the components give a lower bound for free, and only the cycles through the new boundary need to be examined. That is Karp's algorithm restricted to the glue, not rerun over the whole mesh. Monotone bound plus Karp-on-the-glue is what "compositional throughput" means here. So: a payment network doesn't get designed all at once. Channels are added, modified, split, merged. Each modification is a compositional operation in Settle. Conservation and the circuit update by construction; throughput updates by a bounded local recomputation; and the guard layer composes by conjunction. The three-party cycle and the five-party mesh live in the same category, analysed by the same tools, because they're built from the same generators. ## What Changed For readers of the March version: 1. **Levels.** Settle is a sub-SMC of OPetri, not "a category" that is also "an object in OPetri." Objects are boundary multisets; the three-party cycle is a composite morphism (an endomorphism), not an object. 2. **Sieve → R.** The overdraft guard is not a sieve (not closed under precomposition). It is R in W(M) = 𝓛 × M × 𝓡, an effect handled by the step function. The guard's image in the reachability graph is a genuine subgraph, if you want a place to keep the word. 3. **One boundary → two.** ρ (algebraic, inside C, breaks uniform R1CS and λ) and contextuality (categorical, outside C, breaks composition) are distinct. TTT's observer crosses only the first; the overdraft guard crosses only the second. 4. **VSY cuts the other way for ZK.** The self-loop is inequivalent only under partial-order semantics. A single-firing circuit is interleaving-only, so the range-proof encoding is faithful and the contextual boundary dissolves inside the circuit. The ρ boundary does not. 5. **λ does not compose.** Replaced the "no re-analysis" claim with λ(composite) ≥ max λ(components) plus Karp on the glue cycles. ## Further Reading - **Baez, J.C. & Master, J.** (2020). *Open Petri Nets.* Mathematical Structures in Computer Science, 30(3). Extends the categorical framework to composable open systems — directly relevant to payment channel composition. - **Meseguer, J. & Montanari, U.** (1990). *Petri Nets are Monoids.* Information and Computation, 88(2). Proves that Petri net firing semantics is a free symmetric monoidal category — including multi-fan transitions, which is why the ρ boundary does not break composition. - **Montanari, U. & Rossi, F.** (1995). *Contextual Nets.* Acta Informatica, 32(6). Introduces read arcs as contextual dependencies and shows they break the free monoidal structure of ordinary nets. - **Vogler, W., Semenov, A. & Yakovlev, A.** (1998). *Unfolding and Finite Prefix for Nets with Read Arcs.* CONCUR 1998. Proves that simulating read arcs as self-loops is not equivalent under unfolding semantics. Under interleaving semantics — the only semantics a single-step circuit has — the encoding is exact. - **Karp, R.M.** (1978). *A characterization of the minimum cycle mean in a digraph.* Discrete Mathematics, 23(3). The algorithm that computes λ; applied to the glue cycles, it is the compositional throughput step. For the core-observer decomposition and the three-formalism convergence that motivates this: [Earned Compression](/posts/earned-compression) For R as a handled effect rather than a held value: [Structuralism, Not Objects](/posts/structuralism-not-objects) For the categorical structure underneath: [Symmetric Monoidal Categories](/posts/symmetric-monoidal-categories) For the tropical algebra that gives us throughput from topology: [Tropical Petri Nets](/posts/tropical-petri-nets) --- # March Madness Without Monte Carlo - URL: https://blog.stackdump.com/posts/incidence-bridge - Date: 2026-03-16 - Tags: petri-nets, ode, monte-carlo, incidence-reduction, stochastic - Summary: The incidence matrix of an NCAA bracket Petri net connects ODE, Monte Carlo, and analytical methods — and makes simulation redundant. A closed-form formula derived from the bracket topology replaces 150,000 stochastic transitions with 256 exact configurations. # March Madness Without Monte Carlo In [The Incidence Reduction](/posts/integer-reduction), we showed that ODE steady-state values recover exact integers from topology — incidence degrees to terminal transitions. The integers are structural: arc counts in the bipartite graph, readable without simulation. That result used continuous dynamics only. Here we ask the discrete question: does Monte Carlo simulation recover the same structure? When do continuous and stochastic methods agree on a Petri net — and when do they diverge? The answer turns on two properties of the net, and the incidence matrix C is the object that connects everything. ## Three Nets, Two Methods We built three NCAA tournament ranking models using [go-pflow](https://github.com/pflow-xyz/go-pflow), each encoding bracket competition as a Petri net. Same framework, same win probabilities, radically different topologies. ![Three test case net structures](/images/incidence-bridge/net_topologies.svg) **Model 4: Independent chains.** Eight teams, five rounds each. Each team's tokens flow through a linear pipeline — advance (rate proportional to win probability) or eliminate (rate proportional to loss probability). No shared places between teams. 100 tokens per team. Gillespie stochastic simulation. **Model 2: Coupled competition.** Teams in the same region share catalytic arcs. A clash transition requires tokens from both competitors: `propensity = rate x tokens_A x tokens_B`. Both teams' token counts appear in the rate law. ~90 tokens per team. Gillespie stochastic simulation. **Model 6: Structural bracket.** A proper bracket encoded as a Petri net. Each possible matchup has two transitions (one per winner). 96 places, 240 transitions, 960 arcs. Each team starts with exactly 1 token. Discrete round-by-round MC. Two methods solve each model: - **ODE** — continuous integration (Tsit5) with mass-action kinetics - **Monte Carlo** — stochastic simulation on the same net Same nets, same rates, same initial state. Do they produce the same rankings? Model 4: perfect agreement. All 8 teams rank identically. Model 2: 15 of 16 rankings disagree. Not just shuffled — qualitatively different dynamics: ``` Team ODE Tokens MC Mean Rank shift Duke 112.8 0.3 -4 Arizona 106.5 0.2 -6 Houston 95.6 0.1 -13 Gonzaga 66.7 0.8 +12 Alabama 65.0 0.6 +12 ``` The ODE predicts smooth competitive equilibrium. The MC shows cascading elimination — winners gain tokens, losers hit zero and stay there. Rich-get-richer dynamics that the mean-field ODE cannot capture. Model 6: perfect agreement despite coupling. ODE championship values (scaled by 100) match MC win percentages within noise: ``` Team ODE (x100) MC Champ% Duke 27.7 27.8 Arizona 17.5 18.4 Michigan 10.8 11.9 Houston 10.6 9.8 ``` All 16 teams agree on championship ranking. This is the surprise. Model 6 has coupled transitions — both teams must have tokens for a matchup to fire — yet ODE and MC converge. Coupling alone doesn't cause disagreement. ## The Two-Axis Rule The resolution requires two questions, not one. ![Agreement characterization](/images/incidence-bridge/agreement_matrix.svg) **Axis 1: Coupling.** Does any transition have two or more input arcs from places with variable token counts? If no, the kinetics are linear (first-order). The Chemical Master Equation closes at the first moment. ODE = MC exactly. Model 4 lives here. **Axis 2: Token count.** Do the coupled places hold more than 1 token? If no, the mass-action product collapses: ``` propensity = rate x tokens_A x tokens_B = rate x 1 x 1 = rate ``` The nonlinear product degenerates to a binary indicator: either both teams are alive (propensity = rate) or at least one is eliminated (propensity = 0). No intermediate regime exists. No rich-get-richer feedback. No partial depletion. No correlation effects. Model 6 lives here. Both conditions must be present for disagreement: nonlinear coupling *and* multi-token pools. | Net property | ODE/MC agreement | Why | |---|---|---| | Linear topology (at most 1 variable input) | Always agree | First-order kinetics; CME closes at first moment | | Nonlinear + binary tokens {0,1} | Agree | Product collapses to indicator | | Nonlinear + high tokens + positive feedback | Disagree | Rich-get-richer amplifies fluctuations | This is a specific instance of the relationship between the Chemical Master Equation (exact stochastic dynamics) and the macroscopic rate equation (ODE, mean-field approximation) in chemical kinetics. Linear reaction networks agree exactly. Nonlinear networks agree in the thermodynamic limit (molecule counts tending to infinity). Our bracket finding adds a case not usually emphasized: bimolecular reactions with single-molecule reactants also agree, because mass-action degenerates to an indicator. ## The Incidence Matrix as Bridge The incidence matrix C of the bracket net (96 places x 240 transitions) encodes the complete structure: ``` C[p][t] = output_weight(t -> p) - input_weight(p -> t) ``` Every column of C is a state-change vector: when transition t fires, the marking changes by exactly that column. This single matrix defines both simulation methods: - **ODE**: dm/dt = C . v(m, r) where v is the mass-action flux vector - **MC**: delta_m = C . e_t where e_t is the unit vector for the fired transition The drain counts extracted from C reveal the bracket's geometry. For each team's place at each round, count the transitions with C[p][t] < 0: ``` Round R1 drains R2 drains F4 drains Final drains Every team 2 4 8 16 ``` The doubling pattern (2 -> 4 -> 8 -> 16) reflects bracket structure: at each round, the number of possible opponents doubles as cross-region matchups open up. With uniform rates, every team gets 6.25% = 1/16 championship probability — pure topology gives no advantage because the bracket is symmetric. But C also provides an analytical formula. For each round, identify matchup transitions from C, compute advancement probability weighted by opponent survival: ``` P(team reaches R+1) = P(team in R) x sum over opponents [P(opp in R) x P(win | opp)] ``` This forward propagation uses C to identify which transitions connect which places, and the rates to weight them. No simulation needed — one pass through C per round. ### Three-Way Comparison ![Three methods converge](/images/incidence-bridge/three_way_comparison.svg) ``` Analytical ODE MC Team (C + rates) (Model 6) (10k sims) ---------- ---------- --------- ---------- Duke 28.76% 28.32% 29.4% Arizona 18.13% 17.86% 17.9% Michigan 11.21% 11.04% 11.1% Houston 10.25% 10.09% 9.8% Florida 8.86% 8.73% 8.9% Purdue 5.39% 5.31% 5.3% ``` All three methods agree. The analytical column is exact. The ODE approaches it asymptotically (residual ~0.4% from finite integration time). The MC scatters around it (sampling noise). The bridge diagram makes the relationship explicit: ![Incidence matrix as universal bridge](/images/incidence-bridge/incidence_bridge.svg) C defines the ODE (dm/dt = C . v), the MC (delta_m = C . e_t), and the analytical formula (forward propagation through C). The three methods agree because they're computing the same function of the same matrix: ``` P(champ) = product across rounds [ sum over opponents (P(opp present) x P(win | opp)) ] ``` This function is determined entirely by C (which transitions connect which places) and r (the rate vector — team strengths as win probabilities). The incidence matrix is the structural skeleton that both continuous and discrete methods traverse. ## Replacing Simulation The analytical formula doesn't just agree with simulation — it replaces it. For a 16-team bracket, each region has 4 teams in 2 rounds of regional play. The possible outcomes per region: each of the 4 teams can win, giving 4 regional winners. But the path matters — the formula enumerates all structurally possible configurations. Exhaustive regional enumeration: 256 configurations (4 possible regional winners x 4 regions, with 4^4 = 256 cross-region combinations for the Final Four onward). For each configuration, multiply the path probabilities derived from win rates. Sum over all paths where a given team wins. The results match MC for all 16 teams across all rounds. Probabilities sum to exactly 100% per round — no normalization needed, no sampling noise, no convergence criteria. Cost comparison: 256 configurations vs 150,000 MC transitions (10,000 simulations x 15 games each). The analytical formula is three orders of magnitude cheaper and exact. This is not a novel technique for bracket prediction. FiveThirtyEight did the same analytical calculation for March Madness brackets. They were explicit about it: "Most of our sports forecasts rely on Monte Carlo simulations, but March Madness is different; because the structure of the tournament is a single-elimination bracket, we're able to directly calculate the chance of teams advancing to a given round." The formula is identical — a system of conditional probabilities, propagated forward round by round. The difference is where the bracket structure lives. FiveThirtyEight hand-coded it: procedural loops over rounds and matchups, with the bracket tree implicit in the control flow. The Petri net makes it declarative. You define places and transitions — Duke_r1, Kansas_r1, Duke_over_Kansas, Kansas_over_Duke — and the incidence matrix C falls out as a byproduct. The advancement formula falls out of reading C. You don't write bracket-specific probability code; you write a generic "propagate through C" routine that would work on any net. Both approaches are *synthesis* — someone decided the bracket has 16 teams, 4 rounds, specific matchup pairings. Neither discovers this from data. But the procedural version is a one-shot implementation of one method. The declarative version produces a mathematical object (C) that multiple methods can consume: ODE, MC, and the analytical formula all read the same matrix. Change the bracket structure — add a play-in round, reseed after regionals — and C changes, but the analysis code doesn't. The deeper difference is how structure gets recognized. FiveThirtyEight looked at a bracket and saw "single elimination — I can multiply conditional probabilities." That's domain expertise: a human recognizing a pattern and writing code to exploit it. The Petri net route is different. Encode the domain as places and transitions, extract C, and the structure announces itself. Binary tokens, no feedback loops, declared matchups — the two-axis rule tells you the analytical formula works before you try it. You don't need to recognize the pattern. The matrix exhibits it. They took advantage of structure they recognized. We take advantage of structure C exhibits. ## The Boundary This connects three results about what the incidence matrix reveals. [The Incidence Reduction](/posts/integer-reduction) showed that ODE equilibrium with uniform rates recovers integers from topology — incidence degrees to terminal transitions. The integers are structural arc counts, and the ODE is just reading them back. [Earned Compression](/posts/earned-compression) showed that three independent formalisms — ODE, tropical analysis, zero-knowledge proofs — discover the same core-observer boundary in a net. The boundary separates what compresses algebraically from what requires discrete reasoning. The incidence bridge shows that the same matrix C connects ODE, MC, and closed-form analysis — and characterizes exactly when they agree. The two-axis rule (coupling x token count) determines whether C provides a closed-form answer or whether simulation is doing irreducible work. A boundary with the same flavour as earned compression's — declared vs emergent constraints — shows up here. It is not the same boundary: earned compression's ρ boundary is read off C, while this one is about whether C plus rates is *enough*. But they point the same way. For nets with declared structure (the bracket topology is explicit in C) and binary tokens (the nonlinearity is inert), the analytical formula derived from C makes simulation redundant. The bracket is a declared constraint system with binary state. C encodes it completely. For nets with emergent structure — where the constraint patterns arise from dynamics rather than topology — C alone is insufficient. You need the ODE or the MC to discover what C cannot tell you statically. The coffee shop model's resource competition, the coupled competition model's rich-get-richer cascades: these are dynamics that live in the flow, not the graph. The incidence matrix is the bridge when the answer is in the topology. Simulation is the bridge when the answer is in the dynamics. The two-axis rule tells you which case you're in. ## Further Reading - [The Incidence Reduction](/posts/integer-reduction) — ODE equilibrium recovers integers from topology - [Earned Compression](/posts/earned-compression) — three formalisms discover the same structural boundary - [Tropical Petri Nets](/posts/tropical-petri-nets) — max-plus algebra compresses reachability to throughput - [Tic-Tac-Toe Model](/posts/tic-tac-toe-model) — the original analysis net where incidence degrees emerge External references: - **Jahnke, T. & Huisinga, W.** (2007). *Solving the chemical master equation for monomolecular reaction systems analytically.* Journal of Mathematical Biology, 54(1). Proves exact CME/rate equation agreement for first-order (linear) reaction networks. - **Molloy, M.K.** (1982). *Performance Analysis Using Stochastic Petri Nets.* IEEE Transactions on Computers, C-31(9). Foundational paper connecting stochastic Petri net firing semantics to continuous-time Markov chains. - **FiveThirtyEight** — *How Our March Madness Predictions Work.* The same forward-propagation formula applied to bracket prediction, hand-coded rather than derived from an incidence matrix. --- # Bitwrap: Petri Nets as ZK Containers - URL: https://blog.stackdump.com/posts/bitwrap-capstone - Date: 2026-03-18 - Tags: petri-net, zero-knowledge, bitwrap, solidity, groth16 - Summary: From OP_RETURN in 2014 to zero-knowledge Petri nets in 2026 — how bitwrap.io became the capstone for a decade of work on formal state machines, cryptographic proofs, and executable specifications. # Bitwrap: Petri Nets as ZK Containers I registered bitwrap.io in 2014 because of OP_RETURN. Bitcoin had just expanded OP_RETURN from 40 to 80 bytes — enough to embed a hash, a schema pointer, a fingerprint of off-chain state. The idea that caught me wasn't the 80 bytes themselves but what they implied: you could anchor *structured computation* to a chain without bloating it. Put the proof on-chain, keep the witness off-chain. Two years later SegWit made the same insight explicit at the protocol level. Segregated Witness literally separated transaction data from witness data — the signature proving you're authorized from the transaction describing what you want to do. The block sees the commitment; the witness lives elsewhere. That separation — commitment on-chain, witness off-chain — is exactly what a ZK proof does. The verifier checks a succinct proof. The prover holds the private witness. The chain never sees the state. ![From OP_RETURN to ZK Containers](/images/bitwrap-capstone/op-return-to-zk.svg) The question I couldn't answer in 2014 was: *what structure should the witness have?* A hash of what, exactly? The data model was missing. You could embed 80 bytes in OP_RETURN but you had no formal language for what those bytes meant. Petri nets turned out to be the answer. ## The Witness Is a Marking A Petri net state — a *marking* — is a vector of integers: how many tokens sit on each place. A transition fires by consuming tokens from input places and producing tokens on output places. The rules are structural. The arcs *are* the specification. This maps directly to the ZK witness pattern: - **Public inputs**: the pre-state root, the post-state root, and which transition fired - **Private witness**: the actual marking (token counts), the Merkle proof that this marking hashes to the claimed root - **Circuit constraints**: the transition was enabled (all input places had sufficient tokens) and the post-state equals pre-state plus the incidence delta The circuit doesn't know it's verifying a token transfer, or a game move, or a workflow step. It knows places, transitions, and arcs. Change the topology constants and you get ZK proofs for a completely different application. We covered the circuit mechanics in [Zero-Knowledge Proofs for Petri Nets](/posts/zk-petri-nets). Bitwrap is where that theory becomes a tool. ## One Model, Three Outputs Bitwrap treats a Petri net model as a single source of truth that compiles to three artifacts: ![Bitwrap Pipeline](/images/bitwrap-capstone/pipeline.svg) **1. ZK Proof (Groth16)**. Guards become arithmetic constraints. State roots use MiMC-BN254 Merkle trees. The gnark prover generates Groth16 proofs on the BN254 curve — Ethereum-compatible, verifiable on-chain. Six circuits cover ERC-20 operations: transfer, transferFrom, approve, mint, burn, and vesting claims. **2. Solidity Contract**. States become storage variables. Guards become `require()` statements. Arcs become storage updates. Events become Solidity events. A complete Foundry test harness and genesis script are generated alongside the contract. The output is deployable, not a sketch. **3. .btw DSL**. A compact textual syntax for the same models: ``` schema ERC20 { version "1.0.0" register ASSETS.AVAILABLE map[address]uint256 observable fn(transfer) { var from address var to address var amount amount require(ASSETS.AVAILABLE[from] >= amount) ASSETS.AVAILABLE[from] -|amount|> transfer transfer -|amount|> ASSETS.AVAILABLE[to] } } ``` The `-|amount|>` arc syntax reads like a Petri net: consume `amount` tokens from `ASSETS.AVAILABLE[from]`, produce `amount` tokens at `ASSETS.AVAILABLE[to]`. The DSL compiles to the same JSON-LD schema that the visual editor produces. There is no translation layer between these three outputs. They share the same metamodel — the same states, actions, arcs, and guards. A bug in the Solidity contract means a bug in the model, which means the ZK circuit would also reject it. They can't diverge because they're the same structure rendered three ways. ## Token Standards Are Petri Nets The ERC templates are where the abstraction proves itself. Every major token standard maps to a Petri net: **ERC-20** (fungible tokens): three states (`totalSupply`, `balances`, `allowances`), five actions (`transfer`, `approve`, `transferFrom`, `mint`, `burn`). The guard `balances[from] >= amount` is both a ZK constraint and a `require()` statement. **ERC-721** (NFTs): token ownership as place markings, approval as a separate state, safe transfer checks as guards. **ERC-1155** (multi-token): batch operations as parallel transitions, per-token-id balances as parameterized places. **ERC-4626** (tokenized vaults): deposit/withdraw/redeem with share conversion — the exchange rate is a function of the marking. **ERC-5725** (vesting NFTs): temporal claims gated by block timestamps, revocation as an inhibitor arc. These aren't approximations: the bitwrap ERC-20 template produces a contract that handles `transfer`, `approve`, `transferFrom`, `mint`, and `burn` with correct event emission, access control, and overflow protection — directly from the Petri net structure. ## Content-Addressed Models Every model saved through bitwrap gets a CID — a content identifier computed from JSON-LD canonicalization (URDNA2015), SHA2-256 multihash, CIDv1 encoding. Same model always produces the same CID. Models are immutable. This closes the loop from OP_RETURN. In 2014 we wanted to anchor structured state to a chain via a hash. Now we have: - A formal model (Petri net) defining the state machine - A content address (CID) identifying the model - A ZK proof that a transition was valid under that model - A Solidity contract that enforces the same rules on-chain The 80-byte OP_RETURN hash was pointing at something we hadn't built yet. The CID of a Petri net model is what it was pointing at. ## Client-Side Witnesses The witness — your actual balances, your position in the Merkle tree — never leaves your browser. Bitwrap publishes JavaScript modules (MiMC hash, Merkle tree, witness builder) that run client-side. The prover backend receives the witness and returns a proof, but a future version could run the entire prover in WASM (the 22MB `prover.wasm` binary already exists). This is the SegWit principle applied to application state: the witness is yours. The chain sees only the proof. ## The Ecosystem Bitwrap sits at the top of the [pflow ecosystem](/posts/revisiting-the-flows): - **[go-pflow](https://github.com/pflow-xyz/go-pflow)** provides the core Petri net library, ODE solver, and prover infrastructure - **[pflow.xyz](https://pflow.xyz)** is the visual editor for drawing nets - **[The Token Language](/posts/token-language)** defines the four-term DSL (`cell`, `func`, `arrow`, `guard`) - **Bitwrap** compiles these models into deployable artifacts — proofs and contracts The capstone is one visual model flowing through the entire stack without impedance mismatch, not the ZK prover or the Solidity generator in isolation. Draw a net, prove a transition, deploy a contract. Same arcs, same guards, same semantics. ## Try It Bitwrap shipped as a hosted app at app.bitwrap.io (since archived; source at [stackdump/bitwrap-io](https://github.com/stackdump/bitwrap-io)). The editor, prover, and Solidity generator were all available there. The Remix plugin integrated directly with the Solidity IDE for contract deployment. For ZK voting, see vote.bitwrap.io. The source is at [github.com/stackdump/bitwrap-io](https://github.com/stackdump/bitwrap-io). For a different take on Petri nets in action, see [Petri Nets as a Music Sequencer](/posts/petri-net-sequencer) — the same token-flow model, making beats. --- *What started as an 80-byte hash in a Bitcoin transaction became a pipeline: model, prove, deploy. Petri nets were the missing witness structure, and the net topology is the specification.* --- # ZK Polls: Voting as a Visible State Machine - URL: https://blog.stackdump.com/posts/zk-polls-voting-as-state-machine - Date: 2026-03-19 - Tags: petri-net, zk, voting, groth16, gnark, governance, solidity - Summary: Anonymous polls in bitwrap-io: a small Petri net whose cast-vote transition carries a Groth16 proof of registry membership, nullifier and range — and what the current code does about secrecy after the poll closes. # ZK Polls: Voting as a Visible State Machine *Prerequisite: [Zero-Knowledge Proofs for Petri Nets](/posts/zk-petri-nets) covers how a transition and its guard become circuit constraints; this post assumes it.* vote.bitwrap.io ran anonymous polls (the hosted app has since been archived; the code lives in [stackdump/bitwrap-io](https://github.com/stackdump/bitwrap-io)). Create a poll, share a link, collect votes. Each voter proves they're eligible with a zero-knowledge proof — "I'm on the list" without saying *who* on the list — and the rules the proof enforces are a Petri net small enough to read in one sitting. ## Four places, three moves — and a fifth place since ![ZK Poll Petri Net Model](/images/zk-polls-voting-as-state-machine/poll-model.svg) The diagram is the net as it was when this post went up: four places — `voterRegistry` (who may vote, as Merkle-backed commitments), `nullifiers` (who has already voted, without identities), `tallies` (count per choice) and `pollConfig` (pending, active, closed) — and three transitions. `createPoll` is guarded on `pollConfig == 0`; `castVote` on an active poll and an unused nullifier, and it writes the nullifier and adds to a tally; `closePoll` ends it. The template in the repo today, [erc/vote.go](https://github.com/stackdump/bitwrap-io/blob/main/erc/vote.go), adds a fifth place and a fourth transition: `registerVoter` puts a token in `registrySlots` and `castVote` consumes one, so in the net `castVote` is disabled once every registered voter has used theirs. That template is the one the server loads (`?template=vote` in the editor), and it is the source the circuit and the contract are generated from. ## What the proof checks When a voter casts a ballot, the browser builds a Groth16 proof against [`VoteCastCircuit`](https://github.com/stackdump/bitwrap-io/blob/main/prover/vote_gen.go), which is generated from the `castVote` action's declared ZK obligations. Its public inputs are the poll id, the registry's Merkle root, the nullifier, the vote commitment and the number of choices, and its `Define` checks four things: the voter's leaf, `mimcHash(voterSecret, voterWeight)`, sits under that root along a depth-20 path; the nullifier equals `mimcHash(voterSecret, pollId)`, so it is unique per poll and doesn't link a voter across polls; the choice fits in 8 bits and is below `maxChoices`; and the commitment equals `mimcHash(voterSecret, voteChoice)`. "Hasn't voted yet" is not one of them. The original version of this post listed it as a fifth thing the proof guarantees, but the circuit can't see the nullifier set — that check happens outside it, in the server's vote store (which rejects a reused nullifier) and in the generated contract's `require(!nullifiers[_nullifier], "already voted")`. The proof establishes that the nullifier is the right one for this voter and this poll; the state machine is what refuses it twice, which is the split you'd expect, since `nullifiers` is a place and the guard reads it. The post originally quoted about 14,600 constraints and 2–5 seconds to prove in the browser. The prover reports the constraint count when it compiles a circuit, but no test in the repo pins either number, and neither was re-measured for this revision; take them as the March figures. ![VoteCast Proof Flow](/images/zk-polls-voting-as-state-machine/proof-flow.svg) ## How secret the ballot is While a poll is open the commitment is all anyone sees. The commitment's hiding comes from `voterSecret` — 31 random bytes (248 bits) when no wallet is used, otherwise a 248-bit slice of a wallet signature — because with only a handful of choices, anyone who has the secret can recover the choice by hashing each option. The results endpoint returns only a vote count until the poll closes, so an observer can't diff tallies after each vote to correlate timing. What the original post said next — that the choice never leaves the browser in plaintext and the server never learns who picked what — isn't what the code does for the first two poll schemas. In v1 and v2 the tally is built after close: each voter posts their choice and `voterSecret` to `/api/polls/{id}/reveal`, the server checks `mimcHash(secret, choice)` against the stored commitment, and it keeps a (nullifier, choice, secret) bundle for the tally proof ([internal/server/polls.go](https://github.com/stackdump/bitwrap-io/blob/main/internal/server/polls.go)). The server then holds nullifier→choice; whether it can get from there to a person depends on how registration ties a commitment to a wallet. v1's secret is the raw signature slice, so anyone holding that signature can recompute it; v2 mixes in a voter-chosen nonce kept in the browser and the downloaded vote backup. The [homomorphic-tally spec](https://github.com/stackdump/bitwrap-io/blob/main/docs/homomorphic-tally-spec.md) calls this the post-close deanonymization gap. v3 polls are how the repo closes it. A v3 voter submits one additively homomorphic ElGamal ciphertext per choice with a proof ([`voteCastHomomorphic_8`](https://github.com/stackdump/bitwrap-io/blob/main/prover/vote_homomorphic_gen.go)) that the vector is one-hot and the ciphertexts well formed; the poll creator decrypts only the aggregate and publishes a decryption proof (`tallyDecrypt_8`); and `/reveal` on a v3 poll returns 404. The handlers are in [internal/server/polls_v3.go](https://github.com/stackdump/bitwrap-io/blob/main/internal/server/polls_v3.go). The spec's header still says "design (not yet implemented)", while the handlers, generated circuits and tests are in the tree; we read the code, but didn't run the tests here. ## Tallies by replay Every action — create, vote, close — is appended to the poll's event log, and the tallies the results endpoint returns for a closed v1/v2 poll are derived by replaying that log through a fresh runtime built from the same vote template ([internal/server/pollstate.go](https://github.com/stackdump/bitwrap-io/blob/main/internal/server/pollstate.go): `State(t) = fold(apply, initialState, events[0..t])`). That's narrower than the original claim that poll state "isn't sitting in a database." The poll record (title, choices, status) and the vote records are stored on their own, and the replay runs with constraint checking off because guards are enforced at the API layer. What replay gives you is a tally anyone can recompute from the log, not a system whose only state is the log. ## On chain The same template generates a Solidity contract (`BitwrapZKPoll.sol`). Its `castVote` is a vote-specific case in the generator ([solidity/codegen.go](https://github.com/stackdump/bitwrap-io/blob/main/solidity/codegen.go)): it requires an unused nullifier, verifies the Groth16 proof against the same five public inputs, marks the nullifier and stores the commitment, all in one transaction. The server packages contract, verifier, tests and deploy script as a Foundry bundle at `/api/bundle/vote`, and the v3 settlement contract at `/api/bundle/vote-v3`; the public host that served them is gone, so today that means running bitwrap-io yourself. The post said "all 8 Foundry tests pass." Two qualifications. The tests come from [solidity/testgen.go](https://github.com/stackdump/bitwrap-io/blob/main/solidity/testgen.go) and run against a `MockVerifier` whose `verifyProof` always returns true, so they exercise the state machine — a double vote reverts, a vote on an inactive poll reverts, the guards hold — not proof verification. And eight was the count for the three-transition net; reading the generator against the current template gives nine (one per action, one per guard plus a second for `castVote`, one for the views). Not re-run. ## How this compares Snapshot delegates voting strategy to off-chain scripts. MACI uses ZK proofs but relies on a coordinator who can decrypt individual messages to tally. Vocdoni runs its own chain. The claim here is that the diagram, the circuit and the contract are three views of one declaration, and in bitwrap-io they do come from one: `erc/vote.go` is the schema, `vote_gen.go` is marked "generated by bitwrap synth" from it, and the contract is generated from it too. Two seams stay visible, though. The `castVote` ZK obligations aren't arcs — the template's own comment says they "aren't arc-expressible" and declares them as nullifier, commitment and range operations — and the contract's `castVote` is hand-shaped inside the generator. v3 also moves part of the way toward MACI: its creator holds the key that decrypts the aggregate, though not any single ballot. ## Code - **Template**: [erc/vote.go](https://github.com/stackdump/bitwrap-io/blob/main/erc/vote.go) — places, transitions, guards and ZK obligations - **Circuit**: [prover/vote_gen.go](https://github.com/stackdump/bitwrap-io/blob/main/prover/vote_gen.go) — `VoteCastCircuit`, registered in [prover/circuits.go](https://github.com/stackdump/bitwrap-io/blob/main/prover/circuits.go) - **Server**: [internal/server/polls.go](https://github.com/stackdump/bitwrap-io/blob/main/internal/server/polls.go) (v1/v2, reveal) and [polls_v3.go](https://github.com/stackdump/bitwrap-io/blob/main/internal/server/polls_v3.go) (homomorphic) - **Contract and tests**: [solidity/codegen.go](https://github.com/stackdump/bitwrap-io/blob/main/solidity/codegen.go), [solidity/testgen.go](https://github.com/stackdump/bitwrap-io/blob/main/solidity/testgen.go) - **Repo**: [stackdump/bitwrap-io](https://github.com/stackdump/bitwrap-io) (this post was first written against v0.6.3) ## What Changed For readers of the March version (2026-10-25): 1. **The net grew.** The current template has five places and four transitions (`registerVoter`, `registrySlots`); the diagram still shows the original four and three. 2. **Four checks in the proof, not five.** The circuit proves membership, nullifier, range and commitment; "hasn't voted" is enforced by the nullifier set in the server and the contract. The circuit link now points at `vote_gen.go`, where `VoteCastCircuit` is defined. 3. **Secrecy is per schema.** v1/v2 tallies come from a post-close reveal that hands the server choice and secret; only v3 keeps individual choices encrypted. The old "neither the server nor a blockchain ever sees who picked what" was too strong for v1/v2. 4. **Replay, narrowed.** Tallies are derived by replay; poll and vote records are stored as well. 5. **Foundry tests.** They run against a mock verifier, and the generator now emits nine for the current template, not eight. 6. **Numbers hedged.** ~14,600 constraints and 2–5 s proving are the March figures, not re-measured. The vote.bitwrap.io links are gone with the hosted app. *Related: [Zero-Knowledge Proofs for Petri Nets](/posts/zk-petri-nets) · [Bitwrap: Petri Nets as ZK Containers](/posts/bitwrap-capstone)* --- # Petri Nets as a Music Sequencer - URL: https://blog.stackdump.com/posts/petri-net-sequencer - Date: 2026-03-25 - Tags: petri-nets, music, beats-bitwrap, tone-js - Summary: A music sequencer built entirely on Petri nets — token rings become drum machines, Euclidean rhythms fall out of the topology, and polyrhythm comes free. # Petri Nets as a Music Sequencer Go to [beats.bitwrap.io](https://beats.bitwrap.io). Pick a genre — techno, jazz, ambient, drum & bass, 19 in total. Hit Generate. You'll hear a full track: drums, bass, melody, arpeggios. Change the seed, get a different track. Replay the same seed, get the exact same one. There's no piano roll behind this. No timeline grid, no DAW. The whole thing runs on Petri nets — circles connected by arrows, with tokens moving through them. Every note is literally a token moving one step through the diagram. ## A Drum Machine Made of Tokens Picture a ring of 8 circles connected by arrows. One dot — a token — sits at the first circle. Every tick, it hops to the next one. Some circles trigger a kick drum when the token lands. The rest are silent. The token goes around and around. You've got a beat. The pattern of hits comes from [Euclidean rhythm](https://en.wikipedia.org/wiki/Euclidean_rhythm) — popularized by Godfried Toussaint, it spreads K hits across N steps as evenly as possible. With 3 hits across 8 steps you get the [tresillo](https://en.wikipedia.org/wiki/Tresillo_(rhythm)), a rhythm that shows up everywhere from Afro-Cuban son to Rihanna. ![Tresillo rhythm as a token ring](/images/petri-net-sequencer/token-ring.svg) That ring of circles? It's called a **Petri net**. The circles are *places*, the arrows pass through *transitions*, and the dot is a *token*. That's the whole vocabulary. Change the numbers — 4 hits across 16 steps — and you've got four-on-the-floor techno. Same structure, different rhythm. Each instrument gets its own ring. Kick, snare, hihat, clap — all running in parallel. Make the rings different lengths and they drift in and out of sync, giving you **polyrhythm** for free. A 6-step hihat over a 16-step kick sounds like the cross-rhythms in Afro-Cuban and West African music, because it *is* the same math. ## Everything Is a Ring Melodies work the same way. Instead of drum hits, each step carries a note — pitch, velocity, duration. The music theory picks notes that make sense over the chord (chord tones on strong beats, passing tones on weak beats, rests so it can breathe), and lays them out in a ring. Bass lines? Ring. Arpeggios? Ring. Harmony pads? You guessed it. Pick a genre — techno, jazz, ambient, drum & bass — and a preset fills in the details: scales, chords, drum patterns, how much swing, how much humanize. The generator assembles a bunch of rings, and the sequencer just... runs them. No AI, no samples — music theory and random numbers with a seed. Same seed, same track, every time. ## From Loops to Songs Loops are easy — tokens go around, patterns repeat. But a *song* needs intros, drops, breakdowns. The hihat should disappear for 8 bars, then come back. The bass should sneak in during the second verse. So we added **control nets** — rings that don't make sound but fire commands like "mute the hihat" or "bring in the bass." A tiny stage director made of circles and arrows. The first version was hilariously wasteful: a chain of 1,536 individual steps, one per tick, with a command every few hundred steps and nothing in between. It worked! But the project file was 22 MB for a 3-minute track. The fix: **countdown timers**. Instead of 384 empty steps before a section change, we put 384 tokens in a single circle and drain one per tick. A special connection (called an *inhibitor arc*) holds back the control event until the countdown hits zero. Same result, way less diagram: | | Before | After | |---|---|---| | Places | 37,148 | 800 | | Transitions | 37,120 | 800 | | File size | 22 MB | 242 KB | One circle with many tokens does the job of many circles with one token each. Petri nets are flexible like that. ## Transitions Are Just More Net Auto-DJ regenerates a new track at the boundary, and we wanted a curated filter sweep, white-noise wash, or riser to land on the downbeat — not race the project swap from the main thread. The first attempt coordinated main-thread timers against worker-side regen state. It mostly worked, except when a user gesture overlapped the boundary and the macro got silently dropped. We were fighting the runtime. The fix was to stop treating transitions as a side channel. Each Auto-DJ transition is now injected as a one-transition control net with a `fire-macro` binding. `t0` fires on tick 1 of the new track; the worker emits `control-fired`; the main thread runs the macro through the normal queue. Tick-synchronized by construction, because the Petri net executor is already the scheduler. Two timing bugs — the project-swap race and the macro-queue drop — collapsed into one piece of structure. The transition is just another token moving through the graph. Details in the [v1.1.0 release notes](https://github.com/stackdump/beats-bitwrap-io/releases/tag/v1.1.0); the curated transition pool itself shipped in [v1.0.8](https://github.com/stackdump/beats-bitwrap-io/releases/tag/v1.0.8). ## What the Structure Gave Us The token state is the only state, so the same seed produces the same nets and the same notes on any machine — no hidden counters, no drift. The same diagram that runs the music can be drawn on screen and watched: when something sounds wrong, we can *see* a token stuck in the wrong place or a ring out of phase. And because each instrument is its own net and song structure is a separate layer, pulling one out or adding another doesn't break anything else. None of this took extra work; it's just what falls out of having a single primitive carry concurrency, state, and scheduling. ## Try It ![beats.bitwrap.io demo](/images/petri-net-sequencer/beats-btw.mp4) [beats.bitwrap.io](https://beats.bitwrap.io) — 19 genres, all client-side, nothing to install. A huge shoutout to [Tone.js](https://tonejs.github.io/), which does all the heavy lifting on the audio side. Petri nets decide *what* plays and *when* — Tone.js makes it actually sound good. Polyphonic synths, reverb, compression, sub-millisecond scheduling — we got all of that essentially for free. It's one of those libraries where you keep expecting to hit a wall and never do. The Petri net executor is ~400 lines of vanilla JS, with another ~700 in the sequencer worker. The rest — music theory, generators, UI — is bigger, but none of it is framework code. No npm, no bundler. Tone.js from CDN, a Go server that serves static files, and a 60-year-old idea about circles and arrows that turned out to be a pretty good sequencer. Source: [github.com/stackdump/beats-bitwrap-io](https://github.com/stackdump/beats-bitwrap-io) For the formal theory behind why token flow works as a computational model, see [Declarative Differential Models](/posts/declarative-differential-models). To build and simulate your own nets, try [pflow.xyz](https://pflow.xyz). --- *Edit 2026-08-08: this post was [discussed on Hacker News](https://news.ycombinator.com/item?id=49158934). Following the thread, "Bjorklund algorithm" above was changed to "Euclidean rhythm" — the standard term in the field, popularized by Godfried Toussaint. The mobile-performance issues commenters reported are tracked in [beats-bitwrap-io#2](https://github.com/stackdump/beats-bitwrap-io/issues/2).* --- # The Little Language Thesis - URL: https://blog.stackdump.com/posts/little-language-thesis - Date: 2026-03-28 - Tags: petri-net, dsl, domain-driven-design, little-languages, composition, state-machine, formal-methods, forth, tla-plus - Summary: cell, func, arrow, guard are the structural primitives — but the real ubiquitous language lives in the labels. Like Forth, we build up domain vocabularies on a minimal substrate, and the incidence matrix proves what it can see: conservation, not guards or data. # The Little Language Thesis *Prerequisite: [The Token Language](/posts/token-language), which introduces the four terms this post builds on.* Jon Bentley argued that tiny purpose-built languages — `make`, `awk`, `pic` — outperform general-purpose ones for well-scoped problems. Eric Evans argued that developers and domain experts should share a single vocabulary — what he called "ubiquitous language." Both were pointing at the same thing: the best tool for a domain is a language that speaks the domain's own words. What neither says outright is that in the systems that work, the structural language and the domain language are *different layers*. The labels make the model readable to humans; the primitives make it analyzable by machines. ## Complexity You Can Hear The [beats sequencer](https://beats.bitwrap.io) is where the two layers come apart most visibly, though not quite where we first said they did. Each instrument is a ring: [`euclidean.go`](https://github.com/stackdump/beats-bitwrap-io/blob/main/internal/generator/euclidean.go) builds places `p0`…`p(n-1)` and transitions `t0`…`t(n-1)`, puts one token on `p0`, and attaches a MIDI binding (note, channel, velocity) to the transitions at hit positions. The places carry no domain words at all. The vocabulary lives one level up — the net *is* the kick, the snare, the hihat — and in the control nets, whose transitions fire actions named `mute-track`, `unmute-track`, `activate-slot` and `fire-macro` ([`arrange.go`](https://github.com/stackdump/beats-bitwrap-io/blob/main/internal/generator/arrange.go)). A musician works in those words and never needs Petri net theory. And because the sequencer turns token flow into sound, a wrong model is something you hear, not only a test that fails. Underneath, the four structural primitives — `cell`, `func`, `arrow`, `guard` — give that vocabulary a formal meaning. Some of it can be read straight off the structure: every transition in a ring consumes from one place and produces into the next, so the token count around the ring is a conservation law of the net. We have not run go-pflow's reachability or deadlock analyzers over generated tracks for this post; that the tools *could* is a claim about the substrate, not something shown here on a real track. The word "composition" is doing triple duty. Firing a sequence of transitions adds the corresponding columns of the incidence matrix to the marking (M' = M + C·u), so the arithmetic of runs is integer linear algebra — algebraic composition. The nets themselves compose as morphisms in a symmetric monoidal category, wiring outputs to inputs — categorical composition. And the control nets that wire `kick ⊗ snare ⊗ hihat` into an arrangement are musical composition. The same four words describe all three. ![Three Meanings of Composition](/images/little-language-thesis/composition-layers.svg) ## The Forth Precedent [Forth](https://en.wikipedia.org/wiki/Forth_(programming_language)), the stack language Charles Moore grew out of a personal system he'd been building since 1968, is the oldest instance of this two-layer architecture we know of. A Forth programmer builds a domain vocabulary and then writes the application in it. A Forth program for controlling a telescope wouldn't look like Forth; it would look like telescope commands. The stack primitives (`DUP`, `SWAP`, `DROP`, `OVER`) are the structural layer — the invisible substrate nobody talks about. What people *read* and *speak* are the words built on top: `SLEW`, `TRACK`, `CALIBRATE` (our illustration, not taken from a real system). Moore was practicing ubiquitous language decades before Evans gave it a name. You don't see `DUP SWAP` in a finished Forth application any more than a musician sees `cell func arrow` while sequencing a drum pattern. But Forth is Turing-complete, so you can build anything and prove very little about it. The trade we want is to keep the pattern and give up the power. ## The Structural Substrate The [token language](/posts/token-language) that drives our Petri net tools is built on four structural primitives: | Primitive | Role | Structure | |-----------|------|-----------| | `cell` | a place that holds things | state container | | `func` | an action that changes things | state transition | | `arrow` | a connection with direction | flow and dependency | | `guard` | a condition that must be true | constraint | (`arrow` is our name for what Petri net theory calls an *arc* — the same concept, friendlier.) Those four are the structural core, but they are a vocabulary for talking about the grammar, not a count of the parser's keywords. The working DSL in go-pflow spells them `state`, `action`, `arc` and `:guard`, and carries more besides — typed data state like `map[address]uint256`, arc keys, and `constraint` forms for invariants ([`tokenmodel/dsl`](https://github.com/pflow-xyz/go-pflow/tree/main/tokenmodel/dsl)). go-pflow's lower-level `petri` package also has inhibitor arcs, which are a second kind of arrow. These terms are the encoding, the substrate domain languages get built on, and nobody uses them in a meeting. Nobody says "we need a new func." They say "we need a `transfer` operation" or "add a `mute` control." The ubiquitous language lives in the labels. In an ERC-20 token model the cells are `balances`, `totalSupply`, `allowances`, and the funcs are `transfer`, `mint`, `burn`. A financial engineer and a Solidity developer already share these words. They don't need to know what a "cell" is — they need to know that `balances` feeds into `transfer`, guarded by sufficient funds. *That sentence* is the ubiquitous language, and the guard go-pflow actually evaluates is `balances[from] >= amount && to != address(0)` ([`tags_test.go`](https://github.com/pflow-xyz/go-pflow/blob/main/tokenmodel/dsl/tags_test.go) builds the full model). ![Two Traditions, Two Layers](/images/little-language-thesis/dsl-lineage.svg) | | Forth | Petri Net DSL | |---|-------|--------------| | **Structural primitives** | `DUP`, `SWAP`, `DROP`, `OVER` | `cell`, `func`, `arrow`, `guard` | | **Domain vocabulary** | User-defined words (`SLEW`, `TRACK`) | Labels on places, transitions and nets (`balances`, `transfer`, `kick`) | | **Visible state** | The stack | The marking (tokens in places) | | **Composition** | Word concatenation | Net wiring (shared places, control nets) | | **What you ship** | A domain vocabulary | A labeled net | Each model constructs a domain vocabulary on top of a fixed grammar. The domain language is whatever the domain already speaks. ## The Incidence Matrix Bentley's little languages work because they're *closed* — you can't escape into general-purpose complexity. A regular expression can't count, which is exactly why it compiles to a finite automaton. The constraint is the feature. Every Petri net has an incidence matrix C: rows are places, columns are transitions, and C[p][t] is the weight of t's output arc into p minus the weight of p's input arc into t ([`reachability/invariants.go`](https://github.com/pflow-xyz/go-pflow/blob/main/reachability/invariants.go)). An earlier version of this post said the entire behavior of any model is captured in that matrix. It isn't, and the code says so. Inhibitor arcs are skipped when C is built, because they gate firing without moving tokens. Guards never reach it — they are predicates over the marking, the contextual layer that [The Category Settle](/posts/category-settle#two-boundaries-not-one) calls R. And in the token-model package, keyed arcs into data maps like `balances` enter C at unit weight ([`tokenmodel/petri/analysis.go`](https://github.com/pflow-xyz/go-pflow/blob/main/tokenmodel/petri/analysis.go)), so for ERC-20 the matrix sees that `transfer` moves a balance, not how much. What C does give us is integer linear algebra, and that is a lot. P-invariants are the semi-positive vectors y with yC = 0; go-pflow computes a minimal-support basis with the Farkas algorithm, so an empty result is a proof that the net has no token-conservation law — structure alone, no state space explored. ODE simulation works because C defines the continuous relaxation. Reachability and deadlock detection are a different kind of check: go-pflow finds them by exploring the state graph ([`reachability/graph.go`](https://github.com/pflow-xyz/go-pflow/blob/main/reachability/graph.go)), which does respect inhibitor arcs and stops at 10,000 states unless told otherwise. They are answerable for a bounded net because its state space is finite, not because the matrix answers them. What we give up is the point: no unbounded recursion, no implicit state. The line is thinner than "four primitives, no fifth" makes it sound, though. Ordinary place/transition nets are not Turing-complete, but nets with inhibitor arcs can simulate counter machines and are, and a guard over a data map is an arbitrary predicate — both are already in the toolkit. What keeps the analysis honest is not the number of primitives but which layer each one lives in. What C sees can be proved structurally; what sits outside C has to be checked by exploring states. ## Why a Substrate Matters Without a structural substrate, labels have no formal meaning. Write a state machine in Go and `transfer` is just a function name — you can't ask "is this deadlock-free?" without writing a model checker. Write it on the four-term substrate and `transfer` becomes a transition with input arcs, output arcs, and guards that tools can reason about. Same vocabulary, now analyzable. We think this is one reason Evans' ubiquitous language erodes in practice. Teams agree on shared vocabulary, write it into Go or Java, and the vocabulary drifts because the programming language imposes its own structure. The domain words get buried in implementation noise. The four-term substrate has no vocabulary of its own to compete with them. ## TLA+ and the Two-Layer Split [TLA+](https://en.wikipedia.org/wiki/TLA%2B) is the most serious alternative here. Newcombe et al.'s [account of TLA+ at Amazon Web Services](https://lamport.azurewebsites.net/tla/amazon-excerpt.html) describes the model checker finding a bug in DynamoDB's replication and fault-tolerance design that could lose data. But TLA+ conflates the two layers — its structural primitives *are* the vocabulary. Domain words like `balances` and `sender` are embedded in set theory and temporal logic (`\E x \in S : P(x)`, primed variables, `EXCEPT` notation). A financial engineer won't read a TLA+ spec and see their workflow. The tradeoff is real: we give up TLA+'s generality — no arbitrary temporal properties, no fairness specs. For properties that fall out of net structure, we get the checks with a lower barrier to entry: conservation from C, and reachability, deadlock freedom and boundedness by exploring a bounded net's states. A domain expert names places and transitions in their own words, and the analysis happens underneath. We have not put the two side by side on one model; that comparison would be the real test of the barrier-to-entry claim. ## The Test The test we keep applying is whether a domain expert can label the places and transitions, and a developer can read those labels as a spec. Token standards pass cleanly: "the `balances` place feeds into `transfer`, guarded by sufficient funds" is both what an engineer would say and what the [ERC-20 schema](/posts/token-language) declares, and go-pflow parses and executes that schema. Games pass more weakly — the [tic-tac-toe model](/posts/tic-tac-toe-model) names squares `P00`–`P22` and moves `X00`…`O22`, which is board notation more than a designer's vocabulary. Music passes one level up from where the thesis says it should: beats puts `kick`, `snare` and `mute-track` on nets and control actions, while its places stay `p0`, `p1`, … — and if the model is wrong, you hear it. ## The Thesis *The ubiquitous language for a domain can come from labeling a Petri net — its places and transitions, or as beats shows, the nets themselves — in that domain's own words, with four structural primitives underneath that make the labels executable, and provable for the part the incidence matrix sees.* For the class of problems that involve "things in states, changing according to rules," the pattern has held in the models we've built: a small structural grammar, a domain-specific vocabulary, and a clean separation between the two. ## What Changed For readers of the April version: 1. **Beats labels.** The post said beats places are `kick_0`, `snare_3`, `hihat_5` and its transitions are `trigger`, `mute`, `unmute`. The generator names places `p0`… and transitions `t0`…; instrument names are on the nets, and the control actions are `mute-track`, `unmute-track`, `activate-slot`, `fire-macro`. Corrected, with links to the generator source. 2. **The matrix is not the whole model.** "The entire behavior of any model is captured in a matrix of integers" was wrong: C omits guards and inhibitor arcs, and keyed data arcs enter it at unit weight. Reachability and deadlock come from state exploration, not from C. The "add a fifth primitive and analysis breaks" claim is replaced by the layer distinction. 3. **"No fifth primitive."** The four terms are the conceptual grammar; the DSL keywords differ, and the DSL also has data types, keys and constraints. 4. **Tic-tac-toe labels** were given as `p0`–`p8` / `play_X_0`; the model uses `P00`–`P22` / `X00`. 5. **TLA+ at Amazon.** The linked excerpt describes a DynamoDB bug and doesn't mention S3, so S3 is no longer cited from it. 6. **"Seven lines"** for the ERC-20 schema was dropped; the schema in the token-language post is longer than that. --- *Hypothesis: four structural primitives plus a labeling layer suffice for "things in states, changing according to rules." Tried on music, token standards and games. It fails if a domain expert can't label the parts, if composed nets don't behave as their composition, or if a property we need can't be checked either from C or by exploring states. The original footer claimed the incidence matrix recovers all behavioral properties and that no test had failed; that one did, and the [incidence matrix section](#the-incidence-matrix) now says what C misses.* *Related: [The Token Language](/posts/token-language) · [The Category Settle](/posts/category-settle) · [Petri Nets as a Music Sequencer](/posts/petri-net-sequencer) · [A Generator You Can Read](/posts/generator-you-can-read) · [Symmetric Monoidal Categories](/posts/symmetric-monoidal-categories) · [Small Models > LLMs](/posts/small-models-not-llms) · [Earned Compression](/posts/earned-compression) · [pflow.xyz](https://pflow.xyz) · [beats.bitwrap.io](https://beats.bitwrap.io)* --- # The Zipper Whose Hole Is a Universe - URL: https://blog.stackdump.com/posts/tense-type-theory - Date: 2026-04-04 - Tags: petri-net, type-theory, tropical-geometry, category-theory, tic-tac-toe - Summary: Execution state is a zipper — the present moment is not a parameter or a modality but a universe that separates tropical past from predicate future. Tic-tac-toe makes the structure visible. # The Zipper Whose Hole Is a Universe Undo/redo. Cursor position in a text editor. The call stack. These are all zippers — Huet's 1997 observation that a data structure can be decomposed into a hole (the current focus) and two complementary contexts: what came before, what comes after. Navigation is moving the hole. We've been building something that looks like a zipper for the last several posts without calling it one. The [tropical semiring](/posts/tropical-petri-nets) accumulates past firings into a compressed summary. The predicate layer in every model we've built — guards, win detection, turn enforcement — constrains what fires next, given the current marking. The marking itself sits between them: the output of accumulation, the input to constraint. That's a zipper. The marking is the hole. And the hole is not just a value — it is the universe relative to which past and future are both defined. ![Execution State as Zipper](/images/tense-type-theory/zipper-diagram.svg) --- ## Time as Parameter, Modality, or Universe There are two established ways to handle time in a type system, and both miss something. Schultz and Spivak's temporal type theory treats time as a parameter — an interval you index over. Rich and compositional, but the current moment has no special status. It's just a point on the coordinate. Prior's tense logic treats past and future as modalities — operators that shift perspective relative to an implicit now. Expressive, but the boundary between past and future is a semantic convention, not a structural fact. Both smuggle the present in through the side door. A Petri net doesn't. In a [DDM simulation](/posts/declarative-differential-models), on every step you read the marking, check guards, fire a transition, update the marking. The marking is not derived from anything. It is not a point on a timeline. It is the thing the computation is *about*, locally, right now. Change the marking and you are in a different universe — different history is relevant, different transitions are enabled. The marking is the present moment, and the present moment is a universe. The zipper makes this structural. ![Petri Net as Zipper](/images/tense-type-theory/petri-net-zipper.svg) --- ## Two Contexts **The left context is closed.** Every transition that has fired contributes to it, but we don't store the trace — we summarize it. The [tropical semiring](/posts/tropical-petri-nets) (max-plus algebra) accumulates firing history as a matrix of longest paths. This is memory in the precise algebraic sense: a lossy compression that preserves exactly what the future needs to know. Fast-forward is matrix multiplication. Past firings are irreversible, and the tropical core is the proof. The tropical semiring is not chosen for convenience. Simulate a Petri net iteratively and the past *has been tropical all along* — max-plus accumulation is what the left context does. We documented this in [Tropical Petri Nets](/posts/tropical-petri-nets) without using the zipper framing, but the structure was already there. **The right context is open.** Guards constrain what can fire next, given the current marking. The future is not stored — it is computed fresh from the hole on every step. When the marking changes, the right context recomputes. This is the predicate layer we build in every model: win detection, turn enforcement, balance checks. **The hole — the marking — is the tense boundary.** It is simultaneously the output of tropical accumulation and the argument to the predicate layer. It is where the two universes meet. | | Left Context | Hole | Right Context | |---|---|---|---| | **Structure** | Tropical core | Marking | Predicate layer | | **Tense** | Past | Present | Future | | **Operation** | Accumulate | Focus | Constrain | | **Property** | Closed, irreversible | Boundary | Open, recomputed | --- ## What SMC Encoding Loses The [standard categorical treatment](/posts/symmetric-monoidal-categories) of Petri nets maps them into a free symmetric monoidal category. Markings become objects. Firing sequences become morphisms. Composition is well-defined. The hole disappears. In the SMC encoding there is no privileged present. The current marking is smeared across the morphism structure — just another configuration, related to others by transition morphisms. Everything is homogeneous. The zipper has been flattened into a path. This is the right encoding for *process structure* — it's a theorem about what compositions are valid. But it was never a foundation for *computation*. Every serious computational use of Petri nets — workflow engines, protocol stacks, ZK circuits — has to bolt mutable execution state onto the immutable categorical skeleton. The zipper framing gives the diagnosis: you cannot focus without a hole, and the SMC encoding has no hole. --- ## Tic-Tac-Toe Is a Zipper The [tic-tac-toe model](/posts/tic-tac-toe-model) is small enough to see completely and structured enough to show the point. Every layer of the net maps to a layer of the zipper — not as analogy but as a specific partition of places. ![Tic-Tac-Toe Zipper Layers](/images/tense-type-theory/ttt-zipper-layers.svg) ### The Hole Nine places, `P00` through `P22`. Each holds a token if the cell is empty. One more place — `Next` — holds the turn token. That's the complete present-tense state of the game. Every other layer is defined relative to this marking. ### Right Context `X00` is enabled if and only if `P00` has a token (cell empty) and `Next` is unmarked (X's turn). Same logic for all eighteen move transitions. This is the predicate layer — computed fresh from the hole, never stored. When the marking changes, the right context recomputes. Win detection guards sit here too, typed against the current state of the history layer. ### Left Context Every move transition does two things: consumes the token from the board place (`P00` goes empty) and deposits a token into a history place (`_X00` gets marked). History places are write-once — a token arrives and never leaves. Above the history places sit the pattern collectors we built in the [original post](/posts/tic-tac-toe-model#pattern-collectors--win-detection). `X_has_top_row` fires when `_X00`, `_X01`, and `_X02` are all marked. Three tokens in, one structural token out. This is tropical composition — the pattern collector summarizes moves into a verdict without replaying them. The terminal places `win_x` and `win_o` are the end of the left context. Once marked, the right context collapses. No transitions are enabled. The zipper stops. ![History Layer and Pattern Collectors](/images/tense-type-theory/history-layer.svg) ### A Move Is a Zipper Step X plays center. `X11` fires. - `P11` loses its token — the hole updates - `_X11` gains a token — the left context grows - `Next` gains a token — turn passes to O - The right context recomputes: `X11` disabled, O transitions checked against new marking - Any pattern collector that includes `_X11` has one more input satisfied One step to the right. The past absorbed a firing. The future recomputed against the new present. This is all that ever happens. ### The Split | Layer | Places | Role | |---|---|---| | Left context | `_X00`–`_X22`, `_O00`–`_O22`, pattern collectors, `win_x`, `win_o` | Accumulate, summarize, absorb | | Hole | `P00`–`P22`, `Next` | Current board, current turn | | Right context | Transitions `X00`–`X22`, `O00`–`O22` + guards | Recompute from marking | The [earned compression](/posts/earned-compression) post already documented this split as the core-observer decomposition. The zipper framing adds the temporal interpretation: core places accumulate (past), the marking is the boundary (present), and the observer predicates constrain (future). --- ## ZK Proofs Are Left Context Zero-knowledge proofs are inherently past-tense. A ZK proof witnesses that a valid execution occurred — it proves membership in the left context. The prover shows that the tropical core accumulated correctly. The verifier checks the summary without replaying the trace. We built this in [ZK Petri Nets](/posts/zk-petri-nets), [ZK Tic-Tac-Toe](/posts/zk-tic-tac-toe-model), and [Bitwrap](/posts/bitwrap-capstone). The structure maps directly: - **On-chain state** is the hole — the current marking, the tense boundary - **The ZK proof** is the left context — a compressed witness of valid past transitions - **The smart contract** is the right context — predicates typed against the current marking that constrain what fires next Blockchain engineers already build this way. Proof and execution are different things. Past and future are structurally asymmetric. The current state is the only thing connecting them. The zipper is the formal account of what they already know operationally. ![Blockchain as Zipper](/images/tense-type-theory/blockchain-zipper.svg) --- ## The Structure Three traditions, one gap, one structure. | | Schultz-Spivak | Prior | Zipper | |---|---|---|---| | Time as | Parameter | Modality | Universe | | Present | Point on index | Implicit | The hole | | Past | Interval endpoint | □ operator | Tropical context | | Future | Interval endpoint | ◇ operator | Predicate context | | Boundary | Derived | Conventional | **The type** | The zipper doesn't add time to a type system. It recognizes that execution state already has this shape — the present is a universe, the past is tropical, the future is predicate — and names the structure. The [tic-tac-toe model](/posts/tic-tac-toe-model) already had all three layers. The [tropical analysis](/posts/tropical-petri-nets) already identified the left context's algebra. The [core-observer split](/posts/earned-compression) already found the boundary. This post labels the places. --- *Related: [Tropical Petri Nets](/posts/tropical-petri-nets) · [The Incidence Reduction](/posts/integer-reduction) · [Earned Compression](/posts/earned-compression) · [Symmetric Monoidal Categories](/posts/symmetric-monoidal-categories)* --- # The Pflow Square - URL: https://blog.stackdump.com/posts/pflow-square - Date: 2026-04-07 - Tags: petri-net, category-theory, tropical-geometry, type-theory - Summary: One square that collects six earlier posts — the free SMC on a net, the zipper W(M) = 𝓛 × M × 𝓡, and three analyses that read the ρ boundary off the incidence matrix — with what has been checked and what is still only drawn. # The Pflow Square Six posts from February to April — [symmetric monoidal categories](/posts/symmetric-monoidal-categories), [the zipper](/posts/tense-type-theory), [tropical Petri nets](/posts/tropical-petri-nets), [earned compression](/posts/earned-compression), [ZK Petri nets](/posts/zk-petri-nets) and [the incidence reduction](/posts/integer-reduction) — each supplied an arrow, an object, or a claim that two paths agree. This is the one diagram they fit into. ![The Pflow Square](/images/pflow-square/grand-diagram.svg) The square is [symmetric monoidal categories](/posts/symmetric-monoidal-categories) all the way down. F builds the free SMC from a net. U extracts the universe — the marking space. Exec equips a marking with its execution context. ε extracts the focus. The two paths are meant to commute: composition then forget, or contextualize then extract, same marking space. That is what the square asserts; it is drawn, not proved. Petri nets are the worked example. The structure is SMC. *Revised 2026-10-20. W is FU, the comonad; an earlier revision today relabelled it as the monad UF, and that is undone. The figure now draws FU as the round trip at F(N), and its convergence labels no longer call the three analyses independent or the core reversible. See [What Changed](#what-changed).* ## The Zipper Is the On-Ramp We don't need Petri nets to read the diagram. The [zipper](/posts/tense-type-theory) — Huet's 1997 decomposition of a data structure into a hole and two contexts — is universal. Undo/redo, cursor position, the call stack. Any computation that has a current state, an accumulated past, and a set of possible next steps already has this shape. W(M) = 𝓛 × M × 𝓡 says: wrap a bare state with its [tropical past](/posts/tropical-petri-nets) and its [predicate future](/posts/tense-type-theory). W is the *world*, the construction that makes context explicit, and we take it to be a comonad. A comonad needs two operations: - **ε : W(M) → M** — extract the state, forget context - **δ : W(M) → W(W(M))** — the context itself has a context (nested [DDM](/posts/declarative-differential-models) simulation) The figure labels it W = FU, the round trip taken from the SMC side: forget a symmetric monoidal category down to its underlying net with U, then build the free one back up with F. In an adjunction F ⊣ U that composite is the comonad — UF, the round trip from the net side, is the monad — with counit ε : FU ⇒ Id and comultiplication δ = FηU. The figure draws it as a loop at F(N), so no path around the square should be read as W. FU is a comonad by construction; what we have not done is show that the zipper W(M) = 𝓛 × M × 𝓡 is that comonad, or check the comonad laws for its ε and δ directly. So when we read a DDM engine as a coKleisli morphism W(M) → M — read the full context, produce the next state — that is a reading, and it is only as good as the laws. ## Where the Three Analyses Meet The bottom of the diagram is the claim the square was drawn for: three analyses of the incidence matrix put the same transitions on each side of the [core-observer boundary](/posts/earned-compression) — the ρ boundary, where ρ(t) is the tokens transition t consumes over the tokens it produces. [Earned Compression](/posts/earned-compression) was checked against the code in October 2026, and each leg holds up less well than this post first said. The [ODE steady state](/posts/integer-reduction) relaxes the net into ℝ^P under mass-action kinetics and recovers integer structure from the equilibrium. This post said the boundary falls out of the eigenstructure, with ρ = 1 transitions reversible and ρ > 1 irreversible. Nothing computes an eigenstructure for it. What the ODE sees is direction of flow: observer transitions are sinks, and in the tic-tac-toe ODE they fire inside the solve and halt it. "Reversible" was the wrong word for the plays, too — no play has a reverse transition. What the core has is that it destroys nothing (the cell and turn invariants hold), where a win takes five tokens and leaves one. And in the 33-place net the plays are ρ = 2/3, not 1, because each also feeds a `move_tokens` counter that the draw consumes ([`pilot_ttt`](https://github.com/pflow-xyz/pflow-rs/blob/main/crates/pflow-tropical/src/ttt_fixtures.rs)); only the counter-free core has plays at exactly ρ = 1. [Tropical accumulation](/posts/tropical-petri-nets) needs a timed event graph — every place with one producer and one consumer — for `x(k+1) = A ⊗ x(k)` to hold. This post said the places with that 1:1 shape are core and the rest observer. Strictly, the tic-tac-toe core isn't an event graph either: each empty-cell place has two consumers and the turn places have nine. The argument that one turn token serializes the core applies just as well to the single `game_active` token every win consumes, so of the three this is the weakest leg. The [ZK](/posts/zk-petri-nets) leg was supposed to discover the boundary because core transitions compile to uniform constraints and observer transitions need non-uniform witnesses. The circuits we run don't do that. The arkworks circuit in pflow-rs ([`circuit.rs`](https://github.com/pflow-xyz/pflow-rs/blob/main/crates/pflow-zk-arkworks/src/circuit.rs)) puts every transition, wins and draw included, through one boolean selector multiplexer, with a remainder witness per input arc, and the gnark circuit in the ZK post has the same shape. The observer's cost is width: a win has five input arcs to a play's two, and every step's circuit is padded to the widest. So the three do agree on tic-tac-toe, but not as independent discoveries. Each leg is derived from the one sink property, which tracks ρ in this net, and nothing yet runs the three classifications on one net and compares the partitions. What they do share is what they read — C — and that is also why none of them sees a read arc. An overdraft guard has ρ = 1, is not a column of C, and sits on the other, contextual boundary ([two boundaries](/posts/category-settle#two-boundaries-not-one)). ## Why Tropical Forces the Split We think the decomposition into 𝓛 × M × 𝓡 is forced by what recursion requires rather than chosen, though nothing below is a proof of that. Tropical accumulation (max, +) is monotone — max is idempotent, values only grow, once a fact is absorbed it never changes. The left context is a value, not a mutable reference. That's referential transparency for the past, and it's what makes recursion safe. Event sourcing works because the log is immutable. ZK proofs work because the witness is fixed. Tropical matrix multiplication is fast-forward — `A^n = A ⊗ A ⊗ ... ⊗ A` — because you can skip ahead without replaying. The case for the comultiplication δ : W(M) → W(W(M)) is that it is well-defined because 𝓛 can't mutate under you — if the left context were mutable, re-contextualization would be incoherent. That is the intuition, not a check of the comonad laws, and δ is exactly where [Structuralism, Not Objects](/posts/structuralism-not-objects) later found the framing strains: if 𝓡 is an effect that ε handles rather than a value W holds, δ copies the left context as a value and re-installs the right one as a handler, and W is a value, a focus and a scope rather than a product of three values. The right context is the opposite of the left — ephemeral, recomputed from the hole on every step. You can't recurse over the future because it doesn't exist yet. The asymmetry has an algebraic face: one side is a semiring that accumulates, the other a predicate that evaluates. (max, +) on the left, boolean guards on the right. ## Open Question The three-part decomposition L × M × R is not new. Abstract machines (Felleisen's CEK, 1987), game semantics, Girard's geometry of interaction, and event sourcing all independently discover the same shape: accumulated past, current focus, constrained future. A lot of formalizations of computation land here. This post claimed two things for Petri nets: that the left context is specifically tropical — not "some accumulation" but (max, +) — and that three independent analyses (ODE, tropical, ZK) find the same core-observer boundary from the incidence matrix alone. The first is argued above, not proved. The second needs three qualifications from later work. It is the [ρ boundary](/posts/category-settle#two-boundaries-not-one), the one that lives in C; the contextual one is R's business. The analyses are not independent, since each reads the one sink property. And at tic-tac-toe's turn places C is not enough: deciding whether the core behaves like an event graph there takes reachability information, which C doesn't carry. The generalization might be this: the pflow square works for any SMC whose morphism structure is decidable. If the adjunction and the comonad can be stated properly, they would give the tense structure for free — the zipper would always exist. But whether you can *find the boundary* depends on the expressiveness of the morphisms. Petri nets look like a sweet spot. The ρ boundary is a count over the columns of a finite matrix, and where C runs out, as at the turn places, the reachability question is still decidable (Mayr, Kosaraju), if not cheaply. Move to a richer SMC — say, coloured Petri nets with arbitrary data types, or higher-order process calculi — and the tense decomposition might still hold, but the boundary may not be computable. The zipper exists; the question is whether you can compute where the hole is. Decidability of the morphism structure would be the dividing line between "the square commutes in principle" and "the square commutes and you can build tools that exploit it." We haven't tried the square on anything richer than an ordinary net, so this is a conjecture about where the line falls, not a result. ## What Changed Revised 2026-10-20 against [Earned Compression](/posts/earned-compression) as revised on 2026-10-18, the `pilot_ttt` fixture and the arkworks circuit in pflow-rs. Nothing was re-run. 1. **W = FU.** In an adjunction F ⊣ U the round trip FU is the comonad and UF the monad. The April figure said "W = UF, comonad", which cannot both hold. The morning revision of 2026-10-20 kept UF and called it the monad; the decision goes the other way, and W is FU, the comonad. The figure now draws FU as a loop at F(N) rather than leaving it to be read off the top-and-right path, which composes to UF. The comonad laws for the zipper W(M) are still unchecked, and the text no longer says every DDM engine *is* a coKleisli morphism. 2. **ODE leg.** It sees direction of flow, not an eigenstructure. Plays are not reversible, and in the 33-place net they have ρ = 2/3. 3. **Tropical leg.** The core is not structurally an event graph, and the serialization argument covers the observer too. 4. **ZK leg.** The circuits send every transition through one multiplexer; there is no uniform/non-uniform split in the code, only padding to the widest transition. 5. **Convergence.** The three legs read one definition, so their agreement is not independent evidence; nothing compares the partitions on one net. The opening line ("every post in this blog") is now the six posts the figure actually names, and the generalization is stated as a conjecture. 6. **The figure** was relabelled the same day. The centre reads "W = FU / comonad · laws unchecked", with the round trip drawn at F(N). "Three independent analyses discover the same structural boundary" is now "three analyses of C place the same ρ boundary — one sink property, not independent". "ρ = 1 (reversible)" vs. "ρ > 1 (irreversible)" is now the core that destroys nothing (ρ = 2/3 in `pilot_ttt`, 1 counter-free) against the observer as a sink, as in the ODE leg above. --- *Related: [The Zipper Whose Hole Is a Universe](/posts/tense-type-theory) · [Symmetric Monoidal Categories](/posts/symmetric-monoidal-categories) · [Tropical Petri Nets](/posts/tropical-petri-nets) · [Earned Compression](/posts/earned-compression) · [ZK Petri Nets](/posts/zk-petri-nets) · [The Incidence Reduction](/posts/integer-reduction) · [The Category Settle](/posts/category-settle) · [Structuralism, Not Objects](/posts/structuralism-not-objects)* --- # Brouwer Ordinals and the Shape of a Witness - URL: https://blog.stackdump.com/posts/alpha-decidability-past-tense - Date: 2026-04-16 - Tags: type-theory, ordinals, petri-net, tropical-geometry - Summary: de Jong et al. (2026) put an ordinal axis under decidability. That axis is past-tense, and Brouwer ordinals are the scalar specialization of our vector tropical past. # Brouwer Ordinals and the Shape of a Witness de Jong, Kraus, Mohammadzadeh, and Nordvall Forsberg put an ordinal axis under decidability. One definition — α-decidability — subsumes decidable propositions, semidecidable propositions, ∀-of-semidecidable, and a hierarchy of quantifier alternations. The ordinal is the shape of the witness. That axis is past-tense. The paper doesn't frame it that way, but every construction in it reads as a monotone accumulator over completed observations. The [tropical core](/posts/tropical-petri-nets) has been drawing the same axis coordinate by coordinate in every post. Their Theorem 8.4 — that the ω² layer cannot be collapsed without classical logic — is the independent result we needed for the claim that the past/future boundary is structural. Paper: [arXiv:2602.10844](https://arxiv.org/abs/2602.10844) — *Generalized Decidability via Brouwer Trees*, with a Cubical Agda formalization. --- ## α-Decidability in One Line ``` α-decidable P := ∃ y : Brw. (P ↔ α ≤ y) ``` `Brw` is the type of Brouwer ordinals. `α` is a fixed threshold. `P` is α-decidable when there exists a Brouwer ordinal `y` such that `P` holds iff `y` has grown past `α`. | Class | Threshold | |---|---| | `P` already holds | ω·0 | | decidable | ω·1 | | semidecidable | ω·2 | | Twin Prime (∀ of semidec) | ω² | | ∃ of semidec (no choice) | ω·3 | | ∃m. ∀n. P(n,m) upward-closed | ω²+ω | All of these are instances of the same definition. The only thing that changes is `α`. Under countable choice, the finite-`k` classes (ω·k) collapse together — semidecidability absorbs them. The ω² and higher layers do not collapse. --- ## Brouwer Ordinals `Brw` is a quotient inductive-inductive type with three constructors: `zero`, `succ`, and `limit (f : ℕ → Brw)` where `limit` accepts only strictly increasing sequences. The strict-increase constraint is load-bearing. Ordinary ordinals accept any sequence under `sup`, which is why they can be classically well-ordered. Brouwer ordinals only take a limit when strict progress has been witnessed — so `limit f` carries not just the function but evidence that each step advanced. That evidence is what makes the construction usable constructively without choice. The order `α ≤ β` on `Brw` is inductively defined and prelinear, not linear: in general we cannot prove `α ≤ β ∨ β ≤ α` without classical assumptions. The ordinal witness totally orders a given history; it does not totally order independent histories. --- ## The Ψ Accumulator The paper gives a uniform procedure for building a Brouwer witness from an observation process. For a proposition `P` observed at stages `n : ℕ`: ``` Ψ(P) := limit(λn. Ψₙ(P) + n) ``` Each `Ψₙ(P)` summarizes everything observed about `P` through stage `n`. The `+ n` guarantees strict increase even when observation stalls. The limit is the completed observation process. `Ψ(P)` is a monotone accumulator. It grows as observations accumulate. It never retracts. It ends in a limit of strict progress. An ordinal that grows with observation is a past-tense object — the history compressed to its order type. --- ## The ω² Obstruction Theorem 8.4 is the structural payoff. Under countable choice, every finite-`k` α-decidable proposition (α = ω·k) is semidecidable. The obvious hope — that with enough choice you can flatten the quantifier-alternation layer at ω² — is false. Collapsing ω²-decidable to semidecidable would prove `MP ⇒ LPO`. Markov's Principle does not imply the Limited Principle of Omniscience constructively. So the collapse is impossible even with countable choice. The `∀n. semidec(n)` step — the jump from ω·2 to ω² — is a constitutive layer, not a convenience of presentation. Quantifier alternation cannot be absorbed into a single-pass observation process, even with choice. --- ## Defining Functions Out of QIITs Defining functions out of a QIIT like `Brw` by direct pattern-matching forces you to handle every path constructor, and for higher-dimensional paths the coherence burden explodes. The paper's construction of `limMin : Brw × Brw → Brw` hit over three dozen cases with 3D and 4D cube fills. §9.1 replaces the direct construction with a relational one. Define a relation `R : Brw × Brw × Brw → Type` on point constructors only. Prove `R` is single-valued and total. Then `Σ z. R x y z` is contractible and the function extracts via the first projection. The path-constructor cases are discharged by the fact that `R` is a type family — it respects the paths. Single-valuedness plus totality extracts the function without touching any higher-dimensional fills. This is Bove–Capretta ([Modelling general recursion in type theory](https://www.cs.nott.ac.uk/~psztxa/publ/obt.pdf)) adapted from partial functions to higher inductive types. It applies to any function out of a QIIT carrier — marking quotients, Chu-space equalities, forgetful functors between net categories. --- ## The Axis Is Past-Tense `P ↔ α ≤ y` reads as: `P` holds exactly when the accumulator has crossed the threshold. The accumulator grows under observation. The threshold is fixed. The future-tense claim — `P will hold` — is equivalent to a past-tense fact: `y has grown past α`. This is the adjunction hinge the [tense type theory](/posts/tense-type-theory) post drew around execution state. The present is where the past (accumulator) meets the future (predicate). The paper draws exactly this picture, without calling it tense. The ordinal tells you how much past the proposition needs. Decidable propositions need a bounded past. Semidecidable propositions need an ω-bounded past. Twin Prime needs an ω²-bounded past because it quantifies `∀n` over a semidecidable process — the outer universal forces completion of the inner ω-processes before committal. The hierarchy measures how much history the witness has to compress, not how long the proof is. --- ## Scalar and Vector Past The tropical core carries a marking `T : Place → ℝ ∪ {∞}` — a vector past, one coordinate per place. Their framework carries `y : Brw` — a scalar past, one ordinal total. Specialize to a one-place linear net. Tropical composition reduces to min-plus on a single coordinate, which is order-isomorphic to Brouwer ordinal accumulation. `Ψₙ` of a proposition should match the tropical marking at step `n` of the corresponding single-place net. Their framework is the scalar case; the tropical framework is the vector extension. The extension is not free. Vector past has one accumulator per place; independent places accumulate independently, and the past state factorizes along the place-set. Pointwise order on `Place → ℝ ∪ {∞}` is partial — two markings can be incomparable. Brouwer ordinals are prelinear; markings are genuinely partial. --- ## The Concurrent Gap Two independent firings in a Petri net can happen in either order without changing the final marking. An ordinal witness distinguishes them. Picking an ordinal-valued accumulator implicitly picks a schedule. A concurrent past needs an accumulator invariant under Mazurkiewicz equivalence of firing sequences. Brouwer ordinals are the wrong carrier for that — not because the authors missed something but because the structure is total enough that it cannot represent independence. The analogue that works is a Brouwer-indexed diagram over a partial order: event structures, pomset-indexed limits, or a Brouwer poset. The paper does not construct one. That is where the scalar framework stops and the vector tropical framework has to begin. --- *The past is an ordinal when it's scalar. The past is a tropical marking when it's vector. The ω² obstruction says the boundary between past and future is not a convention — it is a structural layer that classical logic is required to collapse.* --- *Related: [Tense Type Theory](/posts/tense-type-theory) · [Tropical Petri Nets](/posts/tropical-petri-nets) · [Earned Compression](/posts/earned-compression)* --- # beats.bitwrap.io is a Jambox Now - URL: https://blog.stackdump.com/posts/beats-launch-jambox - Date: 2026-04-30 - Tags: petri-nets, music, beats-bitwrap, tone-js, live-performance - Summary: Official launch of beats.bitwrap.io as a live-performance tool — content-addressed shareable tracks, full-page Stage visuals, and a hands-free Auto-DJ that runs itself for hours. # beats.bitwrap.io is a Jambox Now A few weeks ago we wrote up [Petri Nets as a Music Sequencer](/posts/petri-net-sequencer) — a deterministic beat generator where every note is a transition firing and every rhythm is tokens circulating through places. That was a toy with an interesting idea underneath it. Today we're promoting it to a jambox. ![beats.bitwrap.io Stage mode](/images/beats-launch-jambox/stage-demo.mp4) Three things shipped that turn it from *demo* into *performance surface*. ## Shareable tracks Every track on [beats.bitwrap.io](https://beats.bitwrap.io) now has a content-addressed share URL. Click **Share**, copy the link, open it in any browser — the listener hears the exact same thing you were hearing: genre, seed, mix, FX, Feel, Auto-DJ settings, Fire-pad config, crop region, all of it. The URL's `?cid=…` is a CIDv1 (base58btc sha-256 of the canonical JSON), so the address *is* the track — same bytes always resolve to the same CID, different bytes can't pretend to be an existing one. Two modes: - **Short-link** uploads canonical JSON to the share store and gives you a tidy URL. - **Self-contained** inlines a gzipped payload directly in the link, so it works from a local copy, in an air-gapped browser, or after the share store is ever purged. A track is now an artifact you can hand to someone — not a screenshot, not a YouTube re-encode, the actual playable thing. ## Stage mode Press **M** (or hit the fullscreen button) and the whole app turns into a visualizer. Every unmuted track renders as its own live sub-Petri-net, arranged as a meta-Petri-net with connector place-circles and arrows between panels. It reads the same audio pipeline as the mixer; the visuals are a read-only projection of what's already happening. ![Stage — Constellation view, default](/images/beats-launch-jambox/stage-constellation.png) The default visualizer is **Constellation**: nine sub-nets arranged in a ring, each showing the active A/B/C variant for its slot, joined through central connector places. There's a row of stats at the bottom (`9/9 nets · 136 places · 136 transitions · 272 arcs`) so you can see the live shape of the running track. Press **⇆ Expand** and it switches to the full **Mandala** — the same nine slots, but now with every variant interleaved instead of just the active one. Same song, denser net. ![Stage — Mandala view, all variants interleaved](/images/beats-launch-jambox/stage-mandala.png) Three other visualizers ride on the same overlay (`Corona`, `Sonar`, `Petal`), and four viz **layers** stack independently on top of Constellation: - Panels **Flow** as the mix drifts - Beat particles **Pulse** toward the composition core on each fire - Per-panel **Flames** ignite on every transition - A 3D **Tilt** sweeps the whole grid All four layers can run at once without rebuilding the DOM. It's the first time the Petri net isn't just a runtime artifact — it's the visual. For the full architectural map of where Stage sits in the rest of the app, see the [control-category diagram](/images/beats-launch-jambox/control-category.svg) in the [beats-bitwrap-io repo](https://github.com/stackdump/beats-bitwrap-io/blob/main/docs/categorical-index.md). ## A gallery you can DJ from Tracks land in a `/feed` gallery — a wall of generated cards, one per share, each with its own colour and its own seed-derived ring topology. Tap **+** on a card to drop it into the queue; click **playlist** in the toolbar and a built-in player opens in the sidebar with Winamp-flavoured chrome — green LCD readout, beveled transport buttons, equalizer-style flame visualizer driven by an `AnalyserNode` on the live audio: ![beats.bitwrap.io feed with the built-in player open in the sidebar over the card grid](/images/beats-launch-jambox/feed-player.png) The player and the gallery share state — drop a card into the queue, hit play, scrub, shuffle, repeat — all the muscle memory you already have, none of the dependencies. It's a couple hundred lines of vanilla CSS and a single `