ReForma 0.3__tar.gz → 0.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {reforma-0.3 → reforma-0.4}/PKG-INFO +5 -2
- {reforma-0.3 → reforma-0.4}/ReForma.egg-info/PKG-INFO +5 -2
- reforma-0.4/ReForma.egg-info/requires.txt +6 -0
- {reforma-0.3 → reforma-0.4}/pyproject.toml +5 -2
- {reforma-0.3 → reforma-0.4}/reforma/__init__.py +19 -19
- {reforma-0.3 → reforma-0.4}/reforma/bin/RePATool.jar +0 -0
- {reforma-0.3 → reforma-0.4}/reforma/client.py +109 -56
- {reforma-0.3 → reforma-0.4}/reforma/jar_bridge.py +4 -0
- {reforma-0.3 → reforma-0.4}/reforma/model.py +2 -1
- {reforma-0.3 → reforma-0.4}/tests/test_reforma.py +2 -2
- reforma-0.3/ReForma.egg-info/requires.txt +0 -2
- {reforma-0.3 → reforma-0.4}/MANIFEST.in +0 -0
- {reforma-0.3 → reforma-0.4}/README.md +0 -0
- {reforma-0.3 → reforma-0.4}/ReForma.egg-info/SOURCES.txt +0 -0
- {reforma-0.3 → reforma-0.4}/ReForma.egg-info/dependency_links.txt +0 -0
- {reforma-0.3 → reforma-0.4}/ReForma.egg-info/top_level.txt +0 -0
- {reforma-0.3 → reforma-0.4}/reforma/bin/js/cytoscape-dagre.js +0 -0
- {reforma-0.3 → reforma-0.4}/reforma/bin/js/cytoscape.min.js +0 -0
- {reforma-0.3 → reforma-0.4}/reforma/bin/js/dagre.js +0 -0
- {reforma-0.3 → reforma-0.4}/setup.cfg +0 -0
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ReForma
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4
|
|
4
4
|
Summary: Python bindings for the RePA/ReForma probabilistic automaton tool
|
|
5
5
|
Author-email: Joshua Dourado <joshuadourado@ua.pt>
|
|
6
6
|
License: MIT
|
|
7
7
|
Classifier: Programming Language :: Python :: 3
|
|
8
8
|
Classifier: Operating System :: OS Independent
|
|
9
9
|
Classifier: Topic :: Scientific/Engineering
|
|
10
|
-
Requires-Python: >=3.
|
|
10
|
+
Requires-Python: >=3.9
|
|
11
11
|
Description-Content-Type: text/markdown
|
|
12
12
|
Requires-Dist: networkx
|
|
13
13
|
Requires-Dist: matplotlib
|
|
14
|
+
Provides-Extra: data
|
|
15
|
+
Requires-Dist: pandas; extra == "data"
|
|
16
|
+
Requires-Dist: numpy; extra == "data"
|
|
14
17
|
|
|
15
18
|
|
|
16
19
|
# ReForma — Reconfigurable Formal Automata in Python
|
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ReForma
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4
|
|
4
4
|
Summary: Python bindings for the RePA/ReForma probabilistic automaton tool
|
|
5
5
|
Author-email: Joshua Dourado <joshuadourado@ua.pt>
|
|
6
6
|
License: MIT
|
|
7
7
|
Classifier: Programming Language :: Python :: 3
|
|
8
8
|
Classifier: Operating System :: OS Independent
|
|
9
9
|
Classifier: Topic :: Scientific/Engineering
|
|
10
|
-
Requires-Python: >=3.
|
|
10
|
+
Requires-Python: >=3.9
|
|
11
11
|
Description-Content-Type: text/markdown
|
|
12
12
|
Requires-Dist: networkx
|
|
13
13
|
Requires-Dist: matplotlib
|
|
14
|
+
Provides-Extra: data
|
|
15
|
+
Requires-Dist: pandas; extra == "data"
|
|
16
|
+
Requires-Dist: numpy; extra == "data"
|
|
14
17
|
|
|
15
18
|
|
|
16
19
|
# ReForma — Reconfigurable Formal Automata in Python
|
|
@@ -4,10 +4,10 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "ReForma"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.4"
|
|
8
8
|
description = "Python bindings for the RePA/ReForma probabilistic automaton tool"
|
|
9
9
|
readme = "README.md"
|
|
10
|
-
requires-python = ">=3.
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
11
|
license = { text = "MIT" }
|
|
12
12
|
authors = [
|
|
13
13
|
{ name = "Joshua Dourado", email = "joshuadourado@ua.pt" }
|
|
@@ -22,6 +22,9 @@ dependencies = [
|
|
|
22
22
|
"matplotlib"
|
|
23
23
|
]
|
|
24
24
|
|
|
25
|
+
[project.optional-dependencies]
|
|
26
|
+
data = ["pandas", "numpy"]
|
|
27
|
+
|
|
25
28
|
[tool.setuptools]
|
|
26
29
|
packages = ["reforma", "reforma.bin", "reforma.bin.js"]
|
|
27
30
|
|
|
@@ -1,19 +1,19 @@
|
|
|
1
|
-
"""
|
|
2
|
-
reforma - Python bindings for the RePA/reforma tool.
|
|
3
|
-
|
|
4
|
-
The JAR is bundled inside the package. Usage:
|
|
5
|
-
|
|
6
|
-
from reforma import ReForma
|
|
7
|
-
|
|
8
|
-
model = ReForma()
|
|
9
|
-
|
|
10
|
-
state = model.load_file("my_model.r")
|
|
11
|
-
state = model.step("go_work")
|
|
12
|
-
result = model.check_pdl("Home", "{P=?[F Office]}")
|
|
13
|
-
"""
|
|
14
|
-
|
|
15
|
-
from .client import ReForma
|
|
16
|
-
from .model import ReFormaModel, Transition, SimulationState
|
|
17
|
-
|
|
18
|
-
__all__ = ["ReForma", "ReFormaModel", "Transition", "SimulationState"]
|
|
19
|
-
__version__ = "0.
|
|
1
|
+
"""
|
|
2
|
+
reforma - Python bindings for the RePA/reforma tool.
|
|
3
|
+
|
|
4
|
+
The JAR is bundled inside the package. Usage:
|
|
5
|
+
|
|
6
|
+
from reforma import ReForma
|
|
7
|
+
|
|
8
|
+
model = ReForma()
|
|
9
|
+
|
|
10
|
+
state = model.load_file("my_model.r")
|
|
11
|
+
state = model.step("go_work")
|
|
12
|
+
result = model.check_pdl("Home", "{P=?[F Office]}")
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from .client import ReForma
|
|
16
|
+
from .model import ReFormaModel, Transition, SimulationState
|
|
17
|
+
|
|
18
|
+
__all__ = ["ReForma", "ReFormaModel", "Transition", "SimulationState"]
|
|
19
|
+
__version__ = "0.4"
|
|
Binary file
|
|
@@ -81,19 +81,20 @@ class ReForma:
|
|
|
81
81
|
|
|
82
82
|
|
|
83
83
|
def create_from_dataframe(
|
|
84
|
-
self,
|
|
85
|
-
df,
|
|
86
|
-
session_col: str = 'user_session',
|
|
87
|
-
event_col: str = 'event_type',
|
|
84
|
+
self,
|
|
85
|
+
df,
|
|
86
|
+
session_col: str = 'user_session',
|
|
87
|
+
event_col: str = 'event_type',
|
|
88
88
|
sub_event_col: Optional[str] = None,
|
|
89
|
-
time_col: str = 'event_time',
|
|
89
|
+
time_col: str = 'event_time',
|
|
90
90
|
model_name: str = "MinedModel",
|
|
91
91
|
save_model_path: Optional[str] = None,
|
|
92
|
-
save_traces_path: Optional[str] = None
|
|
92
|
+
save_traces_path: Optional[str] = None,
|
|
93
|
+
initial_weights: str = "frequency"
|
|
93
94
|
) -> list[list[str]]:
|
|
94
95
|
"""
|
|
95
96
|
Discovers the RePA model directly from a Pandas DataFrame.
|
|
96
|
-
|
|
97
|
+
|
|
97
98
|
:param df: Pandas DataFrame containing the dataset.
|
|
98
99
|
:param session_col: Column name identifying user sessions.
|
|
99
100
|
:param event_col: Column name containing the action/event.
|
|
@@ -102,34 +103,85 @@ class ReForma:
|
|
|
102
103
|
:param model_name: Name to give the automaton.
|
|
103
104
|
:param save_model_path: If set, saves the generated .rta code to this file path.
|
|
104
105
|
:param save_traces_path: If set, saves the session logs (.txt) to this file path.
|
|
106
|
+
:param initial_weights: "frequency" (default) writes the observed transition frequencies (the maximum
|
|
107
|
+
likelihood estimate) as weights; "uniform" leaves the weights out, so every state starts uniform.
|
|
105
108
|
:return: A list of sessions (each session is a list of transition IDs).
|
|
106
109
|
"""
|
|
107
110
|
try:
|
|
108
111
|
import pandas as pd
|
|
112
|
+
import numpy as np
|
|
109
113
|
import re
|
|
110
114
|
except ImportError:
|
|
111
115
|
raise RuntimeError("To use process mining you need to install: pip install pandas")
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
df_work =
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
116
|
+
if initial_weights not in ("frequency", "uniform"):
|
|
117
|
+
raise ValueError("initial_weights must be 'frequency' or 'uniform'")
|
|
118
|
+
|
|
119
|
+
has_sub = bool(sub_event_col) and sub_event_col in df.columns
|
|
120
|
+
cols = [session_col, time_col, event_col] + ([sub_event_col] if has_sub else [])
|
|
121
|
+
df_work = df.loc[df[session_col].notna(), cols]
|
|
122
|
+
# stable sort: events with the same timestamp keep the order of the log
|
|
123
|
+
df_work = df_work.sort_values(by=[session_col, time_col], kind="mergesort")
|
|
124
|
+
|
|
125
|
+
# Every row gets the integer code of its (composite) event; all the work below is done on these codes.
|
|
126
|
+
ev = df_work[event_col].astype("category")
|
|
127
|
+
ev_codes = ev.cat.codes.to_numpy().astype(np.int64)
|
|
128
|
+
ev_names = [str(c) for c in ev.cat.categories]
|
|
129
|
+
if has_sub:
|
|
130
|
+
sub = df_work[sub_event_col].astype("category")
|
|
131
|
+
sub_names = [str(c) for c in sub.cat.categories] + ["other"] # missing sub-events -> "other"
|
|
132
|
+
sub_codes = sub.cat.codes.to_numpy().astype(np.int64)
|
|
133
|
+
sub_codes[sub_codes < 0] = len(sub_names) - 1
|
|
134
|
+
# the last component of a category ("electronics.smartphone" -> "smartphone") is used, unless two
|
|
135
|
+
# different categories share it: those keep their full path
|
|
136
|
+
by_leaf: dict = {}
|
|
137
|
+
for c in sub_names:
|
|
138
|
+
by_leaf.setdefault(c.split('.')[-1], set()).add(c)
|
|
139
|
+
short = [c.split('.')[-1] if len(by_leaf[c.split('.')[-1]]) == 1 else c for c in sub_names]
|
|
140
|
+
combo = ev_codes * len(sub_names) + sub_codes
|
|
141
|
+
uniq, raw_codes = np.unique(combo, return_inverse=True)
|
|
142
|
+
raw_names = [f"{ev_names[u // len(sub_names)]}_{short[u % len(sub_names)]}" for u in uniq]
|
|
123
143
|
else:
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
144
|
+
raw_codes, raw_names = ev_codes, ev_names
|
|
145
|
+
raw_codes = np.asarray(raw_codes, dtype=np.int64)
|
|
146
|
+
|
|
147
|
+
# One Re-Lang name per distinct event, distinct from each other, from the artificial states and from keywords.
|
|
148
|
+
reserved = {"Start", "exit", "init", "training", "calibration", "paradigm", "name", "int", "float", "bool",
|
|
149
|
+
"dyn", "def", "aut", "if", "then", "disabled", "true", "false", "return", "print", "foreach",
|
|
150
|
+
"in", "AND", "OR", "laplace", "aggregation", "arith", "prod", "max", "min", "geom"}
|
|
151
|
+
clean = [""] * len(raw_names)
|
|
152
|
+
used = set(reserved)
|
|
153
|
+
for i in sorted(range(len(raw_names)), key=lambda i: raw_names[i]):
|
|
154
|
+
base = re.sub(r'[^a-zA-Z0-9_]', '_', raw_names[i]).strip('_') or "event"
|
|
155
|
+
if base[0].isdigit() or base in reserved:
|
|
156
|
+
base = "ev_" + base
|
|
157
|
+
candidate, k = base, 2
|
|
158
|
+
while candidate in used:
|
|
159
|
+
candidate, k = f"{base}_{k}", k + 1
|
|
160
|
+
used.add(candidate)
|
|
161
|
+
clean[i] = candidate
|
|
162
|
+
K = len(clean)
|
|
163
|
+
names = clean + ["exit"] # index K is the artificial exit
|
|
164
|
+
|
|
165
|
+
# sessions are contiguous after the sort
|
|
166
|
+
sess_codes = pd.factorize(df_work[session_col], sort=False)[0]
|
|
167
|
+
new_sess = np.empty(len(sess_codes), dtype=bool)
|
|
168
|
+
new_sess[:1] = True
|
|
169
|
+
new_sess[1:] = sess_codes[1:] != sess_codes[:-1]
|
|
170
|
+
is_last = np.empty_like(new_sess)
|
|
171
|
+
is_last[:-1] = new_sess[1:]
|
|
172
|
+
is_last[-1:] = True
|
|
173
|
+
nxt = np.empty_like(raw_codes)
|
|
174
|
+
nxt[:-1] = raw_codes[1:]
|
|
175
|
+
nxt[is_last] = K
|
|
176
|
+
|
|
177
|
+
firsts = raw_codes[new_sess]
|
|
178
|
+
first_counts = np.bincount(firsts, minlength=K)
|
|
179
|
+
pair = raw_codes * (K + 1) + nxt
|
|
180
|
+
pair_counts = np.bincount(pair, minlength=K * (K + 1))
|
|
181
|
+
out_totals = pair_counts.reshape(K, K + 1).sum(axis=1)
|
|
182
|
+
|
|
183
|
+
def weight(count, total) -> str:
|
|
184
|
+
return f" ({count / total:.6f})" if initial_weights == "frequency" else ""
|
|
133
185
|
|
|
134
186
|
rta_output = [
|
|
135
187
|
f"name {model_name};",
|
|
@@ -139,44 +191,33 @@ class ReForma:
|
|
|
139
191
|
"init Start;\n"
|
|
140
192
|
]
|
|
141
193
|
|
|
142
|
-
mapping = {}
|
|
143
194
|
edge_counter = 1
|
|
144
|
-
|
|
145
|
-
|
|
195
|
+
n_sessions = int(first_counts.sum())
|
|
196
|
+
start_label = np.empty(K, dtype=object)
|
|
197
|
+
for i in sorted((i for i in range(K) if first_counts[i] > 0), key=lambda i: (-first_counts[i], names[i])):
|
|
146
198
|
label_id = f"e{edge_counter}"
|
|
147
|
-
rta_output.append(f"Start -enter_{
|
|
148
|
-
|
|
199
|
+
rta_output.append(f"Start -enter_{names[i]}-> {names[i]} : {label_id}{weight(first_counts[i], n_sessions)}")
|
|
200
|
+
start_label[i] = label_id
|
|
149
201
|
edge_counter += 1
|
|
150
202
|
|
|
151
203
|
rta_output.append("")
|
|
152
204
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
mapping[(src, tgt)] = label_id
|
|
162
|
-
edge_counter += 1
|
|
205
|
+
pair_label = np.empty(K * (K + 1), dtype=object)
|
|
206
|
+
observed = [p for p in np.flatnonzero(pair_counts)]
|
|
207
|
+
for p in sorted(observed, key=lambda p: (names[p // (K + 1)], names[p % (K + 1)])):
|
|
208
|
+
src, tgt = names[p // (K + 1)], names[p % (K + 1)]
|
|
209
|
+
label_id = f"e{edge_counter}"
|
|
210
|
+
rta_output.append(f"{src} -{tgt}-> {tgt} : {label_id}{weight(pair_counts[p], out_totals[p // (K + 1)])}")
|
|
211
|
+
pair_label[p] = label_id
|
|
212
|
+
edge_counter += 1
|
|
163
213
|
|
|
164
214
|
loop_edge_id = f"e{edge_counter}"
|
|
165
215
|
rta_output.append("\n// Loop at the final state to avoid deadlock in PRISM")
|
|
166
216
|
rta_output.append(f"exit -loop-> exit : {loop_edge_id} (1.0000)")
|
|
167
217
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
first_in_session = df_work.groupby(session_col)['clean_event'].first()
|
|
172
|
-
start_edges = first_in_session.apply(lambda e: mapping.get(('Start', e)))
|
|
173
|
-
internal_edges = df_work.groupby(session_col)['edge_id'].apply(list)
|
|
174
|
-
|
|
175
|
-
traces = []
|
|
176
|
-
for session_id in internal_edges.index:
|
|
177
|
-
start_edge = start_edges[session_id]
|
|
178
|
-
path = [start_edge] + [e for e in internal_edges[session_id] if pd.notna(e)]
|
|
179
|
-
traces.append(path)
|
|
218
|
+
row_labels = pair_label[pair]
|
|
219
|
+
starts = np.flatnonzero(new_sess)
|
|
220
|
+
traces = [[start_label[f]] + chunk.tolist() for f, chunk in zip(firsts, np.split(row_labels, starts[1:]))]
|
|
180
221
|
|
|
181
222
|
full_source = "\n".join(rta_output)
|
|
182
223
|
self.load(full_source, name=model_name)
|
|
@@ -184,10 +225,12 @@ class ReForma:
|
|
|
184
225
|
if save_model_path:
|
|
185
226
|
with open(save_model_path, "w", encoding="utf-8") as f:
|
|
186
227
|
f.write(full_source)
|
|
187
|
-
|
|
228
|
+
|
|
188
229
|
if save_traces_path:
|
|
189
230
|
with open(save_traces_path, "w", encoding="utf-8") as f:
|
|
190
|
-
|
|
231
|
+
for t in traces:
|
|
232
|
+
f.write(",".join(t))
|
|
233
|
+
f.write("\n")
|
|
191
234
|
|
|
192
235
|
return traces
|
|
193
236
|
|
|
@@ -771,6 +814,11 @@ class ReForma:
|
|
|
771
814
|
self._require_model()
|
|
772
815
|
log_lines = [",".join(s) for s in sessions]
|
|
773
816
|
updated_source = self._bridge.train(self._model.source, log_lines)
|
|
817
|
+
# the engine reports events that could not be replayed (unknown label, or not enabled at that point)
|
|
818
|
+
report = next((l for l in getattr(self._bridge, "last_stdout", "").splitlines() if "Warning:" in l), None)
|
|
819
|
+
if report:
|
|
820
|
+
import warnings
|
|
821
|
+
warnings.warn(report.split("Training completed")[-1].strip(), RuntimeWarning, stacklevel=2)
|
|
774
822
|
self._model = ReFormaModel.from_string(updated_source, self._model.name)
|
|
775
823
|
self._current_source = updated_source
|
|
776
824
|
self._history = []
|
|
@@ -807,6 +855,11 @@ class ReForma:
|
|
|
807
855
|
return self._bridge.get_prism(self._model.source)
|
|
808
856
|
|
|
809
857
|
|
|
858
|
+
def export_mcrl2(self) -> str:
|
|
859
|
+
"""Export to mCRL2."""
|
|
860
|
+
self._require_model()
|
|
861
|
+
return self._bridge.get_mcrl2(self._model.source)
|
|
862
|
+
|
|
810
863
|
def export_glts(self) -> str:
|
|
811
864
|
"""Translate to GLTS (imperative) format."""
|
|
812
865
|
self._require_model()
|
|
@@ -141,6 +141,9 @@ class JarBridge:
|
|
|
141
141
|
def get_prism(self, source: str) -> str:
|
|
142
142
|
return self._run_with_tempfile("-prism", source)
|
|
143
143
|
|
|
144
|
+
def get_mcrl2(self, source: str) -> str:
|
|
145
|
+
return self._run_with_tempfile("-mcrl2", source)
|
|
146
|
+
|
|
144
147
|
def get_cytoscape(self, source: str, history: Optional[list[str]] = None) -> str:
|
|
145
148
|
extra = [",".join(history)] if history else []
|
|
146
149
|
return self._run_with_tempfile("-cytoscape", source, extra_args=extra)
|
|
@@ -193,6 +196,7 @@ class JarBridge:
|
|
|
193
196
|
raise JarError(
|
|
194
197
|
f"JAR exited {result.returncode}:\n{result.stderr.strip()}"
|
|
195
198
|
)
|
|
199
|
+
self.last_stdout = result.stdout or ""
|
|
196
200
|
out_path = args[-1]
|
|
197
201
|
if not os.path.exists(out_path):
|
|
198
202
|
raise JarError(
|
|
@@ -88,7 +88,8 @@ class ReFormaModel:
|
|
|
88
88
|
def from_file(cls, path: str) -> "ReFormaModel":
|
|
89
89
|
with open(path, "r", encoding="utf-8") as f:
|
|
90
90
|
source = f.read()
|
|
91
|
-
|
|
91
|
+
import os
|
|
92
|
+
name = os.path.splitext(os.path.basename(path))[0]
|
|
92
93
|
return cls(source=source, name=name)
|
|
93
94
|
|
|
94
95
|
@classmethod
|
|
@@ -5,9 +5,9 @@ tests/test_reforma.py — full test suite (mocked, no real JAR needed).
|
|
|
5
5
|
import pytest
|
|
6
6
|
from unittest.mock import MagicMock, patch
|
|
7
7
|
|
|
8
|
-
from reforma import reforma
|
|
8
|
+
from reforma import ReForma as reforma
|
|
9
9
|
|
|
10
|
-
from reforma.model import reformaModel, Transition, SimulationState
|
|
10
|
+
from reforma.model import ReFormaModel as reformaModel, Transition, SimulationState
|
|
11
11
|
from reforma.jar_bridge import JarBridge, JarError
|
|
12
12
|
from reforma.client import _parse_pdl_result
|
|
13
13
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|