scientific-computing-system 1.6.2__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.
Files changed (97) hide show
  1. cds/__init__.py +102 -0
  2. cds/__main__.py +8 -0
  3. cds/_version.py +10 -0
  4. cds/cli/__init__.py +104 -0
  5. cds/cli/__main__.py +8 -0
  6. cds/cli/_handlers.py +405 -0
  7. cds/cli/_parser.py +156 -0
  8. cds/cli/_style.py +117 -0
  9. cds/core/__init__.py +5 -0
  10. cds/core/_numeric.py +92 -0
  11. cds/core/models.py +166 -0
  12. cds/data_analysis/__init__.py +30 -0
  13. cds/data_analysis/dataset.py +122 -0
  14. cds/data_analysis/loader.py +75 -0
  15. cds/data_analysis/pandas_io.py +108 -0
  16. cds/data_analysis/transform.py +36 -0
  17. cds/data_analysis/viz.py +88 -0
  18. cds/diffeq/__init__.py +29 -0
  19. cds/diffeq/_implicit.py +349 -0
  20. cds/diffeq/solvers.py +312 -0
  21. cds/graph/__init__.py +31 -0
  22. cds/graph/algorithms.py +399 -0
  23. cds/hypothesis/__init__.py +48 -0
  24. cds/hypothesis/evaluator.py +369 -0
  25. cds/hypothesis/generator.py +275 -0
  26. cds/knowledge/__init__.py +41 -0
  27. cds/knowledge/graph.py +489 -0
  28. cds/knowledge/notes.py +231 -0
  29. cds/knowledge/retrieval.py +167 -0
  30. cds/math_utils/__init__.py +41 -0
  31. cds/math_utils/calculus.py +40 -0
  32. cds/math_utils/linalg.py +461 -0
  33. cds/math_utils/special.py +167 -0
  34. cds/ml/__init__.py +25 -0
  35. cds/ml/clustering.py +190 -0
  36. cds/ml/decomposition.py +172 -0
  37. cds/ml/linear_models.py +225 -0
  38. cds/ml/neighbors.py +162 -0
  39. cds/ml/neural.py +204 -0
  40. cds/ml/preprocessing.py +131 -0
  41. cds/ml/tree.py +202 -0
  42. cds/modeling/__init__.py +40 -0
  43. cds/modeling/_base.py +210 -0
  44. cds/modeling/_nodes.py +526 -0
  45. cds/modeling/expression.py +81 -0
  46. cds/modeling/model.py +134 -0
  47. cds/modeling/solver.py +176 -0
  48. cds/montecarlo/__init__.py +21 -0
  49. cds/montecarlo/methods.py +272 -0
  50. cds/nlp/__init__.py +170 -0
  51. cds/nlp/attention.py +285 -0
  52. cds/nlp/autograd/__init__.py +70 -0
  53. cds/nlp/autograd/_grad.py +120 -0
  54. cds/nlp/autograd/ops.py +133 -0
  55. cds/nlp/autograd/tensor.py +298 -0
  56. cds/nlp/bpe.py +449 -0
  57. cds/nlp/data.py +70 -0
  58. cds/nlp/embed.py +190 -0
  59. cds/nlp/layers.py +259 -0
  60. cds/nlp/model.py +456 -0
  61. cds/nlp/optim.py +163 -0
  62. cds/nlp/training.py +167 -0
  63. cds/nlp/viz.py +254 -0
  64. cds/numerical_integration/__init__.py +31 -0
  65. cds/numerical_integration/quadrature.py +480 -0
  66. cds/optimization/__init__.py +18 -0
  67. cds/optimization/_metaheuristics.py +287 -0
  68. cds/optimization/minimize.py +425 -0
  69. cds/plot/__init__.py +49 -0
  70. cds/plot/_backend.py +32 -0
  71. cds/plot/charts.py +462 -0
  72. cds/probability/__init__.py +55 -0
  73. cds/probability/_advanced.py +283 -0
  74. cds/probability/distributions.py +236 -0
  75. cds/py.typed +0 -0
  76. cds/quantum/__init__.py +51 -0
  77. cds/quantum/circuit.py +92 -0
  78. cds/quantum/multi_qubit.py +255 -0
  79. cds/quantum/simulator.py +38 -0
  80. cds/scientific/__init__.py +34 -0
  81. cds/scientific/constants.py +35 -0
  82. cds/scientific/formulas.py +116 -0
  83. cds/signals/__init__.py +43 -0
  84. cds/signals/filters.py +421 -0
  85. cds/signals/processing.py +224 -0
  86. cds/stats/__init__.py +83 -0
  87. cds/stats/_distributions.py +102 -0
  88. cds/stats/descriptive.py +172 -0
  89. cds/stats/hypothesis_tests.py +403 -0
  90. cds/stats/nonparametric.py +131 -0
  91. cds/stats/regression.py +51 -0
  92. cds/stats/time_series.py +408 -0
  93. scientific_computing_system-1.6.2.dist-info/METADATA +741 -0
  94. scientific_computing_system-1.6.2.dist-info/RECORD +97 -0
  95. scientific_computing_system-1.6.2.dist-info/WHEEL +4 -0
  96. scientific_computing_system-1.6.2.dist-info/entry_points.txt +2 -0
  97. scientific_computing_system-1.6.2.dist-info/licenses/LICENSE +21 -0
cds/knowledge/graph.py ADDED
@@ -0,0 +1,489 @@
1
+ """Knowledge graph — typed relationships between named concepts.
2
+
3
+ The core of :mod:`cds.knowledge`. A :class:`KnowledgeGraph` stores named
4
+ :class:`Concept` nodes (each with optional description, tags, and free-form
5
+ metadata) connected by typed, directed :class:`Relation` edges
6
+ (``"is-a"``, ``"depends-on"``, ``"related-to"``, …).
7
+
8
+ This is deliberately a *purpose-built* structure rather than a wrapper
9
+ around :mod:`cds.graph`. The latter is hard-wired to dense integer vertex
10
+ IDs and carries only an untyped float weight per edge, whereas a knowledge
11
+ graph's value is exactly string-keyed nodes with rich metadata and typed
12
+ relationships. The traversal here (BFS path-finding, transitive closure,
13
+ cycle detection) is small and self-contained.
14
+
15
+ Design choices:
16
+
17
+ * Relations are **directed** (``source -> target``): ``"A depends-on B"``
18
+ is not symmetric. For convenience, :meth:`KnowledgeGraph.neighbors`
19
+ reports both directions, while :meth:`neighbors_in` / :meth:`neighbors_out`
20
+ expose the direction when you need it.
21
+ * Persistence mirrors the :class:`cds.nlp.bpe.BPEMerge` idiom: every
22
+ entity serializes via :meth:`to_dict`/``from_dict``, and the owning
23
+ :class:`KnowledgeGraph` orchestrates :meth:`save`/:meth:`load` with the
24
+ stdlib :mod:`json` module.
25
+
26
+ References:
27
+ - Sowa, J.F. (1976). "Conceptual Graphs for a Data Base Interface"
28
+ — the typed-relationship convention used by :class:`Relation`.
29
+ - Cormen et al., CLRS §22 — the BFS shortest-path / reachability
30
+ algorithms used by :meth:`find_path` and :meth:`reachable`.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import json
36
+ from collections import deque
37
+ from dataclasses import dataclass, field
38
+ from pathlib import Path
39
+
40
+
41
+ @dataclass
42
+ class Concept:
43
+ """A named node in the knowledge graph.
44
+
45
+ Attributes:
46
+ name: the unique, human-readable concept identifier (also its
47
+ dictionary key inside a :class:`KnowledgeGraph`).
48
+ description: optional one- or two-line summary of the concept.
49
+ tags: free-form labels for grouping and retrieval (e.g.
50
+ ``["physics", "mechanics"]``).
51
+ metadata: additional string-valued properties (e.g. source URLs,
52
+ units) that don't fit the structured fields above.
53
+ """
54
+
55
+ name: str
56
+ description: str | None = None
57
+ tags: list[str] = field(default_factory=list)
58
+ metadata: dict[str, str] = field(default_factory=dict)
59
+
60
+ def __eq__(self, other: object) -> bool:
61
+ """Two concepts are equal iff their ``name`` fields match."""
62
+ return isinstance(other, Concept) and other.name == self.name
63
+
64
+ def __hash__(self) -> int:
65
+ """Hash keyed on the ``Concept`` tag and name (matches ``__eq__``)."""
66
+ return hash(("Concept", self.name))
67
+
68
+ def to_dict(self) -> dict[str, object]:
69
+ """Serialize this concept to a JSON-friendly dict."""
70
+ return {
71
+ "name": self.name,
72
+ "description": self.description,
73
+ "tags": list(self.tags),
74
+ "metadata": dict(self.metadata),
75
+ }
76
+
77
+ @classmethod
78
+ def from_dict(cls, data: dict[str, object]) -> Concept:
79
+ """Reconstruct a :class:`Concept` from :meth:`to_dict` output.
80
+
81
+ Raises:
82
+ ValueError: if ``data`` is missing keys or has the wrong types.
83
+ """
84
+ name = data["name"]
85
+ if not isinstance(name, str):
86
+ raise ValueError(f"Invalid concept name: {name!r}")
87
+ description = data["description"]
88
+ if description is not None and not isinstance(description, str):
89
+ raise ValueError(f"Invalid concept description: {description!r}")
90
+ tags_raw = data["tags"]
91
+ if not isinstance(tags_raw, list) or not all(isinstance(t, str) for t in tags_raw):
92
+ raise ValueError(f"Invalid concept tags: {tags_raw!r}")
93
+ metadata_raw = data["metadata"]
94
+ if not isinstance(metadata_raw, dict) or not all(
95
+ isinstance(k, str) and isinstance(v, str) for k, v in metadata_raw.items()
96
+ ):
97
+ raise ValueError(f"Invalid concept metadata: {metadata_raw!r}")
98
+ return cls(
99
+ name=name,
100
+ description=description,
101
+ tags=list(tags_raw),
102
+ metadata=dict(metadata_raw),
103
+ )
104
+
105
+
106
+ @dataclass
107
+ class Relation:
108
+ """A typed, directed edge ``source -> target`` between two concepts.
109
+
110
+ Attributes:
111
+ source: name of the origin concept.
112
+ target: name of the destination concept.
113
+ kind: the relationship type (e.g. ``"is-a"``, ``"depends-on"``,
114
+ ``"related-to"``). Semantics are caller-defined; the graph does
115
+ not interpret kinds beyond using them for filtering.
116
+ weight: optional numeric strength (default 1.0). Higher is stronger;
117
+ used by callers for ranking, not by the core traversal.
118
+ """
119
+
120
+ source: str
121
+ target: str
122
+ kind: str
123
+ weight: float = 1.0
124
+
125
+ def to_dict(self) -> dict[str, object]:
126
+ """Serialize this relation to a JSON-friendly dict."""
127
+ return {
128
+ "source": self.source,
129
+ "target": self.target,
130
+ "kind": self.kind,
131
+ "weight": self.weight,
132
+ }
133
+
134
+ @classmethod
135
+ def from_dict(cls, data: dict[str, object]) -> Relation:
136
+ """Reconstruct a :class:`Relation` from :meth:`to_dict` output.
137
+
138
+ Raises:
139
+ ValueError: if ``data`` is missing keys or has the wrong types.
140
+ """
141
+ source = data["source"]
142
+ target = data["target"]
143
+ kind = data["kind"]
144
+ if not isinstance(source, str):
145
+ raise ValueError(f"Invalid relation source: {source!r}")
146
+ if not isinstance(target, str):
147
+ raise ValueError(f"Invalid relation target: {target!r}")
148
+ if not isinstance(kind, str):
149
+ raise ValueError(f"Invalid relation kind: {kind!r}")
150
+ weight = data["weight"]
151
+ if not isinstance(weight, int | float) or isinstance(weight, bool):
152
+ raise ValueError(f"Invalid relation weight: {weight!r}")
153
+ return cls(source=source, target=target, kind=kind, weight=float(weight))
154
+
155
+
156
+ @dataclass
157
+ class KnowledgeGraph:
158
+ """A knowledge graph of named concepts and typed relations.
159
+
160
+ Attributes:
161
+ name: human-readable graph title (used in :meth:`to_markdown`).
162
+ concepts: mapping of concept name to :class:`Concept` node.
163
+ relations: ordered list of :class:`Relation` edges.
164
+ """
165
+
166
+ name: str
167
+ concepts: dict[str, Concept] = field(default_factory=dict)
168
+ relations: list[Relation] = field(default_factory=list)
169
+
170
+ # ------------------------------------------------------------------ #
171
+ # Construction
172
+ # ------------------------------------------------------------------ #
173
+ def add_concept(
174
+ self,
175
+ name: str,
176
+ description: str | None = None,
177
+ tags: list[str] | None = None,
178
+ metadata: dict[str, str] | None = None,
179
+ ) -> Concept:
180
+ """Add a concept, returning the stored node.
181
+
182
+ If ``name`` already exists, the existing concept is returned
183
+ unchanged (idempotent) rather than overwritten.
184
+
185
+ Args:
186
+ name: unique concept identifier.
187
+ description: optional summary.
188
+ tags: optional grouping labels.
189
+ metadata: optional string-valued properties.
190
+
191
+ Returns:
192
+ the stored :class:`Concept` (newly created or the pre-existing one).
193
+ """
194
+ if name in self.concepts:
195
+ return self.concepts[name]
196
+ concept = Concept(
197
+ name=name,
198
+ description=description,
199
+ tags=list(tags) if tags else [],
200
+ metadata=dict(metadata) if metadata else {},
201
+ )
202
+ self.concepts[name] = concept
203
+ return concept
204
+
205
+ def add_relation(
206
+ self,
207
+ source: str,
208
+ target: str,
209
+ kind: str,
210
+ weight: float = 1.0,
211
+ ) -> Relation:
212
+ """Add a typed, directed relation ``source -> target``.
213
+
214
+ Both endpoints must already exist as concepts (use
215
+ :meth:`link_concepts` to auto-create them).
216
+
217
+ Raises:
218
+ KeyError: if ``source`` or ``target`` is not a known concept.
219
+ """
220
+ for endpoint, label in ((source, "source"), (target, "target")):
221
+ if endpoint not in self.concepts:
222
+ raise KeyError(f"unknown {label} concept: {endpoint!r}")
223
+ relation = Relation(source=source, target=target, kind=kind, weight=weight)
224
+ self.relations.append(relation)
225
+ return relation
226
+
227
+ def link_concepts(
228
+ self,
229
+ source: str,
230
+ target: str,
231
+ kind: str,
232
+ weight: float = 1.0,
233
+ ) -> Relation:
234
+ """Auto-create both concepts (if missing) and add a relation between them."""
235
+ self.add_concept(source)
236
+ self.add_concept(target)
237
+ return self.add_relation(source, target, kind, weight)
238
+
239
+ # ------------------------------------------------------------------ #
240
+ # Queries
241
+ # ------------------------------------------------------------------ #
242
+ def neighbors(self, name: str, kind: str | None = None) -> list[str]:
243
+ """Undirected neighbors of ``name`` — every concept directly linked.
244
+
245
+ A relation touching ``name`` at either endpoint contributes its
246
+ *other* endpoint. ``kind`` optionally restricts to one relation type.
247
+
248
+ Raises:
249
+ KeyError: if ``name`` is not a known concept.
250
+ """
251
+ self._require_concept(name)
252
+ found: list[str] = []
253
+ for relation in self.relations:
254
+ if kind is not None and relation.kind != kind:
255
+ continue
256
+ if relation.source == name and relation.target not in found:
257
+ found.append(relation.target)
258
+ elif relation.target == name and relation.source not in found:
259
+ found.append(relation.source)
260
+ return found
261
+
262
+ def neighbors_out(self, name: str, kind: str | None = None) -> list[str]:
263
+ """Concepts that ``name`` points to via outgoing relations.
264
+
265
+ Raises:
266
+ KeyError: if ``name`` is not a known concept.
267
+ """
268
+ self._require_concept(name)
269
+ found: list[str] = []
270
+ for relation in self.relations:
271
+ if kind is not None and relation.kind != kind:
272
+ continue
273
+ if relation.source == name and relation.target not in found:
274
+ found.append(relation.target)
275
+ return found
276
+
277
+ def neighbors_in(self, name: str, kind: str | None = None) -> list[str]:
278
+ """Concepts that point at ``name`` via incoming relations.
279
+
280
+ Raises:
281
+ KeyError: if ``name`` is not a known concept.
282
+ """
283
+ self._require_concept(name)
284
+ found: list[str] = []
285
+ for relation in self.relations:
286
+ if kind is not None and relation.kind != kind:
287
+ continue
288
+ if relation.target == name and relation.source not in found:
289
+ found.append(relation.source)
290
+ return found
291
+
292
+ def find_path(self, source: str, target: str) -> list[str] | None:
293
+ """Shortest undirected path (by hop count) from ``source`` to ``target``.
294
+
295
+ Returns the sequence of concept names ``[source, ..., target]``, or
296
+ ``None`` if no path exists or the endpoints are unknown. A path of
297
+ length 1 (``source == target``) returns ``[source]``.
298
+
299
+ Uses BFS following the edges in either direction, so the returned
300
+ path may traverse relations against their direction.
301
+ """
302
+ if source not in self.concepts or target not in self.concepts:
303
+ return None
304
+ if source == target:
305
+ return [source]
306
+ predecessor: dict[str, str] = {source: source}
307
+ queue: deque[str] = deque([source])
308
+ while queue:
309
+ node = queue.popleft()
310
+ for neighbor in self.neighbors(node):
311
+ if neighbor in predecessor:
312
+ continue
313
+ predecessor[neighbor] = node
314
+ if neighbor == target:
315
+ return _reconstruct_path(predecessor, target)
316
+ queue.append(neighbor)
317
+ return None
318
+
319
+ def reachable(self, start: str) -> set[str]:
320
+ """All concepts reachable from ``start`` over undirected edges (incl. itself).
321
+
322
+ Returns an empty set if ``start`` is not a known concept.
323
+ """
324
+ if start not in self.concepts:
325
+ return set()
326
+ seen: set[str] = set()
327
+ queue: deque[str] = deque([start])
328
+ seen.add(start)
329
+ while queue:
330
+ node = queue.popleft()
331
+ for neighbor in self.neighbors(node):
332
+ if neighbor not in seen:
333
+ seen.add(neighbor)
334
+ queue.append(neighbor)
335
+ return seen
336
+
337
+ def find_cycles(self) -> list[list[str]]:
338
+ """Return every directed cycle in the graph as a list of concept names.
339
+
340
+ Each cycle is reported once, normalized to start at its
341
+ lexicographically smallest member so the same cycle is not reported
342
+ from every starting rotation. Self-loops (a relation whose source and
343
+ target are equal) are returned as ``[name]``.
344
+
345
+ Uses DFS back-edge detection with an explicit recursion-emulating
346
+ stack so deep graphs do not hit Python's recursion limit.
347
+ """
348
+ adj: dict[str, list[str]] = {name: [] for name in self.concepts}
349
+ for relation in self.relations:
350
+ adj[relation.source].append(relation.target)
351
+
352
+ found: set[tuple[str, ...]] = set()
353
+ WHITE, GRAY, BLACK = 0, 1, 2
354
+ color: dict[str, int] = {name: WHITE for name in self.concepts}
355
+
356
+ for root in sorted(self.concepts):
357
+ if color[root] != WHITE:
358
+ continue
359
+ # Each stack frame: the node plus an iterator position over its successors.
360
+ stack: list[tuple[str, list[str]]] = [(root, list(adj[root]))]
361
+ color[root] = GRAY
362
+ path: list[str] = [root]
363
+ while stack:
364
+ node, succs = stack[-1]
365
+ advanced = False
366
+ while succs:
367
+ nxt = succs.pop()
368
+ if color[nxt] == GRAY:
369
+ # Back edge: a cycle from nxt back along the current
370
+ # DFS path. A node is GRAY iff it is on ``path`` (we
371
+ # always append to ``path`` in lockstep with marking
372
+ # GRAY below), so ``nxt`` is guaranteed to be present.
373
+ cycle = path[path.index(nxt) :]
374
+ found.add(_normalize_cycle(cycle))
375
+ # Do not descend into the gray node; keep scanning successors.
376
+ continue
377
+ # The only remaining color is WHITE: descend into it.
378
+ color[nxt] = GRAY
379
+ path.append(nxt)
380
+ stack.append((nxt, list(adj[nxt])))
381
+ advanced = True
382
+ break
383
+ if not advanced:
384
+ # Exhausted this node's successors: mark black and pop.
385
+ color[node] = BLACK
386
+ path.pop()
387
+ stack.pop()
388
+ return [list(cycle) for cycle in sorted(found)]
389
+
390
+ def _require_concept(self, name: str) -> None:
391
+ if name not in self.concepts:
392
+ raise KeyError(f"unknown concept: {name!r}")
393
+
394
+ # ------------------------------------------------------------------ #
395
+ # Rendering & serialization
396
+ # ------------------------------------------------------------------ #
397
+ def to_markdown(self) -> str:
398
+ """Render this graph as a structured Markdown document."""
399
+ lines: list[str] = [f"# Knowledge Graph: {self.name}", ""]
400
+ if not self.concepts:
401
+ lines += ["_No concepts._", ""]
402
+ else:
403
+ lines += ["## Concepts", ""]
404
+ for name in sorted(self.concepts):
405
+ lines.append(f"- **{name}**")
406
+ lines.append("")
407
+ if not self.relations:
408
+ lines += ["_No relations._", ""]
409
+ else:
410
+ lines += ["## Relations", ""]
411
+ for relation in self.relations:
412
+ lines.append(f"- `{relation.source}` --{relation.kind}--> `{relation.target}`")
413
+ lines.append("")
414
+ return "\n".join(lines)
415
+
416
+ def to_dict(self) -> dict[str, object]:
417
+ """Serialize the whole graph to a JSON-friendly dict."""
418
+ return {
419
+ "name": self.name,
420
+ "concepts": [concept.to_dict() for concept in self.concepts.values()],
421
+ "relations": [relation.to_dict() for relation in self.relations],
422
+ }
423
+
424
+ @classmethod
425
+ def from_dict(cls, data: dict[str, object]) -> KnowledgeGraph:
426
+ """Reconstruct a :class:`KnowledgeGraph` from :meth:`to_dict` output.
427
+
428
+ Raises:
429
+ ValueError: if ``data`` is missing keys or has the wrong types.
430
+ """
431
+ name = data["name"]
432
+ if not isinstance(name, str):
433
+ raise ValueError(f"Invalid graph name: {name!r}")
434
+ concepts_raw = data["concepts"]
435
+ relations_raw = data["relations"]
436
+ if not isinstance(concepts_raw, list):
437
+ raise ValueError(f"Invalid concepts list: {concepts_raw!r}")
438
+ if not isinstance(relations_raw, list):
439
+ raise ValueError(f"Invalid relations list: {relations_raw!r}")
440
+ graph = cls(name=name)
441
+ for item in concepts_raw:
442
+ if not isinstance(item, dict):
443
+ raise ValueError(f"Invalid concept entry: {item!r}")
444
+ concept = Concept.from_dict(item)
445
+ graph.concepts[concept.name] = concept
446
+ for item in relations_raw:
447
+ if not isinstance(item, dict):
448
+ raise ValueError(f"Invalid relation entry: {item!r}")
449
+ graph.relations.append(Relation.from_dict(item))
450
+ return graph
451
+
452
+ def save(self, path: str | Path) -> None:
453
+ """Write this graph to ``path`` as indented UTF-8 JSON."""
454
+ Path(path).write_text(
455
+ json.dumps(self.to_dict(), ensure_ascii=False, indent=2),
456
+ encoding="utf-8",
457
+ )
458
+
459
+ @classmethod
460
+ def load(cls, path: str | Path) -> KnowledgeGraph:
461
+ """Read a graph previously written by :meth:`save`.
462
+
463
+ Raises:
464
+ ValueError: if the file does not contain valid graph JSON.
465
+ """
466
+ data = json.loads(Path(path).read_text(encoding="utf-8"))
467
+ if not isinstance(data, dict):
468
+ raise ValueError(f"Invalid graph file (expected object): {data!r}")
469
+ return cls.from_dict(data)
470
+
471
+
472
+ def _reconstruct_path(predecessor: dict[str, str], target: str) -> list[str]:
473
+ """Walk the ``predecessor`` map backwards from ``target`` to the root."""
474
+ path: list[str] = [target]
475
+ node = target
476
+ while predecessor[node] != node:
477
+ node = predecessor[node]
478
+ path.append(node)
479
+ path.reverse()
480
+ return path
481
+
482
+
483
+ def _normalize_cycle(cycle: list[str]) -> tuple[str, ...]:
484
+ """Return ``cycle`` rotated to start at its smallest member (as a tuple)."""
485
+ if not cycle:
486
+ return ()
487
+ pivot = cycle.index(min(cycle))
488
+ rotated = cycle[pivot:] + cycle[:pivot]
489
+ return tuple(rotated)