Transitions¶
Transitions fire work over the thread pool; arcs connect them to places.
cpnx.Transition
dataclass
¶
A CPN transition that fires when all its input places are enabled.
In CPN formalism a transition has:
- Input arcs whose
key/filterdetermine which tokens are consumed - Output arcs whose
conditiondetermines whether tokens are produced - A guard — a boolean predicate over the binding that must hold for the transition to be enabled (evaluated before the action runs)
Attributes:
| Name | Type | Description |
|---|---|---|
name |
str
|
Unique identifier within a |
inputs |
list[InputArc]
|
Input arcs (place to transition). |
outputs |
list[OutputArc]
|
Output arcs (transition to place). |
action |
Callable[[list[Token]], list[Token]]
|
Callable consuming input tokens and returning output tokens. Runs on the thread pool outside the engine lock. |
guard |
Callable[[list[Token]], bool] | None
|
CPN transition guard — a |
priority |
int
|
Lower value fires first when multiple transitions are enabled. Defaults to 10. |
action_timeout_secs |
float | None
|
Maximum wall-clock seconds the action may run. When This does not kill the underlying OS thread. The timed-out action
continues running in the background until it completes or the process exits;
its return value is silently discarded. Callers must apply native I/O
timeouts inside their actions (e.g. |
max_retries |
int | None
|
Maximum number of times to retry the transition action on failure
before dead-lettering the data tokens.
The budget is counted on |
retry_policy |
RetryPolicy | None
|
How long a rolled-back data token waits before each retry — capped
exponential backoff with optional full jitter; see
|
binding_policy |
BindingPolicy | None
|
How the engine resolves which input tokens bind this transition
when checking enablement — see |
binding_priority_key |
Callable[[list[Token]], object] | None
|
Sort key used only under Performance / lock discipline: unlike a callable |
impacts_colors |
frozenset[str] | None
|
Optional declarative annotation naming the token colour
domain(s) this transition mutates or side-effects — e.g.
|
auto_return_resources |
bool
|
When |
Source code in src/cpnx/transitions.py
488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 | |
cpnx.SubstitutionTransition
dataclass
¶
Bases: Transition
A Transition that encapsulates an entire sub-PetriNet (Hierarchical CPN).
Unlike a plain Transition, whose action is an arbitrary callable, a
SubstitutionTransition delegates firing to a nested PetriNet (the
subnet): named port places inside the subnet are bound to named socket places in the
parent net via port_socket_map, and subnet_deadline_secs bounds how long the subnet
is given to reach quiescence. The child subnet is fully insulated from the parent net —
it carries no reference to its parent. Communication occurs strictly through the
port_socket_map.
A subnet instance may only be wrapped by one SubstitutionTransition at a
time; this is tracked process-wide via a weak set. Attempting to wrap the same subnet
twice raises ValueError. Constructing with a non-dict port_socket_map, or with
non-string port/socket names, raises TypeError. Referencing a port place that does not
exist in the subnet raises ValueError.
Attributes:
| Name | Type | Description |
|---|---|---|
subnet |
PetriNet
|
The nested |
port_socket_map |
dict[str, str]
|
Mapping of port place names (in |
subnet_deadline_secs |
float
|
Maximum wall-clock seconds allowed for the subnet to reach
quiescence when this transition fires. Defaults to |
Source code in src/cpnx/transitions.py
cpnx.InputArc
dataclass
¶
Describe how a transition consumes tokens from one input place.
In CPN formalism an input arc carries an arc expression — a function that, given the current tokens in the place, determines which tokens are consumed and in what order. cpnx splits that inscription into its two honest halves, both per token:
filter— eligibility: which tokens this arc may consume at all;key— order: a total order over the eligible tokens, min-first.
The engine applies filter, orders what survives by key (ascending, ties broken by
insertion order), and consumes the first count. With neither set the arc is plain
FIFO. Per-token parameters are what make the selection indexable, which an opaque
list[Token] -> list[Token] transform can never be — see
docs/adr/0004-arc-selection-key-filter.md.
Attributes:
| Name | Type | Description |
|---|---|---|
place |
str
|
Name of the source place. |
count |
int
|
Number of tokens to consume. Ignored when |
consume_all |
bool
|
Drain the entire place atomically. Defaults to |
settle_secs |
float
|
Wait for no new arrivals for this many seconds before
consuming. Defaults to |
key |
Callable[[Token], object] | None
|
Per-token sort key — a pure |
filter |
Callable[[Token], bool] | None
|
Per-token eligibility predicate — a pure |
Warning
consume_all=True ignores both key and filter. A draining arc takes every
available token, in FIFO order, whatever the selection callables say — a token the
filter rejects is still consumed. This preserves the pre-key/filter
behavior of the arc inscription it replaced, but it is a genuine footgun: filter
reads as a declaration of eligibility, and under consume_all it is not one. Do
not combine them expecting "drain everything eligible"; that pattern is not
supported today. To drain only eligible tokens, use a large count rather than
consume_all.
Because that combination is always a mistake rather than a style choice,
constructing (or reassigning into) such an arc emits a UserWarning naming the
ignored parameters. Silence it with
warnings.filterwarnings("ignore", message="consume_all=True ignores") if you
genuinely mean "drain everything, selection notwithstanding".
Warning
On a deep place, make your key/filter closed-world (certified) — or pay a
quadratic drain. A certified selection callable is served from a persistent
(key, seq) index maintained on the place, so the drain stays roughly linear in
place depth. An uncertified one cannot be indexed — keying happens on the
deposit() path, which cannot host an unbounded callable — so every enabling
check re-reads and re-sorts the whole available pool and dispatches the
callable through the timeout-bounded executor once per token. Both costs are
per-token-per-firing, so draining a deep place is O(N²): worst-case lock-hold
for the arc is len(place) * expr_timeout_secs, and benchmarks/bench_station_costs.py
measures an uncertified key at over 100× a certified key computing the
identical order at depth 2000 — a gap that widens with depth. A filter is
worse to get wrong than a key: a single uncertified filter disqualifies the
whole arc from indexing even when the key is certified, because applying an
uncertified predicate after a capped index read could silently under-select.
To certify, close over immutable values — never read mutable state at call
time. A callable certifies (see [cpnx.certification]) only when it draws on a
fixed vocabulary and closes over nothing mutable. In practice: read configuration
at construction and capture the value. A callable that reads a module global, an
instance attribute, or any list/dict/set on each call does not certify —
that is exactly the pattern that lands on the O(N²) path. Captured values must be
immutable leaves (numbers, strings, bytes, bool, None) or tuple/frozenset
thereof — a frozenset certifies, a set does not::
cutoff = config["vip_cutoff"] # an int, read once at construction
key = lambda t: (t.payload["spend"] < cutoff, t.created_at) # closes over an int -> certifies
If the ordering genuinely depends on state that changes at run time — a live priority a human retunes mid-run — it cannot certify by construction, and the quadratic cost is intrinsic, not a bug: an index needs a key it can compute once, at deposit, and a value that changes afterward would sort already-deposited tokens wrong. There is no engine fix for that case. Restructure so the key is closed-world (snapshot the state into an immutable at construction and rebuild the arc when it changes), or accept the O(N²) on a shallow place only.
Note
Both callables are purity-verified at assignment, and each carries its own inline-safe flag: a certified callable runs inline under the engine lock, an uncertified one on the timeout-bounded expression pool (the source of the cost in the warning above).
Separately, expr_timeout_secs bounds a key's extraction, not the
comparisons between the values it returns — the sort runs inline under the
engine lock. Return plain comparables (numbers, strings, tuples thereof); a value
with a slow or diverging __lt__ can hold the lock past any timeout.
Example
Source code in src/cpnx/transitions.py
239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 | |
cpnx.OutputArc
dataclass
¶
Describe how a transition deposits tokens into one output place.
In CPN formalism an output arc carries an arc expression — a function evaluated
against the transition's output tokens that determines whether tokens flow along this
arc. On the output side that inscription is, in practice, always a boolean activation
predicate, so cpnx names it condition — a different mechanism from the input side's
token selection (see InputArc and
docs/adr/0004-arc-selection-key-filter.md).
Attributes:
| Name | Type | Description |
|---|---|---|
place |
str
|
Name of the target place. |
count |
int
|
Number of tokens to deposit. Defaults to 1. |
condition |
Callable[[list[Token]], bool] | None
|
Arc activation predicate. Receives the list of non-resource
output tokens returned by the action; the arc is skipped
(no tokens deposited) when it returns |
synthesized |
bool
|
|
Source code in src/cpnx/transitions.py
on_color
classmethod
¶
Build an OutputArc that only fires for a matching first token color.
The returned arc's condition is a callable that checks whether the action's
output tokens are non-empty and the first token's color equals color. It closes
over color (an immutable string), so it certifies for inline evaluation.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
color
|
str
|
The |
required |
place
|
str
|
Name of the target place. |
required |
count
|
int
|
Number of tokens to deposit when the arc fires. Defaults to 1. |
1
|
Returns:
| Type | Description |
|---|---|
OutputArc
|
A new |
OutputArc
|
when the first token in the action's output has color |
Source code in src/cpnx/transitions.py
BindingPolicy selects how a transition resolves which input tokens bind it — the deterministic-complete binding search (FIRST, the default), or a selection among the satisfying bindings (RANDOM, PRIORITY). The head-only LEGACY check was removed in 0.7.0.
cpnx.CpnxDeprecationWarning
¶
Bases: UserWarning
Warn that a deprecated cpnx API was used, naming the release that removes it.
A dedicated category so users can warnings.filterwarnings it independently —
silence it during a staged migration, or escalate it to an error for CI via
filterwarnings("error", category=CpnxDeprecationWarning) — without touching cpnx's
other warnings.
Note
This deliberately subclasses UserWarning, not DeprecationWarning, so it is
visible by default. Python's default filters ignore DeprecationWarning outside
__main__, which would hide it from exactly the library users a deprecation is meant
to reach. cpnx has been bitten by a silent deprecation once already:
BindingPolicy.LEGACY was announced as deprecated in the 0.5.0 docs with no runtime
signal whatsoever, so a net relying on it had nothing to notice short of reading the
changelog. It gained this warning in 0.6.0 and was removed in 0.7.0.
Note
No cpnx API currently emits this. It is retained as the project's deprecation
category — the contract above (its own filterable category, visible by default) is
public API that the next deprecation inherits, and removing it would break the
filterwarnings entries users were told to add.
Source code in src/cpnx/transitions.py
cpnx.BindingPolicy
¶
Bases: Enum
Strategy for choosing which input tokens bind a transition when it is enabled.
In CPN theory a transition is enabled if any assignment of place tokens (a
binding) satisfies its guard. cpnx before 0.3.1 tested only the head of each
input place (FIFO, or reordered by InputArc.key), which
causes head-of-line blocking: a place holding [A, B] whose guard wants B
reports the transition disabled, because only A is ever tested. BindingPolicy
selects how the engine resolves that binding. See the design record in
docs/adr/0001-combinatorial-binding-search.md.
Attributes:
| Name | Type | Description |
|---|---|---|
FIRST |
Search input-token combinations in a stable insertion order and select the first combination whose guard is satisfied. Complete (finds a valid binding if one exists anywhere in the place, fixing head-of-line blocking) and deterministic (the same marking always yields the same binding). This is the default. When the transition has no guard, the first candidate is the head and trivially satisfies, so resolution reduces to the O(1) head selection and incurs no search cost. |
|
RANDOM |
Enumerate the satisfying combinations and select one uniformly at
random. Reproducible when the owning |
|
PRIORITY |
Enumerate the satisfying combinations and select the one minimizing
|
Note
The search enumerates the Cartesian product of each input arc's count-sized token
combinations, varying the last arc in Transition.inputs fastest. Consequences
for tuning:
- Resource arcs inflate the space. A
ResourcePlace/PacedResourcePlacepermit arc contributesC(capacity, count)interchangeable options that usually give the guard the same answer, so they can consumebinding_search_limiton redundant permutations. List resource arcs before data arcs inTransition.inputsso the data dimension (the one that actually changes the guard result) varies first, and/or raisebinding_search_limit. - For
FIRSTthe first binding yielded is the head selection, so a guard the head already satisfies costs no search at all. RANDOM/PRIORITYmust scan the whole (bounded) candidate set, so they do not short-circuit and are typically costlier thanFIRST. If the candidate space exceedsbinding_search_limit, they select over the firstlimitcandidates only (a truncated prefix), firingon_binding_search_exhausted. If that prefix contains no satisfying binding, the transition is treated as disabled for that check — it can stall exactly likeFIRST, not just fire over a smaller set.
Source code in src/cpnx/transitions.py
Retry scheduling¶
RetryPolicy says how long a rolled-back data token waits before each retry: capped exponential backoff with optional full jitter, the shape recommended by the AWS Builders' Library's Timeouts, retries, and backoff with jitter. Attach one per transition (Transition.retry_policy) — retry behaviour belongs to the dependency a transition talks to, not to the net — and a transition that declares none inherits PetriNet.retry_policy. How many times it retries before dead-lettering stays Transition.max_retries.
cpnx.RetryPolicy
dataclass
¶
How long a rolled-back data token waits before each retry.
The delay for a 0-based attempt — the token's Token.attempts count at the moment it
failed, so its first failure is attempt 0 — is
Attach one per transition (Transition.retry_policy) to pace each
dependency on its own terms; a transition that declares none inherits the net's default
(PetriNet.retry_policy). How many times a transition retries before
its tokens are dead-lettered stays Transition.max_retries.
The attempt index is per token, not per transition. Token.attempts counts "failed
firings this token has been rolled back from" anywhere in the net, and Token.evolve
carries it forward, so a token that failed three times upstream arrives already at
attempt 3: under RetryPolicy(delay=0.5, backoff=2.0) its first retry at the next
transition waits 4 s, not 0.5 s. Transition.max_retries reads the same counter and so
has the same property — that token also has three of its retries already spent. This is
the documented meaning of attempts rather than an artefact of backoff, but backoff
makes it far more visible, so: on a net where tokens cross several fallible transitions,
either keep backoff=1.0 (flat, no accumulation to notice), set a max_delay low
enough to bound the inherited growth, or have the action return a token with
attempts=0 once it has made progress, which resets the budget for the stages ahead.
Immutable, comparable and cheap, so one policy can be shared by many transitions. Fields are validated on construction — a schedule that could only stall or hot-loop is rejected here, where it is written, rather than inside the rollback that would use it.
Attributes:
| Name | Type | Description |
|---|---|---|
delay |
float
|
Base delay in seconds — the wait before the first retry. Must be a non-negative, finite number. |
backoff |
float
|
Multiplier applied once per failed attempt, so attempt n waits
|
max_delay |
float | None
|
Ceiling in seconds on a single delay, applied before jitter. |
jitter |
bool
|
When |
Raises:
| Type | Description |
|---|---|
TypeError
|
If |
ValueError
|
If |
Note
Numeric fields are stored as float, so RetryPolicy(delay=1, backoff=2).delay
reads back as 1.0. The coercion is load-bearing, not cosmetic: int ** int is
arbitrary-precision and never overflows, so an int backoff would grow without
bound instead of saturating at the ceiling described under max_delay.
Example
from cpnx import RetryPolicy, Transition
# Pace a throttled API: 0.5s, 1s, 2s, 4s, ... capped at 30s, jittered.
flaky_api = RetryPolicy(delay=0.5, backoff=2.0, max_delay=30.0, jitter=True)
Transition(
name="charge_card",
inputs=[InputArc("pending")],
outputs=[OutputArc("charged")],
action=charge,
retry_policy=flaky_api,
max_retries=8,
)
Source code in src/cpnx/retries.py
88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 | |
__post_init__
¶
Reject a schedule that could not retry sanely, and store the fields as float.
See _validated_retry_number. Keeping the coerced value matters as much as the
check: an int field would make backoff ** attempt arbitrary-precision integer
arithmetic, which never raises OverflowError, so delay_for would hand the
rollback a 1000-bit int instead of saturating — and ref_time + delay would then
raise OverflowError outside _RetrySchedule.delay_for's guard, stranding every
token not yet re-deposited. float ** int saturates where this class says it does.
Source code in src/cpnx/retries.py
delay_for
¶
Return the delay in seconds to wait before retry number attempt (0-based).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
attempt
|
int
|
The token's |
required |
rng
|
Random | None
|
Jitter source, used only when |
None
|
Returns:
| Type | Description |
|---|---|
float
|
The delay in seconds, |
float
|
uniformly from |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
TypeError
|
If |
Source code in src/cpnx/retries.py
Side-effect linting¶
Guards, arc key/filter selectors, and output-arc conditions should be deterministic and side-effect-free — they are evaluated to decide enabling, so network I/O, database reads, or clock/randomness use in them make the net's behaviour non-reproducible and undermine static analysability (see ADR 0007). cpnx runs a best-effort AST linter at construction time that warns (CpnxLintWarning) when it detects these trouble spots. Set CPNX_LINT_STRICT=1 or call set_strict to promote warnings to errors for CI gating, or call lint_callable directly to inspect a callable programmatically.
cpnx.lint_callable
¶
Scan func's body for network / database / clock / randomness trouble spots.
Pure inspection: never warns, never raises, never mutates. Returns findings in
source order, de-duplicated by (category, symbol, lineno). Returns []
when func is not callable or its source cannot be recovered (a compiled
builtin, a REPL lambda) — an unseeable body is reported as "nothing found",
never as a false positive, consistent with the best-effort contract.
Source code in src/cpnx/linting.py
cpnx.LintFinding
dataclass
¶
One flagged trouble spot in a callable's body.
category is one of :data:NETWORK/:data:DATABASE/:data:NONDETERMINISM;
symbol is the offending call as written (e.g. "requests.get",
"datetime.now"); lineno is the source line; message is the
actionable, human-readable explanation.
Source code in src/cpnx/linting.py
cpnx.CpnxLintWarning
¶
Bases: UserWarning
Advisory warning that a guard/key/filter contains a decidability trouble spot.
A dedicated category so users can warnings.filterwarnings it independently —
silence it, or escalate it to an error via filterwarnings("error", ...) for
CI — without touching cpnx's other warnings.
Source code in src/cpnx/linting.py
cpnx.set_strict
¶
Promote lint findings from warnings to :class:SideEffectLintError (or back).
Off by default (the honest severity for a best-effort heuristic — see the module
docstring). Opt in per process for CI gating, or set CPNX_LINT_STRICT=1.