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.
Files changed (22) hide show
  1. runledger_cli-0.1.4/PKG-INFO +90 -0
  2. runledger_cli-0.1.4/README.md +81 -0
  3. {runledger_cli-0.1.2 → runledger_cli-0.1.4}/cli/guided.py +78 -31
  4. {runledger_cli-0.1.2 → runledger_cli-0.1.4}/cli/ledger.py +80 -16
  5. {runledger_cli-0.1.2 → runledger_cli-0.1.4}/cli/tests/test_data_features.py +71 -2
  6. {runledger_cli-0.1.2 → runledger_cli-0.1.4}/cli/tests/test_integration.py +80 -41
  7. {runledger_cli-0.1.2 → runledger_cli-0.1.4}/pyproject.toml +1 -1
  8. runledger_cli-0.1.4/runledger_cli.egg-info/PKG-INFO +90 -0
  9. runledger_cli-0.1.2/PKG-INFO +0 -198
  10. runledger_cli-0.1.2/README.md +0 -189
  11. runledger_cli-0.1.2/runledger_cli.egg-info/PKG-INFO +0 -198
  12. {runledger_cli-0.1.2 → runledger_cli-0.1.4}/cli/__init__.py +0 -0
  13. {runledger_cli-0.1.2 → runledger_cli-0.1.4}/cli/docker-compose.yml +0 -0
  14. {runledger_cli-0.1.2 → runledger_cli-0.1.4}/cli/tests/__init__.py +0 -0
  15. {runledger_cli-0.1.2 → runledger_cli-0.1.4}/cli/tests/test_ledger.py +0 -0
  16. {runledger_cli-0.1.2 → runledger_cli-0.1.4}/cli/tests/test_real_results.py +0 -0
  17. {runledger_cli-0.1.2 → runledger_cli-0.1.4}/runledger_cli.egg-info/SOURCES.txt +0 -0
  18. {runledger_cli-0.1.2 → runledger_cli-0.1.4}/runledger_cli.egg-info/dependency_links.txt +0 -0
  19. {runledger_cli-0.1.2 → runledger_cli-0.1.4}/runledger_cli.egg-info/entry_points.txt +0 -0
  20. {runledger_cli-0.1.2 → runledger_cli-0.1.4}/runledger_cli.egg-info/requires.txt +0 -0
  21. {runledger_cli-0.1.2 → runledger_cli-0.1.4}/runledger_cli.egg-info/top_level.txt +0 -0
  22. {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
- try:
183
- pretty = json.dumps(payload, indent=2)
184
- except Exception:
185
- pretty = str(payload)
186
- console.print(Panel(pretty, title=f"Run {run['id']} · {source_file}"))
187
- return
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
- ancestor_parts = parts[:len(parts) - (block + 1)]
211
- pointer = "/" + "/".join(ancestor_parts) if ancestor_parts else ""
212
- node = navigate_json_pointer(payload, pointer) if pointer else payload
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
- # ── Step 1: Folder & Batch ──
247
- console.print("\n[bold][Step 1/5] Select your experiment folder[/bold]")
248
- folder = Prompt.ask("Folder path (drag‑and‑drop or type)",
249
- default="")
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
- # ── Step 2: Scan if needed ──
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
- # ── Step 3: Build conditions ──
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
- if first_condition and len(metric_names) > 1:
339
- another = Confirm.ask("Add another condition?", default=False)
340
- if not another:
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
- # ── Step 4: Block depth ──
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
- # ── Step 5: Search & Results ──
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
- # ── Post‑search menu ──
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 ledgger import build_export_blocks, format_blocks_as_text
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
- API_BASE = "http://localhost:8080/api/runs"
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
- label = "-- full run (full‑text/fuzzy match)"
355
- try:
356
- pretty = json.dumps(payload, indent=2)
357
- except Exception:
358
- pretty = str(payload)
359
- console.print(Panel(pretty, title=f"Run {run['id']} · {source_file}"))
360
- return
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
- ancestor_parts = parts[:len(parts) - (block + 1)]
385
- pointer = "/" + "/".join(ancestor_parts) if ancestor_parts else ""
386
- node = navigate_json_pointer(payload, pointer) if pointer else payload
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
- ancestor_parts = parts[:len(parts) - (block + 1)]
448
- pointer = "/" + "/".join(ancestor_parts) if ancestor_parts else ""
449
- node = navigate_json_pointer(payload, pointer) if pointer else payload
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 9 run(s)" in result.stdout
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