runledger-cli 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.
- cli/__init__.py +0 -0
- cli/guided.py +444 -0
- cli/ledger.py +1276 -0
- cli/tests/__init__.py +0 -0
- cli/tests/test_data_features.py +254 -0
- cli/tests/test_integration.py +177 -0
- cli/tests/test_ledger.py +378 -0
- cli/tests/test_real_results.py +153 -0
- runledger_cli-0.1.0.dist-info/METADATA +198 -0
- runledger_cli-0.1.0.dist-info/RECORD +13 -0
- runledger_cli-0.1.0.dist-info/WHEEL +5 -0
- runledger_cli-0.1.0.dist-info/entry_points.txt +2 -0
- runledger_cli-0.1.0.dist-info/top_level.txt +1 -0
cli/tests/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Feature showcase tests using test-data/sample-runs.
|
|
3
|
+
Each test is labelled with a version number (v1, v2, v3) to reflect
|
|
4
|
+
the order in which features were added.
|
|
5
|
+
|
|
6
|
+
Run with:
|
|
7
|
+
python -m pytest cli/tests/test_data_features.py -v -s
|
|
8
|
+
|
|
9
|
+
Assumes the RunLedger backend is already running (docker compose up -d).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import subprocess
|
|
14
|
+
import time
|
|
15
|
+
import uuid
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
import tempfile
|
|
18
|
+
|
|
19
|
+
import pytest
|
|
20
|
+
import requests
|
|
21
|
+
|
|
22
|
+
# ------------------------------------------------------------
|
|
23
|
+
# Configuration
|
|
24
|
+
# ------------------------------------------------------------
|
|
25
|
+
PROJECT_ROOT = Path(__file__).parent.parent.parent # run-ledger/
|
|
26
|
+
HEALTH_URL = "http://localhost:8080/actuator/health"
|
|
27
|
+
API_BASE = "http://localhost:8080/api/runs"
|
|
28
|
+
SAMPLE_FOLDER = PROJECT_ROOT / "test-data" / "sample-runs"
|
|
29
|
+
# Unique batch per session to avoid cross‑test interference
|
|
30
|
+
BATCH_NAME = f"feature-test-{uuid.uuid4().hex[:8]}"
|
|
31
|
+
|
|
32
|
+
# ------------------------------------------------------------
|
|
33
|
+
# Helper: run the CLI script
|
|
34
|
+
# ------------------------------------------------------------
|
|
35
|
+
def run_cli(*args):
|
|
36
|
+
cmd = ["python", str(PROJECT_ROOT / "cli" / "ledger.py"), *args]
|
|
37
|
+
return subprocess.run(cmd, capture_output=True, encoding='utf-8', cwd=str(PROJECT_ROOT))
|
|
38
|
+
|
|
39
|
+
# ------------------------------------------------------------
|
|
40
|
+
# Ensure backend is available, clean batch, and ingest sample data
|
|
41
|
+
# ------------------------------------------------------------
|
|
42
|
+
@pytest.fixture(scope="session")
|
|
43
|
+
def setup_data():
|
|
44
|
+
"""Wait for backend, clear any old mapping for this batch, then scan."""
|
|
45
|
+
# Wait for backend health
|
|
46
|
+
for _ in range(30):
|
|
47
|
+
try:
|
|
48
|
+
resp = requests.get(HEALTH_URL, timeout=2)
|
|
49
|
+
if resp.status_code == 200:
|
|
50
|
+
break
|
|
51
|
+
except Exception:
|
|
52
|
+
pass
|
|
53
|
+
time.sleep(2)
|
|
54
|
+
else:
|
|
55
|
+
pytest.fail("Backend is not reachable. Start it with 'docker compose up -d'")
|
|
56
|
+
|
|
57
|
+
# Ingest sample runs into the fresh batch
|
|
58
|
+
result = run_cli("scan", str(SAMPLE_FOLDER), "--batch", BATCH_NAME)
|
|
59
|
+
assert result.returncode == 0, result.stderr
|
|
60
|
+
assert "Successfully ingested 9 run(s)" in result.stdout
|
|
61
|
+
|
|
62
|
+
# ------------------------------------------------------------
|
|
63
|
+
# v1 – Basic metric block search
|
|
64
|
+
# ------------------------------------------------------------
|
|
65
|
+
def test_v1_metric_discovery(setup_data):
|
|
66
|
+
"""Metrics endpoint returns keys from the batch (via mapping)."""
|
|
67
|
+
result = run_cli("metrics", "--batch", BATCH_NAME)
|
|
68
|
+
assert result.returncode == 0
|
|
69
|
+
keys = json.loads(result.stdout)
|
|
70
|
+
# Keys present in the sample files
|
|
71
|
+
assert "accuracy" in keys
|
|
72
|
+
assert "loss" in keys
|
|
73
|
+
assert "f1_score" in keys
|
|
74
|
+
assert "experiment" in keys
|
|
75
|
+
print("✅ Metric discovery (batch) --- SUCCESS")
|
|
76
|
+
|
|
77
|
+
def test_v1_numeric_block_search_dot_path(setup_data):
|
|
78
|
+
"""Numeric greater-than search using explicit dot-path metrics.accuracy."""
|
|
79
|
+
# Only run4 and run5 have a metrics.accuracy field (0.91 and 0.93 > 0.8)
|
|
80
|
+
result = run_cli("search", "--metric", "metrics.accuracy", "--op", "gt",
|
|
81
|
+
"--value", "0.8", "--batch", BATCH_NAME, "--json")
|
|
82
|
+
assert result.returncode == 0
|
|
83
|
+
data = json.loads(result.stdout)
|
|
84
|
+
assert data["totalElements"] == 2 # run4, run5
|
|
85
|
+
print(f"✅ metrics.accuracy > 0.8 --- {data['totalElements']} runs found")
|
|
86
|
+
|
|
87
|
+
def test_v1_text_equality_search_root_key(setup_data):
|
|
88
|
+
"""Text equality search on the top-level 'status' field."""
|
|
89
|
+
# run5.json has "status": "completed" at the root
|
|
90
|
+
result = run_cli("search", "--metric", "status", "--op", "eq",
|
|
91
|
+
"--value", "completed", "--batch", BATCH_NAME, "--json")
|
|
92
|
+
assert result.returncode == 0
|
|
93
|
+
data = json.loads(result.stdout)
|
|
94
|
+
assert data["totalElements"] == 1
|
|
95
|
+
assert data["content"][0]["payload"]["experiment"] == "deep_config"
|
|
96
|
+
print("✅ status = completed (root key) --- 1 run found")
|
|
97
|
+
|
|
98
|
+
# ------------------------------------------------------------
|
|
99
|
+
# v2 – Generalised path, shorthand keys, batch isolation
|
|
100
|
+
# ------------------------------------------------------------
|
|
101
|
+
def test_v2_dot_path_search(setup_data):
|
|
102
|
+
"""Explicit dot‑path works for deeply nested fields."""
|
|
103
|
+
result = run_cli("search", "--metric", "config.optimizer.settings.learning_rate",
|
|
104
|
+
"--op", "eq", "--value", "0.0001", "--batch", BATCH_NAME, "--json")
|
|
105
|
+
assert result.returncode == 0
|
|
106
|
+
data = json.loads(result.stdout)
|
|
107
|
+
# run5 has learning_rate = 0.0001
|
|
108
|
+
assert data["totalElements"] == 1
|
|
109
|
+
print("✅ config.optimizer.settings.learning_rate = 0.0001 --- 1 run found")
|
|
110
|
+
|
|
111
|
+
def test_v2_shorthand_key_resolution_shallowest_wins(setup_data):
|
|
112
|
+
"""Shorthand 'accuracy' resolves to the shallowest occurrence (root in run3.json)."""
|
|
113
|
+
result = run_cli("search", "--metric", "accuracy", "--op", "gt",
|
|
114
|
+
"--value", "0.8", "--batch", BATCH_NAME, "--json")
|
|
115
|
+
assert result.returncode == 0
|
|
116
|
+
data = json.loads(result.stdout)
|
|
117
|
+
# Only run3 has a root-level accuracy (0.88)
|
|
118
|
+
assert data["totalElements"] == 1
|
|
119
|
+
assert data["content"][0]["payload"]["experiment"] == "simple_top_level"
|
|
120
|
+
print("✅ Shorthand 'accuracy' (shallowest wins) > 0.8 --- 1 run found")
|
|
121
|
+
|
|
122
|
+
def test_v2_batch_isolation_empty(setup_data):
|
|
123
|
+
"""Searching a different batch returns no results."""
|
|
124
|
+
result = run_cli("search", "--metric", "accuracy", "--op", "gt",
|
|
125
|
+
"--value", "0.5", "--batch", "nonexistent-batch", "--json")
|
|
126
|
+
assert result.returncode == 0
|
|
127
|
+
data = json.loads(result.stdout)
|
|
128
|
+
assert data["totalElements"] == 0
|
|
129
|
+
print("✅ accuracy > 0.5 in non‑existent batch --- 0 runs (correct)")
|
|
130
|
+
|
|
131
|
+
# ------------------------------------------------------------
|
|
132
|
+
# v3 – Full‑text phrase search, fuzzy search, summary output
|
|
133
|
+
# ------------------------------------------------------------
|
|
134
|
+
def test_v3_fulltext_phrase_search(setup_data):
|
|
135
|
+
"""Full‑text search finds a phrase inside notes (using stemming)."""
|
|
136
|
+
# run1 notes contain "calibration samples"
|
|
137
|
+
result = run_cli("search", "--q", "calibration", "--batch", BATCH_NAME, "--json")
|
|
138
|
+
assert result.returncode == 0
|
|
139
|
+
data = json.loads(result.stdout)
|
|
140
|
+
# Both run1 and run2 mention "calibration"
|
|
141
|
+
assert data["totalElements"] >= 1, "Expected at least one run with 'calibration'"
|
|
142
|
+
print(f"✅ Full‑text: 'calibration' --- {data['totalElements']} runs found")
|
|
143
|
+
|
|
144
|
+
def test_v3_fuzzy_search(setup_data):
|
|
145
|
+
"""Fuzzy search with a typo still finds the original word."""
|
|
146
|
+
# "calibraation" is a misspelling of "calibration"
|
|
147
|
+
result = run_cli("search", "--q", "calibraation", "--fuzzy",
|
|
148
|
+
"--batch", BATCH_NAME, "--json")
|
|
149
|
+
assert result.returncode == 0
|
|
150
|
+
data = json.loads(result.stdout)
|
|
151
|
+
assert data["totalElements"] >= 1, "Fuzzy search should find runs with 'calibration'"
|
|
152
|
+
print(f"✅ Fuzzy: 'calibraation' --- {data['totalElements']} runs found")
|
|
153
|
+
|
|
154
|
+
def test_v3_summary_output(setup_data):
|
|
155
|
+
"""Summary flag returns only IDs and source files."""
|
|
156
|
+
# Use dot-path to metrics.accuracy to get results
|
|
157
|
+
result = run_cli("search", "--metric", "metrics.accuracy", "--op", "gt",
|
|
158
|
+
"--value", "0.9", "--batch", BATCH_NAME, "--summary")
|
|
159
|
+
assert result.returncode == 0
|
|
160
|
+
stdout = result.stdout
|
|
161
|
+
# Should find run4 and run5
|
|
162
|
+
assert "duplicate_keys" in stdout or "deep_config" in stdout, "Summary should list at least one source file"
|
|
163
|
+
print("✅ metrics.accuracy > 0.9 (summary) --- table displayed")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# ------------------------------------------------------------
|
|
167
|
+
# v4 – Array‑aware search (new)
|
|
168
|
+
# ------------------------------------------------------------
|
|
169
|
+
def test_array_basic_search(setup_data):
|
|
170
|
+
"""Search inside an array: client.results[].threshold > 0.96"""
|
|
171
|
+
result = run_cli("search", "--metric", "client.results[].threshold",
|
|
172
|
+
"--op", "gt", "--value", "0.96",
|
|
173
|
+
"--batch", BATCH_NAME, "--json")
|
|
174
|
+
assert result.returncode == 0, result.stderr
|
|
175
|
+
data = json.loads(result.stdout)
|
|
176
|
+
# run7 has 0.99, run8 has 0.99 (plus non‑numeric values safely ignored)
|
|
177
|
+
assert data["totalElements"] == 2
|
|
178
|
+
print("✅ Array basic search (threshold > 0.96) --- 2 runs found")
|
|
179
|
+
|
|
180
|
+
def test_array_text_search(setup_data):
|
|
181
|
+
"""Search inside an array: client.results[].status = 'error'"""
|
|
182
|
+
result = run_cli("search", "--metric", "client.results[].status",
|
|
183
|
+
"--op", "eq", "--value", "error",
|
|
184
|
+
"--batch", BATCH_NAME, "--json")
|
|
185
|
+
assert result.returncode == 0, result.stderr
|
|
186
|
+
data = json.loads(result.stdout)
|
|
187
|
+
# Only run8 has status = "error"
|
|
188
|
+
assert data["totalElements"] == 1
|
|
189
|
+
print("✅ Array text search (status = 'error') --- 1 run found")
|
|
190
|
+
|
|
191
|
+
def test_array_stress_nested_search(setup_data):
|
|
192
|
+
"""Search inside nested arrays: phases[].calibration.points[].threshold > 0.97"""
|
|
193
|
+
result = run_cli("search", "--metric", "phases[].calibration.points[].threshold",
|
|
194
|
+
"--op", "gt", "--value", "0.97",
|
|
195
|
+
"--batch", BATCH_NAME, "--json")
|
|
196
|
+
assert result.returncode == 0, result.stderr
|
|
197
|
+
data = json.loads(result.stdout)
|
|
198
|
+
# run9 has 0.99 (phase1) and 0.98 (phase2) – both > 0.97
|
|
199
|
+
assert data["totalElements"] == 1
|
|
200
|
+
print("✅ Array stress nested search (threshold > 0.97) --- 1 run found")
|
|
201
|
+
|
|
202
|
+
def test_array_mixed_types_safe(setup_data):
|
|
203
|
+
"""Search inside an array with mixed types: client.results[].threshold > 0.7"""
|
|
204
|
+
result = run_cli("search", "--metric", "client.results[].threshold",
|
|
205
|
+
"--op", "gt", "--value", "0.7",
|
|
206
|
+
"--batch", BATCH_NAME, "--json")
|
|
207
|
+
assert result.returncode == 0, result.stderr
|
|
208
|
+
data = json.loads(result.stdout)
|
|
209
|
+
# run7 (0.99,0.95) and run8 (0.99,'none',0.5) – non‑numeric safely ignored
|
|
210
|
+
assert data["totalElements"] == 2
|
|
211
|
+
print("✅ Array mixed‑type safety (threshold > 0.7) --- 2 runs found")
|
|
212
|
+
|
|
213
|
+
# ------------------------------------------------------------
|
|
214
|
+
# JSONL ingestion
|
|
215
|
+
# ------------------------------------------------------------
|
|
216
|
+
def test_jsonl_ingestion(setup_data):
|
|
217
|
+
"""Ingest a JSONL file line by line and verify correct _source metadata."""
|
|
218
|
+
batch = f"jsonl-test-{uuid.uuid4().hex[:8]}"
|
|
219
|
+
|
|
220
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
221
|
+
# Create a JSONL file with 3 records
|
|
222
|
+
jl_file = Path(tmpdir) / "steps.jsonl"
|
|
223
|
+
lines = [
|
|
224
|
+
'{"step": 0, "loss": 0.95}',
|
|
225
|
+
'{"step": 1, "loss": 0.82}',
|
|
226
|
+
'{"step": 2, "loss": 0.67}',
|
|
227
|
+
]
|
|
228
|
+
jl_file.write_text("\n".join(lines), encoding="utf-8")
|
|
229
|
+
|
|
230
|
+
# Scan with --jsonl
|
|
231
|
+
result = run_cli("scan", str(tmpdir), "--batch", batch, "--jsonl")
|
|
232
|
+
assert result.returncode == 0, f"scan failed: {result.stderr}"
|
|
233
|
+
|
|
234
|
+
# Verify via API
|
|
235
|
+
resp = requests.get(API_BASE, params={"batch": batch, "size": 50})
|
|
236
|
+
assert resp.status_code == 200, f"API error: {resp.text}"
|
|
237
|
+
data = resp.json()
|
|
238
|
+
content = data.get("content", [])
|
|
239
|
+
assert len(content) == 3, f"Expected 3 runs, got {len(content)}"
|
|
240
|
+
|
|
241
|
+
# Check _source metadata
|
|
242
|
+
for run in content:
|
|
243
|
+
payload = run.get("payload", {})
|
|
244
|
+
src = payload.get("_source", {})
|
|
245
|
+
assert src.get("file") == "steps.jsonl", f"Unexpected file: {src}"
|
|
246
|
+
idx = src.get("index")
|
|
247
|
+
assert idx is not None and 0 <= idx <= 2, f"Index out of range: {idx}"
|
|
248
|
+
# Verify the actual data lines up
|
|
249
|
+
expected = json.loads(lines[idx])
|
|
250
|
+
for k, v in expected.items():
|
|
251
|
+
assert payload.get(k) == v, f"Mismatch at line {idx}: {k} {v} != {payload.get(k)}"
|
|
252
|
+
|
|
253
|
+
# Clean up the temporary batch
|
|
254
|
+
requests.delete(f"{API_BASE}/batch/{batch}") # if this endpoint exists, otherwise manual cleanup
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import subprocess
|
|
3
|
+
import tempfile
|
|
4
|
+
import time
|
|
5
|
+
import uuid
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
import requests
|
|
11
|
+
|
|
12
|
+
PROJECT_ROOT = Path(__file__).parent.parent.parent
|
|
13
|
+
COMPOSE_FILE = PROJECT_ROOT / "docker-compose.yml"
|
|
14
|
+
HEALTH_URL = "http://localhost:8080/actuator/health"
|
|
15
|
+
API_BASE = "http://localhost:8080/api/runs"
|
|
16
|
+
|
|
17
|
+
def wait_for_health(timeout=120):
|
|
18
|
+
start = time.time()
|
|
19
|
+
while time.time() - start < timeout:
|
|
20
|
+
try:
|
|
21
|
+
resp = requests.get(HEALTH_URL, timeout=2)
|
|
22
|
+
if resp.status_code == 200:
|
|
23
|
+
return True
|
|
24
|
+
except Exception:
|
|
25
|
+
pass
|
|
26
|
+
time.sleep(2)
|
|
27
|
+
return False
|
|
28
|
+
|
|
29
|
+
@pytest.fixture(scope="session")
|
|
30
|
+
def docker_stack():
|
|
31
|
+
"""Start the Docker Compose stack and wait for it to be healthy."""
|
|
32
|
+
subprocess.run(
|
|
33
|
+
["docker", "compose", "-f", str(COMPOSE_FILE), "up", "-d", "--build"],
|
|
34
|
+
cwd=str(PROJECT_ROOT),
|
|
35
|
+
check=True,
|
|
36
|
+
capture_output=True,
|
|
37
|
+
)
|
|
38
|
+
if not wait_for_health():
|
|
39
|
+
subprocess.run(
|
|
40
|
+
["docker", "compose", "-f", str(COMPOSE_FILE), "down"],
|
|
41
|
+
cwd=str(PROJECT_ROOT),
|
|
42
|
+
capture_output=True,
|
|
43
|
+
)
|
|
44
|
+
pytest.fail("Backend did not become healthy in time")
|
|
45
|
+
yield
|
|
46
|
+
subprocess.run(
|
|
47
|
+
["docker", "compose", "-f", str(COMPOSE_FILE), "down"],
|
|
48
|
+
cwd=str(PROJECT_ROOT),
|
|
49
|
+
capture_output=True,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
def run_cli(*args):
|
|
53
|
+
cmd = ["python", str(PROJECT_ROOT / "cli" / "ledger.py"), *args]
|
|
54
|
+
return subprocess.run(cmd, capture_output=True, encoding='utf-8', cwd=str(PROJECT_ROOT))
|
|
55
|
+
|
|
56
|
+
@pytest.fixture
|
|
57
|
+
def sample_folder():
|
|
58
|
+
"""Create a temp folder with valid and corrupt JSON files."""
|
|
59
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
60
|
+
folder = Path(tmpdir)
|
|
61
|
+
(folder / "run1.json").write_text(json.dumps({
|
|
62
|
+
"experiment": "exp1", "metrics": {"accuracy": 0.95, "loss": 0.1}
|
|
63
|
+
}))
|
|
64
|
+
(folder / "run2.json").write_text(json.dumps({
|
|
65
|
+
"experiment": "exp2", "metrics": {"accuracy": 0.80, "loss": 0.2}
|
|
66
|
+
}))
|
|
67
|
+
(folder / "batch.json").write_text(json.dumps([
|
|
68
|
+
{"experiment": "exp3", "metrics": {"f1": 0.88}},
|
|
69
|
+
{"experiment": "exp4", "metrics": {"f1": 0.92}}
|
|
70
|
+
]))
|
|
71
|
+
(folder / "bad.json").write_text("this is not json")
|
|
72
|
+
yield folder
|
|
73
|
+
|
|
74
|
+
pytestmark = pytest.mark.integration
|
|
75
|
+
|
|
76
|
+
def test_full_golden_path(docker_stack, sample_folder):
|
|
77
|
+
batch = f"golden-{uuid.uuid4()}"
|
|
78
|
+
|
|
79
|
+
# 1. scan folder (non‑interactive)
|
|
80
|
+
result = run_cli("scan", str(sample_folder), "--batch", batch)
|
|
81
|
+
assert result.returncode == 0, result.stderr
|
|
82
|
+
assert "Successfully ingested 4 run(s)" in result.stdout
|
|
83
|
+
assert f"'{batch}'" in result.stdout
|
|
84
|
+
|
|
85
|
+
# 2. metrics
|
|
86
|
+
result = run_cli("metrics", "--batch", batch)
|
|
87
|
+
assert result.returncode == 0, result.stderr
|
|
88
|
+
metrics = json.loads(result.stdout)
|
|
89
|
+
assert set(metrics).issuperset({"accuracy", "loss", "f1"})
|
|
90
|
+
|
|
91
|
+
# 3. search: accuracy > 0.9
|
|
92
|
+
result = run_cli("search", "--metric", "accuracy", "--op", "gt", "--value", "0.9", "--batch", batch, "--json")
|
|
93
|
+
assert result.returncode == 0, result.stderr
|
|
94
|
+
data = json.loads(result.stdout)
|
|
95
|
+
assert data["totalElements"] == 1
|
|
96
|
+
run = data["content"][0]
|
|
97
|
+
assert run["payload"]["experiment"] == "exp1"
|
|
98
|
+
assert run["payload"]["metrics"]["accuracy"] == 0.95
|
|
99
|
+
assert "_source" in run["payload"], "source metadata missing"
|
|
100
|
+
assert run["payload"]["_source"]["file"] == "run1.json"
|
|
101
|
+
|
|
102
|
+
# 4. search: loss < 0.15
|
|
103
|
+
result = run_cli("search", "--metric", "loss", "--op", "lt", "--value", "0.15", "--batch", batch, "--json")
|
|
104
|
+
assert result.returncode == 0, result.stderr
|
|
105
|
+
data = json.loads(result.stdout)
|
|
106
|
+
assert data["totalElements"] == 1
|
|
107
|
+
assert data["content"][0]["payload"]["experiment"] == "exp1"
|
|
108
|
+
|
|
109
|
+
# 5. status
|
|
110
|
+
result = run_cli("status")
|
|
111
|
+
assert result.returncode == 0, result.stderr
|
|
112
|
+
assert "running" in result.stdout.lower()
|
|
113
|
+
|
|
114
|
+
# 6. deep API verification
|
|
115
|
+
resp = requests.get(API_BASE, params={"batch": batch})
|
|
116
|
+
assert resp.status_code == 200
|
|
117
|
+
runs = resp.json()["content"]
|
|
118
|
+
assert len(runs) == 4
|
|
119
|
+
ids = [r["id"] for r in runs]
|
|
120
|
+
assert len(set(ids)) == 4, "IDs must be unique"
|
|
121
|
+
exp_names = {r["payload"]["experiment"] for r in runs}
|
|
122
|
+
assert exp_names == {"exp1", "exp2", "exp3", "exp4"}
|
|
123
|
+
for r in runs:
|
|
124
|
+
assert r["payload"]["_source"]["file"] in ["run1.json", "run2.json", "batch.json"]
|
|
125
|
+
assert r["createdAt"] is not None
|
|
126
|
+
# Verify ordering (descending by createdAt)
|
|
127
|
+
timestamps = [r["createdAt"] for r in runs]
|
|
128
|
+
assert timestamps == sorted(timestamps, reverse=True), "runs must be ordered by createdAt desc"
|
|
129
|
+
|
|
130
|
+
def test_scan_nonexistent_folder(docker_stack):
|
|
131
|
+
result = run_cli("scan", "/no/such/path")
|
|
132
|
+
assert result.returncode != 0 or "Folder not found" in result.stdout
|
|
133
|
+
|
|
134
|
+
def test_scan_corrupt_file_skipped(docker_stack, sample_folder):
|
|
135
|
+
batch = f"corrupt-{uuid.uuid4()}"
|
|
136
|
+
result = run_cli("scan", str(sample_folder), "--batch", batch)
|
|
137
|
+
assert result.returncode == 0, result.stderr
|
|
138
|
+
assert "Successfully ingested 4 run(s)" in result.stdout
|
|
139
|
+
|
|
140
|
+
def test_metrics_empty_batch(docker_stack):
|
|
141
|
+
result = run_cli("metrics", "--batch", f"nonexistent-{uuid.uuid4()}")
|
|
142
|
+
assert result.returncode == 0, result.stderr
|
|
143
|
+
assert json.loads(result.stdout) == []
|
|
144
|
+
|
|
145
|
+
def test_search_no_results(docker_stack, sample_folder):
|
|
146
|
+
batch = f"noresults-{uuid.uuid4()}"
|
|
147
|
+
run_cli("scan", str(sample_folder), "--batch", batch)
|
|
148
|
+
result = run_cli("search", "--metric", "accuracy", "--op", "gt", "--value", "0.99", "--batch", batch, "--json")
|
|
149
|
+
assert result.returncode == 0, result.stderr
|
|
150
|
+
data = json.loads(result.stdout)
|
|
151
|
+
assert data["totalElements"] == 0
|
|
152
|
+
assert data["content"] == []
|
|
153
|
+
|
|
154
|
+
def test_multi_filter_and_returns_results():
|
|
155
|
+
# Ensure the compound test data folder exists
|
|
156
|
+
compound_dir = "test-data/sample-compound-runs"
|
|
157
|
+
if not os.path.isdir(compound_dir):
|
|
158
|
+
pytest.skip(f"Folder {compound_dir} not found")
|
|
159
|
+
|
|
160
|
+
# Scan the folder with explicit UTF-8 encoding to avoid UnicodeDecodeError
|
|
161
|
+
scan_result = subprocess.run(
|
|
162
|
+
["python", "cli/ledger.py", "scan", compound_dir, "--batch", "compound-int"],
|
|
163
|
+
capture_output=True, text=True, encoding="utf-8", errors="replace"
|
|
164
|
+
)
|
|
165
|
+
assert scan_result.returncode == 0, f"Scan failed: {scan_result.stderr}"
|
|
166
|
+
|
|
167
|
+
# Search with AND
|
|
168
|
+
result = subprocess.run(
|
|
169
|
+
["python", "cli/ledger.py", "search", "--metric", "accuracy", "--op", "gt", "--value", "0.9",
|
|
170
|
+
"--metric", "loss", "--op", "lt", "--value", "0.2", "--combine", "and", "--batch", "compound-int"],
|
|
171
|
+
capture_output=True, text=True, encoding="utf-8", errors="replace"
|
|
172
|
+
)
|
|
173
|
+
assert result.returncode == 0, f"Search failed: {result.stderr}"
|
|
174
|
+
assert result.stdout is not None, "stdout is None (decoding issue)"
|
|
175
|
+
|
|
176
|
+
# compound_run1.json has accuracy=0.95, loss=0.15 → should match
|
|
177
|
+
assert "compound_run1" in result.stdout, f"Output: {result.stdout}"
|