socratic-engine 0.2.11__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,64 @@
1
+ """
2
+ socratic_engine — motor socrático recursivo con semántica epistemológica.
3
+
4
+ Externalized epistemic scaffolding for AI agents. Recursive boolean
5
+ certification trees.
6
+
7
+ El motor evalúa árboles booleanos recursivos (AND/OR/NOT/XOR/IMPLIES) con
8
+ lógica TRIVALUADA (TRUE/FALSE/UNKNOWN), certificación de evidencia
9
+ (certified: evidencia estructural ≠ opinión) y rastro completo de
10
+ razonamiento (explain()/diagnose()).
11
+
12
+ Agnóstico: no contiene lógica de dominio. Los predicates registrados son
13
+ deterministas (type_glob/type_prefix/type_regex/type_has/ctx_has); un LLM
14
+ u otro motor puede registrar los suyos, pero solo la evidencia estructural
15
+ certifica (R10: el LLM opina, no certifica).
16
+
17
+ Uso:
18
+ from socratic_engine import SocraticEngine, Truth, TreeExecutor
19
+ eng = SocraticEngine()
20
+ executor = TreeExecutor(eng)
21
+ result = executor.execute({"op": "AND", "children": [
22
+ {"predicate": "type_prefix", "args": ["$type", "VSL-LANG-"]},
23
+ ]}, {"type": "VSL-LANG-GATES-v1.0"})
24
+ # result.truth == Truth.TRUE, result.certified == True
25
+
26
+ CLI:
27
+ socratic-engine eval-tree <tree.vsm|tree.json> [--context <json>] [--doc-type <TYPE>]
28
+ """
29
+
30
+ from .engine import (
31
+ Evaluation,
32
+ FailureTrace,
33
+ PredicateCache,
34
+ PredicateResult,
35
+ Predicate,
36
+ SocraticEngine,
37
+ Truth,
38
+ cached,
39
+ find_failure_traces,
40
+ )
41
+ from .tree import (
42
+ SocraticTreeBuilder,
43
+ TreeExecutor,
44
+ load_tree,
45
+ parse_socratic_block,
46
+ tree_home,
47
+ )
48
+
49
+ __all__ = [
50
+ "Evaluation",
51
+ "FailureTrace",
52
+ "Predicate",
53
+ "PredicateResult",
54
+ "SocraticEngine",
55
+ "SocraticTreeBuilder",
56
+ "TreeExecutor",
57
+ "Truth",
58
+ "find_failure_traces",
59
+ "load_tree",
60
+ "parse_socratic_block",
61
+ "tree_home",
62
+ ]
63
+
64
+ __version__ = "0.2.11"
@@ -0,0 +1,219 @@
1
+ """Bridge oficial socratic-engine × state-canon.
2
+
3
+ Conecta un StateProvider de state-canon con un SocraticEngine como fuente
4
+ de evidencia ESTRUCTURAL y CERTIFICADA: los predicados canon_* consultan el
5
+ canon (reconciled ground truth — lo OBSERVADO, no lo declarado) y lo
6
+ convierten en PredicateResult con certified=True cuando hay evidencia.
7
+
8
+ Contrato (v0.2.x):
9
+ - state-canon GROUNDS: "what is actually running / has reality drifted"
10
+ - socratic-engine CONSTRAINS: "given these premises, what follows"
11
+
12
+ La regla del provider (ver INTERFACE.md de state-canon): el provider DEBE
13
+ representar el canon reconciliado (lo que observe() devuelve), nunca el
14
+ lado declarado solo. Si se conecta un provider declarado, state_verify
15
+ comprueba contra aspiraciones, no contra realidad — el bridge no corrige
16
+ eso: lo hereda (R4: el bridge verifica contra lo que el provider ofrece).
17
+
18
+ Predicados registrados (prefijo canon_ — no colisiona con builtins):
19
+
20
+ canon_query(domain, filter_json)
21
+ ¿Hay al menos un record en el dominio que cumpla el filtro?
22
+ TRUE certified si hay evidencia; UNKNOWN si no hay records (R9: sin
23
+ concesión silenciosa); FALSE certified si el query falla
24
+ (p.ej. campo de filtro desconocido → ValueError del provider).
25
+
26
+ canon_matches(domain, filter_json, expected_json)
27
+ ¿Los records que cumplen el filtro tienen exactamente los campos
28
+ esperados? TRUE certified si todos matchean; FALSE certified si
29
+ alguno difiere (drift); UNKNOWN si no hay evidencia.
30
+
31
+ canon_field_equals(domain, filter_json, field, expected)
32
+ ¿El campo `field` de los records filtrados == `expected`?
33
+ TRUE/FALSE certified; UNKNOWN si no hay records o el campo no existe.
34
+
35
+ canon_drift(domain, filter_json, declared_field, observed_field)
36
+ ¿Declarado y observado coinciden en los records filtrados?
37
+ TRUE certified si coinciden todos; FALSE certified si difieren al
38
+ menos uno (DRIFT); UNKNOWN si falta evidencia (R9).
39
+
40
+ Uso:
41
+
42
+ from socratic_engine import SocraticEngine
43
+ from socratic_engine.bridge_statecanon import StateCanonBridge
44
+
45
+ eng = SocraticEngine()
46
+ bridge = StateCanonBridge(eng, provider) # provider = StateProvider
47
+ ev = eng.evaluate({"op": "AND", "children": [
48
+ {"predicate": "canon_field_equals",
49
+ "args": ["services", '{"name": "cache"}', "observed_active", True]},
50
+ {"predicate": "canon_field_equals",
51
+ "args": ["services", '{"name": "cache"}', "declared_active", True]},
52
+ ]})
53
+ """
54
+
55
+ from __future__ import annotations
56
+
57
+ import json
58
+ from typing import Any, Optional
59
+
60
+ from .engine import PredicateResult, SocraticEngine, Truth
61
+
62
+ # El provider se importa de forma lazy para que el bridge funcione sin
63
+ # state-canon instalado (los predicados canon_* solo se registran si el
64
+ # provider está disponible — el fallback documentado es ImportError).
65
+
66
+
67
+ def _normalize_filter(filter_arg: Any) -> Optional[dict]:
68
+ """Acepta dict o JSON-string; None si no es parseable."""
69
+ if filter_arg is None:
70
+ return {}
71
+ if isinstance(filter_arg, dict):
72
+ return filter_arg
73
+ if isinstance(filter_arg, str):
74
+ try:
75
+ parsed = json.loads(filter_arg)
76
+ except json.JSONDecodeError:
77
+ return None
78
+ if isinstance(parsed, dict):
79
+ return parsed
80
+ return None
81
+
82
+
83
+ class StateCanonBridge:
84
+ """Registra predicados canon_* en un SocraticEngine sobre un provider."""
85
+
86
+ PREFIX = "canon_"
87
+
88
+ def __init__(self, engine: SocraticEngine, provider: Any):
89
+ self.engine = engine
90
+ self.provider = provider
91
+ self._register()
92
+
93
+ # ── registro ────────────────────────────────────────────────────────
94
+
95
+ def _register(self) -> None:
96
+ self.engine.register("canon_query")(self._canon_query)
97
+ self.engine.register("canon_matches")(self._canon_matches)
98
+ self.engine.register("canon_field_equals")(self._canon_field_equals)
99
+ self.engine.register("canon_drift")(self._canon_drift)
100
+
101
+ # ── helpers ─────────────────────────────────────────────────────────
102
+
103
+ def _records(self, domain: str, filter_arg: Any) -> Optional[list[dict]]:
104
+ """Consulta el provider. None si el filtro es inválido o el query
105
+ falla (p.ej. campo de filtro desconocido → ValueError)."""
106
+ filt = _normalize_filter(filter_arg)
107
+ if filt is None:
108
+ return None
109
+ try:
110
+ return self.provider.query(domain, filt)
111
+ except (ValueError, KeyError, TypeError):
112
+ return None
113
+
114
+ # ── predicados ──────────────────────────────────────────────────────
115
+
116
+ def _canon_query(self, domain: str, filter_arg: Any = None, **kw) -> PredicateResult:
117
+ records = self._records(domain, filter_arg)
118
+ if records is None:
119
+ return PredicateResult(
120
+ truth=Truth.UNKNOWN, certified=False,
121
+ evidence={"domain": domain, "reason": "query_failed"},
122
+ source="canon_query",
123
+ )
124
+ if not records:
125
+ return PredicateResult(
126
+ truth=Truth.UNKNOWN, certified=False,
127
+ evidence={"domain": domain, "filter": filter_arg,
128
+ "reason": "no_records"},
129
+ source="canon_query",
130
+ )
131
+ return PredicateResult(
132
+ truth=Truth.TRUE, certified=True,
133
+ evidence={"domain": domain, "count": len(records)},
134
+ source="canon_query",
135
+ )
136
+
137
+ def _canon_matches(self, domain: str, filter_arg: Any, expected_arg: Any,
138
+ **kw) -> PredicateResult:
139
+ records = self._records(domain, filter_arg)
140
+ if records is None or not records:
141
+ return PredicateResult(
142
+ truth=Truth.UNKNOWN, certified=False,
143
+ evidence={"domain": domain, "reason": "no_evidence"},
144
+ source="canon_matches",
145
+ )
146
+ expected = _normalize_filter(expected_arg)
147
+ if expected is None:
148
+ return PredicateResult(
149
+ truth=Truth.UNKNOWN, certified=False,
150
+ evidence={"domain": domain, "reason": "invalid_expected"},
151
+ source="canon_matches",
152
+ )
153
+ ok = all(all(r.get(k) == v for k, v in expected.items()) for r in records)
154
+ return PredicateResult(
155
+ truth=Truth.TRUE if ok else Truth.FALSE, certified=True,
156
+ evidence={"domain": domain, "expected": expected,
157
+ "records": records},
158
+ source="canon_matches",
159
+ )
160
+
161
+ def _canon_field_equals(self, domain: str, filter_arg: Any, field: str,
162
+ expected: Any, **kw) -> PredicateResult:
163
+ records = self._records(domain, filter_arg)
164
+ if records is None or not records:
165
+ return PredicateResult(
166
+ truth=Truth.UNKNOWN, certified=False,
167
+ evidence={"domain": domain, "reason": "no_evidence"},
168
+ source="canon_field_equals",
169
+ )
170
+ if field not in records[0]:
171
+ return PredicateResult(
172
+ truth=Truth.UNKNOWN, certified=False,
173
+ evidence={"domain": domain, "field": field,
174
+ "reason": "field_missing"},
175
+ source="canon_field_equals",
176
+ )
177
+ ok = all(r.get(field) == expected for r in records)
178
+ return PredicateResult(
179
+ truth=Truth.TRUE if ok else Truth.FALSE, certified=True,
180
+ evidence={"domain": domain, "field": field, "expected": expected,
181
+ "values": [r.get(field) for r in records]},
182
+ source="canon_field_equals",
183
+ )
184
+
185
+ def _canon_drift(self, domain: str, filter_arg: Any, declared_field: str,
186
+ observed_field: str, **kw) -> PredicateResult:
187
+ """Detecta drift declarado vs observado en los records filtrados."""
188
+ records = self._records(domain, filter_arg)
189
+ if records is None or not records:
190
+ return PredicateResult(
191
+ truth=Truth.UNKNOWN, certified=False,
192
+ evidence={"domain": domain, "reason": "no_evidence"},
193
+ source="canon_drift",
194
+ )
195
+ drift = [
196
+ {"name": r.get("name", i), declared_field: r.get(declared_field),
197
+ observed_field: r.get(observed_field)}
198
+ for i, r in enumerate(records)
199
+ if r.get(declared_field) != r.get(observed_field)
200
+ ]
201
+ if drift:
202
+ return PredicateResult(
203
+ truth=Truth.FALSE, certified=True,
204
+ evidence={"domain": domain, "drift": drift},
205
+ source="canon_drift",
206
+ )
207
+ return PredicateResult(
208
+ truth=Truth.TRUE, certified=True,
209
+ evidence={"domain": domain, "drift": []},
210
+ source="canon_drift",
211
+ )
212
+
213
+
214
+ def register_statecanon_bridge(engine: SocraticEngine, provider: Any) -> StateCanonBridge:
215
+ """Función conveniencia: crea y registra el bridge sobre un engine."""
216
+ return StateCanonBridge(engine, provider)
217
+
218
+
219
+ __all__ = ["StateCanonBridge", "register_statecanon_bridge"]
socratic_engine/cli.py ADDED
@@ -0,0 +1,351 @@
1
+ """
2
+ socratic_engine.cli — contrato externo del motor (CLI eval-tree + selftest).
3
+
4
+ El motor se expone por contrato externo estable para que cualquier
5
+ instrumento (gate write-time, plugin TS, MCP server, script CI/CD) lo
6
+ invocable sin importar el paquete Python internamente.
7
+
8
+ Uso:
9
+ socratic-engine eval-tree <tree.vsm|tree.json> [--context <json>] [--doc-type <TYPE>]
10
+ → {"truth":"TRUE","certified":true,"home":"vsl-language","unknown":false,
11
+ "diagnose":[...],"explain":"..."}
12
+
13
+ Sin argumentos: ejecuta el selftest (R4.1: el instrumento se auto-verifica).
14
+ """
15
+
16
+ import json
17
+ import sys
18
+ from pathlib import Path
19
+
20
+ from .engine import PredicateResult, SocraticEngine, Truth
21
+ from .tree import SocraticTreeBuilder, parse_socratic_block, tree_home
22
+
23
+ def _run_selftest() -> None:
24
+ # selftest rápido (R4.1: el instrumento se auto-verifica)
25
+ eng = SocraticEngine()
26
+ t = {
27
+ "op": "AND",
28
+ "children": [
29
+ {"predicate": "type_prefix", "args": ["$type", "VSL-LANG-"]},
30
+ {"op": "NOT", "children": [{"predicate": "type_has", "args": ["$type", "INDEX"]}]},
31
+ ],
32
+ }
33
+ ev = eng.evaluate(t, {"type": "VSL-LANG-GATES-v1.0"})
34
+ assert ev.is_true, "VSL-LANG-GATES debe ser TRUE"
35
+ assert ev.certified, "builtins deterministas deben certificar"
36
+ ev2 = eng.evaluate(t, {"type": "VSL-LANGUAGE-INDEX-v1.0"})
37
+ assert ev2.is_false, "INDEX debe ser FALSE"
38
+
39
+ # trivaluado: UNKNOWN propaga
40
+ @eng.register("maybe")
41
+ def maybe(*a, **k) -> PredicateResult:
42
+ return PredicateResult(truth=Truth.UNKNOWN, certified=False, source="maybe")
43
+ ev3 = eng.evaluate({"op": "AND", "children": [
44
+ {"predicate": "type_prefix", "args": ["$type", "VSL-"]},
45
+ {"predicate": "maybe", "args": ["x"]},
46
+ ]}, {"type": "VSL-X"})
47
+ assert ev3.is_unknown, "AND con UNKNOWN debe ser UNKNOWN"
48
+
49
+ # certificación: bool simple NO certifica; PredicateResult certified=True sí
50
+ ev4 = eng.evaluate({"predicate": "type_glob", "args": ["$type", "*.vsm"]}, {"type": "x.vsm"})
51
+ assert ev4.certified, "type_glob es evidencia estructural → certified"
52
+
53
+ # tree_home: primero TRUE gana; UNKNOWN → '?' (None), no else silencioso
54
+ t2 = {"op": "OR", "children": [
55
+ {"predicate": "type_prefix", "args": ["$type", "THEORY-VC-"], "home": "s3-control"},
56
+ {"predicate": "type_prefix", "args": ["$type", "THEORY-AP-"], "home": "s4-intelligence"},
57
+ ]}
58
+ assert tree_home(t2, "THEORY-VC-01", eng) == "s3-control"
59
+ assert tree_home(t2, "THEORY-AP-01", eng) == "s4-intelligence"
60
+ assert tree_home(t2, "THEORY-DYN-01", eng) is None # no match → '?'
61
+
62
+ # R10: llm_judge opina (TRUE) pero NO certifica → certified=False
63
+ @eng.register("llm_judge")
64
+ def llm_judge(question: str, evidence: str, **kwargs) -> PredicateResult:
65
+ return PredicateResult(
66
+ truth=Truth.TRUE, certified=False,
67
+ evidence=evidence, source="llm:gpt-4",
68
+ metadata={"question": question, "confidence": 0.85},
69
+ )
70
+ ev5 = eng.evaluate({"predicate": "llm_judge", "kwargs": {
71
+ "question": "¿Rompe compatibilidad?", "evidence": "cambio"}}, {})
72
+ assert ev5.is_true and not ev5.certified, "LLM opina pero no certifica (R10)"
73
+
74
+ # trace inverso: AND con llm_judge no certificado → el trace apunta al llm_judge
75
+ tree_diag = {"op": "AND", "children": [
76
+ {"predicate": "type_prefix", "args": ["$type", "VSL-"]},
77
+ {"predicate": "llm_judge", "kwargs": {"question": "¿OK?", "evidence": "cambio"}},
78
+ ]}
79
+ diag = eng.diagnose(tree_diag, {"type": "VSL-X"})
80
+ assert len(diag) >= 1, "diagnose debe encontrar el fallo"
81
+ assert any("llm" in t.path[-1] or "llm" in t.source for t in diag), \
82
+ "el trace inverso debe señalar al llm_judge como causa (source=llm:gpt-4)"
83
+ assert all("certified" not in t.reason or t.reason for t in diag)
84
+
85
+ # builder: árbol válido pasa; predicado no registrado → ValueError descriptivo
86
+ builder = SocraticTreeBuilder(eng)
87
+ built = builder.build({"op": "OR", "children": [
88
+ {"predicate": "type_prefix", "args": ["$type", "THEORY-VC-"]},
89
+ ]})
90
+ assert eng.evaluate(built, {"type": "THEORY-VC-01"}).is_true
91
+ try:
92
+ builder.build({"op": "AND", "children": [{"predicate": "no_such", "args": []}]})
93
+ raise AssertionError("builder debe rechazar predicado no registrado") # pragma: no cover — inalcanzable: builder SÍ lanza (verificado por tests directos); el raise solo corre si el builder fallara en rechazar lo que debe
94
+ except ValueError as e:
95
+ assert "no_such" in str(e), "mensaje debe nombrar el predicado"
96
+ try:
97
+ builder.build({"op": "NOT", "children": [True, False]})
98
+ raise AssertionError("builder debe rechazar NOT con 2 hijos") # pragma: no cover — inalcanzable: builder SÍ lanza (verificado por tests directos); el raise solo corre si el builder fallara en rechazar lo que debe
99
+ except ValueError as e:
100
+ assert "NOT" in str(e), "mensaje debe nombrar el operador"
101
+
102
+ print("✓ socratic_engine selftest OK — trivaluado + certified + explain + diagnose + builder discriminan")
103
+
104
+
105
+ # ─────────────────────────────────────────────────────────────────────────────
106
+ # CLI EXTERNA: socratic-eval — el contrato del shell único de plugins (fase a,
107
+ # PLUGIN-CLASS-TAXONOMY-v1). Un plugin TS delgado (o un gate write-time) llama
108
+ # a este evaluador con un árbol + contexto JSON; recibe la decisión como JSON.
109
+ # R10: el motor decide con evidencia estructural; el LLM puede proponer, nunca
110
+ # certificar. R9: UNKNOWN → '?' visible en home, nunca else_home silencioso.
111
+ #
112
+ # Uso:
113
+ # python3 scripts/vsl/socratic_engine.py eval-tree <arbol.vsm|arbol.json> \
114
+ # --context '{"type":"VSL-LANG-GATES-v1.0","path":"..."}'
115
+ # → {"truth":"TRUE","certified":true,"home":"vsl-language","unknown":false,
116
+ # "diagnose":[...],"explain":"..."}
117
+ # ─────────────────────────────────────────────────────────────────────────────
118
+
119
+ def _eval_tree_cli(argv: list[str]) -> int:
120
+ if len(argv) < 1:
121
+ print("usage: socratic_engine.py eval-tree <tree.vsm|tree.json> "
122
+ "[--context <json>] [--doc-type <TYPE>]", file=sys.stderr)
123
+ return 2
124
+ tree_path = Path(argv[0])
125
+ if not tree_path.exists():
126
+ print(f"tree not found: {tree_path}", file=sys.stderr)
127
+ return 2
128
+ ctx: dict = {}
129
+ i = 1
130
+ while i < len(argv):
131
+ if argv[i] == "--context" and i + 1 < len(argv):
132
+ try:
133
+ ctx = json.loads(argv[i + 1])
134
+ except json.JSONDecodeError as e:
135
+ print(f"context is not valid JSON: {e}", file=sys.stderr)
136
+ return 2
137
+ i += 2
138
+ elif argv[i] == "--doc-type" and i + 1 < len(argv):
139
+ ctx["type"] = argv[i + 1]
140
+ i += 2
141
+ else:
142
+ i += 1
143
+ # árbol: .json → dict directo; .vsm → parse_socratic_block
144
+ if tree_path.suffix == ".json":
145
+ tree = json.loads(tree_path.read_text(encoding="utf-8"))
146
+ else:
147
+ tree = parse_socratic_block(tree_path.read_text(encoding="utf-8"))
148
+ if tree is None:
149
+ print("no socratic(...) block found in tree file", file=sys.stderr)
150
+ return 2
151
+ eng = SocraticEngine()
152
+ try:
153
+ ev = eng.evaluate(tree, ctx)
154
+ except (ValueError, KeyError, TypeError) as e:
155
+ print(f"evaluation error: {e}", file=sys.stderr)
156
+ return 1
157
+ out: dict = {
158
+ "truth": ev.truth.name if hasattr(ev.truth, "name") else str(ev.truth),
159
+ "certified": ev.certified,
160
+ "unknown": ev.is_unknown,
161
+ "home": tree_home(tree, ctx.get("type", ""), eng, ctx),
162
+ "explain": ev.explain(),
163
+ "diagnose": [t.to_dict() if hasattr(t, "to_dict") else str(t)
164
+ for t in eng.diagnose(tree, ctx)],
165
+ }
166
+ print(json.dumps(out, ensure_ascii=False, indent=2))
167
+ return 0
168
+
169
+
170
+ def _decide_cli(argv: list[str]) -> int:
171
+ """CLI: socratic-engine decide --decision "..." [options]
172
+
173
+ Evaluate a decision through the socratic engine.
174
+
175
+ Options:
176
+ --decision TEXT The decision to evaluate (required)
177
+ --alternatives TEXT Comma-separated alternatives
178
+ --impact TEXT Impact description
179
+ --reversible BOOLEAN Whether decision is reversible (default: true)
180
+ --prerequisites TEXT Comma-separated prerequisites
181
+ --approved Mark as approved (for irreversible decisions)
182
+ --context JSON Additional context as JSON
183
+ --json Output as JSON
184
+
185
+ Returns:
186
+ {"truth": "TRUE"/"FALSE", "certified": bool, "home": "pass"/"reject", ...}
187
+ """
188
+ import argparse
189
+
190
+ parser = argparse.ArgumentParser(
191
+ description="Evaluate a decision through the socratic engine"
192
+ )
193
+ parser.add_argument("--decision", required=True, help="The decision to evaluate")
194
+ parser.add_argument("--alternatives", help="Comma-separated alternatives")
195
+ parser.add_argument("--impact", help="Impact description")
196
+ parser.add_argument("--reversible", default="true", help="Whether reversible (true/false)")
197
+ parser.add_argument("--prerequisites", help="Comma-separated prerequisites")
198
+ parser.add_argument("--approved", action="store_true", help="Mark as approved")
199
+ parser.add_argument("--context", help="Additional context as JSON")
200
+ parser.add_argument("--json", action="store_true", help="Output as JSON")
201
+
202
+ args = parser.parse_args(argv)
203
+
204
+ # Build context
205
+ ctx: dict = {
206
+ "decision": args.decision,
207
+ "reversible": args.reversible.lower(), # Keep as string for ctx_equals
208
+ }
209
+
210
+ if args.alternatives:
211
+ ctx["alternatives"] = [a.strip() for a in args.alternatives.split(",")]
212
+
213
+ if args.impact:
214
+ ctx["impact"] = args.impact
215
+
216
+ if args.prerequisites:
217
+ ctx["prerequisites"] = [p.strip() for p in args.prerequisites.split(",")]
218
+
219
+ if args.approved:
220
+ ctx["approved"] = True
221
+
222
+ if args.context:
223
+ try:
224
+ extra = json.loads(args.context)
225
+ ctx.update(extra)
226
+ except json.JSONDecodeError as e:
227
+ print(f"invalid --context JSON: {e}", file=sys.stderr)
228
+ return 2
229
+
230
+ # Build decide tree dynamically
231
+ tree = _build_decide_tree(ctx)
232
+
233
+ # Evaluate
234
+ eng = SocraticEngine()
235
+ try:
236
+ ev = eng.evaluate(tree, ctx)
237
+ except (ValueError, KeyError, TypeError) as e:
238
+ print(f"evaluation error: {e}", file=sys.stderr)
239
+ return 1
240
+
241
+ out: dict = {
242
+ "truth": ev.truth.name if hasattr(ev.truth, "name") else str(ev.truth),
243
+ "certified": ev.certified,
244
+ "unknown": ev.is_unknown,
245
+ "home": "pass" if ev.is_true else "reject",
246
+ "explain": ev.explain(),
247
+ "diagnose": [t.to_dict() if hasattr(t, "to_dict") else str(t)
248
+ for t in eng.diagnose(tree, ctx)],
249
+ "decision": args.decision,
250
+ "context": {
251
+ "reversible": ctx.get("reversible"),
252
+ "has_alternatives": bool(ctx.get("alternatives")),
253
+ "has_impact": bool(ctx.get("impact")),
254
+ "approved": ctx.get("approved", False),
255
+ },
256
+ }
257
+
258
+ if args.json:
259
+ print(json.dumps(out, ensure_ascii=False, indent=2))
260
+ else:
261
+ truth = out["truth"]
262
+ certified = out["certified"]
263
+ home = out["home"]
264
+
265
+ if truth == "TRUE" and certified:
266
+ print(f"✓ DECISION CERTIFIED: {args.decision}")
267
+ print(f" Truth: {truth}, Certified: {certified}")
268
+ elif truth == "FALSE" and certified:
269
+ print(f"✗ DECISION REJECTED: {args.decision}")
270
+ print(f" Truth: {truth}, Certified: {certified}")
271
+ if out.get("explain"):
272
+ print(f" Reason: {out['explain'][:200]}")
273
+ else:
274
+ print(f"? DECISION UNCERTAIN: {args.decision}")
275
+ print(f" Truth: {truth}, Certified: {certified}")
276
+
277
+ return 0 if ev.is_true else 1
278
+
279
+
280
+ def _build_decide_tree(ctx: dict) -> dict:
281
+ """Build a decide tree dynamically from context.
282
+
283
+ Tree structure:
284
+ AND
285
+ ├── ctx_has("decision")
286
+ ├── OR
287
+ │ ├── ctx_has("alternatives")
288
+ │ └── (skip if no alternatives required)
289
+ ├── OR
290
+ │ ├── ctx_has("impact")
291
+ │ └── (skip if no impact required)
292
+ └── OR
293
+ ├── ctx_equals("reversible", "true")
294
+ └── AND
295
+ ├── ctx_equals("reversible", "false")
296
+ └── ctx_has("approved")
297
+ """
298
+ children = [
299
+ # Gate 1: Decision must be provided
300
+ {"predicate": "ctx_has", "args": ["$ctx", "decision"]},
301
+ ]
302
+
303
+ # Gate 2: Alternatives (only if provided in context)
304
+ if ctx.get("alternatives"):
305
+ children.append(
306
+ {"predicate": "ctx_has", "args": ["$ctx", "alternatives"]}
307
+ )
308
+
309
+ # Gate 3: Impact (only if provided in context)
310
+ if ctx.get("impact"):
311
+ children.append(
312
+ {"predicate": "ctx_has", "args": ["$ctx", "impact"]}
313
+ )
314
+
315
+ # Gate 4: Reversibility check
316
+ # Note: ctx.get returns string, so we compare with "true"
317
+ is_reversible = ctx.get("reversible", "true") == "true"
318
+
319
+ if is_reversible:
320
+ # Reversible decisions pass automatically
321
+ children.append(
322
+ {"predicate": "ctx_equals", "args": ["$ctx", "reversible", "true"]}
323
+ )
324
+ else:
325
+ # Irreversible require approval
326
+ children.append({
327
+ "op": "AND",
328
+ "children": [
329
+ {"predicate": "ctx_equals", "args": ["$ctx", "reversible", "false"]},
330
+ {"predicate": "ctx_has", "args": ["$ctx", "approved"]},
331
+ ],
332
+ })
333
+
334
+ return {"op": "AND", "children": children}
335
+
336
+
337
+ def main(argv: list[str] | None = None) -> int:
338
+ """Entry point: `socratic-engine eval-tree <tree> [opts]` o selftest."""
339
+ args = list(sys.argv[1:] if argv is None else argv)
340
+ if args and args[0] == "eval-tree":
341
+ return _eval_tree_cli(args[1:]) # pragma: no cover — verificado por subprocess en tests; coverage no instrumenta procesos hijos
342
+ if args and args[0] == "decide":
343
+ return _decide_cli(args[1:]) # pragma: no cover — verificado por subprocess en tests
344
+ # selftest rápido (R4.1: el instrumento se auto-verifica)
345
+ _run_selftest()
346
+ return 0
347
+
348
+
349
+ if __name__ == "__main__":
350
+ sys.exit(main()) # pragma: no cover — verificado por subprocess en tests; coverage no instrumenta procesos hijos
351
+