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/ledger.py
ADDED
|
@@ -0,0 +1,1276 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
RunLedger CLI – zero‑friction experiment search.
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
python ledger.py scan /path/to/folder [--batch NAME] [--jsonl]
|
|
7
|
+
python ledger.py search --metric <key> --op <gt|lt|eq|...> --value <v> [--batch NAME] [--summary] [--save <name>]
|
|
8
|
+
python ledger.py search --metric <key1> --op <op1> --value <v1> --metric <key2> --op <op2> --value <v2> [--combine and|or]
|
|
9
|
+
python ledger.py search --q <phrase> [--fuzzy] [--batch NAME] [--summary]
|
|
10
|
+
python ledger.py search --saved <name>
|
|
11
|
+
python ledger.py export ... (same filters as search)
|
|
12
|
+
python ledger.py preview --metric <key> --op <gt|lt|eq|...> --value <v> [--batch NAME]
|
|
13
|
+
python ledger.py metrics [--batch NAME] [--verbose]
|
|
14
|
+
python ledger.py interactive [--batch NAME]
|
|
15
|
+
python ledger.py diff <id1> <id2> [--history] [--batch BATCH] [--json]
|
|
16
|
+
python ledger.py aggregate --metric <key> --agg <AVG|MAX|MIN|SUM|COUNT> [--group-by <field>] [--batch NAME] [--json]
|
|
17
|
+
python ledger.py saved list [--batch]
|
|
18
|
+
python ledger.py saved delete <name> [--batch]
|
|
19
|
+
python ledger.py stop
|
|
20
|
+
python ledger.py status
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import argparse
|
|
24
|
+
import json
|
|
25
|
+
import os
|
|
26
|
+
import sys
|
|
27
|
+
import subprocess
|
|
28
|
+
import time
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Any, Dict
|
|
31
|
+
|
|
32
|
+
import requests
|
|
33
|
+
from rich.console import Console
|
|
34
|
+
from rich.panel import Panel
|
|
35
|
+
from rich.progress import Progress
|
|
36
|
+
from rich.prompt import Prompt, Confirm
|
|
37
|
+
from rich.table import Table
|
|
38
|
+
|
|
39
|
+
# ------------------------------------------------------------
|
|
40
|
+
# Force UTF‑8 output so Rich can render box‑drawing characters
|
|
41
|
+
# ------------------------------------------------------------
|
|
42
|
+
try:
|
|
43
|
+
sys.stdout.reconfigure(encoding='utf-8')
|
|
44
|
+
except Exception:
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
# ------------------------------------------------------------
|
|
48
|
+
# Configuration
|
|
49
|
+
# ------------------------------------------------------------
|
|
50
|
+
API_BASE = "http://localhost:8080/api/runs"
|
|
51
|
+
COMPOSE_FILE = os.path.join(os.path.dirname(__file__), "..", "docker-compose.yml")
|
|
52
|
+
HEALTH_URL = "http://localhost:8080/actuator/health"
|
|
53
|
+
PAGE_SIZE = 10
|
|
54
|
+
|
|
55
|
+
console = Console()
|
|
56
|
+
|
|
57
|
+
# ------------------------------------------------------------
|
|
58
|
+
# Helpers
|
|
59
|
+
# ------------------------------------------------------------
|
|
60
|
+
def check_docker():
|
|
61
|
+
result = subprocess.run("docker version", shell=True,
|
|
62
|
+
capture_output=True, text=True)
|
|
63
|
+
if result.returncode != 0:
|
|
64
|
+
console.print("[bold red]Docker is not installed or not reachable.[/bold red]")
|
|
65
|
+
console.print("Please install Docker Desktop from [link]https://www.docker.com/get-started[/link]")
|
|
66
|
+
return False
|
|
67
|
+
return True
|
|
68
|
+
|
|
69
|
+
def backend_running():
|
|
70
|
+
try:
|
|
71
|
+
resp = requests.get(HEALTH_URL, timeout=2)
|
|
72
|
+
return resp.status_code == 200
|
|
73
|
+
except requests.RequestException:
|
|
74
|
+
return False
|
|
75
|
+
|
|
76
|
+
def wait_for_backend(timeout=60):
|
|
77
|
+
console.print("Waiting for the RunLedger backend to be ready...", end="")
|
|
78
|
+
start = time.time()
|
|
79
|
+
while time.time() - start < timeout:
|
|
80
|
+
if backend_running():
|
|
81
|
+
console.print("[green] ready![/green]")
|
|
82
|
+
return True
|
|
83
|
+
time.sleep(2)
|
|
84
|
+
console.print(".", end="")
|
|
85
|
+
console.print("\n[red]Timed out waiting for backend.[/red]")
|
|
86
|
+
return False
|
|
87
|
+
|
|
88
|
+
def start_backend():
|
|
89
|
+
if not os.path.exists(COMPOSE_FILE):
|
|
90
|
+
console.print("[red]docker-compose.yml not found. Are you in the project root?[/red]")
|
|
91
|
+
sys.exit(1)
|
|
92
|
+
console.print("Starting RunLedger backend...")
|
|
93
|
+
subprocess.run(f"docker compose -f {COMPOSE_FILE} up -d", shell=True, check=True)
|
|
94
|
+
if wait_for_backend():
|
|
95
|
+
console.print("[green]RunLedger backend is running.[/green]")
|
|
96
|
+
else:
|
|
97
|
+
console.print("[red]Failed to start RunLedger. Check Docker logs.[/red]")
|
|
98
|
+
sys.exit(1)
|
|
99
|
+
|
|
100
|
+
def stop_backend():
|
|
101
|
+
if os.path.exists(COMPOSE_FILE):
|
|
102
|
+
subprocess.run(f"docker compose -f {COMPOSE_FILE} down", shell=True, check=True)
|
|
103
|
+
console.print("RunLedger backend stopped.")
|
|
104
|
+
else:
|
|
105
|
+
console.print("[red]Cannot find docker-compose.yml[/red]")
|
|
106
|
+
|
|
107
|
+
# ------------------------------------------------------------
|
|
108
|
+
# Ingestion
|
|
109
|
+
# ------------------------------------------------------------
|
|
110
|
+
def ingest_folder(folder: str, batch: str = None, jsonl: bool = False):
|
|
111
|
+
path = Path(folder).expanduser()
|
|
112
|
+
if not path.exists():
|
|
113
|
+
console.print(f"[red]Folder not found: {folder}[/red]")
|
|
114
|
+
return
|
|
115
|
+
|
|
116
|
+
if batch is None:
|
|
117
|
+
batch = path.name
|
|
118
|
+
|
|
119
|
+
json_files = list(path.glob("*.json")) if not jsonl else list(path.glob("*.jsonl")) + list(path.glob("*.json"))
|
|
120
|
+
if not json_files:
|
|
121
|
+
console.print(f"[yellow]No .json files found in {folder}[/yellow]")
|
|
122
|
+
return
|
|
123
|
+
|
|
124
|
+
if jsonl:
|
|
125
|
+
console.print(f"Found {len(json_files)} JSONL file(s) in '{batch}'. Ingesting line by line...")
|
|
126
|
+
else:
|
|
127
|
+
console.print(f"Found {len(json_files)} JSON file(s) in '{batch}'. Ingesting...")
|
|
128
|
+
|
|
129
|
+
count = 0
|
|
130
|
+
with Progress() as progress:
|
|
131
|
+
task = progress.add_task("[cyan]Ingesting...", total=len(json_files))
|
|
132
|
+
for jfile in json_files:
|
|
133
|
+
progress.update(task, description=f"Ingesting {jfile.name}...")
|
|
134
|
+
try:
|
|
135
|
+
with open(jfile, "r", encoding="utf-8") as fh:
|
|
136
|
+
if jsonl:
|
|
137
|
+
for idx, line in enumerate(fh):
|
|
138
|
+
line = line.strip()
|
|
139
|
+
if not line:
|
|
140
|
+
continue
|
|
141
|
+
try:
|
|
142
|
+
run = json.loads(line)
|
|
143
|
+
except json.JSONDecodeError as e:
|
|
144
|
+
console.print(f"[red]Skipping {jfile.name} line {idx+1}: {e}[/red]")
|
|
145
|
+
continue
|
|
146
|
+
payload = {
|
|
147
|
+
"payload": run,
|
|
148
|
+
"batch": batch,
|
|
149
|
+
}
|
|
150
|
+
payload["payload"]["_source"] = {
|
|
151
|
+
"file": jfile.name,
|
|
152
|
+
"index": idx,
|
|
153
|
+
}
|
|
154
|
+
try:
|
|
155
|
+
resp = requests.post(API_BASE, json=payload)
|
|
156
|
+
if resp.status_code == 201:
|
|
157
|
+
count += 1
|
|
158
|
+
else:
|
|
159
|
+
console.print(f"[red]Failed to ingest line {idx+1} from {jfile.name}: {resp.status_code}[/red]")
|
|
160
|
+
except requests.RequestException as e:
|
|
161
|
+
console.print(f"[red]Error sending {jfile.name}: {e}[/red]")
|
|
162
|
+
break
|
|
163
|
+
else:
|
|
164
|
+
data = json.load(fh)
|
|
165
|
+
if isinstance(data, dict):
|
|
166
|
+
data = [data]
|
|
167
|
+
for idx, run in enumerate(data):
|
|
168
|
+
payload = {
|
|
169
|
+
"payload": run,
|
|
170
|
+
"batch": batch,
|
|
171
|
+
}
|
|
172
|
+
payload["payload"]["_source"] = {
|
|
173
|
+
"file": jfile.name,
|
|
174
|
+
"index": idx,
|
|
175
|
+
}
|
|
176
|
+
try:
|
|
177
|
+
resp = requests.post(API_BASE, json=payload)
|
|
178
|
+
if resp.status_code == 201:
|
|
179
|
+
count += 1
|
|
180
|
+
else:
|
|
181
|
+
console.print(f"[red]Failed to ingest run {idx} from {jfile.name}: {resp.status_code}[/red]")
|
|
182
|
+
except requests.RequestException as e:
|
|
183
|
+
console.print(f"[red]Error sending {jfile.name}: {e}[/red]")
|
|
184
|
+
break
|
|
185
|
+
except Exception as e:
|
|
186
|
+
console.print(f"[red]Skipping {jfile.name}: {e}[/red]")
|
|
187
|
+
continue
|
|
188
|
+
progress.advance(task)
|
|
189
|
+
|
|
190
|
+
console.print(f"[green]Successfully ingested {count} run(s) into batch '{batch}'.[/green]")
|
|
191
|
+
if jsonl:
|
|
192
|
+
console.print("[dim]Tip: JSONL ingestion creates one run per line. Use --jsonl flag to process line‑delimited files.[/dim]")
|
|
193
|
+
|
|
194
|
+
# ------------------------------------------------------------
|
|
195
|
+
# API wrappers
|
|
196
|
+
# ------------------------------------------------------------
|
|
197
|
+
def fetch_metrics(batch: str = None) -> list:
|
|
198
|
+
params = {"batch": batch} if batch else {}
|
|
199
|
+
try:
|
|
200
|
+
resp = requests.get(f"{API_BASE}/metrics", params=params)
|
|
201
|
+
if resp.status_code == 200:
|
|
202
|
+
return resp.json()
|
|
203
|
+
else:
|
|
204
|
+
console.print(f"[red]Failed to fetch metrics (HTTP {resp.status_code}).[/red]")
|
|
205
|
+
return []
|
|
206
|
+
except requests.RequestException as e:
|
|
207
|
+
console.print(f"[red]Error fetching metrics: {e}[/red]")
|
|
208
|
+
return []
|
|
209
|
+
|
|
210
|
+
def fetch_verbose_metrics(batch: str) -> list:
|
|
211
|
+
params = {"batch": batch, "verbose": "true"}
|
|
212
|
+
try:
|
|
213
|
+
resp = requests.get(f"{API_BASE}/metrics", params=params)
|
|
214
|
+
if resp.status_code == 200:
|
|
215
|
+
return resp.json()
|
|
216
|
+
else:
|
|
217
|
+
console.print(f"[red]Failed to fetch verbose metrics (HTTP {resp.status_code}).[/red]")
|
|
218
|
+
return []
|
|
219
|
+
except requests.RequestException as e:
|
|
220
|
+
console.print(f"[red]Error fetching verbose metrics: {e}[/red]")
|
|
221
|
+
return []
|
|
222
|
+
|
|
223
|
+
def perform_search(metric=None, op=None, value=None, q=None, fuzzy=False,
|
|
224
|
+
batch=None, page=0, size=PAGE_SIZE, pointers=True):
|
|
225
|
+
"""Single‑condition GET search. Never sends block – CLI handles truncation."""
|
|
226
|
+
params = {
|
|
227
|
+
"page": page,
|
|
228
|
+
"size": size,
|
|
229
|
+
"sort": "created_at,desc",
|
|
230
|
+
}
|
|
231
|
+
if batch:
|
|
232
|
+
params["batch"] = batch
|
|
233
|
+
if q:
|
|
234
|
+
params["q"] = q
|
|
235
|
+
if fuzzy:
|
|
236
|
+
params["fuzzy"] = "true"
|
|
237
|
+
elif metric and op and value is not None:
|
|
238
|
+
params["metric"] = metric
|
|
239
|
+
params["op"] = op
|
|
240
|
+
params["value"] = value
|
|
241
|
+
if pointers:
|
|
242
|
+
params["pointers"] = "true"
|
|
243
|
+
else:
|
|
244
|
+
raise ValueError("Either --q or (--metric, --op, --value) must be provided")
|
|
245
|
+
|
|
246
|
+
try:
|
|
247
|
+
resp = requests.get(API_BASE, params=params)
|
|
248
|
+
if resp.status_code == 200:
|
|
249
|
+
return resp.json()
|
|
250
|
+
else:
|
|
251
|
+
console.print(f"[red]Search failed (HTTP {resp.status_code}).[/red]")
|
|
252
|
+
return None
|
|
253
|
+
except requests.RequestException as e:
|
|
254
|
+
console.print(f"[red]Error searching: {e}[/red]")
|
|
255
|
+
return None
|
|
256
|
+
|
|
257
|
+
def perform_multi_search(filters, combine, batch=None, page=0, size=PAGE_SIZE):
|
|
258
|
+
"""Multi‑condition POST search. No block parameter."""
|
|
259
|
+
body = {
|
|
260
|
+
"filters": filters,
|
|
261
|
+
"combine": combine,
|
|
262
|
+
}
|
|
263
|
+
if batch:
|
|
264
|
+
body["batch"] = batch
|
|
265
|
+
|
|
266
|
+
try:
|
|
267
|
+
resp = requests.post(f"{API_BASE}/search", json=body)
|
|
268
|
+
if resp.status_code == 200:
|
|
269
|
+
return resp.json()
|
|
270
|
+
else:
|
|
271
|
+
console.print(f"[red]Multi‑search failed (HTTP {resp.status_code}).[/red]")
|
|
272
|
+
return None
|
|
273
|
+
except requests.RequestException as e:
|
|
274
|
+
console.print(f"[red]Error in multi‑search: {e}[/red]")
|
|
275
|
+
return None
|
|
276
|
+
|
|
277
|
+
def save_search(name, batch, params):
|
|
278
|
+
body = {"name": name, "batch": batch, "paramsJson": json.dumps(params)}
|
|
279
|
+
resp = requests.post("http://localhost:8080/api/saved", json=body)
|
|
280
|
+
if resp.status_code != 200:
|
|
281
|
+
console.print(f"[red]Failed to save search: {resp.text}[/red]")
|
|
282
|
+
return False
|
|
283
|
+
return True
|
|
284
|
+
|
|
285
|
+
def get_saved_search(name, batch):
|
|
286
|
+
params = {"batch": batch} if batch else {}
|
|
287
|
+
resp = requests.get(f"http://localhost:8080/api/saved/{name}", params=params)
|
|
288
|
+
if resp.status_code == 200:
|
|
289
|
+
return resp.json()
|
|
290
|
+
return None
|
|
291
|
+
|
|
292
|
+
def list_saved_searches(batch):
|
|
293
|
+
params = {"batch": batch} if batch else {}
|
|
294
|
+
resp = requests.get("http://localhost:8080/api/saved", params=params)
|
|
295
|
+
if resp.status_code == 200:
|
|
296
|
+
return resp.json()
|
|
297
|
+
return []
|
|
298
|
+
|
|
299
|
+
def delete_saved_search(name, batch):
|
|
300
|
+
params = {"batch": batch} if batch else {}
|
|
301
|
+
resp = requests.delete(f"http://localhost:8080/api/saved/{name}", params=params)
|
|
302
|
+
return resp.status_code == 204
|
|
303
|
+
|
|
304
|
+
# ------------------------------------------------------------
|
|
305
|
+
# Block display helpers
|
|
306
|
+
# ------------------------------------------------------------
|
|
307
|
+
def navigate_json_pointer(doc, pointer: str):
|
|
308
|
+
parts = pointer.lstrip("/").split("/")
|
|
309
|
+
cur = doc
|
|
310
|
+
for p in parts:
|
|
311
|
+
if p == "":
|
|
312
|
+
continue
|
|
313
|
+
if isinstance(cur, dict):
|
|
314
|
+
cur = cur.get(p, None)
|
|
315
|
+
if cur is None:
|
|
316
|
+
return None
|
|
317
|
+
else:
|
|
318
|
+
return None
|
|
319
|
+
return cur
|
|
320
|
+
|
|
321
|
+
def show_blocks(run: dict, dot_path: str = None, block_levels: list = None):
|
|
322
|
+
payload = run.get("payload", {})
|
|
323
|
+
if isinstance(payload, dict):
|
|
324
|
+
source_file = payload.get("_source", {}).get("file") or payload.get("experiment") or f"run-{run['id']}"
|
|
325
|
+
else:
|
|
326
|
+
source_file = f"run-{run['id']}"
|
|
327
|
+
|
|
328
|
+
matched = run.get("matched")
|
|
329
|
+
if matched:
|
|
330
|
+
console.print(" Matches:")
|
|
331
|
+
for m in matched:
|
|
332
|
+
val = m.get("value")
|
|
333
|
+
if isinstance(val, float):
|
|
334
|
+
val_str = f"{val:.6f}"
|
|
335
|
+
else:
|
|
336
|
+
val_str = str(val)
|
|
337
|
+
console.print(f" • {m['pointer']} = {val_str}")
|
|
338
|
+
console.print()
|
|
339
|
+
|
|
340
|
+
if dot_path is None:
|
|
341
|
+
label = "-- full run (full‑text/fuzzy match)"
|
|
342
|
+
try:
|
|
343
|
+
pretty = json.dumps(payload, indent=2)
|
|
344
|
+
except Exception:
|
|
345
|
+
pretty = str(payload)
|
|
346
|
+
console.print(Panel(pretty, title=f"Run {run['id']} · {source_file}"))
|
|
347
|
+
return
|
|
348
|
+
|
|
349
|
+
parts = [seg.replace("[]", "") for seg in dot_path.split(".")]
|
|
350
|
+
max_block = len(parts) - 1
|
|
351
|
+
|
|
352
|
+
if block_levels is None:
|
|
353
|
+
levels = list(range(max_block + 1))
|
|
354
|
+
else:
|
|
355
|
+
levels = [l for l in block_levels if 0 <= l <= max_block]
|
|
356
|
+
if not levels:
|
|
357
|
+
console.print(f"[yellow]Requested block depth(s) out of range. Max depth is {max_block}.[/yellow]")
|
|
358
|
+
return
|
|
359
|
+
|
|
360
|
+
for block in sorted(levels, reverse=True):
|
|
361
|
+
if block == 0 and matched:
|
|
362
|
+
for i, m in enumerate(matched):
|
|
363
|
+
snippet = m.get("snippet")
|
|
364
|
+
try:
|
|
365
|
+
pretty = json.dumps(snippet, indent=2)
|
|
366
|
+
except Exception:
|
|
367
|
+
pretty = str(snippet)
|
|
368
|
+
title = f"Run {run['id']} · {source_file} | metric: {dot_path} | depth: 0 (match {i+1}/{len(matched)})"
|
|
369
|
+
console.print(Panel(pretty, title=title))
|
|
370
|
+
else:
|
|
371
|
+
ancestor_parts = parts[:len(parts) - (block + 1)]
|
|
372
|
+
pointer = "/" + "/".join(ancestor_parts) if ancestor_parts else ""
|
|
373
|
+
node = navigate_json_pointer(payload, pointer) if pointer else payload
|
|
374
|
+
depth_label = ""
|
|
375
|
+
if block == max_block:
|
|
376
|
+
depth_label = " (full run)"
|
|
377
|
+
elif block == 0:
|
|
378
|
+
depth_label = " (tightest match)"
|
|
379
|
+
try:
|
|
380
|
+
pretty = json.dumps(node, indent=2) if node is not None else "null"
|
|
381
|
+
except Exception:
|
|
382
|
+
pretty = str(node)
|
|
383
|
+
console.print(Panel(pretty, title=f"Run {run['id']} · {source_file} | metric: {dot_path} | depth: {block}{depth_label}"))
|
|
384
|
+
|
|
385
|
+
# ------------------------------------------------------------
|
|
386
|
+
# Export helpers
|
|
387
|
+
# ------------------------------------------------------------
|
|
388
|
+
def build_export_blocks(content, dot_path, block_levels, include_pointers=True):
|
|
389
|
+
blocks = []
|
|
390
|
+
for run in content:
|
|
391
|
+
payload = run.get("payload", {})
|
|
392
|
+
if isinstance(payload, dict):
|
|
393
|
+
source_file = payload.get("_source", {}).get("file") or payload.get("experiment") or f"run-{run['id']}"
|
|
394
|
+
else:
|
|
395
|
+
source_file = f"run-{run['id']}"
|
|
396
|
+
|
|
397
|
+
matched = run.get("matched") if include_pointers else None
|
|
398
|
+
|
|
399
|
+
if dot_path is None:
|
|
400
|
+
blocks.append({
|
|
401
|
+
"id": run["id"],
|
|
402
|
+
"sourceFile": source_file,
|
|
403
|
+
"metric": "(full‑text/fuzzy match)",
|
|
404
|
+
"depth": "full",
|
|
405
|
+
"content": payload,
|
|
406
|
+
"matches": matched
|
|
407
|
+
})
|
|
408
|
+
continue
|
|
409
|
+
|
|
410
|
+
parts = [seg.replace("[]", "") for seg in dot_path.split(".")]
|
|
411
|
+
max_block = len(parts) - 1
|
|
412
|
+
|
|
413
|
+
levels = block_levels if block_levels is not None else list(range(max_block + 1))
|
|
414
|
+
levels = [l for l in levels if 0 <= l <= max_block]
|
|
415
|
+
if not levels:
|
|
416
|
+
levels = [max_block]
|
|
417
|
+
|
|
418
|
+
for block in sorted(levels, reverse=True):
|
|
419
|
+
if block == 0 and matched:
|
|
420
|
+
for i, m in enumerate(matched):
|
|
421
|
+
snippet = m.get("snippet")
|
|
422
|
+
blocks.append({
|
|
423
|
+
"id": run["id"],
|
|
424
|
+
"sourceFile": source_file,
|
|
425
|
+
"metric": dot_path,
|
|
426
|
+
"depth": 0,
|
|
427
|
+
"matchIndex": i + 1,
|
|
428
|
+
"totalMatches": len(matched),
|
|
429
|
+
"content": snippet,
|
|
430
|
+
"pointer": m.get("pointer"),
|
|
431
|
+
"value": m.get("value")
|
|
432
|
+
})
|
|
433
|
+
else:
|
|
434
|
+
ancestor_parts = parts[:len(parts) - (block + 1)]
|
|
435
|
+
pointer = "/" + "/".join(ancestor_parts) if ancestor_parts else ""
|
|
436
|
+
node = navigate_json_pointer(payload, pointer) if pointer else payload
|
|
437
|
+
blocks.append({
|
|
438
|
+
"id": run["id"],
|
|
439
|
+
"sourceFile": source_file,
|
|
440
|
+
"metric": dot_path,
|
|
441
|
+
"depth": block,
|
|
442
|
+
"content": node,
|
|
443
|
+
"matches": matched if block != 0 else None
|
|
444
|
+
})
|
|
445
|
+
return blocks
|
|
446
|
+
|
|
447
|
+
def format_blocks_as_text(blocks):
|
|
448
|
+
lines = []
|
|
449
|
+
for i, block in enumerate(blocks):
|
|
450
|
+
if i > 0:
|
|
451
|
+
lines.append("")
|
|
452
|
+
lines.append("-" * 70)
|
|
453
|
+
lines.append(f"id: {block['id']}")
|
|
454
|
+
lines.append(f"file: {block['sourceFile']}")
|
|
455
|
+
if block.get("metric"):
|
|
456
|
+
lines.append(f"metric: {block['metric']}")
|
|
457
|
+
depth = block.get("depth")
|
|
458
|
+
if depth is not None:
|
|
459
|
+
depth_str = str(depth)
|
|
460
|
+
if block.get("matchIndex"):
|
|
461
|
+
depth_str += f" (match {block['matchIndex']}/{block['totalMatches']})"
|
|
462
|
+
lines.append(f"depth: {depth_str}")
|
|
463
|
+
pointer = block.get("pointer")
|
|
464
|
+
if pointer:
|
|
465
|
+
val = block.get("value")
|
|
466
|
+
if isinstance(val, float):
|
|
467
|
+
val_str = f"{val:.6f}"
|
|
468
|
+
else:
|
|
469
|
+
val_str = str(val)
|
|
470
|
+
lines.append(f"match: {pointer} = {val_str}")
|
|
471
|
+
matches = block.get("matches")
|
|
472
|
+
if matches:
|
|
473
|
+
lines.append("matches:")
|
|
474
|
+
for m in matches:
|
|
475
|
+
val = m.get("value")
|
|
476
|
+
if isinstance(val, float):
|
|
477
|
+
val_str = f"{val:.6f}"
|
|
478
|
+
else:
|
|
479
|
+
val_str = str(val)
|
|
480
|
+
lines.append(f" • {m['pointer']} = {val_str}")
|
|
481
|
+
lines.append("-" * 70)
|
|
482
|
+
lines.append(json.dumps(block["content"], indent=2))
|
|
483
|
+
return "\n".join(lines) + "\n"
|
|
484
|
+
|
|
485
|
+
def suggest_array_path(batch, shorthand):
|
|
486
|
+
try:
|
|
487
|
+
resp = requests.get(f"{API_BASE}/metrics", params={"batch": batch, "verbose": "true"})
|
|
488
|
+
if resp.status_code != 200:
|
|
489
|
+
return None
|
|
490
|
+
entries = resp.json()
|
|
491
|
+
scalar_paths = [e["path"] for e in entries if e["key"] == shorthand and "[]" not in e["path"]]
|
|
492
|
+
if scalar_paths:
|
|
493
|
+
return None
|
|
494
|
+
array_paths = [e["path"] for e in entries if e["key"] == shorthand and "[]" in e["path"]]
|
|
495
|
+
if array_paths:
|
|
496
|
+
best = min(array_paths, key=len)
|
|
497
|
+
return f"Hint: '{shorthand}' exists inside arrays. Did you mean: {best}?"
|
|
498
|
+
except Exception:
|
|
499
|
+
pass
|
|
500
|
+
return None
|
|
501
|
+
|
|
502
|
+
# ------------------------------------------------------------
|
|
503
|
+
# Flatten and diff helpers
|
|
504
|
+
# ------------------------------------------------------------
|
|
505
|
+
def flatten_payload(payload: Any, prefix: str = '') -> Dict[str, Any]:
|
|
506
|
+
result = {}
|
|
507
|
+
if isinstance(payload, dict):
|
|
508
|
+
for k, v in payload.items():
|
|
509
|
+
if k == '_source':
|
|
510
|
+
continue
|
|
511
|
+
path = f"{prefix}.{k}" if prefix else k
|
|
512
|
+
if isinstance(v, (dict, list)):
|
|
513
|
+
result.update(flatten_payload(v, path))
|
|
514
|
+
else:
|
|
515
|
+
result[path] = v
|
|
516
|
+
elif isinstance(payload, list):
|
|
517
|
+
for i, item in enumerate(payload):
|
|
518
|
+
path = f"{prefix}[{i}]"
|
|
519
|
+
if isinstance(item, (dict, list)):
|
|
520
|
+
result.update(flatten_payload(item, path))
|
|
521
|
+
else:
|
|
522
|
+
result[path] = item
|
|
523
|
+
else:
|
|
524
|
+
result[prefix] = payload
|
|
525
|
+
return result
|
|
526
|
+
|
|
527
|
+
def compute_delta(val1, val2):
|
|
528
|
+
try:
|
|
529
|
+
v1 = float(val1)
|
|
530
|
+
v2 = float(val2)
|
|
531
|
+
delta = v2 - v1
|
|
532
|
+
if delta > 0:
|
|
533
|
+
return f"+{delta:.6f}", "green"
|
|
534
|
+
elif delta < 0:
|
|
535
|
+
return f"{delta:.6f}", "red"
|
|
536
|
+
else:
|
|
537
|
+
return "0", "white"
|
|
538
|
+
except (ValueError, TypeError):
|
|
539
|
+
if val1 != val2:
|
|
540
|
+
return "changed", "yellow"
|
|
541
|
+
return "unchanged", "white"
|
|
542
|
+
|
|
543
|
+
# ------------------------------------------------------------
|
|
544
|
+
# Non‑interactive CLI outputs
|
|
545
|
+
# ------------------------------------------------------------
|
|
546
|
+
def cli_metrics(args):
|
|
547
|
+
if args.verbose:
|
|
548
|
+
entries = fetch_verbose_metrics(args.batch)
|
|
549
|
+
if not entries:
|
|
550
|
+
console.print("[yellow]No metrics found.[/yellow]")
|
|
551
|
+
return
|
|
552
|
+
table = Table(title="Verbose Metrics")
|
|
553
|
+
table.add_column("Shorthand Key", style="cyan")
|
|
554
|
+
table.add_column("Full Dot‑Path", style="green")
|
|
555
|
+
table.add_column("Depth", justify="right")
|
|
556
|
+
for item in entries:
|
|
557
|
+
table.add_row(item["key"], item["path"], str(item["depth"]))
|
|
558
|
+
console.print(table)
|
|
559
|
+
else:
|
|
560
|
+
keys = fetch_metrics(args.batch)
|
|
561
|
+
print(json.dumps(keys))
|
|
562
|
+
|
|
563
|
+
def cli_search(args):
|
|
564
|
+
if args.saved:
|
|
565
|
+
saved = get_saved_search(args.saved, args.batch)
|
|
566
|
+
if not saved:
|
|
567
|
+
console.print(f"[red]Saved search '{args.saved}' not found.[/red]")
|
|
568
|
+
sys.exit(1)
|
|
569
|
+
params = json.loads(saved["paramsJson"])
|
|
570
|
+
args.metrics = params.get("metrics", [])
|
|
571
|
+
args.ops = params.get("ops", [])
|
|
572
|
+
args.values = params.get("values", [])
|
|
573
|
+
args.combine = params.get("combine", "and")
|
|
574
|
+
args.batch = params.get("batch", args.batch)
|
|
575
|
+
|
|
576
|
+
filters = None
|
|
577
|
+
if args.metrics and args.ops and args.values:
|
|
578
|
+
if len(args.metrics) != len(args.ops) or len(args.metrics) != len(args.values):
|
|
579
|
+
console.print("[red]Number of --metric, --op, --value arguments must match.[/red]")
|
|
580
|
+
sys.exit(1)
|
|
581
|
+
filters = [
|
|
582
|
+
{"metric": m, "op": op, "value": v}
|
|
583
|
+
for m, op, v in zip(args.metrics, args.ops, args.values)
|
|
584
|
+
]
|
|
585
|
+
|
|
586
|
+
if args.save:
|
|
587
|
+
if not filters:
|
|
588
|
+
console.print("[red]No search parameters to save.[/red]")
|
|
589
|
+
sys.exit(1)
|
|
590
|
+
save_params = {
|
|
591
|
+
"metrics": [f["metric"] for f in filters],
|
|
592
|
+
"ops": [f["op"] for f in filters],
|
|
593
|
+
"values": [f["value"] for f in filters],
|
|
594
|
+
"combine": args.combine or "and",
|
|
595
|
+
"batch": args.batch
|
|
596
|
+
}
|
|
597
|
+
if save_search(args.save, args.batch, save_params):
|
|
598
|
+
console.print(f"[green]Saved search '{args.save}'.[/green]")
|
|
599
|
+
return
|
|
600
|
+
|
|
601
|
+
pointers_enabled = getattr(args, "pointers", True)
|
|
602
|
+
combine = args.combine if args.combine else "and"
|
|
603
|
+
|
|
604
|
+
if args.q:
|
|
605
|
+
data = perform_search(q=args.q, fuzzy=args.fuzzy, batch=args.batch, page=0)
|
|
606
|
+
elif filters and len(filters) > 1:
|
|
607
|
+
data = perform_multi_search(filters, combine, batch=args.batch, page=0)
|
|
608
|
+
elif filters:
|
|
609
|
+
f = filters[0]
|
|
610
|
+
data = perform_search(metric=f["metric"], op=f["op"], value=f["value"],
|
|
611
|
+
batch=args.batch, page=0, pointers=pointers_enabled)
|
|
612
|
+
else:
|
|
613
|
+
console.print("[red]No search parameters provided.[/red]")
|
|
614
|
+
sys.exit(1)
|
|
615
|
+
|
|
616
|
+
if data is None:
|
|
617
|
+
sys.exit(1)
|
|
618
|
+
|
|
619
|
+
if args.summary:
|
|
620
|
+
content = data.get("content", [])
|
|
621
|
+
if not content:
|
|
622
|
+
print("No matching runs found.")
|
|
623
|
+
if args.batch and filters and len(filters) == 1:
|
|
624
|
+
metric = filters[0]["metric"]
|
|
625
|
+
if "." not in metric and "[]" not in metric:
|
|
626
|
+
hint = suggest_array_path(args.batch, metric)
|
|
627
|
+
if hint:
|
|
628
|
+
print(hint)
|
|
629
|
+
return
|
|
630
|
+
table = Table(title="Search Results (summary)")
|
|
631
|
+
table.add_column("ID", justify="right", style="cyan")
|
|
632
|
+
table.add_column("Source File", justify="left", style="green")
|
|
633
|
+
for run in content:
|
|
634
|
+
run_id = run["id"]
|
|
635
|
+
payload = run.get("payload", {})
|
|
636
|
+
source_file = payload.get("experiment") or payload.get("_source", {}).get("file") or f"run-{run_id}"
|
|
637
|
+
table.add_row(str(run_id), source_file)
|
|
638
|
+
console.print(table)
|
|
639
|
+
return
|
|
640
|
+
|
|
641
|
+
if args.json:
|
|
642
|
+
print(json.dumps(data, indent=2))
|
|
643
|
+
return
|
|
644
|
+
|
|
645
|
+
content = data.get("content", [])
|
|
646
|
+
if not content:
|
|
647
|
+
print("No matching runs found.")
|
|
648
|
+
if args.batch and filters and len(filters) == 1:
|
|
649
|
+
metric = filters[0]["metric"]
|
|
650
|
+
if "." not in metric and "[]" not in metric:
|
|
651
|
+
hint = suggest_array_path(args.batch, metric)
|
|
652
|
+
if hint:
|
|
653
|
+
print(hint)
|
|
654
|
+
return
|
|
655
|
+
|
|
656
|
+
dot_path = None
|
|
657
|
+
if not args.q and filters and len(filters) == 1:
|
|
658
|
+
metric = filters[0]["metric"]
|
|
659
|
+
if "." in metric or "[]" in metric:
|
|
660
|
+
dot_path = metric
|
|
661
|
+
else:
|
|
662
|
+
first_payload = content[0].get("payload", {})
|
|
663
|
+
dot_path = find_shallowest_path(first_payload, metric)
|
|
664
|
+
|
|
665
|
+
block_levels = None
|
|
666
|
+
if args.block is not None:
|
|
667
|
+
block_levels = [args.block]
|
|
668
|
+
|
|
669
|
+
for run in content:
|
|
670
|
+
show_blocks(run, dot_path, block_levels)
|
|
671
|
+
|
|
672
|
+
def cli_export(args):
|
|
673
|
+
filters = None
|
|
674
|
+
if args.metrics and args.ops and args.values:
|
|
675
|
+
if len(args.metrics) != len(args.ops) or len(args.metrics) != len(args.values):
|
|
676
|
+
console.print("[red]Number of --metric, --op, --value arguments must match.[/red]")
|
|
677
|
+
sys.exit(1)
|
|
678
|
+
filters = [
|
|
679
|
+
{"metric": m, "op": op, "value": v}
|
|
680
|
+
for m, op, v in zip(args.metrics, args.ops, args.values)
|
|
681
|
+
]
|
|
682
|
+
|
|
683
|
+
pointers_enabled = getattr(args, "pointers", True)
|
|
684
|
+
combine = args.combine if args.combine else "and"
|
|
685
|
+
|
|
686
|
+
if args.q:
|
|
687
|
+
data = perform_search(q=args.q, fuzzy=args.fuzzy, batch=args.batch, page=0)
|
|
688
|
+
elif filters and len(filters) > 1:
|
|
689
|
+
data = perform_multi_search(filters, combine, batch=args.batch, page=0)
|
|
690
|
+
elif filters:
|
|
691
|
+
f = filters[0]
|
|
692
|
+
data = perform_search(metric=f["metric"], op=f["op"], value=f["value"],
|
|
693
|
+
batch=args.batch, page=0, pointers=pointers_enabled)
|
|
694
|
+
else:
|
|
695
|
+
console.print("[red]No search parameters provided.[/red]")
|
|
696
|
+
sys.exit(1)
|
|
697
|
+
|
|
698
|
+
if data is None:
|
|
699
|
+
sys.exit(1)
|
|
700
|
+
|
|
701
|
+
content = data.get("content", [])
|
|
702
|
+
if not content:
|
|
703
|
+
if args.output:
|
|
704
|
+
Path(args.output).write_text("", encoding="utf-8")
|
|
705
|
+
else:
|
|
706
|
+
print("")
|
|
707
|
+
return
|
|
708
|
+
|
|
709
|
+
dot_path = None
|
|
710
|
+
if not args.q and filters and len(filters) == 1:
|
|
711
|
+
metric = filters[0]["metric"]
|
|
712
|
+
if "." in metric or "[]" in metric:
|
|
713
|
+
dot_path = metric
|
|
714
|
+
else:
|
|
715
|
+
first_payload = content[0].get("payload", {})
|
|
716
|
+
dot_path = find_shallowest_path(first_payload, metric)
|
|
717
|
+
|
|
718
|
+
block_levels = None
|
|
719
|
+
if args.block is not None:
|
|
720
|
+
block_levels = [args.block]
|
|
721
|
+
|
|
722
|
+
blocks = build_export_blocks(content, dot_path, block_levels, include_pointers=pointers_enabled)
|
|
723
|
+
|
|
724
|
+
if args.format == "json":
|
|
725
|
+
out = json.dumps(blocks, indent=2)
|
|
726
|
+
else:
|
|
727
|
+
out = format_blocks_as_text(blocks)
|
|
728
|
+
|
|
729
|
+
if args.output:
|
|
730
|
+
Path(args.output).write_text(out, encoding="utf-8")
|
|
731
|
+
console.print(f"[green]Exported {len(blocks)} block(s) to {args.output}[/green]")
|
|
732
|
+
else:
|
|
733
|
+
print(out)
|
|
734
|
+
|
|
735
|
+
def cli_preview(args):
|
|
736
|
+
pointers_enabled = getattr(args, "pointers", True)
|
|
737
|
+
data = perform_search(metric=args.metric, op=args.op, value=args.value,
|
|
738
|
+
batch=args.batch, page=0, size=1,
|
|
739
|
+
pointers=pointers_enabled)
|
|
740
|
+
if data is None or not data.get("content"):
|
|
741
|
+
console.print("[red]No matching run found.[/red]")
|
|
742
|
+
sys.exit(1)
|
|
743
|
+
|
|
744
|
+
run = data["content"][0]
|
|
745
|
+
metric = args.metric
|
|
746
|
+
if "." in metric or "[]" in metric:
|
|
747
|
+
dot_path = metric
|
|
748
|
+
else:
|
|
749
|
+
dot_path = find_shallowest_path(run.get("payload", {}), metric)
|
|
750
|
+
if dot_path is None:
|
|
751
|
+
console.print(f"[red]Could not locate key '{metric}' in the matching run.[/red]")
|
|
752
|
+
sys.exit(1)
|
|
753
|
+
show_blocks(run, dot_path)
|
|
754
|
+
|
|
755
|
+
def find_shallowest_path(payload: dict, key: str):
|
|
756
|
+
def _walk(prefix, node, depth):
|
|
757
|
+
if isinstance(node, dict):
|
|
758
|
+
for k, v in node.items():
|
|
759
|
+
path = f"{prefix}.{k}" if prefix else k
|
|
760
|
+
if k == key and not isinstance(v, dict):
|
|
761
|
+
return (depth, path)
|
|
762
|
+
if isinstance(v, dict):
|
|
763
|
+
res = _walk(path, v, depth+1)
|
|
764
|
+
if res:
|
|
765
|
+
return res
|
|
766
|
+
return None
|
|
767
|
+
return None
|
|
768
|
+
result = _walk("", payload, 0)
|
|
769
|
+
return result[1] if result else None
|
|
770
|
+
|
|
771
|
+
# ------------------------------------------------------------
|
|
772
|
+
# Interactive search loop
|
|
773
|
+
# ------------------------------------------------------------
|
|
774
|
+
def interactive_search(batch: str = None):
|
|
775
|
+
console.print("\n[bold]Interactive Search[/bold]")
|
|
776
|
+
while True:
|
|
777
|
+
metrics = fetch_metrics(batch)
|
|
778
|
+
if not metrics:
|
|
779
|
+
console.print("[yellow]No metrics available yet. Ingest some runs first.[/yellow]")
|
|
780
|
+
break
|
|
781
|
+
|
|
782
|
+
selected_metric = None
|
|
783
|
+
filtered = metrics[:]
|
|
784
|
+
while True:
|
|
785
|
+
if len(filtered) == 1:
|
|
786
|
+
console.print(f"\n[green]Auto‑selected metric: {filtered[0]}[/green]")
|
|
787
|
+
selected_metric = filtered[0]
|
|
788
|
+
break
|
|
789
|
+
if len(filtered) == 0:
|
|
790
|
+
console.print("[red]No matching metrics. Try a different filter.[/red]")
|
|
791
|
+
filtered = metrics[:]
|
|
792
|
+
|
|
793
|
+
table = Table(title="Metrics (type to filter, press Enter for all)")
|
|
794
|
+
table.add_column("#", justify="right")
|
|
795
|
+
table.add_column("Metric Name", style="cyan")
|
|
796
|
+
for i, m in enumerate(filtered, 1):
|
|
797
|
+
table.add_row(str(i), m)
|
|
798
|
+
console.print(table)
|
|
799
|
+
|
|
800
|
+
query = Prompt.ask("Type to filter metrics (or 'all' to reset, number to select)", default="")
|
|
801
|
+
if query.lower() == "all":
|
|
802
|
+
filtered = metrics[:]
|
|
803
|
+
continue
|
|
804
|
+
if query.isdigit():
|
|
805
|
+
idx = int(query) - 1
|
|
806
|
+
if 0 <= idx < len(filtered):
|
|
807
|
+
selected_metric = filtered[idx]
|
|
808
|
+
break
|
|
809
|
+
filtered = [m for m in metrics if query.lower() in m.lower()]
|
|
810
|
+
|
|
811
|
+
metric = selected_metric
|
|
812
|
+
|
|
813
|
+
console.print("\nOperators:")
|
|
814
|
+
console.print(" > (greater than)")
|
|
815
|
+
console.print(" >= (greater than or equal)")
|
|
816
|
+
console.print(" < (less than)")
|
|
817
|
+
console.print(" <= (less than or equal)")
|
|
818
|
+
console.print(" = (equal)")
|
|
819
|
+
op_map = {
|
|
820
|
+
">": "gt", ">=": "gte", "<": "lt", "<=": "lte", "=": "eq",
|
|
821
|
+
"gt": "gt", "gte": "gte", "lt": "lt", "lte": "lte", "eq": "eq",
|
|
822
|
+
}
|
|
823
|
+
op_input = Prompt.ask("Operator", choices=list(op_map.keys()), default=">")
|
|
824
|
+
op = op_map[op_input]
|
|
825
|
+
value = Prompt.ask("Value")
|
|
826
|
+
|
|
827
|
+
page = 0
|
|
828
|
+
while True:
|
|
829
|
+
data = perform_search(metric=metric, op=op, value=value, batch=batch, page=page)
|
|
830
|
+
if data is None:
|
|
831
|
+
break
|
|
832
|
+
display_results(data)
|
|
833
|
+
content = data.get("content", [])
|
|
834
|
+
total_pages = data.get("totalPages", 1)
|
|
835
|
+
if not content or total_pages <= 1:
|
|
836
|
+
break
|
|
837
|
+
cmd = Prompt.ask("Navigation (n/p/q)", choices=["n", "p", "q"], default="q")
|
|
838
|
+
if cmd == "n" and page < total_pages - 1:
|
|
839
|
+
page += 1
|
|
840
|
+
elif cmd == "p" and page > 0:
|
|
841
|
+
page -= 1
|
|
842
|
+
elif cmd == "q":
|
|
843
|
+
break
|
|
844
|
+
|
|
845
|
+
again = Prompt.ask("Another search? (y/n)", choices=["y", "n"], default="n")
|
|
846
|
+
if again != "y":
|
|
847
|
+
break
|
|
848
|
+
|
|
849
|
+
def display_results(data):
|
|
850
|
+
if data is None:
|
|
851
|
+
return
|
|
852
|
+
content = data.get("content", [])
|
|
853
|
+
total_elements = data.get("totalElements", 0)
|
|
854
|
+
total_pages = data.get("totalPages", 1)
|
|
855
|
+
page_number = data.get("pageable", {}).get("pageNumber", 0)
|
|
856
|
+
|
|
857
|
+
if not content:
|
|
858
|
+
console.print("No matching runs found.")
|
|
859
|
+
return
|
|
860
|
+
|
|
861
|
+
table = Table(title=f"Search Results (page {page_number+1}/{total_pages})")
|
|
862
|
+
table.add_column("ID", justify="right", style="cyan")
|
|
863
|
+
table.add_column("Created At", justify="left", style="green")
|
|
864
|
+
table.add_column("Experiment / Source", justify="left")
|
|
865
|
+
table.add_column("Snippet", justify="left", no_wrap=False, max_width=60)
|
|
866
|
+
|
|
867
|
+
for run in content:
|
|
868
|
+
run_id = run["id"]
|
|
869
|
+
created = run["createdAt"]
|
|
870
|
+
payload = run.get("payload", {})
|
|
871
|
+
experiment = payload.get("experiment", "")
|
|
872
|
+
source = payload.get("_source", {}).get("file", "")
|
|
873
|
+
description = experiment or source or "-"
|
|
874
|
+
snippet = json.dumps(payload, indent=2)
|
|
875
|
+
if len(snippet) > 120:
|
|
876
|
+
snippet = snippet[:120] + "..."
|
|
877
|
+
table.add_row(str(run_id), created, description, snippet)
|
|
878
|
+
|
|
879
|
+
console.print(table)
|
|
880
|
+
if total_pages > 1:
|
|
881
|
+
nav = f"Page {page_number+1} of {total_pages} | n = next page, p = previous page, q = quit"
|
|
882
|
+
console.print(nav)
|
|
883
|
+
|
|
884
|
+
# ------------------------------------------------------------
|
|
885
|
+
# Status
|
|
886
|
+
# ------------------------------------------------------------
|
|
887
|
+
def show_status():
|
|
888
|
+
if backend_running():
|
|
889
|
+
console.print("[green]RunLedger backend is running.[/green]")
|
|
890
|
+
try:
|
|
891
|
+
resp = requests.get(f"{API_BASE}/metrics")
|
|
892
|
+
if resp.status_code == 200:
|
|
893
|
+
metrics = resp.json()
|
|
894
|
+
console.print(f"Available metrics across all batches: {', '.join(metrics) if metrics else 'none'}")
|
|
895
|
+
except Exception:
|
|
896
|
+
pass
|
|
897
|
+
else:
|
|
898
|
+
console.print("[red]RunLedger backend is not running.[/red]")
|
|
899
|
+
|
|
900
|
+
# ------------------------------------------------------------
|
|
901
|
+
# Diff
|
|
902
|
+
# ------------------------------------------------------------
|
|
903
|
+
def cli_diff(args):
|
|
904
|
+
if args.history:
|
|
905
|
+
batch = args.batch
|
|
906
|
+
if not batch:
|
|
907
|
+
console.print("[red]--batch is required with --history[/red]")
|
|
908
|
+
sys.exit(1)
|
|
909
|
+
|
|
910
|
+
resp = requests.get(f"{API_BASE}/{args.id1}")
|
|
911
|
+
if resp.status_code != 200:
|
|
912
|
+
console.print("[red]Could not fetch run. Check ID.[/red]")
|
|
913
|
+
sys.exit(1)
|
|
914
|
+
run = resp.json()
|
|
915
|
+
payload = run.get("payload", {})
|
|
916
|
+
source_file = payload.get("_source", {}).get("file", "")
|
|
917
|
+
source_index = payload.get("_source", {}).get("index", 0)
|
|
918
|
+
|
|
919
|
+
resp = requests.get(f"{API_BASE}/versions", params={
|
|
920
|
+
"batch": batch, "sourceFile": source_file, "sourceIndex": source_index
|
|
921
|
+
})
|
|
922
|
+
if resp.status_code != 200 or not resp.json():
|
|
923
|
+
console.print("[red]No versions found.[/red]")
|
|
924
|
+
sys.exit(1)
|
|
925
|
+
versions = resp.json()
|
|
926
|
+
|
|
927
|
+
if len(versions) < 2:
|
|
928
|
+
console.print("[yellow]Only one version exists – nothing to diff.[/yellow]")
|
|
929
|
+
return
|
|
930
|
+
|
|
931
|
+
for i in range(len(versions) - 1):
|
|
932
|
+
v1 = versions[i]
|
|
933
|
+
v2 = versions[i+1]
|
|
934
|
+
flat1 = flatten_payload(v1.get("payload", {}))
|
|
935
|
+
flat2 = flatten_payload(v2.get("payload", {}))
|
|
936
|
+
all_keys = sorted(set(flat1) | set(flat2))
|
|
937
|
+
|
|
938
|
+
table = Table(title=f"Version Diff: v{v1['id']} → v{v2['id']}")
|
|
939
|
+
table.add_column("Metric / Parameter", style="cyan")
|
|
940
|
+
table.add_column(f"v{v1['id']}", style="white")
|
|
941
|
+
table.add_column(f"v{v2['id']}", style="white")
|
|
942
|
+
table.add_column("Δ", style="white")
|
|
943
|
+
|
|
944
|
+
for key in all_keys:
|
|
945
|
+
v1_val = flat1.get(key, "—")
|
|
946
|
+
v2_val = flat2.get(key, "—")
|
|
947
|
+
delta_str, color = compute_delta(v1_val, v2_val)
|
|
948
|
+
style_prefix = f"[{color}]" if color != "white" else ""
|
|
949
|
+
style_suffix = "[/]" if style_prefix else ""
|
|
950
|
+
table.add_row(
|
|
951
|
+
f"{style_prefix}{key}{style_suffix}",
|
|
952
|
+
f"{style_prefix}{v1_val}{style_suffix}",
|
|
953
|
+
f"{style_prefix}{v2_val}{style_suffix}",
|
|
954
|
+
f"{style_prefix}{delta_str}{style_suffix}"
|
|
955
|
+
)
|
|
956
|
+
console.print(table)
|
|
957
|
+
return
|
|
958
|
+
|
|
959
|
+
if args.id2 is None:
|
|
960
|
+
console.print("[red]Second run ID is required (or use --history).[/red]")
|
|
961
|
+
sys.exit(1)
|
|
962
|
+
|
|
963
|
+
resp1 = requests.get(f"{API_BASE}/{args.id1}")
|
|
964
|
+
resp2 = requests.get(f"{API_BASE}/{args.id2}")
|
|
965
|
+
if resp1.status_code != 200 or resp2.status_code != 200:
|
|
966
|
+
console.print("[red]Could not fetch one or both runs. Check IDs.[/red]")
|
|
967
|
+
sys.exit(1)
|
|
968
|
+
|
|
969
|
+
run1 = resp1.json()
|
|
970
|
+
run2 = resp2.json()
|
|
971
|
+
flat1 = flatten_payload(run1.get("payload", {}))
|
|
972
|
+
flat2 = flatten_payload(run2.get("payload", {}))
|
|
973
|
+
|
|
974
|
+
all_keys = sorted(set(flat1) | set(flat2))
|
|
975
|
+
|
|
976
|
+
if args.json:
|
|
977
|
+
diff_data = []
|
|
978
|
+
for key in all_keys:
|
|
979
|
+
v1 = flat1.get(key, None)
|
|
980
|
+
v2 = flat2.get(key, None)
|
|
981
|
+
delta_str, _ = compute_delta(v1, v2)
|
|
982
|
+
diff_data.append({"path": key, "run1": v1, "run2": v2, "delta": delta_str})
|
|
983
|
+
print(json.dumps(diff_data, indent=2))
|
|
984
|
+
return
|
|
985
|
+
|
|
986
|
+
table = Table(title=f"Run Diff: {args.id1} vs {args.id2}")
|
|
987
|
+
table.add_column("Metric / Parameter", style="cyan")
|
|
988
|
+
table.add_column(f"Run {args.id1}", style="white")
|
|
989
|
+
table.add_column(f"Run {args.id2}", style="white")
|
|
990
|
+
table.add_column("Δ", style="white")
|
|
991
|
+
|
|
992
|
+
for key in all_keys:
|
|
993
|
+
v1 = flat1.get(key, "—")
|
|
994
|
+
v2 = flat2.get(key, "—")
|
|
995
|
+
delta_str, color = compute_delta(v1, v2)
|
|
996
|
+
style_prefix = f"[{color}]" if color != "white" else ""
|
|
997
|
+
style_suffix = "[/]" if style_prefix else ""
|
|
998
|
+
table.add_row(
|
|
999
|
+
f"{style_prefix}{key}{style_suffix}",
|
|
1000
|
+
f"{style_prefix}{v1}{style_suffix}",
|
|
1001
|
+
f"{style_prefix}{v2}{style_suffix}",
|
|
1002
|
+
f"{style_prefix}{delta_str}{style_suffix}"
|
|
1003
|
+
)
|
|
1004
|
+
|
|
1005
|
+
console.print(table)
|
|
1006
|
+
|
|
1007
|
+
# ------------------------------------------------------------
|
|
1008
|
+
# Aggregate
|
|
1009
|
+
# ------------------------------------------------------------
|
|
1010
|
+
def cli_aggregate(args):
|
|
1011
|
+
params = {"agg": args.agg, "metric": args.metric}
|
|
1012
|
+
if args.group_by:
|
|
1013
|
+
params["groupBy"] = args.group_by
|
|
1014
|
+
if args.batch:
|
|
1015
|
+
params["batch"] = args.batch
|
|
1016
|
+
|
|
1017
|
+
resp = requests.get(f"{API_BASE}/aggregate", params=params)
|
|
1018
|
+
if resp.status_code != 200:
|
|
1019
|
+
console.print(f"[red]Aggregate failed: {resp.text}[/red]")
|
|
1020
|
+
sys.exit(1)
|
|
1021
|
+
|
|
1022
|
+
data = resp.json()
|
|
1023
|
+
if args.json:
|
|
1024
|
+
print(json.dumps(data, indent=2))
|
|
1025
|
+
return
|
|
1026
|
+
|
|
1027
|
+
if not data:
|
|
1028
|
+
console.print("[yellow]No results.[/yellow]")
|
|
1029
|
+
return
|
|
1030
|
+
|
|
1031
|
+
table = Table(title=f"{args.agg}({args.metric})" +
|
|
1032
|
+
(f" grouped by {args.group_by}" if args.group_by else ""))
|
|
1033
|
+
if args.group_by:
|
|
1034
|
+
table.add_column(args.group_by, style="cyan")
|
|
1035
|
+
table.add_column(args.agg, style="green", justify="right")
|
|
1036
|
+
|
|
1037
|
+
for row in data:
|
|
1038
|
+
if args.group_by:
|
|
1039
|
+
table.add_row(str(row.get("group", "")), f"{row['result']:.6f}")
|
|
1040
|
+
else:
|
|
1041
|
+
table.add_row(f"{row['result']:.6f}")
|
|
1042
|
+
console.print(table)
|
|
1043
|
+
|
|
1044
|
+
# ------------------------------------------------------------
|
|
1045
|
+
# Main entry point
|
|
1046
|
+
# ------------------------------------------------------------
|
|
1047
|
+
def main():
|
|
1048
|
+
parser = argparse.ArgumentParser(description="RunLedger CLI")
|
|
1049
|
+
subparsers = parser.add_subparsers(dest="command", help="Command")
|
|
1050
|
+
|
|
1051
|
+
# scan
|
|
1052
|
+
scan_parser = subparsers.add_parser("scan", help="Scan a folder of JSON files")
|
|
1053
|
+
scan_parser.add_argument("folder", help="Path to the folder")
|
|
1054
|
+
scan_parser.add_argument("--batch", help="Custom batch name (default: folder name)")
|
|
1055
|
+
scan_parser.add_argument("--jsonl", action="store_true", help="Process files line by line (JSONL format)")
|
|
1056
|
+
|
|
1057
|
+
# stop & status
|
|
1058
|
+
subparsers.add_parser("stop", help="Stop the RunLedger backend")
|
|
1059
|
+
subparsers.add_parser("status", help="Show backend status")
|
|
1060
|
+
|
|
1061
|
+
# search – supports multiple --metric --op --value triples
|
|
1062
|
+
search_parser = subparsers.add_parser("search", help="Search runs")
|
|
1063
|
+
search_parser.add_argument("--metric", action='append', dest='metrics',
|
|
1064
|
+
help="Metric key (can be repeated)")
|
|
1065
|
+
search_parser.add_argument("--op", action='append', dest='ops',
|
|
1066
|
+
choices=["gt","gte","lt","lte","eq"],
|
|
1067
|
+
help="Operator (can be repeated)")
|
|
1068
|
+
search_parser.add_argument("--value", action='append', dest='values',
|
|
1069
|
+
help="Value to compare (can be repeated)")
|
|
1070
|
+
search_parser.add_argument("--combine", choices=["and","or"], default=None,
|
|
1071
|
+
help="Combine multiple conditions with AND or OR")
|
|
1072
|
+
search_parser.add_argument("--q", help="Full‑text / fuzzy phrase")
|
|
1073
|
+
search_parser.add_argument("--fuzzy", action="store_true", help="Enable fuzzy search")
|
|
1074
|
+
search_parser.add_argument("--batch")
|
|
1075
|
+
search_parser.add_argument("--summary", action="store_true", help="Show only ID and source file")
|
|
1076
|
+
search_parser.add_argument("--json", action="store_true", help="Output raw JSON instead of block format")
|
|
1077
|
+
search_parser.add_argument("--block", type=int, help="Show only this block depth (default: all levels)")
|
|
1078
|
+
search_parser.add_argument("--pointers", dest="pointers", action="store_true", default=True,
|
|
1079
|
+
help="Show exact match locations (default: True)")
|
|
1080
|
+
search_parser.add_argument("--no-pointers", dest="pointers", action="store_false",
|
|
1081
|
+
help="Disable match pointers")
|
|
1082
|
+
search_parser.add_argument("--save", help="Save this search under a name")
|
|
1083
|
+
search_parser.add_argument("--saved", help="Run a previously saved search by name")
|
|
1084
|
+
|
|
1085
|
+
# preview
|
|
1086
|
+
preview_parser = subparsers.add_parser("preview", help="Show block levels for a metric match")
|
|
1087
|
+
preview_parser.add_argument("--metric", required=True)
|
|
1088
|
+
preview_parser.add_argument("--op", required=True, choices=["gt","gte","lt","lte","eq"])
|
|
1089
|
+
preview_parser.add_argument("--value", required=True)
|
|
1090
|
+
preview_parser.add_argument("--batch")
|
|
1091
|
+
preview_parser.add_argument("--pointers", dest="pointers", action="store_true", default=True,
|
|
1092
|
+
help="Show exact match locations (default: True)")
|
|
1093
|
+
preview_parser.add_argument("--no-pointers", dest="pointers", action="store_false",
|
|
1094
|
+
help="Disable match pointers")
|
|
1095
|
+
|
|
1096
|
+
# export – supports same multi‑filter as search
|
|
1097
|
+
export_parser = subparsers.add_parser("export", help="Export search results to a file")
|
|
1098
|
+
export_parser.add_argument("--metric", action='append', dest='metrics',
|
|
1099
|
+
help="Metric key (can be repeated)")
|
|
1100
|
+
export_parser.add_argument("--op", action='append', dest='ops',
|
|
1101
|
+
choices=["gt","gte","lt","lte","eq"],
|
|
1102
|
+
help="Operator (can be repeated)")
|
|
1103
|
+
export_parser.add_argument("--value", action='append', dest='values',
|
|
1104
|
+
help="Value to compare (can be repeated)")
|
|
1105
|
+
export_parser.add_argument("--combine", choices=["and","or"], default=None,
|
|
1106
|
+
help="Combine multiple conditions with AND or OR")
|
|
1107
|
+
export_parser.add_argument("--q", help="Full‑text / fuzzy phrase")
|
|
1108
|
+
export_parser.add_argument("--fuzzy", action="store_true", help="Enable fuzzy search")
|
|
1109
|
+
export_parser.add_argument("--batch")
|
|
1110
|
+
export_parser.add_argument("--block", type=int, help="Block depth to export (default: all levels)")
|
|
1111
|
+
export_parser.add_argument("--format", choices=["text","json"], default="text",
|
|
1112
|
+
help="Output format (text or json)")
|
|
1113
|
+
export_parser.add_argument("--output", help="File to write to (default: stdout)")
|
|
1114
|
+
export_parser.add_argument("--pointers", dest="pointers", action="store_true", default=True,
|
|
1115
|
+
help="Show exact match locations (default: True)")
|
|
1116
|
+
export_parser.add_argument("--no-pointers", dest="pointers", action="store_false",
|
|
1117
|
+
help="Disable match pointers")
|
|
1118
|
+
|
|
1119
|
+
# metrics
|
|
1120
|
+
metrics_parser = subparsers.add_parser("metrics", help="List available metric keys")
|
|
1121
|
+
metrics_parser.add_argument("--batch")
|
|
1122
|
+
metrics_parser.add_argument("--verbose", action="store_true", help="Show full dot‑path and depth")
|
|
1123
|
+
|
|
1124
|
+
# interactive
|
|
1125
|
+
interactive_parser = subparsers.add_parser("interactive", help="Launch the interactive search wizard")
|
|
1126
|
+
interactive_parser.add_argument("--batch")
|
|
1127
|
+
|
|
1128
|
+
# saved
|
|
1129
|
+
saved_parser = subparsers.add_parser("saved", help="Manage saved searches")
|
|
1130
|
+
saved_sub = saved_parser.add_subparsers(dest="saved_action")
|
|
1131
|
+
saved_list = saved_sub.add_parser("list", help="List saved searches")
|
|
1132
|
+
saved_list.add_argument("--batch")
|
|
1133
|
+
saved_delete = saved_sub.add_parser("delete", help="Delete a saved search")
|
|
1134
|
+
saved_delete.add_argument("name")
|
|
1135
|
+
saved_delete.add_argument("--batch")
|
|
1136
|
+
|
|
1137
|
+
# diff
|
|
1138
|
+
diff_parser = subparsers.add_parser("diff", help="Compare two runs side‑by‑side")
|
|
1139
|
+
diff_parser.add_argument("id1", type=int, help="Run ID (or first run ID for pairwise diff)")
|
|
1140
|
+
diff_parser.add_argument("id2", type=int, nargs="?", default=None, help="Second run ID (optional if using --history)")
|
|
1141
|
+
diff_parser.add_argument("--json", action="store_true", help="Output raw JSON")
|
|
1142
|
+
diff_parser.add_argument("--history", action="store_true", help="Diff all versions of the same run identity")
|
|
1143
|
+
diff_parser.add_argument("--batch", help="Batch name (required with --history)")
|
|
1144
|
+
|
|
1145
|
+
# aggregate
|
|
1146
|
+
agg_parser = subparsers.add_parser("aggregate", help="Aggregate metrics (AVG/MAX/MIN/SUM/COUNT)")
|
|
1147
|
+
agg_parser.add_argument("--metric", required=True, help="Metric path to aggregate")
|
|
1148
|
+
agg_parser.add_argument("--agg", required=True, choices=["AVG","MAX","MIN","SUM","COUNT"], help="Aggregate function")
|
|
1149
|
+
agg_parser.add_argument("--group-by", help="Field to group results by")
|
|
1150
|
+
agg_parser.add_argument("--batch", help="Batch name")
|
|
1151
|
+
agg_parser.add_argument("--json", action="store_true", help="Output raw JSON")
|
|
1152
|
+
|
|
1153
|
+
# guided
|
|
1154
|
+
subparsers.add_parser("guided", help="Interactive guided experiment search")
|
|
1155
|
+
|
|
1156
|
+
args = parser.parse_args()
|
|
1157
|
+
|
|
1158
|
+
if not check_docker():
|
|
1159
|
+
sys.exit(1)
|
|
1160
|
+
|
|
1161
|
+
if args.command is None:
|
|
1162
|
+
try:
|
|
1163
|
+
resp = requests.get(API_BASE, params={"size": 1})
|
|
1164
|
+
has_data = resp.status_code == 200 and resp.json().get("totalElements", 0) > 0
|
|
1165
|
+
except Exception:
|
|
1166
|
+
has_data = False
|
|
1167
|
+
|
|
1168
|
+
if not has_data:
|
|
1169
|
+
from cli.guided import run_guided_search
|
|
1170
|
+
run_guided_search()
|
|
1171
|
+
else:
|
|
1172
|
+
console.print("[bold cyan]RunLedger[/bold cyan] – zero‑friction experiment search")
|
|
1173
|
+
console.print("\nCommon commands:")
|
|
1174
|
+
console.print(" [bold]runledger scan <folder>[/bold] Ingest experiment files")
|
|
1175
|
+
console.print(" [bold]runledger search --metric <key> --op <op> --value <v>[/bold]")
|
|
1176
|
+
console.print(" [bold]runledger guided[/bold] Interactive search wizard")
|
|
1177
|
+
console.print("\nUse [bold]runledger --help[/bold] for full command list.")
|
|
1178
|
+
return
|
|
1179
|
+
|
|
1180
|
+
if args.command == "scan":
|
|
1181
|
+
if not backend_running():
|
|
1182
|
+
start_backend()
|
|
1183
|
+
else:
|
|
1184
|
+
console.print("Backend already running.")
|
|
1185
|
+
batch = args.batch if args.batch else Path(args.folder).name
|
|
1186
|
+
ingest_folder(args.folder, batch, jsonl=args.jsonl)
|
|
1187
|
+
|
|
1188
|
+
console.print(f"\n[bold]Schema summary for batch '{batch}':[/bold]")
|
|
1189
|
+
verbose_metrics = fetch_verbose_metrics(batch)
|
|
1190
|
+
if not verbose_metrics:
|
|
1191
|
+
console.print("[yellow]No metrics were discovered.[/yellow]")
|
|
1192
|
+
else:
|
|
1193
|
+
table = Table(title="Key -> Resolved Path")
|
|
1194
|
+
table.add_column("Shorthand Key", style="cyan", no_wrap=True)
|
|
1195
|
+
table.add_column("Full Dot-Path", style="green")
|
|
1196
|
+
table.add_column("Depth", justify="right")
|
|
1197
|
+
for item in verbose_metrics:
|
|
1198
|
+
table.add_row(item["key"], item["path"], str(item["depth"]))
|
|
1199
|
+
console.print(table)
|
|
1200
|
+
|
|
1201
|
+
deepest = max(verbose_metrics, key=lambda x: x["depth"])
|
|
1202
|
+
console.print(f"\n[bold]Block preview for deepest metric '{deepest['key']}':[/bold]")
|
|
1203
|
+
|
|
1204
|
+
preview_run = None
|
|
1205
|
+
page = 0
|
|
1206
|
+
while True:
|
|
1207
|
+
resp = requests.get(API_BASE, params={"batch": batch, "size": 10, "page": page})
|
|
1208
|
+
if resp.status_code != 200:
|
|
1209
|
+
break
|
|
1210
|
+
data = resp.json()
|
|
1211
|
+
if not data.get("content"):
|
|
1212
|
+
break
|
|
1213
|
+
for run in data["content"]:
|
|
1214
|
+
if navigate_json_pointer(run.get("payload", {}), "/" + deepest["path"].replace(".", "/").replace("[]","")):
|
|
1215
|
+
preview_run = run
|
|
1216
|
+
break
|
|
1217
|
+
if preview_run:
|
|
1218
|
+
break
|
|
1219
|
+
page += 1
|
|
1220
|
+
if page >= 10:
|
|
1221
|
+
break
|
|
1222
|
+
|
|
1223
|
+
if preview_run:
|
|
1224
|
+
show_blocks(preview_run, deepest["path"])
|
|
1225
|
+
else:
|
|
1226
|
+
console.print("[yellow]No run in the batch contains this metric – preview skipped.[/yellow]")
|
|
1227
|
+
|
|
1228
|
+
console.print("\n[dim]Tip: run `ledger preview --metric <key> --op <gt|lt|eq> --value <v> --batch <batch>` to explore block levels for any metric.[/dim]")
|
|
1229
|
+
|
|
1230
|
+
elif args.command == "stop":
|
|
1231
|
+
stop_backend()
|
|
1232
|
+
elif args.command == "status":
|
|
1233
|
+
show_status()
|
|
1234
|
+
elif args.command == "search":
|
|
1235
|
+
cli_search(args)
|
|
1236
|
+
elif args.command == "metrics":
|
|
1237
|
+
cli_metrics(args)
|
|
1238
|
+
elif args.command == "preview":
|
|
1239
|
+
cli_preview(args)
|
|
1240
|
+
elif args.command == "export":
|
|
1241
|
+
cli_export(args)
|
|
1242
|
+
elif args.command == "interactive":
|
|
1243
|
+
if not backend_running():
|
|
1244
|
+
start_backend()
|
|
1245
|
+
interactive_search(args.batch)
|
|
1246
|
+
elif args.command == "saved":
|
|
1247
|
+
if args.saved_action == "list":
|
|
1248
|
+
searches = list_saved_searches(args.batch)
|
|
1249
|
+
if not searches:
|
|
1250
|
+
console.print("No saved searches.")
|
|
1251
|
+
else:
|
|
1252
|
+
table = Table(title="Saved Searches")
|
|
1253
|
+
table.add_column("Name")
|
|
1254
|
+
table.add_column("Batch")
|
|
1255
|
+
table.add_column("Created")
|
|
1256
|
+
for s in searches:
|
|
1257
|
+
table.add_row(s["name"], s.get("batch", ""), s["createdAt"])
|
|
1258
|
+
console.print(table)
|
|
1259
|
+
elif args.saved_action == "delete":
|
|
1260
|
+
ok = delete_saved_search(args.name, args.batch)
|
|
1261
|
+
if ok:
|
|
1262
|
+
console.print(f"[green]Deleted saved search '{args.name}'.[/green]")
|
|
1263
|
+
else:
|
|
1264
|
+
console.print(f"[red]Saved search '{args.name}' not found.[/red]")
|
|
1265
|
+
elif args.command == "diff":
|
|
1266
|
+
cli_diff(args)
|
|
1267
|
+
elif args.command == "aggregate":
|
|
1268
|
+
cli_aggregate(args)
|
|
1269
|
+
elif args.command == "guided":
|
|
1270
|
+
from cli.guided import run_guided_search
|
|
1271
|
+
run_guided_search()
|
|
1272
|
+
else:
|
|
1273
|
+
parser.print_help()
|
|
1274
|
+
|
|
1275
|
+
if __name__ == "__main__":
|
|
1276
|
+
main()
|