eigenhelm 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.
- eigenhelm/__init__.py +29 -0
- eigenhelm/cli/__init__.py +1 -0
- eigenhelm/cli/evaluate.py +260 -0
- eigenhelm/cli/harness.py +120 -0
- eigenhelm/cli/inspect.py +86 -0
- eigenhelm/cli/serve.py +76 -0
- eigenhelm/cli/train.py +134 -0
- eigenhelm/critic/__init__.py +165 -0
- eigenhelm/critic/aesthetic_critic.py +254 -0
- eigenhelm/critic/birkhoff.py +28 -0
- eigenhelm/critic/compression.py +31 -0
- eigenhelm/critic/entropy.py +30 -0
- eigenhelm/eigenspace/__init__.py +96 -0
- eigenhelm/eigenspace/projection.py +93 -0
- eigenhelm/harness/__init__.py +6 -0
- eigenhelm/harness/report.py +43 -0
- eigenhelm/harness/runner.py +140 -0
- eigenhelm/helm/__init__.py +137 -0
- eigenhelm/helm/dynamic_helm.py +182 -0
- eigenhelm/helm/models.py +111 -0
- eigenhelm/helm/pid.py +72 -0
- eigenhelm/metrics/__init__.py +1 -0
- eigenhelm/metrics/cyclomatic.py +55 -0
- eigenhelm/metrics/halstead.py +116 -0
- eigenhelm/metrics/wl_hash.py +102 -0
- eigenhelm/models.py +151 -0
- eigenhelm/parsers/__init__.py +1 -0
- eigenhelm/parsers/language_map.py +100 -0
- eigenhelm/parsers/tree_sitter.py +209 -0
- eigenhelm/serve/__init__.py +5 -0
- eigenhelm/serve/app.py +89 -0
- eigenhelm/serve/middleware/__init__.py +1 -0
- eigenhelm/serve/middleware/size_limit.py +89 -0
- eigenhelm/serve/middleware/timeout.py +61 -0
- eigenhelm/serve/models.py +82 -0
- eigenhelm/serve/routes/__init__.py +1 -0
- eigenhelm/serve/routes/evaluate.py +115 -0
- eigenhelm/serve/routes/health.py +36 -0
- eigenhelm/training/__init__.py +195 -0
- eigenhelm/training/corpus.py +75 -0
- eigenhelm/training/pca.py +85 -0
- eigenhelm/training/serialization.py +41 -0
- eigenhelm/virtue_extractor.py +232 -0
- eigenhelm-0.1.0.dist-info/METADATA +221 -0
- eigenhelm-0.1.0.dist-info/RECORD +49 -0
- eigenhelm-0.1.0.dist-info/WHEEL +5 -0
- eigenhelm-0.1.0.dist-info/entry_points.txt +6 -0
- eigenhelm-0.1.0.dist-info/licenses/LICENSE +618 -0
- eigenhelm-0.1.0.dist-info/top_level.txt +1 -0
eigenhelm/__init__.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Eigenhelm — Software Poet Conscience.
|
|
2
|
+
|
|
3
|
+
Stage 1: IVirtueExtractor
|
|
4
|
+
Language-agnostic code metric extraction producing 69-dimensional feature vectors.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from eigenhelm.models import (
|
|
8
|
+
CodeUnit,
|
|
9
|
+
CyclomaticMetrics,
|
|
10
|
+
EigenspaceModel,
|
|
11
|
+
FeatureVector,
|
|
12
|
+
HalsteadMetrics,
|
|
13
|
+
ProjectionResult,
|
|
14
|
+
TrainingResult,
|
|
15
|
+
UnsupportedLanguageError,
|
|
16
|
+
)
|
|
17
|
+
from eigenhelm.virtue_extractor import VirtueExtractor
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"VirtueExtractor",
|
|
21
|
+
"CodeUnit",
|
|
22
|
+
"FeatureVector",
|
|
23
|
+
"HalsteadMetrics",
|
|
24
|
+
"CyclomaticMetrics",
|
|
25
|
+
"EigenspaceModel",
|
|
26
|
+
"ProjectionResult",
|
|
27
|
+
"TrainingResult",
|
|
28
|
+
"UnsupportedLanguageError",
|
|
29
|
+
]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""CLI entry points for eigenhelm-train and eigenhelm-inspect."""
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""eigenhelm-evaluate CLI — evaluate files in-process.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
eigenhelm-evaluate path/to/file.py
|
|
5
|
+
eigenhelm-evaluate src/ --model model.npz
|
|
6
|
+
echo "def f(): pass" | eigenhelm-evaluate --language python
|
|
7
|
+
eigenhelm-evaluate src/ --json
|
|
8
|
+
|
|
9
|
+
Exit codes:
|
|
10
|
+
0 All files accepted or warned
|
|
11
|
+
1 One or more files rejected
|
|
12
|
+
2 Runtime error
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import sys
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from eigenhelm.helm import DynamicHelm
|
|
24
|
+
from eigenhelm.helm.models import EvaluationRequest, EvaluationResponse
|
|
25
|
+
from eigenhelm.parsers.language_map import LANGUAGE_MAP
|
|
26
|
+
|
|
27
|
+
# Build extension → language mapping from LANGUAGE_MAP
|
|
28
|
+
EXTENSION_TO_LANGUAGE: dict[str, str] = {ext: lang for lang, (_, ext) in LANGUAGE_MAP.items()}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
DEFAULT_EXCLUDES = {".git", "__pycache__", ".venv", "venv", "env", "node_modules", ".pytest_cache", "dist", "build"}
|
|
32
|
+
|
|
33
|
+
def discover_files(paths: list[Path]) -> list[tuple[Path, str]]:
|
|
34
|
+
"""Discover eligible source files from given paths.
|
|
35
|
+
|
|
36
|
+
For files: looks up language via extension, skips unrecognized.
|
|
37
|
+
For directories: walks recursively, filtering out excluded directories.
|
|
38
|
+
Does NOT follow symlinks.
|
|
39
|
+
|
|
40
|
+
Returns (path, language) pairs sorted by path.
|
|
41
|
+
"""
|
|
42
|
+
results: list[tuple[Path, str]] = []
|
|
43
|
+
for p in paths:
|
|
44
|
+
if p.is_file():
|
|
45
|
+
lang = EXTENSION_TO_LANGUAGE.get(p.suffix)
|
|
46
|
+
if lang is None:
|
|
47
|
+
print(f"WARNING: Skipping {p} (unrecognized extension)", file=sys.stderr)
|
|
48
|
+
continue
|
|
49
|
+
results.append((p, lang))
|
|
50
|
+
elif p.is_dir():
|
|
51
|
+
excludes = set(DEFAULT_EXCLUDES)
|
|
52
|
+
ignore_file = p / ".eigenhelmignore"
|
|
53
|
+
if ignore_file.is_file():
|
|
54
|
+
try:
|
|
55
|
+
custom_excludes = [
|
|
56
|
+
line.strip() for line in ignore_file.read_text().splitlines()
|
|
57
|
+
if line.strip() and not line.startswith("#")
|
|
58
|
+
]
|
|
59
|
+
excludes.update(custom_excludes)
|
|
60
|
+
except OSError:
|
|
61
|
+
pass
|
|
62
|
+
|
|
63
|
+
for root, dirs, files in os.walk(p, followlinks=False):
|
|
64
|
+
# Prune excluded directories
|
|
65
|
+
retained = [d for d in dirs if d not in excludes and not d.endswith(".egg-info")]
|
|
66
|
+
dirs.clear()
|
|
67
|
+
dirs.extend(retained)
|
|
68
|
+
|
|
69
|
+
for filename in files:
|
|
70
|
+
if filename in excludes:
|
|
71
|
+
continue
|
|
72
|
+
child = Path(root) / filename
|
|
73
|
+
try:
|
|
74
|
+
if child.is_symlink() or not child.is_file():
|
|
75
|
+
continue
|
|
76
|
+
lang = EXTENSION_TO_LANGUAGE.get(child.suffix)
|
|
77
|
+
if lang is not None:
|
|
78
|
+
results.append((child, lang))
|
|
79
|
+
except OSError:
|
|
80
|
+
continue
|
|
81
|
+
|
|
82
|
+
return sorted(results, key=lambda x: x[0])
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def format_result_human(path: Path | str, response: EvaluationResponse) -> str:
|
|
86
|
+
"""Format a single result for human-readable output."""
|
|
87
|
+
lines = [
|
|
88
|
+
f"{path}",
|
|
89
|
+
f" decision: {response.decision}",
|
|
90
|
+
f" score: {response.score:.2f}",
|
|
91
|
+
f" confidence: {response.structural_confidence}",
|
|
92
|
+
]
|
|
93
|
+
if response.critique.violations:
|
|
94
|
+
lines.append(" violations:")
|
|
95
|
+
for v in response.critique.violations:
|
|
96
|
+
pct = v.contribution * 100
|
|
97
|
+
lines.append(f" {v.dimension} (contribution: {pct:.0f}%)")
|
|
98
|
+
if response.warning:
|
|
99
|
+
lines.append(f" warning: {response.warning}")
|
|
100
|
+
return "\n".join(lines)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def format_summary_human(
|
|
104
|
+
results: list[tuple[Path | str, EvaluationResponse]],
|
|
105
|
+
) -> str:
|
|
106
|
+
"""Format aggregate summary for human-readable output."""
|
|
107
|
+
total = len(results)
|
|
108
|
+
accepted = sum(1 for _, r in results if r.decision == "accept")
|
|
109
|
+
warned = sum(1 for _, r in results if r.decision == "warn")
|
|
110
|
+
rejected = sum(1 for _, r in results if r.decision == "reject")
|
|
111
|
+
mean_score = sum(r.score for _, r in results) / total if total else 0.0
|
|
112
|
+
sep = "─" * 40
|
|
113
|
+
return (
|
|
114
|
+
f"{sep}\n"
|
|
115
|
+
f"Summary: {total} files | {accepted} accepted | {warned} warned | "
|
|
116
|
+
f"{rejected} rejected | mean score: {mean_score:.2f}"
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def format_results_json(
|
|
121
|
+
results: list[tuple[Path | str, EvaluationResponse]],
|
|
122
|
+
) -> str:
|
|
123
|
+
"""Format results as JSON matching BatchResponse schema."""
|
|
124
|
+
result_dicts = []
|
|
125
|
+
for path, resp in results:
|
|
126
|
+
violations = [
|
|
127
|
+
{
|
|
128
|
+
"dimension": v.dimension,
|
|
129
|
+
"raw_value": v.raw_value,
|
|
130
|
+
"normalized_value": v.normalized_value,
|
|
131
|
+
"contribution": v.contribution,
|
|
132
|
+
}
|
|
133
|
+
for v in resp.critique.violations
|
|
134
|
+
]
|
|
135
|
+
result_dicts.append(
|
|
136
|
+
{
|
|
137
|
+
"decision": resp.decision,
|
|
138
|
+
"score": resp.score,
|
|
139
|
+
"structural_confidence": resp.structural_confidence,
|
|
140
|
+
"violations": violations,
|
|
141
|
+
"warning": resp.warning,
|
|
142
|
+
"file_path": str(path),
|
|
143
|
+
}
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
total = len(result_dicts)
|
|
147
|
+
accepted = sum(1 for r in result_dicts if r["decision"] == "accept")
|
|
148
|
+
warned = sum(1 for r in result_dicts if r["decision"] == "warn")
|
|
149
|
+
rejected = sum(1 for r in result_dicts if r["decision"] == "reject")
|
|
150
|
+
mean_score = sum(r["score"] for r in result_dicts) / total if total else 0.0
|
|
151
|
+
|
|
152
|
+
if rejected > 0:
|
|
153
|
+
overall = "reject"
|
|
154
|
+
elif warned > 0:
|
|
155
|
+
overall = "warn"
|
|
156
|
+
else:
|
|
157
|
+
overall = "accept"
|
|
158
|
+
|
|
159
|
+
output = {
|
|
160
|
+
"results": result_dicts,
|
|
161
|
+
"summary": {
|
|
162
|
+
"overall_decision": overall,
|
|
163
|
+
"total_files": total,
|
|
164
|
+
"accepted": accepted,
|
|
165
|
+
"warned": warned,
|
|
166
|
+
"rejected": rejected,
|
|
167
|
+
"mean_score": mean_score,
|
|
168
|
+
},
|
|
169
|
+
}
|
|
170
|
+
return json.dumps(output, indent=2)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _evaluate_stdin(
|
|
174
|
+
helm: DynamicHelm,
|
|
175
|
+
language: str,
|
|
176
|
+
) -> list[tuple[Path | str, EvaluationResponse]]:
|
|
177
|
+
"""Read from stdin and evaluate."""
|
|
178
|
+
source = sys.stdin.read()
|
|
179
|
+
resp = helm.evaluate(EvaluationRequest(source=source, language=language))
|
|
180
|
+
return [("<stdin>", resp)]
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _evaluate_paths(
|
|
184
|
+
helm: DynamicHelm,
|
|
185
|
+
paths: list[Path],
|
|
186
|
+
) -> list[tuple[Path | str, EvaluationResponse]]:
|
|
187
|
+
"""Discover and evaluate files from given paths."""
|
|
188
|
+
files = discover_files(paths)
|
|
189
|
+
results: list[tuple[Path | str, EvaluationResponse]] = []
|
|
190
|
+
for path, lang in files:
|
|
191
|
+
try:
|
|
192
|
+
source = path.read_text(encoding="utf-8")
|
|
193
|
+
except UnicodeDecodeError:
|
|
194
|
+
print(f"WARNING: Skipping binary file {path}", file=sys.stderr)
|
|
195
|
+
continue
|
|
196
|
+
resp = helm.evaluate(EvaluationRequest(source=source, language=lang, file_path=str(path)))
|
|
197
|
+
results.append((path, resp))
|
|
198
|
+
return results
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def main(argv: list[str] | None = None) -> int:
|
|
202
|
+
"""Entry point for eigenhelm-evaluate.
|
|
203
|
+
|
|
204
|
+
Returns exit code: 0 (no rejections), 1 (any rejection), 2 (runtime error).
|
|
205
|
+
"""
|
|
206
|
+
parser = argparse.ArgumentParser(
|
|
207
|
+
prog="eigenhelm-evaluate",
|
|
208
|
+
description="Evaluate code files against the aesthetic manifold",
|
|
209
|
+
)
|
|
210
|
+
parser.add_argument("paths", nargs="*", type=Path, help="File or directory paths")
|
|
211
|
+
parser.add_argument(
|
|
212
|
+
"--language",
|
|
213
|
+
default=None,
|
|
214
|
+
help="Language (required for stdin mode when no paths given)",
|
|
215
|
+
)
|
|
216
|
+
parser.add_argument("--model", default=None, help="Path to .npz eigenspace model")
|
|
217
|
+
parser.add_argument("--json", dest="json_output", action="store_true", help="JSON output")
|
|
218
|
+
args = parser.parse_args(argv)
|
|
219
|
+
|
|
220
|
+
try:
|
|
221
|
+
eigenspace = None
|
|
222
|
+
if args.model:
|
|
223
|
+
from eigenhelm.eigenspace import load_model
|
|
224
|
+
|
|
225
|
+
eigenspace = load_model(args.model)
|
|
226
|
+
|
|
227
|
+
helm = DynamicHelm(eigenspace=eigenspace)
|
|
228
|
+
|
|
229
|
+
if not args.paths:
|
|
230
|
+
if not args.language:
|
|
231
|
+
print(
|
|
232
|
+
"ERROR: --language is required when reading from stdin",
|
|
233
|
+
file=sys.stderr,
|
|
234
|
+
)
|
|
235
|
+
return 2
|
|
236
|
+
results = _evaluate_stdin(helm, args.language)
|
|
237
|
+
else:
|
|
238
|
+
results = _evaluate_paths(helm, args.paths)
|
|
239
|
+
|
|
240
|
+
if not results:
|
|
241
|
+
return 0
|
|
242
|
+
|
|
243
|
+
if args.json_output:
|
|
244
|
+
print(format_results_json(results))
|
|
245
|
+
else:
|
|
246
|
+
for path, resp in results:
|
|
247
|
+
print(format_result_human(path, resp))
|
|
248
|
+
if len(results) > 1:
|
|
249
|
+
print(format_summary_human(results))
|
|
250
|
+
|
|
251
|
+
has_rejection = any(r.decision == "reject" for _, r in results)
|
|
252
|
+
return 1 if has_rejection else 0
|
|
253
|
+
|
|
254
|
+
except Exception as exc:
|
|
255
|
+
print(f"ERROR: {exc}", file=sys.stderr)
|
|
256
|
+
return 2
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
if __name__ == "__main__":
|
|
260
|
+
sys.exit(main())
|
eigenhelm/cli/harness.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""eigenhelm-harness CLI — compare two corpora with Mann-Whitney U test.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
eigenhelm-harness --before corpus/before/ --after corpus/after/
|
|
5
|
+
eigenhelm-harness --before before/ --after after/ --model model.npz --json
|
|
6
|
+
|
|
7
|
+
Exit codes:
|
|
8
|
+
0 Harness completed (regardless of significance)
|
|
9
|
+
1 One or both corpus directories empty/unreadable
|
|
10
|
+
2 Runtime error
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import json
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from eigenhelm.harness.report import HarnessReport
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def format_harness_human(report: HarnessReport) -> str:
|
|
24
|
+
"""Format a HarnessReport for human-readable output."""
|
|
25
|
+
b = report.before
|
|
26
|
+
a = report.after
|
|
27
|
+
|
|
28
|
+
delta_symbol = "✓ Improvement" if report.improvement else "✗ No improvement"
|
|
29
|
+
sig_symbol = "YES ✓" if report.significant else "NO ✗"
|
|
30
|
+
|
|
31
|
+
lines = [
|
|
32
|
+
"eigenhelm-harness: Quality Comparison Report",
|
|
33
|
+
f" Before corpus: {b.n_files} files evaluated, {b.n_skipped} skipped",
|
|
34
|
+
f" Mean score: {b.mean_score:.2f} "
|
|
35
|
+
f"(median: {b.median_score:.2f}, std: {b.std_score:.2f})",
|
|
36
|
+
f" Accept/Warn/Reject: {b.accepted} / {b.warned} / {b.rejected}",
|
|
37
|
+
"",
|
|
38
|
+
f" After corpus: {a.n_files} files evaluated, {a.n_skipped} skipped",
|
|
39
|
+
f" Mean score: {a.mean_score:.2f} "
|
|
40
|
+
f"(median: {a.median_score:.2f}, std: {a.std_score:.2f})",
|
|
41
|
+
f" Accept/Warn/Reject: {a.accepted} / {a.warned} / {a.rejected}",
|
|
42
|
+
"",
|
|
43
|
+
f" Delta (after − before): {report.delta_mean_score:+.2f} {delta_symbol}",
|
|
44
|
+
f" Mann-Whitney U: {report.u_statistic:.1f}",
|
|
45
|
+
f" p-value: {report.p_value:.4f}",
|
|
46
|
+
f" Significant at α=0.05: {sig_symbol}",
|
|
47
|
+
]
|
|
48
|
+
return "\n".join(lines)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def format_harness_json(report: HarnessReport) -> str:
|
|
52
|
+
"""Format a HarnessReport as JSON matching field names exactly."""
|
|
53
|
+
|
|
54
|
+
def _stats_dict(s):
|
|
55
|
+
return {
|
|
56
|
+
"n_files": s.n_files,
|
|
57
|
+
"n_skipped": s.n_skipped,
|
|
58
|
+
"mean_score": s.mean_score,
|
|
59
|
+
"median_score": s.median_score,
|
|
60
|
+
"std_score": s.std_score,
|
|
61
|
+
"accepted": s.accepted,
|
|
62
|
+
"warned": s.warned,
|
|
63
|
+
"rejected": s.rejected,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
output = {
|
|
67
|
+
"before": _stats_dict(report.before),
|
|
68
|
+
"after": _stats_dict(report.after),
|
|
69
|
+
"delta_mean_score": report.delta_mean_score,
|
|
70
|
+
"u_statistic": report.u_statistic,
|
|
71
|
+
"p_value": report.p_value,
|
|
72
|
+
"significant": report.significant,
|
|
73
|
+
"improvement": report.improvement,
|
|
74
|
+
}
|
|
75
|
+
return json.dumps(output, indent=2)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def main(argv: list[str] | None = None) -> int:
|
|
79
|
+
"""Entry point for eigenhelm-harness.
|
|
80
|
+
|
|
81
|
+
Returns exit code: 0 (success), 1 (empty corpus), 2 (runtime error).
|
|
82
|
+
"""
|
|
83
|
+
parser = argparse.ArgumentParser(
|
|
84
|
+
prog="eigenhelm-harness",
|
|
85
|
+
description="Compare two corpora with statistical testing",
|
|
86
|
+
)
|
|
87
|
+
parser.add_argument("--before", required=True, type=Path, help="Before corpus directory")
|
|
88
|
+
parser.add_argument("--after", required=True, type=Path, help="After corpus directory")
|
|
89
|
+
parser.add_argument("--model", default=None, help="Path to .npz eigenspace model")
|
|
90
|
+
parser.add_argument("--json", dest="json_output", action="store_true", help="JSON output")
|
|
91
|
+
args = parser.parse_args(argv)
|
|
92
|
+
|
|
93
|
+
try:
|
|
94
|
+
eigenspace = None
|
|
95
|
+
if args.model:
|
|
96
|
+
from eigenhelm.eigenspace import load_model
|
|
97
|
+
|
|
98
|
+
eigenspace = load_model(args.model)
|
|
99
|
+
|
|
100
|
+
from eigenhelm.harness.runner import run_harness
|
|
101
|
+
|
|
102
|
+
report = run_harness(args.before, args.after, eigenspace=eigenspace)
|
|
103
|
+
|
|
104
|
+
if args.json_output:
|
|
105
|
+
print(format_harness_json(report))
|
|
106
|
+
else:
|
|
107
|
+
print(format_harness_human(report))
|
|
108
|
+
|
|
109
|
+
return 0
|
|
110
|
+
|
|
111
|
+
except ValueError as exc:
|
|
112
|
+
print(f"ERROR: {exc}", file=sys.stderr)
|
|
113
|
+
return 1
|
|
114
|
+
except Exception as exc:
|
|
115
|
+
print(f"ERROR: {exc}", file=sys.stderr)
|
|
116
|
+
return 2
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
if __name__ == "__main__":
|
|
120
|
+
sys.exit(main())
|
eigenhelm/cli/inspect.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""eigenhelm-inspect: Inspect a trained .npz eigenspace model."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def main(argv: list[str] | None = None) -> None:
|
|
12
|
+
"""Entry point for eigenhelm-inspect CLI.
|
|
13
|
+
|
|
14
|
+
Exit codes:
|
|
15
|
+
0 — success
|
|
16
|
+
1 — invalid model file (missing keys, bad format)
|
|
17
|
+
"""
|
|
18
|
+
parser = argparse.ArgumentParser(
|
|
19
|
+
prog="eigenhelm-inspect",
|
|
20
|
+
description="Inspect a trained .npz eigenspace model.",
|
|
21
|
+
)
|
|
22
|
+
parser.add_argument(
|
|
23
|
+
"model_path",
|
|
24
|
+
metavar="model-path",
|
|
25
|
+
type=Path,
|
|
26
|
+
help="Path to a .npz eigenspace model",
|
|
27
|
+
)
|
|
28
|
+
parser.add_argument(
|
|
29
|
+
"--json",
|
|
30
|
+
action="store_true",
|
|
31
|
+
dest="as_json",
|
|
32
|
+
help="Output as JSON instead of plain text",
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
args = parser.parse_args(argv)
|
|
36
|
+
|
|
37
|
+
from eigenhelm.training import inspect_model
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
info = inspect_model(args.model_path)
|
|
41
|
+
except FileNotFoundError as exc:
|
|
42
|
+
print(f"eigenhelm-inspect: error: {exc}", file=sys.stderr)
|
|
43
|
+
sys.exit(1)
|
|
44
|
+
except KeyError as exc:
|
|
45
|
+
print(f"eigenhelm-inspect: invalid model file: {exc}", file=sys.stderr)
|
|
46
|
+
sys.exit(1)
|
|
47
|
+
except Exception as exc:
|
|
48
|
+
print(f"eigenhelm-inspect: error: {exc}", file=sys.stderr)
|
|
49
|
+
sys.exit(1)
|
|
50
|
+
|
|
51
|
+
if args.as_json:
|
|
52
|
+
evr = info["explained_variance_ratio"]
|
|
53
|
+
out = {
|
|
54
|
+
"version": info["version"],
|
|
55
|
+
"n_components": info["n_components"],
|
|
56
|
+
"corpus_hash": info["corpus_hash"],
|
|
57
|
+
"projection_shape": list(info["projection_shape"]),
|
|
58
|
+
"cumulative_variance": info["cumulative_variance"],
|
|
59
|
+
"explained_variance_ratio": evr.tolist() if evr is not None else None,
|
|
60
|
+
"mean_range": list(info["mean_range"]),
|
|
61
|
+
"std_range": list(info["std_range"]),
|
|
62
|
+
}
|
|
63
|
+
print(json.dumps(out, indent=2))
|
|
64
|
+
else:
|
|
65
|
+
evr = info["explained_variance_ratio"]
|
|
66
|
+
if evr is not None:
|
|
67
|
+
per_pc = " ".join(f"PC{i + 1}: {v * 100:.1f}%" for i, v in enumerate(evr))
|
|
68
|
+
variance_line = f"{info['cumulative_variance'] * 100:.1f}% cumulative"
|
|
69
|
+
else:
|
|
70
|
+
per_pc = "(not available)"
|
|
71
|
+
variance_line = "N/A"
|
|
72
|
+
|
|
73
|
+
lines = [
|
|
74
|
+
"eigenhelm-inspect: Model summary",
|
|
75
|
+
f" Version: {info['version']}",
|
|
76
|
+
f" Components: {info['n_components']}",
|
|
77
|
+
f" Corpus hash: {info['corpus_hash']}",
|
|
78
|
+
f" Projection: {info['projection_shape']}",
|
|
79
|
+
f" Variance: {variance_line}",
|
|
80
|
+
f" {per_pc}",
|
|
81
|
+
f" Feature mean: [{info['mean_range'][0]:.2f}, {info['mean_range'][1]:.2f}] (range)",
|
|
82
|
+
f" Feature std: [{info['std_range'][0]:.2f}, {info['std_range'][1]:.2f}] (range)",
|
|
83
|
+
]
|
|
84
|
+
print("\n".join(lines))
|
|
85
|
+
|
|
86
|
+
sys.exit(0)
|
eigenhelm/cli/serve.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""eigenhelm-serve CLI — start the FastAPI sidecar server.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
eigenhelm-serve --host 0.0.0.0 --port 8080 --model model.npz
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import sys
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def main(argv: list[str] | None = None) -> None:
|
|
14
|
+
"""Entry point for eigenhelm-serve."""
|
|
15
|
+
try:
|
|
16
|
+
import uvicorn # noqa: F401, I001
|
|
17
|
+
from eigenhelm.serve import create_app # noqa: I001
|
|
18
|
+
except ImportError:
|
|
19
|
+
print(
|
|
20
|
+
"ERROR: eigenhelm[serve] extras not installed. Run: pip install 'eigenhelm[serve]'",
|
|
21
|
+
file=sys.stderr,
|
|
22
|
+
)
|
|
23
|
+
sys.exit(1)
|
|
24
|
+
|
|
25
|
+
parser = argparse.ArgumentParser(
|
|
26
|
+
prog="eigenhelm-serve",
|
|
27
|
+
description="Start the eigenhelm evaluation sidecar server",
|
|
28
|
+
)
|
|
29
|
+
parser.add_argument("--host", default="0.0.0.0", help="Bind address (default: 0.0.0.0)")
|
|
30
|
+
parser.add_argument("--port", type=int, default=8080, help="Port (default: 8080)")
|
|
31
|
+
parser.add_argument("--model", default=None, help="Path to .npz eigenspace model")
|
|
32
|
+
parser.add_argument(
|
|
33
|
+
"--timeout-graceful-shutdown",
|
|
34
|
+
type=int,
|
|
35
|
+
default=30,
|
|
36
|
+
help="Seconds to wait for in-flight requests on SIGTERM (default: 30)",
|
|
37
|
+
)
|
|
38
|
+
args = parser.parse_args(argv)
|
|
39
|
+
|
|
40
|
+
eigenspace = None
|
|
41
|
+
if args.model:
|
|
42
|
+
try:
|
|
43
|
+
from eigenhelm.eigenspace import load_model
|
|
44
|
+
|
|
45
|
+
eigenspace = load_model(args.model)
|
|
46
|
+
print(
|
|
47
|
+
f"INFO: Loading eigenspace model from {args.model} "
|
|
48
|
+
f"(version={eigenspace.version}, corpus_hash={eigenspace.corpus_hash})",
|
|
49
|
+
file=sys.stderr,
|
|
50
|
+
)
|
|
51
|
+
except (FileNotFoundError, OSError) as exc:
|
|
52
|
+
print(f"ERROR: Failed to load model: {exc}", file=sys.stderr)
|
|
53
|
+
sys.exit(1)
|
|
54
|
+
else:
|
|
55
|
+
print(
|
|
56
|
+
"WARNING: No eigenspace model loaded; running in low-confidence mode",
|
|
57
|
+
file=sys.stderr,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
app = create_app(eigenspace=eigenspace)
|
|
61
|
+
model_status = "loaded" if eigenspace else "none"
|
|
62
|
+
print(
|
|
63
|
+
f"INFO: eigenhelm-serve starting on {args.host}:{args.port} (model={model_status})",
|
|
64
|
+
file=sys.stderr,
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
uvicorn.run(
|
|
68
|
+
app,
|
|
69
|
+
host=args.host,
|
|
70
|
+
port=args.port,
|
|
71
|
+
timeout_graceful_shutdown=args.timeout_graceful_shutdown,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
if __name__ == "__main__":
|
|
76
|
+
main()
|