Coverage for datesat / constraint_validator.py: 34.3%
631 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-02-10 23:47 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-02-10 23:47 +0000
1"""
2Pure-Python constraint validation for solved assignments.
4This module executes generated constraint code using a lightweight builder that
5supports date, int, and bool variables, and then evaluates all constraints
6against a provided concrete solution (no solving capability).
7"""
9from typing import Any, Dict, Optional, Tuple, Union
10from dateutil.relativedelta import relativedelta
11import datetime
12import warnings
14from .core import Date, Period
15from .enumeration_baseline import (
16 ConstraintWrapper,
17 Or_enumeration,
18 And_enumeration,
19 Not_enumeration,
20 Implies_enumeration,
21)
24# Global flag used during validation to detect when any intermediate date
25# computation has gone outside DateSAT's supported range. This allows the
26# higher-level validation/summary code to classify such cases as "warning"
27# instead of fully "wrong".
28_OUT_OF_BOUNDS_USED: bool = False
31class EvalDateVar:
32 def __init__(self, name: str):
33 self.name = name
34 self._value: Optional[Date] = None
35 self._lazy_op = None # ("add"|"sub", EvalDateVar, Period)
37 def set_value(self, year: int, month: int, day: int) -> None:
38 try:
39 self._value = Date(year, month, day)
40 except ValueError:
41 self._value = None
43 def clear_value(self) -> None:
44 self._value = None
46 def _hard_reset_value(self) -> None:
47 self._value = None
49 def get_value(self) -> Optional[Date]:
50 if self._value is None and self._lazy_op is not None:
51 op_type, left, period = self._lazy_op
52 left_val = left.get_value()
53 if left_val is None: 53 ↛ 54line 53 didn't jump to line 54 because the condition on line 53 was never true
54 return None
55 py_date = left_val.to_python_date()
56 delta = relativedelta(
57 years=period.years, months=period.months, days=period.days
58 )
59 if op_type == "add": 59 ↛ 62line 59 didn't jump to line 62 because the condition on line 59 was always true
60 result_date = py_date + delta
61 else:
62 result_date = py_date - delta
63 try:
64 # Try to create a Date object (respects bounds)
65 result = Date.from_python_date(result_date)
66 self.set_value(result.year, result.month, result.day)
67 except ValueError:
68 # Date is out of DateSAT bounds, but still valid for validation.
69 # Store as a pseudo-Date using a custom unbounded wrapper and
70 # record that we went outside the supported range.
71 global _OUT_OF_BOUNDS_USED
72 _OUT_OF_BOUNDS_USED = True
74 # Emit a warning for visibility during ad‑hoc runs
75 warnings.warn(
76 f"Intermediate date computation resulted in date outside allowed range: "
77 f"{result_date.year}-{result_date.month:02d}-{result_date.day:02d} "
78 f"(allowed [1900-03-01..2100-02-28]). Using unbounded date for validation.",
79 UserWarning,
80 stacklevel=2,
81 )
83 # Construct Date with bounded=False for dates outside allowed range
84 self._value = Date(
85 result_date.year, result_date.month, result_date.day, bounded=False
86 )
87 return self._value
89 # comparisons
90 def _cmp(self, op: str, other: Union[Date, "EvalDateVar"]) -> ConstraintWrapper:
91 def compare():
92 lhs = self.get_value()
93 if lhs is None: 93 ↛ 94line 93 didn't jump to line 94 because the condition on line 93 was never true
94 return False
95 rhs_val: Optional[Date]
96 if isinstance(other, Date): 96 ↛ 99line 96 didn't jump to line 99 because the condition on line 96 was always true
97 rhs_val = other
98 else:
99 rhs_val = other.get_value()
100 if rhs_val is None: 100 ↛ 101line 100 didn't jump to line 101 because the condition on line 100 was never true
101 return False
102 return getattr(lhs.to_python_date(), f"__{op}__")(rhs_val.to_python_date())
104 concrete_value = other if isinstance(other, Date) else None
105 return ConstraintWrapper(
106 compare, var_ref=self, concrete_value=concrete_value, rhs_ref=other
107 )
109 def __eq__(self, other: Union[Date, "EvalDateVar"]) -> ConstraintWrapper: # type: ignore[override]
110 return self._cmp("eq", other)
112 def __ne__(self, other: Union[Date, "EvalDateVar"]) -> ConstraintWrapper: # type: ignore[override]
113 return self._cmp("ne", other)
115 def __lt__(self, other: Union[Date, "EvalDateVar"]) -> ConstraintWrapper:
116 return self._cmp("lt", other)
118 def __le__(self, other: Union[Date, "EvalDateVar"]) -> ConstraintWrapper:
119 return self._cmp("le", other)
121 def __gt__(self, other: Union[Date, "EvalDateVar"]) -> ConstraintWrapper:
122 return self._cmp("gt", other)
124 def __ge__(self, other: Union[Date, "EvalDateVar"]) -> ConstraintWrapper:
125 return self._cmp("ge", other)
127 def __add__(self, other: Period) -> "EvalDateVar":
128 if not isinstance(other, Period): 128 ↛ 129line 128 didn't jump to line 129 because the condition on line 128 was never true
129 raise TypeError("Can only add Period to DateVar")
130 out = EvalDateVar(f"{self.name}_plus")
131 out._lazy_op = ("add", self, other)
132 return out
134 def __sub__(self, other: Period) -> "EvalDateVar":
135 if not isinstance(other, Period):
136 raise TypeError("Can only subtract Period from DateVar")
137 out = EvalDateVar(f"{self.name}_minus")
138 out._lazy_op = ("sub", self, other)
139 return out
141 # year/month/day projections
142 @property
143 def year(self) -> "EvalDateComponent":
144 return EvalDateComponent(self, "year")
146 @property
147 def month(self) -> "EvalDateComponent":
148 return EvalDateComponent(self, "month")
150 @property
151 def day(self) -> "EvalDateComponent":
152 return EvalDateComponent(self, "day")
155class EvalDateComponent:
156 def __init__(self, parent: EvalDateVar, attr: str):
157 self.parent = parent
158 self.attr = attr
160 def _get_component_value(self) -> Optional[int]:
161 """Return the concrete component value (year/month/day) or None."""
162 d = self.parent.get_value()
163 if d is None:
164 return None
165 # Works for both bounded and unbounded Date
166 return getattr(d, self.attr)
168 def _cmp(self, op: str, other: Union[int, "EvalIntVar", "EvalDateComponent"]) -> ConstraintWrapper:
169 def compare():
170 component_val = self._get_component_value()
171 if component_val is None:
172 return False
174 # Handle EvalIntVar on the right
175 if isinstance(other, EvalIntVar):
176 other_val = other.get_value()
177 # Handle another date component on the right (e.g., D4.month <= D3.day)
178 elif isinstance(other, EvalDateComponent):
179 other_val = other._get_component_value()
180 else:
181 try:
182 other_val = int(other)
183 except Exception:
184 other_val = None
186 if other_val is None:
187 return False
189 try:
190 return getattr(component_val, f"__{op}__")(other_val)
191 except (OverflowError, ZeroDivisionError, ValueError, TypeError):
192 return False
194 return ConstraintWrapper(compare)
196 def __eq__(self, other: Union[int, "EvalIntVar", "EvalDateComponent"]) -> ConstraintWrapper: # type: ignore[override]
197 return self._cmp("eq", other)
199 def __ne__(self, other: Union[int, "EvalIntVar", "EvalDateComponent"]) -> ConstraintWrapper: # type: ignore[override]
200 return self._cmp("ne", other)
202 def __lt__(self, other: Union[int, "EvalIntVar", "EvalDateComponent"]) -> ConstraintWrapper:
203 return self._cmp("lt", other)
205 def __le__(self, other: Union[int, "EvalIntVar", "EvalDateComponent"]) -> ConstraintWrapper:
206 return self._cmp("le", other)
208 def __gt__(self, other: Union[int, "EvalIntVar", "EvalDateComponent"]) -> ConstraintWrapper:
209 return self._cmp("gt", other)
211 def __ge__(self, other: Union[int, "EvalIntVar", "EvalDateComponent"]) -> ConstraintWrapper:
212 return self._cmp("ge", other)
214 # Arithmetic operations returning EvalIntVar so that expressions like
215 # D4.year * 1982 or (D7.day + I0) are supported during validation.
216 def _binary_int_op(self, op: str, other: Union[int, "EvalIntVar"]) -> "EvalIntVar":
217 out = EvalIntVar(f"{self.parent.name}_{self.attr}_{op}")
219 def compute():
220 lhs = self._get_component_value()
221 if lhs is None:
222 # Debug: check if parent is set
223 parent_val = self.parent.get_value()
224 if parent_val is None:
225 # Parent date variable hasn't been set - this is likely the bug
226 import warnings
227 warnings.warn(f"Parent date variable {self.parent.name} not set when evaluating {self.parent.name}.{self.attr} {op} {other}")
228 return None
229 if isinstance(other, EvalIntVar):
230 rv = other.get_value()
231 else:
232 try:
233 rv = int(other)
234 except Exception:
235 rv = None
236 if rv is None:
237 return None
238 try:
239 return getattr(lhs, f"__{op}__")(rv)
240 except (OverflowError, ZeroDivisionError, ValueError):
241 return None
243 out.get_value = compute # type: ignore[attr-defined]
244 return out
246 def __add__(self, other: Union[int, "EvalIntVar"]) -> "EvalIntVar":
247 return self._binary_int_op("add", other)
249 def __radd__(self, other: Union[int, "EvalIntVar"]) -> "EvalIntVar":
250 return self.__add__(other)
252 def __sub__(self, other: Union[int, "EvalIntVar"]) -> "EvalIntVar":
253 return self._binary_int_op("sub", other)
255 def __rsub__(self, other: Union[int, "EvalIntVar"]) -> "EvalIntVar":
256 # Implement as (other - component)
257 out = EvalIntVar(f"const_minus_{self.parent.name}_{self.attr}")
259 def compute():
260 rhs = self._get_component_value()
261 if rhs is None:
262 return None
263 if isinstance(other, EvalIntVar):
264 lv = other.get_value()
265 else:
266 try:
267 lv = int(other)
268 except Exception:
269 lv = None
270 if lv is None:
271 return None
272 try:
273 return getattr(lv, "__sub__")(rhs)
274 except (OverflowError, ZeroDivisionError, ValueError):
275 return None
277 out.get_value = compute # type: ignore[attr-defined]
278 return out
280 def __mul__(self, other: Union[int, "EvalIntVar"]) -> "EvalIntVar":
281 return self._binary_int_op("mul", other)
283 def __rmul__(self, other: Union[int, "EvalIntVar"]) -> "EvalIntVar":
284 return self.__mul__(other)
286 # Integer division using `/` in the DSL
287 def __truediv__(self, other: Union[int, "EvalIntVar"]) -> "EvalIntVar":
288 # Delegate to floordiv semantics for integer division
289 return self._binary_int_op("floordiv", other)
291 def __rtruediv__(self, other: Union[int, "EvalIntVar"]) -> "EvalIntVar":
292 # Implement as (other / component) with floor semantics
293 out = EvalIntVar(f"const_div_{self.parent.name}_{self.attr}")
295 def compute():
296 rhs = self._get_component_value()
297 if rhs is None or rhs == 0:
298 return None
299 if isinstance(other, EvalIntVar):
300 lv = other.get_value()
301 else:
302 try:
303 lv = int(other)
304 except Exception:
305 lv = None
306 if lv is None:
307 return None
308 try:
309 return lv // rhs
310 except (OverflowError, ZeroDivisionError, ValueError):
311 return None
313 out.get_value = compute # type: ignore[attr-defined]
314 return out
316 def __mod__(self, other: Union[int, "EvalIntVar"]) -> "EvalIntVar":
317 return self._binary_int_op("mod", other)
319 def __rmod__(self, other: Union[int, "EvalIntVar"]) -> "EvalIntVar":
320 # Implement as (other % component)
321 out = EvalIntVar(f"const_mod_{self.parent.name}_{self.attr}")
323 def compute():
324 rhs = self._get_component_value()
325 if rhs is None or rhs == 0:
326 return None
327 if isinstance(other, EvalIntVar):
328 lv = other.get_value()
329 else:
330 try:
331 lv = int(other)
332 except Exception:
333 lv = None
334 if lv is None:
335 return None
336 try:
337 return getattr(lv, "__mod__")(rhs)
338 except (OverflowError, ZeroDivisionError, ValueError):
339 return None
341 out.get_value = compute # type: ignore[attr-defined]
342 return out
345class EvalIntVar:
346 def __init__(self, name: str):
347 self.name = name
348 self._value: Optional[int] = None
350 def set_value(self, v: int) -> None:
351 try:
352 self._value = int(v)
353 except Exception:
354 self._value = None
356 def get_value(self) -> Optional[int]:
357 return self._value
359 def _cmp(self, op: str, other: Union[int, "EvalIntVar"]) -> ConstraintWrapper:
360 def rhs():
361 # Support comparisons against another EvalIntVar
362 if isinstance(other, EvalIntVar): 362 ↛ 363line 362 didn't jump to line 363 because the condition on line 362 was never true
363 return other.get_value()
365 # Support comparisons where the *right-hand side* is a date component
366 # such as D6.month or D3.day. The parser may generate expressions like
367 # "I6 > D6.month", which should be valid and interpreted as an int
368 # comparison, just like "D6.month < I6".
369 from .constraint_validator import EvalDateComponent # type: ignore
371 if isinstance(other, EvalDateComponent): 371 ↛ 372line 371 didn't jump to line 372 because the condition on line 371 was never true
372 return other._get_component_value()
374 # Fallback: treat as a plain integer literal
375 try:
376 return int(other)
377 except Exception:
378 return None
380 def compare():
381 lhs = self.get_value()
382 rv = rhs()
383 if lhs is None or rv is None: 383 ↛ 384line 383 didn't jump to line 384 because the condition on line 383 was never true
384 return False
385 return getattr(lhs, f"__{op}__")(rv)
387 return ConstraintWrapper(compare, var_ref=self, rhs_ref=other)
389 def __eq__(self, other: Union[int, "EvalIntVar"]) -> ConstraintWrapper: # type: ignore[override]
390 return self._cmp("eq", other)
392 def __ne__(self, other: Union[int, "EvalIntVar"]) -> ConstraintWrapper: # type: ignore[override]
393 return self._cmp("ne", other)
395 def __lt__(self, other: Union[int, "EvalIntVar"]) -> ConstraintWrapper:
396 return self._cmp("lt", other)
398 def __le__(self, other: Union[int, "EvalIntVar"]) -> ConstraintWrapper:
399 return self._cmp("le", other)
401 def __gt__(self, other: Union[int, "EvalIntVar"]) -> ConstraintWrapper:
402 return self._cmp("gt", other)
404 def __ge__(self, other: Union[int, "EvalIntVar"]) -> ConstraintWrapper:
405 return self._cmp("ge", other)
407 def __add__(self, other: Union[int, "EvalIntVar", "EvalDateComponent"]) -> "EvalIntVar":
408 out = EvalIntVar(f"{self.name}_plus")
410 def compute():
411 lhs = self.get_value()
412 # Support addition with another EvalIntVar
413 if isinstance(other, EvalIntVar):
414 rv = other.get_value()
415 # Support addition with a date component (e.g., I8 + D5.year)
416 elif isinstance(other, EvalDateComponent):
417 rv = other._get_component_value()
418 else:
419 try:
420 rv = int(other)
421 except Exception:
422 rv = None
423 if lhs is None or rv is None:
424 return None
425 return lhs + rv
427 out.get_value = compute # type: ignore
428 return out
430 def __sub__(self, other: Union[int, "EvalIntVar"]) -> "EvalIntVar":
431 out = EvalIntVar(f"{self.name}_minus")
433 def compute():
434 lhs = self.get_value()
435 # Support subtraction with another EvalIntVar
436 if isinstance(other, EvalIntVar):
437 rv = other.get_value()
438 # Support subtraction with a date component (e.g., I8 - D5.year or x.year * 2 - x.year)
439 elif isinstance(other, EvalDateComponent):
440 rv = other._get_component_value()
441 else:
442 try:
443 rv = int(other)
444 except Exception:
445 rv = None
446 if lhs is None or rv is None:
447 return None
448 return lhs - rv
450 out.get_value = compute # type: ignore
451 return out
453 def __mul__(self, other: Union[int, "EvalIntVar", "EvalDateComponent"]) -> "EvalIntVar":
454 out = EvalIntVar(f"{self.name}_times")
456 def compute():
457 lhs = self.get_value()
458 # Support multiplication with another EvalIntVar
459 if isinstance(other, EvalIntVar):
460 rv = other.get_value()
461 # Support multiplication with a date component (e.g., I8 * D5.year)
462 elif isinstance(other, EvalDateComponent):
463 rv = other._get_component_value()
464 else:
465 try:
466 rv = int(other)
467 except Exception:
468 rv = None
469 if lhs is None or rv is None:
470 return None
471 return lhs * rv
473 out.get_value = compute # type: ignore
474 return out
476 def __floordiv__(self, other: Union[int, "EvalIntVar", "EvalDateComponent"]) -> "EvalIntVar":
477 out = EvalIntVar(f"{self.name}_div")
479 def compute():
480 lhs = self.get_value()
481 # Support division by another EvalIntVar
482 if isinstance(other, EvalIntVar):
483 rv = other.get_value()
484 # Support division by a date component (e.g., I8 // D5.year)
485 elif isinstance(other, EvalDateComponent):
486 rv = other._get_component_value()
487 else:
488 try:
489 rv = int(other)
490 except Exception:
491 rv = None
492 if lhs is None or rv is None or rv == 0:
493 return None
494 return lhs // rv
496 out.get_value = compute # type: ignore
497 return out
499 def __mod__(self, other: Union[int, "EvalIntVar", "EvalDateComponent"]) -> "EvalIntVar":
500 out = EvalIntVar(f"{self.name}_mod")
502 def compute():
503 lhs = self.get_value()
504 # Support modulo with another EvalIntVar
505 if isinstance(other, EvalIntVar):
506 rv = other.get_value()
507 # Support modulo with a date component (e.g., I8 % D5.year)
508 elif isinstance(other, EvalDateComponent):
509 rv = other._get_component_value()
510 else:
511 try:
512 rv = int(other)
513 except Exception:
514 rv = None
515 if lhs is None or rv is None or rv == 0:
516 return None
517 return lhs % rv
519 out.get_value = compute # type: ignore
520 return out
522 def __pow__(self, other: Union[int, "EvalIntVar", "EvalDateComponent"]) -> "EvalIntVar":
523 out = EvalIntVar(f"{self.name}_pow")
525 def compute():
526 lhs = self.get_value()
527 # Support exponentiation with another EvalIntVar
528 if isinstance(other, EvalIntVar):
529 rv = other.get_value()
530 # Support exponentiation with a date component (e.g., I8 ** D5.year)
531 elif isinstance(other, EvalDateComponent):
532 rv = other._get_component_value()
533 else:
534 try:
535 rv = int(other)
536 except Exception:
537 rv = None
538 if lhs is None or rv is None:
539 return None
540 try:
541 return lhs ** rv
542 except (OverflowError, ValueError):
543 return None
545 out.get_value = compute # type: ignore
546 return out
548 # Reverse operations for when int is on the left side
549 def __radd__(self, other: int) -> "EvalIntVar":
550 return self.__add__(other)
552 def __rsub__(self, other: int) -> "EvalIntVar":
553 out = EvalIntVar(f"const_minus_{self.name}")
555 def compute():
556 rhs = self.get_value()
557 if rhs is None:
558 return None
559 return int(other) - rhs
561 out.get_value = compute # type: ignore
562 return out
564 def __rmul__(self, other: int) -> "EvalIntVar":
565 return self.__mul__(other)
567 # Integer division using `/` in the DSL
568 def __truediv__(self, other: Union[int, "EvalIntVar"]) -> "EvalIntVar":
569 out = EvalIntVar(f"{self.name}_div")
571 def compute():
572 lhs = self.get_value()
573 # Support division by another EvalIntVar
574 if isinstance(other, EvalIntVar):
575 rv = other.get_value()
576 # Support division by a date component (e.g., I8 / D5.year)
577 elif isinstance(other, EvalDateComponent):
578 rv = other._get_component_value()
579 else:
580 try:
581 rv = int(other)
582 except Exception:
583 rv = None
584 if lhs is None or rv is None or rv == 0:
585 return None
586 return lhs // rv
588 out.get_value = compute # type: ignore
589 return out
591 def __rtruediv__(self, other: int) -> "EvalIntVar":
592 out = EvalIntVar(f"const_div_{self.name}")
594 def compute():
595 rhs = self.get_value()
596 if rhs is None or rhs == 0:
597 return None
598 return int(other) // rhs
600 out.get_value = compute # type: ignore
601 return out
603 def __rmod__(self, other: int) -> "EvalIntVar":
604 out = EvalIntVar(f"const_mod_{self.name}")
606 def compute():
607 rhs = self.get_value()
608 if rhs is None or rhs == 0:
609 return None
610 return int(other) % rhs
612 out.get_value = compute # type: ignore
613 return out
615 def __rpow__(self, other: int) -> "EvalIntVar":
616 out = EvalIntVar(f"const_pow_{self.name}")
618 def compute():
619 rhs = self.get_value()
620 if rhs is None:
621 return None
622 try:
623 return int(other) ** rhs
624 except (OverflowError, ValueError):
625 return None
627 out.get_value = compute # type: ignore
628 return out
631class EvalBoolVar:
632 def __init__(self, name: str):
633 self.name = name
634 self._value: Optional[bool] = None
636 def set_value(self, v: Union[bool, str, int]) -> None:
637 if isinstance(v, bool): 637 ↛ 640line 637 didn't jump to line 640 because the condition on line 637 was always true
638 self._value = v
639 return
640 if isinstance(v, str):
641 lv = v.strip().lower()
642 if lv in ("true", "1", "yes", "y", "t"):
643 self._value = True
644 return
645 if lv in ("false", "0", "no", "n", "f"):
646 self._value = False
647 return
648 try:
649 self._value = bool(int(v))
650 except Exception:
651 self._value = None
653 def get_value(self) -> Optional[bool]:
654 return self._value
656 def _cmp(self, op: str, other: Union[bool, "EvalBoolVar", ConstraintWrapper]) -> ConstraintWrapper:
657 def rhs():
658 if isinstance(other, EvalBoolVar): 658 ↛ 659line 658 didn't jump to line 659 because the condition on line 658 was never true
659 return other.get_value()
660 if isinstance(other, bool): 660 ↛ 662line 660 didn't jump to line 662 because the condition on line 660 was always true
661 return other
662 if isinstance(other, ConstraintWrapper):
663 # Evaluate the constraint wrapper to get a boolean
664 return other.evaluate()
665 return None
667 def compare():
668 lhs = self.get_value()
669 rv = rhs()
670 if lhs is None or rv is None: 670 ↛ 671line 670 didn't jump to line 671 because the condition on line 670 was never true
671 return False
672 return getattr(lhs, f"__{op}__")(rv)
674 return ConstraintWrapper(compare, var_ref=self, rhs_ref=other)
676 def __eq__(self, other: Union[bool, "EvalBoolVar", ConstraintWrapper]) -> ConstraintWrapper: # type: ignore[override]
677 return self._cmp("eq", other)
679 def __ne__(self, other: Union[bool, "EvalBoolVar", ConstraintWrapper]) -> ConstraintWrapper: # type: ignore[override]
680 return self._cmp("ne", other)
682 def __invert__(self) -> ConstraintWrapper:
683 return ConstraintWrapper(lambda: not bool(self.get_value()))
685 def __and__(self, other: Any) -> ConstraintWrapper:
686 return And_enumeration(self, other)
688 def __or__(self, other: Any) -> ConstraintWrapper:
689 return Or_enumeration(self, other)
692class EvalBuilder:
693 """Lightweight builder used only for validation of a provided solution."""
695 def __init__(self):
696 self.date_vars: Dict[str, EvalDateVar] = {}
697 self.int_vars: Dict[str, EvalIntVar] = {}
698 self.bool_vars: Dict[str, EvalBoolVar] = {}
699 self.constraints: list = []
701 # variable builders
702 def add_date_var(self, name: str) -> EvalDateVar:
703 if name not in self.date_vars: 703 ↛ 705line 703 didn't jump to line 705 because the condition on line 703 was always true
704 self.date_vars[name] = EvalDateVar(name)
705 return self.date_vars[name]
707 def add_int_var(self, name: str) -> EvalIntVar:
708 if name not in self.int_vars: 708 ↛ 710line 708 didn't jump to line 710 because the condition on line 708 was always true
709 self.int_vars[name] = EvalIntVar(name)
710 return self.int_vars[name]
712 def add_bool_var(self, name: str) -> EvalBoolVar:
713 if name not in self.bool_vars: 713 ↛ 715line 713 didn't jump to line 715 because the condition on line 713 was always true
714 self.bool_vars[name] = EvalBoolVar(name)
715 return self.bool_vars[name]
717 # logical wrappers
718 def Or(self, *args) -> ConstraintWrapper:
719 return Or_enumeration(*args)
721 def And(self, *args) -> ConstraintWrapper:
722 return And_enumeration(*args)
724 def Not(self, arg) -> ConstraintWrapper:
725 return Not_enumeration(arg)
727 def Implies(self, antecedent, consequent) -> ConstraintWrapper:
728 return Implies_enumeration(antecedent, consequent)
730 # constraint collector
731 def add_constraint(self, constraint: Any) -> None:
732 self.constraints.append(constraint)
734 # context for exec
735 def get_execution_context(self) -> Dict[str, Any]:
736 import builtins
738 class MockZ3:
739 Or = staticmethod(lambda *args: Or_enumeration(*args))
740 And = staticmethod(lambda *args: And_enumeration(*args))
741 Not = staticmethod(lambda arg: Not_enumeration(arg))
742 Implies = staticmethod(lambda a, c: Implies_enumeration(a, c))
743 Int = staticmethod(lambda *args: None)
744 Bool = staticmethod(lambda *args: None)
746 original_import = builtins.__import__
748 def mock_import(name, *args, **kwargs):
749 if name == "z3": 749 ↛ 751line 749 didn't jump to line 751 because the condition on line 749 was always true
750 return MockZ3()
751 return original_import(name, *args, **kwargs)
753 # Custom Date wrapper that handles EvalIntVar arguments
754 def DateWrapper(year: Union[int, EvalIntVar], month: Union[int, EvalIntVar], day: Union[int, EvalIntVar]) -> Union[Date, EvalDateVar]:
755 # If any argument is an EvalIntVar, we need to defer evaluation
756 if isinstance(year, EvalIntVar) or isinstance(month, EvalIntVar) or isinstance(day, EvalIntVar): 756 ↛ 758line 756 didn't jump to line 758 because the condition on line 756 was never true
757 # Create a lazy EvalDateVar that will compute the date when needed
758 lazy_date = EvalDateVar(f"date_literal")
759 year_var = year
760 month_var = month
761 day_var = day
763 def get_date_value():
764 y = year_var.get_value() if isinstance(year_var, EvalIntVar) else year_var
765 m = month_var.get_value() if isinstance(month_var, EvalIntVar) else month_var
766 d = day_var.get_value() if isinstance(day_var, EvalIntVar) else day_var
767 if y is None or m is None or d is None:
768 return None
769 try:
770 return Date(int(y), int(m), int(d))
771 except ValueError:
772 return None
774 lazy_date.get_value = get_date_value # type: ignore
775 return lazy_date
776 else:
777 # All concrete values, can create Date immediately
778 return Date(int(year), int(month), int(day))
780 return {
781 "Date": DateWrapper,
782 "Period": Period,
783 "DateSATBuilder": lambda: self,
784 "builder": self,
785 "result": self,
786 "__builtins__": {**builtins.__dict__, "__import__": mock_import},
787 "And": self.And,
788 "Or": self.Or,
789 "Not": self.Not,
790 "Implies": self.Implies,
791 }
794def _parse_date_string(date_str: str) -> Date:
795 date_str = date_str.strip()
796 if date_str.startswith("Date(") and date_str.endswith(")"):
797 inner = date_str[len("Date(") : -1]
798 parts = [p.strip() for p in inner.split(",")]
799 if len(parts) != 3:
800 raise ValueError(f"Unrecognized Date format: {date_str}")
801 y, m, d = map(int, parts)
802 return Date(y, m, d)
803 raise ValueError(f"Unrecognized Date format: {date_str}")
806def _parse_solution_value(raw: Any) -> Any:
807 if isinstance(raw, Date):
808 return raw
809 if isinstance(raw, bool):
810 return raw
811 if isinstance(raw, int): 811 ↛ 813line 811 didn't jump to line 813 because the condition on line 811 was always true
812 return raw
813 if isinstance(raw, str):
814 s = raw.strip()
815 # Date
816 if s.startswith("Date("):
817 return _parse_date_string(s)
818 # Bool-ish
819 ls = s.lower()
820 if ls in ("true", "1", "yes", "y", "t"):
821 return True
822 if ls in ("false", "0", "no", "n", "f"):
823 return False
824 # Int
825 try:
826 return int(s)
827 except Exception:
828 pass
829 return raw
832def validate_constraint_solution(
833 constraint_code: str, solution: Dict[str, Any]
834) -> Tuple[bool, str]:
835 """
836 Execute constraint_code with a validation-only builder and evaluate
837 the provided concrete solution.
838 """
839 # Reset global out-of-bounds flag for this validation run
840 global _OUT_OF_BOUNDS_USED
841 _OUT_OF_BOUNDS_USED = False
843 # Suppress warnings from datesat.core.Date operations during constraint code execution.
844 # We only care about the _OUT_OF_BOUNDS_USED flag set by EvalDateVar, not warnings
845 # from literal Date expressions in the constraint code (like Date(2042, 12, 18) + Period(...)).
846 with warnings.catch_warnings():
847 warnings.filterwarnings("ignore", message="Intermediate date computation.*", category=UserWarning)
849 builder = EvalBuilder()
850 ctx = builder.get_execution_context()
852 try:
853 exec(constraint_code, ctx)
854 except Exception as e:
855 return False, f"Error executing constraint code: {e}"
857 solver = ctx.get("result") or ctx.get("builder") or builder
859 # Set values
860 for var_name, raw_val in solution.items():
861 parsed = _parse_solution_value(raw_val)
862 if var_name in solver.date_vars:
863 if not isinstance(parsed, Date): 863 ↛ 864line 863 didn't jump to line 864 because the condition on line 863 was never true
864 return False, f"Variable {var_name} expects Date, got {parsed}"
865 solver.date_vars[var_name].set_value(parsed.year, parsed.month, parsed.day)
866 elif var_name in solver.int_vars:
867 if not isinstance(parsed, int): 867 ↛ 868line 867 didn't jump to line 868 because the condition on line 867 was never true
868 return False, f"Variable {var_name} expects int, got {parsed}"
869 solver.int_vars[var_name].set_value(parsed)
870 elif var_name in solver.bool_vars: 870 ↛ 876line 870 didn't jump to line 876 because the condition on line 870 was always true
871 if not isinstance(parsed, bool): 871 ↛ 872line 871 didn't jump to line 872 because the condition on line 871 was never true
872 return False, f"Variable {var_name} expects bool, got {parsed}"
873 solver.bool_vars[var_name].set_value(parsed)
874 else:
875 # Ignore unknown variables; they might be unused
876 continue
878 # Evaluate constraints
879 validation_failed = False
880 failure_message = None
881 try:
882 for c in solver.constraints:
883 if isinstance(c, bool): 883 ↛ 884line 883 didn't jump to line 884 because the condition on line 883 was never true
884 if not c:
885 validation_failed = True
886 failure_message = "Constraint evaluated False"
887 break
888 elif isinstance(c, ConstraintWrapper): 888 ↛ 893line 888 didn't jump to line 893 because the condition on line 888 was always true
889 if not c.evaluate():
890 validation_failed = True
891 failure_message = "Constraint evaluated False"
892 break
893 elif callable(c):
894 if not c():
895 validation_failed = True
896 failure_message = "Constraint evaluated False"
897 break
898 else:
899 if not bool(c):
900 validation_failed = True
901 failure_message = "Constraint evaluated False"
902 break
903 except Exception as e:
904 validation_failed = True
905 failure_message = f"Error during constraint evaluation: {e}"
907 # If validation failed, return False with appropriate message
908 if validation_failed:
909 if _OUT_OF_BOUNDS_USED: 909 ↛ 910line 909 didn't jump to line 910 because the condition on line 909 was never true
910 return False, f"{failure_message} (with warning: Date outside allowed range encountered during intermediate computation)"
911 return False, failure_message
913 # If any intermediate date went outside the supported range during evaluation,
914 # include warning info in the message. The higher-level summary code will classify
915 # these as "warning_correct" or "warning_wrong" based on whether validation actually succeeded.
916 if _OUT_OF_BOUNDS_USED: 916 ↛ 917line 916 didn't jump to line 917 because the condition on line 916 was never true
917 return (
918 True,
919 "Solution validated successfully (with warning: Date outside allowed range encountered during intermediate computation)",
920 )
922 return True, "Solution validated successfully"