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

1""" 

2Pure-Python constraint validation for solved assignments. 

3 

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""" 

8 

9from typing import Any, Dict, Optional, Tuple, Union 

10from dateutil.relativedelta import relativedelta 

11import datetime 

12import warnings 

13 

14from .core import Date, Period 

15from .enumeration_baseline import ( 

16 ConstraintWrapper, 

17 Or_enumeration, 

18 And_enumeration, 

19 Not_enumeration, 

20 Implies_enumeration, 

21) 

22 

23 

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 

29 

30 

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) 

36 

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 

42 

43 def clear_value(self) -> None: 

44 self._value = None 

45 

46 def _hard_reset_value(self) -> None: 

47 self._value = None 

48 

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 

73 

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 ) 

82 

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 

88 

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()) 

103 

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 ) 

108 

109 def __eq__(self, other: Union[Date, "EvalDateVar"]) -> ConstraintWrapper: # type: ignore[override] 

110 return self._cmp("eq", other) 

111 

112 def __ne__(self, other: Union[Date, "EvalDateVar"]) -> ConstraintWrapper: # type: ignore[override] 

113 return self._cmp("ne", other) 

114 

115 def __lt__(self, other: Union[Date, "EvalDateVar"]) -> ConstraintWrapper: 

116 return self._cmp("lt", other) 

117 

118 def __le__(self, other: Union[Date, "EvalDateVar"]) -> ConstraintWrapper: 

119 return self._cmp("le", other) 

120 

121 def __gt__(self, other: Union[Date, "EvalDateVar"]) -> ConstraintWrapper: 

122 return self._cmp("gt", other) 

123 

124 def __ge__(self, other: Union[Date, "EvalDateVar"]) -> ConstraintWrapper: 

125 return self._cmp("ge", other) 

126 

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 

133 

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 

140 

141 # year/month/day projections 

142 @property 

143 def year(self) -> "EvalDateComponent": 

144 return EvalDateComponent(self, "year") 

145 

146 @property 

147 def month(self) -> "EvalDateComponent": 

148 return EvalDateComponent(self, "month") 

149 

150 @property 

151 def day(self) -> "EvalDateComponent": 

152 return EvalDateComponent(self, "day") 

153 

154 

155class EvalDateComponent: 

156 def __init__(self, parent: EvalDateVar, attr: str): 

157 self.parent = parent 

158 self.attr = attr 

159 

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) 

167 

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 

173 

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 

185 

186 if other_val is None: 

187 return False 

188 

189 try: 

190 return getattr(component_val, f"__{op}__")(other_val) 

191 except (OverflowError, ZeroDivisionError, ValueError, TypeError): 

192 return False 

193 

194 return ConstraintWrapper(compare) 

195 

196 def __eq__(self, other: Union[int, "EvalIntVar", "EvalDateComponent"]) -> ConstraintWrapper: # type: ignore[override] 

197 return self._cmp("eq", other) 

198 

199 def __ne__(self, other: Union[int, "EvalIntVar", "EvalDateComponent"]) -> ConstraintWrapper: # type: ignore[override] 

200 return self._cmp("ne", other) 

201 

202 def __lt__(self, other: Union[int, "EvalIntVar", "EvalDateComponent"]) -> ConstraintWrapper: 

203 return self._cmp("lt", other) 

204 

205 def __le__(self, other: Union[int, "EvalIntVar", "EvalDateComponent"]) -> ConstraintWrapper: 

206 return self._cmp("le", other) 

207 

208 def __gt__(self, other: Union[int, "EvalIntVar", "EvalDateComponent"]) -> ConstraintWrapper: 

209 return self._cmp("gt", other) 

210 

211 def __ge__(self, other: Union[int, "EvalIntVar", "EvalDateComponent"]) -> ConstraintWrapper: 

212 return self._cmp("ge", other) 

213 

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}") 

218 

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 

242 

243 out.get_value = compute # type: ignore[attr-defined] 

244 return out 

245 

246 def __add__(self, other: Union[int, "EvalIntVar"]) -> "EvalIntVar": 

247 return self._binary_int_op("add", other) 

248 

249 def __radd__(self, other: Union[int, "EvalIntVar"]) -> "EvalIntVar": 

250 return self.__add__(other) 

251 

252 def __sub__(self, other: Union[int, "EvalIntVar"]) -> "EvalIntVar": 

253 return self._binary_int_op("sub", other) 

254 

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}") 

258 

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 

276 

277 out.get_value = compute # type: ignore[attr-defined] 

278 return out 

279 

280 def __mul__(self, other: Union[int, "EvalIntVar"]) -> "EvalIntVar": 

281 return self._binary_int_op("mul", other) 

282 

283 def __rmul__(self, other: Union[int, "EvalIntVar"]) -> "EvalIntVar": 

284 return self.__mul__(other) 

285 

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) 

290 

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}") 

294 

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 

312 

313 out.get_value = compute # type: ignore[attr-defined] 

314 return out 

315 

316 def __mod__(self, other: Union[int, "EvalIntVar"]) -> "EvalIntVar": 

317 return self._binary_int_op("mod", other) 

318 

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}") 

322 

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 

340 

341 out.get_value = compute # type: ignore[attr-defined] 

342 return out 

343 

344 

345class EvalIntVar: 

346 def __init__(self, name: str): 

347 self.name = name 

348 self._value: Optional[int] = None 

349 

350 def set_value(self, v: int) -> None: 

351 try: 

352 self._value = int(v) 

353 except Exception: 

354 self._value = None 

355 

356 def get_value(self) -> Optional[int]: 

357 return self._value 

358 

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() 

364 

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 

370 

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() 

373 

374 # Fallback: treat as a plain integer literal 

375 try: 

376 return int(other) 

377 except Exception: 

378 return None 

379 

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) 

386 

387 return ConstraintWrapper(compare, var_ref=self, rhs_ref=other) 

388 

389 def __eq__(self, other: Union[int, "EvalIntVar"]) -> ConstraintWrapper: # type: ignore[override] 

390 return self._cmp("eq", other) 

391 

392 def __ne__(self, other: Union[int, "EvalIntVar"]) -> ConstraintWrapper: # type: ignore[override] 

393 return self._cmp("ne", other) 

394 

395 def __lt__(self, other: Union[int, "EvalIntVar"]) -> ConstraintWrapper: 

396 return self._cmp("lt", other) 

397 

398 def __le__(self, other: Union[int, "EvalIntVar"]) -> ConstraintWrapper: 

399 return self._cmp("le", other) 

400 

401 def __gt__(self, other: Union[int, "EvalIntVar"]) -> ConstraintWrapper: 

402 return self._cmp("gt", other) 

403 

404 def __ge__(self, other: Union[int, "EvalIntVar"]) -> ConstraintWrapper: 

405 return self._cmp("ge", other) 

406 

407 def __add__(self, other: Union[int, "EvalIntVar", "EvalDateComponent"]) -> "EvalIntVar": 

408 out = EvalIntVar(f"{self.name}_plus") 

409 

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 

426 

427 out.get_value = compute # type: ignore 

428 return out 

429 

430 def __sub__(self, other: Union[int, "EvalIntVar"]) -> "EvalIntVar": 

431 out = EvalIntVar(f"{self.name}_minus") 

432 

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 

449 

450 out.get_value = compute # type: ignore 

451 return out 

452 

453 def __mul__(self, other: Union[int, "EvalIntVar", "EvalDateComponent"]) -> "EvalIntVar": 

454 out = EvalIntVar(f"{self.name}_times") 

455 

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 

472 

473 out.get_value = compute # type: ignore 

474 return out 

475 

476 def __floordiv__(self, other: Union[int, "EvalIntVar", "EvalDateComponent"]) -> "EvalIntVar": 

477 out = EvalIntVar(f"{self.name}_div") 

478 

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 

495 

496 out.get_value = compute # type: ignore 

497 return out 

498 

499 def __mod__(self, other: Union[int, "EvalIntVar", "EvalDateComponent"]) -> "EvalIntVar": 

500 out = EvalIntVar(f"{self.name}_mod") 

501 

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 

518 

519 out.get_value = compute # type: ignore 

520 return out 

521 

522 def __pow__(self, other: Union[int, "EvalIntVar", "EvalDateComponent"]) -> "EvalIntVar": 

523 out = EvalIntVar(f"{self.name}_pow") 

524 

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 

544 

545 out.get_value = compute # type: ignore 

546 return out 

547 

548 # Reverse operations for when int is on the left side 

549 def __radd__(self, other: int) -> "EvalIntVar": 

550 return self.__add__(other) 

551 

552 def __rsub__(self, other: int) -> "EvalIntVar": 

553 out = EvalIntVar(f"const_minus_{self.name}") 

554 

555 def compute(): 

556 rhs = self.get_value() 

557 if rhs is None: 

558 return None 

559 return int(other) - rhs 

560 

561 out.get_value = compute # type: ignore 

562 return out 

563 

564 def __rmul__(self, other: int) -> "EvalIntVar": 

565 return self.__mul__(other) 

566 

567 # Integer division using `/` in the DSL 

568 def __truediv__(self, other: Union[int, "EvalIntVar"]) -> "EvalIntVar": 

569 out = EvalIntVar(f"{self.name}_div") 

570 

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 

587 

588 out.get_value = compute # type: ignore 

589 return out 

590 

591 def __rtruediv__(self, other: int) -> "EvalIntVar": 

592 out = EvalIntVar(f"const_div_{self.name}") 

593 

594 def compute(): 

595 rhs = self.get_value() 

596 if rhs is None or rhs == 0: 

597 return None 

598 return int(other) // rhs 

599 

600 out.get_value = compute # type: ignore 

601 return out 

602 

603 def __rmod__(self, other: int) -> "EvalIntVar": 

604 out = EvalIntVar(f"const_mod_{self.name}") 

605 

606 def compute(): 

607 rhs = self.get_value() 

608 if rhs is None or rhs == 0: 

609 return None 

610 return int(other) % rhs 

611 

612 out.get_value = compute # type: ignore 

613 return out 

614 

615 def __rpow__(self, other: int) -> "EvalIntVar": 

616 out = EvalIntVar(f"const_pow_{self.name}") 

617 

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 

626 

627 out.get_value = compute # type: ignore 

628 return out 

629 

630 

631class EvalBoolVar: 

632 def __init__(self, name: str): 

633 self.name = name 

634 self._value: Optional[bool] = None 

635 

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 

652 

653 def get_value(self) -> Optional[bool]: 

654 return self._value 

655 

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 

666 

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) 

673 

674 return ConstraintWrapper(compare, var_ref=self, rhs_ref=other) 

675 

676 def __eq__(self, other: Union[bool, "EvalBoolVar", ConstraintWrapper]) -> ConstraintWrapper: # type: ignore[override] 

677 return self._cmp("eq", other) 

678 

679 def __ne__(self, other: Union[bool, "EvalBoolVar", ConstraintWrapper]) -> ConstraintWrapper: # type: ignore[override] 

680 return self._cmp("ne", other) 

681 

682 def __invert__(self) -> ConstraintWrapper: 

683 return ConstraintWrapper(lambda: not bool(self.get_value())) 

684 

685 def __and__(self, other: Any) -> ConstraintWrapper: 

686 return And_enumeration(self, other) 

687 

688 def __or__(self, other: Any) -> ConstraintWrapper: 

689 return Or_enumeration(self, other) 

690 

691 

692class EvalBuilder: 

693 """Lightweight builder used only for validation of a provided solution.""" 

694 

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 = [] 

700 

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] 

706 

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] 

711 

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] 

716 

717 # logical wrappers 

718 def Or(self, *args) -> ConstraintWrapper: 

719 return Or_enumeration(*args) 

720 

721 def And(self, *args) -> ConstraintWrapper: 

722 return And_enumeration(*args) 

723 

724 def Not(self, arg) -> ConstraintWrapper: 

725 return Not_enumeration(arg) 

726 

727 def Implies(self, antecedent, consequent) -> ConstraintWrapper: 

728 return Implies_enumeration(antecedent, consequent) 

729 

730 # constraint collector 

731 def add_constraint(self, constraint: Any) -> None: 

732 self.constraints.append(constraint) 

733 

734 # context for exec 

735 def get_execution_context(self) -> Dict[str, Any]: 

736 import builtins 

737 

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) 

745 

746 original_import = builtins.__import__ 

747 

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) 

752 

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 

762 

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 

773 

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)) 

779 

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 } 

792 

793 

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}") 

804 

805 

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 

830 

831 

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 

842 

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) 

848 

849 builder = EvalBuilder() 

850 ctx = builder.get_execution_context() 

851 

852 try: 

853 exec(constraint_code, ctx) 

854 except Exception as e: 

855 return False, f"Error executing constraint code: {e}" 

856 

857 solver = ctx.get("result") or ctx.get("builder") or builder 

858 

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 

877 

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}" 

906 

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 

912 

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 ) 

921 

922 return True, "Solution validated successfully" 

923