coom-core 0.0.1a1__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.
coom/check/__init__.py ADDED
@@ -0,0 +1,18 @@
1
+ """Static checks of a compiled model and detection of the profiles it uses.
2
+
3
+ The checks decide whether a model is valid regardless of any configuration. They cover the typing of formulas,
4
+ comparisons, assignments, table cells and test steps, values outside declared ranges, empty index ranges and
5
+ structures that contain themselves.
6
+ """
7
+
8
+ from coom.check._profiles import Profile, detect_profiles
9
+ from coom.check._structure import check_structure
10
+ from coom.check._typing import check_types
11
+ from coom.diagnostics import Diagnostic
12
+ from coom.model import Model
13
+
14
+ __all__ = ["Profile", "check", "detect_profiles"]
15
+
16
+
17
+ def check(model: Model) -> tuple[Diagnostic, ...]:
18
+ return (*check_structure(model), *check_types(model))
@@ -0,0 +1,147 @@
1
+ """Detection of the language profiles and modules a model uses."""
2
+
3
+ from enum import StrEnum
4
+
5
+ from coom.check._typing import AGGREGATES, formula_type, formulas
6
+ from coom.model import (
7
+ Behavior,
8
+ Binary,
9
+ BinaryOp,
10
+ Call,
11
+ Combinations,
12
+ Compare,
13
+ Default,
14
+ Feature,
15
+ FeatureStep,
16
+ Formula,
17
+ IsDefined,
18
+ Model,
19
+ Numerical,
20
+ Optimize,
21
+ PathAccess,
22
+ Require,
23
+ Visibility,
24
+ parts,
25
+ walk,
26
+ )
27
+
28
+
29
+ class Profile(StrEnum):
30
+ """A profile or module of the language, CORE being the one every model uses."""
31
+
32
+ CORE = "CORE"
33
+ N_OUT = "N-OUT"
34
+ N_LIN = "N-LIN"
35
+ N_FULL = "N-FULL"
36
+ C_FIX = "C-FIX"
37
+ C_USER = "C-USER"
38
+ C_OPEN = "C-OPEN"
39
+ D_BASE = "D-BASE"
40
+ D_COND = "D-COND"
41
+ OPTIMIZE = "O"
42
+ UI = "UI"
43
+ TEST = "TEST"
44
+
45
+
46
+ def detect_profiles(model: Model) -> tuple[Profile, ...]:
47
+ """Returns the smallest profile of each family that covers the model, in the order of the enumeration.
48
+
49
+ Numbers. N-OUT if no condition, constraint or table reads a number, so calculations stay outside the solution
50
+ space. Otherwise N-LIN if all arithmetic is linear, that is sums, differences, products and quotients with a
51
+ constant factor or divisor, and the aggregates. Otherwise N-FULL.
52
+
53
+ Cardinalities. C-FIX if every cardinality is fixed. C-OPEN if a cardinality is unbounded or a behavior aggregates
54
+ over or ranges across a feature whose number of instances varies, since then the reasoner decides that number.
55
+ Otherwise C-USER, where the user decides it.
56
+
57
+ Defaults. D-BASE if no default has a condition or an explicit priority, otherwise D-COND.
58
+ """
59
+ profiles = [Profile.CORE]
60
+ numbers = _numbers(model)
61
+ if numbers is not None:
62
+ profiles.append(numbers)
63
+ cardinalities = _cardinalities(model)
64
+ if cardinalities is not None:
65
+ profiles.append(cardinalities)
66
+ defaults = [behavior for behavior in model.behaviors if isinstance(behavior, Default)]
67
+ if defaults:
68
+ conditioned = any(default.conditions or default.priority is not None for default in defaults)
69
+ profiles.append(Profile.D_COND if conditioned else Profile.D_BASE)
70
+ if any(isinstance(behavior, Optimize) for behavior in model.behaviors):
71
+ profiles.append(Profile.OPTIMIZE)
72
+ if any(isinstance(behavior, Visibility) for behavior in model.behaviors):
73
+ profiles.append(Profile.UI)
74
+ if model.tests:
75
+ profiles.append(Profile.TEST)
76
+ return tuple(profiles)
77
+
78
+
79
+ def _numbers(model: Model) -> Profile | None:
80
+ declared = any(isinstance(feature.type, Numerical) for feature in model.features()) or any(
81
+ isinstance(attribute.type, Numerical)
82
+ for enumeration in model.enumerations
83
+ for attribute in enumeration.attributes
84
+ )
85
+ if not declared:
86
+ return None
87
+ if not any(_reads_number(behavior) for behavior in model.behaviors):
88
+ return Profile.N_OUT
89
+ linear = all(_linear(formula) for behavior in model.behaviors for formula in formulas(behavior))
90
+ return Profile.N_LIN if linear else Profile.N_FULL
91
+
92
+
93
+ def _reads_number(behavior: Behavior) -> bool:
94
+ """Whether a condition, constraint or table of the behavior depends on a numeric value."""
95
+ conditions = (*behavior.conditions, behavior.condition) if isinstance(behavior, Require) else behavior.conditions
96
+ tested: list[Formula] = list(behavior.columns) if isinstance(behavior, Combinations) else []
97
+ for node in walk(*conditions):
98
+ if isinstance(node, Compare):
99
+ tested += [node.left, node.right]
100
+ elif isinstance(node, IsDefined):
101
+ tested.append(node.formula)
102
+ return any(_is_number(formula) for formula in tested)
103
+
104
+
105
+ def _is_number(formula: Formula) -> bool:
106
+ typed = formula_type(formula)
107
+ return typed is not None and isinstance(typed.base, Numerical)
108
+
109
+
110
+ def _constant(formula: Formula) -> bool:
111
+ return not any(isinstance(node, PathAccess) for node in walk(formula))
112
+
113
+
114
+ def _linear(formula: Formula) -> bool:
115
+ match formula:
116
+ case Binary(op=BinaryOp.MUL):
117
+ return _constant(formula.left) or _constant(formula.right)
118
+ case Binary(op=BinaryOp.DIV):
119
+ return _constant(formula.right)
120
+ case Binary(op=BinaryOp.POW):
121
+ return _constant(formula)
122
+ case Call():
123
+ return formula.function in AGGREGATES or _constant(formula)
124
+ case _:
125
+ return True
126
+
127
+
128
+ def _cardinalities(model: Model) -> Profile | None:
129
+ varying = [feature for feature in model.features() if (feature.min, feature.max) != (1, 1)]
130
+ if not varying:
131
+ return None
132
+ if all(feature.min == feature.max for feature in varying):
133
+ return Profile.C_FIX
134
+ if any(feature.max is None for feature in varying):
135
+ return Profile.C_OPEN
136
+ ranged = {feature for feature in varying if feature.min != feature.max}
137
+ for behavior in model.behaviors:
138
+ for node in walk(*parts(behavior)):
139
+ if isinstance(node, Call) and node.function in AGGREGATES:
140
+ for argument in walk(*node.args):
141
+ if isinstance(argument, PathAccess) and _ranges_over(argument, ranged):
142
+ return Profile.C_OPEN
143
+ return Profile.C_USER
144
+
145
+
146
+ def _ranges_over(access: PathAccess, ranged: set[Feature]) -> bool:
147
+ return any(isinstance(step, FeatureStep) and step.feature in ranged and step.is_range for step in access.path.steps)
@@ -0,0 +1,35 @@
1
+ """Checks of the product hierarchy."""
2
+
3
+ from collections.abc import Iterator
4
+
5
+ from coom.diagnostics import Diagnostic, Severity
6
+ from coom.model import Feature, Model, Structure
7
+
8
+
9
+ def check_structure(model: Model) -> Iterator[Diagnostic]:
10
+ """Reports every structure that contains itself, which would require infinitely many instances to configure."""
11
+ reported: set[Structure] = set()
12
+ for structure in model.structures:
13
+ cycle = _cycle(structure, structure, [])
14
+ if cycle is None or structure in reported:
15
+ continue
16
+ reported.update(feature.owner for feature in cycle)
17
+ through = " > ".join(feature.qualified_name for feature in cycle)
18
+ yield Diagnostic(
19
+ Severity.ERROR, f"the structure {structure.name} contains itself through {through}", cycle[0].span
20
+ )
21
+
22
+
23
+ def _cycle(start: Structure, current: Structure, trail: list[Feature]) -> list[Feature] | None:
24
+ """Returns the features that lead from the current structure back to the start, depth first."""
25
+ for feature in current.features:
26
+ if not isinstance(feature.type, Structure) or feature in trail:
27
+ continue
28
+ if feature.type is start:
29
+ return [*trail, feature]
30
+ if any(step.owner is feature.type for step in trail):
31
+ continue
32
+ found = _cycle(start, feature.type, [*trail, feature])
33
+ if found is not None:
34
+ return found
35
+ return None
coom/check/_typing.py ADDED
@@ -0,0 +1,315 @@
1
+ """Typing of formulas and the type rules of conditions, assignments, tables and tests."""
2
+
3
+ from collections.abc import Iterator
4
+ from decimal import Decimal
5
+ from typing import NamedTuple, assert_never
6
+
7
+ from coom.diagnostics import Diagnostic, Severity
8
+ from coom.model import (
9
+ And,
10
+ Behavior,
11
+ Binary,
12
+ Boolean,
13
+ Call,
14
+ Combinations,
15
+ Compare,
16
+ CompareOp,
17
+ Condition,
18
+ Constant,
19
+ ConstantValue,
20
+ Default,
21
+ Enumeration,
22
+ FeatureStep,
23
+ Formula,
24
+ Function,
25
+ Imply,
26
+ IsAvailable,
27
+ IsBlocked,
28
+ IsDefined,
29
+ IsEmpty,
30
+ IsFixed,
31
+ IsHidden,
32
+ IsReadonly,
33
+ IsShown,
34
+ Model,
35
+ Negate,
36
+ Not,
37
+ Numerical,
38
+ Optimize,
39
+ Option,
40
+ Or,
41
+ Path,
42
+ PathAccess,
43
+ RemoveValue,
44
+ Require,
45
+ ResetValue,
46
+ SetValue,
47
+ Span,
48
+ Structure,
49
+ TestCase,
50
+ Textual,
51
+ Type,
52
+ Visibility,
53
+ parts,
54
+ type_name,
55
+ walk,
56
+ )
57
+
58
+ NUMBER = Numerical()
59
+ """The type of computed numbers, whose grid and bounds do not matter for typing."""
60
+
61
+ AGGREGATES = frozenset({Function.COUNT, Function.SUM, Function.MIN, Function.MAX})
62
+ EQUALITIES = frozenset({CompareOp.EQ, CompareOp.NE})
63
+
64
+
65
+ class Typed(NamedTuple):
66
+ """The type of a formula, a collection if the formula may evaluate to several values."""
67
+
68
+ base: Type
69
+ collection: bool = False
70
+
71
+
72
+ def describe(typed: Typed) -> str:
73
+ """Names the type for messages, such as "a number" or "a collection of options of Color"."""
74
+ match typed.base:
75
+ case Numerical():
76
+ single, plural = "a number", "numbers"
77
+ case Textual():
78
+ single, plural = "a text", "texts"
79
+ case Boolean():
80
+ single, plural = "a truth value", "truth values"
81
+ case Enumeration():
82
+ single, plural = f"an option of {typed.base.name}", f"options of {typed.base.name}"
83
+ case Structure():
84
+ name = "the product" if typed.base.is_root else typed.base.name
85
+ single, plural = f"an instance of {name}", f"instances of {name}"
86
+ case _:
87
+ assert_never(typed.base)
88
+ return f"a collection of {plural}" if typed.collection else single
89
+
90
+
91
+ def comparable(op: CompareOp, left: Type, right: Type) -> bool:
92
+ """Whether values of the two types can be compared by the operator, ignoring collections."""
93
+ match left, right:
94
+ case (Numerical(), Numerical()) | (Textual(), Textual()):
95
+ return True
96
+ case Enumeration(), Enumeration():
97
+ return left is right
98
+ case (Boolean(), Boolean()):
99
+ return op in EQUALITIES
100
+ case Structure(), Structure():
101
+ return left is right and op in EQUALITIES
102
+ case _:
103
+ return False
104
+
105
+
106
+ def constant_type(value: ConstantValue) -> Type:
107
+ match value:
108
+ case bool():
109
+ return Boolean()
110
+ case Decimal():
111
+ return NUMBER
112
+ case str():
113
+ return Textual()
114
+ case Option():
115
+ return value.owner
116
+ case _:
117
+ assert_never(value)
118
+
119
+
120
+ def in_range(numerical: Numerical, value: Decimal) -> bool:
121
+ return (numerical.min is None or value >= numerical.min) and (numerical.max is None or value <= numerical.max)
122
+
123
+
124
+ class TypeChecker:
125
+ """Infers the types of formulas and reports where the type rules of the language are violated."""
126
+
127
+ def __init__(self) -> None:
128
+ self.diagnostics: list[Diagnostic] = []
129
+
130
+ def error(self, span: Span | None, message: str) -> None:
131
+ self.diagnostics.append(Diagnostic(Severity.ERROR, message, span))
132
+
133
+ # Formulas
134
+
135
+ def formula(self, formula: Formula) -> Typed | None:
136
+ """Returns the type of a formula, or None if it has a type error that is already reported."""
137
+ match formula:
138
+ case Constant():
139
+ return Typed(constant_type(formula.value))
140
+ case PathAccess():
141
+ self.path(formula.path)
142
+ return Typed(formula.path.type, formula.path.is_range)
143
+ case Negate():
144
+ return Typed(NUMBER) if self.number(formula.operand) else None
145
+ case Binary():
146
+ left, right = self.number(formula.left), self.number(formula.right)
147
+ return Typed(NUMBER) if left and right else None
148
+ case Call():
149
+ return self._call(formula)
150
+ case _:
151
+ assert_never(formula)
152
+
153
+ def number(self, formula: Formula) -> bool:
154
+ """Checks that a formula is a single number, as arithmetic requires."""
155
+ typed = self.formula(formula)
156
+ if typed is None:
157
+ return False
158
+ if typed.collection:
159
+ self.error(formula.span, f"arithmetic needs a single number, not {describe(typed)}")
160
+ return False
161
+ if not isinstance(typed.base, Numerical):
162
+ self.error(formula.span, f"arithmetic needs a number, not {describe(typed)}")
163
+ return False
164
+ return True
165
+
166
+ def _call(self, call: Call) -> Typed | None:
167
+ if call.function not in AGGREGATES:
168
+ valid = [self.number(argument) for argument in call.args]
169
+ return Typed(NUMBER) if all(valid) else None
170
+ types = [self.formula(argument) for argument in call.args]
171
+ if any(typed is None for typed in types):
172
+ return None
173
+ bases = [typed.base for typed in types if typed is not None]
174
+ match call.function:
175
+ case Function.COUNT:
176
+ return Typed(NUMBER)
177
+ case Function.SUM:
178
+ if not all(isinstance(base, Numerical) for base in bases):
179
+ self.error(call.span, "sum needs numbers")
180
+ return None
181
+ return Typed(NUMBER)
182
+ case _:
183
+ first = bases[0]
184
+ if not all(comparable(CompareOp.LT, first, other) for other in bases):
185
+ self.error(call.span, f"{call.function} needs values that can be ordered against each other")
186
+ return None
187
+ return Typed(NUMBER if isinstance(first, Numerical) else first)
188
+
189
+ def path(self, path: Path) -> None:
190
+ for step in path.steps:
191
+ index = step.index if isinstance(step, FeatureStep) else None
192
+ if index is not None and (index.first >= 0) == (index.last >= 0) and index.first > index.last:
193
+ self.error(step.span, f"the index range [{index}] is empty")
194
+
195
+ # Conditions
196
+
197
+ def condition(self, condition: Condition) -> None:
198
+ match condition:
199
+ case Compare():
200
+ self.compare(condition.op, condition.left, condition.right, condition.span)
201
+ case IsDefined():
202
+ self.formula(condition.formula)
203
+ case Not():
204
+ self.condition(condition.operand)
205
+ case And() | Or():
206
+ for operand in condition.operands:
207
+ self.condition(operand)
208
+ case Constant():
209
+ pass
210
+ case _:
211
+ assert_never(condition)
212
+
213
+ def compare(self, op: CompareOp, left: Formula, right: Formula, span: Span | None) -> None:
214
+ left_type, right_type = self.formula(left), self.formula(right)
215
+ if left_type is None or right_type is None:
216
+ return
217
+ if left_type.collection and right_type.collection:
218
+ self.error(span, "cannot compare two collections with each other")
219
+ elif not comparable(op, left_type.base, right_type.base):
220
+ self.error(span, f"cannot compare {describe(left_type)} with {describe(right_type)} by {op}")
221
+
222
+ # Assignments
223
+
224
+ def assignment(self, target: Path, value: Formula, span: Span | None) -> None:
225
+ """Checks a value written to a feature, by imply, default or a test step."""
226
+ self.path(target)
227
+ if isinstance(target.type, Structure):
228
+ self.error(span, f"cannot assign a value to the structure feature {target}")
229
+ return
230
+ typed = self.formula(value)
231
+ if typed is None:
232
+ return
233
+ if typed.collection:
234
+ self.error(value.span, f"cannot assign {describe(typed)} to {target}")
235
+ elif not comparable(CompareOp.EQ, target.type, typed.base):
236
+ self.error(value.span, f"cannot assign {describe(typed)} to {target}, which holds {type_name(target.type)}")
237
+ elif (
238
+ isinstance(value, Constant)
239
+ and isinstance(value.value, Decimal)
240
+ and isinstance(target.type, Numerical)
241
+ and not in_range(target.type, value.value)
242
+ ):
243
+ self.error(value.span, f"{value.value} is outside the range {type_name(target.type)} of {target}")
244
+
245
+ # Model items
246
+
247
+ def behavior(self, behavior: Behavior) -> None:
248
+ for condition in behavior.conditions:
249
+ self.condition(condition)
250
+ match behavior:
251
+ case Require():
252
+ self.condition(behavior.condition)
253
+ case Imply() | Default():
254
+ self.assignment(behavior.target, behavior.value, behavior.span)
255
+ case Optimize():
256
+ self.path(behavior.target)
257
+ if not isinstance(behavior.target.type, Numerical):
258
+ self.error(behavior.span, f"{behavior.direction} needs a numeric feature, not {behavior.target}")
259
+ case Visibility():
260
+ self.path(behavior.target)
261
+ case Combinations():
262
+ for column in behavior.columns:
263
+ self.formula(column)
264
+ for row in behavior.rows:
265
+ for column, cell in zip(behavior.columns, row.cells, strict=True):
266
+ for atom in cell or ():
267
+ self.compare(atom.op, column, atom.value, atom.span)
268
+ case _:
269
+ assert_never(behavior)
270
+
271
+ def test(self, test: TestCase) -> None:
272
+ for step in test.steps:
273
+ match step:
274
+ case SetValue() | IsFixed() | IsShown() | IsAvailable() | IsBlocked() | IsHidden():
275
+ if step.value is None:
276
+ self.path(step.path)
277
+ else:
278
+ self.assignment(step.path, step.value, step.span)
279
+ case RemoveValue() | ResetValue() | IsEmpty() | IsReadonly():
280
+ self.path(step.path)
281
+ case _:
282
+ assert_never(step)
283
+
284
+ def options(self, enumeration: Enumeration) -> None:
285
+ for option in enumeration.options:
286
+ for attribute, value in option.values.items():
287
+ numerical = attribute.type
288
+ if isinstance(numerical, Numerical) and isinstance(value, Decimal) and not in_range(numerical, value):
289
+ self.error(
290
+ option.span,
291
+ f"{value} is outside the range {type_name(numerical)} of the attribute {attribute.name}",
292
+ )
293
+
294
+
295
+ def check_types(model: Model) -> Iterator[Diagnostic]:
296
+ checker = TypeChecker()
297
+ for enumeration in model.enumerations:
298
+ checker.options(enumeration)
299
+ for behavior in model.behaviors:
300
+ checker.behavior(behavior)
301
+ for test in model.tests:
302
+ checker.test(test)
303
+ yield from checker.diagnostics
304
+
305
+
306
+ def formula_type(formula: Formula) -> Typed | None:
307
+ """Returns the type of a formula without reporting, None if the formula has a type error."""
308
+ return TypeChecker().formula(formula)
309
+
310
+
311
+ def formulas(item: Behavior | TestCase) -> Iterator[Formula]:
312
+ """Yields every formula of a behavior or test, nested ones included."""
313
+ for node in walk(*parts(item)):
314
+ if isinstance(node, Constant | PathAccess | Negate | Binary | Call):
315
+ yield node
coom/check/py.typed ADDED
File without changes
@@ -0,0 +1,30 @@
1
+ """Configurations of a model and their evaluation.
2
+
3
+ A configuration is a tree of instances below the instance of the product. Every feature of an instance has numbered
4
+ slots from 0 up to the maximum cardinality, and each slot is empty or holds an assignment, a value together with the
5
+ role that says why it is there. A structure feature holds instances, which then belong to that slot. Numbers are
6
+ stored as decimals on the grid of their feature, rounded half away from zero when they are assigned. Options, truth
7
+ values and texts are stored as the Option, bool and str objects of the model.
8
+
9
+ The evaluate module decides the truth of conditions and behaviors in a configuration, and the arithmetic module
10
+ defines the exact calculation both rely on. The visibility module says which values a user interface hides or locks.
11
+ Their main entry points are re-exported here.
12
+ """
13
+
14
+ from coom.configuration._configuration import Assignment, Configuration, Instance, Role, Value
15
+ from coom.configuration.evaluate import Truth, Verdict, truth, verdicts
16
+ from coom.configuration.visibility import Visibilities, visibilities
17
+
18
+ __all__ = [
19
+ "Assignment",
20
+ "Configuration",
21
+ "Instance",
22
+ "Role",
23
+ "Truth",
24
+ "Value",
25
+ "Verdict",
26
+ "Visibilities",
27
+ "truth",
28
+ "verdicts",
29
+ "visibilities",
30
+ ]