umaudemc 0.16.0__py3-none-any.whl → 0.17.1__py3-none-any.whl

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.
umaudemc/__init__.py CHANGED
@@ -1 +1 @@
1
- __version__ = '0.16.0'
1
+ __version__ = '0.17.0'
umaudemc/__main__.py CHANGED
@@ -318,6 +318,7 @@ def build_parser():
318
318
  )
319
319
  parser_scheck.add_argument(
320
320
  '-D',
321
+ metavar='name=value',
321
322
  action='append',
322
323
  help='Define a constant to be used in QuaTEx expressions'
323
324
  )
@@ -19,16 +19,16 @@ def show_results(program, nsims, qdata):
19
19
  qdata_it = iter(qdata)
20
20
  q = next(qdata_it, None)
21
21
 
22
- for k, (fname, line, column, params) in enumerate(program.query_locations):
22
+ for k, query in enumerate(program.queries):
23
23
  # Print the query name and location only if there are many
24
24
  if program.nqueries > 1:
25
25
  # If the number of simulation is lower for this query
26
26
  sim_detail = f' ({q.n} simulations)' if q.n != nsims else ''
27
27
 
28
- print(f'Query {k + 1} ({fname}:{line}:{column}){sim_detail}')
28
+ print(f'Query {k + 1} ({query.filename}:{query.line}:{query.column}){sim_detail}')
29
29
 
30
30
  # For parametric queries, we show the result for every value
31
- var = params[0] if params else None
31
+ var = query.parameters[0] if query.parameters else None
32
32
 
33
33
  while q and q.query == k:
34
34
  if var:
@@ -93,10 +93,10 @@ def plot_results(program, qdata):
93
93
  return
94
94
 
95
95
  for k, xs, ys, rs in results:
96
- line, column, _ = program.query_locations[k]
96
+ query = program.queries[k]
97
97
 
98
98
  # Plot the mean
99
- p = plt.plot(xs, ys, label=f'{line}:{column}')
99
+ p = plt.plot(xs, ys, label=f'{query.line}:{query.column}')
100
100
  # Plot the confidence interval
101
101
  plt.fill_between(xs, [y - r for y, r in zip(ys, rs)],
102
102
  [y + r for y, r in zip(ys, rs)],
@@ -96,13 +96,16 @@ class Worker:
96
96
  # (delta, its second argument, does not matter because
97
97
  # convergence is not evaluated by the worker)
98
98
  qdata = [QueryData(k, 1.0, idict)
99
- for k, qinfo in enumerate(program.query_locations)
100
- for idict in make_parameter_dicts(qinfo[3])]
99
+ for k, qinfo in enumerate(program.queries)
100
+ for idict, _ in make_parameter_dicts(qinfo, 1.0)]
101
101
 
102
+ # Compact arrays are used so that they can be sent through the socket
102
103
  sums = array('d', [0.0] * len(qdata))
103
104
  sum_sq = array('d', [0.0] * len(qdata))
104
105
  counts = array('i', [0] * len(qdata))
105
106
 
107
+ converged = array('i') # to store indices of converged queries
108
+
106
109
  while True:
107
110
 
108
111
  for _ in range(block):
@@ -120,14 +123,27 @@ class Worker:
120
123
  # Check whether to continue
121
124
  answer = conn.recv(1)
122
125
 
126
+ # Stop command
123
127
  if answer == b's':
124
128
  print('Done')
125
129
  return
126
130
 
131
+ # Partial convergence
132
+ elif answer == b'p':
133
+ mcount = int.from_bytes(conn.recv(4), 'big')
134
+ # Safety check
135
+ if mcount <= len(qdata):
136
+ converged.frombytes(conn.recv(4 * mcount))
137
+ # Set the converged queries to avoid recomputing them
138
+ for index in converged:
139
+ qdata[index].converged = True
140
+ del converged[:]
141
+
127
142
  elif answer != b'c':
128
143
  usermsgs.print_error(f'Unknown command {answer.decode()}. Stopping.')
129
144
  return
130
145
 
146
+ # Reset the accumulators for the next round
131
147
  for k in range(len(qdata)):
132
148
  sums[k] = 0
133
149
  sum_sq[k] = 0
umaudemc/distributed.py CHANGED
@@ -225,7 +225,7 @@ def setup_workers(args, initial_data, dspec, constants, seen_files, stack):
225
225
  input_data['block'] = block_size # if specified
226
226
 
227
227
  input_data = json.dumps(input_data).encode()
228
- sock.sendall(len(input_data).to_bytes(4) + input_data)
228
+ sock.sendall(len(input_data).to_bytes(4, 'big') + input_data)
229
229
 
230
230
  # Send the relevant files
231
231
  with sock.makefile('wb', buffering=0) as fobj:
@@ -249,6 +249,17 @@ def setup_workers(args, initial_data, dspec, constants, seen_files, stack):
249
249
  return sockets
250
250
 
251
251
 
252
+ class WorkerData:
253
+ """Relevant data for the worker"""
254
+
255
+ __attrs__ = ('index', 'block', 'name')
256
+
257
+ def __init__(self, index, block, data):
258
+ self.index = index
259
+ self.block = data.get('block', block)
260
+ self.name = data.get('name')
261
+
262
+
252
263
  def distributed_check(args, initial_data, min_sim, max_sim, program, constants, seen_files):
253
264
  """Distributed statistical model checking"""
254
265
 
@@ -269,20 +280,27 @@ def distributed_check(args, initial_data, min_sim, max_sim, program, constants,
269
280
  # Use a selector to wait for updates from any worker
270
281
  selector = selectors.DefaultSelector()
271
282
 
272
- for sock, data in zip(sockets, dspec['workers']):
273
- selector.register(sock, selectors.EVENT_READ, data={'block': args.block} | data)
274
- sock.send(b'c')
283
+ # Use a selector to be notified of the worker answers
284
+ for k, (sock, data) in enumerate(zip(sockets, dspec['workers'])):
285
+ selector.register(sock, selectors.EVENT_READ, data=WorkerData(k, args.block, data))
286
+ sock.send(b'c') # continue command
275
287
 
276
288
  buffer = array('d')
277
289
  ibuffer = array('i')
278
290
 
279
291
  # Query data
280
- qdata = [QueryData(k, args.delta, idict)
281
- for k, qinfo in enumerate(program.query_locations)
282
- for idict in make_parameter_dicts(qinfo[3])]
292
+ qdata = [QueryData(k, delta, idict)
293
+ for k, qinfo in enumerate(program.queries)
294
+ for idict, delta in make_parameter_dicts(qinfo, args.delta)]
283
295
  nqueries = len(qdata)
284
296
  num_sims = 0
285
297
 
298
+ # In order to tell the workers which queries have converged, we keep
299
+ # a list of indices of converged queries in cronological order and
300
+ # a list with the next index to be transmitted to each worker
301
+ converged_queries = array('i')
302
+ next_to_tell = [0] * len(sockets)
303
+
286
304
  quantile = get_quantile_func()
287
305
 
288
306
  while sockets:
@@ -294,35 +312,58 @@ def distributed_check(args, initial_data, min_sim, max_sim, program, constants,
294
312
 
295
313
  answer = sock.recv(1)
296
314
 
315
+ # Block finished
297
316
  if answer == b'b':
317
+ # The message is the concatenation of three arrays
318
+ # (sum, sum_sq, and counts) of nqueries elements each.
319
+ # sum and sum_sq contain 64-bit floating-point numbers
320
+ # and counts contains 32-bit integer numbers
298
321
  data = sock.recv(24 * nqueries)
299
322
  buffer.frombytes(data[:16 * nqueries])
300
323
  ibuffer.frombytes(data[16 * nqueries:])
301
324
 
325
+ # Some queries may have not been evaluated because
326
+ # they converged, but it is easier to sum their zeros
302
327
  for k in range(nqueries):
303
328
  qdata[k].sum += buffer[k]
304
329
  qdata[k].sum_sq += buffer[nqueries + k]
305
330
  qdata[k].n += ibuffer[k]
306
331
 
307
- num_sims += key.data['block']
332
+ num_sims += key.data.block
308
333
 
309
334
  del buffer[:]
310
335
  del ibuffer[:]
311
- finished.append(key.fileobj)
336
+ finished.append(key)
312
337
 
313
338
  else:
314
- usermsgs.print_error(f'Server {key.data["name"]} disconnected or misbehaving')
339
+ usermsgs.print_error(f'Server {key.data.name} disconnected or misbehaving')
315
340
  selector.unregister(key.fileobj)
316
341
  sockets.remove(key.fileobj)
317
342
 
318
343
  # Check whether the simulation has converged
319
- converged = check_interval(qdata, num_sims, min_sim, args.alpha, quantile, args.verbose)
344
+ converged, which = check_interval(qdata, num_sims, min_sim, args.alpha, quantile, args.verbose)
345
+
346
+ # More converged queries
347
+ if which:
348
+ converged_queries.extend(which)
320
349
 
321
350
  if converged or max_sim and num_sims >= max_sim:
322
351
  break
323
352
 
324
- for sock in finished:
325
- sock.send(b'c')
353
+ for key in finished:
354
+ index = key.data.index
355
+
356
+ # Transmit which queries have converged
357
+ if next_to_tell[index] != len(converged_queries):
358
+ # We send p followed by the number of indices and then the indices
359
+ key.fileobj.send(b'p'
360
+ + (len(converged_queries) - next_to_tell[index]).to_bytes(4, 'big')
361
+ + converged_queries[next_to_tell[index]:].tobytes())
362
+ next_to_tell[index] = len(converged_queries)
363
+
364
+ # Continue without change
365
+ else:
366
+ key.fileobj.send(b'c')
326
367
 
327
368
  finished.clear()
328
369
 
umaudemc/quatex.py CHANGED
@@ -8,6 +8,19 @@ import os
8
8
  from . import usermsgs
9
9
 
10
10
 
11
+ class QuaTExQuery:
12
+ """QuaTeX query information"""
13
+
14
+ def __init__(self, fname, line, column, expr, parameters, delta):
15
+ self.filename = fname # file cointing the query
16
+ self.line = line # query location
17
+ self.column = column
18
+ self.parameters = parameters # parameters as (variable, start, step, end)
19
+
20
+ self.expr = expr # query expression (only needed for compilation)
21
+ self.delta = delta # delta value
22
+
23
+
11
24
  class QuaTExProgram:
12
25
  """Compiled QuaTEx program"""
13
26
 
@@ -23,7 +36,7 @@ class QuaTExProgram:
23
36
  self.nqueries = len(slots) - ndefs
24
37
 
25
38
  # Query information (file name, line, column, and parameters)
26
- self.query_locations = qinfo
39
+ self.queries = qinfo
27
40
 
28
41
 
29
42
  class QuaTExLexer:
@@ -171,6 +184,7 @@ class QuaTExLexer:
171
184
  self.ltype = self.LT_STRING
172
185
  self._capture_string()
173
186
 
187
+ # Names cannot start with a number
174
188
  elif c.isalpha() or c == '$':
175
189
  self.ltype = self.LT_NAME
176
190
  self._capture(self._is_name)
@@ -211,9 +225,9 @@ class QuaTExLexer:
211
225
  class QuaTExParser:
212
226
  """Parser for QuaTEx"""
213
227
 
214
- PS_IFC = 0 # condition
215
- PS_IFT = 1 # true branch
216
- PS_IFF = 2 # negative branch
228
+ PS_IF_COND = 0 # condition
229
+ PS_IF_THEN = 1 # true branch
230
+ PS_IF_ELSE = 2 # negative branch
217
231
  PS_PAREN = 3 # parenthesis
218
232
  PS_ARITH = 4 # completing an arithmetic expression
219
233
  PS_CALLARGS = 5 # call arguments
@@ -223,6 +237,7 @@ class QuaTExParser:
223
237
  BINOPS_PREC = (4, 4, 3, 3, 3, 11, 12, 7, 7, 6, 6, 6, 6)
224
238
  BINOPS_AST = (ast.Add, ast.Sub, ast.Mult, ast.Div, ast.Mod, ast.And, ast.Or, ast.Eq,
225
239
  ast.NotEq, ast.Lt, ast.LtE, ast.Gt, ast.GtE)
240
+ # Kind of binary operator (0 = BinOp, 1 = BoolOp, 2 = Compare)
226
241
  BINOPS_CMP = (0, ) * 5 + (1, ) * 2 + (2, ) * 6
227
242
  # Unary operator and its precedence (as in C)
228
243
  UNARY_OPS = ('!', )
@@ -238,13 +253,14 @@ class QuaTExParser:
238
253
  self.pending_lexers = []
239
254
  self.seen_files = set() if filename.startswith('<') else {os.path.realpath(filename)}
240
255
 
241
- # Parameters of the current function
242
- self.fvars = []
243
- # Whether the variables that may occur
244
- # in an expression are known
256
+ # Whether the variables that may occur in an expression are known
245
257
  self.known_vars = True
258
+ # Parameters of the current function if self.known_vars
259
+ # or variables found in the last processed expression otherwise
260
+ self.fvars = []
246
261
  # Constants defined outside
247
262
  self.constants = {} if constants is None else constants
263
+ self.pending_constants = []
248
264
 
249
265
  # State stack for parsing expressions
250
266
  self.stack = []
@@ -253,7 +269,7 @@ class QuaTExParser:
253
269
  # Whether parsing errors have been encountered
254
270
  self.ok = True
255
271
 
256
- # Compilation slot indices for each number
272
+ # Compilation slot indices for each element
257
273
  self.fslots = {}
258
274
  self.calls = []
259
275
  self.observations = []
@@ -284,13 +300,29 @@ class QuaTExParser:
284
300
 
285
301
  return True
286
302
 
303
+ def _expect_any(self, *options):
304
+ """Check whether any of the given strings is the next token"""
305
+
306
+ atoken = self.lexer.get_token()
307
+ options_str = ', '.join(f'"{opt}"' for opt in options)
308
+
309
+ if atoken is None:
310
+ self._eprint(f'unexpected end of file where any of {options_str} is required.')
311
+ return None
312
+
313
+ if atoken not in options:
314
+ self._eprint(f'unexpected token "{atoken}" where any of {options_str} is required.')
315
+ return None
316
+
317
+ return atoken
318
+
287
319
  def _in_state(self, state):
288
320
  """Check whether the parser is in the given state"""
289
321
 
290
322
  return self.stack and self.stack[-1] == state
291
323
 
292
324
  def _next_token(self):
293
- """Get the next token without potential file exhaustion"""
325
+ """Get the next token dealing with file exhaustion"""
294
326
 
295
327
  token = self.lexer.get_token()
296
328
 
@@ -332,40 +364,89 @@ class QuaTExParser:
332
364
  self._eprint(f'unexpected token "{var_name}" where a variable name is required.')
333
365
  return (None, ) * 3
334
366
 
367
+ if not self._expect(','):
368
+ return (None, ) * 3
369
+
335
370
  # Parameter specification (name, initial value, step, last value)
336
371
  spec = [var_name]
337
372
 
338
- # Parse the initial value, step, and last value
339
- for _ in range(3):
340
- if not self._expect(','):
341
- return (None, ) * 3
373
+ # Make a copy of the expression variables, since we continue in
374
+ # known variables mode with no variables allowed
375
+ expr_vars = tuple(self.fvars)
376
+ self.fvars.clear()
342
377
 
343
- token = self.lexer.get_token()
378
+ # Parse the initial value, step, and last value
379
+ for k in range(3):
380
+ expr = self._parse_constexpr(',' if k < 2 else ')')
344
381
 
345
- if self.lexer.ltype != self.lexer.LT_NUMBER:
346
- self._eprint(f'unexpected token "{token}" where a number is required.')
382
+ if expr is None:
347
383
  return (None, ) * 3
348
384
 
349
- spec.append(float(token))
350
-
351
- # Check the closing parenthesis
352
- if not self._expect(')'):
353
- return (None, ) * 3
385
+ spec.append(float(expr))
354
386
 
355
387
  # Check whether the variables in the expressions are the parameter
356
- for var, line, column in self.fvars:
388
+ for var, line, column in expr_vars:
357
389
  if var != var_name:
358
390
  self._eprint(f'unknown variable "{var}".', line=line, column=column)
359
391
  self.ok = False
360
392
 
393
+ return tuple(spec)
394
+
395
+ def _parse_delta(self, parameter):
396
+ """Parse the delta annotation"""
397
+
398
+ if not self._expect('delta', '='):
399
+ return None
400
+
401
+ # Only the parameter can appear as free variable in the expression
361
402
  self.fvars.clear()
362
403
 
363
- return tuple(spec)
404
+ if parameter:
405
+ self.fvars.append(parameter[0])
406
+
407
+ if (delta := self._parse_expr(';', constexpr=True)) is None:
408
+ return None
364
409
 
365
- def _parse_expr(self, end_token, inside_def=False):
410
+ # Prepare delta as a function on the parameter (if any)
411
+ args = [ast.arg(parameter[0])] if parameter else []
412
+
413
+ delta_fn = ast.Expression(ast.Lambda(ast.arguments(args=args), delta))
414
+ ast.fix_missing_locations(delta_fn)
415
+
416
+ return eval(compile(delta_fn, '<delta>', 'eval'), {}, {})
417
+
418
+ def _parse_constexpr(self, end_token):
419
+ """Parse a constant expression as a number"""
420
+
421
+ # Soft errors, like unbound variables, are hard here because
422
+ # we run the code to obtain a value for the expression
423
+ old_ok, self.ok = self.ok, True
424
+
425
+ if (expr := self._parse_expr(end_token, constexpr=True)) is None:
426
+ return None
427
+
428
+ elif not self.ok:
429
+ return 0.0 # dummy value to keep running
430
+
431
+ self.ok = old_ok
432
+
433
+ # Evaluate the expression
434
+ expr = ast.Expression(expr)
435
+ ast.fix_missing_locations(expr)
436
+
437
+ result = eval(compile(expr, self.lexer.filename, 'eval'), {}, {})
438
+
439
+ # Check whether it is a number
440
+ if not isinstance(result, (int, float)):
441
+ self._eprint(f'"{result}" obtained while a number is expected.')
442
+ return None
443
+
444
+ return result
445
+
446
+ def _parse_expr(self, end_token, constexpr=False):
366
447
  """Parse an expression"""
367
448
 
368
- # Current expression
449
+ # Current expression (as a Python AST node)
369
450
  current = None
370
451
  # Number of nested conditions in the current position
371
452
  inside_cond = 0
@@ -400,13 +481,13 @@ class QuaTExParser:
400
481
  self._eprint('misplaced "if" keyword.')
401
482
  return None
402
483
 
403
- self.stack.append(self.PS_IFC)
484
+ self.stack.append(self.PS_IF_COND)
404
485
  arg_stack.append([])
405
486
  inside_cond += 1
406
487
 
407
488
  elif token == 'then':
408
- if current and self._in_state(self.PS_IFC):
409
- self.stack[-1] = self.PS_IFT
489
+ if current and self._in_state(self.PS_IF_COND):
490
+ self.stack[-1] = self.PS_IF_THEN
410
491
  arg_stack[-1].append(current)
411
492
  current = None
412
493
  inside_cond -= 1
@@ -415,8 +496,8 @@ class QuaTExParser:
415
496
  return None
416
497
 
417
498
  elif token == 'else':
418
- if current and self._in_state(self.PS_IFT):
419
- self.stack[-1] = self.PS_IFF
499
+ if current and self._in_state(self.PS_IF_THEN):
500
+ self.stack[-1] = self.PS_IF_ELSE
420
501
  arg_stack[-1].append(current)
421
502
  current = None
422
503
  else:
@@ -424,7 +505,7 @@ class QuaTExParser:
424
505
  return None
425
506
 
426
507
  elif token == 'fi':
427
- if current and self._in_state(self.PS_IFF):
508
+ if current and self._in_state(self.PS_IF_ELSE):
428
509
  arg_stack[-1].append(current)
429
510
  current = ast.IfExp(*arg_stack[-1])
430
511
  arg_stack.pop()
@@ -442,6 +523,10 @@ class QuaTExParser:
442
523
  self._eprint('the next operator # cannot be used in conditions or call arguments.')
443
524
  self.ok = False
444
525
 
526
+ elif constexpr:
527
+ self._eprint('the next operator # cannot be used in constant contexts.')
528
+ self.ok = False
529
+
445
530
  # A function call should follow
446
531
  token = self.lexer.get_token()
447
532
  line, column = self.lexer.sline, self.lexer.scolumn
@@ -455,7 +540,8 @@ class QuaTExParser:
455
540
  inside_next = True
456
541
  call_name, call_line, call_column = token, line, column
457
542
 
458
- elif token == 'discard' and not (inside_cond or call_name):
543
+ # discard is a soft keyword
544
+ elif token == 'discard' and not (inside_cond or call_name or constexpr):
459
545
  if current:
460
546
  self._eprint('misplaced discard keyword.')
461
547
  return None
@@ -491,10 +577,15 @@ class QuaTExParser:
491
577
  self._eprint(f'argument missing after a comma in a call to "{call_name}".')
492
578
  self.ok = False
493
579
 
580
+ # Call are internally represented as tuples that indicate whether
581
+ # the call is preceded by next (i.e. to be evaluated after a step)
582
+ # and which code slot contains the callee definition
494
583
  slot = self.fslots.setdefault(call_name, len(self.fslots))
495
584
  current = ast.Tuple([ast.Constant(inside_next), ast.Constant(slot), *args], ast.Load())
496
- current.custom_loc = (call_line, call_column)
497
- self.calls.append((call_name, call_line, call_column, len(args)))
585
+ # We keep the source location for error reporting (using the attributes
586
+ # lineno and col_offset of the AST node only here is not possible)
587
+ current.custom_loc = dict(fname=self.lexer.filename, line=call_line, column=call_column)
588
+ self.calls.append((call_name, self.lexer.filename, call_line, call_column, len(args)))
498
589
  inside_next = False
499
590
  call_name = None
500
591
 
@@ -549,6 +640,10 @@ class QuaTExParser:
549
640
  self._eprint(f'"{token}" is called in a condition or call argument, but this is not allowed.')
550
641
  return None
551
642
 
643
+ if constexpr:
644
+ self._eprint(f'"{token}" is called in a constant context, but calls are not allowed.')
645
+ return None
646
+
552
647
  self.stack.append(self.PS_CALLARGS)
553
648
  arg_stack.append([])
554
649
  call_name = token
@@ -558,17 +653,16 @@ class QuaTExParser:
558
653
  else:
559
654
  current = ast.Name(token, ast.Load())
560
655
 
561
- if not self.known_vars:
656
+ # Constants (from the command line) start with $, but variables can also
657
+ if token.startswith('$') and (value := self.constants.get(token[1:])) is not None:
658
+ current = ast.Constant(value)
659
+
660
+ elif not self.known_vars:
562
661
  self.fvars.append((token, line, column))
563
662
 
564
663
  elif token not in self.fvars:
565
- # The variable is an externally-defined constant
566
- if token.startswith('$') and (value := self.constants.get(token[1:])) is not None:
567
- current = ast.Constant(value)
568
-
569
- else:
570
- self._eprint(f'unknown variable "{token}".', line=line, column=column)
571
- self.ok = False
664
+ self._eprint(f'unknown variable "{token}".', line=line, column=column)
665
+ self.ok = False
572
666
 
573
667
 
574
668
  # We continue with the peeked token
@@ -694,22 +788,29 @@ class QuaTExParser:
694
788
  if parameter:
695
789
  self.known_vars = False
696
790
 
697
- expr = self._parse_expr(']')
791
+ if not (expr := self._parse_expr(']')):
792
+ return False
698
793
 
699
794
  self.known_vars = True
700
795
 
701
796
  # Parse parameter specification in parametric queries
702
797
  parameter = self._parse_parameter() if parameter else None
798
+ delta = None
703
799
 
704
- if not expr or not self._expect(';'):
800
+ # Check whether there is a "with delta = <number>" prefix
801
+ if (token := self._expect_any('with', ';')) is None:
705
802
  return False
706
803
 
804
+ if token == 'with':
805
+ if (delta := self._parse_delta(parameter)) is None:
806
+ return False
807
+
707
808
  # Ignore parameterized expressions with empty range
708
809
  if parameter and parameter[1] > parameter[3]:
709
810
  usermsgs.print_warning_loc(self.lexer.filename, line, column,
710
811
  'ignoring parametric query with empty range.')
711
812
  else:
712
- self.queries.append((self.lexer.filename, line, column, expr, parameter))
813
+ self.queries.append(QuaTExQuery(self.lexer.filename, line, column, expr, parameter, delta))
713
814
 
714
815
  # Function definition -- <name> ( <args> ) = <expr> ;
715
816
  else:
@@ -744,7 +845,7 @@ class QuaTExParser:
744
845
  if not self._expect('='):
745
846
  return False
746
847
 
747
- expr = self._parse_expr(';', inside_def=True)
848
+ expr = self._parse_expr(';')
748
849
 
749
850
  if not expr:
750
851
  return False
@@ -763,8 +864,7 @@ class QuaTExParser:
763
864
  expr, tail_pos = pending.pop()
764
865
 
765
866
  if isinstance(expr, ast.Tuple) and not tail_pos:
766
- self._eprint('non-tail calls are not allowed.',
767
- line=expr.custom_loc[0], column=expr.custom_loc[1])
867
+ self._eprint('non-tail calls are not allowed.', **expr.custom_loc)
768
868
  return False
769
869
 
770
870
  elif isinstance(expr, ast.UnaryOp):
@@ -799,17 +899,17 @@ class QuaTExParser:
799
899
  arities[name] = len(args)
800
900
 
801
901
  # Check whether all calls are well-defined
802
- for name, line, column, arity in self.calls:
902
+ for name, fname, line, column, arity in self.calls:
803
903
  def_arity = arities.get(name)
804
904
 
805
905
  if def_arity is None:
806
906
  self._eprint(f'call to undefined function "{name}".',
807
- line=line, column=column)
907
+ line=line, column=column, fname=fname)
808
908
  ok = False
809
909
 
810
910
  elif arity != def_arity:
811
911
  self._eprint(f'wrong number of arguments in a call to "{name}" ({arity} given, but {def_arity} expected).',
812
- line=line, column=column)
912
+ line=line, column=column, fname=fname)
813
913
  ok = False
814
914
 
815
915
  # Check all calls are tail in expression
@@ -817,8 +917,8 @@ class QuaTExParser:
817
917
  if not self._check_tail(expr):
818
918
  ok = False
819
919
 
820
- for _, _, _, expr, _ in self.queries:
821
- if not self._check_tail(expr):
920
+ for query in self.queries:
921
+ if not self._check_tail(query.expr):
822
922
  ok = False
823
923
 
824
924
  if not ok:
@@ -848,22 +948,24 @@ class QuaTExParser:
848
948
  line=line, column=column)
849
949
  ok = False
850
950
 
851
- for k, (fname, line, column, expr, _) in enumerate(self.queries):
951
+ for k, query in enumerate(self.queries):
852
952
  try:
853
- expr = ast.Expression(expr)
953
+ expr = ast.Expression(query.expr)
854
954
  ast.fix_missing_locations(expr)
855
- slots[used_defs + k] = compile(expr, filename=f'query{fname}:{line}:{column}', mode='eval')
955
+ slots[used_defs + k] = compile(expr, filename=f'query{query.filename}:{query.line}:{query.column}', mode='eval')
856
956
 
857
957
  except TypeError:
858
958
  self._eprint('this query cannot cannot be compiled.',
859
- line=line, column=column, fname=fname)
959
+ line=query.line, column=query.column, fname=query.fname)
860
960
  ok = False
861
961
 
962
+ # No longer needed
963
+ query.expr = None
964
+
862
965
  if not ok:
863
966
  return None
864
967
 
865
- return QuaTExProgram(slots, varnames, len(self.fslots),
866
- tuple((fname, line, column, params) for fname, line, column, _, params in self.queries))
968
+ return QuaTExProgram(slots, varnames, len(self.fslots), self.queries)
867
969
 
868
970
 
869
971
  def parse_quatex(input_file, filename='<string>', legacy=False, constants=None):
umaudemc/simulators.py CHANGED
@@ -109,6 +109,42 @@ class StrategyStepSimulator(BaseSimulator):
109
109
  self.step += 1
110
110
 
111
111
 
112
+ class RuleStepSimulator(BaseSimulator):
113
+ """Simulator where rule application (potentially using random) is the step"""
114
+
115
+ def __init__(self, initial):
116
+ super().__init__(initial)
117
+
118
+ # See the PMaude simulator to an explanation for why we need that
119
+ self.random = None
120
+
121
+ if nat_kind := self.module.findSort('Nat').kind():
122
+ if random := self.module.findSymbol('random', (nat_kind,), nat_kind):
123
+ self.random = random(self.module.parseTerm('1', nat_kind))
124
+
125
+ def restart(self):
126
+ """Restart simulator"""
127
+
128
+ super().restart()
129
+
130
+ # PMaude uses Maude's random symbol, which is memoryless
131
+ # and deterministic for a fixed seed, so we need a new seed
132
+ if self.random:
133
+ self.random.copy().reduce()
134
+ maude.setRandomSeed(random.getrandbits(31))
135
+
136
+ def next_step(self):
137
+ """Perform a step of the simulation"""
138
+
139
+ # Application of any executable rule
140
+ # (assumming it is deterministic)
141
+ next_state, *_ = next(self.state.apply(None), (None,))
142
+
143
+ if next_state is not None:
144
+ self.state = next_state
145
+ self.step += 1
146
+
147
+
112
148
  def all_children(graph, state):
113
149
  """All children of a state in a graph"""
114
150
 
@@ -128,9 +164,9 @@ class UmaudemcSimulator(BaseSimulator):
128
164
  def __init__(self, initial, graph, assigner):
129
165
  super().__init__(initial)
130
166
 
131
- self.state_nr = 0
132
167
  self.graph = graph
133
168
  self.assigner = assigner
169
+ self.state_nr = 0
134
170
  self.time = 0.0
135
171
 
136
172
  def restart(self):
@@ -274,13 +310,12 @@ class PMaudeSimulator(BaseSimulator):
274
310
  self.nat_kind = nat_kind
275
311
  self.val = val
276
312
 
277
- # Prepares the initial term
278
- self.state = self.initial.copy()
279
- self.state.rewrite()
313
+ # restart must be called before running
314
+ self.state = None
280
315
 
281
316
  # Try to find Maude's random symbol for calculating random(1). If the
282
- # PMaude specification only reduces random(0), even after resetting
283
- # the random seed for a new simulation, it will take the same value
317
+ # PMaude specification only reduces random(0), even after resetting the
318
+ # random seed for a new simulation, it will take the same cached value
284
319
  self.random = self.module.findSymbol('random', (nat_kind,), nat_kind)
285
320
 
286
321
  if self.random:
@@ -473,12 +508,15 @@ def get_simulator(method, data):
473
508
  method = 'step' if data.strategy else 'uniform'
474
509
 
475
510
  # Check whether a strategy is provided for methods that require it
476
- if not data.strategy and method in ('step', 'strategy-fast', 'strategy'):
511
+ if not data.strategy and method in ('strategy-fast', 'strategy'):
477
512
  usermsgs.print_error(f'No strategy is provided for the {method} assignment method.')
478
513
  return None
479
514
 
480
515
  if method == 'step':
481
- return StrategyStepSimulator(data.term, data.strategy)
516
+ if data.strategy:
517
+ return StrategyStepSimulator(data.term, data.strategy)
518
+ else:
519
+ return RuleStepSimulator(data.term)
482
520
 
483
521
  if method == 'strategy-fast':
484
522
  return StrategyPathSimulator(data.module, data.term, data.strategy)
umaudemc/statistical.py CHANGED
@@ -120,17 +120,17 @@ class QueryData:
120
120
  self.discarded = 0
121
121
 
122
122
 
123
- def make_parameter_dicts(qinfo):
123
+ def make_parameter_dicts(qinfo, delta):
124
124
  """Make the initial variable mapping for the parameters of a query"""
125
125
 
126
- if qinfo is None:
127
- yield {}
126
+ if qinfo.parameters is None:
127
+ yield {}, (qinfo.delta() if qinfo.delta else delta)
128
128
 
129
129
  else:
130
- var, x, step, end = qinfo
130
+ var, x, step, end = qinfo.parameters
131
131
 
132
132
  while x <= end:
133
- yield {var: x}
133
+ yield {var: x}, (qinfo.delta(x) if qinfo.delta else delta)
134
134
  x += step
135
135
 
136
136
 
@@ -139,8 +139,10 @@ def check_interval(qdata, num_sims, min_sim, alpha, quantile, verbose):
139
139
 
140
140
  # Whether the size of the confidence interval for all queries have converged
141
141
  converged = True
142
+ # Which queries have converged
143
+ which = []
142
144
 
143
- for query in qdata:
145
+ for k, query in enumerate(qdata):
144
146
  # This query has already converged
145
147
  if query.converged:
146
148
  continue
@@ -148,18 +150,23 @@ def check_interval(qdata, num_sims, min_sim, alpha, quantile, verbose):
148
150
  elif query.n == 0:
149
151
  converged = False
150
152
  continue
153
+ # A single execution
154
+ elif query.n == 1:
155
+ query.mu, query.s, query.h = query.sum, 0.0, 0.0
156
+ # General case
157
+ else:
158
+ # The radius encloses the confidence level in the reference
159
+ # distribution for calculating confidence intervals
160
+ tinv = quantile(query.n - 1, 1 - alpha / 2) / math.sqrt(query.n)
151
161
 
152
- # The radius encloses the confidence level in the reference
153
- # distribution for calculating confidence intervals
154
- tinv = quantile(query.n - 1, 1 - alpha / 2) / math.sqrt(query.n)
155
-
156
- query.mu = query.sum / query.n
157
- query.s = math.sqrt(max(query.sum_sq - query.sum * query.mu, 0.0) / (query.n - 1))
158
- query.h = query.s * tinv
162
+ query.mu = query.sum / query.n
163
+ query.s = math.sqrt(max(query.sum_sq - query.sum * query.mu, 0.0) / (query.n - 1))
164
+ query.h = query.s * tinv
159
165
 
160
166
  if query.h <= query.delta and query.n >= min_sim:
161
167
  query.converged = True
162
168
  query.discarded = num_sims - query.n
169
+ which.append(k)
163
170
  else:
164
171
  converged = False
165
172
 
@@ -170,7 +177,10 @@ def check_interval(qdata, num_sims, min_sim, alpha, quantile, verbose):
170
177
  f' σ={" ".join(str(q.s) for q in qdata)}'
171
178
  f' r={" ".join(str(q.h) for q in qdata)}')
172
179
 
173
- return converged
180
+ for k in which:
181
+ print(f' Query {qdata[k].query + 1} has converged')
182
+
183
+ return converged, which
174
184
 
175
185
 
176
186
  def run_single(program, qdata, num_sims, min_sim, max_sim, simulator, alpha, block_size,
@@ -200,7 +210,7 @@ def run_single(program, qdata, num_sims, min_sim, max_sim, simulator, alpha, blo
200
210
  query.sum_sq += value * value
201
211
  query.n += 1
202
212
 
203
- converged = check_interval(qdata, num_sims, min_sim, alpha, quantile, verbose)
213
+ converged, _ = check_interval(qdata, num_sims, min_sim, alpha, quantile, verbose)
204
214
 
205
215
  if converged or max_sim and num_sims >= max_sim:
206
216
  break
@@ -211,7 +221,7 @@ def run_single(program, qdata, num_sims, min_sim, max_sim, simulator, alpha, blo
211
221
  return num_sims, qdata
212
222
 
213
223
 
214
- def thread_main(program, qdata, simulator, num_sims, block_size, seed, queue, barrier, more, dump=None):
224
+ def thread_main(program, qdata, simulator, num_sims, block_size, seed, queue, barrier, more, convergents, dump=None):
215
225
  """Entry point of a calculating thread"""
216
226
 
217
227
  maude.setRandomSeed(seed)
@@ -254,6 +264,12 @@ def thread_main(program, qdata, simulator, num_sims, block_size, seed, queue, ba
254
264
  if not more.value:
255
265
  break
256
266
 
267
+ # Disable queries that have converged
268
+ for k in range(len(qdata)):
269
+ if convergents[k] == -1:
270
+ break
271
+ qdata[convergents[k]].converged = True
272
+
257
273
  # Continue for a next block
258
274
  block = block_size
259
275
 
@@ -280,12 +296,14 @@ def run_parallel(program, qdata, num_sims, min_sim, max_sim, simulator, alpha, b
280
296
  queue = mp.Queue()
281
297
  barrier = mp.Barrier(jobs + 1)
282
298
  more = mp.Value('b', False, lock=False)
299
+ convergents = mp.Array('i', [-1] * len(qdata), lock=False)
283
300
 
284
301
  rest, rest_block = num_sims % jobs, block_size % jobs
285
302
  processes = [mp.Process(target=thread_main,
286
303
  args=(program, qdata, simulator, num_sims // jobs + (k < rest),
287
304
  block_size // jobs + (k < rest_block),
288
- seeds[k], queue, barrier, more, dumps[k])) for k in range(jobs)]
305
+ seeds[k], queue, barrier, more, convergents, dumps[k]))
306
+ for k in range(jobs)]
289
307
 
290
308
  # Start all processes
291
309
  for p in processes:
@@ -301,7 +319,7 @@ def run_parallel(program, qdata, num_sims, min_sim, max_sim, simulator, alpha, b
301
319
  query.sum_sq += sum_sq[k]
302
320
  query.n += counts[k]
303
321
 
304
- converged = check_interval(qdata, num_sims, min_sim, alpha, quantile, verbose)
322
+ converged, which = check_interval(qdata, num_sims, min_sim, alpha, quantile, verbose)
305
323
 
306
324
  if converged or max_sim and num_sims >= max_sim:
307
325
  break
@@ -309,6 +327,11 @@ def run_parallel(program, qdata, num_sims, min_sim, max_sim, simulator, alpha, b
309
327
  num_sims += block_size
310
328
 
311
329
  more.value = True
330
+ # Inform which queries have converged
331
+ for k, qindex in enumerate(which):
332
+ convergents[k] = qindex
333
+ convergents[len(which)] = -1
334
+
312
335
  barrier.wait()
313
336
 
314
337
  more.value = False
@@ -328,9 +351,9 @@ def qdata_to_dict(num_sims, qdata, program):
328
351
  qdata_it = iter(qdata)
329
352
  q = next(qdata_it, None)
330
353
 
331
- for k, (fname, line, column, params) in enumerate(program.query_locations):
354
+ for k, query in enumerate(program.queries):
332
355
  # For parametric queries, we return an array of values
333
- if params:
356
+ if query.parameters:
334
357
  mean, std, radius, count, discarded = [], [], [], [], []
335
358
 
336
359
  while q and q.query == k:
@@ -342,13 +365,15 @@ def qdata_to_dict(num_sims, qdata, program):
342
365
  q = next(qdata_it, None)
343
366
 
344
367
  # We also write information about the parameter
345
- param_info = {'params': [dict(name=params[0], start=params[1], step=params[2], stop=params[3])]}
368
+ param_info = {'params': [dict(zip(('name', 'start', 'step', 'stop'), query.parameters))]}
346
369
 
347
370
  else:
348
371
  mean, std, radius, count, discarded = q.mu, q.s, q.h, q.n, q.discarded
372
+ q = next(qdata_it, None)
349
373
  param_info = {}
350
374
 
351
- queries.append(dict(mean=mean, std=std, radius=radius, file=fname, line=line, column=column,
375
+ queries.append(dict(mean=mean, std=std, radius=radius,
376
+ file=query.filename, line=query.line, column=query.column,
352
377
  nsims=count, discarded=discarded, **param_info))
353
378
 
354
379
  return dict(nsims=num_sims, queries=queries)
@@ -370,9 +395,9 @@ def check(program, simulator, seed, alpha, delta, block, min_sim, max_sim, jobs,
370
395
 
371
396
  # Each query maintains some data like the sum of the outcomes
372
397
  # and the sum of their squares
373
- qdata = [QueryData(k, delta, idict)
374
- for k, qinfo in enumerate(program.query_locations)
375
- for idict in make_parameter_dicts(qinfo[3])]
398
+ qdata = [QueryData(k, dt, idict)
399
+ for k, qinfo in enumerate(program.queries)
400
+ for idict, dt in make_parameter_dicts(qinfo, delta)]
376
401
 
377
402
  # Run the simulations
378
403
  if jobs == 1 and num_sims != 1:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: umaudemc
3
- Version: 0.16.0
3
+ Version: 0.17.1
4
4
  Summary: Unified Maude model-checking utility
5
5
  Author-email: ningit <ningit@users.noreply.github.com>
6
6
  License-Expression: GPL-3.0-or-later
@@ -1,10 +1,10 @@
1
- umaudemc/__init__.py,sha256=ZkVXSbnNkhhpmMRr5ur6FqBcUYuqHyK0KUV5Je_XFn8,23
2
- umaudemc/__main__.py,sha256=zmgS0amNTdNZ4i2fhg860uLechFnBthvPNFBbFgUSJc,15039
1
+ umaudemc/__init__.py,sha256=ctD9pjqBvASXR0DHHzalDZFaQsnMJWDpTalYrvY3e_Y,23
2
+ umaudemc/__main__.py,sha256=LgKeZWi1JRrEclPS3asyZziErEJyldQvlZTtTt_tcyc,15063
3
3
  umaudemc/api.py,sha256=naZ5edEbvx-S-NU29yAAJtqglfYnSAYVS2RJNyxJMQQ,19893
4
4
  umaudemc/backends.py,sha256=mzJkALYwcKPInT0lBiRsCxJSewKvx5j_akQsqWN1Ezo,4590
5
5
  umaudemc/common.py,sha256=UcIf7hTpP2qjcT9u_9-UcYR0nNeosx1xRZW7wsuT2bE,7305
6
6
  umaudemc/counterprint.py,sha256=vVqM_UjGRk_xeftFxBGI5m6cQXV7mf8KvbQ_fvAvSQk,9226
7
- umaudemc/distributed.py,sha256=CljCg0VzLG7pDsDb_q1Lc95OVVjrU02cadkHRz9O8qY,9112
7
+ umaudemc/distributed.py,sha256=jUxVUHtPRefwxNYZmU0ErePre6FTmH5S8RX6ADBL1vA,10611
8
8
  umaudemc/formatter.py,sha256=nbQlIsR5Xv18OEcpJdnTDGqO9xGL_amvBGFMU2OmheU,6026
9
9
  umaudemc/formulae.py,sha256=jZPPDhjgsb7cs5rWvitiQoO0fd8JIlK98at2SN-LzVE,12156
10
10
  umaudemc/grapher.py,sha256=K1chKNNlEzQvfOsiFmRPJmd9OpxRIrg6OyiMW6gqOCU,4348
@@ -15,10 +15,10 @@ umaudemc/mproc.py,sha256=9X5pTb3Z3XHcdOo8ynH7I5RZQpjzm9xr4IBbEtaglUE,11766
15
15
  umaudemc/opsem.py,sha256=Xfdi9QGy-vcpmQ9ni8lBDAlKNw-fCRzYr6wnPbv6m1s,9448
16
16
  umaudemc/probabilistic.py,sha256=MNvFeEd84-OYedSnyksZB87UckPfwizVNJepCItgRy8,29306
17
17
  umaudemc/pyslang.py,sha256=ABSXYUQO2TmDq8EZ3EpVZV8NecZ0p0gERlSvLUIVAm8,87970
18
- umaudemc/quatex.py,sha256=Je5g16Tzb1t9NtHPSww0W6wUTIrHPdQCOJN-bTliOnQ,23888
18
+ umaudemc/quatex.py,sha256=7n7ugusjFUyO5jqKeb6-O8hdH4vj2fgnc-V_Z41w-40,27271
19
19
  umaudemc/resources.py,sha256=qKqvgLYTJVtsQHQMXFObyCLTo6-fssQeu_mG7tvVyD0,932
20
- umaudemc/simulators.py,sha256=ZGDpQjFj2Sv4GLq-NGVBMH78cFiG45KFPKfAfH1ds9w,13283
21
- umaudemc/statistical.py,sha256=FY3yXvv9NRiwYOQdwLDRf4WTXG1QupGJU8KwdHQhJyo,10534
20
+ umaudemc/simulators.py,sha256=1_DKaO2JYxSQHcbTE2wbB_lgd_kFBi40PPjOMvqOgaE,14341
21
+ umaudemc/statistical.py,sha256=I_C1ymz-ue-XaX0zF7lyQUce57-BdEWhctKg6yG2c_A,11371
22
22
  umaudemc/terminal.py,sha256=B4GWLyW4Sdymgoavj418y4TI4MnWqNu3JS4BBoSYeTc,1037
23
23
  umaudemc/usermsgs.py,sha256=h4VPxljyKidEI8vpPcToKJA6mcLu9PtMkIh6vH3rDuA,719
24
24
  umaudemc/webui.py,sha256=XlDV87tOOdcclHp2_oerrvHwRmCZdqAR4PppqeZm47A,11072
@@ -40,8 +40,8 @@ umaudemc/command/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,
40
40
  umaudemc/command/check.py,sha256=PyaPDMw5OnPxSIZ10U4we0b5tTrjnYKAtAeQkJh2uLE,12031
41
41
  umaudemc/command/graph.py,sha256=JqGzESC2sn-LBh2sqctrij03ItzwDO808s2qkNKUle0,6112
42
42
  umaudemc/command/pcheck.py,sha256=eV4e4GcOHanP4hcIhMKd5Js22_ONac6kYj70FXun3mY,7274
43
- umaudemc/command/scheck.py,sha256=wByVmANax4-Jw3S6MHbXevYDiVP81HIhMk1M7yZwuMs,6205
44
- umaudemc/command/sworker.py,sha256=rTfGbIRvXV7cVEmlTwkKrP9tfZN0ESNJKtVLnIVCOMs,4654
43
+ umaudemc/command/scheck.py,sha256=mmAzCdGdIHgKbsyEi0uW-jcj9X0MSc-EEkEPR-EiB8U,6208
44
+ umaudemc/command/sworker.py,sha256=uxw8gmacghUMvM0MUqM-AXX-w9xOA-10Mj-ExPWoNUI,5196
45
45
  umaudemc/command/test.py,sha256=Ru21JXNF61F5N5jayjwxp8okIjOAvuZuAlV_5ltQ-GU,37088
46
46
  umaudemc/data/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
47
47
  umaudemc/data/opsem.maude,sha256=geDP3_RMgtS1rRmYOybJDCXn_-dyHHxg0JxfYg1ftv0,27929
@@ -52,9 +52,9 @@ umaudemc/data/smcgraph.js,sha256=iCNQNmsuGdL_GLnqVhGDisediFtedxw3C24rxSiQwx8,667
52
52
  umaudemc/data/smcview.css,sha256=ExFqrMkSeaf8VxFrJXflyCsRW3FTwbv78q0Hoo2UVrM,3833
53
53
  umaudemc/data/smcview.js,sha256=_fHum1DRU1mhco-9-c6KqTLgiC5u_cCUf61jIK7wcIQ,14509
54
54
  umaudemc/data/templog.maude,sha256=TZ-66hVWoG6gp7gJpS6FsQn7dpBTLrr76bKo-UfHGcA,9161
55
- umaudemc-0.16.0.dist-info/licenses/LICENSE,sha256=MrEGL32oSWfnAZ0Bq4BZNcqnq3Mhp87Q4w6-deXfFnA,17992
56
- umaudemc-0.16.0.dist-info/METADATA,sha256=53-pCZwFfBsUPOyA_ocXL1TauD09WrY6NL574Sf1LbQ,1654
57
- umaudemc-0.16.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
58
- umaudemc-0.16.0.dist-info/entry_points.txt,sha256=8rYRlLkn4orZtAoujDSeol1t_UFBrK0bfjmLTNv9B44,52
59
- umaudemc-0.16.0.dist-info/top_level.txt,sha256=Yo_CF78HLGBSblk3890qLcx6XZ17zHCbGcT9iG8sfMw,9
60
- umaudemc-0.16.0.dist-info/RECORD,,
55
+ umaudemc-0.17.1.dist-info/licenses/LICENSE,sha256=MrEGL32oSWfnAZ0Bq4BZNcqnq3Mhp87Q4w6-deXfFnA,17992
56
+ umaudemc-0.17.1.dist-info/METADATA,sha256=bW8k2ctMQZPpFsXIHJ9wiBc2HoDwqw3VZHNyPgx75qk,1654
57
+ umaudemc-0.17.1.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
58
+ umaudemc-0.17.1.dist-info/entry_points.txt,sha256=8rYRlLkn4orZtAoujDSeol1t_UFBrK0bfjmLTNv9B44,52
59
+ umaudemc-0.17.1.dist-info/top_level.txt,sha256=Yo_CF78HLGBSblk3890qLcx6XZ17zHCbGcT9iG8sfMw,9
60
+ umaudemc-0.17.1.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (80.9.0)
2
+ Generator: setuptools (80.10.2)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5