Coverage for datesat / symbolic_bitvector / epoch_days_bv.py: 83.7%

196 statements  

« prev     ^ index     » next       coverage.py v7.13.4, created at 2026-02-10 23:47 +0000

1""" 

2Epoch_days DateSAT implementation using epoch-based conversion. 

3 

4This module implements the epoch_days approach where dates are represented 

5as days since an epoch, and period arithmetic is done using approximate 

6day conversions. 

7""" 

8 

9from datetime import date, timedelta 

10from typing import List, Tuple, Union 

11 

12from z3 import ( 

13 And, 

14 BitVec, 

15 BitVecRef, 

16 BitVecVal, 

17 BoolRef, 

18 CheckSatResult, 

19 If, 

20 ModelRef, 

21 Not, 

22 Optimize, 

23 Solver, 

24 sat, 

25 unsat, 

26) 

27from ..core import Date, Period 

28from .bitwidths import LEGACY_BITS 

29from .naive_bv import ( 

30 eom_clamp, 

31 normalize_month, 

32 is_leap 

33) 

34# ------------------------------- 

35# Epoch binding: 2000-03-01 

36# ------------------------------- 

37# _ORD_EPOCH = to_ordinal(BitVecVal(2000, LEGACY_BITS), BitVecVal(3, LEGACY_BITS), BitVecVal(1, LEGACY_BITS)) # original ground Z3 term 

38_ORD_EPOCH = BitVecVal( 

39 730179, LEGACY_BITS 

40) # precomputed ordinal of 2000-03-01 (0001-01-01 = 0) 

41 

42_EPOCH = date(2000, 3, 1) 

43 

44_NONLEAP_PREFIX = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334] 

45_LEAP_PREFIX = [0, 31, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335] 

46 

47def date_from_days_since_epoch(days: int) -> Date: 

48 """Convert concrete days since epoch to a concrete Date.""" 

49 result_date = _EPOCH + timedelta(days=days) 

50 return Date(result_date.year, result_date.month, result_date.day) 

51 

52def days_since_epoch_from_date(date_obj: Date) -> int: 

53 """Convert a concrete Date to concrete days since epoch (March 1, 2000).""" 

54 target_python = date(date_obj.year, date_obj.month, date_obj.day) 

55 return (target_python - _EPOCH).days 

56 

57def add_days_ordinal(y, m, d, delta_days) -> BitVecRef: 

58 """ 

59 Exact ordinal-based addition via a single ordinal add. 

60 """ 

61 z = _days_since_epoch_from_ymd_bv(y, m, d) 

62 return z + delta_days 

63 

64def days_before_year(y) -> BitVecRef: 

65 """ 

66 Days from 0001-01-01 to Jan 1 of year y (0-based), Gregorian rules. 

67 """ 

68 y1 = y - BitVecVal(1, LEGACY_BITS) 

69 return ( 

70 BitVecVal(365, LEGACY_BITS) * y1 

71 + y1 / BitVecVal(4, LEGACY_BITS) 

72 - y1 / BitVecVal(100, LEGACY_BITS) 

73 + y1 / BitVecVal(400, LEGACY_BITS) 

74 ) 

75 

76def days_before_month(y, m) -> BitVecRef: 

77 """Z3 piecewise selection (no Python control over symbolic m).""" 

78 expr = BitVecVal(0, LEGACY_BITS) 

79 for i in range(1, 13): 

80 expr = If(m == BitVecVal(i, LEGACY_BITS), _dbm_index(y, i), expr) 

81 return expr 

82 

83def to_ordinal(y, m, d) -> BitVecRef: 

84 """Z3-pure ordinal conversion (day 0 = 0001-01-01).""" 

85 return ( 

86 days_before_year(y) + days_before_month(y, m) + (d - BitVecVal(1, LEGACY_BITS)) 

87 ) 

88 

89 

90def from_ordinal(n) -> Tuple[BitVecRef, BitVecRef, BitVecRef]: 

91 """Z3-pure ordinal to date conversion using 400/100/4/1 year block decomposition.""" 

92 # 400/100/4/1 year block decomposition 

93 D400, D100, D4, D1 = ( 

94 BitVecVal(146097, LEGACY_BITS), 

95 BitVecVal(36524, LEGACY_BITS), 

96 BitVecVal(1461, LEGACY_BITS), 

97 BitVecVal(365, LEGACY_BITS), 

98 ) 

99 q400, r400 = n / D400, n % D400 

100 

101 q100_raw = r400 / D100 

102 q100 = If( 

103 q100_raw >= BitVecVal(4, LEGACY_BITS), BitVecVal(3, LEGACY_BITS), q100_raw 

104 ) # clamp 0..3 

105 r100 = r400 - q100 * D100 

106 

107 q4, r4 = r100 / D4, r100 % D4 

108 

109 q1_raw = r4 / D1 

110 q1 = If( 

111 q1_raw >= BitVecVal(4, LEGACY_BITS), BitVecVal(3, LEGACY_BITS), q1_raw 

112 ) # clamp 0..3 

113 r1 = r4 - q1 * D1 # day-of-year (0..365) 

114 

115 year = ( 

116 q400 * BitVecVal(400, LEGACY_BITS) 

117 + q100 * BitVecVal(100, LEGACY_BITS) 

118 + q4 * BitVecVal(4, LEGACY_BITS) 

119 + q1 

120 + BitVecVal(1, LEGACY_BITS) 

121 ) 

122 

123 # month = max i with r1 >= DBM(year, i) 

124 dbm = [_dbm_index(year, i) for i in range(1, 13)] 

125 month = BitVecVal(1, LEGACY_BITS) 

126 for i in range(2, 13): 

127 month = If(r1 >= dbm[i - 1], BitVecVal(i, LEGACY_BITS), month) 

128 

129 # day = r1 - DBM(year, month) + 1 

130 day_expr = r1 - dbm[0] + BitVecVal(1, LEGACY_BITS) 

131 for i in range(2, 13): 

132 day_expr = If( 

133 r1 >= dbm[i - 1], r1 - dbm[i - 1] + BitVecVal(1, LEGACY_BITS), day_expr 

134 ) 

135 

136 return year, month, day_expr 

137 

138def _ymd_from_days_since_epoch_bv(days_term: BitVecRef) -> Tuple[BitVecRef, BitVecRef, BitVecRef]: 

139 """Decode (y,m,d) from a Z3 BitVec 'days since 2000-03-01'.""" 

140 return from_ordinal(days_term + _ORD_EPOCH) 

141 

142def _days_since_epoch_from_ymd_bv(y: BitVecRef, m: BitVecRef, d: BitVecRef) -> BitVecRef: 

143 """Encode (y,m,d) to Z3 BitVec 'days since 2000-03-01'.""" 

144 return to_ordinal(y, m, d) - _ORD_EPOCH 

145 

146def ymd_from_days_since_epoch(days_term): 

147 """ 

148 Overload: 

149 - ymd_from_days_since_epoch(int) -> Date 

150 - ymd_from_days_since_epoch(BitVecRef) -> (y,m,d) BitVecRefs 

151 """ 

152 if isinstance(days_term, int): 

153 return date_from_days_since_epoch(days_term) 

154 return _ymd_from_days_since_epoch_bv(days_term) 

155 

156def days_since_epoch_from_ymd(*args): 

157 """ 

158 Overload: 

159 - days_since_epoch_from_ymd(Date) -> int 

160 - days_since_epoch_from_ymd(y,m,d) -> BitVecRef 

161 """ 

162 if len(args) == 1 and isinstance(args[0], Date): 

163 return days_since_epoch_from_date(args[0]) 

164 if len(args) == 3: 164 ↛ 167line 164 didn't jump to line 167 because the condition on line 164 was always true

165 y, m, d = args 

166 return _days_since_epoch_from_ymd_bv(y, m, d) 

167 raise TypeError("days_since_epoch_from_ymd expects (Date) or (y, m, d)") 

168 

169def _dbm_index(y, idx) -> BitVecRef: 

170 """days_before_month for fixed idx∈{1..12} as a Z3 term.""" 

171 non = BitVecVal(_NONLEAP_PREFIX[idx - 1], LEGACY_BITS) 

172 lep = BitVecVal(_LEAP_PREFIX[idx - 1], LEGACY_BITS) 

173 return If(is_leap(y), lep, non) 

174 

175class DateVar: 

176 """Symbolic date variable for epoch_days implementation.""" 

177 

178 def __init__(self, name: str): 

179 """Create a symbolic date variable.""" 

180 self.name = name 

181 # Use a single Z3 bitvector variable for days since epoch 

182 self.days_var = BitVec(f"{name}_days", LEGACY_BITS) 

183 # Solver reference for adding bounds to intermediate dates (set after creation if needed) 

184 self._solver = None 

185 

186 def __str__(self) -> str: 

187 return f"DateVar({self.name})" 

188 

189 @property 

190 def year(self) -> BitVecRef: 

191 """Get symbolic year component (decodes from days_var).""" 

192 y, _, _ = ymd_from_days_since_epoch(self.days_var) 

193 return y 

194 

195 @property 

196 def month(self) -> BitVecRef: 

197 """Get symbolic month component (decodes from days_var).""" 

198 _, m, _ = ymd_from_days_since_epoch(self.days_var) 

199 return m 

200 

201 @property 

202 def day(self) -> BitVecRef: 

203 """Get symbolic day component (decodes from days_var).""" 

204 _, _, d = ymd_from_days_since_epoch(self.days_var) 

205 return d 

206 

207 def to_concrete_date(self, model: ModelRef) -> Date: 

208 """Convert Z3 model to concrete Date.""" 

209 days = model.evaluate(self.days_var, model_completion=True).as_signed_long() 

210 return date_from_days_since_epoch(days) 

211 

212 def __ge__(self, other) -> BoolRef: 

213 """Support x >= date comparison.""" 

214 if isinstance(other, Date): 

215 return self.days_var >= BitVecVal(days_since_epoch_from_date(other), LEGACY_BITS) 

216 elif isinstance(other, DateVar): 216 ↛ 219line 216 didn't jump to line 219 because the condition on line 216 was always true

217 return self.days_var >= other.days_var 

218 else: 

219 raise TypeError(f"Cannot compare DateVar with {type(other)}") 

220 

221 def __le__(self, other) -> BoolRef: 

222 """Support x <= date comparison.""" 

223 if isinstance(other, Date): 

224 return self.days_var <= BitVecVal(days_since_epoch_from_date(other), LEGACY_BITS) 

225 elif isinstance(other, DateVar): 225 ↛ 228line 225 didn't jump to line 228 because the condition on line 225 was always true

226 return self.days_var <= other.days_var 

227 else: 

228 raise TypeError(f"Cannot compare DateVar with {type(other)}") 

229 

230 def __lt__(self, other) -> BoolRef: 

231 """Support x < date comparison.""" 

232 if isinstance(other, (Date, DateVar)): 232 ↛ 235line 232 didn't jump to line 235 because the condition on line 232 was always true

233 return Not(self.__ge__(other)) 

234 else: 

235 raise TypeError(f"Cannot compare DateVar with {type(other)}") 

236 

237 def __gt__(self, other) -> BoolRef: 

238 """Support x > date comparison.""" 

239 if isinstance(other, (Date, DateVar)): 239 ↛ 242line 239 didn't jump to line 242 because the condition on line 239 was always true

240 return Not(self.__le__(other)) 

241 else: 

242 raise TypeError(f"Cannot compare DateVar with {type(other)}") 

243 

244 def __eq__(self, other) -> BoolRef: 

245 """Support x == date comparison.""" 

246 if isinstance(other, Date): 

247 return self.days_var == BitVecVal(days_since_epoch_from_date(other), LEGACY_BITS) 

248 elif isinstance(other, DateVar): 248 ↛ 251line 248 didn't jump to line 251 because the condition on line 248 was always true

249 return self.days_var == other.days_var 

250 else: 

251 raise TypeError(f"Cannot compare DateVar with {type(other)}") 

252 

253 def __ne__(self, other) -> BoolRef: 

254 """Support x != date comparison.""" 

255 if isinstance(other, (Date, DateVar)): 255 ↛ 258line 255 didn't jump to line 258 because the condition on line 255 was always true

256 return Not(self.__eq__(other)) 

257 else: 

258 raise TypeError(f"Cannot compare DateVar with {type(other)}") 

259 

260 def _add_bounds(self) -> None: 

261 """Add date validation bounds to this DateVar if solver is available.""" 

262 if self._solver is None: 262 ↛ 263line 262 didn't jump to line 263 because the condition on line 262 was never true

263 return 

264 

265 # Add constraints for valid date ranges [1900-03-01 to 2100-02-28] 

266 # Epoch is March 1, 2000 

267 # 1900-03-01 = -36525 days from epoch 

268 # 2100-02-28 = 36523 days from epoch 

269 self._solver.add(self.days_var >= BitVecVal(-36525, LEGACY_BITS)) 

270 self._solver.add(self.days_var <= BitVecVal(36523, LEGACY_BITS)) 

271 

272 def __add__(self, other) -> "DateVar": 

273 """ 

274 DateVar + Period following semantics. 

275 Steps: normalize Y/M, EOM clamp, then add D days in ordinal space. 

276 """ 

277 if isinstance(other, Period): 277 ↛ 316line 277 didn't jump to line 316 because the condition on line 277 was always true

278 result = DateVar(f"{self.name}_plus") 

279 

280 # Fast-path: only days component (check at Python level since Period components are concrete) 

281 if other.years == 0 and other.months == 0: 

282 days_expr = self.days_var + BitVecVal(other.days, LEGACY_BITS) 

283 else: 

284 oy, om, od = ( 

285 BitVecVal(other.years, LEGACY_BITS), 

286 BitVecVal(other.months, LEGACY_BITS), 

287 BitVecVal(other.days, LEGACY_BITS), 

288 ) 

289 

290 # Decode current date to Y/M/D 

291 y0, m0, d0 = ymd_from_days_since_epoch(self.days_var) 

292 

293 # Step 1: Combine Y and M with normalization (carry years) 

294 period_total_months = oy * BitVecVal(12, LEGACY_BITS) + om 

295 total_months = m0 + period_total_months 

296 y1, m1 = normalize_month(y0, total_months) 

297 

298 # Step 2: EOM clamp 

299 d1 = eom_clamp(y1, m1, d0) 

300 

301 if other.days == 0: 

302 # Encode back to days-since-epoch 

303 days_expr = days_since_epoch_from_ymd(y1, m1, d1) 

304 else: 

305 # Step 3: add D days in ordinal space 

306 days_expr = add_days_ordinal(y1, m1, d1, od) 

307 

308 # Direct assignment  

309 result.days_var = days_expr 

310 

311 # Add bounds to intermediate result 

312 result._solver = self._solver 

313 result._add_bounds() 

314 return result 

315 else: 

316 raise TypeError(f"Cannot add {type(other)} to DateVar") 

317 

318 def __sub__(self, other) -> "DateVar": 

319 """DateVar - Period implemented as DateVar + (-Period).""" 

320 if isinstance(other, Period): 320 ↛ 324line 320 didn't jump to line 324 because the condition on line 320 was always true

321 neg = Period(-other.years, -other.months, -other.days) 

322 return self.__add__(neg) 

323 else: 

324 raise TypeError(f"Cannot subtract {type(other)} from DateVar") 

325 

326 

327class EpochDaysSolver: 

328 """Epoch_days date constraint solver using epoch-based conversion.""" 

329 

330 def __init__(self, timeout_ms=600000, use_maxsat=False): 

331 """Initialize the solver with timeout. 

332 

333 Args: 

334 timeout_ms: Timeout in milliseconds (default: 60 seconds) 

335 use_maxsat: If True, use MaxSAT optimization with soft constraints 

336 """ 

337 self.use_maxsat = use_maxsat 

338 if use_maxsat: 338 ↛ 339line 338 didn't jump to line 339 because the condition on line 338 was never true

339 self.solver = Optimize() 

340 else: 

341 self.solver = Solver() 

342 self.solver.set("timeout", timeout_ms) 

343 self.date_vars = {} 

344 self.constraints = [] 

345 self.timeout_ms = timeout_ms 

346 

347 def add_date_var(self, name: str) -> DateVar: 

348 """Add a symbolic date variable with basic constraints.""" 

349 date_var = DateVar(name) 

350 date_var._solver = self.solver 

351 self.date_vars[name] = date_var 

352 

353 # Add bounds using _add_bounds method 

354 date_var._add_bounds() 

355 return date_var 

356 

357 def add_constraint(self, constraint: BoolRef) -> None: 

358 """Add a constraint to the solver.""" 

359 self.constraints.append(constraint) 

360 self.solver.add(constraint) 

361 

362 def check(self) -> CheckSatResult: 

363 """Check if constraints are satisfiable.""" 

364 return self.solver.check() 

365 

366 def model(self) -> ModelRef: 

367 """Get the model if satisfiable.""" 

368 return self.solver.model() 

369 

370 def get_concrete_dates(self, model: ModelRef) -> dict: 

371 """Get concrete dates from the model.""" 

372 return { 

373 name: var.to_concrete_date(model) for name, var in self.date_vars.items() 

374 } 

375 

376 def solve(self) -> Union[bool, dict]: 

377 """Solve the constraints.""" 

378 # Add MaxSAT soft constraints if enabled 

379 if self.use_maxsat: 379 ↛ 380line 379 didn't jump to line 380 because the condition on line 379 was never true

380 from datetime import date 

381 

382 today = date.today() 

383 today_days = days_since_epoch_from_date(Date.from_python_date(today)) 

384 

385 # Calculate ±50 years and ±10 years in days (approximate) 

386 # Using 365.25 days per year for accuracy 

387 days_50_years = int(50 * 365.25) 

388 days_10_years = int(10 * 365.25) 

389 

390 # Add soft constraints for each date variable 

391 for name, date_var in self.date_vars.items(): 

392 # High weight: today ± 50 years 

393 within_50_years = And( 

394 date_var.days_var 

395 >= BitVecVal(today_days - days_50_years, LEGACY_BITS), 

396 date_var.days_var 

397 <= BitVecVal(today_days + days_50_years, LEGACY_BITS), 

398 ) 

399 self.solver.add_soft(within_50_years, weight=100) 

400 

401 # Low weight: today ± 10 years 

402 within_10_years = And( 

403 date_var.days_var 

404 >= BitVecVal(today_days - days_10_years, LEGACY_BITS), 

405 date_var.days_var 

406 <= BitVecVal(today_days + days_10_years, LEGACY_BITS), 

407 ) 

408 self.solver.add_soft(within_10_years, weight=10) 

409 

410 result = self.check() 

411 if result == sat: 

412 model = self.model() 

413 return { 

414 "status": "sat", 

415 "dates": self.get_concrete_dates(model), 

416 } 

417 elif result == unsat: 417 ↛ 421line 417 didn't jump to line 421 because the condition on line 417 was always true

418 return {"status": "unsat", "dates": {}} 

419 else: 

420 # result == unknown (timeout or resource limit) 

421 return {"status": "timeout", "dates": {}} 

422 

423 def to_smt2(self) -> str: 

424 """Return the current problem in SMT-LIB v2 format.""" 

425 return self.solver.to_smt2() 

426 

427 def get_assertions(self) -> List[BoolRef]: 

428 """Return the list of current Z3 assertions (BoolRef).""" 

429 return list(self.solver.assertions())