mathspec 0.1.0__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.
@@ -0,0 +1,587 @@
1
+ # SPDX-FileCopyrightText: mathspec Contributors
2
+ #
3
+ # SPDX-License-Identifier: MIT
4
+
5
+ """Can two of a named expression's cases claim one coordinate? Decided without data.
6
+
7
+ Each ``when`` is read over cells: regions of one subject's value on which every
8
+ atom is constant. The cells of the pair's subjects are multiplied out, both
9
+ masks are evaluated on each, and a cell where both hold is a witness.
10
+ Independence between subjects over-approximates, so it can manufacture a
11
+ witness but never hide one. The rule itself is stated in
12
+ ``docs/reference/language/expressions.md``.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import datetime
18
+ import itertools
19
+ import math
20
+ from dataclasses import dataclass
21
+ from enum import Enum
22
+ from typing import TYPE_CHECKING, Literal, assert_never
23
+
24
+ from mathspec.program import (
25
+ And,
26
+ BooleanLiteral,
27
+ Constant,
28
+ CountComparison,
29
+ DimensionComparison,
30
+ DimensionPosition,
31
+ ExpressionComparison,
32
+ Mask,
33
+ Negate,
34
+ Not,
35
+ Or,
36
+ Parameter,
37
+ ParameterComparison,
38
+ ParameterDefined,
39
+ PulledBackPredicate,
40
+ RelationComparison,
41
+ RelationDefined,
42
+ RelationPairComparison,
43
+ TranslatedPredicate,
44
+ TypedPredicate,
45
+ VariableDefined,
46
+ )
47
+
48
+ if TYPE_CHECKING:
49
+ from collections.abc import Iterable, Iterator, Mapping
50
+
51
+ from mathspec.program import DeclaredDtype, Expression, Predicate, PredicateOperator
52
+
53
+ #: The most cells one pair may multiply out to; a pair past it is several expressions.
54
+ CELL_BUDGET = 8192
55
+
56
+ #: The dtypes an ordering is decided against. Everything else compares only
57
+ #: with == and !=, which need no order on the values.
58
+ _ORDERED_DTYPES: tuple[DeclaredDtype, ...] = ('float', 'int', 'datetime')
59
+
60
+
61
+ class Undecidable(Exception): # noqa: N818
62
+ """A pair this procedure will not reason about. Carries the rewrite."""
63
+
64
+
65
+ def overlapping(cases: Mapping[str, Predicate], dtypes: Mapping[str, DeclaredDtype]) -> Iterator[str]:
66
+ """One refusal per pair of cases that could both claim a coordinate.
67
+
68
+ Args:
69
+ cases: The ``when`` of every case, keyed by the case's name. The
70
+ block's ``otherwise`` is not among them: it claims what the rest
71
+ leave, so it overlaps nothing by construction.
72
+ dtypes: The declared dtype of every name a mask compares against.
73
+
74
+ Yields:
75
+ A sentence per pair, naming both cases and either a coordinate they
76
+ both claim or what stopped the pair being decided. Empty where every
77
+ pair is proved apart.
78
+ """
79
+ undecided = {name: reason for name, mask in cases.items() if (reason := _undecided(mask)) is not None}
80
+ for name, reason in undecided.items():
81
+ yield (
82
+ f"case '{name}' cannot be told apart before the data arrives: {reason}. "
83
+ f'The `otherwise` is its negation, and only the data says where that falls, so this is refused '
84
+ f'the way a proven overlap is.'
85
+ )
86
+ for (first, left), (second, right) in itertools.combinations(cases.items(), 2):
87
+ if first in undecided or second in undecided:
88
+ continue
89
+ try:
90
+ witness = _witness(left, right, dtypes)
91
+ except Undecidable as exc:
92
+ yield (
93
+ f"cases '{first}' and '{second}' cannot be told apart before the data arrives: {exc}. "
94
+ f'Two cases claiming one coordinate would give it two values, so this is refused '
95
+ f'the way a proven overlap is.'
96
+ )
97
+ continue
98
+ if witness is not None:
99
+ yield (
100
+ f"cases '{first}' and '{second}' both claim the value where {witness}. "
101
+ f'A coordinate two cases claim has two values, so it has none — narrow one of the '
102
+ f'two `when:` strings by the negation of the other, or drop the wider one and let '
103
+ f'`otherwise:` carry that region.'
104
+ )
105
+
106
+
107
+ def _witness(first: Predicate, second: Predicate, dtypes: Mapping[str, DeclaredDtype]) -> str | None:
108
+ """A coordinate both masks claim, rendered — ``None`` where no cell holds both."""
109
+ masks = (Mask(first), Mask(second))
110
+ grid = _Grid.of(masks, dtypes)
111
+ if grid.size > CELL_BUDGET:
112
+ msg = (
113
+ f'{grid.size} regions to check exceeds the budget of {CELL_BUDGET} — '
114
+ f'split this into fewer, wider cases, or into named expressions of its own'
115
+ )
116
+ raise Undecidable(msg)
117
+ for cell in grid.cells():
118
+ if all(_evaluate(mask.root, cell, grid) for mask in masks):
119
+ return grid.witness(cell)
120
+ return None
121
+
122
+
123
+ # ---------------------------------------------------------------------------
124
+ # cells
125
+ # ---------------------------------------------------------------------------
126
+
127
+
128
+ class Special(Enum):
129
+ """Values a cell can hold that are not values of the subject's own type."""
130
+
131
+ #: No row in the table: compares false under every comparator, and is not `defined`.
132
+ NULL = 'null'
133
+ #: A magnitude every comparison reads normally, and the one that is not `defined`.
134
+ POS_INF = '+inf'
135
+ #: Its negative twin.
136
+ NEG_INF = '-inf'
137
+ #: A label none of the masks names — every such label at once.
138
+ OTHER = 'other'
139
+
140
+
141
+ #: What one subject's value is, in one cell.
142
+ Cell = float | str | bool | int | datetime.date | Special
143
+
144
+ #: What a where comparison is written against: a number, a label, or a date.
145
+ #: ``position()`` counts in integers, which are numbers here.
146
+ _Literal = float | str | datetime.date
147
+
148
+
149
+ @dataclass(frozen=True)
150
+ class Subject:
151
+ """What an atom talks about — the key its cells are built for.
152
+
153
+ ``kind`` separates the namespaces that could otherwise collide: a
154
+ dimension's coordinates and its *rank* are two subjects over one name, and
155
+ a rank is further split by the ``by=`` relation it is counted within.
156
+ """
157
+
158
+ kind: Literal['param', 'expression', 'dim', 'rank', 'relation', 'relation_pair', 'variable']
159
+ name: str
160
+ qualifier: str | None = None
161
+ #: A rank's group columns: two positions by one relation into different columns are two subjects.
162
+ group: tuple[str, ...] = ()
163
+
164
+ def __str__(self) -> str:
165
+ if self.kind == 'rank':
166
+ within = f' within {self.qualifier}' if self.qualifier else ''
167
+ return f'the position of {self.name}{within}'
168
+ if self.kind == 'relation_pair':
169
+ return f'{self.name} vs {self.qualifier}'
170
+ return self.name
171
+
172
+
173
+ @dataclass(frozen=True)
174
+ class _Grid:
175
+ """The cells to check, and what reading an atom on one of them needs.
176
+
177
+ ``subjects`` is keyed by ``id(node)``: the where nodes are ``@dataclass``
178
+ with ``eq=True`` and so unhashable. The memo is valid while the masks are alive.
179
+ """
180
+
181
+ domains: dict[Subject, list[Cell]]
182
+ subjects: dict[int, Subject]
183
+
184
+ @classmethod
185
+ def of(cls, masks: Iterable[Mask], dtypes: Mapping[str, DeclaredDtype]) -> _Grid:
186
+ values: dict[Subject, set[_Literal]] = {}
187
+ subjects: dict[int, Subject] = {}
188
+ for mask in masks:
189
+ for node in mask.atoms:
190
+ subject = _subject_of(node)
191
+ subjects[id(node)] = subject
192
+ _observe(node, subject, values.setdefault(subject, set()), dtypes)
193
+ return cls({s: _cells_for(s, seen, dtypes) for s, seen in values.items()}, subjects)
194
+
195
+ @property
196
+ def size(self) -> int:
197
+ return math.prod(len(cells) for cells in self.domains.values())
198
+
199
+ def cells(self) -> Iterator[dict[Subject, Cell]]:
200
+ for combination in itertools.product(*self.domains.values()):
201
+ yield dict(zip(self.domains, combination, strict=True))
202
+
203
+ def witness(self, cell: dict[Subject, Cell]) -> str:
204
+ return ', '.join(f'{subject} is {_shown(subject, value)}' for subject, value in cell.items())
205
+
206
+
207
+ #: A comparator against its mirror, for a test written with its sides swapped.
208
+ _FLIPPED: Mapping[PredicateOperator, PredicateOperator] = {
209
+ '<': '>',
210
+ '>': '<',
211
+ '<=': '>=',
212
+ '>=': '<=',
213
+ '==': '==',
214
+ '!=': '!=',
215
+ }
216
+
217
+
218
+ def _undecided(mask: Predicate) -> str | None:
219
+ """The rewrite for a comparison of expressions under *mask*, or ``None`` where every atom is decidable alone.
220
+
221
+ A case is refused on its own rather than as a pair, because a block of one
222
+ case has no pair and the rule is the same: nothing proves where a
223
+ comparison of expressions falls before the numbers arrive.
224
+ """
225
+ for atom in Mask(mask).atoms:
226
+ if isinstance(atom, ExpressionComparison):
227
+ return _expression_rewrite(atom)
228
+ return None
229
+
230
+
231
+ def _expression_rewrite(node: ExpressionComparison) -> str:
232
+ """Why a comparison of expressions is not decided, and what to write instead.
233
+
234
+ A parameter against a literal is decided, and the same test with its sides
235
+ swapped is not — so that one is named as the order it is, rather than told
236
+ to do what it already does. Only the literal-first order needs naming: the
237
+ other resolves to a :class:`~mathspec.program.ParameterComparison` and
238
+ never reaches here, and a quoted label cannot stand on the left at all.
239
+ """
240
+ left, right = node.left, node.right
241
+ number = _signed_literal(left)
242
+ if number is not None and isinstance(right, Parameter):
243
+ return (
244
+ f'the literal is on the left, and a comparison is read as arithmetic there — write it as '
245
+ f'the same test the other way round, {right.name} {_FLIPPED[node.op]} '
246
+ f'{int(number) if number.is_integer() else number}'
247
+ )
248
+ return (
249
+ 'it compares expressions, whose values only the data decides — compare one parameter against a '
250
+ 'literal, or precompute the test as a boolean parameter and test that'
251
+ )
252
+
253
+
254
+ def _signed_literal(node: Expression) -> float | None:
255
+ """The number *node* is, its sign folded in — ``None`` where it is not a literal."""
256
+ if isinstance(node, Constant):
257
+ return node.value
258
+ if isinstance(node, Negate) and isinstance(node.operand, Constant):
259
+ return -node.operand.value
260
+ return None
261
+
262
+
263
+ def _observe(
264
+ node: TypedPredicate, subject: Subject, values: set[_Literal], dtypes: Mapping[str, DeclaredDtype]
265
+ ) -> None:
266
+ """Record what *node* says about its subject: a position, or a literal.
267
+
268
+ ``position()`` converts the dimension to an integer, so an ordering over a
269
+ rank is an ordering of integers and every comparator is admitted there.
270
+ """
271
+ if isinstance(node, ExpressionComparison):
272
+ raise Undecidable(_expression_rewrite(node))
273
+ if isinstance(node, CountComparison):
274
+ msg = (
275
+ 'it counts the coordinates a predicate admits, which only the data decides — test a parameter '
276
+ 'against a literal, or precompute the count as a parameter and test that'
277
+ )
278
+ raise Undecidable(msg)
279
+ if isinstance(node, TranslatedPredicate):
280
+ msg = (
281
+ 'it reads a predicate at a neighbouring coordinate, and which rows that admits only the data '
282
+ 'decides — test this row, or precompute the neighbour as a boolean parameter and test that'
283
+ )
284
+ raise Undecidable(msg)
285
+ if isinstance(node, PulledBackPredicate):
286
+ msg = (
287
+ f"it reads a predicate through '{node.direction.name}', and which rows that admits only the data "
288
+ 'decides — test this row, or precompute the read as a boolean parameter and test that'
289
+ )
290
+ raise Undecidable(msg)
291
+ if isinstance(node, DimensionPosition):
292
+ values.add(node.position)
293
+ elif isinstance(node, RelationPairComparison):
294
+ if node.op not in ('==', '!='):
295
+ msg = (
296
+ f'{subject} is ordered with {node.op!r}, and two relations carry no order '
297
+ f'against each other — compare them with == or !=, or precompute the '
298
+ f'ordering as a boolean parameter and test that'
299
+ )
300
+ raise Undecidable(msg)
301
+ elif isinstance(node, ParameterComparison | DimensionComparison | RelationComparison):
302
+ if node.op not in ('==', '!=') and dtypes.get(subject.name) not in _ORDERED_DTYPES:
303
+ msg = (
304
+ f'{subject} has dtype {dtypes.get(subject.name)!r} and is ordered with '
305
+ f'{node.op!r}, which puts no two of its values in order — compare it with '
306
+ f'== or !=, or declare it as a number'
307
+ )
308
+ raise Undecidable(msg)
309
+ values.add(node.value)
310
+
311
+
312
+ def _subject_of(node: TypedPredicate) -> Subject:
313
+ match node:
314
+ case ParameterDefined(name=name) | ParameterComparison(name=name):
315
+ return Subject('param', name)
316
+ case VariableDefined(name=name):
317
+ return Subject('variable', name)
318
+ case DimensionComparison(name=name):
319
+ return Subject('dim', name)
320
+ case DimensionPosition(name=name, partition=partition):
321
+ if partition is None:
322
+ return Subject('rank', name)
323
+ return Subject('rank', name, partition.name, partition.group)
324
+ case RelationDefined(name=name) | RelationComparison(name=name):
325
+ return Subject('relation', name)
326
+ case RelationPairComparison(name=name, other=other):
327
+ return Subject('relation_pair', name, other)
328
+ case ExpressionComparison():
329
+ return Subject('expression', 'a comparison of expressions')
330
+ case CountComparison():
331
+ return Subject('expression', 'a count of the coordinates a predicate admits')
332
+ case TranslatedPredicate():
333
+ return Subject('expression', 'a predicate read at a neighbouring coordinate')
334
+ case PulledBackPredicate():
335
+ return Subject('expression', 'a predicate read through a relation')
336
+ case _:
337
+ assert_never(node)
338
+
339
+
340
+ def _cells_for(subject: Subject, values: set[_Literal], dtypes: Mapping[str, DeclaredDtype]) -> list[Cell]:
341
+ """Every region *subject*'s value can sit in — ordinary values first.
342
+
343
+ The order is the order :func:`_witness` searches, so a refusal names an
344
+ absent value or an infinity only where nothing plainer is a witness.
345
+ """
346
+ if subject.kind == 'rank':
347
+ return _rank_cells(subject, {int(v) for v in _numbers(values)})
348
+ if subject.kind in ('relation_pair', 'variable'):
349
+ return [True, False]
350
+ dtype = dtypes.get(subject.name)
351
+ if dtype == 'bool':
352
+ if values:
353
+ msg = (
354
+ f'{subject} has dtype bool and is compared to a literal, which reads as a '
355
+ f'magnitude rather than as truth — write the bare name, or `not {subject}`'
356
+ )
357
+ raise Undecidable(msg)
358
+ return [True, False, Special.NULL]
359
+ numeric = _numeric(dtype, values)
360
+ cells: list[Cell]
361
+ if numeric:
362
+ cells = list(_numeric_cells(_numbers(values), discrete=dtype == 'int'))
363
+ elif _dated(values):
364
+ cells = list(_dated_cells({v for v in values if isinstance(v, datetime.date)}))
365
+ else:
366
+ cells = _label_cells(values)
367
+ cells.extend(_absence_cells(subject, numeric=numeric))
368
+ return cells
369
+
370
+
371
+ def _absence_cells(subject: Subject, *, numeric: bool) -> list[Cell]:
372
+ """The regions where *subject* has no ordinary value.
373
+
374
+ A dimension's coordinates have no null. ``defined`` excludes an infinity,
375
+ so a magnitude needs a region where every comparison still reads normally
376
+ and the bare name is false.
377
+ """
378
+ if subject.kind == 'dim':
379
+ return []
380
+ return [Special.NULL, Special.NEG_INF, Special.POS_INF] if numeric else [Special.NULL]
381
+
382
+
383
+ def _numeric(dtype: DeclaredDtype | None, literals: set[_Literal]) -> bool:
384
+ """Is this subject a magnitude? The declaration says so where it is known."""
385
+ if dtype is not None:
386
+ return dtype in ('float', 'int')
387
+ return bool(literals) and all(isinstance(value, int | float) and not isinstance(value, bool) for value in literals)
388
+
389
+
390
+ def _dated(literals: set[_Literal]) -> bool:
391
+ return bool(literals) and all(isinstance(value, datetime.date) for value in literals)
392
+
393
+
394
+ def _numbers(literals: set[_Literal]) -> set[float]:
395
+ """The literals of a magnitude, as the numbers the dtype rules guarantee they are."""
396
+ numbers = {float(value) for value in literals if isinstance(value, int | float)}
397
+ assert len(numbers) == len(literals), 'a label or a date reached a magnitude; the dtype rules keep them apart'
398
+ return numbers
399
+
400
+
401
+ def _numeric_cells(literals: set[float], *, discrete: bool) -> list[float]:
402
+ """Each number, and one representative of the gap on either side of it.
403
+
404
+ An ``int`` subject has a value between two literals only where the gap is
405
+ wider than one.
406
+ """
407
+ if not literals:
408
+ return [0.0]
409
+ values = sorted(literals)
410
+ cells = [values[0] - 1.0]
411
+ for value, following in itertools.zip_longest(values, values[1:]):
412
+ cells.append(value)
413
+ if following is None:
414
+ continue
415
+ if not discrete:
416
+ cells.append((value + following) / 2.0)
417
+ elif following - value > 1.0:
418
+ cells.append(value + 1.0)
419
+ cells.append(values[-1] + 1.0)
420
+ return cells
421
+
422
+
423
+ def _dated_cells(literals: set[datetime.date]) -> list[datetime.date]:
424
+ """Each date, and one representative of the gap on either side of it.
425
+
426
+ A date steps by a day and a datetime by a second, and there is a value
427
+ between two literals only where the gap is wider than one step.
428
+ """
429
+ values = sorted(literals)
430
+ step = datetime.timedelta(seconds=1) if isinstance(values[0], datetime.datetime) else datetime.timedelta(days=1)
431
+ cells = [values[0] - step]
432
+ for value, following in itertools.zip_longest(values, values[1:]):
433
+ cells.append(value)
434
+ if following is not None and following - value > step:
435
+ cells.append(value + step)
436
+ cells.append(values[-1] + step)
437
+ return cells
438
+
439
+
440
+ def _label_cells(literals: set[_Literal]) -> list[Cell]:
441
+ """Every named label, and one standing for all the labels not named."""
442
+ return [*sorted(literals, key=str), Special.OTHER]
443
+
444
+
445
+ def _rank_cells(subject: Subject, positions_seen: set[int]) -> list[Cell]:
446
+ """Representative ranks, counted from one end — the front, or the back.
447
+
448
+ ``position(dim) == 0`` and ``position(dim) == -1`` are the same row on a
449
+ one-member axis, and a file never says how many members an axis has: its
450
+ coordinates are data. So the two frames cannot be told apart, and a pair
451
+ mixing them is refused with the one rewrite left — split at one end.
452
+
453
+ Within one frame the cells are its own mirror image: counting from the
454
+ front the open end is *after* the last position named, counting from the
455
+ back it is *before* the first, since nothing follows -1.
456
+ """
457
+ positions = sorted(positions_seen)
458
+ if positions and positions[0] < 0 <= positions[-1]:
459
+ within = f' within each {subject.qualifier} group' if subject.qualifier else ''
460
+ msg = (
461
+ f'{subject.name} is split at positions counted from both ends{within} '
462
+ f'({", ".join(str(position) for position in positions)}), and how many members it has '
463
+ f'is data, so they are the same row on a one-member axis — count from one end only'
464
+ )
465
+ raise Undecidable(msg)
466
+ if not positions:
467
+ return [0]
468
+ cells: list[Cell] = []
469
+ if positions[0] != 0:
470
+ cells.append(positions[0] - 1)
471
+ for position, following in itertools.zip_longest(positions, positions[1:]):
472
+ cells.append(position)
473
+ if following is not None and following - position > 1:
474
+ cells.append(position + 1)
475
+ if positions[-1] != -1:
476
+ # nothing follows -1, so the open end is only ever past the front frame
477
+ cells.append(positions[-1] + 1)
478
+ return cells
479
+
480
+
481
+ def _shown(subject: Subject, value: Cell) -> str:
482
+ if subject.kind == 'rank':
483
+ return str(value)
484
+ if subject.kind == 'relation_pair':
485
+ return 'equal' if value else 'different'
486
+ if isinstance(value, Special):
487
+ return {Special.NULL: 'absent', Special.OTHER: 'anything else'}.get(value, value.value)
488
+ if isinstance(value, bool):
489
+ return 'true' if value else 'false'
490
+ return f'{value!r}'
491
+
492
+
493
+ # ---------------------------------------------------------------------------
494
+ # evaluating a mask on one cell
495
+ # ---------------------------------------------------------------------------
496
+
497
+
498
+ def _evaluate(node: Predicate, cell: dict[Subject, Cell], grid: _Grid) -> bool:
499
+ """Is *node* true in this cell?"""
500
+ if isinstance(node, TypedPredicate):
501
+ return _atom(node, cell, grid)
502
+ match node:
503
+ case BooleanLiteral(value=value):
504
+ return value
505
+ case Not(operand=operand):
506
+ return not _evaluate(operand, cell, grid)
507
+ case And(left=left, right=right):
508
+ return _evaluate(left, cell, grid) and _evaluate(right, cell, grid)
509
+ case Or(left=left, right=right):
510
+ return _evaluate(left, cell, grid) or _evaluate(right, cell, grid)
511
+ case _:
512
+ assert_never(node)
513
+
514
+
515
+ def _atom(node: TypedPredicate, cell: dict[Subject, Cell], grid: _Grid) -> bool:
516
+ subject = grid.subjects[id(node)]
517
+ value = cell[subject]
518
+ match node:
519
+ case ParameterDefined() | RelationDefined():
520
+ if isinstance(value, bool):
521
+ return value
522
+ return value not in (Special.NULL, Special.POS_INF, Special.NEG_INF)
523
+ case VariableDefined():
524
+ return bool(value)
525
+ case RelationPairComparison(op=op):
526
+ return bool(value) if op == '==' else not value
527
+ case ExpressionComparison():
528
+ msg = 'a comparison of expressions is refused as undecidable before any cell is read'
529
+ raise AssertionError(msg)
530
+ case CountComparison() | TranslatedPredicate() | PulledBackPredicate():
531
+ msg = (
532
+ 'a predicate read as a count, at a neighbour or through a relation is refused as undecidable '
533
+ 'before any cell is read'
534
+ )
535
+ raise AssertionError(msg)
536
+ case DimensionPosition(op=op, position=position):
537
+ return _compare(value, op, position)
538
+ case ParameterComparison(op=op, value=literal) | RelationComparison(op=op, value=literal):
539
+ if value is Special.NULL:
540
+ return False
541
+ return _compare(value, op, literal)
542
+ case DimensionComparison(op=op, value=literal):
543
+ return _compare(value, op, literal)
544
+ case _:
545
+ assert_never(node)
546
+
547
+
548
+ def _compare(value: Cell, op: PredicateOperator, literal: _Literal) -> bool:
549
+ """One atom's truth in one cell. Both sides are already this cell's frame.
550
+
551
+ Raises:
552
+ AssertionError: The cell and the literal are of different kinds, which
553
+ the dtype rules keep apart before a mask is proved.
554
+ """
555
+ if isinstance(value, Special):
556
+ if value is Special.OTHER:
557
+ # a label none of the masks names sorts nowhere
558
+ if op in ('==', '!='):
559
+ return op == '!='
560
+ msg = f'a label neither case names is ordered with {op!r} — compare labels with == or != instead'
561
+ raise Undecidable(msg)
562
+ value = math.inf if value is Special.POS_INF else -math.inf
563
+ if isinstance(value, int | float) and isinstance(literal, int | float):
564
+ return _ordered(float(value), op, float(literal))
565
+ if isinstance(value, str) and isinstance(literal, str):
566
+ return _ordered(value, op, literal)
567
+ if isinstance(value, datetime.date) and isinstance(literal, datetime.date):
568
+ return _ordered(value, op, literal)
569
+ msg = f'{value!r} is compared with {literal!r}, and the two are of different kinds'
570
+ raise AssertionError(msg)
571
+
572
+
573
+ def _ordered[T: (float, str, datetime.date)](left: T, op: PredicateOperator, right: T) -> bool:
574
+ """One comparison between two values of one kind — the kinds a literal comes in."""
575
+ match op:
576
+ case '==':
577
+ return left == right
578
+ case '!=':
579
+ return left != right
580
+ case '<':
581
+ return left < right
582
+ case '<=':
583
+ return left <= right
584
+ case '>':
585
+ return left > right
586
+ case '>=':
587
+ return left >= right