pycsp3-scheduling 0.2.1__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,865 @@
1
+ """
2
+ Sequence accessor expressions for scheduling models.
3
+
4
+ These functions return expressions that access properties of neighboring
5
+ intervals in a sequence relative to a given interval.
6
+
7
+ The key functions for building transition-based objectives are:
8
+ - next_arg(sequence, interval, last_value, absent_value)
9
+ - prev_arg(sequence, interval, first_value, absent_value)
10
+
11
+ These return pycsp3 variables that can be used to index into transition matrices.
12
+ Similar to pycsp3's maximum_arg/minimum_arg pattern.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from typing import TYPE_CHECKING, Any
18
+
19
+ from pycsp3_scheduling.expressions.interval_expr import ExprType, IntervalExpr
20
+
21
+ if TYPE_CHECKING:
22
+ from pycsp3_scheduling.variables.interval import IntervalVar
23
+ from pycsp3_scheduling.variables.sequence import SequenceVar
24
+
25
+
26
+ # Cache for next_arg/prev_arg variables to avoid duplication
27
+ _next_arg_vars: dict[tuple[int, int], Any] = {}
28
+ _prev_arg_vars: dict[tuple[int, int], Any] = {}
29
+ _sequence_position_vars: dict[int, list[Any]] = {}
30
+ _sequence_present_count_vars: dict[int, Any] = {}
31
+
32
+
33
+ def clear_sequence_expr_cache() -> None:
34
+ """Clear cached sequence expression variables."""
35
+ _next_arg_vars.clear()
36
+ _prev_arg_vars.clear()
37
+ _sequence_position_vars.clear()
38
+ _sequence_present_count_vars.clear()
39
+
40
+
41
+ def _validate_sequence_and_interval(sequence, interval: IntervalVar) -> tuple[list, int]:
42
+ """Validate inputs and return intervals list and index."""
43
+ from pycsp3_scheduling.variables.interval import IntervalVar
44
+ from pycsp3_scheduling.variables.sequence import SequenceVar
45
+
46
+ if isinstance(sequence, SequenceVar):
47
+ intervals = sequence.intervals
48
+ elif isinstance(sequence, (list, tuple)):
49
+ intervals = list(sequence)
50
+ else:
51
+ raise TypeError(
52
+ f"sequence must be a SequenceVar or list, got {type(sequence).__name__}"
53
+ )
54
+
55
+ if not isinstance(interval, IntervalVar):
56
+ raise TypeError(
57
+ f"interval must be an IntervalVar, got {type(interval).__name__}"
58
+ )
59
+
60
+ try:
61
+ idx = intervals.index(interval)
62
+ except ValueError:
63
+ raise ValueError(f"interval '{interval.name}' is not in the sequence")
64
+
65
+ return intervals, idx
66
+
67
+
68
+ def _ensure_sequence_positions(sequence: SequenceVar) -> tuple[list[Any], Any]:
69
+ """
70
+ Create (or reuse) position variables and ordering constraints for a sequence.
71
+
72
+ This function creates position variables that track the order of intervals
73
+ in a sequence. For optional intervals, position 0 indicates absence.
74
+ """
75
+ if sequence._id in _sequence_position_vars:
76
+ return (
77
+ _sequence_position_vars[sequence._id],
78
+ _sequence_present_count_vars[sequence._id],
79
+ )
80
+
81
+ try:
82
+ from pycsp3 import AllDifferent, Var, satisfy
83
+ from pycsp3.classes.nodes import Node, TypeNode
84
+ except ImportError:
85
+ raise ImportError("pycsp3 is required for sequence position variables")
86
+
87
+ from pycsp3_scheduling.constraints._pycsp3 import (
88
+ start_var,
89
+ length_value,
90
+ presence_var,
91
+ )
92
+
93
+ intervals = sequence.intervals
94
+ n = len(intervals)
95
+
96
+ if n == 0:
97
+ # Empty sequence - no constraints needed
98
+ count_var = Var(dom={0}, id=f"seqcount{sequence._id}")
99
+ satisfy(count_var == 0)
100
+ _sequence_position_vars[sequence._id] = []
101
+ _sequence_present_count_vars[sequence._id] = count_var
102
+ return [], count_var
103
+
104
+ positions: list[Any] = []
105
+ presences: list[Any] = []
106
+ has_optional = False
107
+
108
+ for interval in intervals:
109
+ pres = presence_var(interval) if interval.optional else 1
110
+ presences.append(pres)
111
+
112
+ if interval.optional:
113
+ has_optional = True
114
+ pos_dom = range(0, n + 1) # 0 = absent
115
+ else:
116
+ pos_dom = range(1, n + 1)
117
+
118
+ pos_var = Var(dom=pos_dom, id=f"seqpos{sequence._id}_{interval._id}")
119
+ positions.append(pos_var)
120
+
121
+ if interval.optional:
122
+ # Presence <-> position != 0 (bidirectional channeling)
123
+ # present=1 => pos != 0, and pos != 0 => present=1
124
+ satisfy(
125
+ Node.build(
126
+ TypeNode.OR,
127
+ Node.build(TypeNode.EQ, pres, 1),
128
+ Node.build(TypeNode.EQ, pos_var, 0),
129
+ )
130
+ )
131
+ satisfy(
132
+ Node.build(
133
+ TypeNode.OR,
134
+ Node.build(TypeNode.NE, pos_var, 0),
135
+ Node.build(TypeNode.EQ, pres, 0),
136
+ )
137
+ )
138
+
139
+ # Count of present intervals
140
+ count_var = Var(dom=range(0, n + 1), id=f"seqcount{sequence._id}")
141
+ if len(presences) == 1:
142
+ sum_presences = presences[0]
143
+ else:
144
+ sum_presences = Node.build(TypeNode.ADD, *presences)
145
+ satisfy(Node.build(TypeNode.EQ, count_var, sum_presences))
146
+
147
+ # Present intervals must occupy positions 1..count_var (no gaps)
148
+ for interval, pos_var, pres in zip(intervals, positions, presences):
149
+ if interval.optional:
150
+ satisfy(
151
+ Node.build(
152
+ TypeNode.OR,
153
+ Node.build(TypeNode.EQ, pres, 0),
154
+ Node.build(TypeNode.LE, pos_var, count_var),
155
+ )
156
+ )
157
+ else:
158
+ satisfy(Node.build(TypeNode.LE, pos_var, count_var))
159
+
160
+ # All-different positions for present intervals
161
+ # Use native AllDifferent constraint instead of O(n²) pairwise decomposition
162
+ # excepting=0 allows multiple intervals to have position 0 (absent)
163
+ if has_optional:
164
+ # With optional intervals, use AllDifferent with excepting=0
165
+ # This allows multiple absent intervals (position=0) while ensuring
166
+ # all present intervals have unique positions
167
+ satisfy(AllDifferent(positions, excepting=0))
168
+ else:
169
+ # All mandatory: simple AllDifferent
170
+ satisfy(AllDifferent(positions))
171
+
172
+ # Link temporal order to positions
173
+ # Pre-compute start and end expressions once
174
+ starts = [start_var(interval) for interval in intervals]
175
+ ends: list[Any] = []
176
+ for interval, start in zip(intervals, starts):
177
+ length = length_value(interval)
178
+ if isinstance(length, int):
179
+ end = Node.build(TypeNode.ADD, start, length) if length > 0 else start
180
+ else:
181
+ end = Node.build(TypeNode.ADD, start, length)
182
+ ends.append(end)
183
+
184
+ # Temporal ordering constraint: if i ends before j starts, then pos[i] < pos[j]
185
+ # Formulated as: (start[j] < end[i]) OR (pos[i] < pos[j]) OR absent conditions
186
+ for i in range(n):
187
+ for j in range(n):
188
+ if i == j:
189
+ continue
190
+ disjuncts = [
191
+ Node.build(TypeNode.LT, starts[j], ends[i]),
192
+ Node.build(TypeNode.LT, positions[i], positions[j]),
193
+ ]
194
+ if intervals[i].optional:
195
+ disjuncts.insert(0, Node.build(TypeNode.EQ, presences[i], 0))
196
+ if intervals[j].optional:
197
+ disjuncts.insert(0, Node.build(TypeNode.EQ, presences[j], 0))
198
+ satisfy(Node.build(TypeNode.OR, *disjuncts))
199
+
200
+ _sequence_position_vars[sequence._id] = positions
201
+ _sequence_present_count_vars[sequence._id] = count_var
202
+ return positions, count_var
203
+
204
+
205
+ # =============================================================================
206
+ # Next Interval Accessors
207
+ # =============================================================================
208
+
209
+
210
+ def start_of_next(
211
+ sequence,
212
+ interval: IntervalVar,
213
+ last_value: int = 0,
214
+ absent_value: int = 0,
215
+ ) -> IntervalExpr:
216
+ """
217
+ Return the start time of the next interval in the sequence.
218
+
219
+ If the given interval is last in the sequence, returns last_value.
220
+ If the given interval is absent, returns absent_value.
221
+
222
+ Args:
223
+ sequence: SequenceVar or list of IntervalVar.
224
+ interval: The reference interval.
225
+ last_value: Value when interval is last (default: 0).
226
+ absent_value: Value when interval is absent (default: 0).
227
+
228
+ Returns:
229
+ An expression representing the start of the next interval.
230
+
231
+ Example:
232
+ >>> seq = SequenceVar(intervals=[t1, t2, t3], name="machine")
233
+ >>> expr = start_of_next(seq, t1) # Returns start of t2 (or next in order)
234
+ """
235
+ intervals, idx = _validate_sequence_and_interval(sequence, interval)
236
+ return IntervalExpr(
237
+ expr_type=ExprType.START_OF, # Placeholder - actual logic in evaluation
238
+ interval=interval,
239
+ absent_value=absent_value,
240
+ value=last_value, # Store last_value for evaluation
241
+ )
242
+
243
+
244
+ def end_of_next(
245
+ sequence,
246
+ interval: IntervalVar,
247
+ last_value: int = 0,
248
+ absent_value: int = 0,
249
+ ) -> IntervalExpr:
250
+ """
251
+ Return the end time of the next interval in the sequence.
252
+
253
+ If the given interval is last in the sequence, returns last_value.
254
+ If the given interval is absent, returns absent_value.
255
+
256
+ Args:
257
+ sequence: SequenceVar or list of IntervalVar.
258
+ interval: The reference interval.
259
+ last_value: Value when interval is last (default: 0).
260
+ absent_value: Value when interval is absent (default: 0).
261
+
262
+ Returns:
263
+ An expression representing the end of the next interval.
264
+
265
+ Example:
266
+ >>> seq = SequenceVar(intervals=[t1, t2, t3], name="machine")
267
+ >>> expr = end_of_next(seq, t1) # Returns end of next interval after t1
268
+ """
269
+ intervals, idx = _validate_sequence_and_interval(sequence, interval)
270
+ return IntervalExpr(
271
+ expr_type=ExprType.END_OF,
272
+ interval=interval,
273
+ absent_value=absent_value,
274
+ value=last_value,
275
+ )
276
+
277
+
278
+ def size_of_next(
279
+ sequence,
280
+ interval: IntervalVar,
281
+ last_value: int = 0,
282
+ absent_value: int = 0,
283
+ ) -> IntervalExpr:
284
+ """
285
+ Return the size (duration) of the next interval in the sequence.
286
+
287
+ If the given interval is last in the sequence, returns last_value.
288
+ If the given interval is absent, returns absent_value.
289
+
290
+ Args:
291
+ sequence: SequenceVar or list of IntervalVar.
292
+ interval: The reference interval.
293
+ last_value: Value when interval is last (default: 0).
294
+ absent_value: Value when interval is absent (default: 0).
295
+
296
+ Returns:
297
+ An expression representing the size of the next interval.
298
+ """
299
+ intervals, idx = _validate_sequence_and_interval(sequence, interval)
300
+ return IntervalExpr(
301
+ expr_type=ExprType.SIZE_OF,
302
+ interval=interval,
303
+ absent_value=absent_value,
304
+ value=last_value,
305
+ )
306
+
307
+
308
+ def length_of_next(
309
+ sequence,
310
+ interval: IntervalVar,
311
+ last_value: int = 0,
312
+ absent_value: int = 0,
313
+ ) -> IntervalExpr:
314
+ """
315
+ Return the length of the next interval in the sequence.
316
+
317
+ If the given interval is last in the sequence, returns last_value.
318
+ If the given interval is absent, returns absent_value.
319
+
320
+ Args:
321
+ sequence: SequenceVar or list of IntervalVar.
322
+ interval: The reference interval.
323
+ last_value: Value when interval is last (default: 0).
324
+ absent_value: Value when interval is absent (default: 0).
325
+
326
+ Returns:
327
+ An expression representing the length of the next interval.
328
+ """
329
+ intervals, idx = _validate_sequence_and_interval(sequence, interval)
330
+ return IntervalExpr(
331
+ expr_type=ExprType.LENGTH_OF,
332
+ interval=interval,
333
+ absent_value=absent_value,
334
+ value=last_value,
335
+ )
336
+
337
+
338
+ def next_arg(
339
+ sequence,
340
+ interval: IntervalVar,
341
+ last_value: int = 0,
342
+ absent_value: int = 0,
343
+ ) -> Any:
344
+ """
345
+ Return a variable representing the ID of the next interval in the sequence.
346
+
347
+ Similar to pycsp3's maximum_arg pattern, this returns the argument (ID)
348
+ of the successor interval. Used for building transition-based objectives.
349
+
350
+ Requires a SequenceVar with types (IDs) defined.
351
+
352
+ Semantics:
353
+ - If the interval is present and not last: returns the ID of the next interval
354
+ - If the interval is present and last: returns last_value
355
+ - If the interval is absent: returns absent_value
356
+
357
+ Args:
358
+ sequence: SequenceVar with types (IDs) defined.
359
+ interval: The reference interval.
360
+ last_value: Value when interval is last (default: 0).
361
+ absent_value: Value when interval is absent (default: 0).
362
+
363
+ Returns:
364
+ A pycsp3 variable representing the ID of the next interval.
365
+ This variable can be used to index into arrays/matrices.
366
+
367
+ Raises:
368
+ TypeError: If sequence is not a SequenceVar or has no types.
369
+
370
+ Example:
371
+ >>> seq = SequenceVar(intervals=[t1, t2, t3], types=[0, 1, 2], name="machine")
372
+ >>> next_id = next_arg(seq, t1, last_value=3, absent_value=4)
373
+ >>> # If t2 follows t1 in the schedule, next_id == 1
374
+ >>> # If t1 is last, next_id == 3
375
+ >>> # If t1 is absent, next_id == 4
376
+ >>>
377
+ >>> # Use with ElementMatrix for distance objective:
378
+ >>> M = ElementMatrix(travel_times, last_value=depot_return)
379
+ >>> cost = M[current_id, next_id]
380
+ """
381
+ from pycsp3_scheduling.variables.sequence import SequenceVar
382
+
383
+ if not isinstance(sequence, SequenceVar):
384
+ raise TypeError("next_arg requires a SequenceVar")
385
+ if not sequence.has_types:
386
+ raise ValueError("next_arg requires sequence with types defined")
387
+
388
+ intervals, idx = _validate_sequence_and_interval(sequence, interval)
389
+
390
+ # Check cache
391
+ cache_key = (sequence._id, interval._id)
392
+ if cache_key in _next_arg_vars:
393
+ return _next_arg_vars[cache_key]
394
+
395
+ # Build the next_arg variable and constraints
396
+ var = _build_next_arg_var(sequence, interval, idx, last_value, absent_value)
397
+ _next_arg_vars[cache_key] = var
398
+ return var
399
+
400
+
401
+ # Backward compatibility alias
402
+ type_of_next = next_arg
403
+
404
+
405
+ def _build_next_arg_var(
406
+ sequence: SequenceVar,
407
+ interval: IntervalVar,
408
+ idx: int,
409
+ last_value: int,
410
+ absent_value: int,
411
+ ) -> Any:
412
+ """
413
+ Build a pycsp3 variable for next_arg with appropriate constraints.
414
+
415
+ Successor-variable encoding using position variables:
416
+ - Each interval has a position (0 if absent, otherwise 1..m).
417
+ - The successor index is the interval at position +1, or a last/absent marker.
418
+ - Use an element constraint to map successor index to ID values.
419
+ """
420
+ try:
421
+ from pycsp3 import Var, satisfy
422
+ from pycsp3.classes.nodes import Node, TypeNode
423
+ except ImportError:
424
+ raise ImportError("pycsp3 is required for next_arg")
425
+
426
+ from pycsp3_scheduling.constraints._pycsp3 import (
427
+ presence_var,
428
+ )
429
+ from pycsp3_scheduling.expressions.element import element
430
+
431
+ intervals = sequence.intervals
432
+ types = sequence.types
433
+ n = len(intervals)
434
+
435
+ # Build extended types array: [type_0, ..., type_{n-1}, last_value, absent_value]
436
+ last_idx = n
437
+ absent_idx = n + 1
438
+ types_extended = list(types) + [last_value, absent_value]
439
+
440
+ # Successor index variable (interval index, last, absent)
441
+ next_idx_domain = set(range(n)) - {idx}
442
+ next_idx_domain.add(last_idx)
443
+ if interval.optional:
444
+ next_idx_domain.add(absent_idx)
445
+
446
+ next_idx = Var(dom=next_idx_domain, id=f"succ{sequence._id}_{interval._id}")
447
+
448
+ # Result variable mapped from successor index
449
+ result_domain = set(types_extended[j] for j in next_idx_domain)
450
+ result_var = Var(dom=result_domain, id=f"tonext{sequence._id}_{interval._id}")
451
+ satisfy(result_var == element(types_extended, next_idx))
452
+
453
+ # Position-based successor channeling
454
+ positions, count_var = _ensure_sequence_positions(sequence)
455
+ pos_i = positions[idx]
456
+ pres_i = presence_var(interval) if interval.optional else 1
457
+ pos_i_plus_1 = Node.build(TypeNode.ADD, pos_i, 1)
458
+
459
+ if interval.optional:
460
+ # Absent <-> successor is absent marker
461
+ satisfy(
462
+ Node.build(
463
+ TypeNode.OR,
464
+ Node.build(TypeNode.EQ, pres_i, 1),
465
+ Node.build(TypeNode.EQ, next_idx, absent_idx),
466
+ )
467
+ )
468
+ satisfy(
469
+ Node.build(
470
+ TypeNode.OR,
471
+ Node.build(TypeNode.NE, next_idx, absent_idx),
472
+ Node.build(TypeNode.EQ, pres_i, 0),
473
+ )
474
+ )
475
+
476
+ # Last position <-> successor is last marker
477
+ if interval.optional:
478
+ satisfy(
479
+ Node.build(
480
+ TypeNode.OR,
481
+ Node.build(TypeNode.EQ, pres_i, 0),
482
+ Node.build(TypeNode.NE, pos_i, count_var),
483
+ Node.build(TypeNode.EQ, next_idx, last_idx),
484
+ )
485
+ )
486
+ satisfy(
487
+ Node.build(
488
+ TypeNode.OR,
489
+ Node.build(TypeNode.NE, next_idx, last_idx),
490
+ Node.build(TypeNode.EQ, pres_i, 1),
491
+ )
492
+ )
493
+ else:
494
+ satisfy(
495
+ Node.build(
496
+ TypeNode.OR,
497
+ Node.build(TypeNode.NE, pos_i, count_var),
498
+ Node.build(TypeNode.EQ, next_idx, last_idx),
499
+ )
500
+ )
501
+
502
+ satisfy(
503
+ Node.build(
504
+ TypeNode.OR,
505
+ Node.build(TypeNode.NE, next_idx, last_idx),
506
+ Node.build(TypeNode.EQ, pos_i, count_var),
507
+ )
508
+ )
509
+
510
+ # Successor mapping: pos_j == pos_i + 1 <-> next_idx = j
511
+ for j in range(n):
512
+ if j == idx:
513
+ continue
514
+ pos_j = positions[j]
515
+
516
+ # next_idx = j => pos_j = pos_i + 1
517
+ satisfy(
518
+ Node.build(
519
+ TypeNode.OR,
520
+ Node.build(TypeNode.NE, next_idx, j),
521
+ Node.build(TypeNode.EQ, pos_j, pos_i_plus_1),
522
+ )
523
+ )
524
+
525
+ # If i is present and pos_j = pos_i + 1, then next_idx = j
526
+ if interval.optional:
527
+ satisfy(
528
+ Node.build(
529
+ TypeNode.OR,
530
+ Node.build(TypeNode.EQ, pres_i, 0),
531
+ Node.build(TypeNode.NE, pos_j, pos_i_plus_1),
532
+ Node.build(TypeNode.EQ, next_idx, j),
533
+ )
534
+ )
535
+ else:
536
+ satisfy(
537
+ Node.build(
538
+ TypeNode.OR,
539
+ Node.build(TypeNode.NE, pos_j, pos_i_plus_1),
540
+ Node.build(TypeNode.EQ, next_idx, j),
541
+ )
542
+ )
543
+
544
+ return result_var
545
+
546
+
547
+ # =============================================================================
548
+ # Previous Interval Accessors
549
+ # =============================================================================
550
+
551
+
552
+ def start_of_prev(
553
+ sequence,
554
+ interval: IntervalVar,
555
+ first_value: int = 0,
556
+ absent_value: int = 0,
557
+ ) -> IntervalExpr:
558
+ """
559
+ Return the start time of the previous interval in the sequence.
560
+
561
+ If the given interval is first in the sequence, returns first_value.
562
+ If the given interval is absent, returns absent_value.
563
+
564
+ Args:
565
+ sequence: SequenceVar or list of IntervalVar.
566
+ interval: The reference interval.
567
+ first_value: Value when interval is first (default: 0).
568
+ absent_value: Value when interval is absent (default: 0).
569
+
570
+ Returns:
571
+ An expression representing the start of the previous interval.
572
+
573
+ Example:
574
+ >>> seq = SequenceVar(intervals=[t1, t2, t3], name="machine")
575
+ >>> expr = start_of_prev(seq, t2) # Returns start of t1 (or prev in order)
576
+ """
577
+ intervals, idx = _validate_sequence_and_interval(sequence, interval)
578
+ return IntervalExpr(
579
+ expr_type=ExprType.START_OF,
580
+ interval=interval,
581
+ absent_value=absent_value,
582
+ value=first_value,
583
+ )
584
+
585
+
586
+ def end_of_prev(
587
+ sequence,
588
+ interval: IntervalVar,
589
+ first_value: int = 0,
590
+ absent_value: int = 0,
591
+ ) -> IntervalExpr:
592
+ """
593
+ Return the end time of the previous interval in the sequence.
594
+
595
+ If the given interval is first in the sequence, returns first_value.
596
+ If the given interval is absent, returns absent_value.
597
+
598
+ Args:
599
+ sequence: SequenceVar or list of IntervalVar.
600
+ interval: The reference interval.
601
+ first_value: Value when interval is first (default: 0).
602
+ absent_value: Value when interval is absent (default: 0).
603
+
604
+ Returns:
605
+ An expression representing the end of the previous interval.
606
+ """
607
+ intervals, idx = _validate_sequence_and_interval(sequence, interval)
608
+ return IntervalExpr(
609
+ expr_type=ExprType.END_OF,
610
+ interval=interval,
611
+ absent_value=absent_value,
612
+ value=first_value,
613
+ )
614
+
615
+
616
+ def size_of_prev(
617
+ sequence,
618
+ interval: IntervalVar,
619
+ first_value: int = 0,
620
+ absent_value: int = 0,
621
+ ) -> IntervalExpr:
622
+ """
623
+ Return the size (duration) of the previous interval in the sequence.
624
+
625
+ If the given interval is first in the sequence, returns first_value.
626
+ If the given interval is absent, returns absent_value.
627
+
628
+ Args:
629
+ sequence: SequenceVar or list of IntervalVar.
630
+ interval: The reference interval.
631
+ first_value: Value when interval is first (default: 0).
632
+ absent_value: Value when interval is absent (default: 0).
633
+
634
+ Returns:
635
+ An expression representing the size of the previous interval.
636
+ """
637
+ intervals, idx = _validate_sequence_and_interval(sequence, interval)
638
+ return IntervalExpr(
639
+ expr_type=ExprType.SIZE_OF,
640
+ interval=interval,
641
+ absent_value=absent_value,
642
+ value=first_value,
643
+ )
644
+
645
+
646
+ def length_of_prev(
647
+ sequence,
648
+ interval: IntervalVar,
649
+ first_value: int = 0,
650
+ absent_value: int = 0,
651
+ ) -> IntervalExpr:
652
+ """
653
+ Return the length of the previous interval in the sequence.
654
+
655
+ If the given interval is first in the sequence, returns first_value.
656
+ If the given interval is absent, returns absent_value.
657
+
658
+ Args:
659
+ sequence: SequenceVar or list of IntervalVar.
660
+ interval: The reference interval.
661
+ first_value: Value when interval is first (default: 0).
662
+ absent_value: Value when interval is absent (default: 0).
663
+
664
+ Returns:
665
+ An expression representing the length of the previous interval.
666
+ """
667
+ intervals, idx = _validate_sequence_and_interval(sequence, interval)
668
+ return IntervalExpr(
669
+ expr_type=ExprType.LENGTH_OF,
670
+ interval=interval,
671
+ absent_value=absent_value,
672
+ value=first_value,
673
+ )
674
+
675
+
676
+ def prev_arg(
677
+ sequence,
678
+ interval: IntervalVar,
679
+ first_value: int = 0,
680
+ absent_value: int = 0,
681
+ ) -> Any:
682
+ """
683
+ Return a variable representing the ID of the previous interval in the sequence.
684
+
685
+ Similar to pycsp3's maximum_arg pattern, this returns the argument (ID)
686
+ of the predecessor interval. Used for building transition-based objectives.
687
+
688
+ Requires a SequenceVar with types (IDs) defined.
689
+
690
+ Semantics:
691
+ - If the interval is present and not first: returns the ID of the previous interval
692
+ - If the interval is present and first: returns first_value
693
+ - If the interval is absent: returns absent_value
694
+
695
+ Args:
696
+ sequence: SequenceVar with types (IDs) defined.
697
+ interval: The reference interval.
698
+ first_value: Value when interval is first (default: 0).
699
+ absent_value: Value when interval is absent (default: 0).
700
+
701
+ Returns:
702
+ A pycsp3 variable representing the ID of the previous interval.
703
+
704
+ Raises:
705
+ TypeError: If sequence is not a SequenceVar or has no types.
706
+ """
707
+ from pycsp3_scheduling.variables.sequence import SequenceVar
708
+
709
+ if not isinstance(sequence, SequenceVar):
710
+ raise TypeError("prev_arg requires a SequenceVar")
711
+ if not sequence.has_types:
712
+ raise ValueError("prev_arg requires sequence with types defined")
713
+
714
+ intervals, idx = _validate_sequence_and_interval(sequence, interval)
715
+
716
+ # Check cache
717
+ cache_key = (sequence._id, interval._id)
718
+ if cache_key in _prev_arg_vars:
719
+ return _prev_arg_vars[cache_key]
720
+
721
+ # Build the prev_arg variable and constraints
722
+ var = _build_prev_arg_var(sequence, interval, idx, first_value, absent_value)
723
+ _prev_arg_vars[cache_key] = var
724
+ return var
725
+
726
+
727
+ # Backward compatibility alias
728
+ type_of_prev = prev_arg
729
+
730
+
731
+ def _build_prev_arg_var(
732
+ sequence: SequenceVar,
733
+ interval: IntervalVar,
734
+ idx: int,
735
+ first_value: int,
736
+ absent_value: int,
737
+ ) -> Any:
738
+ """
739
+ Build a pycsp3 variable for prev_arg with appropriate constraints.
740
+ """
741
+ try:
742
+ from pycsp3 import Var, satisfy
743
+ from pycsp3.classes.nodes import Node, TypeNode
744
+ except ImportError:
745
+ raise ImportError("pycsp3 is required for prev_arg")
746
+
747
+ from pycsp3_scheduling.constraints._pycsp3 import (
748
+ presence_var,
749
+ )
750
+ from pycsp3_scheduling.expressions.element import element
751
+
752
+ intervals = sequence.intervals
753
+ types = sequence.types
754
+ n = len(intervals)
755
+
756
+ # Build extended types array: [type_0, ..., type_{n-1}, first_value, absent_value]
757
+ first_idx = n
758
+ absent_idx = n + 1
759
+ types_extended = list(types) + [first_value, absent_value]
760
+
761
+ # Predecessor index variable (interval index, first, absent)
762
+ prev_idx_domain = set(range(n)) - {idx}
763
+ prev_idx_domain.add(first_idx)
764
+ if interval.optional:
765
+ prev_idx_domain.add(absent_idx)
766
+
767
+ prev_idx = Var(dom=prev_idx_domain, id=f"pred{sequence._id}_{interval._id}")
768
+
769
+ # Result variable mapped from predecessor index
770
+ result_domain = set(types_extended[j] for j in prev_idx_domain)
771
+ result_var = Var(dom=result_domain, id=f"toprev{sequence._id}_{interval._id}")
772
+ satisfy(result_var == element(types_extended, prev_idx))
773
+
774
+ # Position-based predecessor channeling
775
+ positions, _count_var = _ensure_sequence_positions(sequence)
776
+ pos_i = positions[idx]
777
+ pres_i = presence_var(interval) if interval.optional else 1
778
+ pos_i_minus_1 = Node.build(TypeNode.ADD, pos_i, -1)
779
+
780
+ if interval.optional:
781
+ # Absent <-> predecessor is absent marker
782
+ satisfy(
783
+ Node.build(
784
+ TypeNode.OR,
785
+ Node.build(TypeNode.EQ, pres_i, 1),
786
+ Node.build(TypeNode.EQ, prev_idx, absent_idx),
787
+ )
788
+ )
789
+ satisfy(
790
+ Node.build(
791
+ TypeNode.OR,
792
+ Node.build(TypeNode.NE, prev_idx, absent_idx),
793
+ Node.build(TypeNode.EQ, pres_i, 0),
794
+ )
795
+ )
796
+
797
+ # First position <-> predecessor is first marker
798
+ if interval.optional:
799
+ satisfy(
800
+ Node.build(
801
+ TypeNode.OR,
802
+ Node.build(TypeNode.EQ, pres_i, 0),
803
+ Node.build(TypeNode.NE, pos_i, 1),
804
+ Node.build(TypeNode.EQ, prev_idx, first_idx),
805
+ )
806
+ )
807
+ satisfy(
808
+ Node.build(
809
+ TypeNode.OR,
810
+ Node.build(TypeNode.NE, prev_idx, first_idx),
811
+ Node.build(TypeNode.EQ, pres_i, 1),
812
+ )
813
+ )
814
+ else:
815
+ satisfy(
816
+ Node.build(
817
+ TypeNode.OR,
818
+ Node.build(TypeNode.NE, pos_i, 1),
819
+ Node.build(TypeNode.EQ, prev_idx, first_idx),
820
+ )
821
+ )
822
+
823
+ satisfy(
824
+ Node.build(
825
+ TypeNode.OR,
826
+ Node.build(TypeNode.NE, prev_idx, first_idx),
827
+ Node.build(TypeNode.EQ, pos_i, 1),
828
+ )
829
+ )
830
+
831
+ # Predecessor mapping: pos_j == pos_i - 1 <-> prev_idx = j
832
+ for j in range(n):
833
+ if j == idx:
834
+ continue
835
+ pos_j = positions[j]
836
+
837
+ # prev_idx = j => pos_j = pos_i - 1
838
+ satisfy(
839
+ Node.build(
840
+ TypeNode.OR,
841
+ Node.build(TypeNode.NE, prev_idx, j),
842
+ Node.build(TypeNode.EQ, pos_j, pos_i_minus_1),
843
+ )
844
+ )
845
+
846
+ # If i is present and pos_j = pos_i - 1, then prev_idx = j
847
+ if interval.optional:
848
+ satisfy(
849
+ Node.build(
850
+ TypeNode.OR,
851
+ Node.build(TypeNode.EQ, pres_i, 0),
852
+ Node.build(TypeNode.NE, pos_j, pos_i_minus_1),
853
+ Node.build(TypeNode.EQ, prev_idx, j),
854
+ )
855
+ )
856
+ else:
857
+ satisfy(
858
+ Node.build(
859
+ TypeNode.OR,
860
+ Node.build(TypeNode.NE, pos_j, pos_i_minus_1),
861
+ Node.build(TypeNode.EQ, prev_idx, j),
862
+ )
863
+ )
864
+
865
+ return result_var