lgd-engine 0.1.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.
- lgd_engine/__init__.py +9 -0
- lgd_engine/cli.py +116 -0
- lgd_engine/gate/__init__.py +4 -0
- lgd_engine/gate/engine.py +155 -0
- lgd_engine/judges/__init__.py +18 -0
- lgd_engine/judges/base.py +66 -0
- lgd_engine/judges/judge_510k.py +361 -0
- lgd_engine/judges/judge_postmarket.py +209 -0
- lgd_engine/mcp_server.py +204 -0
- lgd_engine/mcp_smoke.py +63 -0
- lgd_engine/memory/__init__.py +4 -0
- lgd_engine/memory/localmem_min.py +132 -0
- lgd_engine-0.1.0.dist-info/METADATA +286 -0
- lgd_engine-0.1.0.dist-info/RECORD +19 -0
- lgd_engine-0.1.0.dist-info/WHEEL +5 -0
- lgd_engine-0.1.0.dist-info/entry_points.txt +4 -0
- lgd_engine-0.1.0.dist-info/licenses/LICENSE +202 -0
- lgd_engine-0.1.0.dist-info/licenses/NOTICE +23 -0
- lgd_engine-0.1.0.dist-info/top_level.txt +1 -0
lgd_engine/__init__.py
ADDED
lgd_engine/cli.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""lgd_engine.cli — console entry point (pyproject: lgd-gate = lgd_engine.cli:main).
|
|
2
|
+
|
|
3
|
+
Thin CLI over the judgment core. Mirrors demo1.change_gate without depending on
|
|
4
|
+
the demo layer; exit codes follow the release-gate convention:
|
|
5
|
+
0=go, 1=hold, 2=reject, 3=tool error, 64=usage error.
|
|
6
|
+
|
|
7
|
+
`--all DIR` exists because the docs showed `--all` on this entry and it was only
|
|
8
|
+
implemented on the demo CLI — a documented command that errored out is a defect
|
|
9
|
+
in the doc surface, so the parity is now real (R19).
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import json
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from .gate import GateEngine
|
|
19
|
+
from .judges.judge_510k import Judge510kChange
|
|
20
|
+
from .judges.judge_postmarket import JudgePostmarket
|
|
21
|
+
|
|
22
|
+
_DOMAINS = {
|
|
23
|
+
"510k": Judge510kChange,
|
|
24
|
+
"postmarket": JudgePostmarket,
|
|
25
|
+
}
|
|
26
|
+
_STRIP_KEYS = ("expected_verdict", "expected_fda_verdict",
|
|
27
|
+
"expected_nmpa_verdict", "_desensitized_from")
|
|
28
|
+
RC_USAGE, RC_TOOL_ERROR = 64, 3
|
|
29
|
+
_RC = {"go": 0, "hold": 1, "reject": 2}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class _Parser(argparse.ArgumentParser):
|
|
33
|
+
def error(self, message: str) -> None: # argparse exits 2 by default
|
|
34
|
+
self.print_usage(sys.stderr)
|
|
35
|
+
print(f"{self.prog}: error: {message}", file=sys.stderr)
|
|
36
|
+
print(f"(usage errors exit {RC_USAGE}, not 2 — 2 means REJECT)", file=sys.stderr)
|
|
37
|
+
raise SystemExit(RC_USAGE)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _run_one(path: Path, args) -> tuple:
|
|
41
|
+
ctx = json.loads(path.read_text(encoding="utf-8"))
|
|
42
|
+
expected = ctx.get("expected_verdict")
|
|
43
|
+
for k in _STRIP_KEYS:
|
|
44
|
+
ctx.pop(k, None)
|
|
45
|
+
if args.jurisdiction:
|
|
46
|
+
ctx["jurisdiction"] = args.jurisdiction
|
|
47
|
+
eng = GateEngine()
|
|
48
|
+
eng.register(_DOMAINS[args.domain]())
|
|
49
|
+
return eng.evaluate(ctx), expected
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def main(argv: list[str] | None = None) -> int:
|
|
53
|
+
ap = _Parser(
|
|
54
|
+
prog="lgd-gate",
|
|
55
|
+
description="LGD Engine judgment core — go / hold / reject from a change context.",
|
|
56
|
+
)
|
|
57
|
+
ap.add_argument("--case", type=str,
|
|
58
|
+
help="path to a case JSON (change context; expected_* keys are stripped)")
|
|
59
|
+
ap.add_argument("--all", metavar="CASES_DIR",
|
|
60
|
+
help="run every *.json case in this directory")
|
|
61
|
+
ap.add_argument("--domain", choices=list(_DOMAINS), default="510k",
|
|
62
|
+
help="judge family to run (default 510k)")
|
|
63
|
+
ap.add_argument("--jurisdiction", choices=["FDA", "NMPA"], default=None,
|
|
64
|
+
help="override the jurisdiction in the case JSON")
|
|
65
|
+
args = ap.parse_args(argv)
|
|
66
|
+
|
|
67
|
+
if bool(args.case) == bool(args.all):
|
|
68
|
+
ap.error("exactly one of --case PATH or --all CASES_DIR is required")
|
|
69
|
+
|
|
70
|
+
if args.all:
|
|
71
|
+
root = Path(args.all)
|
|
72
|
+
if not root.is_dir():
|
|
73
|
+
ap.error(f"--all expects a directory, got {root}")
|
|
74
|
+
cases = sorted(root.glob("*.json"))
|
|
75
|
+
if not cases:
|
|
76
|
+
ap.error(f"no *.json cases under {root}")
|
|
77
|
+
matched = 0
|
|
78
|
+
for c in cases:
|
|
79
|
+
try:
|
|
80
|
+
report, expected = _run_one(c, args)
|
|
81
|
+
except Exception as exc:
|
|
82
|
+
print(f"[ENGINE ERROR] {c.name}: {type(exc).__name__}: {exc}", file=sys.stderr)
|
|
83
|
+
print(f" exit {RC_TOOL_ERROR} = the tool failed; this is not "
|
|
84
|
+
f"a verdict.", file=sys.stderr)
|
|
85
|
+
return RC_TOOL_ERROR
|
|
86
|
+
got = report.verdict
|
|
87
|
+
if expected and got == expected:
|
|
88
|
+
matched += 1
|
|
89
|
+
print(f"{c.name}: {got}"
|
|
90
|
+
+ (f" (expected {expected})" if expected else ""))
|
|
91
|
+
print(f"\n[summary] {matched}/{len(cases)} cases match expected verdict "
|
|
92
|
+
f"(domain={args.domain})")
|
|
93
|
+
return 0 if matched == len(cases) else 1
|
|
94
|
+
|
|
95
|
+
path = Path(args.case)
|
|
96
|
+
if not path.is_absolute():
|
|
97
|
+
path = (Path.cwd() / path).resolve()
|
|
98
|
+
try:
|
|
99
|
+
report, _expected = _run_one(path, args)
|
|
100
|
+
except Exception as exc:
|
|
101
|
+
print(f"[ENGINE ERROR] {type(exc).__name__}: {exc}", file=sys.stderr)
|
|
102
|
+
print(f" exit {RC_TOOL_ERROR} = the tool failed; this is not a "
|
|
103
|
+
f"verdict.", file=sys.stderr)
|
|
104
|
+
return RC_TOOL_ERROR
|
|
105
|
+
print(report.pretty())
|
|
106
|
+
if getattr(report, "errors", None):
|
|
107
|
+
for e in report.errors:
|
|
108
|
+
print(f"[ENGINE ERROR] {e}", file=sys.stderr)
|
|
109
|
+
print(f" exit {RC_TOOL_ERROR} = the tool failed; the verdict above "
|
|
110
|
+
f"is fail-safe, not a judgment.", file=sys.stderr)
|
|
111
|
+
return RC_TOOL_ERROR
|
|
112
|
+
return _RC.get(report.verdict, 1)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
if __name__ == "__main__":
|
|
116
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""GateEngine: aggregate domain-judge verdicts into one GateReport.
|
|
2
|
+
|
|
3
|
+
- Runs each registered DomainJudge against the same ctx.
|
|
4
|
+
- Honors L3-GATED: any critical reason → final verdict = reject.
|
|
5
|
+
- Honors L2-EVIDENCED: go verdict with no evidence across all judges → hold.
|
|
6
|
+
- Honors L1-RECORDED: caller (CLI/web) is expected to persist via LocalMemory;
|
|
7
|
+
engine itself does not write (keeps it pure & testable).
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Any, Dict, List
|
|
13
|
+
|
|
14
|
+
from ..judges.base import DomainJudge, JudgeVerdict
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class GateReport:
|
|
19
|
+
verdict: str # go | hold | reject
|
|
20
|
+
per_judge: Dict[str, Dict[str, Any]] = field(default_factory=dict)
|
|
21
|
+
reasons: List[Dict[str, str]] = field(default_factory=list)
|
|
22
|
+
evidence: List[Dict[str, str]] = field(default_factory=list)
|
|
23
|
+
law_enforcements: List[str] = field(default_factory=list)
|
|
24
|
+
# Judges that raised. The verdict stays fail-safe (reject), but a consumer
|
|
25
|
+
# must be able to tell "the tool broke" from "the change is rejected" —
|
|
26
|
+
# otherwise a broken install reads as a legitimate veto and the CLI exits 2
|
|
27
|
+
# exactly like a real REJECT (red team S7 2026-11-04).
|
|
28
|
+
errors: List[str] = field(default_factory=list)
|
|
29
|
+
|
|
30
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
31
|
+
return {
|
|
32
|
+
"verdict": self.verdict,
|
|
33
|
+
"per_judge": self.per_judge,
|
|
34
|
+
"reasons": self.reasons,
|
|
35
|
+
"evidence": self.evidence,
|
|
36
|
+
"law_enforcements": self.law_enforcements,
|
|
37
|
+
"errors": self.errors,
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
def pretty(self) -> str:
|
|
41
|
+
"""Human-readable gate report for CLI."""
|
|
42
|
+
lines = []
|
|
43
|
+
badge = {"go": "✅ GO", "hold": "🟡 HOLD", "reject": "🔴 REJECT"}[self.verdict]
|
|
44
|
+
lines.append("=" * 60)
|
|
45
|
+
lines.append(f"GATE VERDICT: {badge}")
|
|
46
|
+
lines.append("=" * 60)
|
|
47
|
+
if self.law_enforcements:
|
|
48
|
+
lines.append("[LGD law enforcements]")
|
|
49
|
+
for e in self.law_enforcements:
|
|
50
|
+
lines.append(f" - {e}")
|
|
51
|
+
lines.append("")
|
|
52
|
+
lines.append("[Per-judge verdicts]")
|
|
53
|
+
for dom, v in self.per_judge.items():
|
|
54
|
+
lines.append(f" · {dom}: {v['verdict'].upper()} "
|
|
55
|
+
f"({len(v['reasons'])} reasons, {len(v['evidence'])} evidence)")
|
|
56
|
+
lines.append("")
|
|
57
|
+
lines.append("[Reason chain]")
|
|
58
|
+
for r in self.reasons:
|
|
59
|
+
lines.append(f" [{r['level'].upper():8}] {r['rule']}")
|
|
60
|
+
lines.append(f" {r['detail']}")
|
|
61
|
+
lines.append("")
|
|
62
|
+
lines.append("[Evidence cited]")
|
|
63
|
+
if not self.evidence:
|
|
64
|
+
lines.append(" (none)")
|
|
65
|
+
for e in self.evidence:
|
|
66
|
+
lines.append(f" - ({e['kind']}) {e['ref']}")
|
|
67
|
+
lines.append("=" * 60)
|
|
68
|
+
return "\n".join(lines)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class GateEngine:
|
|
72
|
+
"""Aggregates N DomainJudge instances into one GateReport."""
|
|
73
|
+
|
|
74
|
+
def __init__(self, judges: List[DomainJudge] | None = None) -> None:
|
|
75
|
+
self.judges: List[DomainJudge] = list(judges or [])
|
|
76
|
+
|
|
77
|
+
def register(self, judge: DomainJudge) -> None:
|
|
78
|
+
self.judges.append(judge)
|
|
79
|
+
|
|
80
|
+
# ------------------------------------------------------------------ run
|
|
81
|
+
def evaluate(self, ctx: Dict[str, Any]) -> GateReport:
|
|
82
|
+
per_judge: Dict[str, Dict[str, Any]] = {}
|
|
83
|
+
all_reasons: List[Dict[str, str]] = []
|
|
84
|
+
all_evidence: List[Dict[str, str]] = []
|
|
85
|
+
law_hits: List[str] = []
|
|
86
|
+
|
|
87
|
+
any_critical = False
|
|
88
|
+
any_hold = False
|
|
89
|
+
any_go = False
|
|
90
|
+
errors: List[str] = []
|
|
91
|
+
|
|
92
|
+
for j in self.judges:
|
|
93
|
+
dom = getattr(j, "domain", j.__class__.__name__)
|
|
94
|
+
try:
|
|
95
|
+
v = j.evaluate(ctx)
|
|
96
|
+
except Exception as exc:
|
|
97
|
+
# A crashing judge is a critical failure: never let it pass.
|
|
98
|
+
# It is also NOT a verdict, so it is recorded as an error.
|
|
99
|
+
errors.append(f"{dom}: {type(exc).__name__}: {exc}")
|
|
100
|
+
v = {
|
|
101
|
+
"verdict": "reject",
|
|
102
|
+
"reasons": [{"rule": f"{dom}.exception",
|
|
103
|
+
"level": "critical",
|
|
104
|
+
"detail": f"judge raised: {exc!r}"}],
|
|
105
|
+
"evidence": [],
|
|
106
|
+
}
|
|
107
|
+
per_judge[dom] = v
|
|
108
|
+
|
|
109
|
+
for r in v.get("reasons", []):
|
|
110
|
+
all_reasons.append(r)
|
|
111
|
+
if r["level"] == "critical":
|
|
112
|
+
any_critical = True
|
|
113
|
+
elif r["level"] == "major":
|
|
114
|
+
any_hold = True
|
|
115
|
+
# L2 downgrade is a hold signal regardless of how the judge tags
|
|
116
|
+
# it; detect by rule name, not by level (LGD-EVAL-2026-009 H1).
|
|
117
|
+
if r["rule"] == "L2-EVIDENCED":
|
|
118
|
+
law_hits.append("L2-EVIDENCED: go-without-evidence downgraded")
|
|
119
|
+
|
|
120
|
+
for e in v.get("evidence", []):
|
|
121
|
+
all_evidence.append(e)
|
|
122
|
+
|
|
123
|
+
if v["verdict"] == "reject":
|
|
124
|
+
any_critical = True
|
|
125
|
+
elif v["verdict"] == "hold":
|
|
126
|
+
any_hold = True
|
|
127
|
+
elif v["verdict"] == "go":
|
|
128
|
+
any_go = True
|
|
129
|
+
|
|
130
|
+
# ---- L3-GATED: any critical → reject ----
|
|
131
|
+
if any_critical:
|
|
132
|
+
final = "reject"
|
|
133
|
+
# dedup
|
|
134
|
+
if "L3-GATED: critical veto forced reject" not in law_hits:
|
|
135
|
+
law_hits.append("L3-GATED: at least one critical rule fired → forced reject")
|
|
136
|
+
elif any_hold:
|
|
137
|
+
final = "hold"
|
|
138
|
+
elif any_go:
|
|
139
|
+
# ---- L2-EVIDENCED at aggregate level ----
|
|
140
|
+
if not all_evidence:
|
|
141
|
+
final = "hold"
|
|
142
|
+
law_hits.append("L2-EVIDENCED: aggregate go with zero evidence → hold")
|
|
143
|
+
else:
|
|
144
|
+
final = "go"
|
|
145
|
+
else:
|
|
146
|
+
final = "hold" # no judges ran — safe default
|
|
147
|
+
|
|
148
|
+
return GateReport(
|
|
149
|
+
errors=errors,
|
|
150
|
+
verdict=final,
|
|
151
|
+
per_judge=per_judge,
|
|
152
|
+
reasons=all_reasons,
|
|
153
|
+
evidence=all_evidence,
|
|
154
|
+
law_enforcements=law_hits,
|
|
155
|
+
)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""lgd_engine.judges — domain adapters."""
|
|
2
|
+
from .base import (
|
|
3
|
+
DomainJudge,
|
|
4
|
+
JudgeVerdict,
|
|
5
|
+
Reason,
|
|
6
|
+
Evidence,
|
|
7
|
+
VALID_VERDICTS,
|
|
8
|
+
VALID_LEVELS,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"DomainJudge",
|
|
13
|
+
"JudgeVerdict",
|
|
14
|
+
"Reason",
|
|
15
|
+
"Evidence",
|
|
16
|
+
"VALID_VERDICTS",
|
|
17
|
+
"VALID_LEVELS",
|
|
18
|
+
]
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""DomainJudge Protocol + JudgeVerdict data structures.
|
|
2
|
+
|
|
3
|
+
Fixed schema (shared with parallel Demo2 line):
|
|
4
|
+
JudgeVerdict = {
|
|
5
|
+
"verdict": "go" | "hold" | "reject",
|
|
6
|
+
"reasons": [{"rule": str, "level": "critical|major|minor", "detail": str}],
|
|
7
|
+
"evidence": [{"kind": str, "ref": str}],
|
|
8
|
+
}
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from dataclasses import dataclass, field, asdict
|
|
13
|
+
from typing import Any, Dict, List, Protocol, runtime_checkable
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
VALID_VERDICTS = ("go", "hold", "reject")
|
|
17
|
+
VALID_LEVELS = ("critical", "major", "minor")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class Reason:
|
|
22
|
+
rule: str
|
|
23
|
+
level: str
|
|
24
|
+
detail: str
|
|
25
|
+
|
|
26
|
+
def __post_init__(self) -> None:
|
|
27
|
+
if self.level not in VALID_LEVELS:
|
|
28
|
+
raise ValueError(f"invalid reason level: {self.level}")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class Evidence:
|
|
33
|
+
kind: str # e.g. "fda_recall", "fda_510k", "nmpa_recall", "internal_verification"
|
|
34
|
+
ref: str # e.g. "Z-1924-2025", "K190204", URL, or local doc id
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class JudgeVerdict:
|
|
39
|
+
verdict: str # go | hold | reject
|
|
40
|
+
reasons: List[Reason] = field(default_factory=list)
|
|
41
|
+
evidence: List[Evidence] = field(default_factory=list)
|
|
42
|
+
|
|
43
|
+
def __post_init__(self) -> None:
|
|
44
|
+
if self.verdict not in VALID_VERDICTS:
|
|
45
|
+
raise ValueError(f"invalid verdict: {self.verdict}")
|
|
46
|
+
|
|
47
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
48
|
+
return {
|
|
49
|
+
"verdict": self.verdict,
|
|
50
|
+
"reasons": [asdict(r) for r in self.reasons],
|
|
51
|
+
"evidence": [asdict(e) for e in self.evidence],
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def has_critical(self) -> bool:
|
|
56
|
+
return any(r.level == "critical" for r in self.reasons)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@runtime_checkable
|
|
60
|
+
class DomainJudge(Protocol):
|
|
61
|
+
"""Every domain adapter implements this. ctx is an input JSON dict."""
|
|
62
|
+
|
|
63
|
+
domain: str
|
|
64
|
+
|
|
65
|
+
def evaluate(self, ctx: Dict[str, Any]) -> Dict[str, Any]: # pragma: no cover - protocol
|
|
66
|
+
...
|