logitgate 0.2.0__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,544 @@
1
+ from __future__ import annotations
2
+
3
+ import ast
4
+ import json
5
+ import re
6
+ import time
7
+ from collections.abc import Callable, Sequence
8
+ from typing import Any
9
+
10
+ from pydantic import BaseModel, ValidationError
11
+
12
+ from logitgate.gates.base import BaseGate
13
+ from logitgate.models import DecisionStatus, LayerTrace, Verdict
14
+
15
+
16
+ def _detect_dag_cycle(tasks: list[dict[str, Any]]) -> str | None:
17
+ """
18
+ Validates that task dependencies exist and contain no cycles.
19
+ Returns error message if invalid, None if acyclic and all dependencies exist.
20
+ """
21
+ task_ids = set()
22
+ for t in tasks:
23
+ tid = t.get("id") or t.get("task_id")
24
+ if tid:
25
+ task_ids.add(str(tid))
26
+
27
+ adj: dict[str, list[str]] = {tid: [] for tid in task_ids}
28
+ in_degree: dict[str, int] = {tid: 0 for tid in task_ids}
29
+
30
+ for t in tasks:
31
+ tid = str(t.get("id") or t.get("task_id"))
32
+ deps = t.get("dependencies") or t.get("deps") or []
33
+ for dep in deps:
34
+ dep_str = str(dep)
35
+ if dep_str not in task_ids:
36
+ return f"Missing dependency: task '{tid}' depends on non-existent task '{dep_str}'"
37
+ adj[dep_str].append(tid)
38
+ in_degree[tid] += 1
39
+
40
+ # Kahn's algorithm
41
+ queue = [tid for tid, deg in in_degree.items() if deg == 0]
42
+ visited_count = 0
43
+
44
+ while queue:
45
+ curr = queue.pop(0)
46
+ visited_count += 1
47
+ for neighbor in adj.get(curr, []):
48
+ in_degree[neighbor] -= 1
49
+ if in_degree[neighbor] == 0:
50
+ queue.append(neighbor)
51
+
52
+ if visited_count != len(task_ids):
53
+ unresolved = [tid for tid, deg in in_degree.items() if deg > 0]
54
+ return f"Cycle detected in task dependencies among: {unresolved}"
55
+ return None
56
+
57
+
58
+ class ASTGate(BaseGate):
59
+ """Deterministic CPU verification gate (Layer 0).
60
+
61
+ Executes in under 0.1ms using native CPU parsing without invoking any LLM.
62
+ Supports Python AST syntax validation, forbidden import analysis, regex pattern
63
+ matching, acyclic DAG dependency verification, and custom callable validators.
64
+
65
+ Args:
66
+ syntax: When True, parses the candidate as Python code via `ast.parse`.
67
+ forbidden_imports: Sequence of module names prohibited from being imported.
68
+ forbidden_patterns: Sequence of regular expressions prohibited in the artifact.
69
+ required_sections: Sequence of string headers or regex patterns required to be present.
70
+ acyclic_dag: When True, interprets candidate as a list/dict of task nodes and ensures
71
+ all dependencies exist and form an acyclic directed graph (DAG).
72
+ validators: Sequence of custom callables `fn(candidate) -> bool | (bool, str)`.
73
+ name: Identifier for this gate instance.
74
+ """
75
+
76
+ def __init__(
77
+ self,
78
+ syntax: bool = False,
79
+ forbidden_imports: Sequence[str] | None = None,
80
+ forbidden_patterns: Sequence[str] | None = None,
81
+ required_sections: Sequence[str] | None = None,
82
+ acyclic_dag: bool = False,
83
+ json_schema: dict[str, Any] | type[BaseModel] | None = None,
84
+ min_length: int | None = None,
85
+ max_length: int | None = None,
86
+ validators: Sequence[Callable[[Any], bool | tuple[bool, str]]] | None = None,
87
+ name: str = "ASTGate",
88
+ ) -> None:
89
+ self.syntax = syntax
90
+ self.forbidden_imports = list(forbidden_imports) if forbidden_imports else None
91
+ self.forbidden_patterns = list(forbidden_patterns) if forbidden_patterns else None
92
+ self.required_sections = list(required_sections) if required_sections else None
93
+ self.acyclic_dag = acyclic_dag
94
+ self.json_schema = json_schema
95
+ self.min_length = min_length
96
+ self.max_length = max_length
97
+ self.validators = list(validators) if validators else None
98
+ self.name = name
99
+
100
+ def __repr__(self) -> str:
101
+ features = []
102
+ if self.syntax:
103
+ features.append("syntax=True")
104
+ if self.forbidden_imports:
105
+ features.append(f"forbidden_imports={self.forbidden_imports!r}")
106
+ if self.forbidden_patterns:
107
+ features.append(f"forbidden_patterns={self.forbidden_patterns!r}")
108
+ if self.required_sections:
109
+ features.append(f"required_sections={self.required_sections!r}")
110
+ if self.acyclic_dag:
111
+ features.append("acyclic_dag=True")
112
+ if self.json_schema:
113
+ schema_name = getattr(self.json_schema, "__name__", str(self.json_schema))
114
+ features.append(f"json_schema={schema_name}")
115
+ if self.min_length is not None:
116
+ features.append(f"min_length={self.min_length}")
117
+ if self.max_length is not None:
118
+ features.append(f"max_length={self.max_length}")
119
+ if self.validators:
120
+ features.append(f"validators=[{len(self.validators)} callables]")
121
+ feat_str = f", {', '.join(features)}" if features else ""
122
+ return f"ASTGate(name='{self.name}'{feat_str})"
123
+
124
+ def evaluate(self, candidate: Any, **kwargs: Any) -> Verdict:
125
+ t0 = time.perf_counter()
126
+ candidate_str = str(candidate)
127
+
128
+ # 0. Min / Max Length Checks
129
+ if self.min_length is not None and len(candidate_str) < self.min_length:
130
+ latency = (time.perf_counter() - t0) * 1000.0
131
+ err = f"Artifact length {len(candidate_str)} is less than minimum required length {self.min_length}"
132
+ trace = LayerTrace(
133
+ layer_name=self.name,
134
+ status=DecisionStatus.FAILED,
135
+ latency_ms=latency,
136
+ diagnostic_report=err,
137
+ )
138
+ return Verdict(
139
+ passed=False,
140
+ status=DecisionStatus.FAILED,
141
+ triggered_layer=self.name,
142
+ total_latency_ms=latency,
143
+ total_tokens_generated=0,
144
+ reason=err,
145
+ traces=[trace],
146
+ )
147
+
148
+ if self.max_length is not None and len(candidate_str) > self.max_length:
149
+ latency = (time.perf_counter() - t0) * 1000.0
150
+ err = f"Artifact length {len(candidate_str)} exceeds maximum allowed length {self.max_length}"
151
+ trace = LayerTrace(
152
+ layer_name=self.name,
153
+ status=DecisionStatus.FAILED,
154
+ latency_ms=latency,
155
+ diagnostic_report=err,
156
+ )
157
+ return Verdict(
158
+ passed=False,
159
+ status=DecisionStatus.FAILED,
160
+ triggered_layer=self.name,
161
+ total_latency_ms=latency,
162
+ total_tokens_generated=0,
163
+ reason=err,
164
+ traces=[trace],
165
+ )
166
+
167
+ # 0b. JSON Schema Check
168
+ if self.json_schema is not None:
169
+ parsed_json: Any = None
170
+ if isinstance(candidate, (dict, list)):
171
+ parsed_json = candidate
172
+ elif isinstance(candidate, str):
173
+ try:
174
+ parsed_json = json.loads(candidate)
175
+ except json.JSONDecodeError as e:
176
+ latency = (time.perf_counter() - t0) * 1000.0
177
+ err = f"Invalid JSON syntax: {e}"
178
+ trace = LayerTrace(
179
+ layer_name=self.name,
180
+ status=DecisionStatus.FAILED,
181
+ latency_ms=latency,
182
+ diagnostic_report=err,
183
+ )
184
+ return Verdict(
185
+ passed=False,
186
+ status=DecisionStatus.FAILED,
187
+ triggered_layer=self.name,
188
+ total_latency_ms=latency,
189
+ total_tokens_generated=0,
190
+ reason=err,
191
+ traces=[trace],
192
+ )
193
+ else:
194
+ latency = (time.perf_counter() - t0) * 1000.0
195
+ err = f"Expected JSON string, dict, or list; got {type(candidate).__name__}"
196
+ trace = LayerTrace(
197
+ layer_name=self.name,
198
+ status=DecisionStatus.FAILED,
199
+ latency_ms=latency,
200
+ diagnostic_report=err,
201
+ )
202
+ return Verdict(
203
+ passed=False,
204
+ status=DecisionStatus.FAILED,
205
+ triggered_layer=self.name,
206
+ total_latency_ms=latency,
207
+ total_tokens_generated=0,
208
+ reason=err,
209
+ traces=[trace],
210
+ )
211
+
212
+ if isinstance(self.json_schema, type) and issubclass(self.json_schema, BaseModel):
213
+ try:
214
+ if isinstance(parsed_json, dict):
215
+ self.json_schema.model_validate(parsed_json)
216
+ else:
217
+ self.json_schema.model_validate_json(candidate_str)
218
+ except (ValidationError, ValueError, TypeError) as e:
219
+ latency = (time.perf_counter() - t0) * 1000.0
220
+ err = f"JSON validation failed for schema {self.json_schema.__name__}: {e}"
221
+ trace = LayerTrace(
222
+ layer_name=self.name,
223
+ status=DecisionStatus.FAILED,
224
+ latency_ms=latency,
225
+ diagnostic_report=err,
226
+ )
227
+ return Verdict(
228
+ passed=False,
229
+ status=DecisionStatus.FAILED,
230
+ triggered_layer=self.name,
231
+ total_latency_ms=latency,
232
+ total_tokens_generated=0,
233
+ reason=err,
234
+ traces=[trace],
235
+ )
236
+ elif isinstance(self.json_schema, dict):
237
+ if not isinstance(parsed_json, dict):
238
+ latency = (time.perf_counter() - t0) * 1000.0
239
+ err = f"Expected JSON object (dict) for schema validation, got {type(parsed_json).__name__}"
240
+ trace = LayerTrace(
241
+ layer_name=self.name,
242
+ status=DecisionStatus.FAILED,
243
+ latency_ms=latency,
244
+ diagnostic_report=err,
245
+ )
246
+ return Verdict(
247
+ passed=False,
248
+ status=DecisionStatus.FAILED,
249
+ triggered_layer=self.name,
250
+ total_latency_ms=latency,
251
+ total_tokens_generated=0,
252
+ reason=err,
253
+ traces=[trace],
254
+ )
255
+
256
+ required_keys = self.json_schema.get("required")
257
+ if isinstance(required_keys, (list, tuple, set)):
258
+ for req_key in required_keys:
259
+ if req_key not in parsed_json:
260
+ latency = (time.perf_counter() - t0) * 1000.0
261
+ err = f"Missing required JSON key: '{req_key}'"
262
+ trace = LayerTrace(
263
+ layer_name=self.name,
264
+ status=DecisionStatus.FAILED,
265
+ latency_ms=latency,
266
+ diagnostic_report=err,
267
+ )
268
+ return Verdict(
269
+ passed=False,
270
+ status=DecisionStatus.FAILED,
271
+ triggered_layer=self.name,
272
+ total_latency_ms=latency,
273
+ total_tokens_generated=0,
274
+ reason=err,
275
+ traces=[trace],
276
+ )
277
+
278
+ props = self.json_schema.get("properties")
279
+ if isinstance(props, dict):
280
+ type_map: dict[str, type | tuple[type, ...]] = {
281
+ "string": str,
282
+ "integer": int,
283
+ "number": (int, float),
284
+ "boolean": bool,
285
+ "array": list,
286
+ "object": dict,
287
+ }
288
+ for prop_name, prop_def in props.items():
289
+ if prop_name in parsed_json and isinstance(prop_def, dict) and "type" in prop_def:
290
+ expected_type_str = prop_def["type"]
291
+ expected_type = type_map.get(expected_type_str)
292
+ if expected_type and not isinstance(parsed_json[prop_name], expected_type):
293
+ latency = (time.perf_counter() - t0) * 1000.0
294
+ err = f"Property '{prop_name}' has invalid type: expected {expected_type_str}, got {type(parsed_json[prop_name]).__name__}"
295
+ trace = LayerTrace(
296
+ layer_name=self.name,
297
+ status=DecisionStatus.FAILED,
298
+ latency_ms=latency,
299
+ diagnostic_report=err,
300
+ )
301
+ return Verdict(
302
+ passed=False,
303
+ status=DecisionStatus.FAILED,
304
+ triggered_layer=self.name,
305
+ total_latency_ms=latency,
306
+ total_tokens_generated=0,
307
+ reason=err,
308
+ traces=[trace],
309
+ )
310
+ elif "required" not in self.json_schema:
311
+ for key, expected in self.json_schema.items():
312
+ if key not in parsed_json:
313
+ latency = (time.perf_counter() - t0) * 1000.0
314
+ err = f"Missing required JSON key: '{key}'"
315
+ trace = LayerTrace(
316
+ layer_name=self.name,
317
+ status=DecisionStatus.FAILED,
318
+ latency_ms=latency,
319
+ diagnostic_report=err,
320
+ )
321
+ return Verdict(
322
+ passed=False,
323
+ status=DecisionStatus.FAILED,
324
+ triggered_layer=self.name,
325
+ total_latency_ms=latency,
326
+ total_tokens_generated=0,
327
+ reason=err,
328
+ traces=[trace],
329
+ )
330
+ if isinstance(expected, type) and not isinstance(parsed_json[key], expected):
331
+ latency = (time.perf_counter() - t0) * 1000.0
332
+ err = f"Key '{key}' expected type {expected.__name__}, got {type(parsed_json[key]).__name__}"
333
+ trace = LayerTrace(
334
+ layer_name=self.name,
335
+ status=DecisionStatus.FAILED,
336
+ latency_ms=latency,
337
+ diagnostic_report=err,
338
+ )
339
+ return Verdict(
340
+ passed=False,
341
+ status=DecisionStatus.FAILED,
342
+ triggered_layer=self.name,
343
+ total_latency_ms=latency,
344
+ total_tokens_generated=0,
345
+ reason=err,
346
+ traces=[trace],
347
+ )
348
+
349
+ # 1. Syntax Check
350
+ parsed_ast: ast.AST | None = None
351
+ if self.syntax or self.forbidden_imports:
352
+ try:
353
+ parsed_ast = ast.parse(candidate_str)
354
+ except SyntaxError as e:
355
+ if self.syntax:
356
+ latency = (time.perf_counter() - t0) * 1000.0
357
+ err = f"Syntax error at line {e.lineno}, offset {e.offset}: {e.msg}"
358
+ trace = LayerTrace(
359
+ layer_name=self.name,
360
+ status=DecisionStatus.FAILED,
361
+ latency_ms=latency,
362
+ diagnostic_report=err,
363
+ )
364
+ return Verdict(
365
+ passed=False,
366
+ status=DecisionStatus.FAILED,
367
+ triggered_layer=self.name,
368
+ total_latency_ms=latency,
369
+ total_tokens_generated=0,
370
+ reason=err,
371
+ traces=[trace],
372
+ )
373
+
374
+ # 2. Forbidden Imports
375
+ if self.forbidden_imports:
376
+ found_imports: set[str] = set()
377
+ if parsed_ast is not None:
378
+ for node in ast.walk(parsed_ast):
379
+ if isinstance(node, ast.Import):
380
+ for alias in node.names:
381
+ found_imports.add(alias.name)
382
+ elif isinstance(node, ast.ImportFrom) and node.module:
383
+ found_imports.add(node.module)
384
+ else:
385
+ # Regex fallback if AST not parsed
386
+ for match in re.finditer(r"^\s*(?:import|from)\s+([a-zA-Z0-9_\.]+)", candidate_str, re.MULTILINE):
387
+ found_imports.add(match.group(1))
388
+
389
+ for imp in found_imports:
390
+ for forbidden in self.forbidden_imports:
391
+ if imp == forbidden or imp.startswith(f"{forbidden}."):
392
+ latency = (time.perf_counter() - t0) * 1000.0
393
+ err = f"Forbidden import detected: '{imp}'"
394
+ trace = LayerTrace(
395
+ layer_name=self.name,
396
+ status=DecisionStatus.FAILED,
397
+ latency_ms=latency,
398
+ diagnostic_report=err,
399
+ )
400
+ return Verdict(
401
+ passed=False,
402
+ status=DecisionStatus.FAILED,
403
+ triggered_layer=self.name,
404
+ total_latency_ms=latency,
405
+ total_tokens_generated=0,
406
+ reason=err,
407
+ traces=[trace],
408
+ )
409
+
410
+ # 3. Forbidden Patterns
411
+ if self.forbidden_patterns:
412
+ for pattern in self.forbidden_patterns:
413
+ if re.search(pattern, candidate_str):
414
+ latency = (time.perf_counter() - t0) * 1000.0
415
+ err = f"Forbidden pattern matched: '{pattern}'"
416
+ trace = LayerTrace(
417
+ layer_name=self.name,
418
+ status=DecisionStatus.FAILED,
419
+ latency_ms=latency,
420
+ diagnostic_report=err,
421
+ )
422
+ return Verdict(
423
+ passed=False,
424
+ status=DecisionStatus.FAILED,
425
+ triggered_layer=self.name,
426
+ total_latency_ms=latency,
427
+ total_tokens_generated=0,
428
+ reason=err,
429
+ traces=[trace],
430
+ )
431
+
432
+ # 4. Required Sections
433
+ if self.required_sections:
434
+ for section in self.required_sections:
435
+ if section not in candidate_str and not re.search(section, candidate_str):
436
+ latency = (time.perf_counter() - t0) * 1000.0
437
+ err = f"Missing required section: '{section}'"
438
+ trace = LayerTrace(
439
+ layer_name=self.name,
440
+ status=DecisionStatus.FAILED,
441
+ latency_ms=latency,
442
+ diagnostic_report=err,
443
+ )
444
+ return Verdict(
445
+ passed=False,
446
+ status=DecisionStatus.FAILED,
447
+ triggered_layer=self.name,
448
+ total_latency_ms=latency,
449
+ total_tokens_generated=0,
450
+ reason=err,
451
+ traces=[trace],
452
+ )
453
+
454
+ # 5. Acyclic DAG Check
455
+ if self.acyclic_dag:
456
+ tasks_data: list[dict[str, Any]] | None = None
457
+ if isinstance(candidate, list):
458
+ tasks_data = candidate
459
+ elif isinstance(candidate, dict):
460
+ tasks_data = candidate.get("tasks") or candidate.get("nodes")
461
+ elif isinstance(candidate, str):
462
+ try:
463
+ loaded = json.loads(candidate)
464
+ if isinstance(loaded, list):
465
+ tasks_data = loaded
466
+ elif isinstance(loaded, dict):
467
+ tasks_data = loaded.get("tasks") or loaded.get("nodes")
468
+ except json.JSONDecodeError:
469
+ pass
470
+
471
+ if tasks_data is not None:
472
+ dag_err = _detect_dag_cycle(tasks_data)
473
+ if dag_err:
474
+ latency = (time.perf_counter() - t0) * 1000.0
475
+ trace = LayerTrace(
476
+ layer_name=self.name,
477
+ status=DecisionStatus.FAILED,
478
+ latency_ms=latency,
479
+ diagnostic_report=dag_err,
480
+ )
481
+ return Verdict(
482
+ passed=False,
483
+ status=DecisionStatus.FAILED,
484
+ triggered_layer=self.name,
485
+ total_latency_ms=latency,
486
+ total_tokens_generated=0,
487
+ reason=dag_err,
488
+ traces=[trace],
489
+ )
490
+
491
+ # 6. Custom Validators
492
+ if self.validators:
493
+ for val_fn in self.validators:
494
+ try:
495
+ res = val_fn(candidate)
496
+ if isinstance(res, tuple):
497
+ val_ok, val_msg = res
498
+ else:
499
+ val_ok, val_msg = bool(res), f"Validator '{getattr(val_fn, '__name__', str(val_fn))}' rejected artifact"
500
+ except (ValueError, TypeError, AssertionError, KeyError, IndexError, AttributeError, RuntimeError, OSError) as ex:
501
+ val_ok = False
502
+ val_msg = f"Validator raised exception: {ex}"
503
+
504
+ if not val_ok:
505
+ latency = (time.perf_counter() - t0) * 1000.0
506
+ trace = LayerTrace(
507
+ layer_name=self.name,
508
+ status=DecisionStatus.FAILED,
509
+ latency_ms=latency,
510
+ diagnostic_report=val_msg,
511
+ )
512
+ return Verdict(
513
+ passed=False,
514
+ status=DecisionStatus.FAILED,
515
+ triggered_layer=self.name,
516
+ total_latency_ms=latency,
517
+ total_tokens_generated=0,
518
+ reason=val_msg,
519
+ traces=[trace],
520
+ )
521
+
522
+ latency = (time.perf_counter() - t0) * 1000.0
523
+ trace = LayerTrace(
524
+ layer_name=self.name,
525
+ status=DecisionStatus.PASSED,
526
+ latency_ms=latency,
527
+ )
528
+ return Verdict(
529
+ passed=True,
530
+ status=DecisionStatus.PASSED,
531
+ triggered_layer=self.name,
532
+ total_latency_ms=latency,
533
+ total_tokens_generated=0,
534
+ reason="All deterministic AST and contract checks passed.",
535
+ traces=[trace],
536
+ )
537
+
538
+ async def aevaluate(self, candidate: Any, **kwargs: Any) -> Verdict:
539
+ """Asynchronously evaluates candidate artifact (direct sync call for CPU AST validation)."""
540
+ return self.evaluate(candidate, **kwargs)
541
+
542
+
543
+ # Alias
544
+ ContractGate = ASTGate
@@ -0,0 +1,68 @@
1
+ from __future__ import annotations
2
+
3
+ import abc
4
+ import asyncio
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ from logitgate.models import Verdict
8
+
9
+ if TYPE_CHECKING:
10
+ from logitgate.cascade import Cascade
11
+
12
+
13
+ class BaseGate(abc.ABC):
14
+ """Abstract base class for all evaluation gates and cascaded routing layers.
15
+
16
+ All gates support composition via the `>>` and `|` operators to construct
17
+ multi-layer decision pipelines (e.g., `ASTGate >> LogitGate >> JudgeGate`).
18
+ """
19
+
20
+ name: str = "BaseGate"
21
+
22
+ @abc.abstractmethod
23
+ def evaluate(self, candidate: Any, **kwargs: Any) -> Verdict:
24
+ """Evaluates the candidate artifact synchronously.
25
+
26
+ Args:
27
+ candidate: Artifact to evaluate (string, dict, code, or structured object).
28
+ **kwargs: Additional layer-specific evaluation parameters.
29
+
30
+ Returns:
31
+ Verdict containing decision status, latency, tokens, and telemetry traces.
32
+ """
33
+
34
+ async def aevaluate(self, candidate: Any, **kwargs: Any) -> Verdict:
35
+ """Evaluates the candidate artifact asynchronously.
36
+
37
+ Args:
38
+ candidate: Artifact to evaluate (string, dict, code, or structured object).
39
+ **kwargs: Additional layer-specific evaluation parameters.
40
+
41
+ Returns:
42
+ Verdict containing decision status, latency, tokens, and telemetry traces.
43
+ """
44
+ loop = asyncio.get_running_loop()
45
+ return await loop.run_in_executor(None, lambda: self.evaluate(candidate, **kwargs))
46
+
47
+ def __call__(self, candidate: Any, **kwargs: Any) -> Verdict:
48
+ """Evaluates the candidate artifact synchronously (callable shorthand for `evaluate`)."""
49
+ return self.evaluate(candidate, **kwargs)
50
+
51
+ def __rshift__(self, other: BaseGate) -> Cascade:
52
+ """Chains this gate with another gate into a cascaded routing pipeline.
53
+
54
+ Example:
55
+ >>> pipeline = ASTGate(syntax=True) >> LogitGate(rubric="Clean code")
56
+ """
57
+ from logitgate.cascade import Cascade
58
+ if isinstance(self, Cascade) and isinstance(other, Cascade):
59
+ return Cascade(gates=list(self.gates) + list(other.gates))
60
+ if isinstance(self, Cascade):
61
+ return Cascade(gates=list(self.gates) + [other])
62
+ if isinstance(other, Cascade):
63
+ return Cascade(gates=[self] + list(other.gates))
64
+ return Cascade(gates=[self, other])
65
+
66
+ def __or__(self, other: BaseGate) -> Cascade:
67
+ """Alias for `>>` allowing pipeline chaining with pipe syntax."""
68
+ return self.__rshift__(other)