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.
- logitgate/__init__.py +103 -0
- logitgate/backends/__init__.py +25 -0
- logitgate/backends/base.py +259 -0
- logitgate/backends/dory_pytorch.py +330 -0
- logitgate/backends/llamacpp.py +352 -0
- logitgate/backends/ollama.py +286 -0
- logitgate/backends/openai_compat.py +195 -0
- logitgate/backends/recurrent.py +245 -0
- logitgate/cascade.py +394 -0
- logitgate/cli.py +264 -0
- logitgate/config.py +172 -0
- logitgate/debiasing/__init__.py +35 -0
- logitgate/debiasing/inversion.py +180 -0
- logitgate/debiasing/permutation.py +287 -0
- logitgate/debiasing/softmax.py +98 -0
- logitgate/gates/__init__.py +14 -0
- logitgate/gates/ast_gate.py +544 -0
- logitgate/gates/base.py +68 -0
- logitgate/gates/judge_gate.py +318 -0
- logitgate/gates/logit_gate.py +951 -0
- logitgate/integrations/__init__.py +10 -0
- logitgate/integrations/deepeval.py +212 -0
- logitgate/integrations/langgraph.py +30 -0
- logitgate/integrations/pytest_plugin.py +68 -0
- logitgate/models.py +109 -0
- logitgate/py.typed +0 -0
- logitgate/snapshot.py +224 -0
- logitgate-0.2.0.dist-info/METADATA +1236 -0
- logitgate-0.2.0.dist-info/RECORD +33 -0
- logitgate-0.2.0.dist-info/WHEEL +5 -0
- logitgate-0.2.0.dist-info/entry_points.txt +5 -0
- logitgate-0.2.0.dist-info/licenses/LICENSE +201 -0
- logitgate-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -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
|
logitgate/gates/base.py
ADDED
|
@@ -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)
|