Skip to content

Commit e78c8ed

Browse files
committed
fix(contracts): preserve bounded producer results
1 parent 4f1e84c commit e78c8ed

9 files changed

Lines changed: 115 additions & 14 deletions

File tree

src/jacobian/math/finite_topology_spaces/_models.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ class KolmogorovQuotientRequest(StrictModel):
5656

5757

5858
class KolmogorovQuotientResult(StrictModel):
59-
quotient_points: tuple[OpaqueLabel, ...]
59+
quotient_points: tuple[tuple[OpaqueLabel, ...], ...]
6060
quotient_preorder: tuple[tuple[int, ...], ...]
6161

6262

src/jacobian/math/finite_topology_spaces/operations.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ def kolmogorov_quotient(space: FiniteTopologicalSpace) -> dict[str, object]:
9898
nbhd_to_class.setdefault(key, []).append(i)
9999
classes = list(nbhd_to_class.values())
100100
quotient_points = tuple(
101-
"|".join(space.points[idx] for idx in sorted(cls)) for cls in classes
101+
tuple(space.points[idx] for idx in sorted(cls)) for cls in classes
102102
)
103103
class_map: dict[int, int] = {}
104104
for class_idx, cls in enumerate(classes):

src/jacobian/math/petri_nets/_models.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,8 +54,24 @@ def require_valid_marking_size(self) -> Self:
5454
class FireTransitionResult(StrictModel):
5555
"""Result of firing a transition."""
5656

57-
fired: bool
58-
new_marking: tuple[int, ...] = Field(default=())
57+
status: Literal["FIRED", "NOT_ENABLED", "ESCAPES_DECLARED_ENVELOPE"]
58+
new_marking: Marking | None = None
59+
envelope_escape: tuple[int, ...] | None = None
60+
61+
@model_validator(mode="after")
62+
def require_consistent_outcome(self) -> Self:
63+
if self.status == "ESCAPES_DECLARED_ENVELOPE":
64+
if self.new_marking is not None or self.envelope_escape is None:
65+
raise ValueError(
66+
"envelope escape must carry only the successor witness"
67+
)
68+
if any(token < 0 for token in self.envelope_escape):
69+
raise ValueError("envelope escape tokens must be nonnegative")
70+
if all(token <= MAX_PETRI_MARKING for token in self.envelope_escape):
71+
raise ValueError("envelope escape must contain an out-of-range token")
72+
elif self.new_marking is None or self.envelope_escape is not None:
73+
raise ValueError("ordinary firing outcomes must carry only a marking")
74+
return self
5975

6076

6177
class IncidenceMatrixRequest(StrictModel):

src/jacobian/math/petri_nets/_operations.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
fire_transition,
2121
reachability_graph,
2222
)
23+
from jacobian.math.petri_nets.values import MAX_PETRI_MARKING, Marking
2324

2425
__all__ = [
2526
"compute_enabled_transitions",
@@ -41,7 +42,15 @@ def compute_fire_transition(request: FireTransitionRequest) -> FireTransitionRes
4142
success, new_marking = fire_transition(
4243
request.net, request.marking, request.transition
4344
)
44-
return FireTransitionResult(fired=success, new_marking=new_marking)
45+
if any(token > MAX_PETRI_MARKING for token in new_marking):
46+
return FireTransitionResult(
47+
status="ESCAPES_DECLARED_ENVELOPE",
48+
envelope_escape=new_marking,
49+
)
50+
return FireTransitionResult(
51+
status="FIRED" if success else "NOT_ENABLED",
52+
new_marking=Marking(tokens=new_marking),
53+
)
4554

4655

4756
def compute_incidence(request: IncidenceMatrixRequest) -> IncidenceMatrixResult:

src/jacobian/math/petri_nets/_tools.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,9 +61,9 @@ def _op[
6161
_op(
6262
"petri_net.fire_transition.compute",
6363
"Fire one transition in a Petri net",
64-
"Fire a single transition at the given marking and return whether it "
65-
"succeeded and the resulting marking. If the transition is not "
66-
"enabled, it does not fire.",
64+
"Fire a single transition at the given marking. Return the canonical "
65+
"resulting marking, report that the transition is not enabled, or "
66+
"return the exact successor when it leaves the declared envelope.",
6767
FireTransitionRequest,
6868
FireTransitionResult,
6969
compute_fire_transition,

tests/integration/catalog/test_opaque_label_contracts.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,3 +69,20 @@ def test_public_opaque_labels_remain_mathematical_labels(label: str) -> None:
6969
catalog = Catalog.open()
7070
for operation_id, payload in _payloads(label):
7171
invoke_operation(operation_id, payload, catalog)
72+
73+
74+
def test_kolmogorov_quotient_retains_long_labels_as_a_class() -> None:
75+
left = "a" * 64
76+
right = "b" * 64
77+
result = invoke_operation(
78+
"topology.finite.kolmogorov_quotient.compute",
79+
{
80+
"space": {
81+
"points": [left, right],
82+
"preorder": [[0, 1], [0, 1]],
83+
}
84+
},
85+
Catalog.open(),
86+
)
87+
88+
assert result.output["quotient_points"] == [[left, right]]

tests/integration/catalog/test_strict_json_contracts.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from jacobian.catalog.catalog import Catalog
44
from jacobian.dispatch import invoke_operation
5+
from jacobian.math.petri_nets.values import MAX_PETRI_ARC_WEIGHT, MAX_PETRI_MARKING
56
from jacobian.math.quadratic_forms._models import (
67
MAX_ENTRY_DIGITS,
78
MAX_VECTOR_DIGITS,
@@ -18,3 +19,26 @@ def test_large_quadratic_integer_result_survives_public_dispatch() -> None:
1819
)
1920

2021
assert result.output["value"] == str(int(entry) * int(vector) ** 2)
22+
23+
24+
def test_petri_firing_reports_successor_outside_marking_envelope() -> None:
25+
result = invoke_operation(
26+
"petri_net.fire_transition.compute",
27+
{
28+
"net": {
29+
"place_count": 1,
30+
"transition_count": 1,
31+
"pre": [[0]],
32+
"post": [[MAX_PETRI_ARC_WEIGHT]],
33+
},
34+
"marking": {"tokens": [MAX_PETRI_MARKING]},
35+
"transition": 0,
36+
},
37+
Catalog.open(),
38+
)
39+
40+
assert result.output == {
41+
"status": "ESCAPES_DECLARED_ENVELOPE",
42+
"new_marking": None,
43+
"envelope_escape": [MAX_PETRI_MARKING + MAX_PETRI_ARC_WEIGHT],
44+
}

tests/math/finite_topology_spaces/test_finite_topology_spaces.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,19 @@ def test_discrete_is_t0(self) -> None:
127127
)
128128
assert len(result.quotient_points) == 2
129129

130+
def test_equivalence_classes_retain_long_source_labels(self) -> None:
131+
left = "a" * 64
132+
right = "b" * 64
133+
space = FiniteTopologicalSpace(
134+
points=(left, right),
135+
preorder=((0, 1), (0, 1)),
136+
)
137+
138+
result = compute_kolmogorov_quotient(KolmogorovQuotientRequest(space=space))
139+
140+
assert result.quotient_points == ((left, right),)
141+
assert result.quotient_preorder == ((0,),)
142+
130143

131144
# ---------------------------------------------------------------------------
132145
# Continuity check

tests/math/petri_nets/test_petri_nets.py

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -121,26 +121,48 @@ def test_fire_success(self):
121121
result = compute_fire_transition(
122122
FireTransitionRequest(net=net, marking=marking, transition=0)
123123
)
124-
assert result.fired is True
125-
assert result.new_marking == (1, 0)
124+
assert result.status == "FIRED"
125+
assert result.new_marking == Marking(tokens=(1, 0))
126+
assert result.envelope_escape is None
126127

127128
def test_fire_disabled(self):
128129
net = _simple_net()
129130
marking = Marking(tokens=(0, 0))
130131
result = compute_fire_transition(
131132
FireTransitionRequest(net=net, marking=marking, transition=0)
132133
)
133-
assert result.fired is False
134-
assert result.new_marking == (0, 0)
134+
assert result.status == "NOT_ENABLED"
135+
assert result.new_marking == Marking(tokens=(0, 0))
136+
assert result.envelope_escape is None
135137

136138
def test_fire_cyclic(self):
137139
net = _token_passing_net()
138140
marking = Marking(tokens=(1, 0))
139141
result = compute_fire_transition(
140142
FireTransitionRequest(net=net, marking=marking, transition=0)
141143
)
142-
assert result.fired is True
143-
assert result.new_marking == (0, 1)
144+
assert result.status == "FIRED"
145+
assert result.new_marking == Marking(tokens=(0, 1))
146+
assert result.envelope_escape is None
147+
148+
def test_fire_reports_successor_outside_marking_envelope(self):
149+
net = PetriNet(
150+
place_count=1,
151+
transition_count=1,
152+
pre=((0,),),
153+
post=((MAX_PETRI_ARC_WEIGHT,),),
154+
)
155+
result = compute_fire_transition(
156+
FireTransitionRequest(
157+
net=net,
158+
marking=Marking(tokens=(MAX_PETRI_MARKING,)),
159+
transition=0,
160+
)
161+
)
162+
163+
assert result.status == "ESCAPES_DECLARED_ENVELOPE"
164+
assert result.new_marking is None
165+
assert result.envelope_escape == (2 * MAX_PETRI_MARKING,)
144166

145167

146168
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)