deriva-dev 0.8.2__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.
Files changed (21) hide show
  1. {deriva_dev-0.8.2 → deriva_dev-0.8.4}/PKG-INFO +2 -2
  2. {deriva_dev-0.8.2 → deriva_dev-0.8.4}/pyproject.toml +2 -2
  3. {deriva_dev-0.8.2 → deriva_dev-0.8.4}/pyproject.toml.orig +2 -2
  4. {deriva_dev-0.8.2 → deriva_dev-0.8.4}/src/deriva/bench/benchmarking.py +141 -18
  5. {deriva_dev-0.8.2 → deriva_dev-0.8.4}/src/deriva/bench/cli.py +71 -13
  6. {deriva_dev-0.8.2 → deriva_dev-0.8.4}/src/deriva/bench/config_deviation.py +1 -1
  7. deriva_dev-0.8.4/src/deriva/bench/gate.py +305 -0
  8. {deriva_dev-0.8.2 → deriva_dev-0.8.4}/src/deriva/bench/reference_eval.py +60 -4
  9. {deriva_dev-0.8.2 → deriva_dev-0.8.4}/src/deriva/bench/router.py +38 -4
  10. {deriva_dev-0.8.2 → deriva_dev-0.8.4}/src/deriva/bench/session.py +26 -3
  11. {deriva_dev-0.8.2 → deriva_dev-0.8.4}/src/deriva/bench/session_metrics.py +79 -10
  12. deriva_dev-0.8.2/src/deriva/bench/gate.py +0 -216
  13. {deriva_dev-0.8.2 → deriva_dev-0.8.4}/LICENSE +0 -0
  14. {deriva_dev-0.8.2 → deriva_dev-0.8.4}/README.md +0 -0
  15. {deriva_dev-0.8.2 → deriva_dev-0.8.4}/src/deriva/bench/__init__.py +0 -0
  16. {deriva_dev-0.8.2 → deriva_dev-0.8.4}/src/deriva/bench/analysis.py +0 -0
  17. {deriva_dev-0.8.2 → deriva_dev-0.8.4}/src/deriva/bench/baseline_b1.py +0 -0
  18. {deriva_dev-0.8.2 → deriva_dev-0.8.4}/src/deriva/bench/benchmark_views.py +0 -0
  19. {deriva_dev-0.8.2 → deriva_dev-0.8.4}/src/deriva/bench/conventions_report.py +0 -0
  20. {deriva_dev-0.8.2 → deriva_dev-0.8.4}/src/deriva/bench/data/conventions.json +0 -0
  21. {deriva_dev-0.8.2 → deriva_dev-0.8.4}/src/deriva/bench/step_benchmark.py +0 -0
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: deriva-dev
3
- Version: 0.8.2
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.2
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.2"
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.2"]
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.2"
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.2"]
11
+ dependencies = ["deriva-studio==0.8.4"]
12
12
 
13
13
  [project.urls]
14
14
  Homepage = "https://deriva.dev"
@@ -297,13 +297,14 @@ class OCELStepContext:
297
297
  self._created_relationships.append(relationship_id)
298
298
 
299
299
  def _elapsed_seconds(self) -> float:
300
- return round(time.perf_counter() - self._start_time, 2)
300
+ """The step's duration to the microsecond: the case log derives the start from it, so steps must not overlap."""
301
+ return round(time.perf_counter() - self._start_time, 6)
301
302
 
302
303
  def complete(self, message: str = "") -> None:
303
304
  """Mark step as completed and log to OCEL."""
304
305
  self._completed = True
305
306
  if self.logger.verbose:
306
- print(f" {self.step}: {self._elapsed_seconds()}s, {self.items_created} created")
307
+ print(f" {self.step}: {self._elapsed_seconds():.2f}s, {self.items_created} created")
307
308
  config_type = "extraction" if self.logger._current_phase == "extraction" else "derivation"
308
309
  self.logger.log_config_result(
309
310
  config_type=config_type,
@@ -319,7 +320,7 @@ class OCELStepContext:
319
320
  """Mark step as errored."""
320
321
  self._completed = True
321
322
  if self.logger.verbose:
322
- print(f" {self.step}: {self._elapsed_seconds()}s, ERROR {error[:120]}")
323
+ print(f" {self.step}: {self._elapsed_seconds():.2f}s, ERROR {error[:120]}")
323
324
  config_type = "extraction" if self.logger._current_phase == "extraction" else "derivation"
324
325
  self.logger.log_config_result(
325
326
  config_type=config_type,
@@ -506,6 +507,8 @@ class BenchmarkOrchestrator:
506
507
  self._call_logs: dict[str, LlmCallLog] = {}
507
508
  # LLM answers per decision per step, recorded in the session metadata
508
509
  self._llm_samples: dict[str, int] = {}
510
+ # The code commit and dirty flag when the session started, recorded in the session metadata
511
+ self._code: dict[str, Any] = {"commit": None, "dirty": None}
509
512
  self._current_model: str | None = None
510
513
  self._current_repo: str | None = None
511
514
 
@@ -1344,7 +1347,7 @@ class BenchmarkOrchestrator:
1344
1347
  return response
1345
1348
 
1346
1349
  # Attach cache keys list to function for retrieval after run
1347
- 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]
1348
1351
  return query_fn
1349
1352
 
1350
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:
@@ -1457,6 +1460,7 @@ class BenchmarkOrchestrator:
1457
1460
  model_name=model_display_name,
1458
1461
  properties=leveled.properties,
1459
1462
  views=leveled.views,
1463
+ relationship_properties=leveled.relationship_properties,
1460
1464
  )
1461
1465
 
1462
1466
  # JSON snapshot for consistency analysis: the XML carries no properties,
@@ -1478,11 +1482,18 @@ class BenchmarkOrchestrator:
1478
1482
  "name": e.name,
1479
1483
  "source": (e.properties or {}).get("source"),
1480
1484
  "level": "architect",
1481
- # The name came from an LLM choice among several candidates (origin split)
1482
- "name_choice": bool((e.properties or {}).get("name_choice")) or len((e.properties or {}).get("name_candidates") or []) > 1,
1485
+ # The name came from an LLM choice among several candidates (origin split): as the naming
1486
+ # step recorded it, else (steps that record none) several candidates mean a choice
1487
+ "name_choice": bool((e.properties or {})["name_choice"])
1488
+ if "name_choice" in (e.properties or {})
1489
+ else len((e.properties or {}).get("name_candidates") or []) > 1,
1483
1490
  # The structural name (first candidate) next to the chosen one: what LLM naming adds
1484
1491
  # (no naming call: the name is the structural one)
1485
1492
  "structure_name": (e.properties or {}).get("structure_name") or next(iter((e.properties or {}).get("name_candidates") or []), None) or e.name,
1493
+ # A closed-set name's candidates in order (None for a name that is no choice)
1494
+ "name_candidates": list((e.properties or {}).get("name_candidates") or []) or None,
1495
+ # The abstraction levels the abstraction_levels step wrote (None: not tagged, enterprise)
1496
+ "levels": list((e.properties or {})["levels"]) if isinstance((e.properties or {}).get("levels"), list) else None,
1486
1497
  }
1487
1498
  for e in elements
1488
1499
  ],
@@ -1493,6 +1504,9 @@ class BenchmarkOrchestrator:
1493
1504
  "type": r.relationship_type,
1494
1505
  "derived_from": (r.properties or {}).get("derived_from"),
1495
1506
  "confidence": (r.properties or {}).get("confidence"),
1507
+ # Its levels (those both ends share); a cross-level link (no shared level) counts in the union only
1508
+ "levels": list((r.properties or {})["levels"]) if isinstance((r.properties or {}).get("levels"), list) else None,
1509
+ "cross_level": bool((r.properties or {}).get("cross_level")),
1496
1510
  }
1497
1511
  for r in relationships
1498
1512
  ],
@@ -1569,12 +1583,20 @@ class BenchmarkOrchestrator:
1569
1583
  self.session_start.isoformat(),
1570
1584
  ],
1571
1585
  )
1586
+ self._capture_code()
1572
1587
  try:
1573
1588
  self._write_session_inputs()
1574
1589
  except Exception as exc:
1575
1590
  # A record that cannot be written does not stop the session (as a failed model export does not fail its run)
1576
1591
  logger.warning("Could not record the inputs of session %s: %s", self.session_id, exc)
1577
1592
 
1593
+ def _capture_code(self) -> None:
1594
+ """The commit the session starts on, with a dirty flag (the code may change while the session runs)."""
1595
+ try:
1596
+ self._code = export_bundle.code_commit()
1597
+ except Exception as exc: # A commit that cannot be read does not stop the session
1598
+ logger.warning("Could not read the code commit of session %s: %s", self.session_id, exc)
1599
+
1578
1600
  def _write_session_inputs(self) -> None:
1579
1601
  """Everything that changes results, as it is when the session starts, into the session folder.
1580
1602
 
@@ -1703,6 +1725,8 @@ class BenchmarkOrchestrator:
1703
1725
  summary = {
1704
1726
  "session_id": self.session_id,
1705
1727
  "config": self.config.to_dict(),
1728
+ # The code the session ran: its commit and whether the working tree had changes at the start
1729
+ "code": self._code,
1706
1730
  "started_at": self.session_start.isoformat() if self.session_start else None,
1707
1731
  "completed_at": datetime.now().isoformat(),
1708
1732
  "total_events": len(self.ocel_log.events),
@@ -1860,27 +1884,66 @@ def export_process_logs(benchmarks_dir: str | Path, session_ids: list[str], out_
1860
1884
  return paths
1861
1885
 
1862
1886
 
1863
- 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]]:
1864
1896
  """
1865
- List recent benchmark sessions.
1897
+ List benchmark sessions, newest first, with the repositories, models and runs they hold.
1866
1898
 
1867
1899
  Args:
1868
1900
  engine: DuckDB connection
1869
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)
1870
1907
 
1871
1908
  Returns:
1872
1909
  List of session dictionaries
1873
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 ""
1874
1926
  rows = engine.execute(
1875
- """
1876
- SELECT session_id, description, status, started_at, completed_at
1877
- FROM benchmark_sessions
1878
- ORDER BY started_at DESC
1879
- 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 ?
1880
1936
  """,
1881
- [limit],
1937
+ [*params, limit, offset],
1882
1938
  ).fetchall()
1883
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
+
1884
1947
  return [
1885
1948
  {
1886
1949
  "session_id": row[0],
@@ -1888,11 +1951,59 @@ def list_benchmark_sessions(engine: Any, limit: int = 10) -> list[dict[str, Any]
1888
1951
  "status": row[2],
1889
1952
  "started_at": row[3],
1890
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),
1891
1958
  }
1892
1959
  for row in rows
1893
1960
  ]
1894
1961
 
1895
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
+
1896
2007
  def get_benchmark_session(engine: Any, session_id: str) -> dict[str, Any] | None:
1897
2008
  """
1898
2009
  Get details for a specific benchmark session.
@@ -2176,13 +2287,25 @@ def model_quality_for_session(
2176
2287
  row: dict[str, Any] = {"repository": repo, "model": model, "run": int(run), **compute_model_quality(elements, relationships).to_dict()}
2177
2288
  snapshot_path = path.with_suffix(".json")
2178
2289
  try:
2179
- row["solve"] = json.loads(snapshot_path.read_text(encoding="utf-8")).get("solve") if snapshot_path.is_file() else None
2290
+ snapshot = json.loads(snapshot_path.read_text(encoding="utf-8")) if snapshot_path.is_file() else {}
2180
2291
  except OSError, ValueError:
2181
- row["solve"] = None
2292
+ snapshot = {}
2293
+ row["solve"] = snapshot.get("solve")
2182
2294
  row["reference"] = None
2183
2295
  if reference:
2184
- # Lineage-free: name + type, the user's manual matches and derivable labels next to the reference
2185
- derived = [{"id": e.identifier, "name": e.name, "type": e.element_type} for e in elements]
2296
+ # Name + type, the user's accepted names and structural refs (sources and lineage from the run's
2297
+ # snapshot: the XML carries none) and derivable labels: the gate's matching (golden_mapping)
2298
+ structure = {str(e.get("identifier")): e for e in snapshot.get("elements", []) if isinstance(e, dict)}
2299
+ derived = [
2300
+ {
2301
+ "id": e.identifier,
2302
+ "name": e.name,
2303
+ "type": e.element_type,
2304
+ "source": structure.get(e.identifier, {}).get("source"),
2305
+ "lineage": structure.get(e.identifier, {}).get("lineage") or [],
2306
+ }
2307
+ for e in elements
2308
+ ]
2186
2309
  aliases, derivable = session_metrics.manual_inputs(reference_models[repo])
2187
2310
  row["reference"] = session_metrics.golden_comparison(derived, reference, aliases=aliases, derivable=derivable)
2188
2311
  rows.append(row)
@@ -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)
@@ -238,7 +238,7 @@ def benchmark_run(
238
238
  typer.echo(f" ... and {len(result.errors) - 5} more")
239
239
 
240
240
  typer.echo("\nTo analyze results:")
241
- typer.echo(f" deriva benchmark analyze {result.session_id}")
241
+ typer.echo(f" deriva-cli benchmark analyze {result.session_id}")
242
242
 
243
243
  if not result.success:
244
244
  raise typer.Exit(1)
@@ -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")],
@@ -383,10 +431,10 @@ def benchmark_gate(
383
431
  reference: Annotated[str | None, typer.Option("--reference", help="Golden model folder (default: workspace/reference)")] = None,
384
432
  benchmarks: Annotated[str | None, typer.Option("--benchmarks", help="Benchmark folder (default: workspace/benchmarks)")] = None,
385
433
  ) -> None:
386
- """Recall, precision and consistency by id per repository, and the verdict of a candidate against a baseline.
434
+ """Element and relationship recall and precision and consistency by id per repository, and the verdict of a candidate.
387
435
 
388
- Accepted when recall or precision rises on at least two thirds of the measured repositories, none drops by more
389
- than 2 points on either, and element and relationship consistency by id stay at 95% or more everywhere.
436
+ Accepted when one of the four measures rises by 1 point or more on at least two thirds of the measured repositories,
437
+ none of them drops by more than 2 points anywhere, and element and relationship consistency by id stay at 95% or more.
390
438
  """
391
439
  from deriva.bench import gate
392
440
 
@@ -399,17 +447,27 @@ def benchmark_gate(
399
447
  typer.echo(f"Error: no benchmark session {', '.join(missing)} in {root}", err=True)
400
448
  raise typer.Exit(1)
401
449
  try:
402
- measures = {name: gate.measure([root / s for s in side], goldens_dir, reference_dir, model=model) for name, side in sides.items() if side}
450
+ measures = {
451
+ level: {name: gate.measure([root / s for s in side], goldens_dir, reference_dir, model=model, level=level) for name, side in sides.items() if side}
452
+ for level in gate.GATE_LEVELS
453
+ }
403
454
  except ValueError as exc:
404
455
  typer.echo(f"Error: {exc}", err=True)
405
456
  raise typer.Exit(1) from exc
406
457
  lines = [f"# Gate: {', '.join(sides['baseline'])}" + (f" vs {', '.join(sides['candidate'])}" if sides["candidate"] else ""), ""]
407
- lines += gate.table_markdown(measures["baseline"], f"Baseline ({', '.join(sides['baseline'])})")
408
- payload: dict = {"sessions": sides, "baseline": measures["baseline"]}
409
- if "candidate" in measures:
410
- lines += gate.table_markdown(measures["candidate"], f"Candidate ({', '.join(sides['candidate'])})")
411
- payload |= {"candidate": measures["candidate"], "verdict": gate.verdict(measures["baseline"], measures["candidate"])}
412
- lines += gate.verdict_markdown(payload["verdict"])
458
+ payload: dict = {"sessions": sides, "levels": {}}
459
+ for level, by_side in measures.items():
460
+ lines += [f"## {level.capitalize()} level", ""]
461
+ lines += gate.table_markdown(by_side["baseline"], f"Baseline ({', '.join(sides['baseline'])})")
462
+ payload["levels"][level] = {"baseline": by_side["baseline"]}
463
+ if "candidate" in by_side:
464
+ lines += gate.table_markdown(by_side["candidate"], f"Candidate ({', '.join(sides['candidate'])})")
465
+ payload["levels"][level] |= {"candidate": by_side["candidate"], "verdict": gate.verdict(by_side["baseline"], by_side["candidate"])}
466
+ lines += gate.verdict_markdown(payload["levels"][level]["verdict"])
467
+ if sides["candidate"]:
468
+ result, voting = gate.overall_verdict({level: entry["verdict"] for level, entry in payload["levels"].items()})
469
+ payload["verdict"] = {"verdict": result, "levels": voting}
470
+ lines += [f"**Overall: {result}** (levels that voted: {', '.join(voting) or 'none'})", ""]
413
471
  for line in lines:
414
472
  typer.echo(line)
415
473
  name = "_".join(sides["baseline"]) + (f"_vs_{'_'.join(sides['candidate'])}" if sides["candidate"] else "")
@@ -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: