Skip to content

Commit b7d2d8c

Browse files
authored
Record state count info in manifest, check during CI (#122)
Signed-off-by: Andrew Helwer <[email protected]>
1 parent cf8313d commit b7d2d8c

9 files changed

+545
-108
lines changed

.github/scripts/check_manifest_features.py

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,16 @@
99

1010
from argparse import ArgumentParser
1111
from dataclasses import dataclass
12+
import logging
1213
import glob
1314
from os.path import basename, dirname, join, normpath, splitext
1415
from typing import Any
1516
import re
1617
import tla_utils
1718
from tree_sitter import Language, Parser
1819

20+
logging.basicConfig(level=logging.INFO)
21+
1922
def build_ts_grammar(ts_path):
2023
"""
2124
Builds the tree-sitter-tlaplus grammar and constructs the parser.
@@ -199,44 +202,51 @@ def check_features(parser, queries, manifest, examples_root):
199202
for spec in manifest['specifications']:
200203
if spec['title'] == '':
201204
success = False
202-
print(f'ERROR: Spec {spec["path"]} does not have a title')
205+
logging.error(f'Spec {spec["path"]} does not have a title')
203206
if spec['description'] == '':
204207
success = False
205-
print(f'ERROR: Spec {spec["path"]} does not have a description')
208+
logging.error(f'Spec {spec["path"]} does not have a description')
206209
if not any(spec['authors']):
207210
success = False
208-
print(f'ERROR: Spec {spec["path"]} does not have any listed authors')
211+
logging.error(f'Spec {spec["path"]} does not have any listed authors')
209212
for module in spec['modules']:
210213
module_path = module['path']
211214
tree, text, parse_err = parse_module(examples_root, parser, module_path)
212215
if parse_err:
213216
success = False
214-
print(f'ERROR: Module {module["path"]} contains syntax errors')
217+
logging.error(f'Module {module["path"]} contains syntax errors')
215218
expected_features = get_tree_features(tree, queries)
216219
actual_features = set(module['features'])
217220
if expected_features != actual_features:
218221
success = False
219-
print(
220-
f'ERROR: Module {module["path"]} has incorrect features in manifest; '
222+
logging.error(
223+
f'Module {module["path"]} has incorrect features in manifest; '
221224
+ f'expected {list(expected_features)}, actual {list(actual_features)}'
222225
)
223226
expected_imports = get_community_imports(examples_root, tree, text, dirname(module_path), 'proof' in expected_features, queries)
224227
actual_imports = set(module['communityDependencies'])
225228
if expected_imports != actual_imports:
226229
success = False
227-
print(
228-
f'ERROR: Module {module["path"]} has incorrect community dependencies in manifest; '
230+
logging.error(
231+
f'Module {module["path"]} has incorrect community dependencies in manifest; '
229232
+ f'expected {list(expected_imports)}, actual {list(actual_imports)}'
230233
)
231234
for model in module['models']:
232235
expected_features = get_model_features(examples_root, model['path'])
233236
actual_features = set(model['features'])
234237
if expected_features != actual_features:
235238
success = False
236-
print(
237-
f'ERROR: Model {model["path"]} has incorrect features in manifest; '
239+
logging.error(
240+
f'Model {model["path"]} has incorrect features in manifest; '
238241
+ f'expected {list(expected_features)}, actual {list(actual_features)}'
239242
)
243+
if tla_utils.has_state_count(model) and not tla_utils.is_state_count_valid(model):
244+
success = False
245+
logging.error(
246+
f'Model {model["path"]} has state count info recorded; this is '
247+
+ 'only valid for exhaustive search models that complete successfully.'
248+
)
249+
240250
return success
241251

242252
if __name__ == '__main__':
@@ -253,9 +263,9 @@ def check_features(parser, queries, manifest, examples_root):
253263
queries = build_queries(TLAPLUS_LANGUAGE)
254264

255265
if check_features(parser, queries, manifest, examples_root):
256-
print('SUCCESS: metadata in manifest is correct')
266+
logging.info('SUCCESS: metadata in manifest is correct')
257267
exit(0)
258268
else:
259-
print("ERROR: metadata in manifest is incorrect")
269+
logging.error("Metadata in manifest is incorrect")
260270
exit(1)
261271

.github/scripts/check_small_models.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,17 @@ def check_model(module_path, model, expected_runtime):
5555
logging.error(f'Model {model_path} expected result {expected_result} but got {actual_result}')
5656
logging.error(tlc_result.stdout)
5757
return False
58+
if tla_utils.is_state_count_valid(model) and tla_utils.has_state_count(model):
59+
state_count_info = tla_utils.extract_state_count_info(tlc_result.stdout)
60+
if state_count_info is None:
61+
logging.error("Failed to find state info in TLC output")
62+
logging.error(tlc_result.stdout)
63+
return False
64+
if not tla_utils.is_state_count_info_correct(model, *state_count_info):
65+
logging.error("Recorded state count info differed from actual state counts:")
66+
logging.error(f"(distinct/total/depth); expected: {tla_utils.get_state_count_info(model)}, actual: {state_count_info}")
67+
logging.error(tlc_result.stdout)
68+
return False
5869
return True
5970
case TimeoutExpired():
6071
logging.error(f'{model_path} hit hard timeout of {hard_timeout_in_seconds} seconds')

.github/scripts/generate_manifest.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -140,9 +140,10 @@ def find_corresponding_model(old_model, new_module):
140140
return models[0] if any(models) else None
141141

142142
def integrate_model_info(old_model, new_model):
143-
fields = ['runtime', 'size', 'mode', 'result']
143+
fields = ['runtime', 'size', 'mode', 'result', 'distinctStates', 'totalStates', 'stateDepth']
144144
for field in fields:
145-
new_model[field] = old_model[field]
145+
if field in old_model:
146+
new_model[field] = old_model[field]
146147

147148
def integrate_old_manifest_into_new(old_manifest, new_manifest):
148149
for old_spec in old_manifest['specifications']:
@@ -180,7 +181,5 @@ def integrate_old_manifest_into_new(old_manifest, new_manifest):
180181
new_manifest = generate_new_manifest(examples_root, ignored_dirs, parser, queries)
181182
integrate_old_manifest_into_new(old_manifest, new_manifest)
182183

183-
# Write generated manifest to file
184-
with open(manifest_path, 'w', encoding='utf-8') as new_manifest_file:
185-
json.dump(new_manifest, new_manifest_file, indent=2, ensure_ascii=False)
184+
tla_utils.write_json(new_manifest, manifest_path)
186185

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
"""
2+
Records the number of unique & total states encountered by TLC for each small
3+
model where that info is not present, then writes it to the manifest.json.
4+
"""
5+
6+
from argparse import ArgumentParser
7+
import logging
8+
from os.path import dirname, normpath
9+
from subprocess import CompletedProcess, TimeoutExpired
10+
import tla_utils
11+
12+
parser = ArgumentParser(description='Updates manifest.json with unique & total model states for each small model.')
13+
parser.add_argument('--tools_jar_path', help='Path to the tla2tools.jar file', required=True)
14+
parser.add_argument('--tlapm_lib_path', help='Path to the TLA+ proof manager module directory; .tla files should be in this directory', required=True)
15+
parser.add_argument('--community_modules_jar_path', help='Path to the CommunityModules-deps.jar file', required=True)
16+
parser.add_argument('--manifest_path', help='Path to the tlaplus/examples manifest.json file', required=True)
17+
args = parser.parse_args()
18+
19+
logging.basicConfig(level=logging.INFO)
20+
21+
tools_jar_path = normpath(args.tools_jar_path)
22+
tlapm_lib_path = normpath(args.tlapm_lib_path)
23+
community_jar_path = normpath(args.community_modules_jar_path)
24+
manifest_path = normpath(args.manifest_path)
25+
examples_root = dirname(manifest_path)
26+
27+
def check_model(module_path, model):
28+
module_path = tla_utils.from_cwd(examples_root, module_path)
29+
model_path = tla_utils.from_cwd(examples_root, model['path'])
30+
logging.info(model_path)
31+
hard_timeout_in_seconds = 60
32+
tlc_result = tla_utils.check_model(
33+
tools_jar_path,
34+
module_path,
35+
model_path,
36+
tlapm_lib_path,
37+
community_jar_path,
38+
model['mode'],
39+
hard_timeout_in_seconds
40+
)
41+
match tlc_result:
42+
case CompletedProcess():
43+
expected_result = model['result']
44+
actual_result = tla_utils.resolve_tlc_exit_code(tlc_result.returncode)
45+
if expected_result != actual_result:
46+
logging.error(f'Model {model_path} expected result {expected_result} but got {actual_result}')
47+
logging.error(tlc_result.stdout)
48+
return False
49+
state_count_info = tla_utils.extract_state_count_info(tlc_result.stdout)
50+
if state_count_info is None:
51+
logging.error("Failed to find state info in TLC output")
52+
logging.error(tlc_result.stdout)
53+
return False
54+
logging.info(f'States (distinct, total, depth): {state_count_info}')
55+
model['distinctStates'], model['totalStates'], model['stateDepth'] = state_count_info
56+
return True
57+
case TimeoutExpired():
58+
logging.error(f'{model_path} hit hard timeout of {hard_timeout_in_seconds} seconds')
59+
logging.error(tlc_result.output.decode('utf-8'))
60+
return False
61+
case _:
62+
logging.error(f'Unhandled TLC result type {type(tlc_result)}: {tlc_result}')
63+
return False
64+
65+
# Ensure longest-running modules go first
66+
manifest = tla_utils.load_json(manifest_path)
67+
small_models = sorted(
68+
[
69+
(module['path'], model, tla_utils.parse_timespan(model['runtime']))
70+
for spec in manifest['specifications']
71+
for module in spec['modules']
72+
for model in module['models']
73+
if model['size'] == 'small'
74+
and tla_utils.is_state_count_valid(model)
75+
and (
76+
'distinctStates' not in model
77+
or 'totalStates' not in model
78+
or 'stateDepth' not in model
79+
)
80+
# This model is nondeterministic due to use of the Random module
81+
and model['path'] != 'specifications/SpanningTree/SpanTreeRandom.cfg'
82+
],
83+
key = lambda m: m[2],
84+
reverse=True
85+
)
86+
87+
for module_path, model, _ in small_models:
88+
success = check_model(module_path, model)
89+
if not success:
90+
exit(1)
91+
tla_utils.write_json(manifest, manifest_path)
92+
93+
exit(0)
94+

.github/scripts/tla_utils.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import json
33
from os.path import join, normpath, pathsep
44
import subprocess
5+
import re
56

67
def from_cwd(root, path):
78
"""
@@ -42,6 +43,13 @@ def load_json(path):
4243
with open(normpath(path), 'r', encoding='utf-8') as file:
4344
return json.load(file)
4445

46+
def write_json(data, path):
47+
"""
48+
Writes the given json to the given file.
49+
"""
50+
with open(path, 'w', encoding='utf-8') as file:
51+
json.dump(data, file, indent=2, ensure_ascii=False)
52+
4553
def parse_timespan(unparsed):
4654
"""
4755
Parses the timespan format used in the manifest.json format.
@@ -116,3 +124,47 @@ def resolve_tlc_exit_code(code):
116124

117125
return tlc_exit_codes[code] if code in tlc_exit_codes else str(code)
118126

127+
def is_state_count_valid(model):
128+
"""
129+
Whether state count info could be valid for the given model.
130+
"""
131+
return model['mode'] == 'exhaustive search' and model['result'] == 'success'
132+
133+
def has_state_count(model):
134+
"""
135+
Whether the given model has state count info.
136+
"""
137+
return 'distinctStates' in model or 'totalStates' in model or 'stateDepth' in model
138+
139+
def get_state_count_info(model):
140+
"""
141+
Gets whatever state count info is present in the given model.
142+
"""
143+
get_or_none = lambda key: model[key] if key in model else None
144+
return (get_or_none('distinctStates'), get_or_none('totalStates'), get_or_none('stateDepth'))
145+
146+
def is_state_count_info_correct(model, distinct_states, total_states, state_depth):
147+
"""
148+
Whether the given state count info concords with the model.
149+
"""
150+
expected_distinct_states, expected_total_states, expected_state_depth = get_state_count_info(model)
151+
none_or_equal = lambda expected, actual: expected is None or expected == actual
152+
# State depth not yet deterministic due to TLC bug: https://github.com/tlaplus/tlaplus/issues/883
153+
return none_or_equal(expected_distinct_states, distinct_states) and none_or_equal(expected_total_states, total_states) #and none_or_equal(expected_state_depth, state_depth)
154+
155+
state_count_regex = re.compile(r'(?P<total_states>\d+) states generated, (?P<distinct_states>\d+) distinct states found, 0 states left on queue.')
156+
state_depth_regex = re.compile(r'The depth of the complete state graph search is (?P<state_depth>\d+).')
157+
158+
def extract_state_count_info(tlc_output):
159+
"""
160+
Parse & extract state count info from TLC output.
161+
"""
162+
state_count_findings = state_count_regex.search(tlc_output)
163+
state_depth_findings = state_depth_regex.search(tlc_output)
164+
if state_count_findings is None or state_depth_findings is None:
165+
return None
166+
distinct_states = int(state_count_findings.group('distinct_states'))
167+
total_states = int(state_count_findings.group('total_states'))
168+
state_depth = int(state_depth_findings.group('state_depth'))
169+
return (distinct_states, total_states, state_depth)
170+

.github/workflows/CI.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ jobs:
1818
include:
1919
- { os: windows-latest }
2020
- { os: ubuntu-latest }
21-
- { os: macos-14 }
21+
# https://github.com/tlaplus/Examples/issues/119
22+
#- { os: macos-14 }
2223
fail-fast: false
2324
env:
2425
SCRIPT_DIR: .github/scripts

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ Here is a list of specs included in this repository, with links to the relevant
5252
| [Minimal Circular Substring](specifications/LeastCircularSubstring) | Andrew Helwer | | ||| |
5353
| [Snapshot Key-Value Store](specifications/KeyValueStore) | Andrew Helwer, Murat Demirbas | | ||| |
5454
| [Chang-Roberts Algorithm for Leader Election in a Ring](specifications/chang_roberts) | Stephan Merz | | ||| |
55+
| [MultiPaxos in SMR-Style](specifications/MultiPaxos-SMR) | Guanzhou Hu | | ||| |
5556
| [Resource Allocator](specifications/allocator) | Stephan Merz | | | || |
5657
| [Transitive Closure](specifications/TransitiveClosure) | Stephan Merz | | | || |
5758
| [Atomic Commitment Protocol](specifications/acp) | Stephan Merz | | | || |
@@ -86,7 +87,6 @@ Here is a list of specs included in this repository, with links to the relevant
8687
| [RFC 3506: Voucher Transaction System](specifications/byihive) | Santhosh Raju, Cherry G. Mathew, Fransisca Andriani | | | || |
8788
| [TLA⁺ Level Checking](specifications/LevelChecking) | Leslie Lamport | | | | | |
8889
| [Condition-Based Consensus](specifications/cbc_max) | Thanh Hai Tran, Igor Konnov, Josef Widder | | | | | |
89-
| [MultiPaxos in SMR-Style](specifications/MultiPaxos-SMR) | Guanzhou Hu | | ||| |
9090

9191
## Examples Elsewhere
9292
Here is a list of specs stored in locations outside this repository, including submodules.
@@ -185,6 +185,9 @@ Otherwise, follow these directions:
185185
- `"safety failure"` if the model violates an invariant
186186
- `"liveness failure"` if the model fails to satisfy a liveness property
187187
- `"deadlock failure"` if the model encounters deadlock
188+
- (Optional) Model state count info: distinct states, total states, and state depth
189+
- These are all individually optional and only valid if your model uses exhaustive search and results in success
190+
- Recording these turns your model into a powerful regression test for TLC
188191
- Other fields are auto-generated by the script; if you are adding an entry manually, ensure their values are present and correct (see other entries or the [`manifest-schema.json`](manifest-schema.json) file)
189192

190193
Before submitted your changes to run in the CI, you can quickly check your [`manifest.json`](manifest.json) for errors and also check it against [`manifest-schema.json`](manifest-schema.json) at https://www.jsonschemavalidator.net/.

manifest-schema.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,9 @@
5555
"pattern": "^(([0-9][0-9]:[0-9][0-9]:[0-9][0-9])|unknown)$"
5656
},
5757
"size": {"enum": ["small", "medium", "large", "unknown"]},
58+
"distinctStates": {"type": "integer"},
59+
"totalStates": {"type": "integer"},
60+
"stateDepth": {"type": "integer"},
5861
"mode": {
5962
"oneOf": [
6063
{

0 commit comments

Comments
 (0)