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 ADDED
@@ -0,0 +1,9 @@
1
+ """lgd-engine: LGD (有籍有证有门禁) minimal kernel.
2
+
3
+ Version: 0.1.0
4
+ Domain-agnostic gate + memory + judge skeleton, with the 510(k) change
5
+ classification judge as the first concrete domain adapter.
6
+ """
7
+
8
+ __version__ = "0.1.0"
9
+ __all__ = ["__version__"]
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,4 @@
1
+ """lgd_engine.gate — GateEngine aggregates domain verdicts and enforces LGD laws."""
2
+ from .engine import GateEngine, GateReport
3
+
4
+ __all__ = ["GateEngine", "GateReport"]
@@ -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
+ ...