deriva-dev 0.8.3__tar.gz → 0.8.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.
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: deriva-dev
3
- Version: 0.8.3
3
+ Version: 0.8.4
4
4
  Summary: Deriva with benchmarking: the studio, the CLI and multi-model consistency benchmarks.
5
5
  Author: S. T. Grond
6
6
  Author-email: S. T. Grond <steven@deriva.dev>
7
7
  License-Expression: AGPL-3.0-or-later
8
8
  License-File: LICENSE
9
- Requires-Dist: deriva-studio==0.8.3
9
+ Requires-Dist: deriva-studio==0.8.4
10
10
  Requires-Python: >=3.14
11
11
  Project-URL: Homepage, https://deriva.dev
12
12
  Project-URL: Documentation, https://docs.deriva.dev
@@ -1,12 +1,12 @@
1
1
  [project]
2
2
  name = "deriva-dev"
3
- version = "0.8.3"
3
+ version = "0.8.4"
4
4
  description = "Deriva with benchmarking: the studio, the CLI and multi-model consistency benchmarks."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.14"
7
7
  license = "AGPL-3.0-or-later"
8
8
  license-files = ["LICENSE"]
9
- dependencies = ["deriva-studio==0.8.3"]
9
+ dependencies = ["deriva-studio==0.8.4"]
10
10
 
11
11
  [[project.authors]]
12
12
  name = "S. T. Grond"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "deriva-dev"
3
- version = "0.8.3"
3
+ version = "0.8.4"
4
4
  description = "Deriva with benchmarking: the studio, the CLI and multi-model consistency benchmarks."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.14"
@@ -8,7 +8,7 @@ license = "AGPL-3.0-or-later"
8
8
  # A copy of the repository LICENSE (a package cannot point outside its folder); tests/test_packaging.py keeps it identical
9
9
  license-files = ["LICENSE"]
10
10
  authors = [{ name = "S. T. Grond", email = "steven@deriva.dev" }]
11
- dependencies = ["deriva-studio==0.8.3"]
11
+ dependencies = ["deriva-studio==0.8.4"]
12
12
 
13
13
  [project.urls]
14
14
  Homepage = "https://deriva.dev"
@@ -1347,7 +1347,7 @@ class BenchmarkOrchestrator:
1347
1347
  return response
1348
1348
 
1349
1349
  # Attach cache keys list to function for retrieval after run
1350
- query_fn.used_cache_keys = used_cache_keys # type: ignore[attr-defined]
1350
+ query_fn.used_cache_keys = used_cache_keys # type: ignore[attr-defined] # ty: ignore[unresolved-attribute]
1351
1351
  return query_fn
1352
1352
 
1353
1353
  def _record_llm_call(self, llm: Any, step: str | None, prompt: str, schema: dict | None, system_prompt: str | None, response: Any, error: str | None) -> None:
@@ -1884,27 +1884,66 @@ def export_process_logs(benchmarks_dir: str | Path, session_ids: list[str], out_
1884
1884
  return paths
1885
1885
 
1886
1886
 
1887
- def list_benchmark_sessions(engine: Any, limit: int = 10) -> list[dict[str, Any]]:
1887
+ def list_benchmark_sessions(
1888
+ engine: Any,
1889
+ limit: int = 10,
1890
+ offset: int = 0,
1891
+ search: str | None = None,
1892
+ repository: str | None = None,
1893
+ model: str | None = None,
1894
+ status: str | None = None,
1895
+ ) -> list[dict[str, Any]]:
1888
1896
  """
1889
- List recent benchmark sessions.
1897
+ List benchmark sessions, newest first, with the repositories, models and runs they hold.
1890
1898
 
1891
1899
  Args:
1892
1900
  engine: DuckDB connection
1893
1901
  limit: Maximum number of sessions to return
1902
+ offset: Sessions to skip (a page at a time)
1903
+ search: Text in the session id or description (any case)
1904
+ repository: Only sessions with a run of this repository
1905
+ model: Only sessions with a run of this model config
1906
+ status: Only sessions with this status (completed, failed, running)
1894
1907
 
1895
1908
  Returns:
1896
1909
  List of session dictionaries
1897
1910
  """
1911
+ where: list[str] = []
1912
+ params: list[Any] = []
1913
+ if search:
1914
+ where.append("(s.session_id ILIKE ? OR coalesce(s.description, '') ILIKE ?)")
1915
+ params += [f"%{search}%", f"%{search}%"]
1916
+ if repository:
1917
+ where.append("EXISTS (SELECT 1 FROM benchmark_runs r WHERE r.session_id = s.session_id AND r.repository = ?)")
1918
+ params.append(repository)
1919
+ if model:
1920
+ where.append("EXISTS (SELECT 1 FROM benchmark_runs r WHERE r.session_id = s.session_id AND r.model_name = ?)")
1921
+ params.append(model)
1922
+ if status:
1923
+ where.append("s.status = ?")
1924
+ params.append(status)
1925
+ clause = f"WHERE {' AND '.join(where)}" if where else ""
1898
1926
  rows = engine.execute(
1899
- """
1900
- SELECT session_id, description, status, started_at, completed_at
1901
- FROM benchmark_sessions
1902
- ORDER BY started_at DESC
1903
- LIMIT ?
1927
+ f"""
1928
+ SELECT s.session_id, s.description, s.status, s.started_at, s.completed_at, s.config,
1929
+ count(r.run_id), list(DISTINCT r.repository), list(DISTINCT r.model_name)
1930
+ FROM benchmark_sessions s
1931
+ LEFT JOIN benchmark_runs r ON r.session_id = s.session_id
1932
+ {clause}
1933
+ GROUP BY s.session_id, s.description, s.status, s.started_at, s.completed_at, s.config
1934
+ ORDER BY s.started_at DESC, s.session_id DESC
1935
+ LIMIT ? OFFSET ?
1904
1936
  """,
1905
- [limit],
1937
+ [*params, limit, offset],
1906
1938
  ).fetchall()
1907
1939
 
1940
+ def stages(config: Any) -> list[str]:
1941
+ try:
1942
+ found = json.loads(config) if isinstance(config, str) else (config or {})
1943
+ except ValueError:
1944
+ return []
1945
+ return [str(stage) for stage in found.get("stages") or []] if isinstance(found, dict) else []
1946
+
1908
1947
  return [
1909
1948
  {
1910
1949
  "session_id": row[0],
@@ -1912,11 +1951,59 @@ def list_benchmark_sessions(engine: Any, limit: int = 10) -> list[dict[str, Any]
1912
1951
  "status": row[2],
1913
1952
  "started_at": row[3],
1914
1953
  "completed_at": row[4],
1954
+ "stages": stages(row[5]),
1955
+ "runs": int(row[6]),
1956
+ "repositories": sorted(x for x in row[7] or [] if x is not None),
1957
+ "models": sorted(x for x in row[8] or [] if x is not None),
1915
1958
  }
1916
1959
  for row in rows
1917
1960
  ]
1918
1961
 
1919
1962
 
1963
+ def folder_bytes(folder: Path) -> int:
1964
+ """Bytes the files under a folder take (0 when it does not exist)."""
1965
+ return sum(f.stat().st_size for f in folder.rglob("*") if f.is_file()) if folder.is_dir() else 0
1966
+
1967
+
1968
+ def delete_benchmark_session(engine: Any, session_id: str, benchmarks_dir: str | Path) -> dict[str, Any]:
1969
+ """Delete a session: its rows (session, runs, metrics) and its folder (event logs, snapshots, inputs).
1970
+
1971
+ Raises:
1972
+ KeyError: no such session
1973
+ ValueError: the session is still running (mark it failed first when its process stopped)
1974
+ """
1975
+ info = get_benchmark_session(engine, session_id)
1976
+ if info is None:
1977
+ raise KeyError(session_id)
1978
+ if info["status"] == "running":
1979
+ raise ValueError(f"Session {session_id} is running; mark it failed first when its process has stopped")
1980
+ (runs,) = engine.execute("SELECT count(*) FROM benchmark_runs WHERE session_id = ?", [session_id]).fetchone()
1981
+ for table in ("benchmark_metrics", "benchmark_runs", "benchmark_sessions"):
1982
+ engine.execute(f"DELETE FROM {table} WHERE session_id = ?", [session_id])
1983
+ folder = Path(benchmarks_dir) / session_id
1984
+ size = folder_bytes(folder)
1985
+ if folder.is_dir():
1986
+ shutil.rmtree(folder)
1987
+ return {"session_id": session_id, "runs": int(runs), "bytes": size}
1988
+
1989
+
1990
+ def prune_benchmark_sessions(engine: Any, benchmarks_dir: str | Path, keep: int | None = None, failed: bool = False) -> list[str]:
1991
+ """Delete the failed sessions, or every session past the latest ``keep``; running sessions always stay.
1992
+
1993
+ Returns the deleted session ids, newest first.
1994
+ """
1995
+ rows = engine.execute("SELECT session_id, status FROM benchmark_sessions ORDER BY started_at DESC, session_id DESC").fetchall()
1996
+ if failed:
1997
+ victims = [sid for sid, status in rows if status == "failed"]
1998
+ elif keep is not None:
1999
+ victims = [sid for sid, status in rows[max(keep, 0) :] if status != "running"]
2000
+ else:
2001
+ return []
2002
+ for session_id in victims:
2003
+ delete_benchmark_session(engine, session_id, benchmarks_dir)
2004
+ return victims
2005
+
2006
+
1920
2007
  def get_benchmark_session(engine: Any, session_id: str) -> dict[str, Any] | None:
1921
2008
  """
1922
2009
  Get details for a specific benchmark session.
@@ -7,7 +7,7 @@ Provides commands for multi-model benchmarking.
7
7
  from __future__ import annotations
8
8
 
9
9
  import json
10
- from collections.abc import Iterator
10
+ from collections.abc import Generator
11
11
  from contextlib import contextmanager
12
12
  from pathlib import Path
13
13
  from typing import TYPE_CHECKING, Annotated
@@ -26,7 +26,7 @@ app = typer.Typer(name="benchmark", help="Multi-model benchmarking")
26
26
 
27
27
 
28
28
  @contextmanager
29
- def _benchmarks() -> Iterator[BenchmarkSession]:
29
+ def _benchmarks() -> Generator[BenchmarkSession]:
30
30
  """A connected PipelineSession with the benchmark operations."""
31
31
  with PipelineSession() as session:
32
32
  yield BenchmarkSession(session)
@@ -328,6 +328,54 @@ def benchmark_list(
328
328
  typer.echo("")
329
329
 
330
330
 
331
+ def _megabytes(size: int) -> str:
332
+ return f"{size / 1024 / 1024:.1f} MB"
333
+
334
+
335
+ @app.command("delete")
336
+ def benchmark_delete(
337
+ session_ids: Annotated[list[str], typer.Argument(help="Benchmark sessions to delete")],
338
+ ) -> None:
339
+ """Delete benchmark sessions with their runs, metrics and folders (a running session is refused)."""
340
+ failed = False
341
+ with _benchmarks() as session:
342
+ for session_id in session_ids:
343
+ try:
344
+ result = session.delete_benchmark(session_id)
345
+ except KeyError:
346
+ typer.echo(f"Error: Benchmark session not found: {session_id}", err=True)
347
+ failed = True
348
+ continue
349
+ except ValueError as e:
350
+ typer.echo(f"Error: {e}", err=True)
351
+ failed = True
352
+ continue
353
+ typer.echo(f"Deleted {session_id} ({result['runs']} runs, {_megabytes(result['bytes'])})")
354
+ if failed:
355
+ raise typer.Exit(1)
356
+
357
+
358
+ @app.command("prune")
359
+ def benchmark_prune(
360
+ keep: Annotated[int | None, typer.Option("--keep", min=0, help="Keep the latest N sessions, delete the older ones")] = None,
361
+ failed: Annotated[bool, typer.Option("--failed", help="Delete the failed sessions")] = False,
362
+ yes: Annotated[bool, typer.Option("-y", "--yes", help="Do not ask first")] = False,
363
+ ) -> None:
364
+ """Delete the failed sessions, or every session past the latest N, with their runs, metrics and folders; running sessions stay."""
365
+ if keep is None and not failed:
366
+ typer.echo("Error: say which sessions to delete: --keep N or --failed", err=True)
367
+ raise typer.Exit(1)
368
+ which = "the failed sessions" if failed else f"every session past the latest {keep}"
369
+ if not yes and not typer.confirm(f"Delete {which}, with their runs, metrics and folders?"):
370
+ raise typer.Exit(1)
371
+ with _benchmarks() as session:
372
+ deleted = session.prune_benchmarks(keep=None if failed else keep, failed=failed)
373
+ if not deleted:
374
+ typer.echo("No sessions to delete.")
375
+ return
376
+ typer.echo(f"Deleted {len(deleted)} session{'' if len(deleted) == 1 else 's'}: {', '.join(deleted)}")
377
+
378
+
331
379
  @app.command("conventions")
332
380
  def benchmark_conventions(
333
381
  session_ids: Annotated[list[str], typer.Argument(help="Benchmark sessions whose run snapshots are checked")],
@@ -198,7 +198,7 @@ class ConfigDeviationAnalyzer:
198
198
  return {"config_id": config_id, "error": "No objects found for config"}
199
199
 
200
200
  # Build per-object breakdown
201
- object_details = []
201
+ object_details: list[dict[str, Any]] = []
202
202
  objects_by_run = {run_id: nodes_by_run.get(run_id, set()) | elements_by_run.get(run_id, set()) for run_id in all_runs}
203
203
 
204
204
  for obj_id in config_objects:
@@ -53,8 +53,41 @@ def _view(read: Any) -> Any:
53
53
 
54
54
 
55
55
  @router.get("")
56
- def list_sessions(limit: int = Query(20, ge=1, le=500), session: Any = Depends(get_benchmarks)) -> list[dict[str, Any]]:
57
- return session.list_benchmarks(limit)
56
+ def list_sessions(
57
+ limit: int = Query(20, ge=1, le=500),
58
+ offset: int = Query(0, ge=0),
59
+ search: str | None = None,
60
+ repository: str | None = None,
61
+ model: str | None = None,
62
+ status: str | None = None,
63
+ session: Any = Depends(get_benchmarks),
64
+ ) -> list[dict[str, Any]]:
65
+ """Sessions newest first, a page at a time: by text (id or description), repository, model and status."""
66
+ return session.list_benchmarks(limit, offset=offset, search=search, repository=repository, model=model, status=status)
67
+
68
+
69
+ def _when_idle(request: Request, action: Any) -> Any:
70
+ """Session changes wait for a run to end: a benchmark in progress writes its session."""
71
+ try:
72
+ return request.app.state.runs.while_idle(action)
73
+ except RunBusy as exc:
74
+ raise HTTPException(status_code=409, detail="A run is in progress; try again when it has finished") from exc
75
+
76
+
77
+ @router.post("/{session_id}/mark-failed")
78
+ def mark_session_failed(session_id: str, request: Request, session: Any = Depends(get_benchmarks)) -> dict[str, Any]:
79
+ """End a session whose process stopped without finishing (still running) as failed; its files stay."""
80
+ runs = _view(lambda: _when_idle(request, lambda: session.mark_benchmark_failed(session_id)))
81
+ return {"session_id": session_id, "runs_marked": runs}
82
+
83
+
84
+ @router.delete("/{session_id}")
85
+ def delete_session(session_id: str, request: Request, session: Any = Depends(get_benchmarks)) -> dict[str, Any]:
86
+ """Delete a finished or failed session with its runs, metrics and folder."""
87
+ try:
88
+ return _view(lambda: _when_idle(request, lambda: session.delete_benchmark(session_id)))
89
+ except KeyError as exc:
90
+ raise HTTPException(status_code=404, detail=f"Benchmark session not found: {session_id}") from exc
58
91
 
59
92
 
60
93
  @router.get("/models")
@@ -297,16 +297,39 @@ class BenchmarkSession:
297
297
  order = _pipeline_order(engine)
298
298
  return {repo: [s.to_dict() for s in sorted(steps, key=lambda s: order(s.step))] for repo, steps in stability.items()}
299
299
 
300
- def list_benchmarks(self, limit: int = 10) -> list[dict]:
300
+ def list_benchmarks(
301
+ self,
302
+ limit: int = 10,
303
+ offset: int = 0,
304
+ search: str | None = None,
305
+ repository: str | None = None,
306
+ model: str | None = None,
307
+ status: str | None = None,
308
+ benchmarks_dir: str | Path | None = None,
309
+ ) -> list[dict]:
301
310
  """
302
- List recent benchmark sessions.
311
+ List benchmark sessions, newest first, each with the bytes its folder takes.
303
312
 
304
313
  Args:
305
314
  limit: Maximum number of sessions to return
315
+ offset: Sessions to skip (a page at a time)
316
+ search, repository, model, status: filters (see ``benchmarking.list_benchmark_sessions``)
306
317
 
307
318
  Returns:
308
319
  List of session dictionaries
309
320
  """
310
321
  engine, _, _ = self.session.managers()
322
+ rows = benchmarking.list_benchmark_sessions(engine, limit, offset=offset, search=search, repository=repository, model=model, status=status)
323
+ folder = _folder(benchmarks_dir)
324
+ return [{**row, "bytes": benchmarking.folder_bytes(folder / str(row["session_id"]))} for row in rows]
311
325
 
312
- return benchmarking.list_benchmark_sessions(engine, limit)
326
+ def delete_benchmark(self, session_id: str, benchmarks_dir: str | Path | None = None) -> dict[str, Any]:
327
+ """Delete a finished or failed session with its runs, metrics and folder."""
328
+ _check_session_id(session_id)
329
+ engine, _, _ = self.session.managers()
330
+ return benchmarking.delete_benchmark_session(engine, session_id, _folder(benchmarks_dir))
331
+
332
+ def prune_benchmarks(self, keep: int | None = None, failed: bool = False, benchmarks_dir: str | Path | None = None) -> list[str]:
333
+ """Delete the failed sessions, or every session past the latest ``keep``; running sessions stay."""
334
+ engine, _, _ = self.session.managers()
335
+ return benchmarking.prune_benchmark_sessions(engine, _folder(benchmarks_dir), keep=keep, failed=failed)
@@ -18,7 +18,7 @@ from __future__ import annotations
18
18
  import ast
19
19
  import itertools
20
20
  import json
21
- from collections import Counter
21
+ from collections import defaultdict
22
22
  from pathlib import Path
23
23
  from typing import Any
24
24
 
@@ -128,10 +128,11 @@ def _identity_report(views: list[dict[str, Any]], identity: str) -> dict[str, An
128
128
  return [{x for x in s if keep(x)} for s in sets]
129
129
 
130
130
  all_elements = set().union(*elements) if elements else set()
131
+ present = sorted({str(layer) for x in all_elements if (layer := layers.get(x)) is not None})
131
132
  report = {
132
133
  "elements": {"cons": _share(elements), "jbar": _jbar(elements), "count": len(all_elements)},
133
134
  "relationships": {"cons": _share(rels), "jbar": _jbar(rels), "count": len(set().union(*rels)) if rels else 0},
134
- "layers": {layer: _share(part(elements, lambda x, layer=layer: layers.get(x) == layer)) for layer in sorted({layers.get(x) for x in all_elements} - {None})},
135
+ "layers": {layer: _share(part(elements, lambda x, layer=layer: layers.get(x) == layer)) for layer in present},
135
136
  "origin": {},
136
137
  }
137
138
  for name, wanted in (("llm", True), ("structural", False)):
@@ -174,13 +175,13 @@ def krippendorff_alpha(runs: list[dict[str, Any]]) -> float | None:
174
175
  for run in runs:
175
176
  for item, value in run.items():
176
177
  values.setdefault(item, []).append(repr(value))
177
- coincidence: Counter = Counter()
178
+ coincidence: dict[tuple[str, str], float] = defaultdict(float)
178
179
  for found in values.values():
179
180
  if len(found) < 2:
180
181
  continue
181
182
  for a, b in itertools.permutations(range(len(found)), 2):
182
183
  coincidence[(found[a], found[b])] += 1 / (len(found) - 1)
183
- totals: Counter = Counter()
184
+ totals: dict[str, float] = defaultdict(float)
184
185
  for (a, _), n in coincidence.items():
185
186
  totals[a] += n
186
187
  n = sum(totals.values())
File without changes
File without changes