Skip to content

Commit 4e45856

Browse files
committed
improve ExplicitAts API
- ExplicitAts: improve API for state, choice and branch manipulation - ExplicitAts: add utilities for state reordering - readme: remove tag badge - UmbIndex: more validations
1 parent 39d0a93 commit 4e45856

23 files changed

Lines changed: 811 additions & 293 deletions

README.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
[![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue)](https://www.python.org/)
44
[![License MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE)
55
[![PyPI](https://img.shields.io/pypi/v/umbi)](https://pypi.org/project/umbi/)
6-
[![Latest Tag](https://img.shields.io/github/tag/pmc-tools/umbi.svg)](https://github.com/pmc-tools/umbi/tags)
76
[![Build Status](https://img.shields.io/github/actions/workflow/status/pmc-tools/umbi/test-build.yml)](https://github.com/pmc-tools/umbi/actions)
87

98
Library for input/output of annotated transition systems (ATSs) in a *unified Markov binary (UMB)* format. See the [format specification](https://github.com/pmc-tools/umb/) for details.

tests/binary/test_sized_type.py

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,10 +67,10 @@ class TestSizedTypeValidation:
6767

6868
def test_positive_size_required(self):
6969
"""Test that size must be positive."""
70-
with pytest.raises(AssertionError):
70+
with pytest.raises(ValueError):
7171
SizedType(NumericPrimitiveType.UINT, 0)
7272

73-
with pytest.raises(AssertionError):
73+
with pytest.raises(ValueError):
7474
SizedType(NumericPrimitiveType.UINT, -1)
7575

7676
def test_string_must_be_64_bits(self):
@@ -147,3 +147,50 @@ def test_double_default_size(self):
147147
"""Test default size for DOUBLE."""
148148
sized_type = SizedType.for_type(NumericPrimitiveType.DOUBLE)
149149
assert sized_type.size_bits == 64
150+
151+
152+
class TestSizedTypeNumericProperties:
153+
"""Test SizedType numeric classification properties."""
154+
155+
def test_is_numeric_for_numeric_types(self):
156+
"""Test is_numeric returns True for numeric types."""
157+
assert SizedType(NumericPrimitiveType.INT, 32).is_numeric
158+
assert SizedType(NumericPrimitiveType.UINT, 32).is_numeric
159+
assert SizedType(NumericPrimitiveType.DOUBLE, 64).is_numeric
160+
assert SizedType(NumericPrimitiveType.RATIONAL, 128).is_numeric
161+
162+
def test_is_numeric_for_primitive_types(self):
163+
"""Test is_numeric returns False for primitive types."""
164+
assert not SizedType(PrimitiveType.BOOL, 1).is_numeric
165+
assert not SizedType(PrimitiveType.STRING, 64).is_numeric
166+
167+
def test_is_discrete_for_discrete_types(self):
168+
"""Test is_discrete returns True for discrete numeric types."""
169+
assert SizedType(NumericPrimitiveType.INT, 32).is_discrete
170+
assert SizedType(NumericPrimitiveType.UINT, 32).is_discrete
171+
assert SizedType(NumericPrimitiveType.INT, 64).is_discrete
172+
173+
def test_is_discrete_for_continuous_types(self):
174+
"""Test is_discrete returns False for continuous numeric types."""
175+
assert not SizedType(NumericPrimitiveType.DOUBLE, 64).is_discrete
176+
assert not SizedType(NumericPrimitiveType.RATIONAL, 128).is_discrete
177+
178+
def test_is_discrete_for_primitive_types(self):
179+
"""Test is_discrete returns False for primitive types."""
180+
assert not SizedType(PrimitiveType.BOOL, 1).is_discrete
181+
assert not SizedType(PrimitiveType.STRING, 64).is_discrete
182+
183+
def test_is_continuous_for_continuous_types(self):
184+
"""Test is_continuous returns True for continuous numeric types."""
185+
assert SizedType(NumericPrimitiveType.DOUBLE, 64).is_continuous
186+
assert SizedType(NumericPrimitiveType.RATIONAL, 128).is_continuous
187+
188+
def test_is_continuous_for_discrete_types(self):
189+
"""Test is_continuous returns False for discrete numeric types."""
190+
assert not SizedType(NumericPrimitiveType.INT, 32).is_continuous
191+
assert not SizedType(NumericPrimitiveType.UINT, 32).is_continuous
192+
193+
def test_is_continuous_for_primitive_types(self):
194+
"""Test is_continuous returns False for primitive types."""
195+
assert not SizedType(PrimitiveType.BOOL, 1).is_continuous
196+
assert not SizedType(PrimitiveType.STRING, 64).is_continuous

tests/datatypes/test_interval.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,44 @@ def test_interval_equal_bounds(self):
7878
assert interval.left == 5
7979
assert interval.right == 5
8080

81+
82+
class TestIntervalTypeClassification:
83+
"""Test IntervalType classification properties."""
84+
85+
def test_is_discrete_for_int_interval(self):
86+
"""Test that int-interval is discrete."""
87+
it = IntervalType(NumericPrimitiveType.INT)
88+
assert it.is_discrete
89+
90+
def test_is_discrete_for_uint_interval(self):
91+
"""Test that uint-interval is discrete."""
92+
it = IntervalType(NumericPrimitiveType.UINT)
93+
assert it.is_discrete
94+
95+
def test_is_discrete_for_continuous_intervals(self):
96+
"""Test that double-interval and rational-interval are not discrete."""
97+
double_it = IntervalType(NumericPrimitiveType.DOUBLE)
98+
rational_it = IntervalType(NumericPrimitiveType.RATIONAL)
99+
assert not double_it.is_discrete
100+
assert not rational_it.is_discrete
101+
102+
def test_is_continuous_for_double_interval(self):
103+
"""Test that double-interval is continuous."""
104+
it = IntervalType(NumericPrimitiveType.DOUBLE)
105+
assert it.is_continuous
106+
107+
def test_is_continuous_for_rational_interval(self):
108+
"""Test that rational-interval is continuous."""
109+
it = IntervalType(NumericPrimitiveType.RATIONAL)
110+
assert it.is_continuous
111+
112+
def test_is_continuous_for_discrete_intervals(self):
113+
"""Test that int-interval and uint-interval are not continuous."""
114+
int_it = IntervalType(NumericPrimitiveType.INT)
115+
uint_it = IntervalType(NumericPrimitiveType.UINT)
116+
assert not int_it.is_continuous
117+
assert not uint_it.is_continuous
118+
81119
def test_interval_str_repr(self):
82120
"""Test Interval string representation."""
83121
interval = Interval(1, 5)

tests/datatypes/test_numeric_primitive.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,36 @@ def test_bool_not_numeric_primitive(self):
5252
assert numeric_primitive_type_of(True) == NumericPrimitiveType.INT
5353

5454

55+
class TestNumericPrimitiveTypeClassification:
56+
"""Test numeric type classification properties."""
57+
58+
def test_is_discrete_for_int(self):
59+
"""Test that INT is discrete."""
60+
assert NumericPrimitiveType.INT.is_discrete
61+
62+
def test_is_discrete_for_uint(self):
63+
"""Test that UINT is discrete."""
64+
assert NumericPrimitiveType.UINT.is_discrete
65+
66+
def test_is_discrete_for_continuous_types(self):
67+
"""Test that DOUBLE and RATIONAL are not discrete."""
68+
assert not NumericPrimitiveType.DOUBLE.is_discrete
69+
assert not NumericPrimitiveType.RATIONAL.is_discrete
70+
71+
def test_is_continuous_for_double(self):
72+
"""Test that DOUBLE is continuous."""
73+
assert NumericPrimitiveType.DOUBLE.is_continuous
74+
75+
def test_is_continuous_for_rational(self):
76+
"""Test that RATIONAL is continuous."""
77+
assert NumericPrimitiveType.RATIONAL.is_continuous
78+
79+
def test_is_continuous_for_discrete_types(self):
80+
"""Test that INT and UINT are not continuous."""
81+
assert not NumericPrimitiveType.INT.is_continuous
82+
assert not NumericPrimitiveType.UINT.is_continuous
83+
84+
5585
class TestNumericPrimitivePromotionType:
5686
"""Test numeric_primitive_promotion_type function."""
5787

umbi/__main__.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import click
44

55
import umbi
6+
import umbi.umb
67

78
logger = logging.getLogger(__name__)
89

@@ -36,9 +37,9 @@ def main(log_level, import_umb, import_ats, export):
3637
raise ValueError("--export specified, but nothing to export")
3738
if umb is not None and ats is not None:
3839
raise ValueError("cannot specify both --import-umb and --import-ats when using --export")
39-
if import_umb is not None:
40+
if umb is not None:
4041
umbi.umb.write(umb, export)
41-
if import_ats is not None:
42+
if ats is not None:
4243
umbi.ats.write(ats, export)
4344

4445

umbi/ats/__init__.py

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,15 @@
1616
write,
1717
)
1818
from .entity_class import EntityClass
19-
from .explicit_ats import ExplicitAts, TimeType
19+
from .explicit_ats import Branch, Choice, ExplicitAts, TimeType
2020
from .model_info import ModelInfo
21-
from .variable_valuations import EntityClassValuations, EntityValuation, EntityValuations, Variable, VariableValuations
21+
from .variable_valuations import (
22+
EntityClassValuations,
23+
EntityValuation,
24+
EntityValuations,
25+
EntityVariableValuations,
26+
Variable,
27+
)
2228

2329
__all__ = [
2430
"examples",
@@ -30,11 +36,13 @@
3036
# model_info
3137
"ModelInfo",
3238
# explicit_ats
39+
"Branch",
40+
"Choice",
3341
"TimeType",
3442
"ExplicitAts",
3543
# variable_valuations
44+
"EntityVariableValuations",
3645
"Variable",
37-
"VariableValuations",
3846
"EntityValuation",
3947
"EntityValuations",
4048
"EntityClassValuations",

umbi/ats/annotations.py

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -121,19 +121,19 @@ def observation_values(self) -> list[Scalar]:
121121
def player_values(self) -> list[Scalar]:
122122
return self.get_values_for(EntityClass.PLAYERS)
123123

124-
def set_state_values(self, values: list[Scalar]):
124+
def set_state_values(self, values: Sequence[Scalar]):
125125
self.set_values_for(EntityClass.STATES, values)
126126

127-
def set_choice_values(self, values: list[Scalar]):
127+
def set_choice_values(self, values: Sequence[Scalar]):
128128
self.set_values_for(EntityClass.CHOICES, values)
129129

130-
def set_branch_values(self, values: list[Scalar]):
130+
def set_branch_values(self, values: Sequence[Scalar]):
131131
self.set_values_for(EntityClass.BRANCHES, values)
132132

133-
def set_observation_values(self, values: list[Scalar]):
133+
def set_observation_values(self, values: Sequence[Scalar]):
134134
self.set_values_for(EntityClass.OBSERVATIONS, values)
135135

136-
def set_player_values(self, values: list[Scalar]):
136+
def set_player_values(self, values: Sequence[Scalar]):
137137
self.set_values_for(EntityClass.PLAYERS, values)
138138

139139
def unset_state_values(self):
@@ -172,6 +172,18 @@ def validate(self):
172172
"""Validate the annotation data."""
173173
return
174174

175+
def _reorder_entities(self, entity_class: EntityClass, new_order: list[int]) -> None:
176+
"""Reorder the values for the given entity class according to the new order.
177+
178+
:param entity_class: the entity class for which to reorder values
179+
:param new_order: a list of indices representing the new order of values. Must be a permutation of the full list of entity indices.
180+
"""
181+
if not self.has_values_for(entity_class):
182+
return
183+
values = self._entity_class_to_values[entity_class]
184+
# we don't check the order
185+
self._entity_class_to_values[entity_class] = [values[i] for i in new_order]
186+
175187

176188
class RewardAnnotation(Annotation):
177189
"""Reward annotation is a numeric annotation that can be associated with states, choices, or branches."""

umbi/ats/ats_to_umb.py

Lines changed: 61 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ def explicit_umb_to_explicit_ats(umb: umbi.umb.ExplicitUmb) -> ExplicitAts:
1818
ats = ExplicitAts()
1919

2020
## index
21-
# skip format_version, format_revision and file_data
21+
# strip format_version, format_revision and file_data
22+
# load umb.index.model_data
2223
md = umb.index.model_data
2324
if md is not None:
2425
ats.model_info = ModelInfo(
@@ -30,33 +31,51 @@ def explicit_umb_to_explicit_ats(umb: umbi.umb.ExplicitUmb) -> ExplicitAts:
3031
doi=md.doi,
3132
url=md.url,
3233
)
34+
3335
# load index.transition_system
3436
ts = umb.index.transition_system
3537
ats.time = TimeType(ts.time)
3638
ats.num_players = ts.num_players
37-
ats.num_states = ts.num_states
3839
ats.num_initial_states = ts.num_initial_states
39-
ats.num_choices = ts.num_choices
4040
ats.num_choice_actions = ts.num_choice_actions
41-
ats.num_branches = ts.num_branches
4241
ats.num_branch_actions = ts.num_branch_actions
4342
ats.player_to_name = ts.player_to_name
4443

4544
## values
4645
ats.state_is_initial = umb.state_is_initial
47-
ats.state_to_choice = umb.state_to_choices
4846
ats.state_to_player = umb.state_to_player
4947

5048
ats.state_is_markovian = umb.state_is_markovian
5149
ats.state_to_exit_rate = umb.state_to_exit_rate
5250

53-
ats.choice_to_branches = umb.choice_to_branches
54-
ats.branch_to_target = umb.branch_to_target
55-
ats.branch_to_probability = umb.branch_to_probability
51+
for state_index in range(ts.num_states):
52+
state = ats.add_state()
53+
assert state == state_index
54+
55+
state_to_choices = (
56+
umb.state_to_choices if umb.state_to_choices is not None else [s for s in range(ats.num_states + 1)]
57+
)
58+
if ts.num_choices > 0:
59+
for state in ats.states:
60+
for choice_idx in range(state_to_choices[state], state_to_choices[state + 1]):
61+
choice = ats.add_state_choice(state=state)
62+
if ts.num_branches > 0:
63+
assert umb.choice_to_branches is not None, "num_branches > 0 but choice_to_branches is None"
64+
for branch_idx in range(umb.choice_to_branches[choice_idx], umb.choice_to_branches[choice_idx + 1]):
65+
assert umb.branch_to_target is not None, "num_branches > 0 but branch_to_target is None"
66+
target = umb.branch_to_target[branch_idx]
67+
prob = umb.branch_to_probability[branch_idx] if umb.branch_to_probability is not None else None
68+
branch_action = (
69+
umb.branch_to_branch_action[branch_idx] if umb.branch_to_branch_action is not None else None
70+
)
71+
choice.add_branch(target=target, prob=prob, action=branch_action)
72+
if ts.num_choice_actions > 0:
73+
assert umb.choice_to_choice_action is not None, (
74+
"num_choice_actions > 0 but choice_to_choice_action is None"
75+
)
76+
choice.action = umb.choice_to_choice_action[choice_idx]
5677

57-
ats.choice_to_choice_action = umb.choice_to_choice_action
5878
ats.choice_action_to_name = umb.choice_action_to_string
59-
ats.branch_to_branch_action = umb.branch_to_branch_action
6079
ats.branch_action_to_name = umb.branch_action_to_string
6180

6281
# load annotations
@@ -167,7 +186,6 @@ def explicit_ats_to_explicit_umb(ats: ExplicitAts) -> umbi.umb.ExplicitUmb:
167186
# warning: this does not copy the values, but directly uses the lists from ats.
168187
# this is fine as long as we don't modify them
169188
umb.state_is_initial = ats.state_is_initial
170-
umb.state_to_choices = ats.state_to_choice
171189
umb.state_to_player = ats.state_to_player
172190

173191
umb.state_is_markovian = ats.state_is_markovian
@@ -178,19 +196,40 @@ def explicit_ats_to_explicit_umb(ats: ExplicitAts) -> umbi.umb.ExplicitUmb:
178196
umb.index.transition_system.exit_rate_type = SizedType.for_type(target_type)
179197
umb.state_to_exit_rate = vector # type: ignore
180198

181-
umb.choice_to_branches = ats.choice_to_branches
182-
umb.branch_to_target = ats.branch_to_target
183-
184-
if ats.branch_to_probability is not None:
185-
# promote
186-
target_type, vector = umbi.datatypes.promote_scalars(ats.branch_to_probability)
187-
assert isinstance(target_type, umbi.datatypes.NumericType), "branch probabilities must be numeric"
188-
umb.index.transition_system.branch_probability_type = SizedType.for_type(target_type)
189-
umb.branch_to_probability = vector # type: ignore
199+
umb.state_to_choices = []
200+
num_choices = 0
201+
for state in ats.states:
202+
umb.state_to_choices.append(num_choices)
203+
num_choices += ats.num_state_choices(state)
204+
umb.state_to_choices.append(num_choices)
205+
206+
if ats.num_choices > 0:
207+
if ats.num_choice_actions > 0:
208+
umb.choice_to_choice_action = [choice.action for choice in ats.choices] # type: ignore
209+
210+
branches = []
211+
umb.choice_to_branches = []
212+
for choice in ats.choices:
213+
umb.choice_to_branches.append(len(branches))
214+
branches.extend(choice.branches)
215+
umb.choice_to_branches.append(len(branches))
216+
if len(branches) > 0:
217+
umb.branch_to_target = [branch.target for branch in branches]
218+
if any(branch.prob is not None for branch in branches):
219+
# has branch probabilities
220+
branch_to_probability = [branch.prob if branch.prob is not None else 1 for branch in branches]
221+
# promote
222+
target_type, vector = umbi.datatypes.promote_scalars(branch_to_probability)
223+
# assert isinstance(target_type, umbi.datatypes.NumericType), "branch probabilities must be numeric"
224+
umb.index.transition_system.branch_probability_type = SizedType.for_type(target_type)
225+
umb.branch_to_probability = vector # type: ignore
226+
if ats.num_branch_actions > 0:
227+
branch_actions = [branch.action for branch in branches]
228+
if any(action is None for action in branch_actions):
229+
raise ValueError("if num_branch_actions > 0, all branches must have an action")
230+
umb.branch_to_branch_action = branch_actions # type: ignore
190231

191-
umb.choice_to_choice_action = ats.choice_to_choice_action
192232
umb.choice_action_to_string = ats.choice_action_to_name
193-
umb.branch_to_branch_action = ats.branch_to_branch_action
194233
umb.branch_action_to_string = ats.branch_action_to_name
195234

196235
# add annotations

0 commit comments

Comments
 (0)