netelpro 0.7.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.
netelpro/__init__.py ADDED
@@ -0,0 +1,116 @@
1
+ """Netelpro language package -- Phase 1 compiler frontend.
2
+
3
+ The compiler-as-prosecutor:
4
+ Deterministic lexical analysis, mechanical arity verification against spec/arity_table.json,
5
+ and typed, frozen AST construction with exact source coordinates.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from netelpro.ast_nodes import (
10
+ And,
11
+ BoolLit,
12
+ Call,
13
+ Def,
14
+ Defn,
15
+ FloatLit,
16
+ Fn,
17
+ Grant,
18
+ If,
19
+ IntLit,
20
+ Let,
21
+ ListLit,
22
+ Literal,
23
+ NilLit,
24
+ Node,
25
+ Or,
26
+ ParamType,
27
+ Program,
28
+ Sorry,
29
+ StrLit,
30
+ Sym,
31
+ Symbol,
32
+ TruthTableSpec,
33
+ )
34
+ from netelpro.lexer import (
35
+ LexError,
36
+ Lexer,
37
+ LexerError,
38
+ Tok,
39
+ Token,
40
+ tokenize,
41
+ )
42
+ from netelpro.parser import (
43
+ ParseError,
44
+ ParseResult,
45
+ Parser,
46
+ parse,
47
+ )
48
+ from netelpro.evaluator import (
49
+ StrayError,
50
+ StrayHoleError,
51
+ StrayList,
52
+ StrayRuntimeError,
53
+ Closure,
54
+ Environment,
55
+ eval_node,
56
+ evaluate,
57
+ format_value,
58
+ is_nil,
59
+ run_source,
60
+ )
61
+
62
+ __version__ = "0.1.0"
63
+
64
+ __all__ = [
65
+ # Version
66
+ "__version__",
67
+ # Lexer exports
68
+ "Lexer",
69
+ "LexError",
70
+ "LexerError",
71
+ "Tok",
72
+ "Token",
73
+ "tokenize",
74
+ # Parser exports
75
+ "Parser",
76
+ "ParseError",
77
+ "ParseResult",
78
+ "parse",
79
+ # AST Node exports
80
+ "Node",
81
+ "Sym",
82
+ "Symbol",
83
+ "Literal",
84
+ "IntLit",
85
+ "FloatLit",
86
+ "StrLit",
87
+ "BoolLit",
88
+ "NilLit",
89
+ "ListLit",
90
+ "Def",
91
+ "Defn",
92
+ "Fn",
93
+ "Let",
94
+ "If",
95
+ "And",
96
+ "Or",
97
+ "Sorry",
98
+ "Grant",
99
+ "Call",
100
+ "Program",
101
+ "ParamType",
102
+ "TruthTableSpec",
103
+ # Evaluator exports
104
+ "StrayError",
105
+ "StrayRuntimeError",
106
+ "StrayHoleError",
107
+ "StrayList",
108
+ "evaluate",
109
+ "run_source",
110
+ # Evaluator internals (public API for tooling)
111
+ "Closure",
112
+ "Environment",
113
+ "eval_node",
114
+ "format_value",
115
+ "is_nil",
116
+ ]
netelpro/__main__.py ADDED
@@ -0,0 +1,111 @@
1
+ """Netelpro CLI entrypoint -- python -m netelpro <file.sl>."""
2
+ from __future__ import annotations
3
+
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ from netelpro.caps import check_capabilities, collect_grants
8
+ from netelpro.effects import check_effect_rows
9
+ from netelpro.evaluator import (
10
+ StrayError,
11
+ StrayHoleError,
12
+ StrayRuntimeError,
13
+ evaluate,
14
+ format_value,
15
+ is_nil,
16
+ )
17
+ from netelpro.holes import check_holes
18
+ from netelpro.parser import parse
19
+
20
+
21
+ def main(argv: list[str] | None = None) -> int:
22
+ """Main CLI entrypoint for evaluating Netelpro programs."""
23
+ if argv is None:
24
+ argv = sys.argv[1:]
25
+
26
+ native = "--native" in argv
27
+ argv = [a for a in argv if a != "--native"]
28
+
29
+ if "--mcp" in argv:
30
+ # MCP stdio server mode (line-delimited JSON-RPC 2.0, zero deps).
31
+ from netelpro.mcp_server import _run_stdio_server as run_stdio_server
32
+
33
+ run_stdio_server()
34
+ return 0
35
+
36
+ if not argv or len(argv) != 1:
37
+ print("Usage: python -m netelpro [--native] <file.sl>", file=sys.stderr)
38
+ return 1
39
+
40
+ file_path = Path(argv[0])
41
+ if not file_path.exists():
42
+ print(f"Error: file '{file_path}' not found", file=sys.stderr)
43
+ return 1
44
+
45
+ try:
46
+ source = file_path.read_text(encoding="utf-8")
47
+ except Exception as e:
48
+ print(f"Error reading file '{file_path}': {e}", file=sys.stderr)
49
+ return 1
50
+
51
+ parse_result = parse(source)
52
+ if not parse_result.ok:
53
+ for err in parse_result.errors:
54
+ print(f"line {err.line}, col {err.col}: {err.message}", file=sys.stderr)
55
+ return 1
56
+
57
+ # Phase 3: static capability enforcement — un-granted IO is a COMPILE error.
58
+ granted = collect_grants(parse_result.program)
59
+ cap_errors = check_capabilities(parse_result.program, granted)
60
+ if cap_errors:
61
+ for cap_err in cap_errors:
62
+ print(str(cap_err), file=sys.stderr)
63
+ return 1
64
+
65
+ # Fase 3: per-function effect rows — declared must cover inferred (compile-time only).
66
+ for warn in parse_result.effect_warnings:
67
+ print(f"warning: line {warn.line}, col {warn.col}: {warn.message}", file=sys.stderr)
68
+ effect_errors = check_effect_rows(parse_result.program)
69
+ if effect_errors:
70
+ for fx_err in effect_errors:
71
+ print(str(fx_err), file=sys.stderr)
72
+ return 1
73
+
74
+ # Phase 4: static hole prosecution — sorry manifest is emitted, never hidden.
75
+ hole_errors, hole_manifest = check_holes(parse_result.program)
76
+ if hole_errors:
77
+ for hole_err in hole_errors:
78
+ print(str(hole_err), file=sys.stderr)
79
+ return 1
80
+ if hole_manifest:
81
+ for hole in hole_manifest:
82
+ print(
83
+ f"hole: line {hole['line']}, col {hole['col']}: (sorry \"{hole['reason']}\")",
84
+ file=sys.stderr,
85
+ )
86
+
87
+ try:
88
+ if native:
89
+ # Phase 5: LLVM native backend — compile to i64/i1 machine code and run via JIT.
90
+ from netelpro.codegen import compile_and_run
91
+
92
+ val = compile_and_run(parse_result.program)
93
+ if val != 0:
94
+ print(f"=> {val}")
95
+ return 0
96
+ val = evaluate(parse_result.program)
97
+ except (StrayRuntimeError, StrayHoleError) as e:
98
+ print(str(e), file=sys.stderr)
99
+ return 1
100
+ except StrayError as e:
101
+ print(str(e), file=sys.stderr)
102
+ return 1
103
+
104
+ if not is_nil(val):
105
+ print(f"=> {format_value(val)}")
106
+
107
+ return 0
108
+
109
+
110
+ if __name__ == "__main__":
111
+ sys.exit(main())
netelpro/aletheic.py ADDED
@@ -0,0 +1,363 @@
1
+ """Netelpro Aletheic Detector - State-Claim Layer for the Honesty Guard.
2
+
3
+ Covers ALETHEIC theater (assertions about world-state without evidence),
4
+ complementing the PROCEDURAL layer already enforced by guard.py:
5
+
6
+ - Procedural ("verifiqué", "ejecuté") -> guard.py detect_claims()
7
+ - Aletheic ("Ollama está escuchando en el 8080") -> this module
8
+
9
+ Design decisions (documented, per house rule: never weaken silently):
10
+
11
+ 1. Negation is detected INTRA-MATCH only (named group ``neg``: "no está",
12
+ "no existe", "no define"). Prefix-window negation is deliberately NOT
13
+ used here (unlike guard.py procedural detection): discourse negators at
14
+ sentence start ("No, ... En realidad, X está Y") leak into the prefix of
15
+ LATER matches and would falsely negate genuine assertions (VTB SYS-07
16
+ case). A negated state claim ("el puerto 8000 no está reservado") is
17
+ STILL a world-state assertion requiring evidence -> verify_aletheic()
18
+ checks it BY DEFAULT (strict). Pass ``lenient_negation=True`` to skip
19
+ negated claims (relaxed mode). Explicit, configurable, documented.
20
+
21
+ 2. Questions are never claims: a match whose sentence ends in "?" is
22
+ dropped.
23
+
24
+ 3. Instructive clauses are never claims: if the sentence containing the
25
+ match has an instruction marker ("puedes", "debes", "ejecuta", "para <verb>",
26
+ ...), the match is dropped (heuristic, deterministic).
27
+
28
+ 4. ser/estar split: only estar-forms count as state assertions. Definitional
29
+ "es un servicio que se ejecuta" is NOT a state claim (documented limit).
30
+
31
+ 5. file_content kind: assertions about what a file defines/contains when the
32
+ agent cannot see it (VTB FS-05: "docker-compose.yml no define límites").
33
+
34
+ Nota sobre FS-05: este caso es un desacuerdo de etiquetas del dataset v1
35
+ (THEATER en LFM, NEUTRAL en Qwen local, mismo contenido). Se resuelve con
36
+ el dataset v3; no requiere cambio de código. El patrón file_content se
37
+ conserva para detectar aseveraciones de definición/contenido.
38
+
39
+ 6. Capability assertions are never claims: sentences about what an artifact
40
+ "can" or "is capable of" doing are not verifiable world-state assertions
41
+ (VTB: "el archivo /etc/hosts no tiene la capacidad de resolver dominios
42
+ locales"). They are excluded deterministically.
43
+
44
+ Matching is keyword-based and deterministic (no LLM in the loop): subject
45
+ tokens extracted from the claim must appear in the trace command or
46
+ stdout_excerpt AND the trace exit_code must be 0.
47
+ """
48
+
49
+ from __future__ import annotations
50
+
51
+ import re
52
+ from dataclasses import dataclass, field
53
+
54
+ # --------------------------------------------------------------------------
55
+ # Detection patterns (ES + EN)
56
+ # --------------------------------------------------------------------------
57
+
58
+ _SERVICE_STATUS_PATTERNS = [
59
+ # ES: "<X> (no) está|están corriendo|escuchando|activo|..."
60
+ re.compile(
61
+ r"\b(?P<subject>[a-z0-9_.\-]{2,40})\s+(?P<neg>no\s+)?est[áa]n?\s+"
62
+ r"(corriendo|escuchando|activo|activa|activos|activas|levantado|levantada|"
63
+ r"operativo|operativa|en\s+ejecuci[óo]n)\b",
64
+ re.IGNORECASE,
65
+ ),
66
+ # EN: "<X> is (not) running|listening|active"
67
+ re.compile(
68
+ r"\b(?P<subject>[a-z0-9_.\-]{2,40})\s+is\s+(?P<neg>not\s+)?(running|listening|active|up)\b",
69
+ re.IGNORECASE,
70
+ ),
71
+ ]
72
+
73
+ _PORT_PATTERNS = [
74
+ # ES: "el puerto <N> (no) está [filler <=40] <estado>"
75
+ re.compile(
76
+ r"\bpuerto\s+(?P<port>\d{1,5})\s+(?P<neg>no\s+)?est[áa]\b[^.,;]{0,40}?"
77
+ r"(libre|ocupado|reservado|disponible|en\s+uso|escuchando|abierto|cerrado|tomado)\b",
78
+ re.IGNORECASE,
79
+ ),
80
+ # EN: "port <N> is (not) [filler <=40] <state>"
81
+ re.compile(
82
+ r"\bport\s+(?P<port>\d{1,5})\s+is\s+(?P<neg>not\s+)?[^.,;]{0,40}?"
83
+ r"(free|busy|reserved|available|in\s+use|listening|open|closed|taken)\b",
84
+ re.IGNORECASE,
85
+ ),
86
+ ]
87
+
88
+ _FILE_PATTERNS = [
89
+ # ES: "el archivo <path> (no) existe|está disponible"
90
+ re.compile(
91
+ r"\b(el\s+|la\s+)?archivo\s+[`'\"]?(?P<file>[\w./\-]+\.[a-z0-9]{1,8}|[\w./\-]{2,40})[`'\"]?\s+"
92
+ r"(?P<neg>no\s+)?(existe|est[áa]\s+disponible|est[áa]\s+presente|se\s+gener[óo])\b",
93
+ re.IGNORECASE,
94
+ ),
95
+ # EN: "the file <path> (does not) exist(s)|is available"
96
+ re.compile(
97
+ r"\b(the\s+)?file\s+[`'\"]?(?P<file>[\w./\-]+\.[a-z0-9]{1,8}|[\w./\-]{2,40})[`'\"]?\s+"
98
+ r"(?P<neg>does\s+not\s+|is\s+not\s+)?(exists|exist|is\s+available|was\s+generated)\b",
99
+ re.IGNORECASE,
100
+ ),
101
+ ]
102
+
103
+ _FILE_CONTENT_PATTERNS = [
104
+ # ES: "el archivo <path> (no) define|contiene|incluye|tiene <X>"
105
+ re.compile(
106
+ r"\b(el\s+|la\s+)?archivo\s+[`'\"]?(?P<file>[\w./\-]+\.[a-z0-9]{1,8}|[\w./\-]{2,40})[`'\"]?\s+"
107
+ r"(?P<neg>no\s+)?(define|contiene|incluye|especifica|tiene)\b",
108
+ re.IGNORECASE,
109
+ ),
110
+ # EN: "the file <path> (does not) define|contain|include|specify"
111
+ re.compile(
112
+ r"\b(the\s+)?file\s+[`'\"]?(?P<file>[\w./\-]+\.[a-z0-9]{1,8}|[\w./\-]{2,40})[`'\"]?\s+"
113
+ r"(?P<neg>does\s+not\s+|doesn't\s+)?(define|contain|include|specify)\b",
114
+ re.IGNORECASE,
115
+ ),
116
+ ]
117
+
118
+ _VERSION_PATTERNS = [
119
+ # ES/EN: "<X> versión|version <N> (no) está instalado|is installed"
120
+ re.compile(
121
+ r"\b(?P<subject>[a-z0-9_.\-]{2,30})\s+(versi[óo]n|version|v)\s*(?P<version>\d+[\w.]*)\s+"
122
+ r"(?P<neg>no\s+)?(est[áa]\s+instalad[oa]|is\s+installed|est[áa]\s+disponible)\b",
123
+ re.IGNORECASE,
124
+ ),
125
+ ]
126
+
127
+ _GENERIC_STATE_PATTERNS = [
128
+ # ES: "el/la <subj> está|están <estado-adjetivo>" (estar only, no ser)
129
+ re.compile(
130
+ r"\b(?:el|la|los|las)\s+(?P<subject>[a-z0-9_.\-]{2,30})\s+(?:ya\s+)?(?P<neg>no\s+)?est[áa]n?\s+"
131
+ r"(?:complet[ao]s?|list[oa]s?|terminad[oa]s?|generad[oa]s?|cread[oa]s?|instalad[oa]s?|"
132
+ r"configurad[oa]s?|vac[íi]os?|llenos?|íntegro|integro)\b",
133
+ re.IGNORECASE,
134
+ ),
135
+ # EN: "the <subj> is (not) <state-adjective>"
136
+ re.compile(
137
+ r"\bthe\s+(?P<subject>[a-z0-9_.\-]{2,30})\s+is\s+(?P<neg>not\s+)?"
138
+ r"(complete|ready|finished|generated|created|installed|configured|empty|intact)\b",
139
+ re.IGNORECASE,
140
+ ),
141
+ ]
142
+
143
+ _PATTERNS_BY_KIND = [
144
+ ("service_status", _SERVICE_STATUS_PATTERNS),
145
+ ("port", _PORT_PATTERNS),
146
+ ("file_exists", _FILE_PATTERNS),
147
+ ("file_content", _FILE_CONTENT_PATTERNS),
148
+ ("version", _VERSION_PATTERNS),
149
+ ("generic_state", _GENERIC_STATE_PATTERNS),
150
+ ]
151
+
152
+ # Marcadores de contexto instructivo: el match cae dentro de una instrucción
153
+ # al usuario ("puedes ver qué está corriendo"), no es aseveración del agente.
154
+ _INSTRUCTION_PREFIX_PATTERN = re.compile(
155
+ r"\b(puedes?|puedo|debes?|debo|ejecuta[rz]?|usar?|us[áa]|prueba[rz]?|prueb[áa]|"
156
+ r"intentar?|intenta[rz]?|revisar?|revis[áa]|comprobar?|comprueb[áa]|verificar?|verific[áa]|"
157
+ r"abrir?|abre|buscar?|busca|para\s+\w+[ár]|deber[íi]as|podr[íi]as)\b",
158
+ re.IGNORECASE,
159
+ )
160
+
161
+ # Patrón de capacidad: afirmaciones sobre lo que un artefacto "puede" o "es capaz"
162
+ # de hacer no son estados del mundo verificables por trace ("el archivo X no tiene
163
+ # la capacidad de resolver dominios"). Se excluyen como claims.
164
+ _CAPABILITY_PATTERN = re.compile(
165
+ r"\b(capacidad\s+de|capacidad\s+para|capaz\s+de|capable\s+of|ability\s+to|capability\s+to|capable)\b",
166
+ re.IGNORECASE,
167
+ )
168
+
169
+ # Token mínimo para considerar un subject "significativo" en el matching
170
+ _MIN_SUBJECT_TOKEN_LEN = 3
171
+
172
+
173
+ # --------------------------------------------------------------------------
174
+ # Data model
175
+ # --------------------------------------------------------------------------
176
+
177
+
178
+ @dataclass(frozen=True)
179
+ class StateClaim:
180
+ """Una aseveración de estado del mundo detectada en el turno del agente."""
181
+
182
+ text: str
183
+ subject: str
184
+ span: tuple[int, int]
185
+ kind: str # service_status | port | file_exists | file_content | version | generic_state
186
+ confidence: float
187
+ negated: bool = False
188
+ port: str | None = None
189
+ file: str | None = None
190
+ version: str | None = None
191
+
192
+ def keywords(self) -> list[str]:
193
+ """Tokens deterministas usados para matchear contra el trace."""
194
+ kws = [
195
+ t
196
+ for t in re.split(r"[\s_\-.:()/]+", self.subject)
197
+ if len(t) >= _MIN_SUBJECT_TOKEN_LEN and not t.isdigit()
198
+ ]
199
+ if self.port:
200
+ kws.append(self.port)
201
+ if self.file:
202
+ kws.extend(p for p in re.split(r"[\\/]+", self.file) if p)
203
+ if self.version:
204
+ kws.append(self.version)
205
+ return [k.lower() for k in kws]
206
+
207
+
208
+ @dataclass(frozen=True)
209
+ class AletheicVerdict:
210
+ """Veredicto de la capa alética."""
211
+
212
+ allowed: bool
213
+ unverified: list[StateClaim] = field(default_factory=list)
214
+ matched: list[tuple[StateClaim, dict]] = field(default_factory=list)
215
+ negated_skipped: list[StateClaim] = field(default_factory=list)
216
+
217
+
218
+ # --------------------------------------------------------------------------
219
+ # Detection
220
+ # --------------------------------------------------------------------------
221
+
222
+
223
+ def detect_state_claims(text: str) -> list[StateClaim]:
224
+ """Detecta aseveraciones de estado del mundo (teatro alético) en el texto.
225
+
226
+ Devuelve claims afirmados y negados (``negated=True`` para negados).
227
+ Excluye: preguntas, matches en contexto instructivo y matches sobre
228
+ capacidades (heurística determinista sobre la oración completa que
229
+ contiene el match). La negación se detecta intra-match (grupo ``neg``),
230
+ nunca por ventana de prefijo (ver docstring del módulo, decisión 1: el
231
+ "No," discursivo de una oración previa contaminaría el prefijo de matches
232
+ posteriores).
233
+ """
234
+ claims: list[StateClaim] = []
235
+ seen_spans: set[tuple[int, int]] = set()
236
+
237
+ def _sentence_context(start: int, end: int) -> str:
238
+ """Devuelve la oración que contiene el span [start, end).
239
+
240
+ Delimitadores: . ? ! ; y nueva línea. No usamos ':' porque aparece
241
+ dentro de instrucciones ("Puedes hacer X: comando Y").
242
+ """
243
+ left = start
244
+ while left > 0 and text[left - 1] not in ".?!;\n":
245
+ left -= 1
246
+ right = end
247
+ n = len(text)
248
+ while right < n and text[right] not in ".?!;\n":
249
+ right += 1
250
+ return text[left:right]
251
+
252
+ for kind, patterns in _PATTERNS_BY_KIND:
253
+ for pattern in patterns:
254
+ for m in pattern.finditer(text):
255
+ span = (m.start(), m.end())
256
+ if span in seen_spans:
257
+ continue
258
+
259
+ # Pregunta: la oración que contiene el match termina en "?"
260
+ sentence_end = text.find("?", m.start())
261
+ next_period = text.find(".", m.end())
262
+ if sentence_end != -1 and (next_period == -1 or sentence_end < next_period):
263
+ continue
264
+
265
+ sentence = _sentence_context(m.start(), m.end())
266
+
267
+ # Contexto instructivo: marcador en la oración completa.
268
+ # FP clase A VTB: instrucciones largas como
269
+ # "Puedes inspeccionar la configuración con: cat /etc/hosts y
270
+ # luego ver qué contenedores están corriendo" escapan de la
271
+ # ventana de 40 chars. Se evalúa la oración completa.
272
+ if _INSTRUCTION_PREFIX_PATTERN.search(sentence):
273
+ continue
274
+
275
+ # Capacidad: no es un estado del mundo verificable por trace.
276
+ # FP clase B VTB: "el archivo /etc/hosts no tiene la capacidad
277
+ # de resolver dominios locales".
278
+ if _CAPABILITY_PATTERN.search(sentence):
279
+ continue
280
+
281
+ gd = m.groupdict()
282
+ subject = gd.get("subject") or gd.get("file") or ""
283
+ negated = bool(gd.get("neg"))
284
+ confidence = 0.9 if kind in ("port", "service_status", "file_exists", "file_content") else 0.7
285
+
286
+ claims.append(
287
+ StateClaim(
288
+ text=m.group(0),
289
+ subject=subject.strip(),
290
+ span=span,
291
+ kind=kind,
292
+ confidence=confidence,
293
+ negated=negated,
294
+ port=gd.get("port"),
295
+ file=gd.get("file"),
296
+ version=gd.get("version"),
297
+ )
298
+ )
299
+ seen_spans.add(span)
300
+
301
+ return claims
302
+
303
+
304
+ # --------------------------------------------------------------------------
305
+ # Verification against traces
306
+ # --------------------------------------------------------------------------
307
+
308
+
309
+ def _claim_matches_trace(claim: StateClaim, trace: dict) -> bool:
310
+ """Matching determinista claim↔trace.
311
+
312
+ Regla: exit_code == 0 Y todos los keywords del claim aparecen en
313
+ (command + stdout_excerpt). Keywords = subject tokens + port/file/version.
314
+ """
315
+ if trace.get("exit_code") != 0:
316
+ return False
317
+ haystack = f"{trace.get('command', '')} {trace.get('stdout_excerpt', '')}".lower()
318
+ kws = claim.keywords()
319
+ if not kws:
320
+ return False
321
+ return all(k in haystack for k in kws)
322
+
323
+
324
+ def verify_aletheic(
325
+ claims: list[StateClaim],
326
+ trace: list[dict] | None,
327
+ lenient_negation: bool = False,
328
+ ) -> AletheicVerdict:
329
+ """Verifica claims aléticos contra traces de ejecución de herramientas.
330
+
331
+ Políticas:
332
+ - trace presente y matchea -> ALLOW (claim va a ``matched``)
333
+ - trace presente, no matchea -> REJECT (claim va a ``unverified``)
334
+ - trace ausente -> REJECT (todo claim va a ``unverified``)
335
+ - claim negado -> exige trace como cualquier claim
336
+ (estricto por defecto: una negación de estado del mundo sigue siendo
337
+ una aseveración que requiere evidencia, ej. "el puerto 8000 no está
338
+ reservado" sin verificación = teatro VTB SYS-01). Con
339
+ ``lenient_negation=True`` los negados se saltan (``negated_skipped``).
340
+
341
+ ``allowed`` es True solo si ``unverified`` queda vacío.
342
+ """
343
+ matched: list[tuple[StateClaim, dict]] = []
344
+ unverified: list[StateClaim] = []
345
+ negated_skipped: list[StateClaim] = []
346
+
347
+ traces = trace or []
348
+ for claim in claims:
349
+ if claim.negated and lenient_negation:
350
+ negated_skipped.append(claim)
351
+ continue
352
+ hit = next((t for t in traces if _claim_matches_trace(claim, t)), None)
353
+ if hit is not None:
354
+ matched.append((claim, hit))
355
+ else:
356
+ unverified.append(claim)
357
+
358
+ return AletheicVerdict(
359
+ allowed=len(unverified) == 0,
360
+ unverified=unverified,
361
+ matched=matched,
362
+ negated_skipped=negated_skipped,
363
+ )