smtconfig 1.0.2__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.
@@ -0,0 +1,11 @@
1
+ Copyright 2016 Nikolai Krivoshchapov
2
+
3
+ Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
4
+
5
+ 1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
6
+
7
+ 2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
8
+
9
+ 3. Neither the name of the copyright holder nor the names of its contributors may be used to endorse or promote products derived from this software without specific prior written permission.
10
+
11
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS “AS IS” AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,31 @@
1
+ Metadata-Version: 2.4
2
+ Name: smtconfig
3
+ Version: 1.0.2
4
+ Summary: Declarative SMT-based configuration system built on top of Z3
5
+ Author: Nikolai Krivoshchapov
6
+ License: BSD 3-Clause License
7
+ Requires-Python: >=3.9
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Requires-Dist: z3-solver
11
+ Requires-Dist: pydantic
12
+ Requires-Dist: networkx
13
+ Requires-Dist: pandas
14
+ Requires-Dist: rich
15
+ Requires-Dist: textual
16
+ Requires-Dist: icecream
17
+ Dynamic: license-file
18
+
19
+ ## SMTConfig
20
+
21
+ Configuration validation and completion using z3 solver.
22
+
23
+ ### Installation
24
+
25
+ ```
26
+ pip install smtconfig
27
+ ```
28
+
29
+ ### Example
30
+
31
+ [see here](https://gitlab.com/knvvv/smtconfig/-/blob/master/example.py)
@@ -0,0 +1,13 @@
1
+ ## SMTConfig
2
+
3
+ Configuration validation and completion using z3 solver.
4
+
5
+ ### Installation
6
+
7
+ ```
8
+ pip install smtconfig
9
+ ```
10
+
11
+ ### Example
12
+
13
+ [see here](https://gitlab.com/knvvv/smtconfig/-/blob/master/example.py)
@@ -0,0 +1,30 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "smtconfig"
7
+ version = "1.0.2"
8
+ description = "Declarative SMT-based configuration system built on top of Z3"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "BSD 3-Clause License" }
12
+ authors = [
13
+ { name = "Nikolai Krivoshchapov" }
14
+ ]
15
+
16
+ dependencies = [
17
+ "z3-solver",
18
+ "pydantic",
19
+ "networkx",
20
+ "pandas",
21
+ "rich",
22
+ "textual",
23
+ "icecream"
24
+ ]
25
+
26
+ [tool.setuptools]
27
+ packages = ["smtconfig"]
28
+
29
+ [tool.setuptools.package-dir]
30
+ "" = "."
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,2 @@
1
+ from .config import Config, ConfigInstance, SMTResult, FieldInst, FieldStatus
2
+ from .vis import ConfigViewer
@@ -0,0 +1,794 @@
1
+ from __future__ import annotations
2
+ from typing import Literal, Optional, Iterable, Mapping, Type, Any, Self
3
+ import pydantic as pc
4
+ import networkx as nx
5
+ import itertools
6
+ import z3
7
+ import enum
8
+ from icecream import ic
9
+ import pandas as pd
10
+ from .utils import free_vars, SimpleTypesZ3, OptTypesZ3, ArbTypesModel
11
+
12
+ DECLARED_SORTS = set()
13
+
14
+
15
+ class SimpleZ3Type(ArbTypesModel):
16
+ py_type: Type
17
+ z3_sort: z3.SortRef
18
+
19
+ @classmethod
20
+ def from_py(cls, input: Type) -> Self:
21
+ assert input in SimpleTypesZ3, f"'{input}' is not supported as simple type"
22
+ return cls(
23
+ py_type=input,
24
+ z3_sort=SimpleTypesZ3[input],
25
+ )
26
+
27
+ def get_constraint_value(self, input: Any):
28
+ if self.py_type is int:
29
+ return int(input)
30
+ elif self.py_type is float:
31
+ return float(input)
32
+ elif self.py_type is bool:
33
+ return bool(input)
34
+ elif self.py_type is str:
35
+ return z3.StringVal(input)
36
+ else:
37
+ raise Exception("Unknown type")
38
+
39
+ def from_z3(self, input: Any, m: z3.Model):
40
+ if self.py_type is int:
41
+ return int(str(input))
42
+ elif self.py_type is float:
43
+ return float(str(input))
44
+ elif self.py_type is bool:
45
+ return bool(z3.is_true(input))
46
+ elif self.py_type is str:
47
+ return input.as_string()
48
+ else:
49
+ raise Exception("Unknown type")
50
+
51
+ def pretty_str(self):
52
+ return str(self.z3_sort)
53
+
54
+
55
+ class EnumZ3Type(ArbTypesModel):
56
+ enum_type: Type | list[str]
57
+ z3_sort: z3.DatatypeSortRef
58
+ z2py: dict[z3.DatatypeRef, Any]
59
+ py2z: dict[Any, z3.DatatypeRef]
60
+ name: str
61
+
62
+ def get_constraint_value(self, input: Any):
63
+ assert input in self.py2z
64
+ return self.py2z[input]
65
+
66
+ def from_z3(self, input: Any, m: z3.Model):
67
+ assert input in self.z2py
68
+ return self.z2py[input]
69
+
70
+ def pretty_str(self):
71
+ return str(self.z3_sort)
72
+
73
+
74
+ class OptionalZ3Type(ArbTypesModel):
75
+ py_type: Type
76
+ z3_sort: z3.DatatypeSortRef
77
+
78
+ @classmethod
79
+ def from_py(cls, input: Type) -> Self:
80
+ assert input in OptTypesZ3, f"Optional types are not supported for '{input}'"
81
+ return cls(
82
+ py_type=input,
83
+ z3_sort=OptTypesZ3[input],
84
+ )
85
+
86
+ def get_constraint_value(self, input: Any):
87
+ if input is None:
88
+ return self.z3_sort.Nil
89
+ elif self.py_type is int:
90
+ val = int(input)
91
+ elif self.py_type is float:
92
+ val = float(input)
93
+ elif self.py_type is bool:
94
+ val = bool(input)
95
+ elif self.py_type is str:
96
+ val = z3.StringVal(input)
97
+ else:
98
+ raise Exception("Unknown type")
99
+ return self.z3_sort.Some(val)
100
+
101
+ def from_z3(self, input: Any, m: z3.Model):
102
+ if not m.eval(self.z3_sort.is_Some(input)):
103
+ return None
104
+
105
+ val = m.eval(self.z3_sort.val(input))
106
+ # ic(input, val, val.sort(), val.sort().name())
107
+ if self.py_type is int:
108
+ res = val.as_long()
109
+ elif self.py_type is float:
110
+ res = float(val)
111
+ elif self.py_type is bool:
112
+ res = bool(val)
113
+ elif self.py_type is str:
114
+ res = val.as_string()
115
+ else:
116
+ raise Exception("Unknown type")
117
+ return res
118
+
119
+ def pretty_str(self):
120
+ return str(self.z3_sort)
121
+
122
+
123
+ class Field(ArbTypesModel):
124
+ name: str
125
+ typ: SimpleZ3Type | EnumZ3Type | OptionalZ3Type
126
+ type_kind: Literal['simple', 'enum', 'optional']
127
+ z3var: z3.SeqRef | z3.BoolRef | z3.ArithRef | z3.DatatypeRef
128
+ default: Any
129
+ optional: bool
130
+ doc: Optional[str]
131
+ tracking_literal: z3.BoolRef
132
+ kind: Literal['field'] = 'field'
133
+ expr: Optional[z3.BoolRef] = None # Bad design, but should work for now...
134
+
135
+ def value_constraint(self, input: Any) -> z3.BoolRef:
136
+ value = self.typ.get_constraint_value(input)
137
+ return self.z3var == value
138
+
139
+ def from_z3(self, input: Any, m: z3.Model):
140
+ return self.typ.from_z3(input, m)
141
+
142
+
143
+ class Rule(ArbTypesModel):
144
+ expr: z3.BoolRef
145
+ name: str
146
+ tags: tuple[str, ...]
147
+ tracking_literal: z3.BoolRef
148
+ kind: Literal['rule'] = 'rule'
149
+
150
+
151
+ class Config:
152
+
153
+ def __init__(
154
+ self,
155
+ name: Optional[str] = None,
156
+ solver_options: Optional[dict] = None,
157
+ ):
158
+ self._fields: dict[str, Field] = {}
159
+ self._rules: dict[str, Rule] = {}
160
+ self._enum_sorts: dict[str, EnumZ3Type] = {}
161
+ self._rule_count = 0
162
+ self._cast: dict = {}
163
+
164
+ def is_nil(self, field_name: str) -> z3.BoolRef:
165
+ assert type(field_name) is str, (
166
+ "Make sure you are passing field name, not Z3 constant")
167
+ field = self._fields[field_name]
168
+ assert field.optional, (
169
+ f"Attempted check for nil state for non-optional field '{field_name}'"
170
+ )
171
+ return field.z3var == field.typ.z3_sort.Nil
172
+
173
+ def not_nil(self, field_name: str) -> z3.BoolRef:
174
+ assert type(field_name) is str, (
175
+ "Make sure you are passing field name, not Z3 constant")
176
+ field = self._fields[field_name]
177
+ assert field.optional, (
178
+ f"Attempted check for nil state for non-optional field '{field_name}'"
179
+ )
180
+ return field.z3var != field.typ.z3_sort.Nil
181
+
182
+ def get_some(self, field_name: str, strict: bool = False):
183
+ assert type(field_name) is str, (
184
+ "Make sure you are passing field name, not Z3 constant")
185
+ field = self._fields[field_name]
186
+ if strict and not field.optional:
187
+ assert field.optional, (
188
+ f"Attempted check for nil state for non-optional field '{field_name}'"
189
+ )
190
+ elif not field.optional:
191
+ return field.z3var
192
+ else:
193
+ return field.typ.z3_sort.val(field.z3var)
194
+
195
+ def equivalent(self, *clauses):
196
+ if len(clauses) < 2:
197
+ return []
198
+ return (a == b for i, a in enumerate(clauses)
199
+ for j, b in enumerate(clauses) if i < j)
200
+
201
+ def distinct(self, *clauses):
202
+ if len(clauses) < 2:
203
+ return []
204
+ return (a != b for i, a in enumerate(clauses)
205
+ for j, b in enumerate(clauses) if i < j)
206
+
207
+ def exclusive(self, *clauses):
208
+ if len(clauses) < 2:
209
+ return tuple()
210
+ return (z3.Implies(true_clause, z3.Not(false_clause))
211
+ for i, true_clause in enumerate(clauses)
212
+ for j, false_clause in enumerate(clauses) if i != j)
213
+
214
+ def _mk_field_type(
215
+ self,
216
+ typ: Type,
217
+ optional: bool,
218
+ ) -> SimpleZ3Type | OptionalZ3Type:
219
+ if optional:
220
+ return OptionalZ3Type.from_py(typ)
221
+ else:
222
+ return SimpleZ3Type.from_py(typ)
223
+
224
+ def _mk_z3_var(self, name: str, sort: z3.SortRef):
225
+ return z3.Const(name, sort)
226
+
227
+ def add_field(
228
+ self,
229
+ name: str,
230
+ typ: Type | list[str],
231
+ *,
232
+ default: Any = None,
233
+ optional: bool = False,
234
+ doc: Optional[str] = None,
235
+ ) -> Field:
236
+ if name in self._fields:
237
+ raise ValueError(f"Field '{name}' already exists")
238
+
239
+ if isinstance(typ, list) or issubclass(typ, enum.Enum):
240
+ return self._add_enum(
241
+ name=name,
242
+ enum_cls=typ,
243
+ default=default,
244
+ optional=optional,
245
+ doc=doc,
246
+ )
247
+ else:
248
+ return self._add_basic_field(
249
+ name=name,
250
+ typ=typ,
251
+ default=default,
252
+ optional=optional,
253
+ doc=doc,
254
+ )
255
+
256
+ def _add_basic_field(
257
+ self,
258
+ name: str,
259
+ typ: Type,
260
+ *,
261
+ default: Any = None,
262
+ optional: bool = False,
263
+ doc: Optional[str] = None,
264
+ ) -> Field:
265
+ type_obj = self._mk_field_type(typ, optional)
266
+ # ic(name, type_obj)
267
+ z3var = self._mk_z3_var(name, type_obj.z3_sort)
268
+ tracking_literal = z3.Bool(f"__var_track_{name}")
269
+ field = Field(name=name,
270
+ typ=type_obj,
271
+ type_kind='simple',
272
+ z3var=z3var,
273
+ default=default,
274
+ optional=optional,
275
+ doc=doc,
276
+ tracking_literal=tracking_literal)
277
+ self._fields[name] = field
278
+ return field
279
+
280
+ def _mk_enum_sort(
281
+ self,
282
+ enum_cls: Type | list[str],
283
+ sort_name: Optional[str] = None,
284
+ ) -> EnumZ3Type:
285
+ if isinstance(enum_cls, type) and issubclass(enum_cls, enum.Enum):
286
+ mode = 'enum'
287
+ elif isinstance(enum_cls, list) and all(
288
+ type(x) is str for x in enum_cls):
289
+ mode = 'list'
290
+ else:
291
+ raise TypeError(
292
+ f"Enum types created from either a subclass of enum.Enum or a list of string values. Got {enum_cls}"
293
+ )
294
+
295
+ enum_names: list[str]
296
+ if mode == 'enum':
297
+ enum_names = [m.name for m in enum_cls]
298
+ else:
299
+ enum_names = list(enum_cls)
300
+ assert len(enum_names) == len(set(enum_names)), (
301
+ f"Duplicates are found in the list of options: {enum_names}")
302
+
303
+ signature = self._get_enum_signature(enum_cls, enum_names, mode)
304
+ if signature in self._enum_sorts:
305
+ return self._enum_sorts[signature]
306
+
307
+ if sort_name is None and mode == 'enum':
308
+ sort_name = enum_cls.__name__
309
+ elif sort_name is None and mode == 'list':
310
+ i = 0
311
+ while True:
312
+ i += 1
313
+ sort_name = f"Enum{i}"
314
+ if all(sort.name != sort_name for sort in self._enum_sorts.
315
+ values()) and sort_name not in DECLARED_SORTS:
316
+ break
317
+
318
+ # ic(sort_name)
319
+ DECLARED_SORTS.add(sort_name)
320
+ sort, z3_vals = z3.EnumSort(f"{sort_name}_EnumSort", enum_names)
321
+
322
+ # Complicated updates for convenient casting
323
+ py2z = {m: z3_vals[i] for i, m in enumerate(enum_cls)}
324
+ z2py = {v: k for k, v in py2z.items()}
325
+ self._cast.update(py2z)
326
+ self._cast.update(z2py)
327
+ if mode == 'enum':
328
+ py2z.update({k.name: v for k, v in py2z.items()})
329
+ self._cast.update({
330
+ (enum_cls.__name__, k.name): v
331
+ for k, v in py2z.items()
332
+ })
333
+
334
+ enum_type = EnumZ3Type(
335
+ enum_type=enum_cls,
336
+ z3_sort=sort,
337
+ z2py=z2py,
338
+ py2z=py2z,
339
+ name=sort_name,
340
+ )
341
+ self._enum_sorts[signature] = enum_type
342
+ return enum_type
343
+
344
+ def _get_enum_signature(
345
+ self,
346
+ enum_cls: Type | list[str],
347
+ enum_names: list[str],
348
+ mode: Literal['enum', 'list'],
349
+ ) -> str:
350
+ if mode == 'enum':
351
+ sig = enum_cls.__name__
352
+ if sig in self._enum_sorts:
353
+ orig_sort = self._enum_sorts[sig]
354
+ assert sorted([x.name
355
+ for x in orig_sort.enum_type]) == enum_names
356
+ return sig
357
+ else:
358
+ return tuple(sorted(enum_names))
359
+
360
+ def _add_enum(
361
+ self,
362
+ name: str,
363
+ enum_cls: Type | list[str],
364
+ *,
365
+ default: Any = None,
366
+ sort_name: Optional[str] = None,
367
+ optional: bool = False,
368
+ doc: Optional[str] = None,
369
+ ) -> Field:
370
+ assert not optional, "Optional mode is not supported for enums. Include null case into your enum."
371
+ assert default is None or default in enum_cls, (
372
+ f"default value '{default}' must be a member of {enum_cls.__name__}"
373
+ )
374
+
375
+ ctx = z3.main_ctx()
376
+ enum_sort = self._mk_enum_sort(enum_cls, sort_name)
377
+ z3var = z3.Const(name, enum_sort.z3_sort)
378
+ tracking_literal = z3.Bool(f"__var_track_{name}")
379
+
380
+ field = Field(
381
+ name=name,
382
+ typ=enum_sort,
383
+ type_kind='enum',
384
+ z3var=z3var,
385
+ default=enum_sort.py2z[default] if default is not None else None,
386
+ optional=optional,
387
+ doc=doc,
388
+ tracking_literal=tracking_literal,
389
+ )
390
+ self._fields[name] = field
391
+ return field
392
+
393
+ def __getitem__(self, key: str) -> z3.ExprRef:
394
+ return self._fields[key].z3var
395
+
396
+ def cast(self, x):
397
+ if x in self._cast:
398
+ return self._cast[x]
399
+ else:
400
+ return x
401
+
402
+ def add_rules(
403
+ self,
404
+ exprs: Iterable[z3.BoolRef],
405
+ name: Optional[str] = None,
406
+ tags: Iterable[str] = (),
407
+ ) -> list[Rule]:
408
+ return [
409
+ self.add_rule(expr,
410
+ name=f"{name}-{i}" if name is not None else None)
411
+ for i, expr in enumerate(exprs)
412
+ ]
413
+
414
+ def add_rule(
415
+ self,
416
+ expr: z3.BoolRef,
417
+ *,
418
+ name: Optional[str] = None,
419
+ tags: Iterable[str] = (),
420
+ ) -> Rule:
421
+ assert z3.is_bool(expr), (
422
+ f"expr must be a boolean z3 expression; got sort {getattr(expr, 'sort', lambda: None)()}"
423
+ )
424
+
425
+ unique_name = name or f"rule_{self._rule_count}"
426
+ assert unique_name not in self._rules, f"Rule '{unique_name}' already exists"
427
+ self._rule_count += 1
428
+
429
+ tracking_literal = z3.Bool(f"__rule_track_{unique_name}")
430
+ tags_tuple = tuple(map(str, tags)) if tags is not None else tuple()
431
+ rule = Rule(
432
+ expr=expr,
433
+ name=unique_name,
434
+ tags=tags_tuple,
435
+ tracking_literal=tracking_literal,
436
+ )
437
+ self._rules[unique_name] = rule
438
+ return rule
439
+
440
+ def rules_on_fields(self, field_names: list[str]) -> list[Rule]:
441
+ field_names_set = {self._fields[name].z3var for name in field_names}
442
+ res = [
443
+ rule for rule in self._rules.values()
444
+ if len(free_vars(rule.expr).intersection(field_names_set)) > 0
445
+ ]
446
+ return res
447
+
448
+ def new(
449
+ self,
450
+ mapping: Mapping[str, Any],
451
+ *,
452
+ partial: bool = True,
453
+ ) -> "ConfigInstance":
454
+ # TODO: Find redundant input params
455
+ res = self._basic_new(
456
+ mapping=mapping,
457
+ partial=partial,
458
+ get_core=True,
459
+ )
460
+
461
+ if res.status.sat() and partial:
462
+ uncertain_fields = {}
463
+ for name, field in self._fields.items():
464
+ if name in mapping or field.default is not None:
465
+ continue
466
+
467
+ elim_res = self._basic_new(
468
+ mapping=mapping,
469
+ partial=partial,
470
+ extra_constraints=[
471
+ (z3.Not(field.value_constraint(res.solved[name])),
472
+ field.tracking_literal)
473
+ ],
474
+ get_core=False)
475
+ if elim_res.status.sat():
476
+ uncertain_fields[name] = elim_res
477
+ res.uncertain_fields = uncertain_fields
478
+ elif res.status.unsat():
479
+ assert res.conflicts is not None
480
+ do_next_step = True
481
+ while do_next_step:
482
+ tmp_res = self._basic_new(
483
+ mapping=mapping,
484
+ partial=partial,
485
+ drop_rules=[
486
+ rule_name for core in res.conflicts
487
+ for rule_name in core.rules.keys()
488
+ ],
489
+ get_core=True,
490
+ )
491
+
492
+ do_next_step = tmp_res.status.unsat()
493
+ if do_next_step:
494
+ assert tmp_res.conflicts is not None
495
+ res.conflicts += tmp_res.conflicts
496
+ return res
497
+
498
+ def _basic_new(
499
+ self,
500
+ mapping: Mapping[str, Any],
501
+ *,
502
+ extra_constraints: list[tuple[z3.ExprRef, z3.BoolRef]] = [],
503
+ drop_rules: list[str] = [],
504
+ partial: bool = True,
505
+ get_core: bool = True,
506
+ ) -> "ConfigInstance":
507
+ solver = z3.Solver()
508
+ provided = dict(mapping)
509
+
510
+ def add(expr, track):
511
+ if get_core:
512
+ solver.assert_and_track(expr, track)
513
+ else:
514
+ solver.assert_exprs(expr)
515
+
516
+ for name, field in self._fields.items():
517
+ if name in provided:
518
+ val = provided[name]
519
+ elif field.default is not None:
520
+ val = field.default
521
+ elif not partial and not field.optional:
522
+ raise Exception(f"Missing required field '{name}'")
523
+ else:
524
+ field.expr = None
525
+ continue
526
+ expr = field.value_constraint(val)
527
+ add(expr, field.tracking_literal)
528
+ field.expr = expr
529
+
530
+ for rule in self._rules.values():
531
+ if rule.name in drop_rules:
532
+ continue
533
+ add(rule.expr, rule.tracking_literal)
534
+
535
+ for constraint, lit in extra_constraints:
536
+ add(constraint, lit)
537
+
538
+ if get_core:
539
+ assert len(extra_constraints) == 0, (
540
+ "Cannot get unsat core with extra constraints yet")
541
+ tracking = {
542
+ x.tracking_literal: x
543
+ for x in itertools.chain(self._rules.values(),
544
+ self._fields.values())
545
+ # if x.name not in drop_rules
546
+ }
547
+
548
+ result = solver.check()
549
+ if result == z3.sat:
550
+ model = solver.model()
551
+ solved = {}
552
+ for name, field in self._fields.items():
553
+ new_val = model.eval(field.z3var, model_completion=True)
554
+ # ic(name, field, new_val)
555
+ solved[name] = field.from_z3(new_val, model)
556
+
557
+ return ConfigInstance(
558
+ config=self,
559
+ provided=provided,
560
+ solved=solved,
561
+ status=SMTResult.SAT,
562
+ model=model,
563
+ )
564
+ elif result == z3.unsat:
565
+ # TODO: Conflict resolution strats:
566
+ # constraint removal, max-smt, interactive
567
+ if get_core:
568
+ unsat_core = [tracking[c] for c in solver.unsat_core()]
569
+ disjoint_cores = self._get_disjoint_cores(unsat_core)
570
+ # NOTE: It seems that unsat_core always returns only one conn comp
571
+ assert len(disjoint_cores) == 1, (
572
+ "Expected one conn comp from unsat core")
573
+ conflicts = [
574
+ ConflictingConstraints(
575
+ values={c.name: c
576
+ for c in core if c.kind == 'field'},
577
+ rules={c.name: c
578
+ for c in core if c.kind == 'rule'},
579
+ ) for core in disjoint_cores
580
+ ]
581
+ else:
582
+ unsat_core = {}
583
+ conflicts = None
584
+
585
+ return ConfigInstance(config=self,
586
+ provided=provided,
587
+ status=SMTResult.UNSAT,
588
+ conflicts=conflicts)
589
+ else:
590
+ return ConfigInstance(config=self,
591
+ provided=provided,
592
+ status=SMTResult.UNKNOWN)
593
+
594
+ def _get_disjoint_cores(
595
+ self, unsat_core: list[Rule | Field]) -> list[list[Rule | Field]]:
596
+ gr = nx.Graph()
597
+
598
+ class NodeKind(enum.Enum):
599
+ EXPR = enum.auto()
600
+ SETVAR = enum.auto()
601
+ FREEVAR = enum.auto()
602
+
603
+ vartypes = {}
604
+ for x in unsat_core:
605
+ if x.kind == 'rule':
606
+ continue
607
+ vars = free_vars(x.expr)
608
+ assert len(vars) == 1
609
+ cur_var = next(iter(vars))
610
+ vartypes[x.name] = cur_var
611
+
612
+ for x in unsat_core:
613
+ if x.kind == 'field':
614
+ continue
615
+ ic(x.expr, free_vars(x.expr))
616
+ vars = free_vars(x.expr)
617
+ gr.add_node(x.name)
618
+ gr.nodes[x.name]['kind'] = NodeKind.EXPR
619
+ gr.add_nodes_from([(str(v), {
620
+ 'kind':
621
+ NodeKind.SETVAR if str(v) in vartypes else NodeKind.FREEVAR
622
+ }) for v in vars])
623
+ gr.add_edges_from([(x.name, str(i)) for i in vars])
624
+
625
+ # ic(list(gr.nodes(data=True)))
626
+ # ic(list(gr.edges))
627
+
628
+ subg = gr.subgraph([
629
+ n for n, data in gr.nodes(data=True)
630
+ if data['kind'] != NodeKind.SETVAR
631
+ ])
632
+
633
+ # ic(list(subg.nodes(data=True)))
634
+ # ic(list(subg.edges))
635
+
636
+ ind_cores = []
637
+ for comp in nx.connected_components(subg):
638
+ ind_gr_nodes = set(comp)
639
+ for rule_node in comp:
640
+ if gr.nodes[rule_node]['kind'] != NodeKind.EXPR:
641
+ continue
642
+ for nb in gr.neighbors(rule_node):
643
+ if gr.nodes[nb]['kind'] == NodeKind.SETVAR:
644
+ ind_gr_nodes.add(nb)
645
+ # ind_gr = gr.subgraph(ind_gr_nodes)
646
+ # ic(list(ind_gr.nodes(data=True)))
647
+ # ic(list(ind_gr.edges))
648
+ ind_cores.append([x for x in unsat_core if x.name in ind_gr_nodes])
649
+
650
+ return ind_cores
651
+
652
+
653
+ class ConflictingConstraints(ArbTypesModel):
654
+ values: dict[str, Field]
655
+ rules: dict[str, Rule]
656
+
657
+ def __repr__(self):
658
+ values_str = "\n".join([
659
+ f"{i}) {field.expr}"
660
+ for i, (name, field) in enumerate(self.values.items(), 1)
661
+ ])
662
+ rule_str = "\n".join([
663
+ f"{i}) {rule.expr}"
664
+ for i, (name, rule) in enumerate(self.rules.items(), 1)
665
+ ])
666
+ return f"""\
667
+ Field values:
668
+ {values_str}
669
+ are in conflict with rules:
670
+ {rule_str}
671
+ """
672
+
673
+
674
+ class SMTResult(enum.Enum):
675
+ SAT = enum.auto()
676
+ UNSAT = enum.auto()
677
+ UNKNOWN = enum.auto()
678
+
679
+ def __str__(self):
680
+ return str(self._name_)
681
+
682
+ def __repr__(self):
683
+ return str(self._name_)
684
+
685
+ def sat(self):
686
+ return self == SMTResult.SAT
687
+
688
+ def unsat(self):
689
+ return self == SMTResult.UNSAT
690
+
691
+
692
+ class FieldStatus(enum.Enum):
693
+ PROVIDED = enum.auto()
694
+ SOLVED = enum.auto()
695
+ CONFLICT = enum.auto()
696
+ UNCERTAIN = enum.auto()
697
+ UNSOLVED = enum.auto()
698
+
699
+ def __str__(self):
700
+ return str(self._name_).lower()
701
+
702
+
703
+ class FieldInst(ArbTypesModel):
704
+ status: FieldStatus
705
+ conflict_ids: Optional[list[int]] = None
706
+
707
+ @classmethod
708
+ def new(cls, status: FieldStatus, ids: Optional[list[int]] = None) -> Self:
709
+ return cls(
710
+ status=status,
711
+ conflict_ids=ids,
712
+ )
713
+
714
+ @pc.model_validator(mode='after')
715
+ def validate_conflict_ids(self) -> Self:
716
+ if self.status != FieldStatus.CONFLICT and self.conflict_ids is not None:
717
+ raise pc.ValidationError(
718
+ f"conflict_ids must be None when status is {self.status}, "
719
+ f"but got {self.conflict_ids}")
720
+
721
+ if self.status == FieldStatus.CONFLICT and (
722
+ self.conflict_ids is None or len(self.conflict_ids) == 0):
723
+ raise pc.ValidationError(
724
+ "conflict_ids must be provided when status is CONFLICT")
725
+
726
+ return self
727
+
728
+ def __str__(self):
729
+ if self.status == FieldStatus.CONFLICT:
730
+ assert self.conflict_ids is not None
731
+ return f"{self.status} ({','.join(str(x) for x in self.conflict_ids)})"
732
+ else:
733
+ return str(self.status)
734
+
735
+
736
+ class ConfigInstance(ArbTypesModel):
737
+ config: Config
738
+ provided: dict
739
+ status: SMTResult
740
+ solved: Optional[dict] = pc.Field(default=None)
741
+ model: Optional[z3.ModelRef] = pc.Field(default=None)
742
+ conflicts: Optional[list[ConflictingConstraints]] = pc.Field(default=None)
743
+ uncertain_fields: Optional[dict[str,
744
+ ConfigInstance]] = pc.Field(default=None)
745
+
746
+ def build_table_dataframe(self) -> pd.DataFrame:
747
+ rows = []
748
+
749
+ handled_fields = set()
750
+
751
+ def handle_field(
752
+ key: str,
753
+ value: str | int | bool | None,
754
+ status: FieldInst,
755
+ ) -> None:
756
+ if key not in self.config._fields:
757
+ return
758
+ type_str = self.config._fields[key].typ.pretty_str()
759
+ rows.append({
760
+ "Field": key,
761
+ "Value": str(value),
762
+ "Type": type_str,
763
+ "Status": status,
764
+ })
765
+ handled_fields.add(key)
766
+
767
+ if self.conflicts is not None:
768
+ for i, conflict in enumerate(self.conflicts, 1):
769
+ for key, field in conflict.values.items():
770
+ handle_field(key, self.provided[key],
771
+ FieldInst.new(FieldStatus.CONFLICT, [i]))
772
+
773
+ for key, v in self.provided.items():
774
+ if key in handled_fields:
775
+ continue
776
+ handle_field(key, v, FieldInst.new(FieldStatus.PROVIDED))
777
+
778
+ if self.uncertain_fields is not None:
779
+ assert self.solved is not None
780
+ for key in self.uncertain_fields:
781
+ handle_field(key, self.solved[key],
782
+ FieldInst.new(FieldStatus.UNCERTAIN))
783
+
784
+ if self.solved is not None:
785
+ for key, v in self.solved.items():
786
+ if key in handled_fields:
787
+ continue
788
+ handle_field(key, v, FieldInst.new(FieldStatus.SOLVED))
789
+
790
+ for key, field in self.config._fields.items():
791
+ if key in handled_fields:
792
+ continue
793
+ handle_field(key, "", FieldInst.new(FieldStatus.UNSOLVED))
794
+ return pd.DataFrame(rows)
@@ -0,0 +1,61 @@
1
+ from z3 import *
2
+ from pydantic import BaseModel
3
+ from pydantic.config import ConfigDict
4
+ from typing import Type, TypedDict, Never
5
+
6
+
7
+ class ArbTypesModel(BaseModel):
8
+ model_config: ConfigDict = ConfigDict(arbitrary_types_allowed=True)
9
+
10
+
11
+ def free_vars(expr):
12
+ s = set()
13
+ stack = [expr]
14
+ while stack:
15
+ e = stack.pop()
16
+ if is_const(e) and e.decl().kind() == Z3_OP_UNINTERPRETED:
17
+ s.add(e)
18
+ for c in e.children():
19
+ stack.append(c)
20
+ return s
21
+
22
+
23
+ class SimpleDatatypes(TypedDict):
24
+ int: ArithSortRef
25
+ float: FPSortRef
26
+ bool: BoolSortRef
27
+ str: SeqSortRef
28
+
29
+
30
+ def get_simple_datatypes() -> SimpleDatatypes:
31
+ return {
32
+ int: IntSort(),
33
+ float: Float64(),
34
+ bool: BoolSort(),
35
+ str: StringSort(),
36
+ }
37
+
38
+
39
+ class OptDatatypes(TypedDict):
40
+ int: DatatypeSortRef
41
+ float: DatatypeSortRef
42
+ bool: DatatypeSortRef
43
+ str: DatatypeSortRef
44
+
45
+
46
+ def get_optional_datatypes(simple_types: SimpleDatatypes) -> OptDatatypes:
47
+
48
+ def _build_opt_dt(raw_sort: z3.SortRef, typename: str) -> DatatypeSortRef:
49
+ NewType = Datatype('Opt' + typename)
50
+ NewType.declare('Nil')
51
+ NewType.declare('Some', ('val', raw_sort))
52
+ return NewType.create()
53
+
54
+ return {
55
+ py_type: _build_opt_dt(z3_raw_sort, py_type.__name__.capitalize())
56
+ for py_type, z3_raw_sort in simple_types.items()
57
+ }
58
+
59
+
60
+ SimpleTypesZ3 = get_simple_datatypes()
61
+ OptTypesZ3 = get_optional_datatypes(SimpleTypesZ3)
@@ -0,0 +1,182 @@
1
+ from pydantic import BaseModel
2
+ from pydantic.config import ConfigDict
3
+ import pandas as pd
4
+ from textual.coordinate import Coordinate
5
+ from textual.app import App, ComposeResult
6
+ from textual.widgets import Header, Footer, DataTable, Static, TabbedContent, TabPane, Label
7
+ from textual.containers import Vertical, VerticalScroll
8
+ from rich.text import Text
9
+ from rich.style import Style
10
+ from typing import Self, Optional
11
+
12
+ from smtconfig.utils import free_vars
13
+
14
+ from .config import ConfigInstance, FieldInst, FieldStatus, Rule, SMTResult
15
+ from icecream import ic
16
+
17
+ STATUS_COLORS = {
18
+ FieldStatus.PROVIDED: "cyan",
19
+ FieldStatus.SOLVED: "green",
20
+ FieldStatus.CONFLICT: "red",
21
+ FieldStatus.UNCERTAIN: "yellow",
22
+ FieldStatus.UNSOLVED: "magenta",
23
+ }
24
+
25
+ Label.link_style = Style(underline=True)
26
+
27
+
28
+ class ArbTypesModel(BaseModel):
29
+ model_config: ConfigDict = ConfigDict(arbitrary_types_allowed=True)
30
+
31
+
32
+ class ViewerDocument(ArbTypesModel):
33
+ name: str
34
+ config_inst: ConfigInstance
35
+ df: pd.DataFrame
36
+
37
+ @classmethod
38
+ def prep_document(cls, name: str, config_inst: ConfigInstance) -> Self:
39
+ df = config_inst.build_table_dataframe()
40
+ return cls(name=name, config_inst=config_inst, df=df)
41
+
42
+
43
+ class ConfigViewer(App):
44
+ CSS = """
45
+ #status {
46
+ padding: 1;
47
+ height: 3;
48
+ }
49
+ #bottom-pane {
50
+ dock: bottom;
51
+ height: 50%;
52
+ }
53
+ """
54
+
55
+ def __init__(self,
56
+ config_inst: ConfigInstance,
57
+ title: Optional[str] = None):
58
+ super().__init__()
59
+ self.title = title
60
+ self.sub_title = "ConfigViewer"
61
+ self.config_inst = config_inst
62
+ self.status_header = Static("kek", markup=True, id="status")
63
+ self.df = self.config_inst.build_table_dataframe()
64
+
65
+ def compose(self) -> ComposeResult:
66
+ yield Header()
67
+ yield Footer()
68
+ with Vertical():
69
+ yield self.status_header
70
+ with VerticalScroll():
71
+ yield DataTable(id="fields",
72
+ cursor_type="row") # self.field_table
73
+ with TabbedContent(id="bottom-pane"):
74
+ with TabPane("Rules", id="rules-pane"):
75
+ yield Label("Rules", markup=True, id="rules")
76
+ with TabPane("Versions", id="versions-pane"):
77
+ yield Static("Versions", markup=True, id="versions")
78
+ with TabPane("Logs", id="logs-pane"):
79
+ yield Static("Logs", markup=True, id="logs")
80
+
81
+ def _status_markup(self):
82
+ s = self.config_inst.status
83
+ if s.sat():
84
+ return f"Status: [green]{s}[/green]"
85
+ else:
86
+ return f"Status: [red]{s}[/red]"
87
+
88
+ def _fmt_field_status(self, st: FieldInst):
89
+ c = STATUS_COLORS.get(st.status, "white")
90
+ return f"[{c}]{str(st)}[/{c}]"
91
+
92
+ def _find_field_row(self, field_name: str):
93
+ data = self.df[self.df["Field"] == field_name]
94
+ if len(data) == 0:
95
+ return None
96
+ assert len(data) == 1
97
+ return next(iter(data.itertuples(index=False)))
98
+
99
+ def on_mount(self):
100
+ field_table = self.query_one(DataTable)
101
+ field_table.add_columns("Field", "Value", "Type", "Status")
102
+ for row in self.df.itertuples():
103
+ field_table.add_row(row.Field, str(row.Value), row.Type,
104
+ self._fmt_field_status(row.Status))
105
+ self.status_header.content = self._status_markup()
106
+
107
+ def _format_rule_expr(self, rule: Rule) -> Text:
108
+ rule_expr = str(rule.expr)
109
+ fields = [str(var) for var in free_vars(rule.expr)]
110
+ out = Text()
111
+ i = 0
112
+ n = len(rule_expr)
113
+
114
+ fields_sorted = sorted(fields, key=len, reverse=True)
115
+
116
+ while i < n:
117
+ matched = False
118
+ for f in fields_sorted:
119
+ if rule_expr.startswith(f, i):
120
+ # raise Exception(
121
+ # STATUS_COLORS[self._find_field_row(f).Status.status])
122
+ if not self._find_field_row(f):
123
+ continue
124
+ seg = Text(
125
+ f,
126
+ STATUS_COLORS[self._find_field_row(f).Status.status])
127
+ seg = seg.on(click=f"app.highlight_field('{f}')")
128
+ out.append(seg)
129
+ i += len(f)
130
+ matched = True
131
+ break
132
+ if not matched:
133
+ out.append(rule_expr[i])
134
+ i += 1
135
+ return out
136
+
137
+ def action_highlight_field(self, field_name: str):
138
+ idx = self.df.index[self.df["Field"] == field_name].tolist()
139
+ assert len(idx) == 1
140
+ idx = idx[0]
141
+ field_table = self.query_one(DataTable)
142
+ field_table.cursor_coordinate = Coordinate(row=idx, column=0)
143
+ # field_table.
144
+
145
+ def _refresh_rules(self, new_field: int):
146
+ rules_info: Label = self.query_one("#rules", Label)
147
+ # self.app.highligh_field("EEEEEEEe")
148
+ field_name = self.df.loc[new_field]["Field"]
149
+ rules = self.config_inst.config.rules_on_fields([field_name])
150
+
151
+ def _get_rule_expr(rule: Rule):
152
+ parts = [Text(rule.name), self._format_rule_expr(rule)]
153
+ res = Text(": ").join(parts)
154
+ return res
155
+
156
+ if len(rules) > 0:
157
+ rule_exprs = [_get_rule_expr(rule) for rule in rules]
158
+ rules_info.content = (
159
+ Text(f"'{field_name}' is involved in rules:\n", ) +
160
+ Text('\n').join(
161
+ Text(f'{i}) ') + expr
162
+ for i, expr in enumerate(rule_exprs, start=1)))
163
+ else:
164
+ rules_info.content = f"'{field_name}' is not involved in any rules"
165
+
166
+ def on_key(self, event):
167
+ field_table = self.query_one(DataTable)
168
+ if event.key in ("q", ):
169
+ self.exit()
170
+ elif event.key == "j":
171
+ field_table.action_cursor_down()
172
+ elif event.key == "k":
173
+ field_table.action_cursor_up()
174
+
175
+ def on_data_table_row_highlighted(self,
176
+ event: DataTable.RowHighlighted) -> None:
177
+ match event.data_table.id:
178
+ case "fields":
179
+ self._refresh_rules(event.cursor_row)
180
+ case _:
181
+ raise Exception(
182
+ f"Unexpected datatable '{event.data_table.id}'")
@@ -0,0 +1,31 @@
1
+ Metadata-Version: 2.4
2
+ Name: smtconfig
3
+ Version: 1.0.2
4
+ Summary: Declarative SMT-based configuration system built on top of Z3
5
+ Author: Nikolai Krivoshchapov
6
+ License: BSD 3-Clause License
7
+ Requires-Python: >=3.9
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Requires-Dist: z3-solver
11
+ Requires-Dist: pydantic
12
+ Requires-Dist: networkx
13
+ Requires-Dist: pandas
14
+ Requires-Dist: rich
15
+ Requires-Dist: textual
16
+ Requires-Dist: icecream
17
+ Dynamic: license-file
18
+
19
+ ## SMTConfig
20
+
21
+ Configuration validation and completion using z3 solver.
22
+
23
+ ### Installation
24
+
25
+ ```
26
+ pip install smtconfig
27
+ ```
28
+
29
+ ### Example
30
+
31
+ [see here](https://gitlab.com/knvvv/smtconfig/-/blob/master/example.py)
@@ -0,0 +1,16 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ ./smtconfig/__init__.py
5
+ ./smtconfig/config.py
6
+ ./smtconfig/utils.py
7
+ ./smtconfig/vis.py
8
+ smtconfig/__init__.py
9
+ smtconfig/config.py
10
+ smtconfig/utils.py
11
+ smtconfig/vis.py
12
+ smtconfig.egg-info/PKG-INFO
13
+ smtconfig.egg-info/SOURCES.txt
14
+ smtconfig.egg-info/dependency_links.txt
15
+ smtconfig.egg-info/requires.txt
16
+ smtconfig.egg-info/top_level.txt
@@ -0,0 +1,7 @@
1
+ z3-solver
2
+ pydantic
3
+ networkx
4
+ pandas
5
+ rich
6
+ textual
7
+ icecream
@@ -0,0 +1 @@
1
+ smtconfig