runledger-cli 0.1.2__tar.gz → 0.1.4__tar.gz
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.
- runledger_cli-0.1.4/PKG-INFO +90 -0
- runledger_cli-0.1.4/README.md +81 -0
- {runledger_cli-0.1.2 → runledger_cli-0.1.4}/cli/guided.py +78 -31
- {runledger_cli-0.1.2 → runledger_cli-0.1.4}/cli/ledger.py +80 -16
- {runledger_cli-0.1.2 → runledger_cli-0.1.4}/cli/tests/test_data_features.py +71 -2
- {runledger_cli-0.1.2 → runledger_cli-0.1.4}/cli/tests/test_integration.py +80 -41
- {runledger_cli-0.1.2 → runledger_cli-0.1.4}/pyproject.toml +1 -1
- runledger_cli-0.1.4/runledger_cli.egg-info/PKG-INFO +90 -0
- runledger_cli-0.1.2/PKG-INFO +0 -198
- runledger_cli-0.1.2/README.md +0 -189
- runledger_cli-0.1.2/runledger_cli.egg-info/PKG-INFO +0 -198
- {runledger_cli-0.1.2 → runledger_cli-0.1.4}/cli/__init__.py +0 -0
- {runledger_cli-0.1.2 → runledger_cli-0.1.4}/cli/docker-compose.yml +0 -0
- {runledger_cli-0.1.2 → runledger_cli-0.1.4}/cli/tests/__init__.py +0 -0
- {runledger_cli-0.1.2 → runledger_cli-0.1.4}/cli/tests/test_ledger.py +0 -0
- {runledger_cli-0.1.2 → runledger_cli-0.1.4}/cli/tests/test_real_results.py +0 -0
- {runledger_cli-0.1.2 → runledger_cli-0.1.4}/runledger_cli.egg-info/SOURCES.txt +0 -0
- {runledger_cli-0.1.2 → runledger_cli-0.1.4}/runledger_cli.egg-info/dependency_links.txt +0 -0
- {runledger_cli-0.1.2 → runledger_cli-0.1.4}/runledger_cli.egg-info/entry_points.txt +0 -0
- {runledger_cli-0.1.2 → runledger_cli-0.1.4}/runledger_cli.egg-info/requires.txt +0 -0
- {runledger_cli-0.1.2 → runledger_cli-0.1.4}/runledger_cli.egg-info/top_level.txt +0 -0
- {runledger_cli-0.1.2 → runledger_cli-0.1.4}/setup.cfg +0 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: runledger-cli
|
|
3
|
+
Version: 0.1.4
|
|
4
|
+
Summary: Zero‑instrumentation experiment registry CLI
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: requests
|
|
8
|
+
Requires-Dist: rich
|
|
9
|
+
|
|
10
|
+
Here’s the updated README with the troubleshooting note added right after the installation instructions. The primary command remains `runledger`, but users who hit the Windows PATH issue now have a clear fallback.
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
# RunLedger
|
|
15
|
+
|
|
16
|
+
**A zero-instrumentation experiment registry.** Point RunLedger at a folder of JSON experiment files and search, filter, aggregate, diff, and export the results; no SDK, no code changes, no cloud.
|
|
17
|
+
|
|
18
|
+
Most experiment trackers make you instrument your training code before they can help you. RunLedger reads the JSON you're already writing, no `wandb.init()`, no `mlflow.log_metric()`, and indexes it in real Postgres, giving you full relational search: structured comparisons, full-text, fuzzy matching, and exact array-element lookups, from one CLI command.
|
|
19
|
+
|
|
20
|
+
Backed by Postgres, served by a Spring Boot API, and fronted by a Python CLI.
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install runledger-cli
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Requires Docker. If it's not installed, `runledger` detects that on first run and links you straight to the installer; everything else (Postgres, the backend) is managed for you.
|
|
29
|
+
|
|
30
|
+
### Troubleshooting: `runledger` command not found (Windows)
|
|
31
|
+
|
|
32
|
+
On some Windows systems, the Python Scripts folder is not automatically added to your PATH. If `runledger` is not recognized after installation, you have two options:
|
|
33
|
+
|
|
34
|
+
1. **Add the Scripts folder to your PATH permanently** (recommended):
|
|
35
|
+
- Find where `runledger` was installed: `pip show runledger-cli | findstr Location`
|
|
36
|
+
- Navigate to that folder, then up one level to the `Scripts` directory.
|
|
37
|
+
- Add that `Scripts` path to your system PATH via **System Properties > Environment Variables**.
|
|
38
|
+
- Restart your terminal.
|
|
39
|
+
|
|
40
|
+
2. **Use the Python module fallback** (works immediately, no PATH changes):
|
|
41
|
+
```bash
|
|
42
|
+
python -m cli.ledger
|
|
43
|
+
```
|
|
44
|
+
All subcommands and the guided wizard work exactly the same way; just prefix every command with `python -m cli.ledger`.
|
|
45
|
+
|
|
46
|
+
Once the PATH is configured, `runledger` will work as shown in the examples below.
|
|
47
|
+
|
|
48
|
+
<details>
|
|
49
|
+
<summary>Installing from source</summary>
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
git clone https://github.com/yourusername/run-ledger.git
|
|
53
|
+
cd run-ledger
|
|
54
|
+
docker compose up -d
|
|
55
|
+
pip install -e .
|
|
56
|
+
```
|
|
57
|
+
</details>
|
|
58
|
+
|
|
59
|
+
## Quick start
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
runledger scan ~/experiments --batch my-sweep
|
|
63
|
+
runledger search --metric accuracy --op gt --value 0.9 --batch my-sweep
|
|
64
|
+
runledger aggregate --metric accuracy --agg AVG --group-by experiment --batch my-sweep
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
New here? Run `runledger` with no arguments — the guided wizard walks you through your first scan and search.
|
|
68
|
+
|
|
69
|
+
## What you get
|
|
70
|
+
|
|
71
|
+
- **Search any field, no schema required** — structured comparisons (`gt`, `lt`, `eq`...), full-text with stemming, and fuzzy matching for typos, all on arbitrary dot-paths, including fields nested inside arrays (`results[].sst2_cacc`).
|
|
72
|
+
- **Compound AND/OR** across multiple conditions in one query.
|
|
73
|
+
- **Exact match pointers** — array searches tell you exactly which element(s) matched (`results[3].sst2_cacc = 0.933`), not just that the run did.
|
|
74
|
+
- **Aggregates & group-by** — `AVG` / `MAX` / `MIN` / `SUM` / `COUNT`, straight from the CLI.
|
|
75
|
+
- **Safe re-scanning** — ingestion is idempotent; a changed file gets a new version, and `diff --history` shows you what changed and when.
|
|
76
|
+
- **Run diff, saved searches, JSONL ingestion, block-depth control** — see [docs/examples.md](docs/examples.md) for each in action.
|
|
77
|
+
- **Self-hosted, local-first** — your data never leaves your machine.
|
|
78
|
+
|
|
79
|
+
## Metric paths
|
|
80
|
+
|
|
81
|
+
Address any value with a dot-path — `accuracy`, `metrics.loss`, `results[].sst2_cacc`. Shorthand (`cacc`) resolves automatically when it's unambiguous. Run `runledger metrics --batch <batch>` to see everything discovered in a scan, or add `--verbose` for full paths.
|
|
82
|
+
|
|
83
|
+
## Docs
|
|
84
|
+
|
|
85
|
+
- [API reference](docs/api.md)
|
|
86
|
+
- [Usage examples](docs/examples.md)
|
|
87
|
+
|
|
88
|
+
## License
|
|
89
|
+
|
|
90
|
+
MIT
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
Here’s the updated README with the troubleshooting note added right after the installation instructions. The primary command remains `runledger`, but users who hit the Windows PATH issue now have a clear fallback.
|
|
2
|
+
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# RunLedger
|
|
6
|
+
|
|
7
|
+
**A zero-instrumentation experiment registry.** Point RunLedger at a folder of JSON experiment files and search, filter, aggregate, diff, and export the results; no SDK, no code changes, no cloud.
|
|
8
|
+
|
|
9
|
+
Most experiment trackers make you instrument your training code before they can help you. RunLedger reads the JSON you're already writing, no `wandb.init()`, no `mlflow.log_metric()`, and indexes it in real Postgres, giving you full relational search: structured comparisons, full-text, fuzzy matching, and exact array-element lookups, from one CLI command.
|
|
10
|
+
|
|
11
|
+
Backed by Postgres, served by a Spring Boot API, and fronted by a Python CLI.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install runledger-cli
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Requires Docker. If it's not installed, `runledger` detects that on first run and links you straight to the installer; everything else (Postgres, the backend) is managed for you.
|
|
20
|
+
|
|
21
|
+
### Troubleshooting: `runledger` command not found (Windows)
|
|
22
|
+
|
|
23
|
+
On some Windows systems, the Python Scripts folder is not automatically added to your PATH. If `runledger` is not recognized after installation, you have two options:
|
|
24
|
+
|
|
25
|
+
1. **Add the Scripts folder to your PATH permanently** (recommended):
|
|
26
|
+
- Find where `runledger` was installed: `pip show runledger-cli | findstr Location`
|
|
27
|
+
- Navigate to that folder, then up one level to the `Scripts` directory.
|
|
28
|
+
- Add that `Scripts` path to your system PATH via **System Properties > Environment Variables**.
|
|
29
|
+
- Restart your terminal.
|
|
30
|
+
|
|
31
|
+
2. **Use the Python module fallback** (works immediately, no PATH changes):
|
|
32
|
+
```bash
|
|
33
|
+
python -m cli.ledger
|
|
34
|
+
```
|
|
35
|
+
All subcommands and the guided wizard work exactly the same way; just prefix every command with `python -m cli.ledger`.
|
|
36
|
+
|
|
37
|
+
Once the PATH is configured, `runledger` will work as shown in the examples below.
|
|
38
|
+
|
|
39
|
+
<details>
|
|
40
|
+
<summary>Installing from source</summary>
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
git clone https://github.com/yourusername/run-ledger.git
|
|
44
|
+
cd run-ledger
|
|
45
|
+
docker compose up -d
|
|
46
|
+
pip install -e .
|
|
47
|
+
```
|
|
48
|
+
</details>
|
|
49
|
+
|
|
50
|
+
## Quick start
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
runledger scan ~/experiments --batch my-sweep
|
|
54
|
+
runledger search --metric accuracy --op gt --value 0.9 --batch my-sweep
|
|
55
|
+
runledger aggregate --metric accuracy --agg AVG --group-by experiment --batch my-sweep
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
New here? Run `runledger` with no arguments — the guided wizard walks you through your first scan and search.
|
|
59
|
+
|
|
60
|
+
## What you get
|
|
61
|
+
|
|
62
|
+
- **Search any field, no schema required** — structured comparisons (`gt`, `lt`, `eq`...), full-text with stemming, and fuzzy matching for typos, all on arbitrary dot-paths, including fields nested inside arrays (`results[].sst2_cacc`).
|
|
63
|
+
- **Compound AND/OR** across multiple conditions in one query.
|
|
64
|
+
- **Exact match pointers** — array searches tell you exactly which element(s) matched (`results[3].sst2_cacc = 0.933`), not just that the run did.
|
|
65
|
+
- **Aggregates & group-by** — `AVG` / `MAX` / `MIN` / `SUM` / `COUNT`, straight from the CLI.
|
|
66
|
+
- **Safe re-scanning** — ingestion is idempotent; a changed file gets a new version, and `diff --history` shows you what changed and when.
|
|
67
|
+
- **Run diff, saved searches, JSONL ingestion, block-depth control** — see [docs/examples.md](docs/examples.md) for each in action.
|
|
68
|
+
- **Self-hosted, local-first** — your data never leaves your machine.
|
|
69
|
+
|
|
70
|
+
## Metric paths
|
|
71
|
+
|
|
72
|
+
Address any value with a dot-path — `accuracy`, `metrics.loss`, `results[].sst2_cacc`. Shorthand (`cacc`) resolves automatically when it's unambiguous. Run `runledger metrics --batch <batch>` to see everything discovered in a scan, or add `--verbose` for full paths.
|
|
73
|
+
|
|
74
|
+
## Docs
|
|
75
|
+
|
|
76
|
+
- [API reference](docs/api.md)
|
|
77
|
+
- [Usage examples](docs/examples.md)
|
|
78
|
+
|
|
79
|
+
## License
|
|
80
|
+
|
|
81
|
+
MIT
|
|
@@ -112,6 +112,14 @@ def fetch_verbose_metrics(batch):
|
|
|
112
112
|
return resp.json()
|
|
113
113
|
return []
|
|
114
114
|
|
|
115
|
+
def build_shorthand_map(batch):
|
|
116
|
+
"""Return dict {shorthand_key: [full_path, ...]} from verbose metrics."""
|
|
117
|
+
entries = fetch_verbose_metrics(batch)
|
|
118
|
+
mapping = {}
|
|
119
|
+
for e in entries:
|
|
120
|
+
mapping.setdefault(e['key'], []).append(e['path'])
|
|
121
|
+
return mapping
|
|
122
|
+
|
|
115
123
|
def perform_search(metric=None, op=None, value=None, q=None, fuzzy=False, batch=None, pointers=True, block=None):
|
|
116
124
|
params = {"page": 0, "size": 50, "sort": "created_at,desc"}
|
|
117
125
|
if batch:
|
|
@@ -179,12 +187,18 @@ def show_blocks(run: dict, dot_path: str = None, block_levels: list = None):
|
|
|
179
187
|
console.print()
|
|
180
188
|
|
|
181
189
|
if dot_path is None:
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
190
|
+
# If we have matched pointers, derive a path from the first concrete pointer
|
|
191
|
+
if matched:
|
|
192
|
+
concrete = matched[0]['pointer']
|
|
193
|
+
parts = concrete.replace('[', '.').replace(']', '').split('.')
|
|
194
|
+
dot_path = '.'.join(parts)
|
|
195
|
+
else:
|
|
196
|
+
try:
|
|
197
|
+
pretty = json.dumps(payload, indent=2)
|
|
198
|
+
except Exception:
|
|
199
|
+
pretty = str(payload)
|
|
200
|
+
console.print(Panel(pretty, title=f"Run {run['id']} · {source_file}"))
|
|
201
|
+
return
|
|
188
202
|
|
|
189
203
|
parts = [seg.replace("[]", "") for seg in dot_path.split(".")]
|
|
190
204
|
max_block = len(parts) - 1
|
|
@@ -207,9 +221,17 @@ def show_blocks(run: dict, dot_path: str = None, block_levels: list = None):
|
|
|
207
221
|
title = f"Run {run['id']} · {source_file} | metric: {dot_path} | depth: 0 (match {i+1}/{len(matched)})"
|
|
208
222
|
console.print(Panel(pretty, title=title))
|
|
209
223
|
else:
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
224
|
+
if block > 0 and matched:
|
|
225
|
+
concrete = matched[0]['pointer']
|
|
226
|
+
segments = concrete.replace('[', '.').replace(']', '').split('.')
|
|
227
|
+
ancestor_segments = segments[:len(segments) - (block + 1)]
|
|
228
|
+
pointer = "/" + "/".join(ancestor_segments) if ancestor_segments else ""
|
|
229
|
+
node = navigate_json_pointer(payload, pointer) if pointer else payload
|
|
230
|
+
else:
|
|
231
|
+
ancestor_parts = parts[:len(parts) - (block + 1)]
|
|
232
|
+
pointer = "/" + "/".join(ancestor_parts) if ancestor_parts else ""
|
|
233
|
+
node = navigate_json_pointer(payload, pointer) if pointer else payload
|
|
234
|
+
|
|
213
235
|
depth_label = ""
|
|
214
236
|
if block == max_block:
|
|
215
237
|
depth_label = " (full run)"
|
|
@@ -243,15 +265,14 @@ def run_guided_search():
|
|
|
243
265
|
"Answer a few questions and we'll find your best experiments.",
|
|
244
266
|
title="Welcome"))
|
|
245
267
|
|
|
246
|
-
#
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
batch = Prompt.ask("Batch name (optional, default: folder name)", default="")
|
|
268
|
+
# 1. Folder
|
|
269
|
+
folder = Prompt.ask("[bold]Folder containing your JSON/JSONL files[/bold]\n"
|
|
270
|
+
"(drag‑and‑drop or type a path)")
|
|
271
|
+
batch = Prompt.ask("[bold]Batch name (optional, default: folder name)[/bold]", default="")
|
|
251
272
|
if not batch:
|
|
252
273
|
batch = Path(folder).name
|
|
253
274
|
|
|
254
|
-
#
|
|
275
|
+
# 2. Scan if needed
|
|
255
276
|
console.print(f"\n[bold][Step 2/5] Ingest data from '{batch}'[/bold]")
|
|
256
277
|
metrics = fetch_verbose_metrics(batch)
|
|
257
278
|
if metrics:
|
|
@@ -267,6 +288,9 @@ def run_guided_search():
|
|
|
267
288
|
console.print("[red]Metric discovery failed. Exiting.[/red]")
|
|
268
289
|
return
|
|
269
290
|
|
|
291
|
+
# Build shorthand -> full paths map
|
|
292
|
+
shorthand_map = build_shorthand_map(batch)
|
|
293
|
+
|
|
270
294
|
# Show schema summary table
|
|
271
295
|
table = Table(title="Available Metrics")
|
|
272
296
|
table.add_column("#", justify="right")
|
|
@@ -276,14 +300,14 @@ def run_guided_search():
|
|
|
276
300
|
table.add_row(str(i), m["key"], m["path"])
|
|
277
301
|
console.print(table)
|
|
278
302
|
|
|
279
|
-
#
|
|
303
|
+
# 3. Build one or more conditions
|
|
280
304
|
console.print(f"\n[bold][Step 3/5] Define your search conditions[/bold]")
|
|
281
305
|
filters = []
|
|
282
306
|
combine = "and"
|
|
283
307
|
first_condition = True
|
|
284
308
|
|
|
285
309
|
while True:
|
|
286
|
-
# Metric selection (filter‑as‑you‑type)
|
|
310
|
+
# Metric selection (filter‑as‑you‑type with explicit path and sub‑list support)
|
|
287
311
|
metric_names = [m["key"] for m in metrics]
|
|
288
312
|
selected_metric = None
|
|
289
313
|
filtered = metric_names[:]
|
|
@@ -304,6 +328,32 @@ def run_guided_search():
|
|
|
304
328
|
console.print(ftable)
|
|
305
329
|
|
|
306
330
|
query = Prompt.ask("Type to filter metrics (or 'all' to reset, number to select)", default="")
|
|
331
|
+
|
|
332
|
+
# Explicit dot‑path / array‑path – accept directly
|
|
333
|
+
if '.' in query or '[]' in query:
|
|
334
|
+
selected_metric = query
|
|
335
|
+
break
|
|
336
|
+
|
|
337
|
+
# Shorthand with multiple possible full paths – show sub‑list
|
|
338
|
+
if query in shorthand_map:
|
|
339
|
+
paths = shorthand_map[query]
|
|
340
|
+
if len(paths) == 1:
|
|
341
|
+
selected_metric = paths[0]
|
|
342
|
+
break
|
|
343
|
+
else:
|
|
344
|
+
sub_table = Table(title=f"Select full path for '{query}'")
|
|
345
|
+
sub_table.add_column("#", justify="right")
|
|
346
|
+
sub_table.add_column("Full Path", style="cyan")
|
|
347
|
+
for i, p in enumerate(paths, 1):
|
|
348
|
+
sub_table.add_row(str(i), p)
|
|
349
|
+
console.print(sub_table)
|
|
350
|
+
sub_choice = Prompt.ask("Choose a path", default="1")
|
|
351
|
+
if sub_choice.isdigit():
|
|
352
|
+
idx = int(sub_choice) - 1
|
|
353
|
+
if 0 <= idx < len(paths):
|
|
354
|
+
selected_metric = paths[idx]
|
|
355
|
+
break
|
|
356
|
+
|
|
307
357
|
if query.lower() == "all":
|
|
308
358
|
filtered = metric_names[:]
|
|
309
359
|
continue
|
|
@@ -335,24 +385,24 @@ def run_guided_search():
|
|
|
335
385
|
conds = " [bold yellow]" + f" {combine.upper()} ".join(f"{f['metric']} {f['op']} {f['value']}" for f in filters) + "[/bold yellow]"
|
|
336
386
|
console.print(f"\n[bold]Current filter(s):[/bold]{conds}")
|
|
337
387
|
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
break
|
|
342
|
-
if len(filters) >= 2:
|
|
343
|
-
combine = Prompt.ask("Combine with", choices=["and", "or"], default="and")
|
|
344
|
-
else:
|
|
388
|
+
# Ask for another condition
|
|
389
|
+
another = Confirm.ask("Add another condition?", default=False)
|
|
390
|
+
if not another:
|
|
345
391
|
break
|
|
392
|
+
|
|
393
|
+
# If this is about to be the second condition, ask for combine operator BEFORE adding it
|
|
394
|
+
if len(filters) == 1:
|
|
395
|
+
combine = Prompt.ask("Combine with", choices=["and", "or"], default="and")
|
|
346
396
|
first_condition = False
|
|
347
397
|
|
|
348
|
-
#
|
|
398
|
+
# Block depth
|
|
349
399
|
console.print(f"\n[bold][Step 4/5] Choose output detail[/bold]")
|
|
350
400
|
block = None
|
|
351
401
|
if Confirm.ask("Limit output to a specific block depth? (block‑0 = tightest match)", default=False):
|
|
352
402
|
block = Prompt.ask("Block depth", default="0")
|
|
353
403
|
block = int(block)
|
|
354
404
|
|
|
355
|
-
#
|
|
405
|
+
# 5. Run search
|
|
356
406
|
console.print(f"\n[bold][Step 5/5] Searching...[/bold]")
|
|
357
407
|
if len(filters) == 1:
|
|
358
408
|
f = filters[0]
|
|
@@ -366,7 +416,7 @@ def run_guided_search():
|
|
|
366
416
|
for run in data["content"]:
|
|
367
417
|
show_blocks(run, filters[0]["metric"] if len(filters) == 1 else None, [block] if block is not None else None)
|
|
368
418
|
|
|
369
|
-
#
|
|
419
|
+
# Post‑search menu
|
|
370
420
|
while True:
|
|
371
421
|
console.print("\n[bold]What would you like to do next?[/bold]")
|
|
372
422
|
console.print(" [1] Save this search")
|
|
@@ -385,23 +435,20 @@ def run_guided_search():
|
|
|
385
435
|
if save_search(name, batch, params):
|
|
386
436
|
console.print(f"[green]Saved search '{name}'.[/green]")
|
|
387
437
|
elif choice == "2":
|
|
388
|
-
# Simple text export to a file
|
|
389
438
|
out_file = Prompt.ask("Output file path", default="runledger_export.txt")
|
|
390
439
|
if data and data.get("content"):
|
|
391
|
-
from
|
|
440
|
+
from ledger import build_export_blocks, format_blocks_as_text
|
|
392
441
|
blocks = build_export_blocks(data["content"], filters[0]["metric"] if len(filters) == 1 else None, [block] if block is not None else None, include_pointers=True)
|
|
393
442
|
Path(out_file).write_text(format_blocks_as_text(blocks), encoding="utf-8")
|
|
394
443
|
console.print(f"[green]Exported {len(blocks)} block(s) to {out_file}[/green]")
|
|
395
444
|
else:
|
|
396
445
|
console.print("[yellow]No data to export.[/yellow]")
|
|
397
446
|
elif choice == "3":
|
|
398
|
-
# Restart the guided flow
|
|
399
447
|
run_guided_search()
|
|
400
448
|
return
|
|
401
449
|
elif choice == "4":
|
|
402
450
|
id1 = Prompt.ask("First Run ID")
|
|
403
451
|
id2 = Prompt.ask("Second Run ID")
|
|
404
|
-
# Quick diff using the API
|
|
405
452
|
resp1 = requests.get(f"{API_BASE}/{id1}")
|
|
406
453
|
resp2 = requests.get(f"{API_BASE}/{id2}")
|
|
407
454
|
if resp1.status_code == 200 and resp2.status_code == 200:
|
|
@@ -47,9 +47,10 @@ except Exception:
|
|
|
47
47
|
# ------------------------------------------------------------
|
|
48
48
|
# Configuration
|
|
49
49
|
# ------------------------------------------------------------
|
|
50
|
-
|
|
50
|
+
_BASE = os.environ.get("RUNLEDGER_BASE_URL", "http://localhost:8080")
|
|
51
|
+
API_BASE = f"{_BASE}/api/runs"
|
|
52
|
+
HEALTH_URL = f"{_BASE}/actuator/health"
|
|
51
53
|
COMPOSE_FILE = os.path.join(os.path.dirname(__file__), "docker-compose.yml")
|
|
52
|
-
HEALTH_URL = "http://localhost:8080/actuator/health"
|
|
53
54
|
PAGE_SIZE = 10
|
|
54
55
|
|
|
55
56
|
console = Console()
|
|
@@ -333,11 +334,13 @@ def navigate_json_pointer(doc, pointer: str):
|
|
|
333
334
|
|
|
334
335
|
def show_blocks(run: dict, dot_path: str = None, block_levels: list = None):
|
|
335
336
|
payload = run.get("payload", {})
|
|
337
|
+
# Use experiment name, then source file, then run id
|
|
336
338
|
if isinstance(payload, dict):
|
|
337
339
|
source_file = payload.get("_source", {}).get("file") or payload.get("experiment") or f"run-{run['id']}"
|
|
338
340
|
else:
|
|
339
341
|
source_file = f"run-{run['id']}"
|
|
340
342
|
|
|
343
|
+
# Show matched pointers if available
|
|
341
344
|
matched = run.get("matched")
|
|
342
345
|
if matched:
|
|
343
346
|
console.print(" Matches:")
|
|
@@ -351,14 +354,21 @@ def show_blocks(run: dict, dot_path: str = None, block_levels: list = None):
|
|
|
351
354
|
console.print()
|
|
352
355
|
|
|
353
356
|
if dot_path is None:
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
357
|
+
# If we have matched pointers, derive a path from the first concrete pointer
|
|
358
|
+
if matched:
|
|
359
|
+
concrete = matched[0]['pointer']
|
|
360
|
+
parts = concrete.replace('[', '.').replace(']', '').split('.')
|
|
361
|
+
dot_path = '.'.join(parts)
|
|
362
|
+
else:
|
|
363
|
+
label = "-- full run (full‑text/fuzzy match)"
|
|
364
|
+
try:
|
|
365
|
+
pretty = json.dumps(payload, indent=2)
|
|
366
|
+
except Exception:
|
|
367
|
+
pretty = str(payload)
|
|
368
|
+
console.print(Panel(pretty, title=f"Run {run['id']} · {source_file}"))
|
|
369
|
+
return
|
|
361
370
|
|
|
371
|
+
# Strip "[]" so JSON Pointer navigation works
|
|
362
372
|
parts = [seg.replace("[]", "") for seg in dot_path.split(".")]
|
|
363
373
|
max_block = len(parts) - 1
|
|
364
374
|
|
|
@@ -372,6 +382,7 @@ def show_blocks(run: dict, dot_path: str = None, block_levels: list = None):
|
|
|
372
382
|
|
|
373
383
|
for block in sorted(levels, reverse=True):
|
|
374
384
|
if block == 0 and matched:
|
|
385
|
+
# For block=0, show each matched snippet individually
|
|
375
386
|
for i, m in enumerate(matched):
|
|
376
387
|
snippet = m.get("snippet")
|
|
377
388
|
try:
|
|
@@ -381,9 +392,19 @@ def show_blocks(run: dict, dot_path: str = None, block_levels: list = None):
|
|
|
381
392
|
title = f"Run {run['id']} · {source_file} | metric: {dot_path} | depth: 0 (match {i+1}/{len(matched)})"
|
|
382
393
|
console.print(Panel(pretty, title=title))
|
|
383
394
|
else:
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
395
|
+
if block > 0 and matched:
|
|
396
|
+
# Use the first match's concrete pointer for precise navigation
|
|
397
|
+
# Example: "clients[1].sweep[0].fin_asr" → "/clients/1/sweep"
|
|
398
|
+
concrete = matched[0]['pointer']
|
|
399
|
+
segments = concrete.replace('[', '.').replace(']', '').split('.')
|
|
400
|
+
ancestor_segments = segments[:len(segments) - (block + 1)]
|
|
401
|
+
pointer = "/" + "/".join(ancestor_segments) if ancestor_segments else ""
|
|
402
|
+
node = navigate_json_pointer(payload, pointer) if pointer else payload
|
|
403
|
+
else:
|
|
404
|
+
ancestor_parts = parts[:len(parts) - (block + 1)]
|
|
405
|
+
pointer = "/" + "/".join(ancestor_parts) if ancestor_parts else ""
|
|
406
|
+
node = navigate_json_pointer(payload, pointer) if pointer else payload
|
|
407
|
+
|
|
387
408
|
depth_label = ""
|
|
388
409
|
if block == max_block:
|
|
389
410
|
depth_label = " (full run)"
|
|
@@ -394,7 +415,6 @@ def show_blocks(run: dict, dot_path: str = None, block_levels: list = None):
|
|
|
394
415
|
except Exception:
|
|
395
416
|
pretty = str(node)
|
|
396
417
|
console.print(Panel(pretty, title=f"Run {run['id']} · {source_file} | metric: {dot_path} | depth: {block}{depth_label}"))
|
|
397
|
-
|
|
398
418
|
# ------------------------------------------------------------
|
|
399
419
|
# Export helpers
|
|
400
420
|
# ------------------------------------------------------------
|
|
@@ -444,9 +464,16 @@ def build_export_blocks(content, dot_path, block_levels, include_pointers=True):
|
|
|
444
464
|
"value": m.get("value")
|
|
445
465
|
})
|
|
446
466
|
else:
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
467
|
+
if block > 0 and matched:
|
|
468
|
+
concrete = matched[0]['pointer']
|
|
469
|
+
segments = concrete.replace('[', '.').replace(']', '').split('.')
|
|
470
|
+
ancestor_segments = segments[:len(segments) - (block + 1)]
|
|
471
|
+
pointer = "/" + "/".join(ancestor_segments) if ancestor_segments else ""
|
|
472
|
+
node = navigate_json_pointer(payload, pointer) if pointer else payload
|
|
473
|
+
else:
|
|
474
|
+
ancestor_parts = parts[:len(parts) - (block + 1)]
|
|
475
|
+
pointer = "/" + "/".join(ancestor_parts) if ancestor_parts else ""
|
|
476
|
+
node = navigate_json_pointer(payload, pointer) if pointer else payload
|
|
450
477
|
blocks.append({
|
|
451
478
|
"id": run["id"],
|
|
452
479
|
"sourceFile": source_file,
|
|
@@ -556,6 +583,14 @@ def compute_delta(val1, val2):
|
|
|
556
583
|
# ------------------------------------------------------------
|
|
557
584
|
# Non‑interactive CLI outputs
|
|
558
585
|
# ------------------------------------------------------------
|
|
586
|
+
def build_shorthand_map(batch):
|
|
587
|
+
"""Return dict {shorthand_key: [full_path, ...]} from verbose metrics."""
|
|
588
|
+
entries = fetch_verbose_metrics(batch)
|
|
589
|
+
mapping = {}
|
|
590
|
+
for e in entries:
|
|
591
|
+
mapping.setdefault(e['key'], []).append(e['path'])
|
|
592
|
+
return mapping
|
|
593
|
+
|
|
559
594
|
def cli_metrics(args):
|
|
560
595
|
if args.verbose:
|
|
561
596
|
entries = fetch_verbose_metrics(args.batch)
|
|
@@ -792,6 +827,9 @@ def interactive_search(batch: str = None):
|
|
|
792
827
|
console.print("[yellow]No metrics available yet. Ingest some runs first.[/yellow]")
|
|
793
828
|
break
|
|
794
829
|
|
|
830
|
+
# Build shorthand -> full paths map
|
|
831
|
+
shorthand_map = build_shorthand_map(batch)
|
|
832
|
+
|
|
795
833
|
selected_metric = None
|
|
796
834
|
filtered = metrics[:]
|
|
797
835
|
while True:
|
|
@@ -811,6 +849,32 @@ def interactive_search(batch: str = None):
|
|
|
811
849
|
console.print(table)
|
|
812
850
|
|
|
813
851
|
query = Prompt.ask("Type to filter metrics (or 'all' to reset, number to select)", default="")
|
|
852
|
+
|
|
853
|
+
# Explicit dot‑path / array‑path – accept directly
|
|
854
|
+
if '.' in query or '[]' in query:
|
|
855
|
+
selected_metric = query
|
|
856
|
+
break
|
|
857
|
+
|
|
858
|
+
# Shorthand with multiple possible full paths – show sub‑list
|
|
859
|
+
if query in shorthand_map:
|
|
860
|
+
paths = shorthand_map[query]
|
|
861
|
+
if len(paths) == 1:
|
|
862
|
+
selected_metric = paths[0]
|
|
863
|
+
break
|
|
864
|
+
else:
|
|
865
|
+
sub_table = Table(title=f"Select full path for '{query}'")
|
|
866
|
+
sub_table.add_column("#", justify="right")
|
|
867
|
+
sub_table.add_column("Full Path", style="cyan")
|
|
868
|
+
for i, p in enumerate(paths, 1):
|
|
869
|
+
sub_table.add_row(str(i), p)
|
|
870
|
+
console.print(sub_table)
|
|
871
|
+
sub_choice = Prompt.ask("Choose a path", default="1")
|
|
872
|
+
if sub_choice.isdigit():
|
|
873
|
+
idx = int(sub_choice) - 1
|
|
874
|
+
if 0 <= idx < len(paths):
|
|
875
|
+
selected_metric = paths[idx]
|
|
876
|
+
break
|
|
877
|
+
|
|
814
878
|
if query.lower() == "all":
|
|
815
879
|
filtered = metrics[:]
|
|
816
880
|
continue
|
|
@@ -57,7 +57,7 @@ def setup_data():
|
|
|
57
57
|
# Ingest sample runs into the fresh batch
|
|
58
58
|
result = run_cli("scan", str(SAMPLE_FOLDER), "--batch", BATCH_NAME)
|
|
59
59
|
assert result.returncode == 0, result.stderr
|
|
60
|
-
assert "Successfully ingested
|
|
60
|
+
assert "Successfully ingested 10 run(s)" in result.stdout
|
|
61
61
|
|
|
62
62
|
# ------------------------------------------------------------
|
|
63
63
|
# v1 – Basic metric block search
|
|
@@ -251,4 +251,73 @@ def test_jsonl_ingestion(setup_data):
|
|
|
251
251
|
assert payload.get(k) == v, f"Mismatch at line {idx}: {k} {v} != {payload.get(k)}"
|
|
252
252
|
|
|
253
253
|
# Clean up the temporary batch
|
|
254
|
-
requests.delete(f"{API_BASE}/batch/{batch}") # if this endpoint exists, otherwise manual cleanup
|
|
254
|
+
requests.delete(f"{API_BASE}/batch/{batch}") # if this endpoint exists, otherwise manual cleanup
|
|
255
|
+
|
|
256
|
+
def test_nested_array_block_depth():
|
|
257
|
+
"""Verify block‑depth on nested arrays using direct API calls."""
|
|
258
|
+
import uuid
|
|
259
|
+
import requests
|
|
260
|
+
|
|
261
|
+
batch = f"nest-{uuid.uuid4().hex[:6]}"
|
|
262
|
+
|
|
263
|
+
# 1. Ingest the nested file directly via the API
|
|
264
|
+
payload = {
|
|
265
|
+
"experiment": "nested_arrays_block_test",
|
|
266
|
+
"clients": [
|
|
267
|
+
{
|
|
268
|
+
"client_name": "clean_client",
|
|
269
|
+
"sweep": [
|
|
270
|
+
{"w_base": 0.5, "fin_asr": 0.12, "sst2_asr": 0.07},
|
|
271
|
+
{"w_base": 0.75, "fin_asr": 0.09, "sst2_asr": 0.06}
|
|
272
|
+
]
|
|
273
|
+
},
|
|
274
|
+
{
|
|
275
|
+
"client_name": "poisoned_client",
|
|
276
|
+
"sweep": [
|
|
277
|
+
{"w_base": 0.5, "fin_asr": 0.45, "sst2_asr": 0.88},
|
|
278
|
+
{"w_base": 1.0, "fin_asr": 0.28, "sst2_asr": 0.72}
|
|
279
|
+
]
|
|
280
|
+
}
|
|
281
|
+
]
|
|
282
|
+
}
|
|
283
|
+
resp = requests.post(API_BASE, json={"payload": payload, "batch": batch})
|
|
284
|
+
assert resp.status_code == 201, f"ingest failed: {resp.text}"
|
|
285
|
+
|
|
286
|
+
# 2. Search for fin_asr < 0.3 with block=0
|
|
287
|
+
params = {"metric": "clients[].sweep[].fin_asr", "op": "lt", "value": "0.3",
|
|
288
|
+
"batch": batch, "block": "0", "size": "1", "pointers": "true"}
|
|
289
|
+
resp = requests.get(API_BASE, params=params)
|
|
290
|
+
assert resp.status_code == 200, resp.text
|
|
291
|
+
data = resp.json()
|
|
292
|
+
assert data["totalElements"] == 1
|
|
293
|
+
run = data["content"][0]
|
|
294
|
+
|
|
295
|
+
# 3. Verify matched pointers exist
|
|
296
|
+
matched = run.get("matched")
|
|
297
|
+
assert matched is not None, "matched pointers missing"
|
|
298
|
+
assert len(matched) == 3, f"expected 3 matches, got {len(matched)}"
|
|
299
|
+
|
|
300
|
+
# 4. block=1 should return the sweep array (a list)
|
|
301
|
+
params["block"] = "1"
|
|
302
|
+
resp = requests.get(API_BASE, params=params)
|
|
303
|
+
assert resp.status_code == 200, resp.text
|
|
304
|
+
data1 = resp.json()
|
|
305
|
+
block1_payload = data1["content"][0]["payload"]
|
|
306
|
+
assert isinstance(block1_payload, list), f"block=1 payload should be a list, got {type(block1_payload)}"
|
|
307
|
+
assert any("fin_asr" in json.dumps(elem) for elem in block1_payload)
|
|
308
|
+
|
|
309
|
+
# 5. block=2 should return the client object
|
|
310
|
+
params["block"] = "2"
|
|
311
|
+
resp = requests.get(API_BASE, params=params)
|
|
312
|
+
assert resp.status_code == 200, resp.text
|
|
313
|
+
data2 = resp.json()
|
|
314
|
+
block2_payload = data2["content"][0]["payload"]
|
|
315
|
+
assert isinstance(block2_payload, dict) and block2_payload.get("client_name") == "clean_client"
|
|
316
|
+
|
|
317
|
+
# 6. block=3 should return the clients array
|
|
318
|
+
params["block"] = "3"
|
|
319
|
+
resp = requests.get(API_BASE, params=params)
|
|
320
|
+
assert resp.status_code == 200, resp.text
|
|
321
|
+
data3 = resp.json()
|
|
322
|
+
block3_payload = data3["content"][0]["payload"]
|
|
323
|
+
assert isinstance(block3_payload, list) and len(block3_payload) > 0
|