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,450 @@
1
+ """
2
+ Interval variable implementation for scheduling models.
3
+
4
+ An interval variable represents a task/activity with:
5
+ - start: start time (can be a range [min, max])
6
+ - end: end time (can be a range [min, max])
7
+ - size: duration/size (can be a range [min, max])
8
+ - length: length (can differ from size with intensity)
9
+ - intensity: stepwise function relating size to length
10
+ - granularity: scale for the intensity function
11
+ - optional: whether the interval can be absent
12
+ - name: identifier for the variable
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import warnings
18
+ from collections.abc import Sequence
19
+ from dataclasses import dataclass, field
20
+ from typing import Union
21
+
22
+ # Type aliases for bounds and stepwise functions
23
+ Bound = Union[int, tuple[int, int]]
24
+ Step = tuple[int, int]
25
+
26
+ # Constants for default bounds
27
+ INTERVAL_MIN = 0
28
+ INTERVAL_MAX = 2**30 - 1 # Large but not overflow-prone
29
+
30
+
31
+ @dataclass
32
+ class IntervalVar:
33
+ """
34
+ Represents an interval variable for scheduling.
35
+
36
+ An interval variable represents a task or activity with a start time,
37
+ end time, and duration. The interval can be optional (may be absent
38
+ from the solution).
39
+
40
+ Attributes:
41
+ name: Unique identifier for this interval variable.
42
+ start: Start time bound as int or (min, max) tuple.
43
+ end: End time bound as int or (min, max) tuple.
44
+ size: Duration/size bound as int or (min, max) tuple.
45
+ length: Length bound (can differ from size with intensity functions).
46
+ intensity: Stepwise function relating size to length.
47
+ granularity: Scale for the intensity function.
48
+ optional: If True, the interval may be absent from the solution.
49
+ _id: Internal unique identifier.
50
+
51
+ Note:
52
+ When using intensity functions, you should explicitly set ``length`` bounds.
53
+ At lower intensity values, more elapsed time (length) is needed to complete
54
+ the same amount of work (size). For example, at 50% intensity, a task with
55
+ size=10 needs length=20. If length is not set, it defaults to size, which
56
+ may be too restrictive. A warning is issued in this case.
57
+
58
+ Example:
59
+ >>> task = IntervalVar(size=10, name="task1")
60
+ >>> optional_task = IntervalVar(size=(5, 20), optional=True, name="opt")
61
+ >>> bounded_task = IntervalVar(start=(0, 100), end=(10, 200), size=15)
62
+ >>> # With intensity: explicitly set length bounds to allow larger values
63
+ >>> intensity = [(INTERVAL_MIN, 100), (10, 50)] # 100% until t=10, then 50%
64
+ >>> variable_rate = IntervalVar(
65
+ ... size=10,
66
+ ... length=(10, 30), # Allow length up to 30 for lower intensity
67
+ ... intensity=intensity,
68
+ ... granularity=100,
69
+ ... name="variable_rate"
70
+ ... )
71
+ """
72
+
73
+ name: str | None = None
74
+ start: Bound = field(default_factory=lambda: (INTERVAL_MIN, INTERVAL_MAX))
75
+ end: Bound = field(default_factory=lambda: (INTERVAL_MIN, INTERVAL_MAX))
76
+ size: Bound = field(default_factory=lambda: (0, INTERVAL_MAX))
77
+ length: Bound | None = None
78
+ intensity: list[Step] | None = None
79
+ granularity: int = 1
80
+ optional: bool = False
81
+ _id: int = field(default=-1, repr=False, compare=False)
82
+
83
+ # Class-level counter for unique IDs
84
+ _counter: int = field(default=0, init=False, repr=False, compare=False)
85
+
86
+ def __post_init__(self) -> None:
87
+ """Normalize bounds and assign unique ID."""
88
+ # Normalize bounds to tuples
89
+ self.start = self._normalize_bound(self.start)
90
+ self.end = self._normalize_bound(self.end)
91
+ self.size = self._normalize_bound(self.size)
92
+ # Track if length was explicitly provided
93
+ length_was_explicit = self.length is not None
94
+ if self.length is not None:
95
+ self.length = self._normalize_bound(self.length)
96
+ else:
97
+ # Length defaults to size if not specified
98
+ self.length = self.size
99
+ if self.intensity is not None:
100
+ self.intensity = self._normalize_intensity(self.intensity)
101
+ # Warn if intensity is set but length was not explicitly provided
102
+ # With intensity, the required length often exceeds size (at low intensity)
103
+ if not length_was_explicit and self.intensity:
104
+ warnings.warn(
105
+ f"IntervalVar '{self.name or 'unnamed'}' has intensity but no explicit "
106
+ f"length bounds. With variable intensity, length may need to exceed size. "
107
+ f"Consider setting length=(min, max) to allow larger values. "
108
+ f"Current length defaults to size={self.size}.",
109
+ UserWarning,
110
+ stacklevel=2,
111
+ )
112
+ self._validate_granularity()
113
+
114
+ # Assign unique ID if not set
115
+ if self._id == -1:
116
+ self._id = IntervalVar._get_next_id()
117
+
118
+ # Generate name if not provided
119
+ if self.name is None:
120
+ self.name = f"_interval_{self._id}"
121
+
122
+ # Validate bounds
123
+ self._validate_bounds()
124
+
125
+ # Register for model compilation/interop helpers
126
+ register_interval(self)
127
+
128
+ @staticmethod
129
+ def _get_next_id() -> int:
130
+ """Get next unique ID for interval variables."""
131
+ # Use a module-level counter stored as class attribute
132
+ current = getattr(IntervalVar, "_id_counter", 0)
133
+ IntervalVar._id_counter = current + 1
134
+ return current
135
+
136
+ @staticmethod
137
+ def _normalize_bound(bound: Bound) -> tuple[int, int]:
138
+ """Convert bound to (min, max) tuple."""
139
+ if isinstance(bound, int):
140
+ return (bound, bound)
141
+ if isinstance(bound, tuple) and len(bound) == 2:
142
+ return (int(bound[0]), int(bound[1]))
143
+ raise ValueError(f"Invalid bound: {bound}. Expected int or (min, max) tuple.")
144
+
145
+ def _validate_bounds(self) -> None:
146
+ """Validate that bounds are consistent."""
147
+ start_min, start_max = self.start
148
+ end_min, end_max = self.end
149
+ size_min, size_max = self.size
150
+
151
+ if start_min > start_max:
152
+ raise ValueError(f"Invalid start bounds: min={start_min} > max={start_max}")
153
+ if end_min > end_max:
154
+ raise ValueError(f"Invalid end bounds: min={end_min} > max={end_max}")
155
+ if size_min > size_max:
156
+ raise ValueError(f"Invalid size bounds: min={size_min} > max={size_max}")
157
+ if size_min < 0:
158
+ raise ValueError(f"Size cannot be negative: min={size_min}")
159
+
160
+ # Check feasibility: end >= start + size
161
+ if end_max < start_min + size_min:
162
+ raise ValueError(
163
+ f"Infeasible bounds: end_max={end_max} < start_min + size_min="
164
+ f"{start_min + size_min}"
165
+ )
166
+
167
+ @staticmethod
168
+ def _normalize_intensity(intensity: Sequence[Step]) -> list[Step] | None:
169
+ """Normalize stepwise intensity to minimal consecutive steps."""
170
+ if not isinstance(intensity, Sequence) or isinstance(intensity, (str, bytes)):
171
+ raise TypeError("Intensity must be a sequence of (x, value) pairs")
172
+ steps: list[Step] = []
173
+ last_x: int | None = None
174
+ for step in intensity:
175
+ if not isinstance(step, (tuple, list)) or len(step) != 2:
176
+ raise ValueError("Intensity step must be a pair (x, value)")
177
+ x, val = step
178
+ if not isinstance(x, int) or not isinstance(val, int):
179
+ raise TypeError("Intensity step coordinates must be integers")
180
+ x = int(x)
181
+ val = int(val)
182
+ if val < 0:
183
+ raise ValueError("Intensity values must be non-negative")
184
+ if last_x is not None and x <= last_x:
185
+ raise ValueError("Intensity steps must be strictly increasing in x")
186
+ if steps and steps[-1][1] == val:
187
+ last_x = x
188
+ continue
189
+ steps.append((x, val))
190
+ last_x = x
191
+ if steps and steps[0][1] == 0:
192
+ steps.pop(0)
193
+ return steps if steps else None
194
+
195
+ def _validate_granularity(self) -> None:
196
+ """Validate intensity granularity."""
197
+ if not isinstance(self.granularity, int):
198
+ raise TypeError("granularity must be an int")
199
+ if self.granularity <= 0:
200
+ raise ValueError("granularity must be positive")
201
+
202
+ @property
203
+ def start_min(self) -> int:
204
+ """Minimum possible start time."""
205
+ return self.start[0]
206
+
207
+ @property
208
+ def start_max(self) -> int:
209
+ """Maximum possible start time."""
210
+ return self.start[1]
211
+
212
+ @property
213
+ def end_min(self) -> int:
214
+ """Minimum possible end time."""
215
+ return self.end[0]
216
+
217
+ @property
218
+ def end_max(self) -> int:
219
+ """Maximum possible end time."""
220
+ return self.end[1]
221
+
222
+ @property
223
+ def size_min(self) -> int:
224
+ """Minimum possible size/duration."""
225
+ return self.size[0]
226
+
227
+ @property
228
+ def size_max(self) -> int:
229
+ """Maximum possible size/duration."""
230
+ return self.size[1]
231
+
232
+ @property
233
+ def length_min(self) -> int:
234
+ """Minimum possible length."""
235
+ assert self.length is not None
236
+ return self.length[0]
237
+
238
+ @property
239
+ def length_max(self) -> int:
240
+ """Maximum possible length."""
241
+ assert self.length is not None
242
+ return self.length[1]
243
+
244
+ @property
245
+ def is_optional(self) -> bool:
246
+ """Whether this interval can be absent."""
247
+ return self.optional
248
+
249
+ @property
250
+ def is_present(self) -> bool:
251
+ """Whether this interval must be present (not optional)."""
252
+ return not self.optional
253
+
254
+ @property
255
+ def is_fixed_size(self) -> bool:
256
+ """Whether the size is fixed (not a range)."""
257
+ return self.size[0] == self.size[1]
258
+
259
+ @property
260
+ def is_fixed_start(self) -> bool:
261
+ """Whether the start is fixed."""
262
+ return self.start[0] == self.start[1]
263
+
264
+ @property
265
+ def is_fixed_end(self) -> bool:
266
+ """Whether the end is fixed."""
267
+ return self.end[0] == self.end[1]
268
+
269
+ def __hash__(self) -> int:
270
+ """Hash based on unique ID."""
271
+ return hash(self._id)
272
+
273
+ def __eq__(self, other: object) -> bool:
274
+ """Equality based on unique ID."""
275
+ if not isinstance(other, IntervalVar):
276
+ return NotImplemented
277
+ return self._id == other._id
278
+
279
+ def __repr__(self) -> str:
280
+ """String representation."""
281
+ parts = [f"IntervalVar({self.name!r}"]
282
+ if self.is_fixed_size:
283
+ parts.append(f"size={self.size_min}")
284
+ else:
285
+ parts.append(f"size={self.size}")
286
+ if not self.is_fixed_start or self.start_min != INTERVAL_MIN:
287
+ parts.append(f"start={self.start}")
288
+ if not self.is_fixed_end or self.end_max != INTERVAL_MAX:
289
+ parts.append(f"end={self.end}")
290
+ if self.intensity is not None:
291
+ parts.append(f"intensity={self.intensity}")
292
+ if self.granularity != 1:
293
+ parts.append(f"granularity={self.granularity}")
294
+ if self.optional:
295
+ parts.append("optional=True")
296
+ return ", ".join(parts) + ")"
297
+
298
+
299
+ def IntervalVarArray(
300
+ size: int | Sequence[int],
301
+ *,
302
+ start: Bound | None = None,
303
+ end: Bound | None = None,
304
+ size_range: Bound | None = None,
305
+ length: Bound | None = None,
306
+ intensity: Sequence[Step] | None = None,
307
+ granularity: int = 1,
308
+ optional: bool = False,
309
+ name: str | None = None,
310
+ ) -> list[IntervalVar]:
311
+ """
312
+ Create an array of interval variables.
313
+
314
+ Args:
315
+ size: Number of intervals, or tuple for multi-dimensional array.
316
+ start: Start bound for all intervals.
317
+ end: End bound for all intervals.
318
+ size_range: Size/duration bound for all intervals (named size_range
319
+ to avoid conflict with the size parameter).
320
+ length: Length bound for all intervals.
321
+ intensity: Stepwise intensity function for all intervals.
322
+ granularity: Scale for the intensity function.
323
+ optional: Whether all intervals are optional.
324
+ name: Base name for intervals (will be suffixed with index).
325
+
326
+ Returns:
327
+ List of IntervalVar objects (nested list for multi-dimensional).
328
+
329
+ Example:
330
+ >>> tasks = IntervalVarArray(5, size_range=10, name="task")
331
+ >>> ops = IntervalVarArray((3, 4), size_range=(5, 20), optional=True)
332
+ """
333
+ # Handle single dimension
334
+ if isinstance(size, int):
335
+ dims = [size]
336
+ else:
337
+ dims = list(size)
338
+
339
+ base_name = name or "_interval"
340
+
341
+ def create_recursive(dims: list[int], indices: list[int]) -> list:
342
+ """Recursively create nested array structure."""
343
+ if len(dims) == 1:
344
+ # Base case: create actual interval variables
345
+ result = []
346
+ for i in range(dims[0]):
347
+ idx = indices + [i]
348
+ var_name = f"{base_name}[{']['.join(map(str, idx))}]"
349
+ kwargs: dict = {"name": var_name, "optional": optional}
350
+ if start is not None:
351
+ kwargs["start"] = start
352
+ if end is not None:
353
+ kwargs["end"] = end
354
+ if size_range is not None:
355
+ kwargs["size"] = size_range
356
+ if length is not None:
357
+ kwargs["length"] = length
358
+ if intensity is not None:
359
+ kwargs["intensity"] = intensity
360
+ if granularity != 1:
361
+ kwargs["granularity"] = granularity
362
+ result.append(IntervalVar(**kwargs))
363
+ return result
364
+ else:
365
+ # Recursive case: create nested list
366
+ return [
367
+ create_recursive(dims[1:], indices + [i])
368
+ for i in range(dims[0])
369
+ ]
370
+
371
+ return create_recursive(dims, [])
372
+
373
+
374
+ def IntervalVarDict(
375
+ keys: Sequence,
376
+ *,
377
+ start: Bound | None = None,
378
+ end: Bound | None = None,
379
+ size_range: Bound | None = None,
380
+ length: Bound | None = None,
381
+ intensity: Sequence[Step] | None = None,
382
+ granularity: int = 1,
383
+ optional: bool = False,
384
+ name: str | None = None,
385
+ ) -> dict:
386
+ """
387
+ Create a dictionary of interval variables indexed by keys.
388
+
389
+ Args:
390
+ keys: Sequence of keys for the dictionary.
391
+ start: Start bound for all intervals.
392
+ end: End bound for all intervals.
393
+ size_range: Size/duration bound for all intervals.
394
+ length: Length bound for all intervals.
395
+ intensity: Stepwise intensity function for all intervals.
396
+ granularity: Scale for the intensity function.
397
+ optional: Whether all intervals are optional.
398
+ name: Base name for intervals.
399
+
400
+ Returns:
401
+ Dictionary mapping keys to IntervalVar objects.
402
+
403
+ Example:
404
+ >>> tasks = IntervalVarDict(["A", "B", "C"], size_range=10, name="task")
405
+ >>> tasks["A"].size_min # 10
406
+ """
407
+ base_name = name or "_interval"
408
+ result = {}
409
+ for key in keys:
410
+ var_name = f"{base_name}[{key}]"
411
+ kwargs: dict = {"name": var_name, "optional": optional}
412
+ if start is not None:
413
+ kwargs["start"] = start
414
+ if end is not None:
415
+ kwargs["end"] = end
416
+ if size_range is not None:
417
+ kwargs["size"] = size_range
418
+ if length is not None:
419
+ kwargs["length"] = length
420
+ if intensity is not None:
421
+ kwargs["intensity"] = intensity
422
+ if granularity != 1:
423
+ kwargs["granularity"] = granularity
424
+ result[key] = IntervalVar(**kwargs)
425
+ return result
426
+
427
+
428
+ # Registry for all interval variables (for model compilation)
429
+ # Uses set for O(1) membership check, list for insertion order
430
+ _interval_registry_set: set[IntervalVar] = set()
431
+ _interval_registry_ordered: list[IntervalVar] = []
432
+
433
+
434
+ def register_interval(interval: IntervalVar) -> None:
435
+ """Register an interval variable for model compilation."""
436
+ if interval not in _interval_registry_set: # O(1) lookup
437
+ _interval_registry_set.add(interval)
438
+ _interval_registry_ordered.append(interval)
439
+
440
+
441
+ def get_registered_intervals() -> list[IntervalVar]:
442
+ """Get all registered interval variables in registration order."""
443
+ return list(_interval_registry_ordered)
444
+
445
+
446
+ def clear_interval_registry() -> None:
447
+ """Clear the interval variable registry."""
448
+ _interval_registry_set.clear()
449
+ _interval_registry_ordered.clear()
450
+ IntervalVar._id_counter = 0
@@ -0,0 +1,244 @@
1
+ """
2
+ Sequence variable implementation for scheduling models.
3
+
4
+ A sequence variable represents an ordered sequence of interval variables,
5
+ typically used to model a disjunctive resource (machine) where intervals
6
+ must be totally ordered and non-overlapping.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, field
12
+ from typing import TYPE_CHECKING
13
+
14
+ if TYPE_CHECKING:
15
+ from collections.abc import Sequence
16
+
17
+ from pycsp3_scheduling.variables.interval import IntervalVar
18
+
19
+
20
+ @dataclass
21
+ class SequenceVar:
22
+ """
23
+ Represents a sequence variable for scheduling.
24
+
25
+ A sequence variable represents an ordered set of interval variables.
26
+ It is typically used to model a disjunctive resource where only one
27
+ interval can execute at a time.
28
+
29
+ Each interval in the sequence can optionally have an associated type
30
+ (integer identifier) used for transition matrix constraints.
31
+
32
+ Attributes:
33
+ intervals: List of interval variables in this sequence.
34
+ types: Optional list of type identifiers (one per interval).
35
+ Used for transition constraints (setup times).
36
+ name: Unique identifier for this sequence variable.
37
+ _id: Internal unique identifier.
38
+
39
+ Example:
40
+ >>> task1 = IntervalVar(size=10, name="task1")
41
+ >>> task2 = IntervalVar(size=15, name="task2")
42
+ >>> task3 = IntervalVar(size=8, name="task3")
43
+ >>> seq = SequenceVar(intervals=[task1, task2, task3], name="machine1")
44
+
45
+ >>> # With types for transition matrix
46
+ >>> seq = SequenceVar(
47
+ ... intervals=[task1, task2, task3],
48
+ ... types=[0, 1, 0],
49
+ ... name="machine1"
50
+ ... )
51
+ """
52
+
53
+ intervals: list[IntervalVar] = field(default_factory=list)
54
+ types: list[int] | None = None
55
+ name: str | None = None
56
+ _id: int = field(default=-1, repr=False, compare=False)
57
+
58
+ def __post_init__(self) -> None:
59
+ """Validate and initialize the sequence variable."""
60
+ # Convert intervals to list if needed
61
+ if not isinstance(self.intervals, list):
62
+ self.intervals = list(self.intervals)
63
+
64
+ # Validate types length matches intervals
65
+ if self.types is not None:
66
+ if not isinstance(self.types, list):
67
+ self.types = list(self.types)
68
+ if len(self.types) != len(self.intervals):
69
+ raise ValueError(
70
+ f"Length of types ({len(self.types)}) must match "
71
+ f"length of intervals ({len(self.intervals)})"
72
+ )
73
+ # Validate types are non-negative integers
74
+ for i, t in enumerate(self.types):
75
+ if not isinstance(t, int) or t < 0:
76
+ raise ValueError(
77
+ f"Type at index {i} must be a non-negative integer, got {t}"
78
+ )
79
+
80
+ # Assign unique ID if not set
81
+ if self._id == -1:
82
+ self._id = SequenceVar._get_next_id()
83
+
84
+ # Generate name if not provided
85
+ if self.name is None:
86
+ self.name = f"_sequence_{self._id}"
87
+
88
+ # Register for model compilation/interop helpers
89
+ register_sequence(self)
90
+
91
+ @staticmethod
92
+ def _get_next_id() -> int:
93
+ """Get next unique ID for sequence variables."""
94
+ current = getattr(SequenceVar, "_id_counter", 0)
95
+ SequenceVar._id_counter = current + 1
96
+ return current
97
+
98
+ @property
99
+ def size(self) -> int:
100
+ """Number of intervals in this sequence."""
101
+ return len(self.intervals)
102
+
103
+ @property
104
+ def has_types(self) -> bool:
105
+ """Whether this sequence has type identifiers."""
106
+ return self.types is not None
107
+
108
+ def get_interval(self, index: int) -> IntervalVar:
109
+ """Get interval at given index."""
110
+ return self.intervals[index]
111
+
112
+ def get_type(self, index: int) -> int | None:
113
+ """Get type identifier at given index, or None if no types."""
114
+ if self.types is None:
115
+ return None
116
+ return self.types[index]
117
+
118
+ def get_intervals_by_type(self, type_id: int) -> list[IntervalVar]:
119
+ """Get all intervals with the given type identifier."""
120
+ if self.types is None:
121
+ return []
122
+ return [
123
+ interval
124
+ for interval, t in zip(self.intervals, self.types)
125
+ if t == type_id
126
+ ]
127
+
128
+ def __len__(self) -> int:
129
+ """Number of intervals in the sequence."""
130
+ return len(self.intervals)
131
+
132
+ def __iter__(self):
133
+ """Iterate over intervals."""
134
+ return iter(self.intervals)
135
+
136
+ def __getitem__(self, index: int) -> IntervalVar:
137
+ """Get interval by index."""
138
+ return self.intervals[index]
139
+
140
+ def __hash__(self) -> int:
141
+ """Hash based on unique ID."""
142
+ return hash(self._id)
143
+
144
+ def __eq__(self, other: object) -> bool:
145
+ """Equality based on unique ID."""
146
+ if not isinstance(other, SequenceVar):
147
+ return NotImplemented
148
+ return self._id == other._id
149
+
150
+ def __repr__(self) -> str:
151
+ """String representation."""
152
+ interval_names = [iv.name for iv in self.intervals]
153
+ parts = [f"SequenceVar({self.name!r}"]
154
+ parts.append(f"intervals={interval_names}")
155
+ if self.types is not None:
156
+ parts.append(f"types={self.types}")
157
+ return ", ".join(parts) + ")"
158
+
159
+
160
+ def SequenceVarArray(
161
+ size: int | Sequence[int],
162
+ intervals_per_sequence: list[list[IntervalVar]] | None = None,
163
+ *,
164
+ types_per_sequence: list[list[int]] | None = None,
165
+ name: str | None = None,
166
+ ) -> list[SequenceVar]:
167
+ """
168
+ Create an array of sequence variables.
169
+
170
+ Args:
171
+ size: Number of sequences, or tuple for multi-dimensional array.
172
+ intervals_per_sequence: List of interval lists, one per sequence.
173
+ types_per_sequence: Optional list of type lists, one per sequence.
174
+ name: Base name for sequences (will be suffixed with index).
175
+
176
+ Returns:
177
+ List of SequenceVar objects (nested list for multi-dimensional).
178
+
179
+ Example:
180
+ >>> # Create sequences for 3 machines
181
+ >>> ops_m0 = [IntervalVar(size=10) for _ in range(5)]
182
+ >>> ops_m1 = [IntervalVar(size=15) for _ in range(5)]
183
+ >>> ops_m2 = [IntervalVar(size=8) for _ in range(5)]
184
+ >>> sequences = SequenceVarArray(
185
+ ... 3,
186
+ ... intervals_per_sequence=[ops_m0, ops_m1, ops_m2],
187
+ ... name="machine"
188
+ ... )
189
+ """
190
+ # Handle single dimension
191
+ if isinstance(size, int):
192
+ n = size
193
+ else:
194
+ if len(size) != 1:
195
+ raise ValueError("SequenceVarArray only supports 1D arrays")
196
+ n = size[0]
197
+
198
+ if intervals_per_sequence is not None and len(intervals_per_sequence) != n:
199
+ raise ValueError(
200
+ f"Length of intervals_per_sequence ({len(intervals_per_sequence)}) "
201
+ f"must match size ({n})"
202
+ )
203
+
204
+ if types_per_sequence is not None and len(types_per_sequence) != n:
205
+ raise ValueError(
206
+ f"Length of types_per_sequence ({len(types_per_sequence)}) "
207
+ f"must match size ({n})"
208
+ )
209
+
210
+ base_name = name or "_sequence"
211
+ result = []
212
+
213
+ for i in range(n):
214
+ seq_name = f"{base_name}[{i}]"
215
+ intervals = intervals_per_sequence[i] if intervals_per_sequence else []
216
+ types = types_per_sequence[i] if types_per_sequence else None
217
+ result.append(SequenceVar(intervals=intervals, types=types, name=seq_name))
218
+
219
+ return result
220
+
221
+
222
+ # Registry for all sequence variables (for model compilation)
223
+ # Uses set for O(1) membership check, list for insertion order
224
+ _sequence_registry_set: set[SequenceVar] = set()
225
+ _sequence_registry_ordered: list[SequenceVar] = []
226
+
227
+
228
+ def register_sequence(sequence: SequenceVar) -> None:
229
+ """Register a sequence variable for model compilation."""
230
+ if sequence not in _sequence_registry_set: # O(1) lookup
231
+ _sequence_registry_set.add(sequence)
232
+ _sequence_registry_ordered.append(sequence)
233
+
234
+
235
+ def get_registered_sequences() -> list[SequenceVar]:
236
+ """Get all registered sequence variables in registration order."""
237
+ return list(_sequence_registry_ordered)
238
+
239
+
240
+ def clear_sequence_registry() -> None:
241
+ """Clear the sequence variable registry."""
242
+ _sequence_registry_set.clear()
243
+ _sequence_registry_ordered.clear()
244
+ SequenceVar._id_counter = 0